Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/workflows/qa.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ on:
- 'docs/doc-source-pins-baseline.txt'
- 'scripts/check-doc-source-pins.py'
- 'tests/test831-doc-source-pins/**'
- 'tests/test813-grok-mcp-readiness/**'
push:
branches: [main]
paths:
Expand Down Expand Up @@ -90,6 +91,7 @@ on:
- 'docs/doc-source-pins-baseline.txt'
- 'scripts/check-doc-source-pins.py'
- 'tests/test831-doc-source-pins/**'
- 'tests/test813-grok-mcp-readiness/**'

# Older runs on the same ref get cancelled — saves minutes when a PR is
# updated rapidly. main pushes run independently.
Expand Down Expand Up @@ -308,6 +310,21 @@ jobs:
# 🔴 这一步已搬到 .github/workflows/doc-symbol-pins.yml(无 paths 过滤)。
# 留在这里跑不到该跑的时候:qa.yml 的 paths 里没有 docs/architecture.md,
# 而那正是行号漂得最多的文件(#1001 一次抓到三处)。
# test813 —— #825 加的 Grok CommHub MCP 就绪门。接进来的理由很直接:
# 那条 PR 的目的是「MCP 没就绪时 fail closed」,而验证这件事的套件
# 如果不进 CI,fail-closed 这个保证就没有任何东西持续守着它。
# Dockerfile 的 build-arg 名是 SOURCE_COMMIT(不是 831 的 TEST831_SOURCE_COMMIT),
# 容器里由 ENV TEST813_SOURCE_COMMIT 承接 —— 照抄 831 的参数名会静默拿不到值。
- name: Build test813-grok-mcp-readiness
run: |
docker build \
--build-arg SOURCE_COMMIT="$GITHUB_SHA" \
-t anet-test813-grok-mcp-readiness \
-f tests/test813-grok-mcp-readiness/Dockerfile .

- name: Run test813-grok-mcp-readiness
run: docker run --rm --network none anet-test813-grok-mcp-readiness

- name: Build test831-doc-source-pins
run: |
docker build \
Expand Down
23 changes: 23 additions & 0 deletions agent-node/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3621,8 +3621,10 @@ async function ensureGrokCopresenceRuntime(): Promise<GrokCopresenceSession> {
const {
prepareGrokCliHome,
assertNoDiscoveredGrokHooks,
assertGrokCommhubMcpDoctor,
grokCliStateKey,
grokProjectPolicyPaths,
resolveGrokCommhubMcpCommand,
} = await import("./runtime/grok-build-cli-home");
const {
assertGrokCopresenceFeatures,
Expand Down Expand Up @@ -3654,6 +3656,10 @@ async function ensureGrokCopresenceRuntime(): Promise<GrokCopresenceSession> {
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({
Expand All @@ -3663,6 +3669,7 @@ async function ensureGrokCopresenceRuntime(): Promise<GrokCopresenceSession> {
projectCwd: grokCwd,
useLeader: true,
commhubMcp: {
command: commhubMcpCommand,
serverPath: commhubMcpServer,
envFile: commhubMcpEnv,
alias: currentAlias(),
Expand Down Expand Up @@ -3755,6 +3762,22 @@ async function ensureGrokCopresenceRuntime(): Promise<GrokCopresenceSession> {
}
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();
};
Expand Down
61 changes: 59 additions & 2 deletions agent-node/src/runtime/grok-build-cli-home.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
mkdirSync,
readdirSync,
readFileSync,
realpathSync,
rmSync,
statSync,
symlinkSync,
Expand All @@ -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";

Expand All @@ -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}$/);
Expand Down Expand Up @@ -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: "指挥狗",
Expand Down Expand Up @@ -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)}]`);
Expand Down Expand Up @@ -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");
Expand Down
70 changes: 66 additions & 4 deletions agent-node/src/runtime/grok-build-cli-home.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createHash, randomBytes } from "crypto";
import { spawn } from "child_process";
import {
accessSync,
chmodSync,
closeSync,
constants,
Expand All @@ -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 {
Expand All @@ -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;
Expand Down Expand Up @@ -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],
Expand All @@ -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 };
}

/**
Expand Down Expand Up @@ -1404,7 +1466,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",
Expand Down
2 changes: 1 addition & 1 deletion docs/message-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ if (["new_task", "broadcast"].includes(ev.type)) {
|---|------|--------|------|
| 1 | sendReply 用 send_message | agent-node | ✅ v1.4.2 |
| 2 | SSE 只响应 new_task / broadcast | agent-node | ✅ [cli.ts:1102 `["new_task", "broadcast"].includes(ev.type)`](https://github.com/sleep2agi/agent-network/blob/main/agent-node/src/cli.ts#L1102);`new_reply` 单独走日志记录 cli.ts:1106 |
| 3 | 低价值消息过滤 | agent-node | ✅ v1.4.0;当前在 [cli.ts:4639 `shouldSkipMessage`](https://github.com/sleep2agi/agent-network/blob/main/agent-node/src/cli.ts#L4639) |
| 3 | 低价值消息过滤 | agent-node | ✅ v1.4.0;当前在 [cli.ts:4662 `shouldSkipMessage`](https://github.com/sleep2agi/agent-network/blob/main/agent-node/src/cli.ts#L4662) |
| 4 | CLAUDE.md 不对 message 回复 | 各项目 CLAUDE.md | ✅ R195 chain 模板已加 |
| 5 | developer_instructions 安静规则 | agent-node | ✅ v1.4.1 |

Expand Down
Loading
Loading