// 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 `/bus-host/launch.sock` (0600) with one line, // `{"cap": "", "instance": ""}`, and gets one // line back: `{"ok": true, "result": {run, instance, harness, model}}` or // `{"ok": false, "error": ""}`. 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. 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 // (capacity-full) // 7. 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. // // `bus start --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, launchSocketPath, newRun, readSessions, Registry, runnerOf, sessionEnv, spawnSession, UNSHARE, workspaceDir, writeSessionFile, } from "../../seat/src/session.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, namespace, 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, namespace = true, 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 6. 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`); 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 child = spawnSession({ runDir, env: sessionEnv(env, run), namespace }); let bound = false; let entry = null; const exited = new Promise((resolve) => child.once("close", (code, signal) => resolve({ 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 }); 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] === UNSHARE[0]) { 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 }; }