Skip to content

Commit 72e28fd

Browse files
committed
fix(#204): preview.7 — per-node isolated cwd to defeat stale .mcp.json discovery
Vincent 06:11 UAT preview.6 still shows from=grok测试员 on receiver. 通信龙 self-grep root cause: Grok CLI auto-reads cwd .mcp.json AND ALSO uses our ACP session/new mcpServers injection → two commhub MCP servers coexist; the stale stdio one (Vincent's grok-build/.mcp.json had COMMHUB_ALIAS= grok测试员 from a prior anet start) wins the LLM's hello message. Phase 1 design (双重 review PASS by 通信龙 + 通信牛): - Option E (ACP protocol control field): @zed-industries/agent-client-protocol @0.4.5 NewSessionRequest schema has only _meta/cwd/mcpServers — no replaceProjectMcpServers / mcpDiscoveryMode / noProjectMcp. OUT. - Option D (grok CLI flag): grok agent stdio --help is just -h/--help. grok mcp manages ~/.grok/config.toml independently. No mcp-config-path flag. OUT. - Option B (isolated cwd, ACP-friendly): pass per-node isolated dir via ACP session/new's existing cwd field. Per-node mirrors top-level user files via symlink (so LLM Read('./README.md') still works) but skips .mcp.json so cwd discovery finds nothing. Hub-side callerAlias from ntok tokenName (commhub-server src/index.ts:446, 通信牛 d1d867e) does attribution. Multi-node concurrent-spawn safe by construction. PICKED. - Option A (.mcp.json rewrite): would pollute Vincent's project — NOT. Implementation: 1. New file agent-node/src/grok-isolated-cwd.ts exporting pure helper prepareGrokIsolatedCwd({ userCwd, nodeId, alias, home?, onWarn? }): { cwd, isolated, symlinked, skipped, error? } - mkdirSync recursive at `<home>/.anet/nodes/<sanitisedNodeKey>/grok-cwd/` - readdirSync(userCwd), skip .mcp.json, symlink the rest (idempotent existsSync check, per-entry try/catch — single failure does not abort the loop, warn() is invoked and the loop continues) - On mkdir/readdir failure, returns { isolated:false, cwd:userCwd, error } so the caller transparently falls back to userCwd (preview.6 behaviour; no regression). - sanitiseNodeKey: regex /[^A-Za-z0-9._-]/g → `_` — defends against weird alias chars sneaking path separators through (test verifies the resolved dir stays under home/). 2. agent-node/src/cli.ts processWithGrok refactored: ~30 LOC inline block replaced with ~10 LOC helper call. cwd field of runGrokAcpTurn() now uses isolatedResult.cwd. preview.6 ACP HTTP MCP injection unchanged (that's still our only commhub MCP source; the isolated cwd just stops Grok from also reading stale cwd .mcp.json). 3. agent-node/package.json: 2.4.7-preview.6 → 2.4.7-preview.7 (no publish, 工程马 cascades on Phase 4). Phase 2 verification: - agent-node/src/grok-isolated-cwd.test.ts (NEW, 197 lines, **11 tests, all passing**): creates per-node dir / alias fallback / nodeKey sanitisation / .mcp.json skipped / files+dirs symlinked / idempotent restart / re-run freshness (new files picked up) / mkdir failure fallback / readdir failure fallback / per-entry symlink failure warn-not-throw / two-node concurrency isolation. - bun test src/: **77/77 pass** (66 pre-existing + 11 new). No regressions. - bun build src/cli.ts: 0.59MB / 0 errors. - Docker minimal smoke (/tmp/issue-204-p7-smoke/run-minimal.sh): installed version: agent-node v2.4.7-preview.7 hub up 2s, SSE connected 2s, alias source log present MINIMAL SMOKE PASS Why not full Docker E2E here: Phase 2 self-test was originally a 7-case Docker harness that needed real Grok + xAI auth to exercise the LLM-side hello message. 通信龙 + I agreed (after a hung 10-second timeout on the first attempt) that: - The isolated-cwd ALGORITHM itself is pure fs ops — unit-testable cleanly. - The end-to-end attribution proof needs a real Grok agent + xAI quota. - Phase 3 测试马 owns the real-Grok 5-case Docker matrix (he has the auth). This gives us: deterministic Phase 2 in 10min + decisive Phase 3 in 45min, instead of brittle Phase 2 dragging on retries. Refs: - #204 root cause: 通信龙 self-grep finding (cwd .mcp.json + ACP injection double-spawn) - Phase 1 design 双 review PASS: 通信龙 + 通信牛 - 通信牛 d1d867e: commhub-server /mcp callerAlias from ntok tokenName - preview.6 (abefbe8): ACP HTTP MCP transport switch (kept as the in-band attribution mechanism; preview.7 just defeats the OTHER cwd-discovered commhub MCP that was shadowing it)
1 parent dfb8323 commit 72e28fd

4 files changed

Lines changed: 348 additions & 2 deletions

File tree

agent-node/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@sleep2agi/agent-node",
3-
"version": "2.4.7-preview.6",
3+
"version": "2.4.7-preview.7",
44
"description": "AI Agent runtime for CommHub networks. Supports Claude Agent SDK, Codex SDK, and OpenAI/Anthropic-compatible HTTP API.",
55
"bin": {
66
"agent-node": "dist/cli.js"

agent-node/src/cli.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1335,11 +1335,40 @@ async function processWithGrok(task: string, from: string, images?: string[]): P
13351335
url: `${COMMHUB_URL}/mcp`,
13361336
headers,
13371337
}];
1338+
1339+
// #204 preview.7 — per-node isolated cwd. preview.2 → preview.6 chain
1340+
// showed that even with HTTP MCP injected via ACP, Grok CLI ALSO reads
1341+
// the cwd `.mcp.json` and spawns a *second* commhub MCP server from
1342+
// there. That second instance carries whatever `COMMHUB_ALIAS=<stale>`
1343+
// was last written to the file (Vincent's grok-build/.mcp.json had
1344+
// `grok测试员` from a prior `anet node start`), and Grok's hello-
1345+
// message tool call routes to that stale server, breaking attribution.
1346+
//
1347+
// Fix: pass an isolated cwd to ACP `session/new` so Grok's cwd-discovery
1348+
// hits an empty dir (no .mcp.json) and only our ACP-injected HTTP MCP
1349+
// is in play. Logic extracted to `grok-isolated-cwd.ts` so it can be
1350+
// unit-tested without spinning up a real Grok agent (preview.7 Phase 2
1351+
// smoke ran into the obvious "exercising algorithm end-to-end needs
1352+
// real Grok + xAI auth" problem).
1353+
const { prepareGrokIsolatedCwd } = await import("./grok-isolated-cwd");
1354+
const isolatedResult = prepareGrokIsolatedCwd({
1355+
userCwd: process.cwd(),
1356+
nodeId: NODE_ID,
1357+
alias: ALIAS,
1358+
onWarn: (m) => warn(`[grok] ${m}`),
1359+
});
1360+
if (isolatedResult.isolated) {
1361+
log(`[grok] #204 isolated cwd: ${isolatedResult.cwd} (symlinked=${isolatedResult.symlinked}, skipped=${isolatedResult.skipped})`);
1362+
} else {
1363+
warn(`[grok] #204 isolated cwd failed: ${isolatedResult.error} — falling back to process.cwd(), may re-read project .mcp.json`);
1364+
}
1365+
const grokCwd = isolatedResult.cwd;
1366+
13381367
const runOnce = async (sessionId?: string, label = "primary") => {
13391368
const t0 = Date.now();
13401369
const result = await runGrokAcpTurn({
13411370
prompt: buildGrokCommhubPrompt(task, from),
1342-
cwd: process.cwd(),
1371+
cwd: grokCwd,
13431372
sessionId,
13441373
mcpServers: grokMcpServers,
13451374
timeoutMs: parseInt(process.env.GROK_ACP_TIMEOUT_MS || fileConfig.flags?.grokAcpTimeoutMs || "300000"),
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
// #204 preview.7 — unit tests for `prepareGrokIsolatedCwd`.
2+
//
3+
// We use a real (but disposable) temp tree as `home`, so the helper exercises
4+
// the actual fs ops (mkdir / readdir / symlink / stat) rather than mocked
5+
// stubs. Faster + more reliable than the Docker E2E (which needs real Grok +
6+
// xAI auth), and catches the same algorithm bugs the original smoke would.
7+
8+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
9+
import {
10+
chmodSync,
11+
existsSync,
12+
lstatSync,
13+
mkdirSync,
14+
mkdtempSync,
15+
readlinkSync,
16+
rmSync,
17+
writeFileSync,
18+
} from "fs";
19+
import { join } from "path";
20+
import { tmpdir } from "os";
21+
import { prepareGrokIsolatedCwd } from "./grok-isolated-cwd";
22+
23+
describe("prepareGrokIsolatedCwd (#204 preview.7)", () => {
24+
let tmpRoot: string;
25+
let userCwd: string;
26+
let fakeHome: string;
27+
28+
beforeEach(() => {
29+
tmpRoot = mkdtempSync(join(tmpdir(), "p204-iso-cwd-"));
30+
userCwd = join(tmpRoot, "user-project");
31+
fakeHome = join(tmpRoot, "fake-home");
32+
mkdirSync(userCwd, { recursive: true });
33+
mkdirSync(fakeHome, { recursive: true });
34+
// Plant a realistic Vincent-style user cwd:
35+
// - .mcp.json (stale, must be skipped)
36+
// - README.md (top-level file — must be symlinked)
37+
// - docs/ (top-level dir — must be symlinked as dir)
38+
// - src/index.ts (nested file — only its parent `src` gets a top-level
39+
// symlink; the nested file is accessible via that symlink)
40+
writeFileSync(join(userCwd, ".mcp.json"), '{"mcpServers":{"commhub":{"env":{"COMMHUB_ALIAS":"bogus-stale"}}}}');
41+
writeFileSync(join(userCwd, "README.md"), "user project README\n");
42+
mkdirSync(join(userCwd, "docs"), { recursive: true });
43+
writeFileSync(join(userCwd, "docs", "intro.md"), "doc body\n");
44+
mkdirSync(join(userCwd, "src"), { recursive: true });
45+
writeFileSync(join(userCwd, "src", "noindex.ts"), "// app entry\n");
46+
});
47+
48+
afterEach(() => {
49+
try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* noop */ }
50+
});
51+
52+
it("creates per-node grok-cwd directory under home/.anet/nodes/<nodeKey>/grok-cwd", () => {
53+
const r = prepareGrokIsolatedCwd({ userCwd, nodeId: "n_abc123", home: fakeHome });
54+
expect(r.isolated).toBe(true);
55+
expect(r.cwd).toBe(join(fakeHome, ".anet", "nodes", "n_abc123", "grok-cwd"));
56+
expect(existsSync(r.cwd)).toBe(true);
57+
});
58+
59+
it("falls back to alias when nodeId is absent", () => {
60+
const r = prepareGrokIsolatedCwd({ userCwd, alias: "grok-test-A", home: fakeHome });
61+
expect(r.cwd).toBe(join(fakeHome, ".anet", "nodes", "grok-test-A", "grok-cwd"));
62+
expect(r.isolated).toBe(true);
63+
});
64+
65+
it("sanitises nodeKey to avoid path traversal / weird chars", () => {
66+
const r = prepareGrokIsolatedCwd({ userCwd, alias: "../escape/../bad alias.txt", home: fakeHome });
67+
// Slashes / spaces / `..` segments collapse — `.` and `_` and `-` are
68+
// preserved (they're not traversal-dangerous as part of a single segment).
69+
// Assert: the resolved real path stays *under* fakeHome (no escape).
70+
const { resolve } = require("path");
71+
const resolved = resolve(r.cwd);
72+
expect(resolved.startsWith(resolve(fakeHome) + "/")).toBe(true);
73+
// No raw path separator slipped through.
74+
const nodeKeySegment = resolved.split("/nodes/")[1].split("/")[0];
75+
expect(nodeKeySegment).not.toContain("/");
76+
expect(r.isolated).toBe(true);
77+
});
78+
79+
it("skips .mcp.json (does NOT symlink it into isolated cwd)", () => {
80+
const r = prepareGrokIsolatedCwd({ userCwd, nodeId: "n_skip", home: fakeHome });
81+
expect(r.isolated).toBe(true);
82+
expect(r.skipped).toBe(1); // exactly the .mcp.json
83+
expect(existsSync(join(r.cwd, ".mcp.json"))).toBe(false);
84+
});
85+
86+
it("symlinks top-level files (README.md) and directories (docs/, src/)", () => {
87+
const r = prepareGrokIsolatedCwd({ userCwd, nodeId: "n_symlink", home: fakeHome });
88+
expect(r.isolated).toBe(true);
89+
// README.md is a regular-file symlink
90+
const readmeLink = join(r.cwd, "README.md");
91+
expect(existsSync(readmeLink)).toBe(true);
92+
expect(lstatSync(readmeLink).isSymbolicLink()).toBe(true);
93+
expect(readlinkSync(readmeLink)).toBe(join(userCwd, "README.md"));
94+
// docs/ is a directory-symlink — and the nested file is reachable via it
95+
const docsLink = join(r.cwd, "docs");
96+
expect(lstatSync(docsLink).isSymbolicLink()).toBe(true);
97+
expect(existsSync(join(docsLink, "intro.md"))).toBe(true);
98+
// src/ likewise
99+
const srcLink = join(r.cwd, "src");
100+
expect(lstatSync(srcLink).isSymbolicLink()).toBe(true);
101+
expect(existsSync(join(srcLink, "noindex.ts"))).toBe(true);
102+
// 3 entries symlinked (README, docs, src) — .mcp.json is skipped
103+
expect(r.symlinked).toBe(3);
104+
});
105+
106+
it("is idempotent — second run sees existing symlinks and counts 0 new", () => {
107+
const r1 = prepareGrokIsolatedCwd({ userCwd, nodeId: "n_idem", home: fakeHome });
108+
expect(r1.symlinked).toBe(3);
109+
expect(r1.skipped).toBe(1);
110+
111+
// Second run — same opts, same cwd. Must not throw EEXIST + must report
112+
// 0 new symlinks (existing ones are kept as-is for cheapness).
113+
const r2 = prepareGrokIsolatedCwd({ userCwd, nodeId: "n_idem", home: fakeHome });
114+
expect(r2.isolated).toBe(true);
115+
expect(r2.symlinked).toBe(0);
116+
expect(r2.skipped).toBe(1);
117+
// Symlinks still resolve
118+
expect(existsSync(join(r2.cwd, "README.md"))).toBe(true);
119+
});
120+
121+
it("picks up new entries on re-run (snapshot freshness)", () => {
122+
const r1 = prepareGrokIsolatedCwd({ userCwd, nodeId: "n_fresh", home: fakeHome });
123+
expect(r1.symlinked).toBe(3);
124+
// User adds a new file after first prepare (mid-session)
125+
writeFileSync(join(userCwd, "NEW.md"), "newly added\n");
126+
const r2 = prepareGrokIsolatedCwd({ userCwd, nodeId: "n_fresh", home: fakeHome });
127+
// Old 3 are existsSync-hit → not re-symlinked; only NEW.md is added.
128+
expect(r2.symlinked).toBe(1);
129+
expect(existsSync(join(r2.cwd, "NEW.md"))).toBe(true);
130+
});
131+
132+
it("falls back to userCwd (isolated=false) when mkdir fails", () => {
133+
// Make fakeHome non-writable so mkdirSync(<home>/.anet/...) fails.
134+
chmodSync(fakeHome, 0o555);
135+
const warns: string[] = [];
136+
const r = prepareGrokIsolatedCwd({
137+
userCwd,
138+
nodeId: "n_perm",
139+
home: fakeHome,
140+
onWarn: (m) => warns.push(m),
141+
});
142+
chmodSync(fakeHome, 0o755); // restore for cleanup
143+
expect(r.isolated).toBe(false);
144+
expect(r.cwd).toBe(userCwd);
145+
expect(r.error).toMatch(/mkdir failed/);
146+
});
147+
148+
it("falls back to userCwd when userCwd does not exist (readdir fails)", () => {
149+
const r = prepareGrokIsolatedCwd({
150+
userCwd: join(tmpRoot, "does-not-exist"),
151+
nodeId: "n_noread",
152+
home: fakeHome,
153+
});
154+
expect(r.isolated).toBe(false);
155+
// cwd echoes back input even on failure so caller has a non-empty value
156+
expect(r.cwd).toContain("does-not-exist");
157+
expect(r.error).toMatch(/readdir userCwd failed/);
158+
});
159+
160+
it("does NOT throw on per-entry symlink failure — warns and continues", () => {
161+
// Pre-create a *real file* at the would-be symlink target so symlinkSync
162+
// would race-collide on a parallel test. Simpler: create a dst dir that
163+
// is non-writable. Actually easiest: just put a regular file at the dst
164+
// path so existsSync returns true and the loop skips it cleanly. This
165+
// exercises the "already exists" branch; for true per-entry failure we
166+
// need an unreachable src. Use a broken symlink as src:
167+
const brokenSrc = join(userCwd, "broken-link");
168+
// ../truly-missing → broken on resolution but readdir lists it
169+
require("fs").symlinkSync(join(tmpRoot, "truly-missing"), brokenSrc);
170+
171+
const warns: string[] = [];
172+
const r = prepareGrokIsolatedCwd({
173+
userCwd,
174+
nodeId: "n_broken",
175+
home: fakeHome,
176+
onWarn: (m) => warns.push(m),
177+
});
178+
expect(r.isolated).toBe(true);
179+
// broken-link triggered statSync failure → onWarn called → loop continues
180+
expect(warns.some((w) => w.includes("broken-link"))).toBe(true);
181+
// The other 3 valid entries still got symlinked.
182+
expect(r.symlinked).toBeGreaterThanOrEqual(3);
183+
// .mcp.json still skipped
184+
expect(r.skipped).toBe(1);
185+
});
186+
187+
it("two different nodes get fully isolated dirs (concurrency safe by construction)", () => {
188+
const a = prepareGrokIsolatedCwd({ userCwd, nodeId: "n_alpha", home: fakeHome });
189+
const b = prepareGrokIsolatedCwd({ userCwd, nodeId: "n_beta", home: fakeHome });
190+
expect(a.cwd).not.toBe(b.cwd);
191+
expect(a.cwd).toContain("/n_alpha/");
192+
expect(b.cwd).toContain("/n_beta/");
193+
// Both have own copies of the symlinked README
194+
expect(existsSync(join(a.cwd, "README.md"))).toBe(true);
195+
expect(existsSync(join(b.cwd, "README.md"))).toBe(true);
196+
});
197+
});
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
// #204 preview.7 — per-node isolated cwd for grok-build-acp runtime.
2+
//
3+
// Why this exists (in 3 lines):
4+
// 1. Grok CLI auto-discovers MCP servers from <cwd>/.mcp.json on spawn.
5+
// 2. Vincent's user cwd can carry a stale .mcp.json (COMMHUB_ALIAS=<old node>).
6+
// 3. We need Grok to find NO .mcp.json — only the ACP-injected HTTP MCP.
7+
//
8+
// Strategy: pass `~/.anet/nodes/<nodeKey>/grok-cwd/` as the ACP session/new
9+
// `cwd` field. That dir mirrors the user's top-level entries via symlink
10+
// (so LLM `Read('./README.md')` still works) but explicitly omits
11+
// `.mcp.json` so Grok's cwd-discovery finds nothing.
12+
//
13+
// Extracted from inline processWithGrok block to make it testable: the
14+
// inline path was failing Docker smoke not because the algorithm is wrong
15+
// but because exercising it end-to-end requires a real Grok binary +
16+
// xAI auth. A direct bun-test against this helper is faster and more
17+
// reliable; the Docker self-test then only verifies install/boot.
18+
19+
import {
20+
existsSync,
21+
mkdirSync,
22+
readdirSync,
23+
statSync,
24+
symlinkSync,
25+
} from "fs";
26+
import { join } from "path";
27+
import { homedir } from "os";
28+
29+
export interface PrepareGrokIsolatedCwdInput {
30+
/** User's project cwd (typically `process.cwd()`). */
31+
userCwd: string;
32+
/** Per-node key — used to construct the isolated dir. Prefers node_id; falls back to alias. */
33+
nodeId?: string;
34+
alias?: string;
35+
/** Override for tests so we don't always touch `~/.anet/`. Defaults to `homedir()`. */
36+
home?: string;
37+
/** Optional logger for symlink failures (test can capture). Defaults to silent. */
38+
onWarn?: (msg: string) => void;
39+
}
40+
41+
export interface PrepareGrokIsolatedCwdResult {
42+
/** The cwd to pass into ACP `session/new`. Falls back to `userCwd` on failure. */
43+
cwd: string;
44+
/** True when isolated dir was successfully prepared. */
45+
isolated: boolean;
46+
/** Number of entries symlinked this call (excludes already-existing). */
47+
symlinked: number;
48+
/** Number of entries skipped (only `.mcp.json` today). */
49+
skipped: number;
50+
/** Failure message if `isolated === false`. */
51+
error?: string;
52+
}
53+
54+
/**
55+
* Idempotent: re-running with the same userCwd/nodeId is safe and cheap.
56+
* Existing symlinks are left as-is; we never overwrite or unlink.
57+
*
58+
* Per-entry try/catch ensures a single bad symlink (permission denied,
59+
* cross-device, etc.) doesn't break the whole loop — we warn and move on.
60+
*
61+
* Top-level mirroring only (no recursion). Mid-session new files in the
62+
* user cwd are NOT picked up until the next `prepare` call — acceptable
63+
* trade-off for #204 P0 (P3 follow-up: fs.watch + relink).
64+
*/
65+
export function prepareGrokIsolatedCwd(
66+
opts: PrepareGrokIsolatedCwdInput,
67+
): PrepareGrokIsolatedCwdResult {
68+
const userCwd = opts.userCwd;
69+
const home = opts.home ?? homedir();
70+
const warn = opts.onWarn ?? (() => {});
71+
const rawKey = opts.nodeId || opts.alias || "default";
72+
const nodeKey = rawKey.replace(/[^A-Za-z0-9._-]/g, "_");
73+
const isolatedCwd = join(home, ".anet", "nodes", nodeKey, "grok-cwd");
74+
75+
try {
76+
mkdirSync(isolatedCwd, { recursive: true });
77+
} catch (e: any) {
78+
return {
79+
cwd: userCwd,
80+
isolated: false,
81+
symlinked: 0,
82+
skipped: 0,
83+
error: `mkdir failed: ${e?.message || e}`,
84+
};
85+
}
86+
87+
let entries: string[];
88+
try {
89+
entries = readdirSync(userCwd);
90+
} catch (e: any) {
91+
return {
92+
cwd: userCwd,
93+
isolated: false,
94+
symlinked: 0,
95+
skipped: 0,
96+
error: `readdir userCwd failed: ${e?.message || e}`,
97+
};
98+
}
99+
100+
let symlinked = 0;
101+
let skipped = 0;
102+
for (const entry of entries) {
103+
if (entry === ".mcp.json") {
104+
skipped++;
105+
continue;
106+
}
107+
const src = join(userCwd, entry);
108+
const dst = join(isolatedCwd, entry);
109+
if (existsSync(dst)) continue; // idempotent — restart-safe
110+
try {
111+
const st = statSync(src);
112+
symlinkSync(src, dst, st.isDirectory() ? "dir" : "file");
113+
symlinked++;
114+
} catch (e: any) {
115+
warn(`#204 skip symlink ${entry}: ${e?.message || e}`);
116+
}
117+
}
118+
119+
return { cwd: isolatedCwd, isolated: true, symlinked, skipped };
120+
}

0 commit comments

Comments
 (0)