MOSAIC_CLAUDE_BIN names the claude binary by absolute path instead of finding it on PATH. scripts/bus-service.sh render|install --claude PATH writes it into the unit (default: command -v claude in the installing shell); the claude adapter, the launcher's version read and the seat session environment all use it. Mos's Q18 ask: the unit's PATH found /usr/bin/claude 2.1.259, which refuses claude-opus-5-5. Candidate: round 2 manifest 21c1ab05, round 1 (a2008d8b) rebased ontof2f2861bafter row 55 changed docs/TOOLS.md and packages/cli/README.md; the changed lines are byte-identical. Approved by Filbert (27349, 27374) and Dewey (27350, 27375). Sage's gate onf2f2861bplus the patch: cli 112, seat 27, harness 57, nine scripts/test-*.sh 0 failed, Docker on. Co-Authored-By: Claude Opus 5.5 <[email protected]>
258 lines
12 KiB
JavaScript
258 lines
12 KiB
JavaScript
// 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 assert from "node:assert/strict";
|
|
import { spawn, spawnSync } from "node:child_process";
|
|
import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
import { basename, dirname, join } from "node:path";
|
|
import { buildBundle } from "../src/bundle.mjs";
|
|
import { adapterEnv } from "../src/runner.mjs";
|
|
import { fakeToolSocket, mockAnthropic, REPO, 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";
|
|
|
|
async function session(t, script, tools = ["read", "bash"]) {
|
|
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: (r) => `ANSWER ${JSON.stringify(r.map((x) => [x.isError, x.content.slice(0, 600)]))}` });
|
|
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"),
|
|
};
|
|
const { files, manifest } = buildBundle({ dir: join(dir, "bundle"), resolved, contract: "# Coder\n", session: { ...s, run: "run-1", launchedBy: "pm" } });
|
|
s.bundle = files;
|
|
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, MOSAIC_REQUEST: request }, stdio: ["ignore", "pipe", "pipe"], detached: true });
|
|
let stdout = "";
|
|
let stderr = "";
|
|
child.stdout.on("data", (b) => (stdout += b));
|
|
child.stderr.on("data", (b) => (stderr += b));
|
|
const clock = setTimeout(() => process.kill(-child.pid, "SIGKILL"), 120_000);
|
|
child.on("close", (code) => {
|
|
clearTimeout(clock);
|
|
resolve({ code, stdout, 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);
|
|
});
|
|
|
|
// --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 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_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 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_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);
|
|
}
|
|
});
|
|
|
|
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(/[/.]/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");
|
|
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);
|
|
});
|