diff --git a/agent-node/src/cli.ts b/agent-node/src/cli.ts index c39bffa71..172a5dfc6 100644 --- a/agent-node/src/cli.ts +++ b/agent-node/src/cli.ts @@ -3590,8 +3590,10 @@ async function ensureGrokCopresenceRuntime(): Promise { const { prepareGrokCliHome, assertNoDiscoveredGrokHooks, + assertGrokCommhubMcpDoctor, grokCliStateKey, grokProjectPolicyPaths, + resolveGrokCommhubMcpCommand, } = await import("./runtime/grok-build-cli-home"); const { assertGrokCopresenceFeatures, @@ -3623,6 +3625,10 @@ async function ensureGrokCopresenceRuntime(): Promise { if (leaderSocket === attachSocket) { throw new Error("grok leader and attach sockets must use different paths"); } + const commhubMcpCommand = resolveGrokCommhubMcpCommand( + process.env.BUN_BIN || "bun", + process.env.PATH || "", + ); const prepareRuntime = () => { const grokCliHome = prepareGrokCliHome({ @@ -3632,6 +3638,7 @@ async function ensureGrokCopresenceRuntime(): Promise { projectCwd: grokCwd, useLeader: true, commhubMcp: { + command: commhubMcpCommand, serverPath: commhubMcpServer, envFile: commhubMcpEnv, alias: currentAlias(), @@ -3725,6 +3732,22 @@ async function ensureGrokCopresenceRuntime(): Promise { } assertNoDiscoveredGrokHooks(inspection); assertGrokCopresenceApprovalOwnership(inspection, auditRuntime.grokCliHome.home); + let doctor: string; + try { + doctor = execFileSync(grokBinary, ["mcp", "doctor", "commhub", "--json"], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + timeout: 10_000, + maxBuffer: 2 * 1024 * 1024, + cwd: grokCwd, + env: auditRuntime.env, + }); + } catch (error: any) { + throw new Error( + `grok copresence CommHub MCP readiness preflight failed (${error?.code || error?.status || "doctor error"})`, + ); + } + assertGrokCommhubMcpDoctor(doctor); // Clear runtime-owned executable state once more after the inspector. return prepareRuntime(); }; diff --git a/agent-node/src/runtime/grok-build-cli-home.test.ts b/agent-node/src/runtime/grok-build-cli-home.test.ts index bc05b35d6..70eac769d 100644 --- a/agent-node/src/runtime/grok-build-cli-home.test.ts +++ b/agent-node/src/runtime/grok-build-cli-home.test.ts @@ -8,6 +8,7 @@ import { mkdirSync, readdirSync, readFileSync, + realpathSync, rmSync, statSync, symlinkSync, @@ -18,12 +19,14 @@ import { homedir } from "os"; import { basename, dirname, join, resolve } from "path"; import { acquireGrokProjectTurnLock, + assertGrokCommhubMcpDoctor, assertNoDiscoveredGrokHooks, cleanupGrokCliPostStopState, cleanupGrokCliStoppedTuiGeneration, grokCliStateKey, GROK_POST_STOP_CLEANUP_POLICY, prepareGrokCliHome, + resolveGrokCommhubMcpCommand, } from "./grok-build-cli-home"; import { assertGrokCopresenceAgentProfile } from "./grok-copresence/policy"; @@ -35,6 +38,53 @@ afterEach(() => { }); describe("prepareGrokCliHome", () => { + it("resolves the CommHub MCP command to one canonical executable", () => { + const root = mkdtempSync(join(tmpdir(), "grok-cli-mcp-command-")); + roots.push(root); + const bin = join(root, "bin"); + mkdirSync(bin); + const bun = join(bin, "bun"); + writeFileSync(bun, "#!/bin/sh\nexit 0\n", { mode: 0o700 }); + + expect(resolveGrokCommhubMcpCommand("bun", bin)).toBe(realpathSync(bun)); + expect(resolveGrokCommhubMcpCommand(bun, "/missing")).toBe(realpathSync(bun)); + chmodSync(bun, 0o600); + expect(() => resolveGrokCommhubMcpCommand("bun", bin)) + .toThrow("could not be resolved or executed"); + expect(() => resolveGrokCommhubMcpCommand("bun", "/missing")) + .toThrow("could not be resolved or executed"); + }); + + it("requires a real CommHub MCP doctor handshake and all three tools", () => { + const healthy = JSON.stringify({ + healthy_count: 1, + failing_count: 0, + servers: [{ + name: "commhub", + healthy: true, + checks: [ + { label: "command found", passed: true }, + { label: "server started", passed: true }, + { label: "handshake OK", passed: true }, + { label: "4 tools discovered", passed: true }, + ], + }], + }); + expect(() => assertGrokCommhubMcpDoctor(healthy)).not.toThrow(); + const missingCommand = JSON.parse(healthy); + missingCommand.healthy_count = 0; + missingCommand.failing_count = 1; + missingCommand.servers[0].healthy = false; + missingCommand.servers[0].checks[0].passed = false; + expect(() => assertGrokCommhubMcpDoctor(JSON.stringify(missingCommand))) + .toThrow("command found"); + const missingTools = JSON.parse(healthy); + missingTools.servers[0].checks.pop(); + expect(() => assertGrokCommhubMcpDoctor(JSON.stringify(missingTools))) + .toThrow("4 tools discovered"); + expect(() => assertGrokCommhubMcpDoctor("not-json")).toThrow("invalid JSON"); + }); + it("derives an opaque path segment and rejects dot identities", () => { expect(grokCliStateKey("n_grokcli215")).toMatch(/^node-[a-f0-9]{24}$/); expect(grokCliStateKey("node/../../escape")).toMatch(/^node-[a-f0-9]{24}$/); @@ -780,6 +830,7 @@ describe("prepareGrokCliHome", () => { writeFileSync(commhubServer, "// reviewed commhub MCP server\n", { mode: 0o600 }); writeFileSync(commhubEnv, "COMMHUB_TOKEN=ntok_test\n", { mode: 0o600 }); const commhubMcp = { + command: realpathSync(process.execPath), serverPath: commhubServer, envFile: commhubEnv, alias: "指挥狗", @@ -808,7 +859,7 @@ describe("prepareGrokCliHome", () => { expect(config).toContain('[ui]\ndefault_selected_permission = "always_allow_all_sessions"'); expect(config).toContain("remember_tool_approvals = true"); expect(config).toContain("[mcp_servers.commhub]"); - expect(config).toContain('command = "bun"'); + expect(config).toContain(`command = ${JSON.stringify(realpathSync(process.execPath))}`); const stagedServer = join(stateHome, "runtime-mcp", "node-server.js"); const stagedEnv = join(dirname(dirname(stateHome)), ".anet-grok-credentials", basename(stateHome), ".env"); expect(config).toContain(`args = [${JSON.stringify(stagedServer)}]`); @@ -938,7 +989,13 @@ describe("prepareGrokCliHome", () => { projectCwd: project, useLeader: true, }; - const valid = { serverPath, envFile, alias: "node-a", resumeId: "grok-cli-n_a" }; + const valid = { + command: realpathSync(process.execPath), + serverPath, + envFile, + alias: "node-a", + resumeId: "grok-cli-n_a", + }; expect(() => prepareGrokCliHome({ ...base, commhubMcp: valid })).not.toThrow(); const config = readFileSync(join(stateHome, "config.toml"), "utf8"); diff --git a/agent-node/src/runtime/grok-build-cli-home.ts b/agent-node/src/runtime/grok-build-cli-home.ts index b6b80db46..aa8114b58 100644 --- a/agent-node/src/runtime/grok-build-cli-home.ts +++ b/agent-node/src/runtime/grok-build-cli-home.ts @@ -1,6 +1,7 @@ import { createHash, randomBytes } from "crypto"; import { spawn } from "child_process"; import { + accessSync, chmodSync, closeSync, constants, @@ -22,7 +23,7 @@ import { unlinkSync, writeFileSync, } from "fs"; -import { basename, dirname, isAbsolute, join, parse, relative, resolve } from "path"; +import { basename, delimiter, dirname, isAbsolute, join, parse, relative, resolve } from "path"; import { homedir } from "os"; import { buildGrokHelperEnv } from "./grok-child-env"; import { @@ -43,12 +44,65 @@ export interface PrepareGrokCliHomeOptions { } export interface GrokCommhubMcpConfig { + /** Absolute, preflighted Bun executable used by the runtime-owned MCP. */ + command: string; serverPath: string; envFile: string; alias: string; resumeId: string; } +/** + * Resolve the runtime-owned CommHub MCP executable before the TUI can become + * ready. A bare `command = "bun"` makes recovery depend on whichever PATH + * happened to launch tmux, producing a false-healthy inbound-only node. + */ +export function resolveGrokCommhubMcpCommand(command: string, pathEnv: string): string { + if (!command || command.includes("\0") || command.includes("\r") || command.includes("\n")) { + throw new Error("grok copresence CommHub MCP command is invalid"); + } + const candidates = isAbsolute(command) + ? [command] + : pathEnv.split(delimiter).filter(Boolean).map((directory) => resolve(directory, command)); + for (const candidate of candidates) { + try { + const canonical = realpathSync(candidate); + if (!statSync(canonical).isFile()) continue; + accessSync(canonical, constants.X_OK); + return canonical; + } catch {} + } + throw new Error("grok copresence CommHub MCP command could not be resolved or executed"); +} + +/** Fail closed unless Grok actually starts and handshakes the one MCP server. */ +export function assertGrokCommhubMcpDoctor(doctorJson: string): void { + let report: any; + try { + report = JSON.parse(doctorJson); + } catch { + throw new Error("grok copresence CommHub MCP doctor returned invalid JSON"); + } + const server = Array.isArray(report?.servers) + ? report.servers.find((candidate: any) => candidate?.name === "commhub") + : undefined; + const requiredChecks = ["command found", "server started", "handshake OK", "4 tools discovered"]; + const checks = Array.isArray(server?.checks) ? server.checks : []; + const missing = requiredChecks.filter((label) => !checks.some( + (check: any) => check?.label === label && check?.passed === true, + )); + if ( + report?.healthy_count !== 1 + || report?.failing_count !== 0 + || server?.healthy !== true + || missing.length > 0 + ) { + throw new Error( + `grok copresence CommHub MCP readiness failed${missing.length ? `: ${missing.join(", ")}` : ""}`, + ); + } +} + export interface GrokCliHome { home: string; authPath: string; @@ -996,16 +1050,24 @@ function validateCommhubMcpConfig( projectCwd: string, ): GrokCommhubMcpConfig { const projectAnet = join(projectCwd, ".anet"); + const command = resolve(config.command); const serverPath = resolve(config.serverPath); const envFile = resolve(config.envFile); if ( - config.serverPath !== serverPath + config.command !== command + || config.serverPath !== serverPath || config.envFile !== envFile || serverPath !== join(projectAnet, "node-server.js") || envFile !== join(projectAnet, ".env") ) { throw new Error("grok-build-cli commhub MCP paths must be the canonical project .anet artifacts"); } + try { + if (!statSync(command).isFile() || realpathSync(command) !== command) throw new Error("not canonical"); + accessSync(command, constants.X_OK); + } catch { + throw new Error("grok-build-cli commhub MCP command is not a canonical executable file"); + } const uid = process.getuid?.(); for (const [path, label, expectedMode] of [ [serverPath, "server", undefined], @@ -1028,7 +1090,7 @@ function validateCommhubMcpConfig( throw new Error(`grok-build-cli commhub MCP ${label} is invalid`); } } - return { serverPath, envFile, alias: config.alias, resumeId: config.resumeId }; + return { command, serverPath, envFile, alias: config.alias, resumeId: config.resumeId }; } /** @@ -1388,7 +1450,7 @@ export function prepareGrokCliHome(opts: PrepareGrokCliHomeOptions): GrokCliHome "", ...(commhubMcp ? [ "[mcp_servers.commhub]", - 'command = "bun"', + `command = ${JSON.stringify(commhubMcp.command)}`, `args = [${JSON.stringify(commhubMcp.serverPath)}]`, `env = { ANET_COMMHUB_ENV_FILE = ${JSON.stringify(commhubMcp.envFile)}, ANET_COMMHUB_MODE = "outbound-only", COMMHUB_ALIAS = ${JSON.stringify(commhubMcp.alias)}, COMMHUB_RESUME_ID = ${JSON.stringify(commhubMcp.resumeId)} }`, "enabled = true", diff --git a/docs/tests/report-test813-grok-mcp-readiness.txt b/docs/tests/report-test813-grok-mcp-readiness.txt new file mode 100644 index 000000000..ac446ae9e --- /dev/null +++ b/docs/tests/report-test813-grok-mcp-readiness.txt @@ -0,0 +1,131 @@ +# test813 — Grok copresence CommHub MCP readiness and product startup + +Date: 2026-08-13 +Base: 034f00647d42d38d5086d7fc057eb7824a441791 +Source commit: 8186b79de8e2f904c28bec268d93a523503a6845 +Source tree: 319a3e0b2383ffebe6cf2ffb6d445d4dca454206 +Readiness image: anet-test813-readiness:8186b79d +Readiness image ID: sha256:d326ad02629eb5264ab0c7b87687f4785249b3f92008c1818ea98084a4a89042 +Unit image: anet-test813-unit:8186b79d +Unit image ID: sha256:f1ab2ac603adf9d12ca619a386d5797b3cafb3ba063b4da08d5e663478be29e0 + +## Result + +PASS. Both images were built from `git archive` of the exact source commit. +Nine source/fixture files present in the readiness image were copied back out +and compared with the corresponding Git blobs: `9/9 MATCH`. + +The readiness image emitted: + + MCP_READINESS_PASS tools=commhub_get_all_status,commhub_send_message,commhub_send_task,commhub_upload_file + MUTATION_RED upload-tool-removed + MUTATION_RED stale-three-tool-doctor + PRODUCT_PATH_NEGATIVE_PASS registration=absent session=unchanged + PRODUCT_PATH_RECOVERY_PASS session=preserved + MUTATION_RED doctor-three-tools-product-path-before-tui + MUTATION_RED bun-resolver-bypassed + PRODUCT_PATH_NEGATIVE_PASS registration=absent session=unchanged + PRODUCT_PATH_RECOVERY_PASS session=preserved + RESULT: PASS source_commit=8186b79de8e2f904c28bec268d93a523503a6845 + +The full exact-source agent-node domain emitted: + + 1283 pass + 0 fail + 4373 expect() calls + Ran 1283 tests across 91 files. + MUTATION_RED readable-attachment-runtime-disconnected rc=1 + RESULT: PASS + +## What the readiness gate executes + +The first layer starts production `agent-network/src/node-server.ts` over MCP +stdio, performs `initialize`, `notifications/initialized`, and `tools/list`, +and requires the exact four-tool outbound set. The production +`assertGrokCommhubMcpDoctor` parser must accept exactly the four named health +checks and reject the old three-tool shape. + +The product-path layer builds and runs the real production +`agent-node/dist/cli.js` as non-root with the reviewed test225 Grok 0.2.93-shaped +PTY fixture. The fixture is a process boundary, not a dependency-injection +replacement: production CLI code resolves Bun, generates and stages the MCP, +runs the real MCP subprocess handshake, opens the PTY/Leader, writes the +session, and reaches TUI readiness. + +The negative lane launches Node by absolute path while giving the agent a slim +PATH and an invalid explicit `BUN_BIN`. It requires the production runtime gate +to reject before the timestamped registration log, and proves the config +checksum is unchanged. The recovery lane uses canonical `/usr/local/bin/bun`, +requires a timestamped TUI-ready runtime event, then starts the same product +entry again and requires the persisted Grok session UUID to remain identical. + +Both lanes use an unreachable loopback Hub so registration cannot accidentally +be mistaken for success. The reviewed fake reproduces native Grok's temporary +0444 project placeholders; because the probe stops at that unreachable Hub +boundary rather than through the normal launcher, it validates the exact tuple +before performing the launcher's cleanup between recovery generations. + +## Witnessed-red mutations + +1. `upload-tool-removed` deletes `commhub_upload_file` from the production + outbound allowlist. The MCP wire result fails at `TOOL_SET_MISMATCH`. +2. `stale-three-tool-doctor` changes the production doctor requirement back to + `3 tools discovered`. The exact four-tool response fails at the named + readiness assertion. +3. `doctor-three-tools-product-path-before-tui` changes the process-boundary + Grok doctor's healthy output from four tools to three. The production CLI + fails its pre-spawn audit, and the product-path gate goes red specifically + because no anchored timestamped TUI-ready runtime event exists. +4. `bun-resolver-bypassed` replaces the one product call-site input + `process.env.BUN_BIN || "bun"` with `/usr/local/bin/bun`. The slim-PATH lane + then gets past the intended resolver; the gate fails specifically at + `NEGATIVE_RUNTIME_GATE_NOT_REACHED`. + +The unmodified readiness and product probes run before and after the mutations. +Every production mutation changes bytes in the disposable container, has a +target-cardinality/post-change guard, and restores the exact original file or +fixture. + +## Correction made while establishing this gate + +The superseded report at source `91cf0206` relied on an unanchored substring +search for `[grok-copresence] TUI ready session=`. A reverse experiment changed +the fake doctor to report three tools. Production correctly failed before TUI +spawn, but the thrown stack printed the minified `dist/cli.js` source line, +which itself contained that literal. The old gate therefore accepted source +text in a stack trace as a runtime event and only failed later at an unrelated +version assertion. + +This source replaces those substring checks with exact timestamped runtime-log +lines and adds the process-boundary doctor mutation above. The old PR/report is +not evidence for this source. + +## Provenance + + readiness build log sha256 7e87381dcd4719a7978633fb34d7e1a65f4dda3fcaf933a6c35524904cb1811a + readiness run log sha256 a96738754762545bca99fd991534baf94c25679f75abdd09daf730e32ba990b9 + unit build log sha256 9d0d74390d57daccbf2af3a5c590d901a190596990eb15db88dc452e4c432841 + unit run log sha256 38c37ae069e8a54739f0fbd6c7a3f20d20f1b67a8cf28dc023cdf9ac6e6a699a + +The log digests record this run; they are not claimed byte-reproducible because +Docker progress/timing and test timing vary. The source/tree/image coordinates +and the `9/9 MATCH` comparison are the reproducible anchors. + +## Honest limits + +- The deterministic product fixture exercises the production CLI, PTY, + Leader, MCP child and session writeback, but it does not call a model or a + remote Hub. +- The earlier real Grok 0.2.93 keyless vendor-doctor evidence belonged to + source `ce8184a5`; it motivated and validated the parser shape, but is not + claimed as evidence for this source because that old binary is no longer + present on the host. The current installed Grok is 1.0.3 and was not + substituted for the pinned compatibility claim. +- Upload bytes, authenticated Hub lifecycle, continuous human attach, and a + real model turn remain in their own suites or the eventual single-node + pilot. +- The three other bare-Bun writers are tracked separately in issue #821. A + 12-process live sample currently resolves Bun through inherited nvm PATH; + they are latent slim-PATH risks, not a blocker or a current fleet outage. +- No package was published and no production process, config, database or node + was changed by this test. diff --git a/tests/test225-grok-preview-package-live/fake-grok.mjs b/tests/test225-grok-preview-package-live/fake-grok.mjs index 5351995ef..9441f643d 100755 --- a/tests/test225-grok-preview-package-live/fake-grok.mjs +++ b/tests/test225-grok-preview-package-live/fake-grok.mjs @@ -116,6 +116,121 @@ if (argv[0] === "inspect" && argv.includes("--json")) { process.exit(0); } +function parseTomlJsonValue(content, key) { + const line = content.split("\n").find((candidate) => candidate.startsWith(`${key} = `)); + if (!line) throw new Error(`missing ${key}`); + return JSON.parse(line.slice(key.length + 3)); +} + +function parseTomlEnv(content) { + const line = content.split("\n").find((candidate) => candidate.startsWith("env = { ")); + if (!line?.endsWith(" }")) throw new Error("missing env"); + const result = {}; + for (const pair of line.slice(8, -2).split(", ")) { + const at = pair.indexOf(" = "); + if (at < 1) throw new Error("invalid env"); + result[pair.slice(0, at)] = JSON.parse(pair.slice(at + 3)); + } + return result; +} + +async function mcpRequest(child, payload) { + return await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`MCP timeout for ${payload.method}`)), 5_000); + let buffer = ""; + const onData = (chunk) => { + buffer += chunk.toString("utf8"); + for (;;) { + const newline = buffer.indexOf("\n"); + if (newline < 0) break; + const line = buffer.slice(0, newline); + buffer = buffer.slice(newline + 1); + try { + const message = JSON.parse(line); + if (message.id !== payload.id) continue; + clearTimeout(timer); + child.stdout.off("data", onData); + if (message.error) reject(new Error(String(message.error.message || message.error.code))); + else resolve(message.result); + return; + } catch {} + } + }; + child.stdout.on("data", onData); + child.stdin.write(`${JSON.stringify(payload)}\n`); + }); +} + +if (argv[0] === "mcp" && argv[1] === "doctor" && argv[2] === "commhub" && argv.includes("--json")) { + const configPath = path.join(process.env.GROK_HOME || process.env.HOME || "", "config.toml"); + let child; + try { + const content = fs.readFileSync(configPath, "utf8"); + const command = parseTomlJsonValue(content, "command"); + const args = parseTomlJsonValue(content, "args"); + const mcpEnv = parseTomlEnv(content); + const canonicalCommand = fs.realpathSync(command); + if (!path.isAbsolute(command) || canonicalCommand !== command) throw new Error("command not canonical"); + fs.accessSync(command, fs.constants.X_OK); + child = spawn(command, args, { + cwd: process.cwd(), + env: { ...process.env, ...mcpEnv }, + stdio: ["pipe", "pipe", "pipe"], + }); + const initialized = await mcpRequest(child, { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "test225-fake-grok-doctor", version: "1.0.0" }, + }, + }); + child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" })}\n`); + const listed = await mcpRequest(child, { + jsonrpc: "2.0", id: 2, method: "tools/list", params: {}, + }); + const toolNames = (listed?.tools || []).map((tool) => tool?.name).sort(); + const expectedTools = [ + "commhub_get_all_status", + "commhub_send_message", + "commhub_send_task", + "commhub_upload_file", + ]; + if (JSON.stringify(toolNames) !== JSON.stringify(expectedTools)) { + throw new Error(`unexpected tools: ${toolNames.join(",")}`); + } + recordEnvironment("mcp-doctor", { + commandAbsolute: path.isAbsolute(command), + commandCanonical: canonicalCommand === command, + toolNames, + }); + process.stdout.write(JSON.stringify({ + servers: [{ + name: "commhub", + transport: "stdio", + healthy: true, + checks: [ + { label: "command found", passed: true }, + { label: "server started", passed: true }, + { label: "handshake OK", passed: Boolean(initialized?.serverInfo) }, + { label: "4 tools discovered", passed: toolNames.length === 4 }, + ], + }], + healthy_count: 1, + failing_count: 0, + }) + "\n"); + child.stdin.end(); + child.kill("SIGTERM"); + process.exit(0); + } catch (error) { + try { child?.kill("SIGTERM"); } catch {} + process.stderr.write(`fake grok: MCP doctor failed: ${error?.message || error}\n`); + process.exit(71); + } +} + function valueAfter(flag) { const index = argv.indexOf(flag); return index >= 0 ? argv[index + 1] : ""; diff --git a/tests/test225-grok-preview-package-live/run.sh b/tests/test225-grok-preview-package-live/run.sh index 566599e05..573e15296 100755 --- a/tests/test225-grok-preview-package-live/run.sh +++ b/tests/test225-grok-preview-package-live/run.sh @@ -2140,9 +2140,9 @@ run_keyless_gate() { and .servers[0].name == "commhub" and .servers[0].transport == "stdio" and .servers[0].healthy == true - and any(.servers[0].checks[]; .label == "3 tools discovered" and .passed == true) + and any(.servers[0].checks[]; .label == "4 tools discovered" and .passed == true) ' "$mcp_doctor" >/dev/null \ - || fail "runtime-owned commhub MCP doctor did not prove the exact three-tool outbound-only server" + || fail "runtime-owned commhub MCP doctor did not prove the exact four-tool outbound-only server" scan_fixed_file /tmp/test225-markers "$mcp_doctor" \ || fail "commhub MCP doctor leaked a synthetic credential marker" rm -f -- "$mcp_doctor" diff --git a/tests/test813-grok-mcp-readiness/Dockerfile b/tests/test813-grok-mcp-readiness/Dockerfile new file mode 100644 index 000000000..732e7f611 --- /dev/null +++ b/tests/test813-grok-mcp-readiness/Dockerfile @@ -0,0 +1,42 @@ +FROM node:22-bookworm-slim@sha256:d649c27dae7ba0137b3cef5dd75baa422c08dc3d9e3fc0c23dfb172dc3cc6436 + +ARG SOURCE_COMMIT +ARG BUN_VERSION=1.3.14 +ARG BUN_LINUX_X64_SHA256=951ee2aee855f08595aeec6225226a298d3fea83a3dcd6465c09cbccdf7e848f + +RUN apt-get update \ + && apt-get install -y --no-install-recommends bash build-essential ca-certificates curl python3 unzip util-linux \ + && rm -rf /var/lib/apt/lists/* + +RUN curl --fail --silent --show-error --location \ + --retry 3 --retry-delay 2 --retry-all-errors \ + "https://github.com/oven-sh/bun/releases/download/bun-v${BUN_VERSION}/bun-linux-x64.zip" \ + --output /tmp/bun-linux-x64.zip \ + && echo "${BUN_LINUX_X64_SHA256} /tmp/bun-linux-x64.zip" | sha256sum --check --strict \ + && unzip -j /tmp/bun-linux-x64.zip 'bun-linux-x64/bun' -d /usr/local/bin \ + && chmod 0755 /usr/local/bin/bun \ + && test "$(bun --version)" = "$BUN_VERSION" \ + && rm -f /tmp/bun-linux-x64.zip + +WORKDIR /workspace +RUN install -d -o node -g node -m 0700 "/run/user/$(id -u node)" \ + && chown node:node /workspace +USER node +COPY --chown=node:node agent-node/package.json ./agent-node/ +COPY --chown=node:node agent-network/package.json agent-network/package-lock.json ./agent-network/ +RUN cd agent-node && npm install --include=optional \ + && cd ../agent-network && npm ci + +ENV TEST813_SOURCE_COMMIT=$SOURCE_COMMIT + +COPY --chown=node:node agent-network/src ./agent-network/src +COPY --chown=node:node agent-node/src ./agent-node/src +COPY --chown=node:node tests/test813-grok-mcp-readiness ./tests/test813-grok-mcp-readiness +COPY --chown=node:node tests/test225-grok-preview-package-live/fake-grok.mjs ./tests/test813-grok-mcp-readiness/fake-grok.mjs +COPY --chown=node:node tests/lib/safe-rm.sh ./tests/lib/safe-rm.sh + +RUN chmod 0755 tests/test813-grok-mcp-readiness/run.sh \ + tests/test813-grok-mcp-readiness/product-path.sh \ + tests/test813-grok-mcp-readiness/fake-grok.mjs + +ENTRYPOINT ["bash", "tests/test813-grok-mcp-readiness/run.sh"] diff --git a/tests/test813-grok-mcp-readiness/probe.ts b/tests/test813-grok-mcp-readiness/probe.ts new file mode 100644 index 000000000..0df503f8d --- /dev/null +++ b/tests/test813-grok-mcp-readiness/probe.ts @@ -0,0 +1,118 @@ +import { spawn } from "child_process"; +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { assertGrokCommhubMcpDoctor } from "../../agent-node/src/runtime/grok-build-cli-home"; + +type RpcResult = Record; + +function request(child: ReturnType, payload: Record): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`RPC_TIMEOUT:${payload.method}`)), 5_000); + let buffer = ""; + const onData = (chunk: Buffer) => { + buffer += chunk.toString("utf8"); + for (;;) { + const newline = buffer.indexOf("\n"); + if (newline < 0) return; + const line = buffer.slice(0, newline); + buffer = buffer.slice(newline + 1); + let message: any; + try { message = JSON.parse(line); } catch { continue; } + if (message.id !== payload.id) continue; + clearTimeout(timer); + child.stdout?.off("data", onData); + if (message.error) reject(new Error(`RPC_ERROR:${JSON.stringify(message.error)}`)); + else resolve(message.result || {}); + return; + } + }; + child.stdout?.on("data", onData); + child.stdin?.write(`${JSON.stringify(payload)}\n`); + }); +} + +const root = mkdtempSync(join(tmpdir(), "test813-")); +const envFile = join(root, "commhub.env"); +writeFileSync(envFile, "COMMHUB_URL=http://127.0.0.1:9\nCOMMHUB_TOKEN=fixture-token\n", { mode: 0o600 }); +chmodSync(root, 0o700); + +const child = spawn("bun", ["agent-network/src/node-server.ts"], { + cwd: "/workspace", + env: { + ...process.env, + ANET_COMMHUB_ENV_FILE: envFile, + ANET_COMMHUB_MODE: "outbound-only", + COMMHUB_ALIAS: "test813-dog", + COMMHUB_RESUME_ID: "test813-resume", + HOME: root, + }, + stdio: ["pipe", "pipe", "pipe"], +}); + +let stderr = ""; +child.stderr?.on("data", (chunk) => { stderr += chunk.toString("utf8"); }); + +try { + const initialized = await request(child, { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "test813", version: "1.0.0" }, + }, + }); + child.stdin?.write(`${JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" })}\n`); + const listed: any = await request(child, { + jsonrpc: "2.0", id: 2, method: "tools/list", params: {}, + }); + const tools = (listed.tools || []).map((tool: any) => tool?.name).sort(); + const expected = [ + "commhub_get_all_status", + "commhub_send_message", + "commhub_send_task", + "commhub_upload_file", + ]; + if (JSON.stringify(tools) !== JSON.stringify(expected)) { + throw new Error(`TOOL_SET_MISMATCH expected=${expected.join(",")} actual=${tools.join(",")}`); + } + if (!(initialized as any).serverInfo) throw new Error("INITIALIZE_HANDSHAKE_MISSING"); + + const healthy = JSON.stringify({ + servers: [{ + name: "commhub", + transport: "stdio", + healthy: true, + checks: [ + { label: "command found", passed: true }, + { label: "server started", passed: true }, + { label: "handshake OK", passed: true }, + { label: `${tools.length} tools discovered`, passed: true }, + ], + }], + healthy_count: 1, + failing_count: 0, + }); + assertGrokCommhubMcpDoctor(healthy); + + const stale = JSON.parse(healthy); + stale.servers[0].checks[3].label = "3 tools discovered"; + let staleRejected = false; + try { assertGrokCommhubMcpDoctor(JSON.stringify(stale)); } catch (error) { + staleRejected = String(error).includes("4 tools discovered"); + } + if (!staleRejected) throw new Error("STALE_THREE_TOOL_DOCTOR_ACCEPTED"); + process.stdout.write(`MCP_READINESS_PASS tools=${tools.join(",")}\n`); +} finally { + child.stdin?.end(); + child.kill("SIGTERM"); + await Promise.race([ + new Promise((resolve) => child.once("exit", resolve)), + new Promise((resolve) => setTimeout(resolve, 1_000)), + ]); + rmSync(root, { recursive: true, force: true }); +} + +if (stderr.includes("fatal:")) throw new Error(`MCP_CHILD_FATAL:${stderr}`); diff --git a/tests/test813-grok-mcp-readiness/product-path.sh b/tests/test813-grok-mcp-readiness/product-path.sh new file mode 100644 index 000000000..29b5de928 --- /dev/null +++ b/tests/test813-grok-mcp-readiness/product-path.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +set -euo pipefail +source tests/lib/safe-rm.sh + +mode=${1:-all} +case "$mode" in + all|negative|recovery) ;; + *) echo "FAIL: unknown product-path mode: $mode" >&2; exit 1 ;; +esac + +root=$(mktemp -d /tmp/test813-product.XXXXXX) +chmod 700 "$root" +cleanup() { safe_rm_rf "$root"; } +trap cleanup EXIT + +home="$root/home" +work="$root/work" +config="$root/config.json" +mkdir -p "$home/.grok" "$work/.anet" +chmod 700 "$home" "$home/.grok" "$work" +cp tests/test813-grok-mcp-readiness/fake-grok.mjs "$root/grok" +chmod 700 "$root/grok" + +cat >"$config" <<'JSON' +{"runtime":"grok-build-cli","grokCopresence":true,"hub":"http://127.0.0.1:9","token":"fixture-token","grokCliSession":"11111111-1111-4111-8111-111111111813","flags":{}} +JSON +chmod 600 "$config" + +(cd agent-node && bun run build >/dev/null) +(cd "$work" && bun build /workspace/agent-network/src/node-server.ts \ + --target bun --outfile .anet/node-server.js >/dev/null) +chmod 700 "$work/.anet/node-server.js" +printf '%s\n' \ + 'COMMHUB_URL=http://127.0.0.1:9' \ + 'COMMHUB_TOKEN=fixture-token' >"$work/.anet/.env" +chmod 600 "$work/.anet/.env" + +run_agent() { + local path=$1 bun_bin=$2 log=$3 + set +e + ( + cd "$work" + timeout 15s env -i \ + HOME="$home" PATH="$path" BUN_BIN="$bun_bin" GROK_BINARY="$root/grok" \ + /usr/local/bin/node /workspace/agent-node/dist/cli.js \ + --config "$config" --alias test813-dog --runtime grok-build-cli + ) >"$log" 2>&1 + local rc=$? + set -e + [ "$rc" -ne 0 ] || { echo "FAIL: product-path process unexpectedly stayed successful" >&2; return 1; } +} + +stop_fake_leaders() { + local proc pid + local -a argv=() + for proc in /proc/[0-9]*; do + [ -r "$proc/cmdline" ] || continue + argv=() + mapfile -d '' -t argv <"$proc/cmdline" || true + [ "${#argv[@]}" -ge 4 ] || continue + if [ "${argv[1]}" = "$root/grok" ] \ + && [ "${argv[2]}" = agent ] && [ "${argv[3]}" = leader ]; then + pid=${proc##*/} + kill -TERM "$pid" + for _ in $(seq 1 50); do + [ ! -e "$proc" ] && break + sleep 0.02 + done + [ ! -e "$proc" ] || { echo "FAIL: fake Grok Leader $pid did not stop" >&2; exit 1; } + fi + done + find "$home/.anet-grok" -type s \( -name 'leader.sock' -o -name 'attach.sock' \) -delete +} + +if [ "$mode" = all ] || [ "$mode" = negative ]; then + before=$(sha256sum "$config" | cut -d' ' -f1) + run_agent /usr/bin:/bin /definitely/missing/bun "$root/negative.log" + grep -Fxq 'Error: grok copresence CommHub MCP command could not be resolved or executed' "$root/negative.log" || { + echo "NEGATIVE_RUNTIME_GATE_NOT_REACHED" >&2 + sed -n '1,100p' "$root/negative.log" >&2 + exit 1 + } + ! grep -Eq '^\[[0-9]{2}:[0-9]{2}:[0-9]{2}\] \[INFO \] \[test813-dog\] 已注册到 CommHub$' "$root/negative.log" || { + echo "FAIL: missing-Bun product path registered before its runtime gate" >&2 + exit 1 + } + after=$(sha256sum "$config" | cut -d' ' -f1) + [ "$before" = "$after" ] || { + echo "FAIL: missing-Bun product path changed the persisted session config" >&2 + exit 1 + } + echo "PRODUCT_PATH_NEGATIVE_PASS registration=absent session=unchanged" +fi + +if [ "$mode" = all ] || [ "$mode" = recovery ]; then + node -e 'const fs=require("fs");const p=process.argv[1];const c=JSON.parse(fs.readFileSync(p));delete c.grokCliSession;fs.writeFileSync(p,JSON.stringify(c)+"\n",{mode:0o600})' "$config" + run_agent /usr/local/bin:/usr/bin:/bin /usr/local/bin/bun "$root/first.log" + grep -Eq '^\[[0-9]{2}:[0-9]{2}:[0-9]{2}\] \[INFO \] \[test813-dog\] \[grok-copresence\] TUI ready session=[0-9a-f]{8} attach=/.+$' "$root/first.log" || { + echo "FAIL: canonical-Bun product path did not reach TUI readiness" >&2 + sed -n '1,120p' "$root/first.log" >&2 + exit 1 + } + grep -Eq '^\[[0-9]{2}:[0-9]{2}:[0-9]{2}\] \[INFO \] \[test813-dog\] \[grok-copresence\] grok 0\.2\.93 \(f00f96316d\); attach with anet grok attach test813-dog$' "$root/first.log" || { + echo "FAIL: canonical-Bun product path did not complete the real doctor/startup boundary" >&2 + exit 1 + } + sid1=$(node -e 'const fs=require("fs");console.log(JSON.parse(fs.readFileSync(process.argv[1])).grokCliSession||"")' "$config") + [[ "$sid1" =~ ^[0-9a-f-]{36}$ ]] || { echo "FAIL: first product start did not persist a Grok session" >&2; exit 1; } + stop_fake_leaders + + # The reviewed fake reproduces native Grok's temporary read-only sandbox + # placeholders. A real launcher stop removes them; this probe deliberately + # terminates at the unreachable Hub registration boundary, so verify their + # exact harmless tuple before performing that launcher-owned cleanup. + for name in .grok .claude .cursor .mcp.json .envrc; do + placeholder="$work/$name" + [ -f "$placeholder" ] && [ ! -L "$placeholder" ] \ + && [ ! -s "$placeholder" ] && [ "$(stat -c %a "$placeholder")" = 444 ] || { + echo "FAIL: fake Grok sandbox placeholder tuple changed: $name" >&2 + exit 1 + } + chmod 600 "$placeholder" + rm -f -- "$placeholder" + done + + run_agent /usr/local/bin:/usr/bin:/bin /usr/local/bin/bun "$root/second.log" + grep -Eq '^\[[0-9]{2}:[0-9]{2}:[0-9]{2}\] \[INFO \] \[test813-dog\] \[grok-copresence\] TUI ready session=[0-9a-f]{8} attach=/.+$' "$root/second.log" || { + echo "FAIL: product recovery did not return to TUI readiness" >&2 + sed -n '1,120p' "$root/second.log" >&2 + exit 1 + } + sid2=$(node -e 'const fs=require("fs");console.log(JSON.parse(fs.readFileSync(process.argv[1])).grokCliSession||"")' "$config") + [ "$sid1" = "$sid2" ] || { echo "FAIL: product recovery replaced the existing Grok session" >&2; exit 1; } + stop_fake_leaders + echo "PRODUCT_PATH_RECOVERY_PASS session=preserved" +fi diff --git a/tests/test813-grok-mcp-readiness/run.sh b/tests/test813-grok-mcp-readiness/run.sh new file mode 100644 index 000000000..75c56499d --- /dev/null +++ b/tests/test813-grok-mcp-readiness/run.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +set -euo pipefail +source tests/lib/safe-rm.sh + +SOURCE_COMMIT=${TEST813_SOURCE_COMMIT:-} +[[ "$SOURCE_COMMIT" =~ ^[0-9a-f]{40}$ ]] || { + echo "FAIL: TEST813_SOURCE_COMMIT must be one full lowercase Git SHA" >&2 + exit 1 +} + +probe() { + bun tests/test813-grok-mcp-readiness/probe.ts +} + +expect_red() { + local name=$1 expected=$2 + shift 2 + local log="/tmp/test813-${name}.log" + cp agent-network/src/node-server.ts /tmp/test813-node-server.orig + cp agent-node/src/runtime/grok-build-cli-home.ts /tmp/test813-home.orig + "$@" + if probe >"$log" 2>&1; then + echo "FAIL: mutation survived: $name" >&2 + cat "$log" >&2 + exit 1 + fi + grep -Fq "$expected" "$log" || { + echo "FAIL: mutation $name died for the wrong reason" >&2 + cat "$log" >&2 + exit 1 + } + cp /tmp/test813-node-server.orig agent-network/src/node-server.ts + cp /tmp/test813-home.orig agent-node/src/runtime/grok-build-cli-home.ts + echo "MUTATION_RED $name" +} + +probe + +expect_red upload-tool-removed TOOL_SET_MISMATCH \ + sed -i '/^[[:space:]]*"commhub_upload_file",[[:space:]]*$/d' agent-network/src/node-server.ts + +expect_red stale-three-tool-doctor 'readiness failed: 3 tools discovered' \ + sed -i 's/"4 tools discovered"/"3 tools discovered"/' agent-node/src/runtime/grok-build-cli-home.ts + +probe + +bash tests/test813-grok-mcp-readiness/product-path.sh all + +# A stack trace from minified dist/cli.js contains source string literals. +# The product gate must recognize a timestamped runtime log event, not merely +# find `TUI ready session=` anywhere in a failed stack dump. +cp tests/test813-grok-mcp-readiness/fake-grok.mjs /tmp/test813-fake-grok.orig +doctor_target=' { label: "4 tools discovered", passed: toolNames.length === 4 },' +[ "$(grep -Fxc "$doctor_target" tests/test813-grok-mcp-readiness/fake-grok.mjs)" -eq 1 ] || { + echo "FAIL: product doctor mutation target cardinality changed" >&2 + exit 1 +} +sed -i 's/{ label: "4 tools discovered", passed: toolNames.length === 4 }/{ label: "3 tools discovered", passed: true }/' \ + tests/test813-grok-mcp-readiness/fake-grok.mjs +if bash tests/test813-grok-mcp-readiness/product-path.sh recovery >/tmp/test813-product-doctor-mutation.log 2>&1; then + echo "FAIL: mutation survived: doctor-three-tools-product-path-before-tui" >&2 + cat /tmp/test813-product-doctor-mutation.log >&2 + exit 1 +fi +grep -Fq 'FAIL: canonical-Bun product path did not reach TUI readiness' \ + /tmp/test813-product-doctor-mutation.log || { + echo "FAIL: product doctor mutation did not die at the anchored TUI readiness gate" >&2 + cat /tmp/test813-product-doctor-mutation.log >&2 + exit 1 + } +grep -Fq 'GrokCopresenceFailure: grok copresence pre-spawn audit failed: grok copresence CommHub MCP readiness failed: 4 tools discovered' \ + /tmp/test813-product-doctor-mutation.log || { + echo "FAIL: product doctor mutation died for the wrong reason" >&2 + cat /tmp/test813-product-doctor-mutation.log >&2 + exit 1 + } +cp /tmp/test813-fake-grok.orig tests/test813-grok-mcp-readiness/fake-grok.mjs +echo "MUTATION_RED doctor-three-tools-product-path-before-tui" + +cp agent-node/src/cli.ts /tmp/test813-cli.orig +target='process.env.BUN_BIN || "bun",' +[ "$(grep -Fc "$target" agent-node/src/cli.ts)" -eq 1 ] || { + echo "FAIL: product-path mutation target cardinality changed" >&2 + exit 1 +} +sed -i 's/process\.env\.BUN_BIN || "bun",/"\/usr\/local\/bin\/bun",/' agent-node/src/cli.ts +grep -Fq '"/usr/local/bin/bun",' agent-node/src/cli.ts || { + echo "FAIL: product-path mutation did not change production source" >&2 + exit 1 +} +if bash tests/test813-grok-mcp-readiness/product-path.sh negative >/tmp/test813-product-mutation.log 2>&1; then + echo "FAIL: mutation survived: bun-resolver-bypassed" >&2 + cat /tmp/test813-product-mutation.log >&2 + exit 1 +fi +grep -Fq 'NEGATIVE_RUNTIME_GATE_NOT_REACHED' /tmp/test813-product-mutation.log || { + echo "FAIL: product-path mutation died for the wrong reason" >&2 + cat /tmp/test813-product-mutation.log >&2 + exit 1 +} +cp /tmp/test813-cli.orig agent-node/src/cli.ts +echo "MUTATION_RED bun-resolver-bypassed" + +bash tests/test813-grok-mcp-readiness/product-path.sh all + +if [ "${RUN_VENDOR_GROK:-0}" = 1 ]; then + real_bin=${TEST813_REAL_GROK_BIN:-/host-grok/grok-0.2.93} + [ -x "$real_bin" ] || { + echo "FAIL: pinned Grok binary is not executable: $real_bin" >&2 + exit 1 + } + [[ "$("$real_bin" --version)" =~ ^grok\ 0\.2\.93\ \(f00f96316d\)(\ \[stable\])?$ ]] || { + echo "FAIL: vendor doctor requires exact Grok 0.2.93" >&2 + exit 1 + } + vendor_root=$(mktemp -d /tmp/test813-vendor.XXXXXX) + chmod 700 "$vendor_root" + printf '%s\n' \ + 'COMMHUB_URL=http://127.0.0.1:9' \ + 'COMMHUB_TOKEN=fixture-token' >"$vendor_root/commhub.env" + chmod 600 "$vendor_root/commhub.env" + printf '%s\n' \ + '[mcp_servers.commhub]' \ + 'command = "/usr/local/bin/bun"' \ + 'args = ["/workspace/agent-network/src/node-server.ts"]' \ + "env = { ANET_COMMHUB_ENV_FILE = \"$vendor_root/commhub.env\", ANET_COMMHUB_MODE = \"outbound-only\", COMMHUB_ALIAS = \"test813-dog\", COMMHUB_RESUME_ID = \"test813-resume\" }" \ + 'enabled = true' >"$vendor_root/config.toml" + chmod 600 "$vendor_root/config.toml" + vendor_report="$vendor_root/doctor.json" + if ! env -i \ + PATH=/usr/local/bin:/usr/bin:/bin \ + HOME="$vendor_root" GROK_HOME="$vendor_root" GROK_AUTH_PATH="$vendor_root/auth.json" \ + "$real_bin" mcp doctor commhub --json >"$vendor_report" 2>"$vendor_root/doctor.stderr"; then + echo "FAIL: real Grok vendor doctor failed" >&2 + sed -n '1,80p' "$vendor_root/doctor.stderr" >&2 + exit 1 + fi + bun tests/test813-grok-mcp-readiness/validate-vendor-doctor.ts "$vendor_report" + safe_rm_rf "$vendor_root" +fi + +echo "RESULT: PASS source_commit=$SOURCE_COMMIT" diff --git a/tests/test813-grok-mcp-readiness/validate-vendor-doctor.ts b/tests/test813-grok-mcp-readiness/validate-vendor-doctor.ts new file mode 100644 index 000000000..60d6891da --- /dev/null +++ b/tests/test813-grok-mcp-readiness/validate-vendor-doctor.ts @@ -0,0 +1,13 @@ +import { readFileSync } from "fs"; +import { assertGrokCommhubMcpDoctor } from "../../agent-node/src/runtime/grok-build-cli-home"; + +const path = process.argv[2]; +if (!path) throw new Error("VENDOR_DOCTOR_PATH_MISSING"); +const raw = readFileSync(path, "utf8"); +assertGrokCommhubMcpDoctor(raw); +const report = JSON.parse(raw); +const server = report.servers.find((candidate: any) => candidate?.name === "commhub"); +const labels = (server?.checks || []) + .filter((check: any) => check?.passed === true) + .map((check: any) => check?.label); +process.stdout.write(`VENDOR_DOCTOR_PASS labels=${labels.join("|")}\n`);