Files
stack/packages/seat/tests/sandbox.test.mjs
T
jason.woltjeandClaude Opus 5.5 0069949ffc seat: give role sessions a git identity and report the checkout commit (row 104, #1598)
sandboxArgv sets GIT_AUTHOR_NAME/EMAIL and GIT_COMMITTER_NAME/EMAIL from
the instance and business ids (name = instance id, email
<instance>@<business>.mosaic.invalid); spawnSession and the launcher pass
the ids through. checkout results carry the clone's commit (a kept clone
gets none), and the bundle prompt names it. allowed() is unchanged.
Packet agents/darkwing/work/queue-104/r1/ (manifest f99f2ae7). Dewey
approved round 1 in 28293 (posted under jarvis by mistake; 28294 is
Dewey's confirmation). Sage's gate on f00613106 plus the candidate:
every package suite and scripts/test-*.sh green (test-release failed 3
activation checks once, not reproduced; rerun 14/0).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 17:40:33 -05:00

298 lines
16 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 { 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 { 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 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.
async function session(t, { expose = () => [] } = {}) {
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" },
roles: {
pm: { authority: { withinRole: ["message.send"], crossRole: [] } },
coder: { authority: { withinRole: ["message.send"], crossRole: [] } },
},
},
},
});
const brokerSocket = join(root, "bus.sock");
const server = await serve({ broker, path: brokerSocket });
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, [
`body=$(printf '%s\\n' "$MOSAIC_REQUEST" | sed '1,/^$/d')`,
`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, ...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) });
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"]);
});