Slice 1 S6. packages/harness runs a role session under the pi or claude
adapter behind a tool gate, and packages/seat holds the session and its
process. The bus host gains a launch socket: `mosaic bus start
<business> --pm <harness>:<model>` runs the PM without a window, the PM
launches role sessions through the broker within the business file's
`launch.max`, and every launch, refusal and end is a launch-log line and
a broker event. `mosaic talk`, `mosaic launches off|on|list` and
`mosaic stop <run>` are new CLI verbs. packages/bus gains the IPC ops
identity, authorize, refuse, endLaunch and credentialStatus, plus
Broker.endLaunch (decision 77); no socket verb, event kind or schema
changed. The session environment is an allowlist, and the runner exits
before claiming if a founder credential reaches it.
Filbert built it (agents/filbert/work/s6). Darkwing reviewed on #1523:
round 1 changes (26991/26995), round 2 changes (27004/27010), round 3
changes (27012/27032), round 4 approve (27033/27034) on manifest
08a78972, 42 paths.
Sage's gate on 64b0f9f1 (12:32Z), sequential and teed: node suites
business 60, bus 74, cli 83, control-board 124, conversation 182,
discord 178, harness 56, ledger 78, mosaic 69, queue 149, runs 41,
seat 27, tasks 51, webui 39, every one 0 failed; test-auth 15,
test-conductor 17, test-config 24, test-discord 66,
test-extension-package 18, test-foundation 44, test-queue 27,
test-release 14, test-task 98 and 26 without Docker, all 0 failed.
64b0f9f1..ee7f909b touches none of the 42 paths. It landed after the
Q14 hold ended at 2026-10-11T17:19:23.857Z. The recorded PM-to-coder
run on a scratch data root is still owed before row 41 moves to done.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
373 lines
14 KiB
JavaScript
373 lines
14 KiB
JavaScript
// The managed session process: `node runner.mjs <run dir>`, with the run's
|
|
// capability as one JSON line on stdin ({"cap": "..."}). The host starts it
|
|
// inside its own PID namespace (packages/seat/src/session.mjs) after the
|
|
// broker bound the launch, and ends the launch when it exits.
|
|
//
|
|
// 1. Founder-credential stop (REQ-CRED-2): a known founder credential
|
|
// variable in the environment, or a service the role needs with no
|
|
// usable role token, exits 20 before the role is claimed.
|
|
// 2. role.claim; a refusal exits 21.
|
|
// 3. The tool socket (0600) serves the harness's typed tools: a name in
|
|
// tools.json is forwarded with the runner's capability, to the broker
|
|
// or, for `launch`, to the host's launch socket. The harness never sees
|
|
// the capability.
|
|
// 4. One harness turn per received message, through the adapter, in its
|
|
// own process group under a wall clock (S0 line 4). The turn's stdout
|
|
// goes back to the sender as a RESULT, except for an incoming RESULT,
|
|
// which never gets an automatic reply. Then message.read.
|
|
// 5. SIGTERM ends the turn, releases the role and exits 0; before the
|
|
// claim it exits 0 without claiming.
|
|
//
|
|
// Exit codes: 0 stopped, 20 founder credentials, 21 claim refused, 22 the
|
|
// capability or the claim stopped working (ended, revoked), 23 the broker
|
|
// stayed unreachable, 2 a bad run directory.
|
|
|
|
import { spawn } from "node:child_process";
|
|
import { chmodSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, closeSync } from "node:fs";
|
|
import { createServer, connect } from "node:net";
|
|
import { join } from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import { Client } from "../../bus/src/client.mjs";
|
|
import { SOURCES } from "./bundle.mjs";
|
|
import { claudeBuiltins } from "./gate.mjs";
|
|
|
|
export const EXIT = Object.freeze({ stopped: 0, founder: 20, claim: 21, session: 22, broker: 23, usage: 2 });
|
|
|
|
// Environment variables that carry a founder (human) credential for a
|
|
// service the broker brokers. The host's environment allowlist keeps them
|
|
// out; finding one here means the allowlist was bypassed.
|
|
export const FOUNDER_ENV = Object.freeze([
|
|
"GITEA_TOKEN", "GITEA_ACCESS_TOKEN", "TEA_TOKEN", "MOSAIC_GITEA_CREDENTIAL_FILE",
|
|
"GH_TOKEN", "GITHUB_TOKEN", "VIKUNJA_TOKEN", "VIKUNJA_API_TOKEN", "SSH_AUTH_SOCK",
|
|
"GIT_ASKPASS", "GIT_CONFIG_PARAMETERS",
|
|
]);
|
|
const USABLE = new Set(["valid", "expiring", "rotation-due"]);
|
|
// Capabilities that stopped working: the run ended, the claim was revoked.
|
|
const SESSION_GONE = new Set(["unauthenticated", "not-holder", "run-ended"]);
|
|
const BODY_MAX = 32000;
|
|
const DEFAULTS = Object.freeze({ turnTimeout: 900, pollInterval: 2000, brokerRetries: 30 });
|
|
|
|
// founderCheck(policy, env) -> null | reason
|
|
export function founderCheck(policy, env) {
|
|
const found = FOUNDER_ENV.filter((k) => env[k] !== undefined);
|
|
if (found.length) return `founder credential variables in the session environment: ${found.join(", ")}`;
|
|
for (const service of policy.needs ?? []) {
|
|
const own = (policy.credentials ?? []).filter((c) => c.service === service && USABLE.has(c.state));
|
|
if (!own.length) return `no usable role token for ${service}`;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// The request text one message becomes.
|
|
export function turnRequest(m) {
|
|
const head = [`Message ${m.id} from ${m.from_role}`, `class ${m.class}`];
|
|
if (m.in_reply_to) head.push(`in reply to ${m.in_reply_to}`);
|
|
if (m.decision) head.push(`citing decision ${m.decision}`);
|
|
return `${head.join(", ")}:\n\n${m.body}`;
|
|
}
|
|
|
|
function clip(text) {
|
|
return text.length > BODY_MAX ? text.slice(0, BODY_MAX) + "\n[answer cut at 32000 characters]" : text;
|
|
}
|
|
|
|
const readLine = (stream) => new Promise((resolve, reject) => {
|
|
let input = "";
|
|
stream.setEncoding("utf8");
|
|
stream.on("data", (s) => {
|
|
input += s;
|
|
if (input.length > 4096) reject(new Error("stdin too large"));
|
|
});
|
|
stream.on("end", () => resolve(input));
|
|
stream.on("error", reject);
|
|
});
|
|
|
|
// One JSON line to the host's launch socket.
|
|
function launchCall(path, request, timeout = 120_000) {
|
|
return new Promise((resolve, reject) => {
|
|
const socket = connect(path);
|
|
let input = "";
|
|
let settled = false;
|
|
const end = (error, value) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
socket.destroy();
|
|
error ? reject(error) : resolve(value);
|
|
};
|
|
socket.setTimeout(timeout, () => end(Object.assign(new Error("launch timed out"), { code: "outcome-unknown" })));
|
|
socket.on("connect", () => socket.write(JSON.stringify(request) + "\n"));
|
|
socket.on("error", () => end(Object.assign(new Error("launch socket"), { code: "outcome-unknown" })));
|
|
socket.on("end", () => end(Object.assign(new Error("launch socket closed"), { code: "outcome-unknown" })));
|
|
socket.on("data", (b) => {
|
|
input += b.toString("utf8");
|
|
if (!input.includes("\n")) return;
|
|
try {
|
|
const r = JSON.parse(input);
|
|
r.ok ? end(null, r.result) : end(Object.assign(new Error(r.error), { code: r.error }));
|
|
} catch {
|
|
end(Object.assign(new Error("invalid launch reply"), { code: "invalid-response" }));
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// Serve the typed tools on `path`. Returns the server.
|
|
function toolServer({ path, tools, client, launch }) {
|
|
const byName = new Map(tools.map((t) => [t.name, t]));
|
|
rmSync(path, { force: true });
|
|
const server = createServer((socket) => {
|
|
let input = "";
|
|
const answer = (r) => socket.end(JSON.stringify(r) + "\n");
|
|
socket.setTimeout(150_000, () => socket.destroy());
|
|
socket.on("error", () => {});
|
|
socket.on("data", async (b) => {
|
|
input += b.toString("utf8");
|
|
if (input.length > 65536) return answer({ ok: false, error: "request-too-large" });
|
|
if (!input.includes("\n")) return;
|
|
let request;
|
|
try {
|
|
request = JSON.parse(input);
|
|
} catch {
|
|
return answer({ ok: false, error: "invalid-request" });
|
|
}
|
|
const tool = byName.get(request?.tool);
|
|
if (!tool) return answer({ ok: false, error: "unknown-tool" });
|
|
const args = request.args && typeof request.args === "object" && !Array.isArray(request.args) ? request.args : {};
|
|
try {
|
|
const result = tool.verb === "launch" ? await launch(args) : await client.call(tool.verb, args);
|
|
answer({ ok: true, result });
|
|
} catch (e) {
|
|
answer({ ok: false, error: /^[a-z-]{1,64}$/.test(e.code ?? "") ? e.code : "tool-failed" });
|
|
}
|
|
});
|
|
});
|
|
return new Promise((resolve, reject) => {
|
|
server.once("error", reject);
|
|
server.listen(path, () => {
|
|
chmodSync(path, 0o600);
|
|
resolve(server);
|
|
});
|
|
});
|
|
}
|
|
|
|
// Run one turn. Resolves { ok, text, reason }.
|
|
function runTurn({ session, request, n, env, onChild }) {
|
|
const marker = session.turnMarker;
|
|
rmSync(marker, { force: true });
|
|
const turns = join(session.runDir, "turns");
|
|
mkdirSync(turns, { recursive: true, mode: 0o700 });
|
|
const errFd = openSync(join(turns, `${String(n).padStart(4, "0")}.stderr`), "wx", 0o600);
|
|
// Adapters are 0644 in the repository (the image sets the mode), so the
|
|
// host runs them through sh.
|
|
const child = spawn("/bin/sh", [session.adapter], {
|
|
cwd: session.workspace,
|
|
env: { ...env, MOSAIC_REQUEST: request },
|
|
stdio: ["ignore", "pipe", errFd],
|
|
detached: true,
|
|
});
|
|
closeSync(errFd);
|
|
onChild(child);
|
|
let out = "";
|
|
child.stdout.setEncoding("utf8");
|
|
child.stdout.on("data", (s) => {
|
|
if (out.length < 4 * BODY_MAX) out += s;
|
|
});
|
|
return new Promise((resolve) => {
|
|
let timedOut = false;
|
|
const kill = (signal) => {
|
|
try {
|
|
process.kill(-child.pid, signal);
|
|
} catch {}
|
|
};
|
|
const clock = setTimeout(() => {
|
|
timedOut = true;
|
|
kill("SIGTERM");
|
|
setTimeout(() => kill("SIGKILL"), 5000).unref();
|
|
}, session.turnTimeout * 1000);
|
|
child.on("error", (e) => {
|
|
clearTimeout(clock);
|
|
onChild(null);
|
|
resolve({ ok: false, reason: `adapter didn't start: ${e.code ?? e.message}` });
|
|
});
|
|
child.on("close", (code, signal) => {
|
|
clearTimeout(clock);
|
|
kill("SIGKILL");
|
|
onChild(null);
|
|
if (timedOut) return resolve({ ok: false, reason: `turn passed its ${session.turnTimeout} s wall clock` });
|
|
if (code !== 0) return resolve({ ok: false, reason: signal ? `adapter killed by ${signal}` : `adapter exited ${code}` });
|
|
if (session.harness === "pi" && !existsSync(marker)) return resolve({ ok: false, reason: "pi exited before the turn ended (no agent_end)" });
|
|
resolve({ ok: true, text: out.trim() });
|
|
});
|
|
});
|
|
}
|
|
|
|
// The adapter's environment: the host's allowlisted environment plus the
|
|
// bundle. Nothing here is a credential.
|
|
export function adapterEnv(session, env) {
|
|
const b = session.bundle;
|
|
const tools = JSON.parse(readFileSync(b.tools, "utf8"));
|
|
const policy = JSON.parse(readFileSync(b.policy, "utf8"));
|
|
const out = {
|
|
...env,
|
|
MOSAIC_SYSTEM_PROMPT_FILE: b.prompt,
|
|
MOSAIC_WORKSPACE: session.workspace,
|
|
MOSAIC_SESSION_DIR: session.sessionDir,
|
|
MOSAIC_POLICY_FILE: b.policy,
|
|
MOSAIC_TOOLS_FILE: b.tools,
|
|
MOSAIC_TOOL_SOCKET: session.toolSocket,
|
|
MOSAIC_TURN_MARKER: session.turnMarker,
|
|
MOSAIC_PROVIDER: session.provider ?? "",
|
|
MOSAIC_MODEL: session.model,
|
|
};
|
|
if (session.harness === "pi") {
|
|
out.MOSAIC_TOOLS = [...policy.tools, ...tools.map((t) => t.name)].join(",");
|
|
out.MOSAIC_EXTENSIONS = SOURCES.piExtension;
|
|
out.PI_PROVIDER = session.provider ?? "";
|
|
out.PI_MODEL = session.model;
|
|
} else {
|
|
out.MOSAIC_TOOLS = claudeBuiltins(policy).join(",");
|
|
out.MOSAIC_CLAUDE_SETTINGS = b.settings;
|
|
out.MOSAIC_CLAUDE_MCP_CONFIG = b.mcp;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export async function main(runDir, { stdin = process.stdin, env = process.env, log = (s) => process.stderr.write(s + "\n") } = {}) {
|
|
// The handlers go in first. Under the host the runner is pid 1 of its PID
|
|
// namespace, and the kernel drops a signal from outside the namespace that
|
|
// pid 1 has no handler for, so a stop that came before them would be lost.
|
|
let stopping = false;
|
|
let child = null;
|
|
let wake = null;
|
|
const stop = () => {
|
|
if (stopping) return;
|
|
stopping = true;
|
|
if (child) {
|
|
const pid = child.pid;
|
|
const kill = (signal) => {
|
|
try {
|
|
process.kill(-pid, signal);
|
|
} catch {}
|
|
};
|
|
kill("SIGTERM");
|
|
setTimeout(() => kill("SIGKILL"), 5000).unref();
|
|
}
|
|
wake?.();
|
|
};
|
|
process.on("SIGTERM", stop);
|
|
process.on("SIGINT", stop);
|
|
|
|
let session, cap, policy;
|
|
try {
|
|
session = { ...DEFAULTS, ...JSON.parse(readFileSync(join(runDir, "session.json"), "utf8")), runDir };
|
|
cap = JSON.parse(await readLine(stdin)).cap;
|
|
if (typeof cap !== "string" || !/^[0-9a-f]{64}$/.test(cap)) throw new Error("no capability on stdin");
|
|
policy = JSON.parse(readFileSync(session.bundle.policy, "utf8"));
|
|
} catch (e) {
|
|
log(`runner: ${e.message}`);
|
|
return EXIT.usage;
|
|
}
|
|
if (stopping) {
|
|
log("runner: stopped before claim");
|
|
return EXIT.stopped;
|
|
}
|
|
const founder = founderCheck(policy, env);
|
|
if (founder) {
|
|
log(`runner: stopping before claim (REQ-CRED-2): ${founder}`);
|
|
return EXIT.founder;
|
|
}
|
|
const client = new Client({ path: session.brokerSocket, cap, timeout: 30_000 });
|
|
try {
|
|
await client.call("role.claim");
|
|
} catch (e) {
|
|
// No answer is not a refusal: the broker is down or the outcome is unknown.
|
|
if (e.code === "outcome-unknown") {
|
|
log("runner: role.claim got no answer from the broker");
|
|
return EXIT.broker;
|
|
}
|
|
log(`runner: role.claim refused: ${e.code ?? e.message}`);
|
|
return SESSION_GONE.has(e.code) ? EXIT.session : EXIT.claim;
|
|
}
|
|
log(`runner: ${session.business}/${session.instance} claimed by run ${session.run}`);
|
|
|
|
const tools = JSON.parse(readFileSync(session.bundle.tools, "utf8"));
|
|
const server = await toolServer({
|
|
path: session.toolSocket,
|
|
tools,
|
|
client,
|
|
launch: (args) => launchCall(session.launchSocket, { cap, instance: args.instance }),
|
|
});
|
|
const turnEnv = adapterEnv(session, env);
|
|
|
|
let code = EXIT.stopped;
|
|
let failures = 0;
|
|
let n = 0;
|
|
const sleep = (ms) => new Promise((r) => {
|
|
const t = setTimeout(r, ms);
|
|
wake = () => {
|
|
clearTimeout(t);
|
|
r();
|
|
};
|
|
});
|
|
loop: while (!stopping) {
|
|
let messages;
|
|
try {
|
|
messages = await client.call("message.receive");
|
|
failures = 0;
|
|
} catch (e) {
|
|
if (SESSION_GONE.has(e.code)) {
|
|
log(`runner: session stopped working: ${e.code}`);
|
|
code = EXIT.session;
|
|
break;
|
|
}
|
|
if (++failures >= session.brokerRetries) {
|
|
log(`runner: broker unreachable after ${failures} tries: ${e.code ?? e.message}`);
|
|
code = EXIT.broker;
|
|
break;
|
|
}
|
|
await sleep(session.pollInterval);
|
|
continue;
|
|
}
|
|
for (const m of messages) {
|
|
if (stopping) break loop;
|
|
const turn = await runTurn({ session, request: turnRequest(m), n: ++n, env: turnEnv, onChild: (c) => (child = c) });
|
|
if (stopping) break loop;
|
|
if (!turn.ok) log(`runner: turn ${n} for message ${m.id} failed: ${turn.reason}`);
|
|
try {
|
|
if (m.class !== "RESULT") {
|
|
const body = turn.ok ? turn.text || "(the session gave no answer)" : `The turn failed: ${turn.reason}`;
|
|
await client.call("message.send", { to: m.from_role, body: clip(body), class: "RESULT", in_reply_to: m.id });
|
|
}
|
|
await client.call("message.read", { id: m.id });
|
|
} catch (e) {
|
|
log(`runner: reply to ${m.id} failed: ${e.code ?? e.message}`);
|
|
if (SESSION_GONE.has(e.code)) {
|
|
code = EXIT.session;
|
|
break loop;
|
|
}
|
|
}
|
|
}
|
|
if (!messages.length) await sleep(session.pollInterval);
|
|
}
|
|
server.close();
|
|
rmSync(session.toolSocket, { force: true });
|
|
if (code === EXIT.stopped) {
|
|
try {
|
|
await client.call("role.release");
|
|
} catch (e) {
|
|
log(`runner: role.release: ${e.code ?? e.message}`);
|
|
}
|
|
}
|
|
log(`runner: exiting ${code}`);
|
|
return code;
|
|
}
|
|
|
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
if (!process.argv[2]) {
|
|
process.stderr.write("usage: runner.mjs <run dir> (capability on stdin)\n");
|
|
process.exit(EXIT.usage);
|
|
}
|
|
process.exitCode = await main(process.argv[2]);
|
|
// Pending sockets or timers must not keep a stopped session alive.
|
|
setTimeout(() => process.exit(process.exitCode), 1000).unref();
|
|
}
|