import { fixtureCacheEnv } from '../../../scripts/testing/lane-fixtures.mjs'; // The host's claude CLI through adapters/claude with the bundle's hook and // MCP server, against the scripted Messages API. Scratch CLAUDE_CONFIG_DIR // and HOME, a dummy key, nonessential traffic off: nothing reaches a real // model or the user's Claude configuration. Skipped when claude isn't on PATH, // except the argv check, which uses a stand-in claude. import { test } from "node:test"; import { createServer as createHttp } from "node:http"; import assert from "node:assert/strict"; import { spawn, spawnSync } from "node:child_process"; import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { basename, dirname, join } from "node:path"; import { buildBundle } from "../src/bundle.mjs"; import { adapterEnv, unslopCounts, unslopLog, unslopNotes } from "../src/runner.mjs"; import { fakeToolSocket, HARNESS, mockAnthropic, REPO, REPO_SKILLS, resolvedFor, scratch } from "./helpers.mjs"; const ADAPTER = join(REPO, "adapters", "claude", "adapter.sh"); const which = spawnSync("sh", ["-c", "command -v claude"], { encoding: "utf8" }); const CLAUDE = which.status === 0 ? which.stdout.trim() : null; const skip = CLAUDE ? false : "claude is not on PATH"; // Reconstruct the pre-stream text invocation, including its success-only // session persistence, without requiring the private helper handshake. function textAdapterSource() { const source = readFileSync(ADAPTER, 'utf8'); const args = source.match(/node "\$STREAM" "\$CLAUDE"([\s\S]*?)"\$SESSION_FLAG" "\$SESSION_ID"/); assert.ok(args, 'stream CLI invocation found'); return source.slice(0, source.indexOf('# Completion travels')) + '"$CLAUDE"' + args[1].replace('--output-format stream-json --verbose --include-partial-messages', '--output-format text') + '"$SESSION_FLAG" "$SESSION_ID" || exit $?\n' + "[ \"$SESSION_FLAG\" = --resume ] || printf '%s\\n' \"$SESSION_ID\" > \"$ID_FILE\"\n"; } async function session(t, script, tools = ["read", "bash"], done = (r) => `ANSWER ${JSON.stringify(r.map((x) => [x.isError, x.content.slice(0, 600)]))}`) { const dir = scratch(t); const workspace = join(dir, "ws"); mkdirSync(workspace); writeFileSync(join(workspace, "notes.txt"), "inside\n"); writeFileSync(join(dir, "secret.txt"), "outside\n"); if (typeof script === "function") script = script(workspace); const api = await mockAnthropic(t, { script, done }); const resolved = resolvedFor(t, "coder", (d) => { d.roles.coder.vars = { harness: "claude-code", model: "claude-sonnet-5-5", "limits.tools": tools }; }); const sock = await fakeToolSocket(t, dir, (tool, args) => { if (tool === "raise_decision") throw new Error("not-allowed"); return { tool, args }; }); const s = { harness: "claude-code", provider: null, model: "claude-sonnet-5-5", workspace, sessionDir: join(dir, "session"), toolSocket: sock.path, turnMarker: join(dir, "turn.done"), // Row 66: the run directory, so the unslop hooks log to /unslop.jsonl, which the // runner makes before the first turn. runDir: dir, }; const { files, manifest, env: harnessEnv } = buildBundle({ dir: join(dir, "bundle"), resolved, contract: "# Coder\n", harness: HARNESS["claude-code"], skillsDir: REPO_SKILLS, session: { ...s, run: "run-1", launchedBy: "pm" } }); s.bundle = files; s.harnessEnv = harnessEnv; const env = adapterEnv(s, { PATH: `${dirname(CLAUDE)}:${dirname(process.execPath)}:/usr/bin:/bin`, HOME: join(dir, "home"), CLAUDE_CONFIG_DIR: join(dir, "claude-config"), ANTHROPIC_BASE_URL: api.url, ANTHROPIC_API_KEY: "probe-dummy", CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1", DISABLE_TELEMETRY: "1", DISABLE_AUTOUPDATER: "1", DISABLE_ERROR_REPORTING: "1", }); return { dir, workspace, api, sock, s, env, manifest }; } function turn(env, request, cwd, adapter = ADAPTER) { return new Promise((resolve) => { const child = spawn("/bin/sh", [adapter], { cwd, env: { ...env, ...fixtureCacheEnv(env.CLAUDE_CONFIG_DIR), MOSAIC_REQUEST: request }, stdio: ["ignore", "pipe", "pipe"], detached: true }); const chunks = []; let stderr = ""; child.stdout.on("data", (b) => chunks.push(b)); child.stderr.on("data", (b) => (stderr += b)); const clock = setTimeout(() => process.kill(-child.pid, "SIGKILL"), 120_000); child.on("close", (code) => { clearTimeout(clock); const stdoutBytes = Buffer.concat(chunks); resolve({ code, stdout: stdoutBytes.toString("utf8"), stdoutBytes, stderr }); }); }); } test("claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist", { skip }, async (t) => { const { workspace, api, sock, s, env, manifest } = await session(t, (workspace) => [ { name: "mcp__mosaic__list_agents", input: {} }, { name: "mcp__mosaic__raise_decision", input: { question: "q", options: ["a", "b"] } }, { name: "Read", input: { file_path: join(workspace, "notes.txt") } }, { name: "Read", input: { file_path: join(dirname(workspace), "secret.txt") } }, { name: "Write", input: { file_path: join(workspace, "new.txt"), content: "x" } }, { name: "Bash", input: { command: "echo ran > bash.txt", description: "probe" } }, ]); const r = await turn(env, "Message 1 from pm, class ASSIGNMENT:\n\ngo", s.workspace); assert.equal(r.code, 0, r.stderr); const answer = r.stdout.trim(); assert.match(answer, /^ANSWER /, r.stderr); const results = JSON.parse(answer.slice("ANSWER ".length)); assert.equal(results.length, 6, answer); assert.equal(results[0][0], false, answer); assert.match(results[0][1], /list_agents/); assert.equal(results[1][0], true, answer); assert.match(results[1][1], /refused: not-allowed/); assert.equal(results[2][0], false, answer); assert.match(results[2][1], /inside/); // Outside the workspace: --restricted or the hook stops it; either way it doesn't run. assert.equal(results[3][0], true, answer); assert.doesNotMatch(results[3][1], /outside\n/); // Write isn't in --tools. assert.equal(results[4][0], true, answer); assert.ok(!existsSync(join(workspace, "new.txt"))); // Bash is allowed and runs in the workspace. assert.equal(results[5][0], false, answer); assert.equal(readFileSync(join(workspace, "bash.txt"), "utf8"), "ran\n"); assert.deepEqual(sock.calls.map((c) => c.tool), ["list_agents", "raise_decision"]); const offered = api.requests.find((x) => x.tools.length)?.tools ?? []; assert.ok(offered.includes("mcp__mosaic__list_agents"), offered.join(",")); assert.ok(!offered.includes("Write") && !offered.includes("WebFetch"), offered.join(",")); assert.deepEqual(manifest.builtins, ["Read", "Bash"]); // The first turn kept its session id for the next one. assert.match(readFileSync(join(s.sessionDir, "claude-session-id"), "utf8"), /^[0-9a-f-]{36}\n$/); }); test("claude: the hook alone blocks a path outside the workspace", { skip }, async (t) => { // Bash can reach anything the OS user can (S0 line 5); the gate stops the // Read tool on its own path check, which is what this pins. const { workspace, api, s, env } = await session(t, [ { name: "Read", input: { file_path: "/etc/hostname" } }, ], ["read"]); const r = await turn(env, "x", workspace); assert.equal(r.code, 0, r.stderr); const results = JSON.parse(r.stdout.trim().slice("ANSWER ".length)); assert.equal(results[0][0], true); assert.match(results[0][1], /mosaic gate: .*outside the workspace/); assert.ok(api.requests.length > 0); assert.ok(s.bundle.settings); }); test("claude: a second turn resumes the first turn's session", { skip }, async (t) => { const { workspace, s, env } = await session(t, []); const first = await turn(env, "one", workspace); assert.equal(first.code, 0, first.stderr); const id = readFileSync(join(s.sessionDir, "claude-session-id"), "utf8"); const second = await turn(env, "two", workspace); assert.equal(second.code, 0, second.stderr); assert.equal(readFileSync(join(s.sessionDir, "claude-session-id"), "utf8"), id); }); for (const answer of ['Answer', ' Answer é 🦆\nsecond line\n', 'Answer\n\n']) { test(`claude: streaming preserves the real CLI text answer byte for byte (${JSON.stringify(answer)})`, { skip }, async t => { const { dir, workspace, env } = await session(t, [], [], () => answer); const textAdapter = join(dir, 'text-adapter.sh'); writeFileSync(textAdapter, textAdapterSource()); const text = await turn({ ...env, MOSAIC_SESSION_DIR: join(dir, 'text-session') }, 'answer', workspace, textAdapter); const stream = await turn(env, 'answer', workspace); assert.equal(text.code, 0, text.stderr); assert.equal(stream.code, 0, stream.stderr); assert.equal(stream.stdout, text.stdout); assert.ok(stream.stdout.includes(answer)); }); } test("claude: HTTP 400 keeps real text-mode stdout and exit status without model activity or a new session", { skip }, async t => { const { dir, workspace, env } = await session(t, [], []); let requests = 0; const api = createHttp((req, res) => { req.resume(); req.on('end', () => { if (!/\/messages(\?|$)/.test(req.url)) { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ input_tokens: 1 })); } requests++; res.writeHead(400, { 'content-type': 'application/json' }); res.end(JSON.stringify({ type: 'error', error: { type: 'invalid_request_error', message: 'review-local-api-error' } })); }); }); await new Promise(resolve => api.listen(0, '127.0.0.1', resolve)); t.after(() => api.close()); const textAdapter = join(dir, 'text-adapter.sh'); writeFileSync(textAdapter, textAdapterSource()); const results = []; for (const [name, adapter] of [['text', textAdapter], ['stream', ADAPTER]]) { const runDir = join(dir, name); mkdirSync(runDir); const result = await turn({ ...env, HOME: join(runDir, 'home'), CLAUDE_CONFIG_DIR: join(runDir, 'config'), ANTHROPIC_BASE_URL: `http://127.0.0.1:${api.address().port}`, MOSAIC_SESSION_DIR: join(runDir, 'session'), MOSAIC_MODEL_ACTIVITY_DIR: runDir, }, 'answer', workspace, adapter); results.push(result); assert.equal(result.code, 1, `${name}: ${result.stderr}`); assert.equal(existsSync(join(runDir, 'session/claude-session-id')), false, name); assert.equal(existsSync(join(runDir, 'proxy-first-output')), false, name); } const [text, stream] = results; assert.ok(requests >= 2, 'both real CLI invocations reached only the local scripted API'); assert.deepEqual(text.stdoutBytes, Buffer.from('API Error: 400 review-local-api-error\n')); assert.deepEqual(stream.stdoutBytes, text.stdoutBytes); assert.equal(stream.code, text.code); assert.equal(text.stderr, ''); assert.equal(stream.stderr, ''); }); // --restricted is what keeps CLAUDE.md files and auto-memory out of the // session's prompt (README "Limits"). This one runs without claude: a // stand-in records the adapter's argv. test("claude adapter: --restricted is always passed", (t) => { const dir = scratch(t); mkdirSync(join(dir, "bin")); writeFileSync(join(dir, "bin", "claude"), '#!/bin/sh\nprintf "%s\\n" "$@" > "$ARGV_OUT"\necho \'{"type":"result","subtype":"success","result":"ok"}\'\n'); chmodSync(join(dir, "bin", "claude"), 0o755); for (const f of ["prompt.md", "settings.json", "mcp.json"]) writeFileSync(join(dir, f), "{}"); const r = spawnSync("/bin/sh", [ADAPTER], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], env: { PATH: `${join(dir, "bin")}:/usr/bin:/bin`, ARGV_OUT: join(dir, "argv"), MOSAIC_SYSTEM_PROMPT_FILE: join(dir, "prompt.md"), MOSAIC_REQUEST: "x", MOSAIC_WORKSPACE: join(dir, "ws"), MOSAIC_SESSION_DIR: join(dir, "session"), MOSAIC_MODEL: "claude-sonnet-5-5", MOSAIC_THINKING: "high", MOSAIC_CLAUDE_SETTINGS: join(dir, "settings.json"), MOSAIC_CLAUDE_MCP_CONFIG: join(dir, "mcp.json"), }, }); assert.equal(r.status, 0, r.stderr); const argv = readFileSync(join(dir, "argv"), "utf8").split("\n"); assert.ok(argv.includes("--restricted"), argv.join(" ")); assert.ok(!argv.includes("--bare"), argv.join(" ")); }); // Row 68: MOSAIC_CLAUDE_BIN, when set, is the CLI the adapter runs, ahead // of any claude on PATH; a relative or missing one exits 2 before it runs. test("claude adapter: MOSAIC_CLAUDE_BIN wins over PATH and must be an absolute executable", (t) => { const dir = scratch(t); mkdirSync(join(dir, "bin")); const stand = (p, word) => { writeFileSync(p, `#!/bin/sh\necho ${word} > "$WHO_OUT"\necho \'{"type":"result","subtype":"success","result":"ok"}\'\n`); chmodSync(p, 0o755); return p; }; stand(join(dir, "bin", "claude"), "path"); const pinned = stand(join(dir, "pinned-claude"), "pinned"); for (const f of ["prompt.md", "settings.json", "mcp.json"]) writeFileSync(join(dir, f), "{}"); const run = (bin) => spawnSync("/bin/sh", [ADAPTER], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], env: { PATH: `${join(dir, "bin")}:/usr/bin:/bin`, WHO_OUT: join(dir, "who"), MOSAIC_SYSTEM_PROMPT_FILE: join(dir, "prompt.md"), MOSAIC_REQUEST: "x", MOSAIC_WORKSPACE: join(dir, "ws"), MOSAIC_SESSION_DIR: join(dir, "session"), MOSAIC_MODEL: "claude-sonnet-5-5", MOSAIC_THINKING: "high", MOSAIC_CLAUDE_SETTINGS: join(dir, "settings.json"), MOSAIC_CLAUDE_MCP_CONFIG: join(dir, "mcp.json"), ...(bin === undefined ? {} : { MOSAIC_CLAUDE_BIN: bin }), }, }); const who = () => readFileSync(join(dir, "who"), "utf8").trim(); let r = run(pinned); assert.equal(r.status, 0, r.stderr); assert.equal(who(), "pinned"); r = run(undefined); assert.equal(r.status, 0, r.stderr); assert.equal(who(), "path"); r = run(""); assert.equal(r.status, 0, r.stderr); assert.equal(who(), "path"); for (const [bin, why] of [["pinned-claude", /must be an absolute path/], [join(dir, "missing"), /not an executable file/], [join(dir, "prompt.md"), /not an executable file/]]) { rmSync(join(dir, "who"), { force: true }); r = run(bin); assert.equal(r.status, 2, bin); assert.match(r.stderr, why, bin); assert.equal(existsSync(join(dir, "who")), false, bin); } }); // Row 72 (#1566): a directory pin exits 2 with the adapter's own message, // not 126 at exec; a pin under a directory whose name has a space runs, // which needs "$CLAUDE" quoted. test("claude adapter: a directory pin exits 2 before it runs; a pin with a space runs", (t) => { const dir = scratch(t); mkdirSync(join(dir, "bin")); const stand = (p, word) => { writeFileSync(p, `#!/bin/sh\necho ${word} > "$WHO_OUT"\necho \'{"type":"result","subtype":"success","result":"ok"}\'\n`); chmodSync(p, 0o755); return p; }; stand(join(dir, "bin", "claude"), "path"); for (const f of ["prompt.md", "settings.json", "mcp.json"]) writeFileSync(join(dir, f), "{}"); const run = (bin) => spawnSync("/bin/sh", [ADAPTER], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], env: { PATH: `${join(dir, "bin")}:/usr/bin:/bin`, WHO_OUT: join(dir, "who"), MOSAIC_SYSTEM_PROMPT_FILE: join(dir, "prompt.md"), MOSAIC_REQUEST: "x", MOSAIC_WORKSPACE: join(dir, "ws"), MOSAIC_SESSION_DIR: join(dir, "session"), MOSAIC_MODEL: "claude-sonnet-5-5", MOSAIC_THINKING: "high", MOSAIC_CLAUDE_SETTINGS: join(dir, "settings.json"), MOSAIC_CLAUDE_MCP_CONFIG: join(dir, "mcp.json"), MOSAIC_CLAUDE_BIN: bin, }, }); const pinDir = join(dir, "claude-dir"); mkdirSync(pinDir); chmodSync(pinDir, 0o755); let r = run(pinDir); assert.equal(r.status, 2, r.stderr); assert.match(r.stderr, /MOSAIC_CLAUDE_BIN is not an executable file/); assert.equal(existsSync(join(dir, "who")), false); mkdirSync(join(dir, "Claude Code")); const spaced = stand(join(dir, "Claude Code", "claude"), "spaced"); r = run(spaced); assert.equal(r.status, 0, r.stderr); assert.equal(readFileSync(join(dir, "who"), "utf8").trim(), "spaced"); }); test("claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do", { skip }, async (t) => { const { dir, workspace, env } = await session(t, []); const slug = realpathSync(workspace).replace(/[^A-Za-z0-9]/g, "-"); const plant = { "MARKER-USER": [join(env.HOME, ".claude", "CLAUDE.md"), join(env.CLAUDE_CONFIG_DIR, "CLAUDE.md")], "MARKER-PARENT": [join(dir, "CLAUDE.md")], "MARKER-WS": [join(workspace, "CLAUDE.md")], "MARKER-MEMORY": [join(env.HOME, ".claude", "projects", slug, "memory", "MEMORY.md"), join(env.CLAUDE_CONFIG_DIR, "projects", slug, "memory", "MEMORY.md")], }; for (const [marker, files] of Object.entries(plant)) { for (const f of files) { mkdirSync(dirname(f), { recursive: true }); writeFileSync(f, `${marker}\n`); } } const seen = async (adapter) => { const api = await mockAnthropic(t, {}); const r = await turn({ ...env, ANTHROPIC_BASE_URL: api.url, MOSAIC_SESSION_DIR: join(dir, `session-${basename(adapter)}`) }, "x", workspace, adapter); assert.equal(r.code, 0, r.stderr); const all = api.requests.map((x) => x.raw).join("\n"); assert.match(all, /## This session/); return Object.keys(plant).filter((m) => all.includes(m)); }; assert.deepEqual(await seen(ADAPTER), []); // The control: the same adapter without --restricted lets all four in. const loose = join(dir, "adapter-loose.sh"); symlinkSync(join(REPO, "adapters/claude/stream.mjs"), join(dir, "stream.mjs")); writeFileSync(loose, readFileSync(ADAPTER, "utf8").replace(" --restricted \\\n", "")); assert.notEqual(readFileSync(loose, "utf8"), readFileSync(ADAPTER, "utf8")); assert.deepEqual(await seen(loose), Object.keys(plant)); }); test("claude: a missing hook or MCP file refuses before claude starts", { skip }, async (t) => { const { dir, api, workspace, env } = await session(t, []); for (const k of ["MOSAIC_CLAUDE_SETTINGS", "MOSAIC_CLAUDE_MCP_CONFIG", "MOSAIC_SYSTEM_PROMPT_FILE"]) { const r = await turn({ ...env, [k]: join(dir, "missing.json") }, "x", workspace); assert.equal(r.code, 2, k); assert.match(r.stderr, /not readable/); } assert.equal(api.requests.length, 0); }); // Row 66 (#1559): the unslop check's Stop and UserPromptSubmit hooks. The scripted model's answers // are the session's replies; the requests show what the model was sent on the next turn. const TELL = "Let me delve into the build. It is a pivotal step."; const replying = (answers) => { let n = 0; return () => answers[n++] ?? "Done."; }; const notices = (api) => api.requests.filter((r) => r.tools.length).map((r) => r.raw.split("UNSLOP NOTICE").length - 1); test("claude: row 66 a reply with a tell gets the correction notice on the next turn, a clean reply gets none, and the counts read after", { skip }, async (t) => { const { dir, api, s, env } = await session(t, [], undefined, replying([TELL, "The build is green.", TELL, "Done."])); assert.equal(env.MOSAIC_UNSLOP_LOG, unslopLog(dir)); const notes = unslopNotes(unslopLog(dir)); for (const [i, request] of ["status?", "and now?", "and the tests?", "thanks"].entries()) { const r = await turn(env, `Message ${i + 1} from pm, class REQUEST:\n\n${request}`, s.workspace); assert.equal(r.code, 0, r.stderr); notes.read(); // The runner's note for the operator: on each turn whose reply had the tell, and on no other. assert.equal(/1 reply of this session flagged since the last note: banned word "delve" x1; banned word "pivotal" x1\./.test(notes.note()), i % 2 === 0, `turn ${i + 1}: ${notes.note()}\n${readFileSync(unslopLog(dir), "utf8")}`); notes.sent(); } // The request after each tell carries a notice; the one after a clean reply has no new one, and // a tell after a clean reply is still caught. assert.deepEqual(notices(api), [0, 1, 1, 2]); const second = api.requests.filter((r) => r.tools.length)[1].raw; assert.match(second, /UNSLOP NOTICE \(mechanical style check, not the user speaking\)[^"]*banned word \\"delve\\" x1/); const counts = unslopCounts(unslopLog(dir)); assert.deepEqual({ ...counts, patterns: Object.keys(counts.patterns).length > 0 }, { checked: 4, flagged: 2, patterns: true, notices: 2, broken: null }); }); test("claude: row 66 a broken lists.json gives no clean verdict, the operator is told once, and the session still answers", { skip }, async (t) => { const { dir, api, s, env } = await session(t, [], undefined, replying(["The build is green.", TELL])); writeFileSync(join(dir, "empty-lists.json"), ""); const notes = unslopNotes(unslopLog(dir)); const told = []; for (const [i, request] of ["status?", "and now?"].entries()) { const r = await turn({ ...env, UNSLOP_LISTS: join(dir, "empty-lists.json") }, `Message ${i + 1} from pm, class REQUEST:\n\n${request}`, s.workspace); assert.equal(r.code, 0, r.stderr); assert.ok(r.stdout.trim().length > 0, "the session answered"); notes.read(); told.push(notes.note()); notes.sent(); } assert.match(told[0], /^\n\n\[unslop: the check is broken, so this session's replies go unchecked: lists\.json invalid: empty file/); assert.equal(told[1], ""); assert.deepEqual(notices(api), [0, 0]); const counts = unslopCounts(unslopLog(dir)); assert.equal(counts.checked, 0); assert.equal(counts.flagged, 0); assert.match(counts.broken, /^lists\.json invalid: empty file/); });