Files
stack/packages/harness/src/runner.mjs
T
jason.woltjeandClaude Opus 5.5 fe5c68fa03 feat(harness): meta-harness and PM launching off T3 (#1523, row 41)
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]>
2026-10-11 12:22:50 -05:00

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();
}