seat, cli, harness: role sessions confined to their own files (#1586)

Row 92, round 2. Darkwing built it. A role session runs in a bwrap
sandbox that sees only its own workspace, a private /proc, /dev and IPC
namespace, and the variables ENV_ALLOW passes. The launcher checks the
session credential before a Claude launch, records a runner killed from
the host as killed rather than failed, and the bus unit template carries
the credential file (decision 119). The harness gate and the cli, seat
and harness READMEs say what the sandbox does and does not hide.

Candidate manifest 8bf4141cbe69fc7bed188555bcc88dcab0b1ea2149912cb6f970ba8c2629dd97
(13 paths), base ac37cd17, packet agents/darkwing/work/queue-92/r2/.
Approvals: Dewey 27978, Filbert 27984 (round 1: Dewey 27942, Filbert
asked for changes in 27951). Sage's gate rerun 22:27:37Z-22:30:35Z on
1b7f3781 plus the patch, manifest 13/13: seat 33, cli 120, harness 65,
bus 108, webui 86, control-board 124, and every scripts/test-*.sh exited 0
(test-release 4, Docker cases skipped at a dead socket).

Live at the next bus restart and the next role session launch (decision
109). Installing the new unit and restarting is Mos's call.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-12 17:30:53 -05:00
co-authored by Claude Opus 5.5
parent 4835a8e282
commit 57948a81f3
13 changed files with 799 additions and 105 deletions
+36 -3
View File
@@ -220,6 +220,8 @@ is `{"ok": true, "result": {run, instance, harness, model}}` or
| the instance isn't running | `instance-running` |
| the instance resolves to `pi` or `claude-code` and a model | `unsupported-harness` |
| its role has a contract file | `no-role-contract` |
| a Claude Code session has `CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_API_KEY`, or `ANTHROPIC_AUTH_TOKEN` with `ANTHROPIC_BASE_URL` to log in with | `no-session-credential` |
| bwrap runs here | `sandbox-unavailable` |
| its model names exactly one `launch.max` family | `unknown-model-family` |
| that family has room; every running session counts, the PM's too | `capacity-full` |
| the broker authorizes `role.launch` for the caller | the broker's code (`launch-revoked`, `decision-required`, …) |
@@ -229,13 +231,31 @@ Every refusal is an `action.refused` event (the broker's `refuse`, or its
own refusal for the last broker check) and a line in the launch log. Then
the launcher reads the instance's credential status from the broker
(metadata only), builds the bundle in the run directory, spawns the runner
under `unshare`, binds the outer process's pid and start time, adds the
in its bwrap sandbox (`packages/seat/README.md`, "The sandbox"), binds the
outer bwrap process's pid and start time, adds the
session to `sessions.json`, and writes the capability as one line on the
runner's stdin. The capability is never on disk, in argv or in the
environment. When the runner exits, the launcher sends `endLaunch` with the
end reason from its exit code (`packages/harness/README.md`), which writes
`session.ended`, releases the role and kills the run's capabilities.
A Claude Code session logs in with `CLAUDE_CODE_OAUTH_TOKEN`,
`ANTHROPIC_API_KEY`, or `ANTHROPIC_AUTH_TOKEN` with `ANTHROPIC_BASE_URL`
(a proxy's key), from the host's environment, which the allowlist passes
on. The bus unit loads it ("Unit" below). Nothing from the host's
`~/.claude` reaches a session, whose `HOME` is its own. With none of them
set, a Claude Code launch refuses `no-session-credential` before anything
starts. A Pi session gets the same variables through the allowlist. A
shell the harness opens, Claude Code's `Bash` or Pi's `bash`, can read
them.
A Pi session logs in only through the variables `ENV_ALLOW` passes. Its
`HOME` is the run's own, so Pi never reads the operator's `~/.pi/agent`
(`auth.json`, `models.json`, `settings.json`). A Pi launch has no
credential check: a Pi role on a provider whose login lives only in those
files, or whose key isn't an `ENV_ALLOW` variable, launches and then fails
at its first turn.
The manifest records the Pi version from the repository's pin (the
`@earendil-works/pi-coding-agent` dependency in the root `package.json`)
and, for Claude Code, `--version` from the binary sessions run:
@@ -257,7 +277,7 @@ A host that starts reads the `sessions.json` the last host left. An entry
for this business means that host died without ending the launch, and its
run would keep holding the role. Before the launch socket opens, the
launcher SIGKILLs each such session that still runs (same start time,
`unshare` on its command line), binds the run again with the recorded
bwrap on its command line), binds the run again with the recorded
identity and ends it as `host-lost`, which releases the role. A
`sessions.json` that can't be read refuses the host start with exit 3; move
it aside after checking which sessions still run.
@@ -308,6 +328,7 @@ The unit settings:
first when it isn't one of those
- `Environment=MOSAIC_CLAUDE_BIN=`: the Claude Code binary that managed
sessions and the manifest's version run
- `EnvironmentFile=`: the Claude Code session credential (below)
`MOSAIC_CLAUDE_BIN` comes from `--claude PATH`, or else `command -v claude`
in the installing shell. The path is kept as given, so the native install's
@@ -316,9 +337,21 @@ following its updates; reinstall to change it. It must be absolute, an
executable file, and only `A-Z a-z 0-9 / . _ + -`, because systemd expands
`%` and splits `Environment=` on spaces; anything else refuses with exit 1
and writes nothing. With no claude found, the unit gets a comment line
instead and sessions run `claude` from the unit's `PATH`. A running host
instead and sessions run `claude` from the unit's `PATH`, which in the
sandbox finds only a `claude` under `/usr`. A running host
keeps the environment it started with until it restarts.
The session credential comes from
`EnvironmentFile=-%h/.config/mosaic-dev/secrets/claude-session.env`, the
one place the path is set. The file holds one line, for example
`CLAUDE_CODE_OAUTH_TOKEN=<token>`, mode 0600. The operator writes it;
nothing in the repository creates or reads it, and no session sees the
file itself, only the variable. The leading `-` lets the unit start
without it, so Pi sessions still launch and a Claude Code launch refuses
`no-session-credential`. To change the credential, edit or swap the file
(or this line and this paragraph), then restart the unit; a running host
keeps the environment it started with.
The unit has no `network-online.target` ordering, since a user unit cannot
order on that system target. The notifier needs no network at start: a
send that fails is journaled and retried.
+39 -16
View File
@@ -13,17 +13,21 @@
// 3. the instance isn't already running (instance-running)
// 4. it resolves, has a role contract (no-role-contract) and a supported
// harness and model (unsupported-harness)
// 5. its model is in exactly one launch.max family (unknown-model-family)
// 6. that family has room; every running session counts, the PM's too
// 5. a Claude Code session has a credential to log in with
// (no-session-credential)
// 6. bwrap runs here (sandbox-unavailable)
// 7. its model is in exactly one launch.max family (unknown-model-family)
// 8. that family has room; every running session counts, the PM's too
// (capacity-full)
// 7. the broker authorizes role.launch for the caller (its own codes:
// 9. the broker authorizes role.launch for the caller (its own codes:
// launch-revoked, gated, ...)
//
// Steps 2 to 6 refuse through the broker's refuse(), so every refusal is an
// action.refused event; step 7's refusals the broker records itself. Then
// the host builds the bundle, spawns the runner, binds the run's process
// identity (the outer unshare pid) and writes the new capability on the
// runner's stdin. When the runner exits, the host ends the launch.
// Steps 2 to 8 refuse through the broker's refuse(), so every refusal is an
// action.refused event; step 9's refusals the broker records itself. Then
// the host builds the bundle, spawns the runner in its sandbox
// (packages/seat/src/sandbox.mjs) with the paths it may see, binds the run's
// process identity (the outer bwrap pid) and writes the new capability on
// the runner's stdin. When the runner exits, the host ends the launch.
//
// `bus start <business> --pm` launches launch.by the same way with
// launchedBy = the business's human and no caller capability.
@@ -39,17 +43,20 @@ import {
appendLaunchLog,
endReason,
family,
hasSessionCredential,
launchSocketPath,
newRun,
readSessions,
Registry,
runnerOf,
SESSION_CREDENTIALS,
sessionEnv,
sessionExit,
spawnSession,
UNSHARE,
workspaceDir,
writeSessionFile,
} from "../../seat/src/session.mjs";
import { BWRAP, sandboxProblem } from "../../seat/src/sandbox.mjs";
import { cmdlineOf, startTimeOf } from "../../seat/src/proc.mjs";
import { REPO, rolesDir } from "./config.mjs";
import { CliError } from "./errors.mjs";
@@ -84,7 +91,7 @@ export function claudeVersion(env = process.env) {
}
// host: startHost()'s handle. tracker: true when the broker has task verbs for
// the business. adapters, namespace, sessionDefaults, versions and
// the business. adapters, bwrap, readOnly, sessionDefaults, versions and
// stopTimeoutMs exist for the tests; the command line never sets them.
export function createLauncher({
host,
@@ -92,7 +99,8 @@ export function createLauncher({
env = process.env,
tracker = false,
adapters = ADAPTERS,
namespace = true,
bwrap = BWRAP,
readOnly = [],
sessionDefaults = {},
versions = null,
stopTimeoutMs = STOP_TIMEOUT_MS,
@@ -132,7 +140,7 @@ export function createLauncher({
return run;
}
// Steps 2 to 6. Returns what the launch needs. Only `--pm` may name
// Steps 2 to 8. Returns what the launch needs. Only `--pm` may name
// launch.by itself.
function check(instance, { pm = false } = {}) {
if (!b.launch) throw new Refusal("launch-not-configured", `business ${b.id} has no launch block`);
@@ -146,6 +154,12 @@ export function createLauncher({
throw new Refusal("unsupported-harness", e.message);
}
if (!resolved.contract) throw new Refusal("no-role-contract", `role ${resolved.definition} has no contract file`);
if (model.harness === "claude-code" && !hasSessionCredential(env)) {
const names = SESSION_CREDENTIALS.map((set) => set.join(" with ")).join(", ");
throw new Refusal("no-session-credential", `the bus host has none of ${names}; the bus unit's EnvironmentFile sets one when the bus starts`);
}
const unavailable = sandboxProblem({ bwrap });
if (unavailable) throw new Refusal("sandbox-unavailable", unavailable);
const fam = family(model.model, b.launch.max);
if (!fam) throw new Refusal("unknown-model-family", `model ${model.model} is in none, or more than one, of ${Object.keys(b.launch.max).join(", ")}`);
if (registry.count(b.id, fam) >= b.launch.max[fam]) throw new Refusal("capacity-full", `${fam} sessions: ${registry.count(b.id, fam)} of ${b.launch.max[fam]}`);
@@ -194,16 +208,25 @@ export function createLauncher({
turnMarker,
});
const child = spawnSession({ runDir, env: sessionEnv(env, run), namespace });
const paths = {
workspace,
adapter: adapters[model.harness],
harness: model.harness,
brokerSocket: host.path,
launchSocket: resolved.launch ? launchSocketPath(dataRoot) : null,
claudeBin: env.MOSAIC_CLAUDE_BIN || null,
readOnly,
};
const child = spawnSession({ runDir, env: sessionEnv(env, run), paths, bwrap });
let bound = false;
let entry = null;
const exited = new Promise((resolve) => child.once("close", (code, signal) => resolve({ code, signal })));
const exited = new Promise((resolve) => child.once("close", (code, signal) => resolve(sessionExit(code, signal))));
child.on("error", (e) => log(`run ${run}: ${e.message}`));
child.stdin.on("error", () => {});
try {
if (child.pid === undefined) throw new Error("the session process did not start");
const outerStart = startTimeOf(child.pid);
const runner = await runnerOf(child, { namespace });
const runner = await runnerOf(child);
if (!outerStart) throw new Error("the session process exited at once");
const launch = await host.bindLaunch({ business: b.id, role: instance, run, harness: model.harness, pid: child.pid, startTime: outerStart });
bound = true;
@@ -308,7 +331,7 @@ export function createLauncher({
// its identical launch record, which the broker accepts.
async function recover() {
for (const s of leftover) {
if (startTimeOf(s.pid) === s.startTime && (cmdlineOf(s.pid) ?? [])[0] === UNSHARE[0]) {
if (startTimeOf(s.pid) === s.startTime && (cmdlineOf(s.pid) ?? [])[0] === bwrap) {
try {
process.kill(s.pid, "SIGKILL");
} catch {}
@@ -17,6 +17,11 @@ Environment=PATH=@PATH@
# claudeVersion run, pinned at install (`--claude PATH`, default
# `command -v claude`). Without it they run `claude` from PATH above.
@CLAUDE_ENV@
# Claude Code sessions log in with what this 0600 file sets, which the
# operator writes: CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_API_KEY, or a proxy's
# ANTHROPIC_AUTH_TOKEN with ANTHROPIC_BASE_URL. The `-` lets the unit start
# without it, and a Claude Code launch then refuses.
EnvironmentFile=-%h/.config/mosaic-dev/secrets/claude-session.env
# The host reads the system config and the business file, boots the broker
# (packages/bus/src/process.mjs) and starts the notifier. Config problems and
# refusals exit 3, usage 4, invalid input 2: none of those is retried. A
+4 -2
View File
@@ -1,12 +1,13 @@
// A bus host with its launcher in a process of its own, so a test can kill
// it hard. It launches the PM, prints the launch result as one JSON line and
// runs until it is killed. argv: the adapter to use for both harnesses.
// runs until it is killed. argv: the adapter to use for both harnesses, then
// the files the sandbox shows the session read-only (the fake adapter).
import { bootConfig, loadSystem } from "../../src/config.mjs";
import { startHost } from "../../src/host.mjs";
import { createLauncher } from "../../src/launcher.mjs";
const [adapter] = process.argv.slice(2);
const [adapter, ...readOnly] = process.argv.slice(2);
const system = loadSystem({ env: process.env });
const boot = bootConfig({ system, businessId: "acme", env: process.env });
const host = await startHost({ boot, business: "acme", log: () => {} });
@@ -17,6 +18,7 @@ const launcher = createLauncher({
adapters: { pi: adapter, "claude-code": adapter },
sessionDefaults: { pollInterval: 100 },
versions: { pi: "0.85.1", claude: null },
readOnly,
log: () => {},
});
await launcher.listen();
+6
View File
@@ -482,6 +482,12 @@ test("bus-service.sh renders the unit and installs it into a given directory", (
assert.equal(render.status, 0, render.stderr);
assert.match(render.stdout, new RegExp(`ExecStart=${REPO.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/scripts/mosaic bus start %i`));
assert.match(render.stdout, /RestartPreventExitStatus=2 3 4/);
// One session credential file, optional so Pi sessions start without it.
// The README's paragraph names the same file; nothing else does.
const files = render.stdout.match(/^EnvironmentFile=.*$/gm);
assert.equal(files.length, 1, files.join("\n"));
assert.match(files[0], /^EnvironmentFile=-%h\/\S+$/);
assert.ok(readFileSync(join(REPO, "packages", "cli", "README.md"), "utf8").includes(`\`${files[0]}\``), "the README names the unit's file");
assert.doesNotMatch(render.stdout, /@REPO@|@PATH@|@CLAUDE_ENV@/);
// A user unit cannot order on a system target (Darkwing F3).
assert.doesNotMatch(render.stdout, /network-online/);
+138 -8
View File
@@ -1,5 +1,5 @@
// The S6 launcher inside a real bus host: real broker child, real runners
// under unshare, the fake adapter from packages/harness standing in for the
// in the bwrap sandbox, the fake adapter from packages/harness standing in for the
// harness. The PM launches a coder through its `launch` tool; every host
// refusal comes back over the launch socket and lands in the launch log.
@@ -9,20 +9,21 @@ import { spawn, spawnSync } from "node:child_process";
import { once } from "node:events";
import { createInterface } from "node:readline";
import { connect } from "node:net";
import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { Client } from "../../bus/src/client.mjs";
import { launchLogFile, launchSocketPath, readSessions, Registry, sessionsFile, UNSHARE } from "../../seat/src/session.mjs";
import { launchLogFile, launchSocketPath, readSessions, Registry, SESSION_CREDENTIALS, sessionsFile } from "../../seat/src/session.mjs";
import { sandboxProblem } from "../../seat/src/sandbox.mjs";
import { bootConfig, loadSystem } from "../src/config.mjs";
import { startHost, startTimeOf } from "../src/host.mjs";
import { claudeVersion, createLauncher } from "../src/launcher.mjs";
import { mockAnthropic, REPO } from "../../harness/tests/helpers.mjs";
import { fixture, tmp } from "./helpers.mjs";
const FAKE = fileURLToPath(new URL("../../harness/tests/fixtures/fake-adapter.mjs", import.meta.url));
const HOST = fileURLToPath(new URL("./fixtures/launch-host.mjs", import.meta.url));
const NAMESPACES = spawnSync(UNSHARE[0], [...UNSHARE.slice(1), "true"]).status === 0;
const skip = NAMESPACES ? false : "unprivileged user and PID namespaces are unavailable here";
const skip = sandboxProblem() ?? false;
// pm opus, coder and reviewer sonnet, one of each family; cto is listed but
// runs the system model, which is in no family.
@@ -44,9 +45,13 @@ async function until(fn, ms = 15000) {
}
// A config and data root with the fake adapter; setup() starts a host on it.
// The session credential is a stand-in, and the fake adapter never uses it.
// Every credential variable of the shell running the tests is dropped.
function prepare(t, more = (doc) => doc) {
const root = tmp(t, "mosaic-launch-");
const f = fixture(root, "acme", (doc) => more(edit(doc)));
for (const k of SESSION_CREDENTIALS.flat()) delete f.env[k];
f.env.CLAUDE_CODE_OAUTH_TOKEN = "stand-in";
mkdirSync(f.dataRoot, { mode: 0o700 });
const adapter = join(root, "adapter.sh");
writeFileSync(adapter, `exec '${process.execPath}' '${FAKE}'\n`, { mode: 0o644 });
@@ -67,6 +72,7 @@ async function setup(t, more = (doc) => doc, { root, f, adapter } = prepare(t, m
adapters: { pi: adapter, "claude-code": adapter },
sessionDefaults: { pollInterval: 100 },
versions: { pi: "0.85.1", claude: null },
readOnly: [FAKE],
log: (l) => logs.push(l),
...options,
});
@@ -117,7 +123,7 @@ test("bus start --pm: the PM runs under its own PID namespace, registered, with
assert.equal(s.live, true);
assert.equal(s.family, "opus");
assert.equal(s.launchedBy, "jason");
assert.notEqual(s.pid, s.runnerPid, "the runner is the unshare process's child");
assert.notEqual(s.pid, s.runnerPid, "the runner is the bwrap process's child");
assert.equal(statSync(sessionsFile(f.dataRoot)).mode & 0o777, 0o600);
const runDir = s.runDir;
@@ -190,6 +196,10 @@ test("the PM launches a coder through its launch tool; the coder answers; refusa
assert.equal(coder.run, result.run);
assert.equal(coder.launchedBy, "pm");
assert.equal(coder.live, true);
// Only the role that may launch has the launch socket in its sandbox.
const bound = (pid) => readFileSync(`/proc/${pid}/cmdline`, "utf8").split("\0").includes(sock);
assert.equal(bound(readSessions(f.dataRoot).find((s) => s.instance === "pm").pid), true);
assert.equal(bound(coder.pid), false);
// The coder claimed its role and answers its own messages.
const hello = await client.call("message.send", { to: "coder", class: "REQUEST", body: "hello" });
@@ -197,7 +207,7 @@ test("the PM launches a coder through its launch tool; the coder answers; refusa
assert.equal(echo.from_role, "coder");
assert.match(echo.body, /^echo: Message .* from cto, class REQUEST:\n\nhello/);
// Host refusals, asked with the cto capability (steps 2 to 6 come before
// Host refusals, asked with the cto capability (steps 2 to 8 come before
// the broker's role.launch check, which cto would fail).
const refusals = [
["pm", "instance-not-listed"],
@@ -249,11 +259,131 @@ test("a runner that stops at once ends its launch with the runner's reason", { s
assert.match(readFileSync(join(f.dataRoot, "launches", "acme", pm.run, "runner.log"), "utf8"), /vikunja/);
});
test("a Claude Code launch with no session credential refuses before it starts", { timeout: 30000 }, async (t) => {
const given = prepare(t, (doc) => {
doc.roles.pm.vars.harness = "claude-code";
return doc;
});
delete given.f.env.CLAUDE_CODE_OAUTH_TOKEN;
given.f.env.ANTHROPIC_AUTH_TOKEN = "stand-in"; // a proxy key with no proxy
const { f, launcher } = await setup(t, undefined, given);
const names = "CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN with ANTHROPIC_BASE_URL";
await assert.rejects(launcher.launchPm(), (e) => e.exitCode === 3 && e.message === `PM launch refused: no-session-credential (the bus host has none of ${names}; the bus unit's EnvironmentFile sets one when the bus starts)`);
assert.deepEqual(log(f).map((e) => [e.event, e.code]), [["refused", "no-session-credential"]]);
assert.deepEqual(readSessions(f.dataRoot), []);
assert.deepEqual(readdirSync(join(f.dataRoot, "launches")), ["acme.jsonl"], "no run was made");
});
test("a pi launch needs no session credential: pi logs in through its own ENV_ALLOW variables", { skip, timeout: 30000 }, async (t) => {
const given = prepare(t);
delete given.f.env.CLAUDE_CODE_OAUTH_TOKEN;
assert.deepEqual(SESSION_CREDENTIALS.flat().filter((k) => k in given.f.env), []);
const { f, launcher } = await setup(t, undefined, given);
const pm = await launcher.launchPm();
assert.equal(pm.harness, "pi");
assert.deepEqual(log(f).map((e) => [e.event, e.instance]), [["launch", "pm"]]);
});
// The runner is pid 1 in its namespace and ignores a SIGKILL sent from
// inside it, so the host kills it by its host pid. bwrap then exits 137,
// which the launcher reads back as the signal.
test("a runner killed from the host ends its launch as killed, not failed", { skip, timeout: 30000 }, async (t) => {
const { f, launcher } = await setup(t);
const pm = await launcher.launchPm();
const runnerLog = join(f.dataRoot, "launches", "acme", pm.run, "runner.log");
await until(() => existsSync(runnerLog) && readFileSync(runnerLog, "utf8").includes("claimed by run"));
const [s] = readSessions(f.dataRoot);
process.kill(s.runnerPid, "SIGKILL");
const end = await until(() => log(f).find((e) => e.event === "end" && e.run === pm.run));
assert.deepEqual([end.reason, end.exitCode, end.signal], ["killed", null, "SIGKILL"]);
assert.deepEqual(readSessions(f.dataRoot), []);
});
test("a missing or failing bwrap refuses sandbox-unavailable before anything starts; there is no unconfined fallback",{ timeout: 30000 }, async (t) => {
const given = prepare(t);
const bad = join(given.root, "bwrap");
writeFileSync(bad, "#!/bin/sh\necho 'bwrap: setting up uid map: Permission denied' >&2\nexit 1\n", { mode: 0o755 });
const { f, launcher, cto } = await setup(t, undefined, given, { bwrap: bad });
await assert.rejects(launcher.launchPm(), (e) => e.message === `PM launch refused: sandbox-unavailable (${bad} failed: bwrap: setting up uid map: Permission denied)`);
rmSync(bad);
await assert.rejects(launcher.launchPm(), (e) => e.message === `PM launch refused: sandbox-unavailable (${bad} is missing)`);
// A session's launch request gets the same code, through refuse().
assert.deepEqual(await launcher.fromSession(cto.cap, "coder"), { ok: false, error: "sandbox-unavailable" });
assert.deepEqual(log(f).map((e) => e.code), ["sandbox-unavailable", "sandbox-unavailable", "sandbox-unavailable"]);
assert.deepEqual(readSessions(f.dataRoot), []);
assert.deepEqual(readdirSync(join(f.dataRoot, "launches")), ["acme.jsonl"], "no run was made");
});
// The brief's one real Claude Code launch: the host's claude in a coder's
// sandbox, logged in with a stand-in proxy key against the scripted Messages
// API, runs one Bash call that tries the founder's stand-ins, another run
// and the checkout. No real credential and no real model.
const which = spawnSync("sh", ["-c", "command -v claude"], { encoding: "utf8" });
const CLAUDE = which.status === 0 ? which.stdout.trim() : null;
test("a real Claude Code coder's shell can't read a stand-in secret or anything else its sandbox doesn't hold", { skip: skip || (CLAUDE ? false : "claude is not on PATH"), timeout: 180000 }, async (t) => {
const script = [];
const api = await mockAnthropic(t, { script, done: (r) => `ANSWER ${JSON.stringify(r.map((x) => [x.isError, x.content.slice(0, 1500)]))}` });
const given = prepare(t, (doc) => {
doc.roles.coder.vars.harness = "claude-code";
doc.roles.coder.vars["limits.tools"] = ["bash"];
return doc;
});
delete given.f.env.CLAUDE_CODE_OAUTH_TOKEN;
Object.assign(given.f.env, { ANTHROPIC_AUTH_TOKEN: "stand-in", ANTHROPIC_BASE_URL: api.url, MOSAIC_CLAUDE_BIN: CLAUDE });
const secret = join(given.root, "home", ".config", "mosaic-dev", "secrets", "stand-in.env");
const mosaic = join(given.root, "home", ".mosaic", "fleet", "token");
for (const p of [secret, mosaic]) {
mkdirSync(join(p, ".."), { recursive: true, mode: 0o700 });
writeFileSync(p, "FAKE_TOKEN=stand-in-value\n", { mode: 0o600 });
}
const claudeAdapter = join(REPO, "adapters", "claude", "adapter.sh");
const { f, launcher, client } = await setup(t, undefined, given, { adapters: { pi: given.adapter, "claude-code": claudeAdapter } });
const pm = await launcher.launchPm();
const pmRun = readSessions(f.dataRoot).find((s) => s.run === pm.run).runDir;
// [what, shell command, may the session do it]
const probes = [
["read the stand-in secret", `cat '${secret}'`, false],
["read the stand-in ~/.mosaic", `cat '${mosaic}'`, false],
["read the PM's run directory", `cat '${pmRun}/session.json'`, false],
["read the data root's sessions file", `cat '${sessionsFile(f.dataRoot)}'`, false],
["read the checkout", `cat '${join(REPO, "AGENTS.md")}'`, false],
["write outside", `touch '${given.root}/escaped'`, false],
["write and read its workspace", "echo ws > probe && cat probe", true],
["write and read /tmp", "echo tmp > /tmp/probe && cat /tmp/probe", true],
["HOME is the run's home/", `case "$HOME" in */home) test -w "$HOME";; *) false;; esac`, true],
];
const command = probes.map(([what, cmd], i) => `if { ${cmd}; } >/dev/null 2>&1; then echo "${i} yes"; else echo "${i} no"; fi`).join("\n");
script.push({ name: "Bash", input: { command, description: "probe the sandbox" } });
const sent = await client.call("message.send", { to: "pm", class: "REQUEST", body: 'TOOL launch {"instance":"coder"}' });
const launched = await until(async () => (await client.call("message.receive")).find((m) => m.in_reply_to === sent.id));
assert.equal(JSON.parse(launched.body).instance, "coder", launched.body);
const coder = readSessions(f.dataRoot).find((s) => s.instance === "coder");
assert.equal(coder.harness, "claude-code");
const hello = await client.call("message.send", { to: "coder", class: "REQUEST", body: "probe" });
const reply = await until(async () => (await client.call("message.receive")).find((m) => m.in_reply_to === hello.id), 150000);
assert.equal(reply.from_role, "coder");
const answer = reply.body.match(/ANSWER (\[.*\])/s);
assert.ok(answer, reply.body);
const [[isError, out]] = JSON.parse(answer[1]);
assert.equal(isError, false, out);
const got = Object.fromEntries(out.trim().split("\n").map((l) => l.split(" ")).map(([i, v]) => [probes[i][0], v === "yes"]));
assert.deepEqual(got, Object.fromEntries(probes.map(([what, , may]) => [what, may])));
assert.ok(!existsSync(join(given.root, "escaped")));
assert.ok(!reply.body.includes("stand-in-value"), "the secret's value never comes back");
// The session logged in with the stand-in proxy key, against the mock.
assert.ok(api.requests.length > 0);
assert.ok(api.requests.some((r) => r.tools.includes("Bash")), api.requests.map((r) => r.tools.join(",")).join(" | "));
});
for (const exited of [false, true]) {
test(`a host that died hard leaves its sessions to the next host, which ${exited ? "finds one already exited (23)" : "kills them"} and ends their runs so the role can be launched again`, { skip, timeout: 30000 }, async (t) => {
const given = prepare(t);
const { f } = given;
const old = spawn(process.execPath, [HOST, given.adapter], { env: f.env, stdio: ["ignore", "pipe", "inherit"] });
const old = spawn(process.execPath, [HOST, given.adapter, FAKE], { env: f.env, stdio: ["ignore", "pipe", "inherit"] });
t.after(() => old.kill("SIGKILL"));
const [line] = await once(createInterface({ input: old.stdout }), "line");
const first = JSON.parse(line);