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]>
This commit is contained in:
2026-10-11 12:22:50 -05:00
co-authored by Claude Opus 5.5
parent ee7f909bd3
commit fe5c68fa03
42 changed files with 4998 additions and 63 deletions
+155
View File
@@ -0,0 +1,155 @@
// The launch bundle: everything a managed session runs from, generated
// from one resolved role instance and written once into the run
// directory. prompt.md, policy.json, tools.json and manifest.json for both
// harnesses; settings.json (the gate hook) and mcp.json (the typed tools)
// for Claude Code. The Pi extension and the Claude gate stay at their
// source paths; the manifest records their digests.
//
// Each harness layer the bundle relies on is a row S0 line
// (docs/plans/2026-10-04_slice-1.md, "What S6 can rely on"); the manifest
// names them.
import { createHash } from "node:crypto";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { typedTools, untypedActions } from "./tools.mjs";
import { claudeBuiltins, MCP_PREFIX } from "./gate.mjs";
export const BUNDLE_VERSION = 1;
export const HARNESSES = Object.freeze(["pi", "claude-code"]);
export const SOURCES = Object.freeze({
piExtension: fileURLToPath(new URL("./pi-extension.mjs", import.meta.url)),
claudeGate: fileURLToPath(new URL("./claude-gate.mjs", import.meta.url)),
mcpServer: fileURLToPath(new URL("./mcp-server.mjs", import.meta.url)),
gate: fileURLToPath(new URL("./gate.mjs", import.meta.url)),
tools: fileURLToPath(new URL("./tools.mjs", import.meta.url)),
});
// S0 lines 1-5 for both harnesses; line 6 is the Agent SDK, which S6
// doesn't use.
const RELIES_ON = Object.freeze({
pi: ["S0-1 tool_call block", "S0-2 a throw blocks", "S0-3 a missing -e refuses to start", "S0-4 external wall clock plus agent_end check", "S0-5 bash is limited by the tool limit only"],
"claude-code": ["S0-1 command hook deny, no --bare", "S0-2 hook wrapped as <gate> || exit 2", "S0-3 a missing gate blocks through the wrapper", "S0-4 timeout -k 2 10 <gate> || exit 2 with hook timeout 20", "S0-5 bash is limited by the tool limit only"],
});
// The Claude hook: N = 10, K = 2, hook timeout 20, which leaves the
// margin the brief asks for above N + K.
const GATE_TIMEOUT = { n: 10, k: 2, hook: 20 };
export class BundleError extends Error {}
const sha256 = (text) => createHash("sha256").update(text).digest("hex");
const quote = (s) => {
if (s.includes("'")) throw new BundleError(`path can't hold a single quote: ${s}`);
return `'${s}'`;
};
// The harness, provider and model one instance runs with. The agent-layer
// `harness` and `model` variables win; otherwise the system's execution
// adapter and model. Anything outside the two harnesses refuses.
export function sessionModel(resolved) {
const v = resolved.vars;
const harness = v.harness ?? v["execution.adapter"];
if (!HARNESSES.includes(harness)) throw new BundleError(`instance ${resolved.instance}: harness ${JSON.stringify(harness)} isn't one of ${HARNESSES.join(", ")}`);
const model = v.model ?? v["execution.model"];
if (typeof model !== "string" || !model) throw new BundleError(`instance ${resolved.instance}: no model`);
return { harness, provider: v["execution.provider"] ?? null, model, thinking: v.thinking ?? null };
}
function prompt({ contract, resolved, session, tools, untyped }) {
const lines = [
contract.trimEnd(),
"",
"## This session",
"",
`- Business: ${resolved.business}. Role instance: ${resolved.instance} (definition ${resolved.definition}). Run: ${session.run}.`,
`- Launched by: ${session.launchedBy}. Harness: ${session.harness}, model ${session.model}.`,
`- Workspace: ${session.workspace}. File tools work inside it only.`,
`- Built-in tools: ${resolved.limits.tools.length ? resolved.limits.tools.join(", ") : "none"}.`,
`- Bus tools: ${tools.map((t) => t.name).join(", ")}. The broker decides every call; a refusal names its reason.`,
"- Each message to you arrives as one turn. Your final answer goes back to the sender as the reply, so end each turn with the answer itself.",
"- You never hold a token. You act on the tracker, the forge and other roles only through the bus tools.",
];
if (untyped.length) lines.push(`- Actions you hold that have no bus tool yet: ${untyped.join(", ")}. Say so instead of working around it.`);
return lines.join("\n") + "\n";
}
// buildBundle({ dir, resolved, contract, arbiter, tracker, credentials, session, piVersion, claudeVersion })
// dir the bundle directory, created here (0700)
// resolved resolveInstance(...) output
// contract the role contract's text
// arbiter true when the business names this instance as an arbiter
// tracker true when the broker has task verbs for the business
// credentials the broker's credentialStatus(...) for the instance (metadata only)
// session { run, launchedBy, workspace, toolSocket, turnMarker }
// piVersion the pinned pi version, for the manifest
// claudeVersion the host's `claude --version`, for the manifest
// Returns { manifest, files } with absolute paths.
export function buildBundle({ dir, resolved, contract, arbiter = false, tracker = false, credentials = [], session, piVersion = null, claudeVersion = null }) {
const model = sessionModel(resolved);
const tools = typedTools({ resolved, arbiter, tracker });
const untyped = untypedActions(resolved, tools);
mkdirSync(dir, { recursive: true, mode: 0o700 });
const write = (name, text) => {
writeFileSync(join(dir, name), text, { mode: 0o600, flag: "wx" });
return [name, sha256(text)];
};
const policy = {
harness: model.harness,
workspace: session.workspace,
tools: [...resolved.limits.tools],
typed: tools.map((t) => t.name),
network: resolved.limits.network,
needs: Object.keys(resolved.credentials ?? {}),
credentials,
};
const files = {
prompt: join(dir, "prompt.md"),
policy: join(dir, "policy.json"),
tools: join(dir, "tools.json"),
manifest: join(dir, "manifest.json"),
};
const digests = [
write("prompt.md", prompt({ contract, resolved, session: { ...session, ...model }, tools, untyped })),
write("policy.json", JSON.stringify(policy, null, 2) + "\n"),
write("tools.json", JSON.stringify(tools, null, 2) + "\n"),
];
const sources = { gate: SOURCES.gate, tools: SOURCES.tools };
if (model.harness === "pi") {
sources.piExtension = SOURCES.piExtension;
} else {
sources.claudeGate = SOURCES.claudeGate;
sources.mcpServer = SOURCES.mcpServer;
files.settings = join(dir, "settings.json");
files.mcp = join(dir, "mcp.json");
const node = process.execPath;
const command = `timeout -k ${GATE_TIMEOUT.k} ${GATE_TIMEOUT.n} ${quote(node)} ${quote(SOURCES.claudeGate)} ${quote(files.policy)} || exit 2`;
digests.push(write("settings.json", JSON.stringify({
hooks: { PreToolUse: [{ matcher: "*", hooks: [{ type: "command", command, timeout: GATE_TIMEOUT.hook }] }] },
}, null, 2) + "\n"));
digests.push(write("mcp.json", JSON.stringify({
mcpServers: { mosaic: { type: "stdio", command: node, args: [SOURCES.mcpServer, files.tools, session.toolSocket] } },
}, null, 2) + "\n"));
}
const manifest = {
bundleVersion: BUNDLE_VERSION,
business: resolved.business,
instance: resolved.instance,
definition: resolved.definition,
run: session.run,
launchedBy: session.launchedBy,
resolvedDigest: resolved.digest,
...model,
piVersion: model.harness === "pi" ? piVersion : null,
claudeVersion: model.harness === "claude-code" ? claudeVersion : null,
builtins: model.harness === "pi" ? [...resolved.limits.tools] : claudeBuiltins(policy),
typed: tools.map((t) => (model.harness === "pi" ? t.name : MCP_PREFIX + t.name)),
untyped,
skills: [],
reliesOn: RELIES_ON[model.harness],
gateTimeout: model.harness === "claude-code" ? GATE_TIMEOUT : null,
files: Object.fromEntries(digests),
sources: Object.fromEntries(Object.entries(sources).map(([k, p]) => [k, { path: p, sha256: sha256(readFileSync(p)) }])),
};
writeFileSync(files.manifest, JSON.stringify(manifest, null, 2) + "\n", { mode: 0o600, flag: "wx" });
return { manifest, files, policy, tools };
}
+25
View File
@@ -0,0 +1,25 @@
// Claude Code PreToolUse command hook for a managed session. The bundle's
// settings.json runs it as
// timeout -k 2 10 <node> claude-gate.mjs <policy.json> || exit 2
// with hook timeout 20 (S0 lines 1-4): exit 0 lets the tool run, exit 2
// blocks it with the reason on stderr, and the wrapper turns a crash, a
// missing file or a timeout into exit 2.
import { readFileSync } from "node:fs";
import { decide } from "./gate.mjs";
function block(reason) {
process.stderr.write(`${reason}\n`);
process.exit(2);
}
try {
const policy = JSON.parse(readFileSync(process.argv[2], "utf8"));
if (policy.harness !== "claude-code") block("mosaic gate: policy file is not a claude-code policy");
const event = JSON.parse(readFileSync(0, "utf8"));
const verdict = decide(policy, event.tool_name, event.tool_input);
if (!verdict.allow) block(verdict.reason);
process.exit(0);
} catch (error) {
block(`mosaic gate: ${error.message}`);
}
+164
View File
@@ -0,0 +1,164 @@
// The tool gate both harnesses share. decide(policy, tool, input) answers
// one tool call: the policy's built-in tools and its typed tools pass,
// everything else is blocked, and every path argument of a file tool must
// resolve inside the workspace.
//
// This is a gate on the harness's own tool calls, not a sandbox. `bash`
// isn't confined (the role's tool limit decides whether it exists at all),
// and anything bash can reach, the session can reach. See the README's
// limits.
import { lstatSync, realpathSync } from "node:fs";
import { homedir } from "node:os";
import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
// pi built-in tool -> Claude Code tool. find and ls both map to Glob.
export const CLAUDE_TOOLS = Object.freeze({ read: "Read", write: "Write", edit: "Edit", bash: "Bash", grep: "Grep", find: "Glob", ls: "Glob" });
export const MCP_PREFIX = "mcp__mosaic__";
// The input field that holds a path, per tool, per harness. A missing
// optional path means the working directory, which is the workspace.
const PATH_FIELDS = {
pi: { read: "path", write: "path", edit: "path", grep: "path", find: "path", ls: "path" },
"claude-code": { Read: "file_path", Write: "file_path", Edit: "file_path", Grep: "path", Glob: "path" },
};
// Claude Code tool names the session may call, for --tools and the gate.
export function claudeBuiltins(policy) {
return [...new Set(policy.tools.map((t) => CLAUDE_TOOLS[t]))];
}
// Pi's own path normalisation (dist/utils/paths.js normalizePath with the
// options resolveToCwd passes): unicode spaces, a leading @, ~ and file://.
const UNICODE_SPACES = /[  -    ]/g;
function normalise(p) {
let s = p.replace(UNICODE_SPACES, " ");
if (s.startsWith("@")) s = s.slice(1);
if (s === "~") return homedir();
if (s.startsWith("~/")) return join(homedir(), s.slice(2));
if (/^file:\/\//.test(s)) return fileURLToPath(s);
return s;
}
// Realpath of the nearest ancestor that exists as a name, with the missing
// tail kept. lstat, not exists: a dangling symlink exists as a name, and a
// write through it would create its target wherever that is. So a path that
// reaches a dangling symlink, at any depth, can't be checked and is refused.
function real(p) {
let head = p;
const tail = [];
while (!lstatSync(head, { throwIfNoEntry: false })) {
const up = dirname(head);
if (up === head) break;
tail.unshift(basename(head));
head = up;
}
try {
return join(realpathSync(head), ...tail);
} catch (error) {
if (error.code === "ENOENT") throw Object.assign(new Error("dangling symlink"), { code: "dangling-symlink" });
throw error;
}
}
function within(workspace, path) {
const root = realpathSync(workspace);
const target = real(path);
return target === root || target.startsWith(root + sep);
}
// A relative path is resolved the way the tool resolves it: against its
// cwd. Both adapters cd into the workspace, and a process's cwd is the real
// path, so `..` climbs the real path's parents, not those of a workspace or
// dataRoot given through a symlink. It must be inside against the path as
// given too, so the check doesn't rest on how the harness was started.
export function insideWorkspace(workspace, p) {
const s = normalise(p);
if (isAbsolute(s)) return within(workspace, resolve(s));
return within(workspace, resolve(realpathSync(workspace), s)) && within(workspace, resolve(workspace, s));
}
// Pi's read (dist/core/tools/path-utils.js resolveReadPathAsync) opens
// another spelling when the resolved path doesn't exist: U+202F before
// AM/PM, then NFD, then U+2019 for ', then both. Each applies to the whole
// resolved path, directory names included, and Pi resolves against its cwd,
// the workspace's real path. So every spelling that exists as a name is
// checked, not only the one Pi would pick, and the check doesn't depend on
// which of them exists when Pi opens it. Claude Code's Read gets the same
// check; its own retries aren't documented.
function spellings(workspace, p) {
const s = normalise(p);
const bases = isAbsolute(s) ? [resolve(s)] : [resolve(workspace, s), resolve(realpathSync(workspace), s)];
const curly = (v) => v.replace(/'/g, "\u2019");
const out = new Set();
for (const r of bases) {
const nfd = r.normalize("NFD");
for (const v of [r.replace(/ (AM|PM)\./gi, "\u202F$1."), nfd, curly(r), curly(nfd)]) if (v !== r) out.add(v);
}
return out;
}
// The first spelling that exists and resolves outside, or through a
// dangling link, as a refusal reason; null if there is none.
function otherSpelling(workspace, tool, p) {
for (const v of spellings(workspace, p)) {
let found;
try {
found = lstatSync(v, { throwIfNoEntry: false });
} catch (error) {
// A file used as a directory: Pi's access() fails too.
if (error.code === "ENOTDIR") continue;
return `${tool} path can't be checked under another spelling: ${error.code ?? error.message}`;
}
if (!found) continue;
try {
if (!within(workspace, v)) return `${tool} path is outside the workspace under another spelling: ${p} -> ${JSON.stringify(v)}`;
} catch (error) {
if (error.code === "dangling-symlink") return `${tool} path goes through a dangling symlink under another spelling: ${p} -> ${JSON.stringify(v)}`;
return `${tool} path can't be checked under another spelling: ${error.code ?? error.message}`;
}
}
return null;
}
// decide(policy, tool, input) -> { allow: true } | { allow: false, reason }
// policy { harness: "pi" | "claude-code", workspace, tools: [pi names], typed: [typed tool names] }
export function decide(policy, tool, input) {
const no = (reason) => ({ allow: false, reason: `mosaic gate: ${reason}` });
if (typeof tool !== "string") return no("tool call without a name");
const claude = policy.harness === "claude-code";
if (claude ? tool.startsWith(MCP_PREFIX) && policy.typed.includes(tool.slice(MCP_PREFIX.length)) : policy.typed.includes(tool)) {
return { allow: true };
}
const builtins = claude ? claudeBuiltins(policy) : policy.tools;
if (!builtins.includes(tool)) return no(`${tool} isn't in this session's policy`);
// A glob pattern is a second way to name a path: Glob and find take it
// relative to their root, so an absolute pattern, ~ or a .. segment
// could leave the workspace.
const pattern = (claude ? tool === "Glob" : tool === "find") ? input?.pattern : undefined;
if (pattern !== undefined && (typeof pattern !== "string" || /^[~/@]/.test(normalise(pattern).trim()) || /(^|[/\\])\.\.([/\\]|$)/.test(pattern))) {
return no(`${tool} pattern must stay inside the workspace: ${pattern}`);
}
const field = PATH_FIELDS[policy.harness]?.[tool];
if (!field) return { allow: true };
const value = input?.[field];
if (value === undefined || value === null || value === "") return { allow: true };
if (typeof value !== "string") return no(`${tool}.${field} isn't a string`);
try {
if (!insideWorkspace(policy.workspace, value)) return no(`${tool} path is outside the workspace: ${value}`);
} catch (error) {
if (error.code === "dangling-symlink") return no(`${tool} path goes through a dangling symlink: ${value}`);
return no(`${tool} path can't be checked: ${error.code ?? error.message}`);
}
if (tool === (claude ? "Read" : "read")) {
let other;
try {
other = otherSpelling(policy.workspace, tool, value);
} catch (error) {
return no(`${tool} path can't be checked: ${error.code ?? error.message}`);
}
if (other) return no(other);
}
return { allow: true };
}
+59
View File
@@ -0,0 +1,59 @@
// Minimal stdio MCP server that gives a Claude Code session its typed
// tools: `node mcp-server.mjs <tools.json> <tool socket>`. Newline-delimited
// JSON-RPC 2.0 with initialize, tools/list, tools/call and ping. Each call
// goes to the runner's tool socket; the server holds no capability.
import { readFileSync } from "node:fs";
import { createInterface } from "node:readline";
import { callTool } from "./tools.mjs";
const [toolsFile, socket] = process.argv.slice(2);
if (!toolsFile || !socket) {
process.stderr.write("usage: mcp-server.mjs <tools.json> <tool socket>\n");
process.exit(2);
}
const tools = JSON.parse(readFileSync(toolsFile, "utf8"));
const byName = new Map(tools.map((t) => [t.name, t]));
const send = (message) => process.stdout.write(JSON.stringify({ jsonrpc: "2.0", ...message }) + "\n");
const reply = (id, result) => send({ id, result });
const error = (id, code, message) => send({ id, error: { code, message } });
async function handle(m) {
const id = m.id;
switch (m.method) {
case "initialize":
return reply(id, {
protocolVersion: m.params?.protocolVersion ?? "2025-06-18",
capabilities: { tools: {} },
serverInfo: { name: "mosaic", version: "1" },
});
case "ping":
return reply(id, {});
case "tools/list":
return reply(id, { tools: tools.map((t) => ({ name: t.name, description: t.description, inputSchema: t.parameters })) });
case "tools/call": {
const name = m.params?.name;
if (!byName.has(name)) return error(id, -32602, `unknown tool: ${name}`);
try {
const result = await callTool(socket, name, m.params?.arguments ?? {});
return reply(id, { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] });
} catch (e) {
return reply(id, { content: [{ type: "text", text: e.message }], isError: true });
}
}
default:
if (id !== undefined) return error(id, -32601, `method not found: ${m.method}`);
}
}
createInterface({ input: process.stdin }).on("line", (line) => {
if (!line.trim()) return;
let m;
try {
m = JSON.parse(line);
} catch {
return error(null, -32700, "parse error");
}
handle(m).catch((e) => m.id !== undefined && error(m.id, -32603, e.message));
});
+49
View File
@@ -0,0 +1,49 @@
// Pi extension for a managed session, loaded with `-e` by adapters/pi.
// Three jobs:
// - register the session's typed tools, which call the runner's tool socket;
// - gate every tool call with gate.mjs (S0 lines 1 and 2: a block or a
// throw stops the tool);
// - write the turn marker on agent_end, so the runner can tell a finished
// turn from a pi that exited 0 mid-turn (S0 line 4).
// Missing or unreadable configuration throws here, which fails pi's start
// (S0 line 3): nothing is defaulted.
import { readFileSync, writeFileSync } from "node:fs";
import { decide } from "./gate.mjs";
import { callTool } from "./tools.mjs";
function need(name) {
const value = process.env[name];
if (typeof value !== "string" || !value) throw new Error(`mosaic extension: ${name} is not set`);
return value;
}
export default function (pi) {
const policy = JSON.parse(readFileSync(need("MOSAIC_POLICY_FILE"), "utf8"));
const tools = JSON.parse(readFileSync(need("MOSAIC_TOOLS_FILE"), "utf8"));
const socket = need("MOSAIC_TOOL_SOCKET");
const marker = need("MOSAIC_TURN_MARKER");
if (policy.harness !== "pi" || !Array.isArray(policy.tools) || !Array.isArray(policy.typed)) throw new Error("mosaic extension: policy file is not a pi policy");
pi.on("tool_call", async (event) => {
const verdict = decide(policy, event.toolName, event.input);
return verdict.allow ? undefined : { block: true, reason: verdict.reason };
});
pi.on("agent_end", async () => {
writeFileSync(marker, `${new Date().toISOString()}\n`);
});
for (const t of tools) {
pi.registerTool({
name: t.name,
label: t.name,
description: t.description,
parameters: t.parameters,
async execute(_toolCallId, params) {
// A refusal throws, which pi reports to the model as a tool error.
const result = await callTool(socket, t.name, params ?? {});
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], details: {} };
},
});
}
}
+372
View File
@@ -0,0 +1,372 @@
// 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();
}
+155
View File
@@ -0,0 +1,155 @@
// Typed tools: the only way a managed session acts on the bus. Each tool
// maps one-to-one onto a broker verb (or, for `launch`, onto the host's
// launch socket), and a session gets a tool only for an action its resolved
// instance holds within-role or cross-role, plus the reads and
// raise_decision every agent has. The broker still decides every call;
// a tool's presence is not permission.
//
// The harness never sees the capability. Tools call the runner's tool
// socket (MOSAIC_TOOL_SOCKET) with `{tool, args}`; the runner checks the
// name against this list and forwards the call with its own capability.
import { connect } from "node:net";
import { ACTIONS } from "../../business/src/vocabulary.mjs";
const str = (description, extra = {}) => ({ type: "string", description, ...extra });
const obj = (properties, required = []) => ({ type: "object", properties, required, additionalProperties: false });
const TASK_REF = str("Task reference, as list_tasks shows it");
const COMMON = {
task_ref: TASK_REF,
decision: str("Id of a resolved decision that approves this call; needed when the action is cross-role or gated for you"),
expect: str("The task digest you last saw; the broker refuses if the task changed since", { pattern: "^[0-9a-f]{64}$" }),
};
const RELATION = obj({
kind: str("Relation kind", { enum: ["subtask", "parenttask", "related", "duplicateof", "duplicates", "blocking", "blocked", "precedes", "follows", "copiedfrom", "copiedto"] }),
task_ref: TASK_REF,
}, ["kind", "task_ref"]);
const LABEL_IDS = { type: "array", items: { type: "integer", minimum: 1 }, maxItems: 50, description: "Label ids from the business file" };
const DUE = { type: ["string", "null"], description: "Due date as an ISO 8601 UTC timestamp, or null to clear" };
const PRIORITY = { type: "integer", minimum: 0, maximum: 5, description: "Priority, 0 (unset) to 5 (do now)" };
// action -> [tool name, description, properties, required]
const TASK_TOOLS = {
"task.create": ["task_create", "Create a tracker task. Every task cites the request it came from and a requirement id.", {
title: str("Task title, one line"),
description: str("Task description"),
due_date: DUE,
priority: PRIORITY,
labels: LABEL_IDS,
relations: { type: "array", items: RELATION, maxItems: 50 },
request: str("Id of the message or human input this task answers"),
requirement: str("Requirement id, such as REQ-CRED-2", { pattern: "^REQ-[A-Z]+-[1-9][0-9]*$" }),
decision: COMMON.decision,
}, ["title", "request", "requirement"]],
"task.assign": ["task_assign", "Assign an unassigned task to a role.", { ...COMMON, role: str("Role instance to assign") }, ["task_ref", "role"]],
"task.reassign": ["task_reassign", "Move an assigned task to another role.", { ...COMMON, role: str("Role instance to assign") }, ["task_ref", "role"]],
"task.schedule": ["task_schedule", "Change a task's due date, labels or relations.", {
...COMMON,
due_date: DUE,
labels: obj({ add: LABEL_IDS, remove: LABEL_IDS }),
relations: obj({ add: { type: "array", items: RELATION, maxItems: 50 }, remove: { type: "array", items: RELATION, maxItems: 50 } }),
}, ["task_ref"]],
"task.priority.change": ["task_priority_change", "Change a task's priority.", { ...COMMON, priority: PRIORITY }, ["task_ref", "priority"]],
"task.scope.change": ["task_scope_change", "Change a task's title or description.", { ...COMMON, title: str("New title"), description: str("New description") }, ["task_ref"]],
"task.update.assigned": ["task_update_assigned", "Report progress on a task assigned to you: percent done, state or a comment.", {
...COMMON,
percent_done: { type: "number", minimum: 0, maximum: 1, description: "Fraction done, 0 to 1" },
state: str("Board state", { enum: ["todo", "in-progress", "in-review", "blocked"] }),
comment: str("Comment to add"),
}, ["task_ref"]],
"task.close": ["task_close", "Close a task after its review verdict.", { ...COMMON, verdict: str("Citation of the review verdict") }, ["task_ref", "verdict"]],
};
const MESSAGE_CLASSES = ["REQUEST", "ASSIGNMENT", "REVIEW-REQUEST", "REVIEW-RESULT", "RESULT", "INFO", "DECISION", "REACTION"];
// typedTools({ resolved, arbiter, tracker }) -> [{ name, verb, action, description, parameters }]
// resolved resolveInstance(...) output for the session's instance
// arbiter true when the business names this instance as an arbiter
// tracker true when the broker has task verbs for the business
// `verb` is the broker verb; the `launch` tool has verb "launch" and goes
// to the host instead.
export function typedTools({ resolved, arbiter = false, tracker = false }) {
const held = new Set([...resolved.limits.authority.withinRole, ...resolved.limits.authority.crossRole]);
const tools = [];
const add = (name, verb, action, description, parameters) => tools.push({ name, verb, action, description, parameters });
if (held.has("message.send")) {
add("send_message", "message.send", "message.send", "Send a message to another role instance, or to \"human\". Your final answer to a message is sent back to its sender for you; use this for anything else.", obj({
to: str("Role instance, or \"human\""),
body: str("Message text"),
class: str("Message class", { enum: MESSAGE_CLASSES }),
in_reply_to: str("Id of the message this answers"),
decision: COMMON.decision,
}, ["to", "body"]));
}
if (resolved.launch) {
add("launch", "launch", "role.launch", `Launch a managed session for one of these role instances: ${resolved.launch.instances.join(", ")}. The host refuses an instance that is already running, a full model family, or a launch while launches are switched off.`, obj({
instance: str("Role instance to launch", { enum: [...resolved.launch.instances] }),
}, ["instance"]));
}
if (tracker) {
for (const [action, [name, description, properties, required]] of Object.entries(TASK_TOOLS)) {
if (held.has(action)) add(name, action, action, description, obj(properties, required));
}
}
add("raise_decision", "decision.raise", null, "Ask for a decision before a gated or cross-role action, or when you need a choice made. The answer arrives as a message.", obj({
action: str("The action the decision is about", { enum: [...ACTIONS] }),
question: str("The question, with enough context to answer it"),
options: { type: "array", minItems: 2, maxItems: 9, items: obj({ key: str("Short key"), text: str("What this option means") }, ["key", "text"]) },
recommendation: str("Your recommended option key, with a reason"),
blocking: { type: "boolean", description: "True when you can't continue without the answer" },
target: str("What the action applies to, such as a role instance or task reference"),
approvalChoice: str("The option key that approves the action"),
task_ref: TASK_REF,
requirement_ref: str("Requirement id"),
}, ["action", "question", "options", "recommendation", "blocking"]));
if (arbiter) {
add("resolve_decision", "decision.resolve", null, "Resolve a decision routed to you.", obj({
id: str("Decision id"),
choice: str("The option key you choose"),
note: str("Reason, one or two sentences"),
}, ["id", "choice"]));
}
add("list_agents", "agents", null, "List the role instances that hold a claim now.", obj({}));
add("inbox", "inbox", null, "List the open decisions routed to you.", obj({}));
add("trail", "trail", null, "Show the evidence trail for a subject: a run, message, decision or task.", obj({ subject: str("Subject id") }, ["subject"]));
if (tracker) add("list_tasks", "tasks", null, "List the business's tracker tasks as the broker last saw them.", obj({}));
return tools;
}
// Held actions that have no typed tool. The manifest lists them so a
// reviewer sees what the session can't do through the bus.
export function untypedActions(resolved, tools) {
const typed = new Set(tools.map((t) => t.action).filter(Boolean));
return [...resolved.limits.authority.withinRole, ...resolved.limits.authority.crossRole].filter((a) => !typed.has(a));
}
// One call over a tool socket: a JSON line out, a JSON line back.
export function callTool(path, tool, args = {}, { timeout = 60_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(new Error("tool call timed out")));
socket.on("connect", () => socket.write(JSON.stringify({ tool, args }) + "\n"));
socket.on("error", (e) => end(new Error(`tool socket: ${e.code ?? e.message}`)));
socket.on("end", () => end(new Error("tool socket closed without a reply")));
socket.on("data", (b) => {
input += b.toString("utf8");
if (input.length > 4 * 1024 * 1024) return end(new Error("tool reply too large"));
if (!input.includes("\n")) return;
try {
const r = JSON.parse(input);
r.ok ? end(null, r.result) : end(new Error(`refused: ${r.error}`));
} catch {
end(new Error("tool reply isn't JSON"));
}
});
});
}