A tester binds with a process proof from the bus's own tester-cli.mjs when the business var bus.tester is true. It may send [mos test] messages to a role, receive replies on the tester address and use the read verbs; every other verb refuses. Messages store from_role tester and write no human.input event. turnRequest() marks them as never Jason's instruction. human.mjs is unchanged. Candidate manifest bdaf7f476abd0b2b5095ab2f7f43e38540e6ba48ee14a02a4f4995a1469b79df (Darkwing round 2, packet9f1563ec1), BASEbadfa52b9. Approved by Filbert (comment 28360) and Sage (comment 28361). Sage gate rerun passed. Co-Authored-By: Claude Opus 5.5 <[email protected]>
334 lines
19 KiB
JavaScript
334 lines
19 KiB
JavaScript
// The sandbox a role session runs in (src/sandbox.mjs, #1586). A real
|
|
// session, the runner in its bwrap sandbox against an in-process broker,
|
|
// answers each message by running the body in /bin/sh; the tests read the
|
|
// answers. Stand-ins for the founder's files sit in the test's own
|
|
// directory, plus a file in the host's /dev/shm and a SysV shared memory
|
|
// segment, each checked to exist on the host first, so a refusal means the
|
|
// sandbox hid it.
|
|
|
|
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { spawn, spawnSync } from "node:child_process";
|
|
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { basename, dirname, join, resolve } from "node:path";
|
|
import { Broker } from "../../bus/src/broker.mjs";
|
|
import { serve } from "../../bus/src/server.mjs";
|
|
import { verifyTester } from "../../bus/src/tester.mjs";
|
|
import { Store } from "../../bus/src/store.mjs";
|
|
import { SOURCES } from "../../harness/src/bundle.mjs";
|
|
import { startTimeOf } from "../src/proc.mjs";
|
|
import { newRun, RUNNER, runnerOf, sessionEnv, spawnSession, writeSessionFile } from "../src/session.mjs";
|
|
import { binds, gitIdentity, prepareRun, REPO, RUNNER_FILES, sandboxArgv, sandboxProblem } from "../src/sandbox.mjs";
|
|
|
|
const FAKE = new URL("../../harness/tests/fixtures/fake-adapter.mjs", import.meta.url).pathname;
|
|
const TESTER_CLI = new URL("../../bus/src/tester-cli.mjs", import.meta.url).pathname;
|
|
const HUMAN_MJS = new URL("../../bus/src/human.mjs", import.meta.url).pathname;
|
|
const skip = sandboxProblem() ?? false;
|
|
|
|
function scratch(t) {
|
|
const root = realpathSync(mkdtempSync(join(tmpdir(), "mosaic-sandbox-")));
|
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
return root;
|
|
}
|
|
|
|
test("RUNNER_FILES is exactly what the runner and the harness sources it starts import", () => {
|
|
const seen = new Set();
|
|
const walk = (file) => {
|
|
if (seen.has(file)) return;
|
|
seen.add(file);
|
|
for (const [, rel] of readFileSync(file, "utf8").matchAll(/(?:from|import\()\s*["'](\.\.?\/[^"']+)["']/g)) walk(resolve(dirname(file), rel));
|
|
};
|
|
for (const file of [RUNNER, ...Object.values(SOURCES)]) walk(file);
|
|
assert.deepEqual([...seen].sort(), [...RUNNER_FILES].sort());
|
|
});
|
|
|
|
test("sandboxProblem names a missing bwrap and a bwrap that fails", (t) => {
|
|
const root = scratch(t);
|
|
assert.equal(sandboxProblem({ bwrap: join(root, "nope") }), `${join(root, "nope")} is missing`);
|
|
const bad = join(root, "bwrap");
|
|
writeFileSync(bad, "#!/bin/sh\necho 'bwrap: No permissions to create new namespace' >&2\nexit 1\n");
|
|
chmodSync(bad, 0o755);
|
|
assert.equal(sandboxProblem({ bwrap: bad }), `${bad} failed: bwrap: No permissions to create new namespace`);
|
|
if (!skip) assert.equal(sandboxProblem(), null);
|
|
});
|
|
|
|
test("every bind says why; the bundle and session file stay read-only over the run directory", (t) => {
|
|
const root = scratch(t);
|
|
const runDir = join(root, "run");
|
|
const list = binds({ runDir, workspace: join(root, "ws"), adapter: join(root, "a.sh"), harness: "claude-code", brokerSocket: join(root, "bus.sock") });
|
|
assert.ok(list.every((b) => b.why));
|
|
const at = (src) => list.findIndex((b) => b.src === src);
|
|
assert.equal(list[at(runDir)].kind, "rw");
|
|
for (const f of ["bundle", "session.json"]) {
|
|
assert.equal(list[at(join(runDir, f))].kind, "ro");
|
|
assert.ok(at(join(runDir, f)) > at(runDir), `${f} is mounted over the run directory`);
|
|
}
|
|
assert.deepEqual(list.at(-1), { kind: "rw", src: join(runDir, "tmp"), dest: "/tmp", why: list.at(-1).why });
|
|
assert.equal(at(join(root, "launch.sock")), -1, "no launch socket for a role that may not launch");
|
|
assert.equal(at(join(root, "git.sock")), -1, "no git socket for a role that neither pushes nor reviews");
|
|
assert.ok(!list.some((b) => b.src === realpathSync(join(REPO, "node_modules"))), "Claude Code gets no node_modules");
|
|
});
|
|
|
|
// Row 93 (#1587): the git socket is bound read-only, before the workspace, when the launcher passes one.
|
|
test("the host's git socket is bound only when given, read-only and before the writable binds", (t) => {
|
|
const root = scratch(t);
|
|
const args = { runDir: join(root, "run"), workspace: join(root, "ws"), adapter: join(root, "a.sh"), harness: "pi", brokerSocket: join(root, "bus.sock") };
|
|
const list = binds({ ...args, gitSocket: join(root, "git.sock") });
|
|
const at = (src) => list.findIndex((b) => b.src === src);
|
|
assert.equal(list[at(join(root, "git.sock"))].kind, "ro");
|
|
assert.match(list[at(join(root, "git.sock"))].why, /push_working and fetch_review/);
|
|
assert.ok(at(join(root, "git.sock")) < at(join(root, "ws")));
|
|
assert.equal(binds(args).findIndex((b) => b.src === join(root, "git.sock")), -1);
|
|
assert.ok(RUNNER_FILES.includes(join(REPO, "packages/harness/src/git-tools.mjs")));
|
|
});
|
|
|
|
// Row 104 (#1598): the git identity comes from the business and the role
|
|
// instance, both checked as ids, and sandboxArgv sets it for every session.
|
|
test("the git identity is the instance's, at <instance>@<business>.mosaic.invalid, and sandboxArgv refuses without it", (t) => {
|
|
const email = "[email protected]";
|
|
assert.deepEqual(gitIdentity({ business: "acme", role: "coder" }), { GIT_AUTHOR_NAME: "coder", GIT_AUTHOR_EMAIL: email, GIT_COMMITTER_NAME: "coder", GIT_COMMITTER_EMAIL: email });
|
|
for (const [business, role] of [[undefined, "coder"], ["acme", undefined], ["acme", ""], ["Acme", "coder"], ["acme", "coder\nx"], ["acme", "coder>"], ["acme", 7]]) {
|
|
assert.throws(() => gitIdentity({ business, role }), /^Error: git identity: (business|role) .* isn't an id$/, `${business}/${role}`);
|
|
}
|
|
const root = scratch(t);
|
|
const runDir = join(root, "run");
|
|
const list = binds({ runDir, workspace: join(root, "ws"), adapter: join(root, "a.sh"), harness: "pi", brokerSocket: join(root, "bus.sock") });
|
|
const argv = sandboxArgv(list, runDir, ["/bin/true"], { business: "acme", role: "coder" });
|
|
const set = {};
|
|
argv.slice(0, argv.indexOf("--")).forEach((a, i, all) => {
|
|
if (a === "--setenv") set[all[i + 1]] = all[i + 2];
|
|
});
|
|
assert.deepEqual(set, { HOME: join(runDir, "home"), TMPDIR: "/tmp", ...gitIdentity({ business: "acme", role: "coder" }) });
|
|
assert.throws(() => sandboxArgv(list, runDir, ["/bin/true"], {}), /git identity: business undefined isn't an id/);
|
|
});
|
|
|
|
// The founder's files and the other sessions' state, as stand-ins. The
|
|
// /dev/shm file and the SysV segment are the host's, made for this test and
|
|
// removed after it.
|
|
function standIns(t, root, dataRoot) {
|
|
const s = {
|
|
secret: join(root, "home", ".config", "mosaic-dev", "secrets", "stand-in.env"),
|
|
mosaic: join(root, "home", ".mosaic", "fleet", "token"),
|
|
otherWs: join(root, "ws-other"),
|
|
shm: join("/dev/shm", basename(root)),
|
|
};
|
|
writeFileSync(s.shm, "stand-in\n", { mode: 0o600 });
|
|
t.after(() => rmSync(s.shm, { force: true }));
|
|
const ipc = spawnSync("/usr/bin/ipcmk", ["-M", "4096"], { encoding: "utf8" });
|
|
assert.equal(ipc.status, 0, ipc.stderr);
|
|
s.segment = Number(ipc.stdout.trim().split(" ").at(-1));
|
|
t.after(() => spawnSync("/usr/bin/ipcrm", ["-m", String(s.segment)]));
|
|
for (const f of [s.secret, s.mosaic]) {
|
|
mkdirSync(dirname(f), { recursive: true, mode: 0o700 });
|
|
writeFileSync(f, "stand-in\n", { mode: 0o600 });
|
|
}
|
|
mkdirSync(s.otherWs);
|
|
writeFileSync(join(s.otherWs, "notes.md"), "another instance's work\n");
|
|
const other = newRun(dataRoot, "acme");
|
|
writeFileSync(join(other.runDir, "session.json"), "{}\n", { mode: 0o600 });
|
|
s.otherRun = other.runDir;
|
|
return s;
|
|
}
|
|
|
|
// [what, shell command, may the session do it, the output it must give].
|
|
function checks({ s, ws, runDir, run, dataRoot, store }) {
|
|
return [
|
|
["HOME is the run's home/, empty", `printf %s "$HOME:"; ls -A "$HOME"`, true, `${join(runDir, "home")}:`],
|
|
["TMPDIR is the private /tmp", `printf %s "$TMPDIR"`, true, "/tmp"],
|
|
["read a stand-in ~/.config/mosaic-dev/secrets", `cat '${s.secret}'`, false],
|
|
["read a stand-in ~/.mosaic", `cat '${s.mosaic}'`, false],
|
|
["read another instance's workspace", `cat '${s.otherWs}/notes.md'`, false],
|
|
["read another run directory", `cat '${s.otherRun}/session.json'`, false],
|
|
["read the data root's store", `cat '${store}' >/dev/null`, false],
|
|
["the data root holds only this run", `ls -A '${dataRoot}'; ls -A '${dataRoot}/launches/acme'`, true, `launches\n${run}`],
|
|
["read the checkout outside the runner's files", `cat '${join(REPO, "AGENTS.md")}' >/dev/null`, false],
|
|
["read the seat's own source", `cat '${join(REPO, "packages", "seat", "src", "session.mjs")}' >/dev/null`, false],
|
|
["read a stand-in in the host's /dev/shm", `cat '${s.shm}'`, false],
|
|
["see the test's own pid in /proc", `test -e /proc/${process.pid}`, false],
|
|
["read its own IPC namespace's segment list", "test -r /proc/sysvipc/shm", true],
|
|
["see a SysV segment the host made", `awk '$2 == ${s.segment} { f = 1 } END { exit !f }' /proc/sysvipc/shm`, false],
|
|
["write to /", "touch /probe", false],
|
|
["write beside the workspace", `touch '${dirname(ws)}/probe'`, false],
|
|
["write in the data root", `touch '${dataRoot}/probe'`, false],
|
|
["write a runner file", `test -w '${RUNNER}'`, false],
|
|
["write the run's bundle", `test -w '${runDir}/bundle/prompt.md' || touch '${runDir}/bundle/probe'`, false],
|
|
["write the session file", `test -w '${runDir}/session.json'`, false],
|
|
["write and read its workspace", `echo ws > '${ws}/probe' && cat '${ws}/probe'`, true, "ws"],
|
|
["write and read its run directory", `echo run > '${runDir}/probe' && cat '${runDir}/probe'`, true, "run"],
|
|
["write and read /tmp", "echo tmp > /tmp/probe && cat /tmp/probe", true, "tmp"],
|
|
["run node", `'${process.execPath}' -e 'console.log(6 * 7)'`, true, "42"],
|
|
["run pi, the session's harness", `'${join(REPO, "node_modules", ".bin", "pi")}' --version >/dev/null`, true],
|
|
// Row 104 (#1598): a commit in a clone with no user config carries the
|
|
// instance's identity from the environment, and the clone's config
|
|
// gains no user key.
|
|
["commit in a fresh clone as the instance", `d=$(mktemp -d) && cd "$d" && git init -q o && git -C o commit --allow-empty -q -m base && git clone -q o c && cd c && echo x > f && git add f && git commit -q -m m && ! git config --file .git/config --get-regexp '^user\\.' && git log -1 --format='%an <%ae>|%cn <%ce>'`, true, "coder <[email protected]>|coder <[email protected]>"],
|
|
];
|
|
}
|
|
|
|
// The checks whose answer isn't the expected one, by name.
|
|
async function wrong(table, sh) {
|
|
const out = [];
|
|
for (const [what, cmd, may, expect] of table) {
|
|
const r = await sh(cmd);
|
|
if ((r.rc === 0) !== may || (may && expect !== undefined && r.out !== expect)) out.push(what);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// A real session for coder, its adapter a shell that runs each message's
|
|
// body, or the fake adapter for a TOOL message.
|
|
// expose(standIns) names extra read-only binds, for the test that breaks
|
|
// the sandbox on purpose. tester (row 110) turns bus.tester on, serves the
|
|
// tester proof as the bus runtime does, and binds tester-cli.mjs in.
|
|
async function session(t, { expose = () => [], tester = false } = {}) {
|
|
const root = scratch(t);
|
|
const dataRoot = join(root, "data");
|
|
mkdirSync(dataRoot, { mode: 0o700 });
|
|
const store = new Store(dataRoot);
|
|
const broker = new Broker({
|
|
store,
|
|
businesses: {
|
|
acme: {
|
|
id: "acme",
|
|
human: "jason",
|
|
arbiters: { technical: "pm", delivery: "pm" },
|
|
...(tester ? { vars: { "bus.tester": true } } : {}),
|
|
roles: {
|
|
pm: { authority: { withinRole: ["message.send"], crossRole: [] } },
|
|
coder: { authority: { withinRole: ["message.send"], crossRole: [] } },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
const brokerSocket = join(root, "bus.sock");
|
|
const launches = [];
|
|
const authenticateTester = (proof) => broker.bindTester(verifyTester(proof, { cliPath: TESTER_CLI, launches }));
|
|
const server = await serve({ broker, path: brokerSocket, ...(tester ? { authenticateTester } : {}) });
|
|
t.after(async () => {
|
|
await server.close().catch(() => {});
|
|
store.close();
|
|
});
|
|
|
|
const { run, runDir } = newRun(dataRoot, "acme");
|
|
const s = standIns(t, root, dataRoot);
|
|
const bundle = join(runDir, "bundle");
|
|
const ws = join(root, "ws");
|
|
mkdirSync(bundle, { mode: 0o700 });
|
|
mkdirSync(ws);
|
|
writeFileSync(join(bundle, "prompt.md"), "# test\n");
|
|
writeFileSync(join(bundle, "policy.json"), JSON.stringify({ harness: "pi", workspace: ws, tools: [], typed: [], network: "none", needs: [], credentials: [] }));
|
|
writeFileSync(join(bundle, "tools.json"), "[]");
|
|
const adapter = join(root, "adapter.sh");
|
|
writeFileSync(adapter, [
|
|
// Row 109 (#1603): a state block opens the request, so the body is what follows the "Message ...:" header and its blank line.
|
|
`body=$(printf '%s\\n' "$MOSAIC_REQUEST" | sed '1,/^Message .*:$/d' | sed '1d')`,
|
|
`case $body in TOOL\\ *) exec '${process.execPath}' '${FAKE}' ;; esac`,
|
|
`out=$(cd "$MOSAIC_WORKSPACE" && /bin/sh -c "$body" 2>&1); rc=$?`,
|
|
`printf 'rc=%s\\n%s' "$rc" "$out"`,
|
|
`echo done > "$MOSAIC_TURN_MARKER"`,
|
|
"",
|
|
].join("\n"), { mode: 0o644 });
|
|
writeSessionFile(runDir, {
|
|
business: "acme", instance: "coder", run, launchedBy: "pm", harness: "pi", provider: "fake", model: "fake-model",
|
|
brokerSocket, launchSocket: join(root, "launch.sock"), workspace: ws, sessionDir: join(runDir, "session"), adapter,
|
|
bundle: { prompt: join(bundle, "prompt.md"), policy: join(bundle, "policy.json"), tools: join(bundle, "tools.json") },
|
|
toolSocket: join(runDir, "tools.sock"), turnMarker: join(runDir, "turn.done"), pollInterval: 50,
|
|
});
|
|
|
|
const paths = { workspace: ws, adapter, harness: "pi", brokerSocket, readOnly: [FAKE, ...(tester ? [TESTER_CLI, HUMAN_MJS] : []), ...expose(s)] };
|
|
const child = spawnSession({ runDir, env: sessionEnv({ HOME: process.env.HOME, PATH: process.env.PATH }, run), paths, business: "acme", role: "coder" });
|
|
const exited = new Promise((r) => child.once("close", (code, signal) => r({ code, signal })));
|
|
t.after(async () => {
|
|
try {
|
|
process.kill(child.pid, "SIGTERM");
|
|
} catch {}
|
|
await exited;
|
|
});
|
|
await runnerOf(child);
|
|
const cap = broker.bindLaunch({ business: "acme", role: "coder", run, harness: "pi", pid: child.pid, startTime: startTimeOf(child.pid) });
|
|
launches.push({ pid: child.pid, startTime: startTimeOf(child.pid) });
|
|
const pm = broker.bindLaunch({ business: "acme", role: "pm", run: "pm-run", harness: "pi" });
|
|
broker.request(pm, { verb: "role.claim" });
|
|
child.stdin.end(`${JSON.stringify({ cap })}\n`);
|
|
|
|
const ask = async (body) => {
|
|
const sent = broker.request(pm, { verb: "message.send", args: { to: "coder", body, class: "REQUEST" } });
|
|
const end = Date.now() + 15000;
|
|
while (Date.now() < end) {
|
|
const reply = broker.request(pm, { verb: "message.receive" }).find((m) => m.in_reply_to === sent.id);
|
|
if (reply) return reply.body;
|
|
await new Promise((r) => setTimeout(r, 25));
|
|
}
|
|
throw new Error(`no answer to ${body}`);
|
|
};
|
|
const sh = async (cmd) => {
|
|
const m = (await ask(cmd)).match(/^rc=(\d+)(?:\n([\s\S]*))?$/);
|
|
assert.ok(m, `the session's shell answered ${cmd}`);
|
|
return { rc: Number(m[1]), out: m[2] ?? "" };
|
|
};
|
|
const facts = { s, ws, runDir, run, dataRoot, store: store.path, root, adapter, brokerSocket };
|
|
for (const f of [s.secret, s.mosaic, s.shm, join(s.otherWs, "notes.md"), join(s.otherRun, "session.json"), store.path, join(REPO, "AGENTS.md"), `/proc/${process.pid}`]) {
|
|
assert.ok(existsSync(f), `${f} exists on the host`);
|
|
}
|
|
const listed = spawnSync("awk", [`$2 == ${s.segment} { f = 1 } END { exit !f }`, "/proc/sysvipc/shm"]);
|
|
assert.equal(listed.status, 0, `segment ${s.segment} is listed on the host`);
|
|
return { ...facts, ask, sh };
|
|
}
|
|
|
|
test("a confined session's shell reaches its own workspace and run directory and nothing of the founder's or another session's", { skip, timeout: 60000 }, async (t) => {
|
|
const x = await session(t);
|
|
assert.deepEqual(await wrong(checks(x), x.sh), []);
|
|
// The private /tmp is the run's tmp/ on the host, not the host's /tmp.
|
|
assert.equal(readFileSync(join(x.runDir, "tmp", "probe"), "utf8"), "tmp\n");
|
|
assert.equal(readFileSync(join(x.ws, "probe"), "utf8"), "ws\n");
|
|
// The harness reaches its tool socket: the refusal is the tool server's,
|
|
// not a failed connect.
|
|
assert.equal(await x.ask("TOOL no_such_tool {}"), "refused: unknown-tool");
|
|
});
|
|
|
|
test("a bind that exposes a stand-in, or a missing workspace bind, fails the matching check", { skip, timeout: 60000 }, async (t) => {
|
|
const x = await session(t, { expose: (s) => [s.secret, s.mosaic] });
|
|
assert.deepEqual(await wrong(checks(x), x.sh), ["read a stand-in ~/.config/mosaic-dev/secrets", "read a stand-in ~/.mosaic"]);
|
|
|
|
// The same checks in a sandbox started directly from binds(), without the
|
|
// session: all pass with every bind, and the workspace check fails
|
|
// without the workspace's.
|
|
const all = binds({ runDir: x.runDir, workspace: x.ws, adapter: x.adapter, harness: "pi", brokerSocket: x.brokerSocket });
|
|
const direct = (list) => async (cmd) => {
|
|
const argv = sandboxArgv(list, x.runDir, ["/bin/sh", "-c", cmd], { business: "acme", role: "coder" });
|
|
const r = spawnSync(argv[0], argv.slice(1), { encoding: "utf8", timeout: 10000 });
|
|
return { rc: r.status, out: (r.stdout + r.stderr).replace(/\n$/, "") };
|
|
};
|
|
prepareRun(x.runDir);
|
|
assert.deepEqual(await wrong(checks(x), direct(all)), []);
|
|
assert.deepEqual(await wrong(checks(x), direct(all.filter((b) => b.src !== x.ws))), ["write and read its workspace"]);
|
|
});
|
|
|
|
// Row 110 (#1604): a role session that runs tester-cli.mjs against its own
|
|
// broker socket can't bind as tester. Inside bwrap the shim's pid is the
|
|
// session's PID namespace's, so the broker's nonce check on the host fails,
|
|
// and the walk would reach the session's launch record. The same shim from
|
|
// the test process, outside any run, binds: the refusal is the proof's.
|
|
test("a confined session that runs tester-cli.mjs is refused as tester, and the same shim outside the sandbox binds", { skip, timeout: 60000 }, async (t) => {
|
|
const x = await session(t, { tester: true });
|
|
const request = JSON.stringify({ business: "acme", verb: "agents", args: {} });
|
|
const inside = await x.sh(`printf '%s' '${request}' | '${process.execPath}' '${TESTER_CLI}' '${x.brokerSocket}'`);
|
|
assert.deepEqual(inside, { rc: 2, out: "tester-required" });
|
|
|
|
const env = { ...process.env };
|
|
delete env.MOSAIC_BUS_TESTER_NONCE;
|
|
delete env.MOSAIC_BUS_CLI_NONCE;
|
|
const outside = await new Promise((done) => {
|
|
const child = spawn(process.execPath, [TESTER_CLI, x.brokerSocket], { env });
|
|
let out = "";
|
|
let err = "";
|
|
child.stdout.on("data", (d) => (out += d));
|
|
child.stderr.on("data", (d) => (err += d));
|
|
child.on("close", (status) => done({ status, out, err }));
|
|
child.stdin.end(request);
|
|
});
|
|
assert.equal(outside.status, 0, outside.err);
|
|
assert.ok(Array.isArray(JSON.parse(outside.out)));
|
|
});
|