mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 02:06:43 +00:00
test(telegram): add MCP App Funnel proof fixture (#111238)
This commit is contained in:
@@ -101,6 +101,16 @@ than Telegram-visible behavior`. Use this manifest shape and do not create
|
||||
4. Decide what Telegram message, mock model response, command, callback, button,
|
||||
media, or sequence best proves the PR. Use `MANTIS_INSTRUCTIONS` as extra
|
||||
maintainer guidance, not as a replacement for reading the PR.
|
||||
For an MCP App channel-action proof, use the trusted runner's
|
||||
`--mcp-app-fixture` option with a Tailscale-capable Crabbox provider and send
|
||||
`mcp app conformance qa check`. This starts the pinned official-SDK fixture
|
||||
and publishes the candidate Gateway through its real Funnel lifecycle. The
|
||||
native Telegram button must open the fixture showing `ready`. Click both
|
||||
`Call app tool` and `Read resource`, then capture `companion-called` and
|
||||
`resource-ok`.
|
||||
Reopen that same Telegram button after its ticket expires and capture the
|
||||
expired state. Do not substitute Control UI, transcript, curl, or a newly
|
||||
minted button for any part of that path.
|
||||
5. Create detached worktrees under
|
||||
`.artifacts/qa-e2e/mantis/telegram-desktop-proof-worktrees/baseline` and
|
||||
`.artifacts/qa-e2e/mantis/telegram-desktop-proof-worktrees/candidate`, then
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
|
||||
const appUri = "ui://conformance/app";
|
||||
const appModuleUrl =
|
||||
"https://cdn.jsdelivr.net/npm/@modelcontextprotocol/ext-apps@1.7.4/dist/src/app-with-deps.js";
|
||||
|
||||
const appHtml = `<!doctype html>
|
||||
<meta charset="utf-8" />
|
||||
<button id="call-app">Call app tool</button>
|
||||
<button id="read-resource">Read resource</button>
|
||||
<output id="initialized">pending</output>
|
||||
<output id="app-tool"></output>
|
||||
<output id="resource"></output>
|
||||
<script type="module">
|
||||
import { App } from ${JSON.stringify(appModuleUrl)};
|
||||
const write = (id, value) => { document.getElementById(id).textContent = value; };
|
||||
const app = new App({ name: "OpenClaw conformance fixture", version: "1.0.0" });
|
||||
app.onerror = (error) => console.error("mcp-conformance-app", error);
|
||||
document.getElementById("call-app").onclick = async () => {
|
||||
try {
|
||||
const value = await app.callServerTool({ name: "app_companion", arguments: {} });
|
||||
write("app-tool", JSON.stringify(value.structuredContent ?? value));
|
||||
} catch (error) { write("app-tool", "denied:" + error); }
|
||||
};
|
||||
document.getElementById("read-resource").onclick = async () => {
|
||||
try {
|
||||
const value = await app.readServerResource({ uri: "data://conformance/value" });
|
||||
write("resource", JSON.stringify(value));
|
||||
} catch (error) { write("resource", "denied:" + error); }
|
||||
};
|
||||
await app.connect();
|
||||
write("initialized", "ready");
|
||||
</script>`;
|
||||
|
||||
const server = new McpServer({ name: "mcp-app-conformance", version: "1.0.0" });
|
||||
const show = server.tool("show", "Show the conformance app", async () => ({
|
||||
content: [{ type: "text", text: "initial-result" }],
|
||||
structuredContent: { value: "initial-result" },
|
||||
}));
|
||||
show.update({ _meta: { ui: { resourceUri: appUri } } });
|
||||
|
||||
const appOnly = server.tool("app_companion", "App-only companion", async () => ({
|
||||
content: [{ type: "text", text: "companion-called" }],
|
||||
structuredContent: { value: "companion-called" },
|
||||
}));
|
||||
appOnly.update({ _meta: { ui: { visibility: ["app"] } } });
|
||||
|
||||
server.registerResource(
|
||||
"conformance_app",
|
||||
appUri,
|
||||
{ mimeType: "text/html;profile=mcp-app" },
|
||||
async (uri) => ({
|
||||
contents: [
|
||||
{
|
||||
uri: uri.href,
|
||||
mimeType: "text/html;profile=mcp-app",
|
||||
text: appHtml,
|
||||
_meta: { ui: { csp: { resourceDomains: ["https://cdn.jsdelivr.net"] } } },
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
server.registerResource(
|
||||
"conformance_data",
|
||||
"data://conformance/value",
|
||||
{ mimeType: "text/plain" },
|
||||
async (uri) => ({
|
||||
contents: [{ uri: uri.href, mimeType: "text/plain", text: "resource-ok" }],
|
||||
}),
|
||||
);
|
||||
|
||||
await server.connect(new StdioServerTransport());
|
||||
@@ -289,6 +289,23 @@ function mcpCodeModeApiFileEvents(body, bodyText) {
|
||||
);
|
||||
}
|
||||
|
||||
function mcpAppConformanceEvents(body, bodyText) {
|
||||
const allText = collectText(body).join("\n");
|
||||
if (!/mcp app conformance qa check/i.test(allText)) {
|
||||
return null;
|
||||
}
|
||||
const toolOutput = collectFunctionCallOutputText(body);
|
||||
if (!toolOutput) {
|
||||
if (!hasDeclaredTool(bodyText, "fixture__show")) {
|
||||
return null;
|
||||
}
|
||||
return toolCallEvents("fixture__show", {});
|
||||
}
|
||||
return /initial-result/.test(toolOutput)
|
||||
? responseEvents("MCP_APP_CONFORMANCE_READY")
|
||||
: responseEvents("MCP_APP_CONFORMANCE_FAIL");
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
void (async () => {
|
||||
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
||||
@@ -334,6 +351,11 @@ const server = http.createServer((req, res) => {
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/v1/responses") {
|
||||
const appEvents = mcpAppConformanceEvents(body, bodyText);
|
||||
if (appEvents) {
|
||||
writeResponsesEvents(res, body.stream, appEvents);
|
||||
return;
|
||||
}
|
||||
const codeModeEvents = mcpCodeModeApiFileEvents(body, bodyText);
|
||||
if (codeModeEvents) {
|
||||
writeResponsesEvents(res, body.stream, codeModeEvents);
|
||||
|
||||
@@ -42,6 +42,7 @@ type CrabboxInspect = {
|
||||
sshPort?: string;
|
||||
sshUser?: string;
|
||||
state?: string;
|
||||
tailscale?: unknown;
|
||||
};
|
||||
|
||||
type Options = {
|
||||
@@ -65,6 +66,7 @@ type Options = {
|
||||
idleTimeout: string;
|
||||
keepBox: boolean;
|
||||
leaseId?: string;
|
||||
mcpAppFixture: boolean;
|
||||
mockResponseText: string;
|
||||
mockPort: number;
|
||||
outputDir: string;
|
||||
@@ -92,6 +94,12 @@ type Options = {
|
||||
userDriverScript: string;
|
||||
};
|
||||
|
||||
type FunnelBridge = {
|
||||
proxyPath: string;
|
||||
tunnelLog: string;
|
||||
tunnelPid: number;
|
||||
};
|
||||
|
||||
type LocalSut = {
|
||||
configPath: string;
|
||||
drained: {
|
||||
@@ -108,6 +116,7 @@ type LocalSut = {
|
||||
workspace: string;
|
||||
gateway: ChildProcess;
|
||||
gatewayLog: string;
|
||||
funnelBridge?: FunnelBridge;
|
||||
};
|
||||
|
||||
type SessionFile = {
|
||||
@@ -138,6 +147,7 @@ type SessionFile = {
|
||||
stateDir: string;
|
||||
tempRoot: string;
|
||||
workspace: string;
|
||||
funnelBridge?: FunnelBridge;
|
||||
};
|
||||
outputDir: string;
|
||||
recorder: {
|
||||
@@ -199,6 +209,7 @@ function usageText() {
|
||||
" --id <cbx_id> Reuse an existing Crabbox desktop lease.",
|
||||
" --keep-box Leave the Crabbox lease running for VNC debugging.",
|
||||
" --mock-response-file <path> Text returned by the mock model.",
|
||||
" --mcp-app-fixture Configure the pinned MCP App fixture through a Crabbox Funnel.",
|
||||
" --output-dir <path> Artifact directory under the repo.",
|
||||
" --message-id <id> Telegram message id for proof-view deep link.",
|
||||
" --preview-crop telegram-window Create a side-by-side friendly Telegram-window GIF.",
|
||||
@@ -302,6 +313,7 @@ export function parseArgs(argvInput: string[]): Options {
|
||||
gatewayPort: 19_879,
|
||||
idleTimeout: "60m",
|
||||
keepBox: false,
|
||||
mcpAppFixture: false,
|
||||
mockResponseText: "OPENCLAW_E2E_OK",
|
||||
mockPort: 19_882,
|
||||
outputDir: path.join(DEFAULT_OUTPUT_ROOT, createTelegramProofRunId()),
|
||||
@@ -375,6 +387,8 @@ export function parseArgs(argvInput: string[]): Options {
|
||||
opts.mockPort = parseTcpPort(readValue(), "--mock-port");
|
||||
} else if (arg === "--mock-response-file") {
|
||||
opts.mockResponseText = fs.readFileSync(resolveRepoPath(process.cwd(), readValue()), "utf8");
|
||||
} else if (arg === "--mcp-app-fixture") {
|
||||
opts.mcpAppFixture = true;
|
||||
} else if (arg === "--message-id") {
|
||||
opts.messageId = String(parsePositiveInteger(readValue(), "--message-id"));
|
||||
} else if (arg === "--output-dir") {
|
||||
@@ -445,6 +459,12 @@ export function parseArgs(argvInput: string[]): Options {
|
||||
if (command === "publish" && !opts.publishPr) {
|
||||
throw new Error("publish requires --pr.");
|
||||
}
|
||||
if (opts.mcpAppFixture && command !== "start") {
|
||||
throw new Error("--mcp-app-fixture is available only for start sessions.");
|
||||
}
|
||||
if (opts.mcpAppFixture && opts.leaseId) {
|
||||
throw new Error("--mcp-app-fixture requires a fresh lifecycle-owned Crabbox lease.");
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
@@ -530,12 +550,22 @@ function mockServerEnv(params: { mockPort: number; mockResponseText: string; req
|
||||
};
|
||||
}
|
||||
|
||||
function gatewayEnv(params: { configPath: string; stateDir: string; sutToken: string }) {
|
||||
function gatewayEnv(params: {
|
||||
configPath: string;
|
||||
gatewayPassword?: string;
|
||||
stateDir: string;
|
||||
sutToken: string;
|
||||
tailscaleProxyDir?: string;
|
||||
}) {
|
||||
return {
|
||||
...childProcessBaseEnv(),
|
||||
OPENAI_API_KEY: "sk-openclaw-e2e-mock",
|
||||
OPENCLAW_CONFIG_PATH: params.configPath,
|
||||
...(params.gatewayPassword ? { OPENCLAW_GATEWAY_PASSWORD: params.gatewayPassword } : {}),
|
||||
OPENCLAW_STATE_DIR: params.stateDir,
|
||||
...(params.tailscaleProxyDir
|
||||
? { PATH: `${params.tailscaleProxyDir}${path.delimiter}${process.env.PATH ?? ""}` }
|
||||
: {}),
|
||||
TELEGRAM_BOT_TOKEN: params.sutToken,
|
||||
};
|
||||
}
|
||||
@@ -1104,11 +1134,13 @@ function telegramResultObject(value: unknown, label: string): JsonObject {
|
||||
return value as JsonObject;
|
||||
}
|
||||
|
||||
function writeSutConfig(params: {
|
||||
export function writeSutConfig(params: {
|
||||
gatewayPort: number;
|
||||
groupId: string;
|
||||
mcpAppFixture?: boolean;
|
||||
mockPort: number;
|
||||
outputDir: string;
|
||||
repoRoot?: string;
|
||||
testerId: string;
|
||||
}) {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-tg-crabbox-sut-"));
|
||||
@@ -1157,7 +1189,39 @@ function writeSutConfig(params: {
|
||||
replyToMode: "first",
|
||||
},
|
||||
},
|
||||
gateway: { auth: { mode: "none" }, bind: "loopback", mode: "local", port: params.gatewayPort },
|
||||
gateway: params.mcpAppFixture
|
||||
? {
|
||||
auth: {
|
||||
mode: "password",
|
||||
password: {
|
||||
id: "OPENCLAW_GATEWAY_PASSWORD",
|
||||
provider: "default",
|
||||
source: "env",
|
||||
},
|
||||
},
|
||||
bind: "loopback",
|
||||
mode: "local",
|
||||
port: params.gatewayPort,
|
||||
tailscale: { mode: "funnel", resetOnExit: true },
|
||||
}
|
||||
: { auth: { mode: "none" }, bind: "loopback", mode: "local", port: params.gatewayPort },
|
||||
...(params.mcpAppFixture
|
||||
? {
|
||||
mcp: {
|
||||
servers: {
|
||||
fixture: {
|
||||
args: [
|
||||
path.join(
|
||||
params.repoRoot ?? process.cwd(),
|
||||
"scripts/e2e/mcp-app-conformance-server.mjs",
|
||||
),
|
||||
],
|
||||
command: process.execPath,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
messages: { groupChat: { visibleReplies: "automatic" } },
|
||||
models: {
|
||||
providers: {
|
||||
@@ -1308,10 +1372,12 @@ export async function recordProbeVideo(params: {
|
||||
}
|
||||
|
||||
async function startLocalSutDaemon(params: {
|
||||
funnelBridge?: FunnelBridge;
|
||||
gatewayPort: number;
|
||||
groupId: string;
|
||||
mockResponseText: string;
|
||||
mockPort: number;
|
||||
mcpAppFixture?: boolean;
|
||||
outputDir: string;
|
||||
sutToken: string;
|
||||
testerId: string;
|
||||
@@ -1319,6 +1385,7 @@ async function startLocalSutDaemon(params: {
|
||||
}) {
|
||||
const drained = await drainSutUpdates(params.sutToken);
|
||||
const config = writeSutConfig(params);
|
||||
const gatewayPassword = params.mcpAppFixture ? randomUUID() : undefined;
|
||||
const requestLog = path.join(params.outputDir, "mock-openai-requests.ndjson");
|
||||
const mockLog = path.join(params.outputDir, "mock-openai.log");
|
||||
const gatewayLog = path.join(params.outputDir, "gateway.log");
|
||||
@@ -1337,7 +1404,14 @@ async function startLocalSutDaemon(params: {
|
||||
}
|
||||
await waitForLog(mockLog, /mock-openai listening/u, "mock-openai", 10_000);
|
||||
|
||||
const gatewayEnvVars = gatewayEnv({ ...config, sutToken: params.sutToken });
|
||||
const gatewayEnvVars = gatewayEnv({
|
||||
...config,
|
||||
gatewayPassword,
|
||||
sutToken: params.sutToken,
|
||||
tailscaleProxyDir: params.funnelBridge
|
||||
? path.dirname(params.funnelBridge.proxyPath)
|
||||
: undefined,
|
||||
});
|
||||
const gatewaySpec = createOpenClawGatewaySpawnSpec({
|
||||
env: gatewayEnvVars,
|
||||
gatewayPort: params.gatewayPort,
|
||||
@@ -1364,6 +1438,7 @@ async function startLocalSutDaemon(params: {
|
||||
mockLog,
|
||||
mockPid,
|
||||
requestLog,
|
||||
funnelBridge: params.funnelBridge,
|
||||
};
|
||||
} catch (error) {
|
||||
killPidTree(gatewayPid);
|
||||
@@ -1376,24 +1451,34 @@ function extractLeaseId(output: string) {
|
||||
return output.match(/\b(?:cbx_[a-f0-9]+|tbx_[A-Za-z0-9_-]+)\b/u)?.[0];
|
||||
}
|
||||
|
||||
export function createCrabboxWarmupArgs(
|
||||
opts: Pick<
|
||||
Options,
|
||||
"crabboxClass" | "idleTimeout" | "mcpAppFixture" | "provider" | "target" | "ttl"
|
||||
>,
|
||||
) {
|
||||
return [
|
||||
"warmup",
|
||||
"--provider",
|
||||
opts.provider,
|
||||
"--target",
|
||||
opts.target,
|
||||
"--desktop",
|
||||
"--browser",
|
||||
"--class",
|
||||
opts.crabboxClass,
|
||||
"--idle-timeout",
|
||||
opts.idleTimeout,
|
||||
"--ttl",
|
||||
opts.ttl,
|
||||
...(opts.mcpAppFixture ? ["--tailscale"] : []),
|
||||
];
|
||||
}
|
||||
|
||||
async function warmupCrabbox(opts: Options, root: string) {
|
||||
const result = await runCommand({
|
||||
command: opts.crabboxBin,
|
||||
args: [
|
||||
"warmup",
|
||||
"--provider",
|
||||
opts.provider,
|
||||
"--target",
|
||||
opts.target,
|
||||
"--desktop",
|
||||
"--browser",
|
||||
"--class",
|
||||
opts.crabboxClass,
|
||||
"--idle-timeout",
|
||||
opts.idleTimeout,
|
||||
"--ttl",
|
||||
opts.ttl,
|
||||
],
|
||||
args: createCrabboxWarmupArgs(opts),
|
||||
cwd: root,
|
||||
stdio: "inherit",
|
||||
});
|
||||
@@ -1613,6 +1698,93 @@ async function sshRun(
|
||||
});
|
||||
}
|
||||
|
||||
export function renderTailscaleSshProxy(params: { gatewayPort: number; inspect: CrabboxInspect }) {
|
||||
const ssh = sshArgs(params.inspect);
|
||||
return `#!/usr/bin/env node
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const port = ${JSON.stringify(String(params.gatewayPort))};
|
||||
const allowed =
|
||||
(args.length === 1 && args[0] === "--version") ||
|
||||
(args.length === 2 && args[0] === "status" && args[1] === "--json") ||
|
||||
(args.length === 4 && args[0] === "funnel" && args[1] === "--bg" && args[2] === "--yes" && args[3] === port) ||
|
||||
(args.length === 2 && args[0] === "funnel" && args[1] === "reset");
|
||||
if (!allowed) {
|
||||
process.stderr.write("unsupported proof Tailscale command\\n");
|
||||
process.exit(64);
|
||||
}
|
||||
const quote = (value) => "'" + value.replaceAll("'", "'\\\\''") + "'";
|
||||
const remoteCommand = ["tailscale", ...args].map(quote).join(" ");
|
||||
const result = spawnSync("ssh", ${JSON.stringify([...ssh.base, ssh.target])}.concat(remoteCommand), {
|
||||
stdio: "inherit",
|
||||
});
|
||||
process.exit(result.status ?? 1);
|
||||
`;
|
||||
}
|
||||
|
||||
async function startTailscaleFunnelBridge(params: {
|
||||
gatewayPort: number;
|
||||
inspect: CrabboxInspect;
|
||||
localRoot: string;
|
||||
}) {
|
||||
if (!params.inspect.tailscale) {
|
||||
throw new Error("MCP App fixture proof requires a Tailscale-enabled Crabbox lease.");
|
||||
}
|
||||
// Keep the SUT local while letting its real Gateway lifecycle own Funnel on
|
||||
// the Tailscale-enabled desktop lease; no Tailscale credential leaves Crabbox.
|
||||
const proxyPath = path.join(params.localRoot, "tailscale");
|
||||
await writeExecutable(
|
||||
proxyPath,
|
||||
renderTailscaleSshProxy({ gatewayPort: params.gatewayPort, inspect: params.inspect }),
|
||||
);
|
||||
const tunnelLog = path.join(params.localRoot, "gateway-funnel-tunnel.log");
|
||||
const ssh = sshArgs(params.inspect);
|
||||
const tunnelPid = spawnDaemon({
|
||||
args: [
|
||||
...ssh.base,
|
||||
"-o",
|
||||
"ExitOnForwardFailure=yes",
|
||||
"-N",
|
||||
"-R",
|
||||
`127.0.0.1:${params.gatewayPort}:127.0.0.1:${params.gatewayPort}`,
|
||||
ssh.target,
|
||||
],
|
||||
command: "ssh",
|
||||
cwd: params.localRoot,
|
||||
env: childProcessBaseEnv(),
|
||||
logPath: tunnelLog,
|
||||
});
|
||||
if (!tunnelPid) {
|
||||
throw new Error("Gateway Funnel reverse tunnel did not start.");
|
||||
}
|
||||
await sleep(500);
|
||||
try {
|
||||
process.kill(tunnelPid, 0);
|
||||
} catch {
|
||||
throw new Error(`Gateway Funnel reverse tunnel exited early.\n${readLogTail(tunnelLog)}`);
|
||||
}
|
||||
return { proxyPath, tunnelLog, tunnelPid };
|
||||
}
|
||||
|
||||
async function stopTailscaleFunnelBridge(
|
||||
root: string,
|
||||
bridge: Pick<FunnelBridge, "proxyPath" | "tunnelPid">,
|
||||
) {
|
||||
try {
|
||||
// Explicit reset is the backstop when Gateway shutdown loses its async
|
||||
// resetOnExit cleanup; the public route must not outlive this fresh lease.
|
||||
await runCommand({
|
||||
args: ["funnel", "reset"],
|
||||
command: bridge.proxyPath,
|
||||
cwd: root,
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
} finally {
|
||||
killPidTree(bridge.tunnelPid);
|
||||
}
|
||||
}
|
||||
|
||||
export function renderRemoteSetup(params: { tdlibSha256?: string; tdlibUrl?: string }) {
|
||||
const tdlibSha256 = shellQuote(params.tdlibSha256 ?? "");
|
||||
const tdlibUrl = shellQuote(params.tdlibUrl ?? "");
|
||||
@@ -2212,6 +2384,7 @@ async function startSession(root: string, opts: Options, outputDir: string) {
|
||||
let leaseId = opts.leaseId;
|
||||
let createdLease = false;
|
||||
let localSut: Awaited<ReturnType<typeof startLocalSutDaemon>> | undefined;
|
||||
let funnelBridge: Awaited<ReturnType<typeof startTailscaleFunnelBridge>> | undefined;
|
||||
try {
|
||||
credential = await leaseCredential({ localRoot, opts, root });
|
||||
const sut = opts.sutUsername
|
||||
@@ -2223,6 +2396,13 @@ async function startSession(root: string, opts: Options, outputDir: string) {
|
||||
createdLease = true;
|
||||
}
|
||||
const inspect = await inspectCrabbox(opts, root, leaseId);
|
||||
if (opts.mcpAppFixture) {
|
||||
funnelBridge = await startTailscaleFunnelBridge({
|
||||
gatewayPort: opts.gatewayPort,
|
||||
inspect,
|
||||
localRoot,
|
||||
});
|
||||
}
|
||||
await writeRemoteSessionScripts({
|
||||
inspect,
|
||||
localRoot,
|
||||
@@ -2232,10 +2412,12 @@ async function startSession(root: string, opts: Options, outputDir: string) {
|
||||
sutUsername: sut.username,
|
||||
});
|
||||
localSut = await startLocalSutDaemon({
|
||||
funnelBridge,
|
||||
gatewayPort: opts.gatewayPort,
|
||||
groupId: credential.groupId,
|
||||
mockResponseText: opts.mockResponseText,
|
||||
mockPort: opts.mockPort,
|
||||
mcpAppFixture: opts.mcpAppFixture,
|
||||
outputDir,
|
||||
repoRoot: root,
|
||||
sutToken: credential.sutToken,
|
||||
@@ -2288,6 +2470,9 @@ async function startSession(root: string, opts: Options, outputDir: string) {
|
||||
} catch (error) {
|
||||
killPidTree(localSut?.gatewayPid);
|
||||
killPidTree(localSut?.mockPid);
|
||||
if (funnelBridge) {
|
||||
await stopTailscaleFunnelBridge(root, funnelBridge).catch(() => {});
|
||||
}
|
||||
if (credential) {
|
||||
await releaseCredential(root, opts, credential.leaseFile).catch(() => {});
|
||||
}
|
||||
@@ -2537,6 +2722,13 @@ async function finishSession(root: string, opts: Options, outputDir: string) {
|
||||
} finally {
|
||||
killPidTree(session.localSut.gatewayPid);
|
||||
killPidTree(session.localSut.mockPid);
|
||||
if (session.localSut.funnelBridge) {
|
||||
await stopTailscaleFunnelBridge(root, session.localSut.funnelBridge).catch(
|
||||
(error: unknown) => {
|
||||
summary.funnelResetError = error instanceof Error ? error.message : String(error);
|
||||
},
|
||||
);
|
||||
}
|
||||
await terminateDesktopSession();
|
||||
await releaseCredential(root, opts, session.credential.leaseFile).catch((error: unknown) => {
|
||||
summary.credentialReleaseError = error instanceof Error ? error.message : String(error);
|
||||
|
||||
@@ -158,6 +158,40 @@ describe("mock OpenAI response markers", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("drives the MCP App fixture tool before returning the visible marker", async () => {
|
||||
await withMockServer(mockOpenAiPath, {}, async (baseUrl) => {
|
||||
const first = await fetch(`${baseUrl}/v1/responses`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
input: [{ content: "mcp app conformance qa check", role: "user" }],
|
||||
stream: false,
|
||||
tools: [{ name: "fixture__show", parameters: { type: "object" }, type: "function" }],
|
||||
}),
|
||||
});
|
||||
const firstBody = await first.json();
|
||||
expect(firstBody.output?.[0]).toMatchObject({
|
||||
arguments: "{}",
|
||||
name: "fixture__show",
|
||||
type: "function_call",
|
||||
});
|
||||
|
||||
const second = await fetch(`${baseUrl}/v1/responses`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
input: [
|
||||
{ content: "mcp app conformance qa check", role: "user" },
|
||||
{ output: "initial-result", type: "function_call_output" },
|
||||
],
|
||||
stream: false,
|
||||
}),
|
||||
});
|
||||
const secondBody = await second.json();
|
||||
expect(secondBody.output?.[0]?.content?.[0]?.text).toBe("MCP_APP_CONFORMANCE_READY");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("e2e mock and config helper numeric limits", () => {
|
||||
|
||||
@@ -333,6 +333,10 @@ describe("Mantis Telegram Desktop proof workflow", () => {
|
||||
expect(prompt).toContain("$OPENCLAW_TELEGRAM_USER_PROOF_CMD");
|
||||
expect(prompt).toContain("do not run\n `pnpm qa:telegram-user:crabbox` directly");
|
||||
expect(prompt).toContain("Let `start` return or fail on its\n own");
|
||||
expect(prompt).toContain("`--mcp-app-fixture` option");
|
||||
expect(prompt).toContain("mcp app conformance qa check");
|
||||
expect(prompt).toContain("`companion-called` and\n `resource-ok`");
|
||||
expect(prompt).toContain("Reopen that same Telegram button after its ticket expires");
|
||||
expect(prompt).toContain(
|
||||
"Use a long\n command timeout for `start`, `send`, `view`, and `finish`",
|
||||
);
|
||||
|
||||
@@ -5,10 +5,13 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
||||
import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
COMMAND_TIMEOUT_MS,
|
||||
createCrabboxWarmupArgs,
|
||||
createOpenClawGatewaySpawnSpec,
|
||||
parseArgs,
|
||||
readLogTail,
|
||||
@@ -19,11 +22,13 @@ import {
|
||||
renderRemoteProbe,
|
||||
renderRemoteSetup,
|
||||
renderSelectDesktopChat,
|
||||
renderTailscaleSshProxy,
|
||||
runCommand,
|
||||
signalCommandTree,
|
||||
stageFullSessionArtifacts,
|
||||
startLocalSut,
|
||||
waitForLog,
|
||||
writeSutConfig,
|
||||
} from "../../scripts/e2e/telegram-user-crabbox-proof.ts";
|
||||
import { resolveWindowsTaskkillPath } from "../../scripts/lib/windows-taskkill.mjs";
|
||||
|
||||
@@ -192,6 +197,136 @@ describe("telegram user Crabbox proof log polling", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("enables the pinned MCP App fixture only when explicitly requested", () => {
|
||||
expect(parseArgs(["start", "--mcp-app-fixture"]).mcpAppFixture).toBe(true);
|
||||
expect(parseArgs([]).mcpAppFixture).toBe(false);
|
||||
|
||||
const ordinaryArgs = createCrabboxWarmupArgs(parseArgs([]));
|
||||
const fixtureArgs = createCrabboxWarmupArgs(parseArgs(["start", "--mcp-app-fixture"]));
|
||||
expect(ordinaryArgs).not.toContain("--tailscale");
|
||||
expect(fixtureArgs).toContain("--tailscale");
|
||||
expect(() => parseArgs(["--mcp-app-fixture"])).toThrow(
|
||||
"--mcp-app-fixture is available only for start sessions.",
|
||||
);
|
||||
expect(() => parseArgs(["start", "--mcp-app-fixture", "--id", "cbx_reused"])).toThrow(
|
||||
"--mcp-app-fixture requires a fresh lifecycle-owned Crabbox lease.",
|
||||
);
|
||||
});
|
||||
|
||||
it("writes an isolated Funnel and official MCP App fixture config", () => {
|
||||
const configRoot = writeSutConfig({
|
||||
gatewayPort: 19042,
|
||||
groupId: "group",
|
||||
mcpAppFixture: true,
|
||||
mockPort: 19043,
|
||||
outputDir: makeTempDir(),
|
||||
repoRoot: "/repo",
|
||||
testerId: "tester",
|
||||
});
|
||||
tempDirs.push(configRoot.tempRoot);
|
||||
const config = JSON.parse(fs.readFileSync(configRoot.configPath, "utf8"));
|
||||
|
||||
expect(config.gateway).toMatchObject({
|
||||
auth: {
|
||||
mode: "password",
|
||||
password: { id: "OPENCLAW_GATEWAY_PASSWORD", source: "env" },
|
||||
},
|
||||
tailscale: { mode: "funnel", resetOnExit: true },
|
||||
});
|
||||
expect(config.mcp.servers.fixture).toEqual({
|
||||
args: ["/repo/scripts/e2e/mcp-app-conformance-server.mjs"],
|
||||
command: process.execPath,
|
||||
});
|
||||
expect(JSON.stringify(config)).not.toContain("companion-called");
|
||||
expect(JSON.stringify(config)).not.toContain("resource-ok");
|
||||
});
|
||||
|
||||
it("pins the browser fixture SDK and exposes only the required app capabilities", () => {
|
||||
const fixture = fs.readFileSync("scripts/e2e/mcp-app-conformance-server.mjs", "utf8");
|
||||
const uiPackage = JSON.parse(fs.readFileSync("ui/package.json", "utf8"));
|
||||
|
||||
expect(uiPackage.dependencies["@modelcontextprotocol/ext-apps"]).toBe("1.7.4");
|
||||
expect(fixture).toContain(
|
||||
`@modelcontextprotocol/ext-apps@${uiPackage.dependencies["@modelcontextprotocol/ext-apps"]}`,
|
||||
);
|
||||
expect(fixture).toContain('server.tool("app_companion"');
|
||||
expect(fixture).toContain('visibility: ["app"]');
|
||||
expect(fixture).toContain('"data://conformance/value"');
|
||||
expect(fixture).toContain('text: "resource-ok"');
|
||||
});
|
||||
|
||||
it("serves the fixture view, app-only tool, and resource over official MCP stdio", async () => {
|
||||
const transport = new StdioClientTransport({
|
||||
args: ["scripts/e2e/mcp-app-conformance-server.mjs"],
|
||||
command: process.execPath,
|
||||
cwd: process.cwd(),
|
||||
stderr: "pipe",
|
||||
});
|
||||
const client = new Client({ name: "telegram-proof-fixture-test", version: "1.0.0" });
|
||||
try {
|
||||
await client.connect(transport);
|
||||
const tools = await client.listTools();
|
||||
expect(tools.tools.find((tool) => tool.name === "show")?._meta).toMatchObject({
|
||||
ui: { resourceUri: "ui://conformance/app" },
|
||||
});
|
||||
expect(tools.tools.find((tool) => tool.name === "app_companion")?._meta).toMatchObject({
|
||||
ui: { visibility: ["app"] },
|
||||
});
|
||||
expect(await client.callTool({ arguments: {}, name: "app_companion" })).toMatchObject({
|
||||
structuredContent: { value: "companion-called" },
|
||||
});
|
||||
expect(await client.readResource({ uri: "data://conformance/value" })).toMatchObject({
|
||||
contents: [{ text: "resource-ok" }],
|
||||
});
|
||||
} finally {
|
||||
await Promise.allSettled([client.close(), transport.close()]);
|
||||
}
|
||||
});
|
||||
|
||||
posixIt("limits the Funnel bridge proxy to the Gateway lifecycle commands", () => {
|
||||
const root = makeTempDir();
|
||||
const sshPath = path.join(root, "ssh");
|
||||
const argvPath = path.join(root, "ssh-argv.json");
|
||||
const proxyPath = path.join(root, "tailscale");
|
||||
writeExecutable(
|
||||
sshPath,
|
||||
`#!/usr/bin/env node\nimport fs from "node:fs";\nfs.writeFileSync(process.env.ARGV_PATH, JSON.stringify(process.argv.slice(2)));\n`,
|
||||
);
|
||||
writeExecutable(
|
||||
proxyPath,
|
||||
renderTailscaleSshProxy({
|
||||
gatewayPort: 19042,
|
||||
inspect: {
|
||||
host: "proof.example",
|
||||
sshKey: "/tmp/proof-key",
|
||||
sshPort: "2222",
|
||||
sshUser: "proof",
|
||||
},
|
||||
}),
|
||||
);
|
||||
const env = {
|
||||
...process.env,
|
||||
ARGV_PATH: argvPath,
|
||||
PATH: `${root}${path.delimiter}${process.env.PATH ?? ""}`,
|
||||
};
|
||||
|
||||
const allowed = spawnSync(proxyPath, ["funnel", "--bg", "--yes", "19042"], {
|
||||
encoding: "utf8",
|
||||
env,
|
||||
});
|
||||
expect(allowed.status).toBe(0);
|
||||
expect(JSON.parse(fs.readFileSync(argvPath, "utf8"))).toContain(
|
||||
"'tailscale' 'funnel' '--bg' '--yes' '19042'",
|
||||
);
|
||||
|
||||
const rejected = spawnSync(proxyPath, ["serve", "--bg", "19042"], {
|
||||
encoding: "utf8",
|
||||
env,
|
||||
});
|
||||
expect(rejected.status).toBe(64);
|
||||
expect(rejected.stderr).toContain("unsupported proof Tailscale command");
|
||||
});
|
||||
|
||||
it("reads only the requested log tail", () => {
|
||||
const logPath = path.join(makeTempDir(), "gateway.log");
|
||||
fs.writeFileSync(logPath, `${"old\n".repeat(2000)}ready\n`, "utf8");
|
||||
|
||||
Reference in New Issue
Block a user