Files
stack/packages/cli/src/launcher.mjs
T
jason.woltjeandClaude Opus 5.5 57948a81f3 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]>
2026-10-12 17:30:53 -05:00

445 lines
18 KiB
JavaScript

// The S6 launcher, inside the trusted bus host: it starts managed role
// sessions (packages/seat/src/session.mjs) and serves the launch socket the
// PM's `launch` tool calls. One launch at a time.
//
// A session asks over `<dataRoot>/bus-host/launch.sock` (0600) with one line,
// `{"cap": "<its capability>", "instance": "<role instance>"}`, and gets one
// line back: `{"ok": true, "result": {run, instance, harness, model}}` or
// `{"ok": false, "error": "<code>"}`. The host checks, in order:
//
// 1. identity: the capability names a role of this business
// 2. the business file's launch.instances lists the instance
// (instance-not-listed)
// 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. 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)
// 9. the broker authorizes role.launch for the caller (its own codes:
// launch-revoked, gated, ...)
//
// 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.
import { spawnSync } from "node:child_process";
import { createServer } from "node:net";
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { configDir, loadBusiness, resolveInstance, systemVars } from "../../business/src/index.mjs";
import { buildBundle, sessionModel } from "../../harness/src/bundle.mjs";
import {
ADAPTERS,
appendLaunchLog,
endReason,
family,
hasSessionCredential,
launchSocketPath,
newRun,
readSessions,
Registry,
runnerOf,
SESSION_CREDENTIALS,
sessionEnv,
sessionExit,
spawnSession,
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";
const PI_PACKAGE = "@earendil-works/pi-coding-agent";
const STOP_TIMEOUT_MS = 30000;
const LINE_MAX = 4096;
// A refusal the host decided; `code` goes back to the caller and to the broker.
class Refusal extends Error {
constructor(code, message = code) {
super(message);
this.code = code;
}
}
// The exact pi pin from the repository's package.json.
export function piVersion(repo = REPO) {
try {
const pkg = JSON.parse(readFileSync(join(repo, "package.json"), "utf8"));
return pkg.dependencies?.[PI_PACKAGE] ?? pkg.devDependencies?.[PI_PACKAGE] ?? null;
} catch {
return null;
}
}
// `claude --version` from the binary sessions run (MOSAIC_CLAUDE_BIN, else
// `claude` on PATH), or null when it doesn't answer.
export function claudeVersion(env = process.env) {
const r = spawnSync(env.MOSAIC_CLAUDE_BIN || "claude", ["--version"], { encoding: "utf8", timeout: 10000, env });
return r.status === 0 ? r.stdout.trim().split("\n")[0] || null : null;
}
// host: startHost()'s handle. tracker: true when the broker has task verbs for
// the business. adapters, bwrap, readOnly, sessionDefaults, versions and
// stopTimeoutMs exist for the tests; the command line never sets them.
export function createLauncher({
host,
system,
env = process.env,
tracker = false,
adapters = ADAPTERS,
bwrap = BWRAP,
readOnly = [],
sessionDefaults = {},
versions = null,
stopTimeoutMs = STOP_TIMEOUT_MS,
log = (l) => process.stderr.write(`mosaic-bus: ${l}\n`),
}) {
const dataRoot = system.dataRoot;
const b = loadBusiness(host.business, { dir: configDir(env), rolesDir: rolesDir(env) });
const vars = systemVars(system);
// Sessions an earlier host left in sessions.json: it died without ending
// them, so their runs still hold their roles. listen() ends them. A file
// that can't be read refuses the host rather than drop them unseen.
let leftover;
try {
leftover = readSessions(dataRoot).filter((s) => s.business === b.id);
} catch (e) {
throw new CliError(`${e.message}; check which sessions still run, then move the file aside`, 3);
}
const registry = new Registry(dataRoot);
const children = new Map();
let chain = Promise.resolve();
let closed = false;
let server = null;
const sockets = new Set();
const record = (entry) => {
try {
appendLaunchLog(dataRoot, b.id, entry);
} catch (e) {
log(`launch log: ${e.message}`);
}
};
function serialize(fn) {
const run = chain.then(fn);
chain = run.catch(() => {});
return run;
}
// 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`);
if (!(pm ? instance === b.launch.by : b.launch.instances.includes(instance))) throw new Refusal("instance-not-listed");
if (registry.running(b.id, instance)) throw new Refusal("instance-running");
let resolved, model;
try {
resolved = resolveInstance({ system: vars, business: b, instance });
model = sessionModel(resolved);
} catch (e) {
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]}`);
return { resolved, model, fam };
}
// Bundle, spawn, bind, register, hand over the capability.
async function start({ instance, launchedBy, resolved, model, fam }) {
const credentials = await host.op({ op: "credentialStatus", business: b.id, role: instance });
const { run, runDir } = newRun(dataRoot, b.id);
const workspace = workspaceDir(dataRoot, b.id, instance);
mkdirSync(workspace, { recursive: true, mode: 0o700 });
const sessionDir = join(runDir, "session");
mkdirSync(sessionDir, { mode: 0o700 });
const toolSocket = join(runDir, "tools.sock");
const turnMarker = join(runDir, "turn.done");
const v = versions ?? { pi: piVersion(), claude: model.harness === "claude-code" ? claudeVersion(env) : null };
const bundle = buildBundle({
dir: join(runDir, "bundle"),
resolved,
contract: readFileSync(resolved.contract, "utf8"),
arbiter: Object.values(b.arbiters).includes(instance),
tracker,
credentials,
session: { run, launchedBy, workspace, toolSocket, turnMarker },
piVersion: v.pi,
claudeVersion: v.claude,
});
const { manifest, ...files } = bundle.files;
writeSessionFile(runDir, {
...sessionDefaults,
business: b.id,
instance,
run,
launchedBy,
harness: model.harness,
provider: model.provider,
model: model.model,
brokerSocket: host.path,
launchSocket: launchSocketPath(dataRoot),
workspace,
sessionDir,
adapter: adapters[model.harness],
bundle: files,
toolSocket,
turnMarker,
});
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(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);
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;
entry = {
business: b.id, instance, run, launchedBy, harness: model.harness, model: model.model, family: fam,
pid: child.pid, startTime: outerStart, runnerPid: runner.pid, runnerStartTime: runner.startTime, runDir, startedAt: new Date().toISOString(),
};
registry.add(entry);
child.stdin.end(`${JSON.stringify({ cap: launch.cap })}\n`);
} catch (e) {
child.kill("SIGKILL");
await exited;
if (bound) await endRun(run, "failed", null);
throw new Refusal("launch-failed", e.message);
}
children.set(run, exited);
exited.then(async ({ code, signal }) => {
const reason = endReason(code, signal);
try {
await endRun(run, reason, signal ? null : code);
registry.remove(b.id, run);
record({ event: "end", instance, run, reason, exitCode: code, signal });
log(`run ${run} (${instance}) ended: ${reason}`);
} catch (e) {
log(`run ${run}: ${e.message}`);
} finally {
children.delete(run);
}
});
record({ event: "launch", instance, run, launchedBy, harness: model.harness, model: model.model, family: fam, pid: child.pid, runnerPid: entry.runnerPid, manifest });
return { run, instance, harness: model.harness, model: model.model };
}
async function endRun(run, reason, exitCode) {
try {
await host.op({ op: "endLaunch", record: { business: b.id, run, reason, exitCode } });
} catch (e) {
log(`run ${run}: endLaunch refused: ${e.code ?? e.message}`);
}
}
// A launch a session asked for. Resolves to the reply line's object.
function fromSession(cap, instance) {
return serialize(async () => {
if (closed) return { ok: false, error: "host-closing" };
let who;
try {
who = await host.op({ op: "identity", cap });
} catch (e) {
return { ok: false, error: e.code ?? "unauthenticated" };
}
if (!who.role || who.business !== b.id) {
await host.op({ op: "refuse", cap, code: "not-a-role" }).catch(() => {});
return { ok: false, error: "not-a-role" };
}
let checked;
try {
if (typeof instance !== "string") throw new Refusal("invalid-request");
checked = check(instance);
} catch (e) {
if (!(e instanceof Refusal)) throw e;
await host.op({ op: "refuse", cap, code: e.code }).catch(() => {});
record({ event: "refused", by: who.role, instance, code: e.code, why: e.message });
return { ok: false, error: e.code };
}
try {
await host.op({ op: "authorizeLaunch", cap, instance });
} catch (e) {
record({ event: "refused", by: who.role, instance, code: e.code ?? "not-authorized" });
return { ok: false, error: e.code ?? "not-authorized" };
}
try {
return { ok: true, result: await start({ instance, launchedBy: who.role, ...checked }) };
} catch (e) {
const code = e instanceof Refusal ? e.code : "launch-failed";
log(`launch of ${instance} for ${who.role} failed: ${e.message}`);
await host.op({ op: "refuse", cap, code }).catch(() => {});
record({ event: "refused", by: who.role, instance, code, why: e.message });
return { ok: false, error: code };
}
});
}
// `bus start --pm`: the business's launcher instance, launched by the human.
function launchPm() {
return serialize(async () => {
if (!b.launch) throw new CliError(`business ${b.id} has no launch block, so --pm has nothing to launch`, 3);
try {
return await start({ instance: b.launch.by, launchedBy: b.human, ...check(b.launch.by, { pm: true }) });
} catch (e) {
if (!(e instanceof Refusal)) throw e;
record({ event: "refused", by: b.human, instance: b.launch.by, code: e.code, why: e.message });
throw new CliError(`PM launch refused: ${e.code}${e.message !== e.code ? ` (${e.message})` : ""}`, 3);
}
});
}
// The earlier host's sessions. Their capabilities died with its broker, so
// one that still runs can do nothing: kill it (the namespace takes its
// turn with it). Then end each run in the bus, which releases its role.
// This broker never bound those runs, so each is bound again first with
// 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] === bwrap) {
try {
process.kill(s.pid, "SIGKILL");
} catch {}
const end = Date.now() + 5000;
while (startTimeOf(s.pid) === s.startTime && Date.now() < end) await new Promise((r) => setTimeout(r, 20));
}
try {
await host.bindLaunch({ business: b.id, role: s.instance, run: s.run, harness: s.harness, pid: s.pid, startTime: s.startTime });
} catch (e) {
// A host that died after ending the run but before updating sessions.json.
if (e.code === "run-ended") log(`run ${s.run} (${s.instance}) from an earlier host was already ended`);
else log(`run ${s.run} (${s.instance}) from an earlier host: not ended, bindLaunch refused: ${e.code ?? e.message}`);
continue;
}
await endRun(s.run, "host-lost", null);
record({ event: "end", instance: s.instance, run: s.run, reason: "host-lost", exitCode: null, signal: null });
log(`run ${s.run} (${s.instance}) from an earlier host ended: host-lost`);
}
leftover = [];
}
async function listen() {
await recover();
const path = launchSocketPath(dataRoot);
if (existsSync(path)) {
if (!lstatSync(path).isSocket()) throw new CliError(`${path} exists and isn't a socket`, 3);
rmSync(path);
}
server = createServer((socket) => {
sockets.add(socket);
socket.once("close", () => sockets.delete(socket));
let buf = "";
let answered = false;
const reply = (obj) => {
if (answered) return;
answered = true;
socket.end(`${JSON.stringify(obj)}\n`);
};
socket.setTimeout(10000, () => reply({ ok: false, error: "invalid-request" }));
socket.on("error", () => {});
socket.on("data", (chunk) => {
if (answered) return;
buf += chunk;
if (buf.length > LINE_MAX) return reply({ ok: false, error: "invalid-request" });
const nl = buf.indexOf("\n");
if (nl < 0) return;
socket.setTimeout(0);
let req;
try {
req = JSON.parse(buf.slice(0, nl));
} catch {
return reply({ ok: false, error: "invalid-request" });
}
if (typeof req?.cap !== "string" || !/^[0-9a-f]{64}$/.test(req.cap)) return reply({ ok: false, error: "unauthenticated" });
fromSession(req.cap, req.instance).then(reply, (e) => {
log(`launch socket: ${e.message}`);
reply({ ok: false, error: "launch-failed" });
});
});
});
// bus-host/ is 0700, so the moment before the chmod exposes nothing.
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(path, resolve);
});
chmodSync(path, 0o600);
}
// SIGTERM every runner, wait, then end what's left. Runs before the host
// stops the broker, so endLaunch still works.
async function close() {
closed = true;
if (server) {
// server.close waits for every connection, and a client that never
// ends its side (or never sends) would hold it; so they go first.
const stopped = new Promise((r) => server.close(r));
for (const socket of sockets) socket.destroy();
await stopped;
rmSync(launchSocketPath(dataRoot), { force: true });
}
await chain;
// Again every second: a runner still starting has no handler yet, and as
// pid 1 of its namespace it never sees a signal sent before it has one.
const term = () => {
for (const s of registry.sessions) {
try {
if (startTimeOf(s.runnerPid) === s.runnerStartTime) process.kill(s.runnerPid, "SIGTERM");
} catch {}
}
};
term();
const again = setInterval(term, 1000);
const pending = [...children.values()];
const timer = setTimeout(() => {
for (const s of registry.sessions) {
try {
if (startTimeOf(s.pid) === s.startTime) process.kill(s.pid, "SIGKILL");
} catch {}
}
}, stopTimeoutMs);
await Promise.all(pending);
// The exit handlers run on the same tick as the close events; let them finish.
while (children.size) await new Promise((r) => setTimeout(r, 20));
clearInterval(again);
clearTimeout(timer);
}
host.beforeClose(close);
return { business: b, registry, listen, launchPm, fromSession, close };
}