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
+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 {}