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:
@@ -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 };
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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: {} };
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user