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
+206
View File
@@ -0,0 +1,206 @@
# harness
What a managed role session runs from and runs as (slice 1 S6, issue #1523):
the launch bundle generated from one resolved role instance, the tool gate
both harnesses share, the typed bus tools, the Pi extension, the Claude Code
hook and MCP server, and the session runner. The host's launcher
(`packages/cli/src/launcher.mjs`, `packages/seat/src/session.mjs`) builds the bundle and
starts the runner; nothing here spawns a session on its own.
Plain ESM, no dependencies, Node 24 or newer. Tests: `npm test` in this
directory (the Pi and Claude Code session tests drive the real CLIs against
a scripted Messages API on 127.0.0.1; the Claude Code ones skip when
`claude` isn't on PATH).
## What S6 relies on
Every harness layer below is a row S0 line
(`docs/plans/2026-10-04_slice-1.md`, "What S6 can rely on"), and each
bundle's `manifest.json` names the lines it relies on (`reliesOn`):
| | Pi | Claude Code |
|---|---|---|
| 1. block a tool | `tool_call` handler returns a block | PreToolUse command hook exits 2; `--bare` is never passed |
| 2. gate crash | a throw in the handler blocks | the hook runs as `<gate> \|\| exit 2` |
| 3. gate missing | a missing `-e` file refuses to start | the wrapper blocks |
| 4. gate hang | the runner's wall clock plus the `agent_end` marker | `timeout -k 2 10 <gate> \|\| exit 2`, hook timeout 20 |
| 5. bash | limited only by the tool limit | limited only by the tool limit |
Claude Code also runs with `--restricted`: no user, project or local
settings, file tools confined to the working directory, and no `CLAUDE.md`
file (user, parent or workspace) or auto-memory in the prompt. None of the
S0 lines depend on it, and the gate doesn't rely on it for paths. It is the
layer that keeps founder and repository memory out of the session's
prompt, though, so `claude-session.test.mjs` fails if the adapter stops
passing it, and shows the memory reaching the model without it.
## The bundle
`buildBundle({dir, resolved, contract, arbiter, tracker, credentials, session, piVersion, claudeVersion})`
writes the bundle once into `dir` (0700; every file 0600, created with `wx`,
so an existing bundle is never overwritten):
- `prompt.md`: the role contract, then a generated "This session" section
(business, instance, run, who launched it, workspace, typed tools).
- `policy.json`: harness, workspace, built-in tools (`limits.tools`), typed
tool names, network, the services the role needs, and the credential
status the host read from the broker (metadata only, never a token).
- `tools.json`: the typed tools (below).
- `manifest.json`: bundle version, the instance and run, the resolved
digest, harness, provider, model, thinking, the Pi or Claude Code version, built-in and
typed tools, the actions without a typed tool, the S0 lines, the gate
timeout, and the sha256 of every bundle file and of the gate, extension,
MCP server and tools sources.
- Claude Code only: `settings.json` (the wrapped PreToolUse hook, matcher
`*`) and `mcp.json` (one stdio server, `mosaic`, serving the typed tools).
`sessionModel(resolved)` picks the harness, provider and model: the agent
layer's `harness` and `model` variables win, else the system's execution
adapter and model. Only `pi` and `claude-code` are accepted.
## Typed tools
A session acts on the bus only through typed tools. Each one maps onto one
broker verb, or for `launch` onto the host's launch socket. A session gets
a tool only for an action its resolved instance holds (within-role or
cross-role), plus the reads and `raise_decision`. The broker still decides
every call; a tool being present is not permission.
The harness never holds the capability. Tools call the runner's tool socket
(`tools.sock`, 0600) with `{tool, args}`; the runner checks the name
against `tools.json` and forwards the call with its own capability. A
refusal comes back to the model as `refused: <code>`.
## The gate
`gate.mjs` `decide(policy, tool, input)` answers one tool call: the
policy's built-in tools and typed tools pass, anything else is blocked, and
every path argument of a file tool (and a `find`/`Glob` pattern) must
resolve inside the workspace after symlinks and Pi's own path
normalisation. A path that reaches a dangling symlink, at any depth, is
refused even when the link points inside: a write through it would create
the target wherever it names, and Pi's `write` makes the missing
directories first. Pi calls it from the extension, Claude Code from
`claude-gate.mjs`.
A relative path is resolved the way Pi resolves it: against its working
directory. Both adapters `cd` into the workspace, and a process's working
directory is the real path, so `..` climbs the real path's parents. With
the workspace or the dataRoot behind a symlink, those differ from the
parents of the path as given, and `../../data/ws/x` can be inside as given
but outside for Pi. The gate requires a relative path to be inside from
both the real path and the path as given. The second check is stricter
than Pi: a path that only climbs out and back in through the real path's
parents is refused. That keeps the gate fail-closed whichever directory it
runs in. Claude Code isn't affected the same way: it makes a path absolute
against its own (real) working directory before the `PreToolUse` hook
sees it, so the gate gets the path Claude Code will open.
Pi's `read` doesn't always open the name it is given. When that name
doesn't exist, it tries other spellings of the whole resolved path: a
narrow no-break space (U+202F) before ` AM.` or ` PM.`, the NFD form, a
curly apostrophe (U+2019) for `'`, and NFD with the curly apostrophe. So
for `read`, the gate also checks every one of those spellings that exists
as a name, built from both the workspace as given and its real path (Pi's
working directory). If any of them resolves outside the workspace or goes
through a dangling symlink, the read is refused, whichever one Pi would
pick. A spelling that doesn't exist is ignored. Claude Code's `Read` gets
the same check, though in Darkwing's round 2 probe Claude Code's own
symlink check already stopped the one variant it tries (AM/PM). `write`,
`edit`, `grep`, `find` and `ls` take the name as given in Pi 0.85.1, so
they get no such check.
The gate copies Pi 0.85.1 code, so recheck these files on a Pi upgrade:
`dist/utils/paths.js` (`normalizePath`), `dist/core/tools/path-utils.js`
(`resolveToCwd`, `resolveReadPathAsync`) and which tools in
`dist/core/tools/` call `resolveReadPath*`. The `pi` binary runs the
bundled copy under `dist/bundle/`; in 0.85.1 it holds the same code, and
only `read` and the CLI's own file arguments call `resolveReadPath*`.
## The runner
`node runner.mjs <run dir>`, with `{"cap": "..."}` and a newline on stdin.
The host writes the capability there after the broker bound the launch, so
it never touches the disk and isn't in the environment or argv.
1. Founder-credential stop (REQ-CRED-2): a known founder credential
variable in the environment (`GITEA_TOKEN`, `GH_TOKEN`, `SSH_AUTH_SOCK`
and the rest of `FOUNDER_ENV`), or a service the role needs with no
usable role token, exits 20 before the role is claimed.
2. `role.claim`.
3. The tool socket serves the typed tools.
4. One harness turn per received message, through the adapter
(`/bin/sh <adapter.sh>`: the repository keeps adapters 0644 and only the
image sets the mode), in its own process group, under the wall clock
(`turnTimeout`, default 900 s). A turn counts only if it exits 0 and,
on Pi, the extension wrote the turn marker at `agent_end` (S0 line 4).
Its stdout goes back to the sender as a RESULT; a failed turn replies
`The turn failed: <reason>`; an incoming RESULT never gets an automatic
reply. Then `message.read`.
5. SIGTERM or SIGINT kills the turn's process group, releases the role and
exits 0. The handlers go in before anything else, because pid 1 of a
PID namespace never sees a signal it has no handler for; a stop before
the claim exits 0 without claiming. Node's own startup still leaves a
short window, so the host and `mosaic stop` send SIGTERM again every
second until the runner is gone.
| Exit | Meaning | Launch end reason |
|---|---|---|
| 0 | stopped | `stopped` |
| 2 | bad run directory or stdin | `failed` |
| 20 | founder credentials | `founder-credentials` |
| 21 | `role.claim` refused | `claim-refused` |
| 22 | the capability or claim stopped working (run ended, claim revoked) | `session-invalid` |
| 23 | `role.claim` got no answer, or the broker stayed unreachable for `brokerRetries` polls (default 30) | `broker-unreachable` |
Each turn's stderr goes to `turns/NNNN.stderr` (0600) in the run directory.
## Limits
These are limits, not bugs, and nothing in this package claims otherwise.
- **The gate is not a sandbox.** It gates the harness's own tool calls.
`bash`, where the role's tool limit grants it, can reach anything the OS
user can (S0 line 5).
- **Same UID.** Every session runs as the host's user. Founder files in
`HOME` (`~/.git-credentials`, the tea config, `~/.ssh`) are readable
through `bash`; only the environment is filtered. `/tmp` is shared. The
PID namespace (below) hides other processes, so a session can't signal
or `ptrace` another one by pid.
- **The broker socket is shared.** A session that can run `bash` can
connect to it; it still needs a capability to do anything there. The
tool socket is reachable through `bash` as well, and acts with the
session's own capability only.
- **Network.** Abstract Unix sockets and the network are not confined; the
policy's `network` field is recorded, not enforced.
- **PID namespace.** Each session's runner is pid 1 of its own namespace
(`unshare --user --map-current-user --pid --fork --kill-child
--mount-proc`). It doesn't reap zombies; turns are reaped by the runner's
own waits, and the namespace dies with it. A same-UID process can still
ask something outside its tree (a systemd user manager, an existing tmux
server) to run a command.
- **`mosaic stop`** is same-UID process control: it checks the registry's
start time and that the process is a runner before sending SIGTERM.
- **If the host dies,** sessions keep running until their broker polls fail
`brokerRetries` times, then exit 23. The next host kills what still runs
and ends those launches as `host-lost` (`packages/cli/README.md`,
"Sessions").
- **Resolution** runs without the project layer, there is no skills source
(bundles list none), and the resolved `thinking` level is recorded in the
manifest but not applied to either harness.
- **`--restricted`** (Claude Code) is not one of the S0 lines. It is what
keeps `CLAUDE.md` files and auto-memory out of the prompt (above).
- **Other spellings.** A read is refused when a spelling Pi might open
points outside the workspace, even if the name as given exists and is
inside, and Pi would open that one. The same holds when a directory
next to the workspace has another spelling of its path, for example
`jo’s/ws` beside a workspace in `jo's/ws`.
- **The gate checks a path when the call is made.** A link created or
changed between the check and the tool's own open (by `bash`, or by
another process of the same user) isn't seen. That is the same reach as
`bash` itself.
- **The system prompt is in argv** for both adapters, so the same UID can
read it in `/proc/<pid>/cmdline`; the PID namespace hides it from other
sessions. It holds no secret.
- **Pi adapter paths** with spaces break its unquoted `-e` and skill
splitting; the launcher's paths don't contain spaces.
+15
View File
@@ -0,0 +1,15 @@
{
"name": "@mosaic/harness",
"private": true,
"description": "Launch bundles for managed role sessions: prompt, policy, typed tools and manifest per resolved role instance; the Pi extension, the Claude Code gate and MCP server, and the session runner.",
"license": "UNLICENSED",
"type": "module",
"engines": { "node": ">=24" },
"exports": {
".": "./src/bundle.mjs",
"./gate": "./src/gate.mjs",
"./tools": "./src/tools.mjs",
"./runner": "./src/runner.mjs"
},
"scripts": { "test": "node --test tests/*.test.mjs" }
}
+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"));
}
});
});
}
+112
View File
@@ -0,0 +1,112 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { BundleError, buildBundle, sessionModel, SOURCES } from "../src/bundle.mjs";
import { resolvedFor, scratch } from "./helpers.mjs";
const sha = (p) => createHash("sha256").update(readFileSync(p)).digest("hex");
const mode = (p) => statSync(p).mode & 0o777;
function build(t, resolved, extra = {}) {
const dir = scratch(t);
const workspace = join(dir, "ws");
mkdirSync(workspace);
const bundle = buildBundle({
dir: join(dir, "bundle"),
resolved,
contract: "# Contract\n\nDo the work.\n",
tracker: true,
credentials: [{ service: "gitea", date: "2099-01-01", state: "valid" }],
session: { run: "run-1", launchedBy: "pm", workspace, toolSocket: join(dir, "tools.sock"), turnMarker: join(dir, "turn.done") },
piVersion: "0.85.1",
claudeVersion: "2.1.0 (Claude Code)",
...extra,
});
return { dir, workspace, ...bundle };
}
test("sessionModel: agent vars win, then the system's execution settings", (t) => {
assert.deepEqual(sessionModel(resolvedFor(t, "pm")), { harness: "pi", provider: "zai", model: "claude-opus-5-5", thinking: null });
assert.deepEqual(sessionModel(resolvedFor(t, "coder")), { harness: "pi", provider: "zai", model: "glm-5.3-flash", thinking: null });
// The test system's adapter is "mock", which isn't a session harness.
assert.throws(() => sessionModel(resolvedFor(t, "reviewer")), (e) => e instanceof BundleError && /harness "mock"/.test(e.message));
});
test("a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory", (t) => {
const b = build(t, resolvedFor(t, "pm"));
assert.equal(mode(join(b.dir, "bundle")), 0o700);
for (const f of Object.values(b.files)) assert.equal(mode(f), 0o600, f);
assert.deepEqual(Object.keys(b.files).sort(), ["manifest", "policy", "prompt", "tools"]);
const prompt = readFileSync(b.files.prompt, "utf8");
assert.match(prompt, /^# Contract\n\nDo the work\.\n\n## This session\n/);
assert.match(prompt, /Role instance: pm \(definition pm\)\. Run: run-1\./);
assert.match(prompt, /Bus tools: send_message, launch, /);
assert.deepEqual(JSON.parse(readFileSync(b.files.policy, "utf8")), {
harness: "pi",
workspace: b.workspace,
tools: [...b.manifest.builtins],
typed: b.tools.map((x) => x.name),
network: "api-only",
needs: ["gitea", "vikunja"],
credentials: [{ service: "gitea", date: "2099-01-01", state: "valid" }],
});
const m = b.manifest;
assert.equal(m.harness, "pi");
assert.equal(m.model, "claude-opus-5-5");
assert.equal(m.piVersion, "0.85.1");
assert.equal(m.claudeVersion, null);
assert.equal(m.gateTimeout, null);
assert.deepEqual(m.skills, []);
assert.equal(m.reliesOn.length, 5);
assert.ok(m.typed.includes("launch"));
assert.deepEqual(Object.keys(m.sources).sort(), ["gate", "piExtension", "tools"]);
assert.equal(m.sources.piExtension.sha256, sha(SOURCES.piExtension));
for (const [name, digest] of Object.entries(m.files)) assert.equal(digest, sha(join(b.dir, "bundle", name)), name);
assert.deepEqual(JSON.parse(readFileSync(b.files.manifest, "utf8")), JSON.parse(JSON.stringify(m)));
});
test("a claude-code bundle adds the wrapped gate hook and the MCP config", (t) => {
const r = resolvedFor(t, "coder", (d) => (d.roles.coder.vars = { harness: "claude-code", model: "claude-sonnet-5-5" }));
const b = build(t, r);
assert.deepEqual(Object.keys(b.files).sort(), ["manifest", "mcp", "policy", "prompt", "settings", "tools"]);
const settings = JSON.parse(readFileSync(b.files.settings, "utf8"));
const hook = settings.hooks.PreToolUse[0];
assert.equal(hook.matcher, "*");
assert.equal(hook.hooks[0].timeout, 20);
assert.equal(hook.hooks[0].command, `timeout -k 2 10 '${process.execPath}' '${SOURCES.claudeGate}' '${b.files.policy}' || exit 2`);
const mcp = JSON.parse(readFileSync(b.files.mcp, "utf8"));
assert.deepEqual(mcp, { mcpServers: { mosaic: { type: "stdio", command: process.execPath, args: [SOURCES.mcpServer, b.files.tools, join(b.dir, "tools.sock")] } } });
const m = b.manifest;
assert.equal(m.piVersion, null);
assert.equal(m.claudeVersion, "2.1.0 (Claude Code)");
assert.deepEqual(m.gateTimeout, { n: 10, k: 2, hook: 20 });
assert.ok(m.builtins.includes("Bash"));
assert.ok(m.typed.every((n) => n.startsWith("mcp__mosaic__")));
assert.deepEqual(Object.keys(m.sources).sort(), ["claudeGate", "gate", "mcpServer", "tools"]);
});
test("a bundle is written once: an existing file refuses", (t) => {
const dir = scratch(t);
mkdirSync(join(dir, "bundle"));
writeFileSync(join(dir, "bundle", "prompt.md"), "old");
assert.throws(() => buildBundle({
dir: join(dir, "bundle"),
resolved: resolvedFor(t, "pm"),
contract: "c",
session: { run: "r", launchedBy: "pm", workspace: dir, toolSocket: join(dir, "s"), turnMarker: join(dir, "m") },
}), /EEXIST/);
assert.equal(readFileSync(join(dir, "bundle", "prompt.md"), "utf8"), "old");
});
test("a path with a single quote can't go into the hook command", (t) => {
const r = resolvedFor(t, "coder", (d) => (d.roles.coder.vars = { harness: "claude-code", model: "claude-sonnet-5-5" }));
const dir = scratch(t);
assert.throws(() => buildBundle({
dir: join(dir, "it's"),
resolved: r,
contract: "c",
session: { run: "r", launchedBy: "pm", workspace: dir, toolSocket: join(dir, "s"), turnMarker: join(dir, "m") },
}), (e) => e instanceof BundleError && /single quote/.test(e.message));
});
@@ -0,0 +1,63 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { SOURCES } from "../src/bundle.mjs";
import { scratch } from "./helpers.mjs";
function setup(t, harness = "claude-code") {
const dir = scratch(t);
const workspace = join(dir, "ws");
mkdirSync(workspace);
const policy = join(dir, "policy.json");
writeFileSync(policy, JSON.stringify({ harness, workspace, tools: ["read", "bash"], typed: ["send_message"] }));
return { dir, workspace, policy };
}
// Runs the gate the way the hook does, directly or through a command string.
const gate = (policy, event) => spawnSync(process.execPath, [SOURCES.claudeGate, policy], { input: JSON.stringify(event), encoding: "utf8" });
const hook = (command, event) => spawnSync("sh", ["-c", command], { input: JSON.stringify(event), encoding: "utf8" });
const event = (tool_name, tool_input = {}) => ({ hook_event_name: "PreToolUse", tool_name, tool_input });
test("allow exits 0, a deny exits 2 with the reason on stderr", (t) => {
const { workspace, policy } = setup(t);
assert.equal(gate(policy, event("Read", { file_path: join(workspace, "a") })).status, 0);
assert.equal(gate(policy, event("mcp__mosaic__send_message", { to: "pm", body: "x" })).status, 0);
const denied = gate(policy, event("Write", { file_path: join(workspace, "a"), content: "" }));
assert.equal(denied.status, 2);
assert.match(denied.stderr, /^mosaic gate: Write isn't in this session's policy/);
const outside = gate(policy, event("Read", { file_path: "/etc/passwd" }));
assert.equal(outside.status, 2);
assert.match(outside.stderr, /outside the workspace/);
});
test("a missing or wrong policy, or a bad event, exits 2", (t) => {
const { dir, policy } = setup(t);
assert.equal(gate(join(dir, "missing.json"), event("Read")).status, 2);
const pi = setup(t, "pi").policy;
const r = gate(pi, event("Read"));
assert.equal(r.status, 2);
assert.match(r.stderr, /not a claude-code policy/);
const bad = spawnSync(process.execPath, [SOURCES.claudeGate, policy], { input: "{", encoding: "utf8" });
assert.equal(bad.status, 2);
});
test("the bundle's wrapped command: a missing gate or node still blocks", (t) => {
const { workspace, policy } = setup(t);
const command = (node, script) => `timeout -k 2 10 '${node}' '${script}' '${policy}' || exit 2`;
const allow = event("Read", { file_path: join(workspace, "a") });
assert.equal(hook(command(process.execPath, SOURCES.claudeGate), allow).status, 0);
assert.equal(hook(command(process.execPath, join(workspace, "no-gate.mjs")), allow).status, 2);
assert.equal(hook(command(join(workspace, "no-node"), SOURCES.claudeGate), allow).status, 2);
// A gate that hangs is killed by timeout and blocks.
const slow = join(workspace, "slow.mjs");
writeFileSync(slow, "setTimeout(() => {}, 60000);\n");
const started = Date.now();
const r = hook(`timeout -k 1 1 '${process.execPath}' '${slow}' '${policy}' || exit 2`, allow);
assert.equal(r.status, 2);
assert.ok(Date.now() - started < 5000);
// A policy removed after the bundle was written blocks too.
rmSync(policy);
assert.equal(hook(command(process.execPath, SOURCES.claudeGate), allow).status, 2);
});
@@ -0,0 +1,209 @@
// The host's claude CLI through adapters/claude with the bundle's hook and
// MCP server, against the scripted Messages API. Scratch CLAUDE_CONFIG_DIR
// and HOME, a dummy key, nonessential traffic off: nothing reaches a real
// model or the user's Claude configuration. Skipped when claude isn't on PATH,
// except the argv check, which uses a stand-in claude.
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawn, spawnSync } from "node:child_process";
import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
import { basename, dirname, join } from "node:path";
import { buildBundle } from "../src/bundle.mjs";
import { adapterEnv } from "../src/runner.mjs";
import { fakeToolSocket, mockAnthropic, REPO, resolvedFor, scratch } from "./helpers.mjs";
const ADAPTER = join(REPO, "adapters", "claude", "adapter.sh");
const which = spawnSync("sh", ["-c", "command -v claude"], { encoding: "utf8" });
const CLAUDE = which.status === 0 ? which.stdout.trim() : null;
const skip = CLAUDE ? false : "claude is not on PATH";
async function session(t, script, tools = ["read", "bash"]) {
const dir = scratch(t);
const workspace = join(dir, "ws");
mkdirSync(workspace);
writeFileSync(join(workspace, "notes.txt"), "inside\n");
writeFileSync(join(dir, "secret.txt"), "outside\n");
if (typeof script === "function") script = script(workspace);
const api = await mockAnthropic(t, { script, done: (r) => `ANSWER ${JSON.stringify(r.map((x) => [x.isError, x.content.slice(0, 600)]))}` });
const resolved = resolvedFor(t, "coder", (d) => {
d.roles.coder.vars = { harness: "claude-code", model: "claude-sonnet-5-5", "limits.tools": tools };
});
const sock = await fakeToolSocket(t, dir, (tool, args) => {
if (tool === "raise_decision") throw new Error("not-allowed");
return { tool, args };
});
const s = {
harness: "claude-code",
provider: null,
model: "claude-sonnet-5-5",
workspace,
sessionDir: join(dir, "session"),
toolSocket: sock.path,
turnMarker: join(dir, "turn.done"),
};
const { files, manifest } = buildBundle({ dir: join(dir, "bundle"), resolved, contract: "# Coder\n", session: { ...s, run: "run-1", launchedBy: "pm" } });
s.bundle = files;
const env = adapterEnv(s, {
PATH: `${dirname(CLAUDE)}:${dirname(process.execPath)}:/usr/bin:/bin`,
HOME: join(dir, "home"),
CLAUDE_CONFIG_DIR: join(dir, "claude-config"),
ANTHROPIC_BASE_URL: api.url,
ANTHROPIC_API_KEY: "probe-dummy",
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1",
DISABLE_TELEMETRY: "1",
DISABLE_AUTOUPDATER: "1",
DISABLE_ERROR_REPORTING: "1",
});
return { dir, workspace, api, sock, s, env, manifest };
}
function turn(env, request, cwd, adapter = ADAPTER) {
return new Promise((resolve) => {
const child = spawn("/bin/sh", [adapter], { cwd, env: { ...env, MOSAIC_REQUEST: request }, stdio: ["ignore", "pipe", "pipe"], detached: true });
let stdout = "";
let stderr = "";
child.stdout.on("data", (b) => (stdout += b));
child.stderr.on("data", (b) => (stderr += b));
const clock = setTimeout(() => process.kill(-child.pid, "SIGKILL"), 120_000);
child.on("close", (code) => {
clearTimeout(clock);
resolve({ code, stdout, stderr });
});
});
}
test("claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist", { skip }, async (t) => {
const { workspace, api, sock, s, env, manifest } = await session(t, (workspace) => [
{ name: "mcp__mosaic__list_agents", input: {} },
{ name: "mcp__mosaic__raise_decision", input: { question: "q", options: ["a", "b"] } },
{ name: "Read", input: { file_path: join(workspace, "notes.txt") } },
{ name: "Read", input: { file_path: join(dirname(workspace), "secret.txt") } },
{ name: "Write", input: { file_path: join(workspace, "new.txt"), content: "x" } },
{ name: "Bash", input: { command: "echo ran > bash.txt", description: "probe" } },
]);
const r = await turn(env, "Message 1 from pm, class ASSIGNMENT:\n\ngo", s.workspace);
assert.equal(r.code, 0, r.stderr);
const answer = r.stdout.trim();
assert.match(answer, /^ANSWER /, r.stderr);
const results = JSON.parse(answer.slice("ANSWER ".length));
assert.equal(results.length, 6, answer);
assert.equal(results[0][0], false, answer);
assert.match(results[0][1], /list_agents/);
assert.equal(results[1][0], true, answer);
assert.match(results[1][1], /refused: not-allowed/);
assert.equal(results[2][0], false, answer);
assert.match(results[2][1], /inside/);
// Outside the workspace: --restricted or the hook stops it; either way it doesn't run.
assert.equal(results[3][0], true, answer);
assert.doesNotMatch(results[3][1], /outside\n/);
// Write isn't in --tools.
assert.equal(results[4][0], true, answer);
assert.ok(!existsSync(join(workspace, "new.txt")));
// Bash is allowed and runs in the workspace.
assert.equal(results[5][0], false, answer);
assert.equal(readFileSync(join(workspace, "bash.txt"), "utf8"), "ran\n");
assert.deepEqual(sock.calls.map((c) => c.tool), ["list_agents", "raise_decision"]);
const offered = api.requests.find((x) => x.tools.length)?.tools ?? [];
assert.ok(offered.includes("mcp__mosaic__list_agents"), offered.join(","));
assert.ok(!offered.includes("Write") && !offered.includes("WebFetch"), offered.join(","));
assert.deepEqual(manifest.builtins, ["Read", "Bash"]);
// The first turn kept its session id for the next one.
assert.match(readFileSync(join(s.sessionDir, "claude-session-id"), "utf8"), /^[0-9a-f-]{36}\n$/);
});
test("claude: the hook alone blocks a path outside the workspace", { skip }, async (t) => {
// Bash can reach anything the OS user can (S0 line 5); the gate stops the
// Read tool on its own path check, which is what this pins.
const { workspace, api, s, env } = await session(t, [
{ name: "Read", input: { file_path: "/etc/hostname" } },
], ["read"]);
const r = await turn(env, "x", workspace);
assert.equal(r.code, 0, r.stderr);
const results = JSON.parse(r.stdout.trim().slice("ANSWER ".length));
assert.equal(results[0][0], true);
assert.match(results[0][1], /mosaic gate: .*outside the workspace/);
assert.ok(api.requests.length > 0);
assert.ok(s.bundle.settings);
});
test("claude: a second turn resumes the first turn's session", { skip }, async (t) => {
const { workspace, s, env } = await session(t, []);
const first = await turn(env, "one", workspace);
assert.equal(first.code, 0, first.stderr);
const id = readFileSync(join(s.sessionDir, "claude-session-id"), "utf8");
const second = await turn(env, "two", workspace);
assert.equal(second.code, 0, second.stderr);
assert.equal(readFileSync(join(s.sessionDir, "claude-session-id"), "utf8"), id);
});
// --restricted is what keeps CLAUDE.md files and auto-memory out of the
// session's prompt (README "Limits"). This one runs without claude: a
// stand-in records the adapter's argv.
test("claude adapter: --restricted is always passed", (t) => {
const dir = scratch(t);
mkdirSync(join(dir, "bin"));
writeFileSync(join(dir, "bin", "claude"), '#!/bin/sh\nprintf "%s\\n" "$@" > "$ARGV_OUT"\necho ok\n');
chmodSync(join(dir, "bin", "claude"), 0o755);
for (const f of ["prompt.md", "settings.json", "mcp.json"]) writeFileSync(join(dir, f), "{}");
const r = spawnSync("/bin/sh", [ADAPTER], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
env: {
PATH: `${join(dir, "bin")}:/usr/bin:/bin`,
ARGV_OUT: join(dir, "argv"),
MOSAIC_SYSTEM_PROMPT_FILE: join(dir, "prompt.md"),
MOSAIC_REQUEST: "x",
MOSAIC_WORKSPACE: join(dir, "ws"),
MOSAIC_SESSION_DIR: join(dir, "session"),
MOSAIC_MODEL: "claude-sonnet-5-5",
MOSAIC_CLAUDE_SETTINGS: join(dir, "settings.json"),
MOSAIC_CLAUDE_MCP_CONFIG: join(dir, "mcp.json"),
},
});
assert.equal(r.status, 0, r.stderr);
const argv = readFileSync(join(dir, "argv"), "utf8").split("\n");
assert.ok(argv.includes("--restricted"), argv.join(" "));
assert.ok(!argv.includes("--bare"), argv.join(" "));
});
test("claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do", { skip }, async (t) => {
const { dir, workspace, env } = await session(t, []);
const slug = realpathSync(workspace).replace(/[/.]/g, "-");
const plant = {
"MARKER-USER": [join(env.HOME, ".claude", "CLAUDE.md"), join(env.CLAUDE_CONFIG_DIR, "CLAUDE.md")],
"MARKER-PARENT": [join(dir, "CLAUDE.md")],
"MARKER-WS": [join(workspace, "CLAUDE.md")],
"MARKER-MEMORY": [join(env.HOME, ".claude", "projects", slug, "memory", "MEMORY.md"), join(env.CLAUDE_CONFIG_DIR, "projects", slug, "memory", "MEMORY.md")],
};
for (const [marker, files] of Object.entries(plant)) {
for (const f of files) {
mkdirSync(dirname(f), { recursive: true });
writeFileSync(f, `${marker}\n`);
}
}
const seen = async (adapter) => {
const api = await mockAnthropic(t, {});
const r = await turn({ ...env, ANTHROPIC_BASE_URL: api.url, MOSAIC_SESSION_DIR: join(dir, `session-${basename(adapter)}`) }, "x", workspace, adapter);
assert.equal(r.code, 0, r.stderr);
const all = api.requests.map((x) => x.raw).join("\n");
assert.match(all, /## This session/);
return Object.keys(plant).filter((m) => all.includes(m));
};
assert.deepEqual(await seen(ADAPTER), []);
// The control: the same adapter without --restricted lets all four in.
const loose = join(dir, "adapter-loose.sh");
writeFileSync(loose, readFileSync(ADAPTER, "utf8").replace(" --restricted \\\n", ""));
assert.notEqual(readFileSync(loose, "utf8"), readFileSync(ADAPTER, "utf8"));
assert.deepEqual(await seen(loose), Object.keys(plant));
});
test("claude: a missing hook or MCP file refuses before claude starts", { skip }, async (t) => {
const { dir, api, workspace, env } = await session(t, []);
for (const k of ["MOSAIC_CLAUDE_SETTINGS", "MOSAIC_CLAUDE_MCP_CONFIG", "MOSAIC_SYSTEM_PROMPT_FILE"]) {
const r = await turn({ ...env, [k]: join(dir, "missing.json") }, "x", workspace);
assert.equal(r.code, 2, k);
assert.match(r.stderr, /not readable/);
}
assert.equal(api.requests.length, 0);
});
+36
View File
@@ -0,0 +1,36 @@
// A stand-in harness turn for the runner tests. MOSAIC_REQUEST's body
// (after the blank line) picks the behaviour:
// TOOL <name> <json> call a typed tool through the tool socket, print the result or refusal
// SLEEP hang until killed
// FAIL exit 3
// NOMARKER answer without writing the turn marker
// anything else answer "echo: <request>"
// Every turn appends its pid to $FAKE_PIDS when set.
import { appendFileSync, writeFileSync } from "node:fs";
import { callTool } from "../../src/tools.mjs";
const request = process.env.MOSAIC_REQUEST ?? "";
const body = request.split("\n\n").slice(1).join("\n\n");
if (process.env.FAKE_PIDS) appendFileSync(process.env.FAKE_PIDS, `${process.pid}\n`);
process.stderr.write(`fake adapter: ${process.env.MOSAIC_MODEL}\n`);
const done = () => writeFileSync(process.env.MOSAIC_TURN_MARKER, "done\n");
if (body.startsWith("TOOL ")) {
const [, name, ...rest] = body.split(" ");
try {
process.stdout.write(JSON.stringify(await callTool(process.env.MOSAIC_TOOL_SOCKET, name, JSON.parse(rest.join(" ") || "{}"))));
} catch (e) {
process.stdout.write(e.message);
}
done();
} else if (body === "SLEEP") {
setInterval(() => {}, 1000);
} else if (body === "FAIL") {
process.exit(3);
} else if (body === "NOMARKER") {
process.stdout.write("half an answer");
} else {
process.stdout.write(`echo: ${request}\n`);
done();
}
+233
View File
@@ -0,0 +1,233 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdirSync, realpathSync, symlinkSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { claudeBuiltins, decide, insideWorkspace } from "../src/gate.mjs";
import { scratch } from "./helpers.mjs";
function setup(t, harness, tools = ["read", "write", "edit", "grep", "find", "ls"]) {
const dir = scratch(t);
const workspace = join(dir, "ws");
mkdirSync(join(workspace, "sub"), { recursive: true });
writeFileSync(join(workspace, "a.txt"), "a");
return { dir, workspace, policy: { harness, workspace, tools, typed: ["send_message", "list_agents"] } };
}
const allowed = (v) => assert.deepEqual(v, { allow: true });
const blocked = (v, pattern) => {
assert.equal(v.allow, false);
assert.match(v.reason, /^mosaic gate: /);
if (pattern) assert.match(v.reason, pattern);
};
test("pi: policy tools and typed tools pass, anything else is blocked", (t) => {
const { policy } = setup(t, "pi", ["read", "write"]);
allowed(decide(policy, "send_message", { to: "pm", body: "x" }));
allowed(decide(policy, "read", { path: "a.txt" }));
blocked(decide(policy, "bash", { command: "true" }), /bash isn't in this session's policy/);
blocked(decide(policy, "mcp__mosaic__send_message", {}), /isn't in this session's policy/);
blocked(decide(policy, "resolve_decision", {}));
blocked(decide(policy, undefined, {}), /without a name/);
});
test("claude: builtins map from pi names, typed tools need the mcp prefix", (t) => {
const { policy } = setup(t, "claude-code", ["read", "find", "ls", "bash"]);
assert.deepEqual(claudeBuiltins(policy), ["Read", "Glob", "Bash"]);
allowed(decide(policy, "mcp__mosaic__list_agents", {}));
allowed(decide(policy, "Bash", { command: "ls /" }));
blocked(decide(policy, "list_agents", {}));
blocked(decide(policy, "mcp__mosaic__launch", {}));
blocked(decide(policy, "mcp__other__list_agents", {}));
blocked(decide(policy, "Write", { file_path: join(policy.workspace, "x") }), /Write isn't in this session's policy/);
blocked(decide(policy, "WebFetch", { url: "https://example.test" }));
blocked(decide(policy, "Task", {}));
});
test("file tool paths must resolve inside the workspace", (t) => {
const { dir, workspace, policy } = setup(t, "pi");
allowed(decide(policy, "read", { path: "a.txt" }));
allowed(decide(policy, "write", { path: "sub/new/deep.txt" }));
allowed(decide(policy, "read", { path: join(workspace, "a.txt") }));
allowed(decide(policy, "ls", {}));
allowed(decide(policy, "grep", { pattern: "x", path: "" }));
blocked(decide(policy, "read", { path: "../outside.txt" }), /outside the workspace/);
blocked(decide(policy, "read", { path: "/etc/passwd" }), /outside the workspace/);
blocked(decide(policy, "write", { path: join(dir, "ws-evil", "x") }), /outside the workspace/);
blocked(decide(policy, "read", { path: 42 }), /isn't a string/);
});
test("pi's own path normalisation can't be used to step out", (t) => {
const { workspace, policy } = setup(t, "pi");
blocked(decide(policy, "read", { path: "~/.ssh/id_ed25519" }));
blocked(decide(policy, "read", { path: "~" }));
blocked(decide(policy, "read", { path: "@/etc/passwd" }));
blocked(decide(policy, "read", { path: pathToFileURL("/etc/passwd").href }));
blocked(decide(policy, "read", { path: "/etc/passwd" }));
allowed(decide(policy, "read", { path: `@${join(workspace, "a.txt")}` }));
allowed(decide(policy, "read", { path: pathToFileURL(join(workspace, "a.txt")).href }));
// A workspace under home is still judged by its real path.
assert.equal(insideWorkspace(workspace, "~/"), homedir() === workspace);
});
test("a symlink inside the workspace that points out is outside", (t) => {
const { dir, workspace, policy } = setup(t, "pi");
mkdirSync(join(dir, "secret"));
writeFileSync(join(dir, "secret", "s.txt"), "s");
symlinkSync(join(dir, "secret"), join(workspace, "link"));
blocked(decide(policy, "read", { path: "link/s.txt" }), /outside the workspace/);
blocked(decide(policy, "write", { path: "link/new.txt" }), /outside the workspace/);
});
test("a dangling symlink is refused at any depth, in both harnesses", (t) => {
for (const [harness, tool, field] of [["pi", "write", "path"], ["claude-code", "Write", "file_path"]]) {
const { dir, workspace, policy } = setup(t, harness, ["read", "write"]);
mkdirSync(join(dir, "outside"));
// notes.md names a file that doesn't exist yet, outside; dangling names a
// directory that doesn't; inner points inside but at nothing.
symlinkSync(join(dir, "outside", "planted.txt"), join(workspace, "notes.md"));
symlinkSync(join(dir, "nowhere"), join(workspace, "dangling"));
symlinkSync(join(workspace, "later.txt"), join(workspace, "inner"));
for (const rel of ["notes.md", "dangling/x", "dangling/deep/x", "inner"]) {
blocked(decide(policy, tool, { [field]: rel }), /goes through a dangling symlink/);
blocked(decide(policy, tool, { [field]: join(workspace, rel) }), /goes through a dangling symlink/);
}
// Once the target exists, the usual realpath rule decides.
writeFileSync(join(dir, "outside", "planted.txt"), "p");
blocked(decide(policy, tool, { [field]: "notes.md" }), /outside the workspace/);
writeFileSync(join(workspace, "later.txt"), "l");
allowed(decide(policy, tool, { [field]: "inner" }));
// A file used as a directory can't be checked.
blocked(decide(policy, tool, { [field]: "a.txt/x" }), /can't be checked: ENOTDIR/);
}
});
// Pi's read opens another spelling when the name it's given doesn't exist
// (path-utils.js resolveReadPathAsync). Asked name -> name on disk.
const SPELLINGS = [
["quote", "notes's.txt", "notes\u2019s.txt"],
["ampm", "shot 9.41 AM.png", "shot 9.41\u202FAM.png"],
["nfd", "r\u00E9sum\u00E9.txt", "r\u00E9sum\u00E9.txt".normalize("NFD")],
["nfd and quote", "d'\u00E9cran.png", "d\u2019\u00E9cran.png".normalize("NFD")],
// Each family alone, on a name with both an apostrophe and an accent.
["nfd, straight quote", "l'\u00E9t\u00E9.txt", "l'\u00E9t\u00E9.txt".normalize("NFD")],
["quote, composed", "l'\u00E9t\u00E9.md", "l\u2019\u00E9t\u00E9.md"],
];
test("read is checked under every spelling pi's read would open, in both harnesses", (t) => {
for (const [harness, read, write, field] of [["pi", "read", "write", "path"], ["claude-code", "Read", "Write", "file_path"]]) {
for (const [, asked, disk] of SPELLINGS) {
const { dir, workspace, policy } = setup(t, harness, ["read", "write"]);
mkdirSync(join(dir, "outside"));
writeFileSync(join(dir, "outside", "secret.txt"), "s");
symlinkSync(join(dir, "outside", "secret.txt"), join(workspace, disk));
const why = new RegExp(`outside the workspace under another spelling: (.*/)?${asked.replace(/[.']/g, "\\$&")}`);
blocked(decide(policy, read, { [field]: asked }), why);
blocked(decide(policy, read, { [field]: join(workspace, asked) }), why);
// Write takes the name as given, so writing the asked name stays inside.
allowed(decide(policy, write, { [field]: asked }));
// The same spelling pointing inside is fine.
const inner = setup(t, harness, ["read"]);
symlinkSync(join(inner.workspace, "a.txt"), join(inner.workspace, disk));
allowed(decide(inner.policy, read, { [field]: asked }));
}
}
});
test("other spellings cover directories, dangling links and pi's cwd", (t) => {
const { dir, workspace, policy } = setup(t, "pi", ["read"]);
mkdirSync(join(dir, "outside"));
writeFileSync(join(dir, "outside", "s.txt"), "s");
// A directory name is respelled too.
symlinkSync(join(dir, "outside"), join(workspace, "it\u2019s"));
blocked(decide(policy, "read", { path: "it's/s.txt" }), /outside the workspace under another spelling/);
// A dangling link under another spelling is refused, like the name itself.
symlinkSync(join(dir, "nowhere"), join(workspace, "gone\u2019s"));
blocked(decide(policy, "read", { path: "gone's" }), /dangling symlink under another spelling/);
// A spelling that doesn't exist, or uses a file as a directory, isn't opened.
allowed(decide(policy, "read", { path: "nobody's.txt" }));
writeFileSync(join(workspace, "f\u2019s"), "f");
allowed(decide(policy, "read", { path: "f's/x" }));
// Pi resolves against its cwd, the workspace's real path, and respells
// that too: here the real path has an apostrophe the given path lacks.
mkdirSync(join(dir, "jo's", "ws"), { recursive: true });
mkdirSync(join(dir, "jo\u2019s", "ws"), { recursive: true });
writeFileSync(join(dir, "jo\u2019s", "ws", "x.txt"), "x");
symlinkSync(join(dir, "jo's"), join(dir, "jo"));
const viaLink = { ...policy, workspace: join(dir, "jo", "ws") };
blocked(decide(viaLink, "read", { path: "x.txt" }), /outside the workspace under another spelling/);
});
// Both adapters cd into the workspace, and the tool's cwd is its real path,
// so `..` climbs the real path's parents. Through a symlinked workspace or
// dataRoot those differ from the given path's.
const CLIMBS = {
// <dir>/a/ws -> <dir>/deep/store/ws: up two is <dir> as given, <dir>/deep for pi.
workspace(dir) {
mkdirSync(join(dir, "deep", "store", "ws"), { recursive: true });
mkdirSync(join(dir, "a"));
symlinkSync(join(dir, "deep", "store", "ws"), join(dir, "a", "ws"));
return { workspace: join(dir, "a", "ws"), outside: join(dir, "deep", "a", "ws"), escape: "../../a/ws", stay: "../ws", strict: "../../store/ws" };
},
// dataRoot <dir>/data -> <dir>/deep/store, the workspace under it as the
// launcher builds it: up four is <dir> as given, <dir>/deep for pi.
dataRoot(dir) {
mkdirSync(join(dir, "deep", "store", "workspaces", "b", "i"), { recursive: true });
symlinkSync(join(dir, "deep", "store"), join(dir, "data"));
return { workspace: join(dir, "data", "workspaces", "b", "i"), outside: join(dir, "deep", "data", "workspaces", "b", "i"), escape: "../../../../data/workspaces/b/i", stay: "../i", strict: "../../../../store/workspaces/b/i" };
},
};
test("a relative path climbs from the workspace's real path, in both harnesses", (t) => {
for (const [name, build] of Object.entries(CLIMBS)) {
for (const harness of ["pi", "claude-code"]) {
const dir = scratch(t);
const { workspace, outside, escape, stay, strict } = build(dir);
writeFileSync(join(workspace, "a.txt"), "a");
mkdirSync(outside, { recursive: true });
writeFileSync(join(outside, "secret.txt"), "s");
const policy = { harness, workspace, tools: ["read", "write"], typed: [] };
const [read, write, field] = harness === "pi" ? ["read", "write", "path"] : ["Read", "Write", "file_path"];
const at = `${name}, ${harness}`;
// As given, the escape is inside; from the real path it is outside.
assert.equal(resolve(workspace, escape), workspace, at);
assert.equal(resolve(realpathSync(workspace), escape), outside, at);
blocked(decide(policy, read, { [field]: `${escape}/secret.txt` }), /outside the workspace/);
blocked(decide(policy, write, { [field]: `${escape}/planted.txt` }), /outside the workspace/);
// Climbing out and back in by the same name stays inside on both paths.
allowed(decide(policy, read, { [field]: `${stay}/a.txt` }));
allowed(decide(policy, write, { [field]: `${stay}/new.txt` }));
// The other way round, inside for pi but outside as given, is refused
// too: the gate doesn't rest on the cwd the harness started in.
assert.equal(resolve(realpathSync(workspace), strict), realpathSync(workspace), at);
blocked(decide(policy, read, { [field]: `${strict}/a.txt` }), /outside the workspace/);
}
}
});
test("claude path fields per tool", (t) => {
const { workspace, policy } = setup(t, "claude-code", ["read", "write", "edit", "grep", "find"]);
allowed(decide(policy, "Read", { file_path: join(workspace, "a.txt") }));
allowed(decide(policy, "Edit", { file_path: join(workspace, "a.txt"), old_string: "a", new_string: "b" }));
allowed(decide(policy, "Grep", { pattern: "x" }));
blocked(decide(policy, "Write", { file_path: "/tmp/x", content: "" }), /outside the workspace/);
blocked(decide(policy, "Grep", { pattern: "x", path: "/etc" }), /outside the workspace/);
blocked(decide(policy, "Glob", { pattern: "*", path: "/" }), /outside the workspace/);
});
test("glob patterns stay inside the workspace", (t) => {
const pi = setup(t, "pi").policy;
const claude = setup(t, "claude-code", ["find"]).policy;
allowed(decide(pi, "find", { pattern: "**/*.mjs" }));
allowed(decide(claude, "Glob", { pattern: "src/**/*.mjs" }));
for (const pattern of ["/etc/*", "~/.ssh/*", "@/etc/*", "../*", "a/../../*", "..", " /etc/*"]) {
blocked(decide(pi, "find", { pattern }), /pattern must stay inside/);
blocked(decide(claude, "Glob", { pattern }), /pattern must stay inside/);
}
blocked(decide(claude, "Glob", { pattern: ["*"] }), /pattern must stay inside/);
});
test("a path that can't be checked is blocked", (t) => {
const { policy } = setup(t, "pi");
blocked(decide({ ...policy, workspace: join(policy.workspace, "missing") }, "read", { path: "a.txt" }), /can't be checked: ENOENT/);
});
+105
View File
@@ -0,0 +1,105 @@
// Shared fixtures: resolved role instances from the business package's own
// test business, scratch directories, a fake tool socket and a scripted
// stand-in for the Anthropic Messages API (row S0's probe mock, reduced).
// Nothing here reads the real configuration or calls a real model.
import { createServer as createHttp } from "node:http";
import { createServer } from "node:net";
import { mkdtempSync, realpathSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { resolveInstance, validateBusinessDocument } from "../../business/src/index.mjs";
import { businessDoc, REPO_ROLES, systemFor } from "../../business/tests/helpers.mjs";
export { REPO } from "../../business/tests/helpers.mjs";
export function scratch(t, prefix = "mosaic-harness-") {
const dir = realpathSync(mkdtempSync(join(tmpdir(), prefix)));
t.after(() => rmSync(dir, { recursive: true, force: true }));
return dir;
}
// resolved("pm" | "coder" | ..., { mutate }) in a scratch root.
export function resolvedFor(t, instance, mutate) {
const root = scratch(t, "mosaic-harness-biz-");
const doc = businessDoc(root);
mutate?.(doc);
const business = validateBusinessDocument(doc, join(root, "businesses", "acme.json"), { rolesDir: REPO_ROLES });
return resolveInstance({ system: systemFor(root), business, instance });
}
// A tool socket that answers with `handler(tool, args)`; a throw becomes a refusal.
export async function fakeToolSocket(t, dir, handler) {
const path = join(dir, "tools.sock");
const calls = [];
const server = createServer((s) => {
let input = "";
s.on("data", async (b) => {
input += b;
if (!input.includes("\n")) return;
const { tool, args } = JSON.parse(input);
calls.push({ tool, args });
try {
s.end(JSON.stringify({ ok: true, result: await handler(tool, args) }) + "\n");
} catch (e) {
s.end(JSON.stringify({ ok: false, error: e.message }) + "\n");
}
});
});
await new Promise((r) => server.listen(path, r));
t.after(() => server.close());
return { path, calls };
}
const textOf = (c) => (typeof c === "string" ? c : Array.isArray(c) ? c.map((b) => b.text ?? "").join("") : "");
function toolResults(messages) {
const out = [];
for (const m of messages ?? []) {
if (m.role !== "user" || !Array.isArray(m.content)) continue;
for (const b of m.content) if (b.type === "tool_result") out.push({ id: b.tool_use_id, isError: b.is_error ?? false, content: textOf(b.content) });
}
return out;
}
// A scripted Anthropic Messages API. Each request that offers tools gets
// the next scripted tool call; once every call has a result it answers
// `done(results)` as text. Returns { url, requests, close }.
export async function mockAnthropic(t, { script = [], done = (r) => `DONE ${JSON.stringify(r)}` } = {}) {
const requests = [];
const server = createHttp((req, res) => {
let raw = "";
req.on("data", (c) => (raw += c));
req.on("end", () => {
if (req.method !== "POST" || !/\/messages(\?|$)/.test(req.url)) {
res.writeHead(req.url.includes("count_tokens") ? 200 : 404, { "content-type": "application/json" });
return res.end(JSON.stringify(req.url.includes("count_tokens") ? { input_tokens: 10 } : { type: "error", error: { type: "not_found_error", message: "mock" } }));
}
const body = JSON.parse(raw || "{}");
const tools = Array.isArray(body.tools) ? body.tools.map((x) => x.name) : [];
const results = toolResults(body.messages);
const step = tools.length ? script[results.length] : null;
const r = step ? { kind: "tool", id: `toolu_${results.length + 1}`, name: step.name, input: step.input } : { kind: "text", text: tools.length ? done(results) : "mock" };
requests.push({ tools, results, system: body.system, answer: r, raw });
const content = r.kind === "tool" ? { type: "tool_use", id: r.id, name: r.name, input: {} } : { type: "text", text: "" };
const delta = r.kind === "tool" ? { type: "input_json_delta", partial_json: JSON.stringify(r.input) } : { type: "text_delta", text: r.text };
const stop = r.kind === "tool" ? "tool_use" : "end_turn";
if (!body.stream) {
res.writeHead(200, { "content-type": "application/json" });
const c = r.kind === "tool" ? { type: "tool_use", id: r.id, name: r.name, input: r.input } : { type: "text", text: r.text };
return res.end(JSON.stringify({ id: "msg_mock", type: "message", role: "assistant", model: body.model, content: [c], stop_reason: stop, stop_sequence: null, usage: { input_tokens: 10, output_tokens: 5 } }));
}
const send = (event, data) => res.write(`event: ${event}\ndata: ${JSON.stringify({ type: event, ...data })}\n\n`);
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" });
send("message_start", { message: { id: "msg_mock", type: "message", role: "assistant", model: body.model, content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 10, output_tokens: 1, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 } } });
send("content_block_start", { index: 0, content_block: content });
send("content_block_delta", { index: 0, delta });
send("content_block_stop", { index: 0 });
send("message_delta", { delta: { stop_reason: stop, stop_sequence: null }, usage: { output_tokens: 5 } });
send("message_stop", {});
res.end();
});
});
await new Promise((r) => server.listen(0, "127.0.0.1", r));
t.after(() => server.close());
return { url: `http://127.0.0.1:${server.address().port}`, requests };
}
@@ -0,0 +1,90 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { writeFileSync } from "node:fs";
import { join } from "node:path";
import { createInterface } from "node:readline";
import { SOURCES } from "../src/bundle.mjs";
import { typedTools } from "../src/tools.mjs";
import { fakeToolSocket, resolvedFor, scratch } from "./helpers.mjs";
// The server over stdio: send(message) resolves with the reply to its id.
function serve(t, toolsFile, socket) {
const child = spawn(process.execPath, [SOURCES.mcpServer, toolsFile, socket], { stdio: ["pipe", "pipe", "pipe"] });
t.after(() => child.kill());
const waiting = new Map();
const orphans = [];
createInterface({ input: child.stdout }).on("line", (line) => {
const m = JSON.parse(line);
const w = waiting.get(m.id);
if (w) {
waiting.delete(m.id);
w(m);
} else orphans.push(m);
});
let next = 1;
return {
orphans,
send(method, params) {
const id = next++;
return new Promise((resolve) => {
waiting.set(id, resolve);
child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
});
},
raw: (text) => child.stdin.write(text),
};
}
async function setup(t) {
const dir = scratch(t);
const tools = typedTools({ resolved: resolvedFor(t, "pm"), tracker: true });
const toolsFile = join(dir, "tools.json");
writeFileSync(toolsFile, JSON.stringify(tools));
const sock = await fakeToolSocket(t, dir, (tool, args) => {
if (tool === "launch") throw new Error("capacity-full");
return { tool, args };
});
return { dir, tools, sock, server: serve(t, toolsFile, sock.path) };
}
test("initialize, ping and tools/list", async (t) => {
const { tools, server } = await setup(t);
const init = await server.send("initialize", { protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { name: "x", version: "0" } });
assert.deepEqual(init.result, { protocolVersion: "2025-03-26", capabilities: { tools: {} }, serverInfo: { name: "mosaic", version: "1" } });
assert.deepEqual((await server.send("ping")).result, {});
const list = (await server.send("tools/list")).result.tools;
assert.deepEqual(list.map((x) => x.name), tools.map((x) => x.name));
const launch = list.find((x) => x.name === "launch");
assert.deepEqual(launch.inputSchema, tools.find((x) => x.name === "launch").parameters);
});
test("tools/call goes through the tool socket; a refusal is an isError result", async (t) => {
const { sock, server } = await setup(t);
const ok = (await server.send("tools/call", { name: "list_agents", arguments: { scope: "all" } })).result;
assert.equal(ok.isError, undefined);
assert.deepEqual(JSON.parse(ok.content[0].text), { tool: "list_agents", args: { scope: "all" } });
const refused = (await server.send("tools/call", { name: "launch", arguments: { instance: "coder" } })).result;
assert.equal(refused.isError, true);
assert.equal(refused.content[0].text, "refused: capacity-full");
assert.deepEqual(sock.calls.map((c) => c.tool), ["list_agents", "launch"]);
});
test("unknown tools and methods are JSON-RPC errors and never reach the socket", async (t) => {
const { sock, server } = await setup(t);
const unknown = await server.send("tools/call", { name: "resolve_decision", arguments: {} });
assert.equal(unknown.error.code, -32602);
assert.equal((await server.send("resources/list")).error.code, -32601);
server.raw("not json\n");
// A notification gets no reply; the next request still answers.
server.raw(JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }) + "\n");
assert.deepEqual((await server.send("ping")).result, {});
assert.deepEqual(server.orphans, [{ jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" } }]);
assert.equal(sock.calls.length, 0);
});
test("a missing argument is a usage error", async (t) => {
const child = spawn(process.execPath, [SOURCES.mcpServer], { stdio: "pipe" });
const code = await new Promise((r) => child.on("exit", r));
assert.equal(code, 2);
});
+231
View File
@@ -0,0 +1,231 @@
// Real pi (the pinned CLI) through adapters/pi with the extension, against
// the scripted Messages API. No real model, no network beyond 127.0.0.1.
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { existsSync, mkdirSync, readdirSync, symlinkSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { buildBundle } from "../src/bundle.mjs";
import { adapterEnv } from "../src/runner.mjs";
import { fakeToolSocket, mockAnthropic, REPO, resolvedFor, scratch } from "./helpers.mjs";
const ADAPTER = join(REPO, "adapters", "pi", "adapter.sh");
async function session(t, script, place = (dir) => join(dir, "ws")) {
const dir = scratch(t);
const workspace = place(dir);
const agentDir = join(dir, "pi-agent");
mkdirSync(workspace, { recursive: true });
mkdirSync(agentDir);
writeFileSync(join(workspace, "notes.txt"), "inside\n");
writeFileSync(join(dir, "secret.txt"), "outside\n");
const api = await mockAnthropic(t, { script, done: (r) => `ANSWER ${JSON.stringify(r.map((x) => [x.isError, x.content.slice(0, 80)]))}` });
writeFileSync(join(agentDir, "models.json"), JSON.stringify({ providers: { probe: { baseUrl: api.url, api: "anthropic-messages", apiKey: "probe-dummy", models: [{ id: "probe-model" }] } } }));
const resolved = resolvedFor(t, "pm", (d) => (d.roles.pm.vars = { ...d.roles.pm.vars, model: "probe-model" }));
const sock = await fakeToolSocket(t, dir, (tool, args) => {
if (tool === "launch") throw new Error("capacity-full");
return { tool, args };
});
const s = {
harness: "pi",
provider: "probe",
model: "probe-model",
workspace,
sessionDir: join(dir, "session"),
toolSocket: sock.path,
turnMarker: join(dir, "turn.done"),
};
const { files } = buildBundle({ dir: join(dir, "bundle"), resolved, contract: "# PM\n", tracker: true, session: { ...s, run: "run-1", launchedBy: "jason" } });
s.bundle = files;
const env = adapterEnv(s, {
PATH: `${join(REPO, "node_modules", ".bin")}:/usr/bin:/bin`,
HOME: join(dir, "home"),
PI_CODING_AGENT_DIR: agentDir,
});
return { dir, workspace, api, sock, s, env };
}
function turn(env, request, cwd) {
return new Promise((resolve) => {
const child = spawn("/bin/sh", [ADAPTER], { cwd, env: { ...env, MOSAIC_REQUEST: request }, stdio: ["ignore", "pipe", "pipe"] });
let stdout = "";
let stderr = "";
child.stdout.on("data", (b) => (stdout += b));
child.stderr.on("data", (b) => (stderr += b));
const clock = setTimeout(() => child.kill("SIGKILL"), 60_000);
child.on("close", (code) => {
clearTimeout(clock);
resolve({ code, stdout, stderr });
});
});
}
test("pi: typed tools reach the socket, the gate blocks, agent_end writes the marker", async (t) => {
const { api, sock, s, env } = await session(t, [
{ name: "list_agents", input: {} },
{ name: "launch", input: { instance: "coder" } },
{ name: "read", input: { path: "notes.txt" } },
{ name: "read", input: { path: "../secret.txt" } },
{ name: "bash", input: { command: "cat ../secret.txt" } },
]);
const r = await turn(env, "Message 1 from jason, class REQUEST:\n\nwho is running?", s.workspace);
assert.equal(r.code, 0, r.stderr);
assert.ok(existsSync(s.turnMarker), "agent_end marker");
const answer = r.stdout.trim();
assert.match(answer, /^ANSWER /);
const results = JSON.parse(answer.slice("ANSWER ".length));
assert.equal(results.length, 5);
// list_agents went through the socket; launch was refused by it.
assert.equal(results[0][0], false);
assert.match(results[0][1], /"tool": "list_agents"/);
assert.equal(results[1][0], true);
assert.match(results[1][1], /refused: capacity-full/);
// read inside passes; outside is gated; bash isn't in the PM's tools.
assert.equal(results[2][0], false);
assert.match(results[2][1], /inside/);
assert.equal(results[3][0], true);
assert.match(results[3][1], /outside the workspace/);
assert.equal(results[4][0], true);
assert.deepEqual(sock.calls.map((c) => c.tool), ["list_agents", "launch"]);
// The model saw the typed tools and the generated prompt, not pi's own.
const first = api.requests[0];
assert.ok(first.tools.includes("list_agents") && first.tools.includes("launch") && !first.tools.includes("bash"));
assert.match(JSON.stringify(first.system), /## This session/);
// The session persists in the declared directory.
assert.ok(readdirSync(s.sessionDir).length > 0);
});
test("pi: a write through a dangling symlink is blocked, and nothing appears outside", async (t) => {
const { dir, workspace, s, env } = await session(t, [
{ name: "write", input: { path: "notes.md", content: "planted\n" } },
{ name: "write", input: { path: "gone/x.md", content: "planted\n" } },
{ name: "write", input: { path: "fine.md", content: "inside\n" } },
]);
mkdirSync(join(dir, "outside"));
symlinkSync(join(dir, "outside", "planted.txt"), join(workspace, "notes.md"));
symlinkSync(join(dir, "outside", "made"), join(workspace, "gone"));
const r = await turn(env, "Message 1 from jason, class REQUEST:\n\nwrite your notes", workspace);
assert.equal(r.code, 0, r.stderr);
const results = JSON.parse(r.stdout.trim().slice("ANSWER ".length));
assert.equal(results.length, 3);
assert.equal(results[0][0], true);
assert.match(results[0][1], /dangling symlink: notes\.md/);
assert.equal(results[1][0], true);
assert.match(results[1][1], /dangling symlink: gone\/x\.md/);
assert.equal(results[2][0], false);
assert.ok(existsSync(join(workspace, "fine.md")), "the write inside landed");
assert.deepEqual(readdirSync(join(dir, "outside")), []);
assert.ok(!existsSync(join(dir, "outside", "made")));
assert.ok(existsSync(s.turnMarker));
});
test("pi: a read is refused when pi would open another spelling outside", async (t) => {
// Asked name -> link on disk; pi's read opens the link when the asked
// name doesn't exist (path-utils.js resolveReadPathAsync).
const spellings = [
["notes's.txt", "notes\u2019s.txt"],
["shot 9.41 AM.png", "shot 9.41\u202FAM.png"],
["r\u00E9sum\u00E9.txt", "r\u00E9sum\u00E9.txt".normalize("NFD")],
["d'\u00E9cran.png", "d\u2019\u00E9cran.png".normalize("NFD")],
["l'\u00E9t\u00E9.txt", "l'\u00E9t\u00E9.txt".normalize("NFD")],
["l'\u00E9t\u00E9.md", "l\u2019\u00E9t\u00E9.md"],
];
const { dir, workspace, s, env } = await session(t, [
...spellings.map(([asked]) => ({ name: "read", input: { path: asked } })),
{ name: "read", input: { path: "inside's.txt" } },
]);
mkdirSync(join(dir, "outside"));
writeFileSync(join(dir, "outside", "secret.txt"), "SECRET-OUTSIDE\n");
for (const [, disk] of spellings) symlinkSync(join(dir, "outside", "secret.txt"), join(workspace, disk));
// The same respelling pointing inside is still read.
symlinkSync(join(workspace, "notes.txt"), join(workspace, "inside\u2019s.txt"));
const r = await turn(env, "Message 1 from jason, class REQUEST:\n\nread the files", workspace);
assert.equal(r.code, 0, r.stderr);
assert.doesNotMatch(r.stdout, /SECRET/);
const results = JSON.parse(r.stdout.trim().slice("ANSWER ".length));
assert.equal(results.length, spellings.length + 1);
spellings.forEach(([asked], i) => {
assert.equal(results[i][0], true, asked);
assert.match(results[i][1], /outside the workspace under another spelling/, asked);
});
assert.deepEqual(results.at(-1), [false, "inside\n"]);
assert.ok(existsSync(s.turnMarker));
});
// The adapter cds into the workspace, and pi resolves `..` from that real
// path. Each layout: where the workspace is given, and where `escape` lands
// for pi (as given, it is the workspace itself).
const CLIMBS = {
workspace: {
place(dir) {
mkdirSync(join(dir, "deep", "store", "ws"), { recursive: true });
mkdirSync(join(dir, "a"));
symlinkSync(join(dir, "deep", "store", "ws"), join(dir, "a", "ws"));
return join(dir, "a", "ws");
},
outside: (dir) => join(dir, "deep", "a", "ws"),
escape: "../../a/ws",
stay: "../ws",
},
dataRoot: {
place(dir) {
mkdirSync(join(dir, "deep", "store", "workspaces", "b", "i"), { recursive: true });
symlinkSync(join(dir, "deep", "store"), join(dir, "data"));
return join(dir, "data", "workspaces", "b", "i");
},
outside: (dir) => join(dir, "deep", "data", "workspaces", "b", "i"),
escape: "../../../../data/workspaces/b/i",
stay: "../i",
},
};
for (const [name, { place, outside, escape, stay }] of Object.entries(CLIMBS)) {
test(`pi: a relative path climbs from the real path of a ${name} behind a symlink`, async (t) => {
const { dir, workspace, s, env } = await session(
t,
[
{ name: "read", input: { path: `${escape}/secret.txt` } },
{ name: "write", input: { path: `${escape}/planted.txt`, content: "planted\n" } },
{ name: "read", input: { path: `${stay}/notes.txt` } },
{ name: "write", input: { path: `${stay}/fine.md`, content: "inside\n" } },
],
place,
);
mkdirSync(outside(dir), { recursive: true });
writeFileSync(join(outside(dir), "secret.txt"), "SECRET-OUTSIDE\n");
const r = await turn(env, "Message 1 from jason, class REQUEST:\n\nread and write", workspace);
assert.equal(r.code, 0, r.stderr);
assert.doesNotMatch(r.stdout, /SECRET/);
const results = JSON.parse(r.stdout.trim().slice("ANSWER ".length));
assert.equal(results.length, 4);
for (const i of [0, 1]) {
assert.equal(results[i][0], true);
assert.match(results[i][1], /outside the workspace/);
}
assert.deepEqual(readdirSync(outside(dir)), ["secret.txt"]);
assert.ok(!existsSync(join(workspace, "planted.txt")));
assert.deepEqual(results[2], [false, "inside\n"]);
assert.equal(results[3][0], false);
assert.ok(existsSync(join(workspace, "fine.md")), "the write inside landed");
assert.ok(existsSync(s.turnMarker));
});
}
test("pi: a missing extension refuses before any model call", async (t) => {
const { dir, api, s, env } = await session(t, []);
const r = await turn({ ...env, MOSAIC_EXTENSIONS: join(dir, "missing.mjs") }, "x", s.workspace);
assert.equal(r.code, 2);
assert.match(r.stderr, /extension missing/);
assert.equal(api.requests.length, 0);
});
test("pi: an extension without its configuration fails pi's start", async (t) => {
const { api, s, env } = await session(t, []);
const { MOSAIC_POLICY_FILE, ...rest } = env;
const r = await turn(rest, "x", s.workspace);
assert.notEqual(r.code, 0);
assert.ok(!existsSync(s.turnMarker));
assert.equal(api.requests.length, 0);
assert.ok(MOSAIC_POLICY_FILE);
});
+337
View File
@@ -0,0 +1,337 @@
// The runner against a real broker on a real socket, with a fake adapter.
// The broker runs in this process; the runner is a child, as the host
// starts it (minus the PID namespace, which packages/seat tests).
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawn, spawnSync } from "node:child_process";
import { once } from "node:events";
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { open } from "node:fs/promises";
import { createServer } from "node:net";
import { join } from "node:path";
import { Store } from "../../bus/src/store.mjs";
import { Broker } from "../../bus/src/broker.mjs";
import { serve } from "../../bus/src/server.mjs";
import { EXIT, founderCheck, turnRequest } from "../src/runner.mjs";
import { scratch } from "./helpers.mjs";
const RUNNER = new URL("../src/runner.mjs", import.meta.url).pathname;
const FAKE = new URL("./fixtures/fake-adapter.mjs", import.meta.url).pathname;
const businesses = {
demo: {
id: "demo",
human: "jason",
arbiters: { technical: "pm", delivery: "pm" },
launch: { by: "pm", instances: ["coder"], max: { opus: 2, sonnet: 2 } },
roles: {
pm: { authority: { withinRole: ["message.send", "role.launch"], crossRole: [] } },
coder: { authority: { withinRole: ["message.send"], crossRole: [] } },
},
},
};
test("founderCheck: founder variables, then a needed service without a usable token", () => {
const policy = { needs: ["gitea"], credentials: [{ service: "gitea", state: "valid" }] };
assert.equal(founderCheck(policy, {}), null);
assert.equal(founderCheck({ needs: [] }, { PATH: "/bin" }), null);
assert.match(founderCheck(policy, { GITEA_TOKEN: "x", SSH_AUTH_SOCK: "/s" }), /GITEA_TOKEN, SSH_AUTH_SOCK/);
assert.match(founderCheck(policy, { MOSAIC_GITEA_CREDENTIAL_FILE: "" }), /MOSAIC_GITEA_CREDENTIAL_FILE/);
for (const state of ["expiring", "rotation-due"]) assert.equal(founderCheck({ needs: ["x"], credentials: [{ service: "x", state }] }, {}), null);
assert.match(founderCheck({ needs: ["vikunja"], credentials: [{ service: "vikunja", state: "expired" }] }, {}), /no usable role token for vikunja/);
assert.match(founderCheck({ needs: ["gitea", "vikunja"], credentials: [{ service: "gitea", state: "valid" }] }, {}), /vikunja/);
});
test("turnRequest names the sender, class, reply and decision", () => {
assert.equal(turnRequest({ id: "m1", from_role: "pm", class: "ASSIGNMENT", body: "do it" }), "Message m1 from pm, class ASSIGNMENT:\n\ndo it");
assert.equal(
turnRequest({ id: "m2", from_role: "cto", class: "RESULT", in_reply_to: "m1", decision: "d1", body: "b" }),
"Message m2 from cto, class RESULT, in reply to m1, citing decision d1:\n\nb",
);
});
async function setup(t, { session = {}, policy = {}, tools } = {}) {
const dir = scratch(t);
const store = new Store(dir);
const broker = new Broker({ store, businesses });
const brokerSocket = join(dir, "bus.sock");
const server = await serve({ broker, path: brokerSocket });
t.after(async () => {
await server.close().catch(() => {});
store.close();
});
const runDir = join(dir, "run");
const bundle = join(runDir, "bundle");
mkdirSync(bundle, { recursive: true, mode: 0o700 });
const adapter = join(dir, "adapter.sh");
writeFileSync(adapter, `exec '${process.execPath}' '${FAKE}'\n`);
writeFileSync(join(bundle, "prompt.md"), "# test\n");
writeFileSync(join(bundle, "policy.json"), JSON.stringify({ harness: "pi", workspace: join(dir, "ws"), tools: [], typed: ["send_message", "launch"], network: "none", needs: [], credentials: [], ...policy }));
writeFileSync(join(bundle, "tools.json"), JSON.stringify(tools ?? [
{ name: "send_message", verb: "message.send", action: "message.send" },
{ name: "launch", verb: "launch", action: "role.launch" },
]));
mkdirSync(join(dir, "ws"));
const launches = [];
const launchSocket = join(dir, "launch.sock");
const launchServer = createServer((s) => {
s.once("data", (b) => {
const r = JSON.parse(b);
launches.push(r);
s.end(JSON.stringify(r.instance === "coder" ? { ok: false, error: "capacity-full" } : { ok: true, result: { run: "new-run" } }) + "\n");
});
});
await new Promise((r) => launchServer.listen(launchSocket, r));
t.after(() => launchServer.close());
writeFileSync(join(runDir, "session.json"), JSON.stringify({
business: "demo",
instance: "coder",
run: "coder-run",
launchedBy: "pm",
harness: "pi",
provider: "fake",
model: "fake-model",
brokerSocket,
launchSocket,
workspace: join(dir, "ws"),
sessionDir: join(runDir, "session"),
adapter,
bundle: { prompt: join(bundle, "prompt.md"), policy: join(bundle, "policy.json"), tools: join(bundle, "tools.json") },
toolSocket: join(runDir, "tools.sock"),
turnMarker: join(runDir, "turn.done"),
pollInterval: 50,
turnTimeout: 20,
...session,
}));
const cap = broker.bindLaunch({ business: "demo", role: "coder", run: "coder-run", harness: "pi" });
const pm = broker.bindLaunch({ business: "demo", role: "pm", run: "pm-run", harness: "pi" });
const call = (c, verb, args = {}) => broker.request(c, { verb, args });
call(pm, "role.claim");
return { dir, runDir, store, broker, server, cap, pm, call, launches, pids: join(dir, "pids") };
}
// A runner that hasn't exited after a minute is killed, so a test that
// expected an exit fails on the code instead of hanging.
function startRunner(runDir, cap, env = {}, input = cap === null ? "" : JSON.stringify({ cap }) + "\n") {
const child = spawn(process.execPath, [RUNNER, runDir], {
stdio: ["pipe", "ignore", "pipe"],
env: { PATH: process.env.PATH, ...env },
});
let stderr = "";
child.stderr.on("data", (b) => (stderr += b));
child.stdin.on("error", () => {});
child.stdin.end(input);
const clock = setTimeout(() => child.kill("SIGKILL"), 60_000);
const exited = once(child, "exit").then(([code, signal]) => {
clearTimeout(clock);
return { code, signal, stderr };
});
return { child, exited, stderr: () => stderr };
}
async function until(fn, ms = 15_000) {
const end = Date.now() + ms;
for (;;) {
const v = fn();
if (v) return v;
if (Date.now() > end) throw new Error("timed out waiting");
await new Promise((r) => setTimeout(r, 25));
}
}
const replies = (ctx) => () => {
const got = ctx.call(ctx.pm, "message.receive");
ctx.inbox.push(...got);
return ctx.inbox.length ? ctx.inbox : null;
};
test("a message becomes a turn, the answer goes back as a RESULT, SIGTERM releases and exits 0", async (t) => {
const ctx = await setup(t);
ctx.inbox = [];
const r = startRunner(ctx.runDir, ctx.cap);
await until(() => ctx.store.get("SELECT 1 FROM role_claims WHERE role='coder' AND op='claim'"));
const sent = ctx.call(ctx.pm, "message.send", { to: "coder", body: "hello", class: "REQUEST" });
const [reply] = await until(replies(ctx));
assert.equal(reply.class, "RESULT");
assert.equal(reply.from_role, "coder");
assert.equal(reply.from_run, "coder-run");
assert.equal(reply.in_reply_to, sent.id);
assert.equal(reply.body, `echo: Message ${sent.id} from pm, class REQUEST:\n\nhello`);
await until(() => ctx.store.get("SELECT 1 FROM deliveries WHERE message=? AND op='read'", sent.id));
const err = join(ctx.runDir, "turns", "0001.stderr");
assert.equal(statSync(err).mode & 0o777, 0o600);
assert.equal(readFileSync(err, "utf8"), "fake adapter: fake-model\n");
assert.equal(statSync(join(ctx.runDir, "tools.sock")).mode & 0o777, 0o600);
r.child.kill("SIGTERM");
const out = await r.exited;
assert.equal(out.code, EXIT.stopped, out.stderr);
assert.match(out.stderr, /runner: demo\/coder claimed by run coder-run/);
assert.equal(ctx.store.get("SELECT op FROM role_claims WHERE role='coder' ORDER BY seq DESC LIMIT 1").op, "release");
assert.ok(!existsSync(join(ctx.runDir, "tools.sock")));
});
test("a SIGTERM before the claim stops the runner with exit 0 and no claim", async (t) => {
const ctx = await setup(t);
// session.json becomes a FIFO: main opens it right after its handlers go
// in, so the open on this side returns once a SIGTERM would be caught.
const file = join(ctx.runDir, "session.json");
const body = readFileSync(file);
rmSync(file);
assert.equal(spawnSync("mkfifo", ["-m", "600", file]).status, 0);
const child = spawn(process.execPath, [RUNNER, ctx.runDir], { stdio: ["pipe", "ignore", "pipe"], env: { PATH: process.env.PATH } });
t.after(() => child.kill("SIGKILL"));
let stderr = "";
child.stderr.on("data", (b) => (stderr += b));
const exited = once(child, "exit");
const fifo = await open(file, "w");
child.kill("SIGTERM");
await fifo.write(body);
await fifo.close();
child.stdin.end(JSON.stringify({ cap: ctx.cap }) + "\n");
const [code, signal] = await exited;
assert.deepEqual([code, signal], [EXIT.stopped, null], stderr);
assert.match(stderr, /runner: stopped before claim/);
assert.equal(ctx.store.get("SELECT 1 FROM role_claims WHERE role='coder'"), undefined);
});
test("typed tools carry the runner's capability; launch goes to the host's launch socket", async (t) => {
const ctx = await setup(t);
ctx.inbox = [];
const r = startRunner(ctx.runDir, ctx.cap);
t.after(() => r.child.kill("SIGKILL"));
ctx.call(ctx.pm, "message.send", { to: "coder", body: 'TOOL send_message {"to":"pm","body":"side note","class":"INFO"}' });
await until(() => replies(ctx)() && ctx.inbox.length >= 2);
const [info, result] = ctx.inbox;
assert.equal(info.class, "INFO");
assert.equal(info.body, "side note");
assert.equal(info.from_run, "coder-run");
assert.equal(JSON.parse(result.body).id, info.id);
ctx.inbox.length = 0;
ctx.call(ctx.pm, "message.send", { to: "coder", body: 'TOOL launch {"instance":"coder"}' });
ctx.call(ctx.pm, "message.send", { to: "coder", body: 'TOOL launch {"instance":"reviewer"}' });
ctx.call(ctx.pm, "message.send", { to: "coder", body: 'TOOL resolve_decision {"id":"x"}' });
ctx.call(ctx.pm, "message.send", { to: "coder", body: 'TOOL send_message {"to":"nobody","body":"x"}' });
await until(() => replies(ctx)() && ctx.inbox.length >= 4);
assert.deepEqual(ctx.inbox.map((m) => m.body), ["refused: capacity-full", '{"run":"new-run"}', "refused: unknown-tool", "refused: unknown-role"]);
assert.deepEqual(ctx.launches, [{ cap: ctx.cap, instance: "coder" }, { cap: ctx.cap, instance: "reviewer" }]);
});
test("a RESULT gets no automatic reply; failed turns reply with the reason", async (t) => {
const ctx = await setup(t, { session: { turnTimeout: 1 } });
ctx.inbox = [];
const r = startRunner(ctx.runDir, ctx.cap);
t.after(() => r.child.kill("SIGKILL"));
const first = ctx.call(ctx.pm, "message.send", { to: "coder", body: "hi" });
const res = ctx.call(ctx.pm, "message.send", { to: "coder", body: "thanks", class: "RESULT", in_reply_to: first.id });
ctx.call(ctx.pm, "message.send", { to: "coder", body: "FAIL" });
ctx.call(ctx.pm, "message.send", { to: "coder", body: "NOMARKER" });
ctx.call(ctx.pm, "message.send", { to: "coder", body: "SLEEP" });
await until(() => replies(ctx)() && ctx.inbox.length >= 4);
assert.deepEqual(ctx.inbox.map((m) => m.body.replace(/^echo: .*/s, "echo")), [
"echo",
"The turn failed: adapter exited 3",
"The turn failed: pi exited before the turn ended (no agent_end)",
"The turn failed: turn passed its 1 s wall clock",
]);
await until(() => ctx.store.get("SELECT 1 FROM deliveries WHERE message=? AND op='read'", res.id));
});
test("SIGTERM during a turn kills the turn's process group and still exits 0", async (t) => {
// A long wall clock, so only the stop path can end the turn in time.
const ctx = await setup(t, { session: { turnTimeout: 600 } });
const r = startRunner(ctx.runDir, ctx.cap, { FAKE_PIDS: ctx.pids });
t.after(() => r.child.kill("SIGKILL"));
ctx.call(ctx.pm, "message.send", { to: "coder", body: "SLEEP" });
const pid = Number(await until(() => existsSync(ctx.pids) && readFileSync(ctx.pids, "utf8").trim()));
const started = Date.now();
r.child.kill("SIGTERM");
const out = await Promise.race([r.exited, new Promise((res) => setTimeout(() => res({ code: "still running" }), 8000))]);
assert.equal(out.code, EXIT.stopped, out.stderr);
assert.ok(Date.now() - started < 8000);
await until(() => {
try {
process.kill(pid, 0);
return false;
} catch {
return true;
}
}, 8000);
});
test("founder credentials stop before the claim (20)", async (t) => {
const ctx = await setup(t);
const out = await startRunner(ctx.runDir, ctx.cap, { GH_TOKEN: "x" }).exited;
assert.equal(out.code, EXIT.founder);
assert.match(out.stderr, /REQ-CRED-2.*GH_TOKEN/);
assert.equal(ctx.store.get("SELECT 1 FROM role_claims WHERE role='coder'"), undefined);
const ctx2 = await setup(t, { policy: { needs: ["gitea"], credentials: [{ service: "gitea", state: "expired" }] } });
const out2 = await startRunner(ctx2.runDir, ctx2.cap).exited;
assert.equal(out2.code, EXIT.founder);
assert.match(out2.stderr, /no usable role token for gitea/);
});
test("a refused claim exits 21; an ended run's capability exits 22", async (t) => {
const ctx = await setup(t);
const other = ctx.broker.bindLaunch({ business: "demo", role: "coder", run: "other-run", harness: "pi" });
ctx.call(other, "role.claim");
const out = await startRunner(ctx.runDir, ctx.cap).exited;
assert.equal(out.code, EXIT.claim, out.stderr);
assert.match(out.stderr, /role.claim refused: role-already-held/);
const ctx2 = await setup(t);
ctx2.broker.endLaunch({ business: "demo", run: "coder-run", reason: "stopped", exitCode: 0 });
assert.equal((await startRunner(ctx2.runDir, ctx2.cap).exited).code, EXIT.session);
});
test("the launch ending under a running session exits 22", async (t) => {
const ctx = await setup(t);
const r = startRunner(ctx.runDir, ctx.cap);
await until(() => ctx.store.get("SELECT 1 FROM role_claims WHERE role='coder' AND op='claim'"));
ctx.broker.endLaunch({ business: "demo", run: "coder-run", reason: "killed", exitCode: null });
const out = await r.exited;
assert.equal(out.code, EXIT.session, out.stderr);
assert.match(out.stderr, /session stopped working: unauthenticated/);
});
test("a broker that stays unreachable exits 23 after brokerRetries polls", async (t) => {
const ctx = await setup(t, { session: { brokerRetries: 3 } });
const r = startRunner(ctx.runDir, ctx.cap);
await until(() => ctx.store.get("SELECT 1 FROM role_claims WHERE role='coder' AND op='claim'"));
await ctx.server.close();
const out = await r.exited;
assert.equal(out.code, EXIT.broker, out.stderr);
assert.match(out.stderr, /broker unreachable after 3 tries/);
});
test("a broker that is down at the claim exits 23, not 21", async (t) => {
const ctx = await setup(t);
await ctx.server.close();
const out = await startRunner(ctx.runDir, ctx.cap).exited;
assert.equal(out.code, EXIT.broker, out.stderr);
assert.match(out.stderr, /role.claim got no answer from the broker/);
});
test("no capability, or a malformed one, on stdin exits 2", async (t) => {
const ctx = await setup(t);
assert.equal((await startRunner(ctx.runDir, null).exited).code, EXIT.usage);
assert.equal((await startRunner(ctx.runDir, "not-hex").exited).code, EXIT.usage);
assert.equal((await startRunner(join(ctx.dir, "nope"), ctx.cap).exited).code, EXIT.usage);
// A valid capability inside an over-long line: the 4096-byte cap refuses it.
const long = await startRunner(ctx.runDir, ctx.cap, {}, JSON.stringify({ cap: ctx.cap, pad: "x".repeat(5000) }) + "\n").exited;
assert.equal(long.code, EXIT.usage, long.stderr);
assert.match(long.stderr, /stdin too large/);
assert.equal(ctx.store.get("SELECT 1 FROM role_claims WHERE role='coder'"), undefined);
});
test("a missing or malformed policy exits 2 before the claim", async (t) => {
const ctx = await setup(t);
const policy = join(ctx.runDir, "bundle", "policy.json");
writeFileSync(policy, "{");
const bad = await startRunner(ctx.runDir, ctx.cap).exited;
assert.equal(bad.code, EXIT.usage, bad.stderr);
assert.match(bad.stderr, /^runner: /m);
rmSync(policy);
const missing = await startRunner(ctx.runDir, ctx.cap).exited;
assert.equal(missing.code, EXIT.usage, missing.stderr);
assert.match(missing.stderr, /runner: ENOENT/);
assert.equal(ctx.store.get("SELECT 1 FROM role_claims WHERE role='coder'"), undefined);
});
+59
View File
@@ -0,0 +1,59 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { callTool, typedTools, untypedActions } from "../src/tools.mjs";
import { fakeToolSocket, resolvedFor, scratch } from "./helpers.mjs";
const names = (tools) => tools.map((t) => t.name);
test("the PM gets launch, its task verbs and the reads", (t) => {
const pm = resolvedFor(t, "pm");
const tools = typedTools({ resolved: pm, arbiter: true, tracker: true });
assert.deepEqual(names(tools), [
"send_message", "launch",
"task_create", "task_assign", "task_reassign", "task_schedule", "task_priority_change", "task_scope_change",
"task_update_assigned", "task_close",
"raise_decision", "resolve_decision", "list_agents", "inbox", "trail", "list_tasks",
]);
const launch = tools.find((x) => x.name === "launch");
assert.equal(launch.verb, "launch");
assert.equal(launch.action, "role.launch");
assert.deepEqual(launch.parameters.properties.instance.enum, ["coder", "reviewer"]);
for (const tool of tools) {
assert.equal(tool.parameters.type, "object");
assert.equal(tool.parameters.additionalProperties, false);
assert.ok(tool.description.length > 10);
}
});
test("a coder gets no launch, no resolve_decision, and no task tools without a tracker", (t) => {
const coder = resolvedFor(t, "coder");
const tools = typedTools({ resolved: coder });
assert.deepEqual(names(tools), ["send_message", "raise_decision", "list_agents", "inbox", "trail"]);
// Held actions without a tool are listed, so the manifest shows them.
const untyped = untypedActions(coder, tools);
assert.ok(untyped.includes("git.push.working"));
assert.ok(!untyped.includes("message.send"));
});
test("launch only when the business's launch block names the instance as launcher", (t) => {
const pm = resolvedFor(t, "pm", (d) => delete d.launch);
assert.equal(pm.launch, null);
assert.ok(!names(typedTools({ resolved: pm })).includes("launch"));
});
test("an action outside the instance's authority has no tool", (t) => {
const coder = resolvedFor(t, "coder", (d) => (d.roles.coder.vars["limits.authority"] = ["review.request"]));
assert.deepEqual(names(typedTools({ resolved: coder, tracker: true })), ["raise_decision", "list_agents", "inbox", "trail", "list_tasks"]);
});
test("callTool: one JSON line out, the result back, a refusal rejects", async (t) => {
const dir = scratch(t);
const sock = await fakeToolSocket(t, dir, (tool, args) => {
if (tool === "launch") throw new Error("capacity-full");
return { echoed: args };
});
assert.deepEqual(await callTool(sock.path, "list_agents", { a: 1 }), { echoed: { a: 1 } });
await assert.rejects(callTool(sock.path, "launch", { instance: "coder" }), /refused: capacity-full/);
await assert.rejects(callTool(`${dir}/missing.sock`, "x"), /tool socket: ENOENT/);
assert.deepEqual(sock.calls.map((c) => c.tool), ["list_agents", "launch"]);
});