feat(harness): meta-harness and PM launching off T3 (#1523, row 41)
Slice 1 S6. packages/harness runs a role session under the pi or claude
adapter behind a tool gate, and packages/seat holds the session and its
process. The bus host gains a launch socket: `mosaic bus start
<business> --pm <harness>:<model>` runs the PM without a window, the PM
launches role sessions through the broker within the business file's
`launch.max`, and every launch, refusal and end is a launch-log line and
a broker event. `mosaic talk`, `mosaic launches off|on|list` and
`mosaic stop <run>` are new CLI verbs. packages/bus gains the IPC ops
identity, authorize, refuse, endLaunch and credentialStatus, plus
Broker.endLaunch (decision 77); no socket verb, event kind or schema
changed. The session environment is an allowlist, and the runner exits
before claiming if a founder credential reaches it.
Filbert built it (agents/filbert/work/s6). Darkwing reviewed on #1523:
round 1 changes (26991/26995), round 2 changes (27004/27010), round 3
changes (27012/27032), round 4 approve (27033/27034) on manifest
08a78972, 42 paths.
Sage's gate on 64b0f9f1 (12:32Z), sequential and teed: node suites
business 60, bus 74, cli 83, control-board 124, conversation 182,
discord 178, harness 56, ledger 78, mosaic 69, queue 149, runs 41,
seat 27, tasks 51, webui 39, every one 0 failed; test-auth 15,
test-conductor 17, test-config 24, test-discord 66,
test-extension-package 18, test-foundation 44, test-queue 27,
test-release 14, test-task 98 and 26 without Docker, all 0 failed.
64b0f9f1..ee7f909b touches none of the 42 paths. It landed after the
Q14 hold ended at 2026-10-11T17:19:23.857Z. The recorded PM-to-coder
run on a scratch data root is still owed before row 41 moves to done.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,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.
|
||||
@@ -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" }
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// The launch bundle: everything a managed session runs from, generated
|
||||
// from one resolved role instance and written once into the run
|
||||
// directory. prompt.md, policy.json, tools.json and manifest.json for both
|
||||
// harnesses; settings.json (the gate hook) and mcp.json (the typed tools)
|
||||
// for Claude Code. The Pi extension and the Claude gate stay at their
|
||||
// source paths; the manifest records their digests.
|
||||
//
|
||||
// Each harness layer the bundle relies on is a row S0 line
|
||||
// (docs/plans/2026-10-04_slice-1.md, "What S6 can rely on"); the manifest
|
||||
// names them.
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { typedTools, untypedActions } from "./tools.mjs";
|
||||
import { claudeBuiltins, MCP_PREFIX } from "./gate.mjs";
|
||||
|
||||
export const BUNDLE_VERSION = 1;
|
||||
export const HARNESSES = Object.freeze(["pi", "claude-code"]);
|
||||
export const SOURCES = Object.freeze({
|
||||
piExtension: fileURLToPath(new URL("./pi-extension.mjs", import.meta.url)),
|
||||
claudeGate: fileURLToPath(new URL("./claude-gate.mjs", import.meta.url)),
|
||||
mcpServer: fileURLToPath(new URL("./mcp-server.mjs", import.meta.url)),
|
||||
gate: fileURLToPath(new URL("./gate.mjs", import.meta.url)),
|
||||
tools: fileURLToPath(new URL("./tools.mjs", import.meta.url)),
|
||||
});
|
||||
// S0 lines 1-5 for both harnesses; line 6 is the Agent SDK, which S6
|
||||
// doesn't use.
|
||||
const RELIES_ON = Object.freeze({
|
||||
pi: ["S0-1 tool_call block", "S0-2 a throw blocks", "S0-3 a missing -e refuses to start", "S0-4 external wall clock plus agent_end check", "S0-5 bash is limited by the tool limit only"],
|
||||
"claude-code": ["S0-1 command hook deny, no --bare", "S0-2 hook wrapped as <gate> || exit 2", "S0-3 a missing gate blocks through the wrapper", "S0-4 timeout -k 2 10 <gate> || exit 2 with hook timeout 20", "S0-5 bash is limited by the tool limit only"],
|
||||
});
|
||||
// The Claude hook: N = 10, K = 2, hook timeout 20, which leaves the
|
||||
// margin the brief asks for above N + K.
|
||||
const GATE_TIMEOUT = { n: 10, k: 2, hook: 20 };
|
||||
|
||||
export class BundleError extends Error {}
|
||||
|
||||
const sha256 = (text) => createHash("sha256").update(text).digest("hex");
|
||||
const quote = (s) => {
|
||||
if (s.includes("'")) throw new BundleError(`path can't hold a single quote: ${s}`);
|
||||
return `'${s}'`;
|
||||
};
|
||||
|
||||
// The harness, provider and model one instance runs with. The agent-layer
|
||||
// `harness` and `model` variables win; otherwise the system's execution
|
||||
// adapter and model. Anything outside the two harnesses refuses.
|
||||
export function sessionModel(resolved) {
|
||||
const v = resolved.vars;
|
||||
const harness = v.harness ?? v["execution.adapter"];
|
||||
if (!HARNESSES.includes(harness)) throw new BundleError(`instance ${resolved.instance}: harness ${JSON.stringify(harness)} isn't one of ${HARNESSES.join(", ")}`);
|
||||
const model = v.model ?? v["execution.model"];
|
||||
if (typeof model !== "string" || !model) throw new BundleError(`instance ${resolved.instance}: no model`);
|
||||
return { harness, provider: v["execution.provider"] ?? null, model, thinking: v.thinking ?? null };
|
||||
}
|
||||
|
||||
function prompt({ contract, resolved, session, tools, untyped }) {
|
||||
const lines = [
|
||||
contract.trimEnd(),
|
||||
"",
|
||||
"## This session",
|
||||
"",
|
||||
`- Business: ${resolved.business}. Role instance: ${resolved.instance} (definition ${resolved.definition}). Run: ${session.run}.`,
|
||||
`- Launched by: ${session.launchedBy}. Harness: ${session.harness}, model ${session.model}.`,
|
||||
`- Workspace: ${session.workspace}. File tools work inside it only.`,
|
||||
`- Built-in tools: ${resolved.limits.tools.length ? resolved.limits.tools.join(", ") : "none"}.`,
|
||||
`- Bus tools: ${tools.map((t) => t.name).join(", ")}. The broker decides every call; a refusal names its reason.`,
|
||||
"- Each message to you arrives as one turn. Your final answer goes back to the sender as the reply, so end each turn with the answer itself.",
|
||||
"- You never hold a token. You act on the tracker, the forge and other roles only through the bus tools.",
|
||||
];
|
||||
if (untyped.length) lines.push(`- Actions you hold that have no bus tool yet: ${untyped.join(", ")}. Say so instead of working around it.`);
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
|
||||
// buildBundle({ dir, resolved, contract, arbiter, tracker, credentials, session, piVersion, claudeVersion })
|
||||
// dir the bundle directory, created here (0700)
|
||||
// resolved resolveInstance(...) output
|
||||
// contract the role contract's text
|
||||
// arbiter true when the business names this instance as an arbiter
|
||||
// tracker true when the broker has task verbs for the business
|
||||
// credentials the broker's credentialStatus(...) for the instance (metadata only)
|
||||
// session { run, launchedBy, workspace, toolSocket, turnMarker }
|
||||
// piVersion the pinned pi version, for the manifest
|
||||
// claudeVersion the host's `claude --version`, for the manifest
|
||||
// Returns { manifest, files } with absolute paths.
|
||||
export function buildBundle({ dir, resolved, contract, arbiter = false, tracker = false, credentials = [], session, piVersion = null, claudeVersion = null }) {
|
||||
const model = sessionModel(resolved);
|
||||
const tools = typedTools({ resolved, arbiter, tracker });
|
||||
const untyped = untypedActions(resolved, tools);
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
const write = (name, text) => {
|
||||
writeFileSync(join(dir, name), text, { mode: 0o600, flag: "wx" });
|
||||
return [name, sha256(text)];
|
||||
};
|
||||
const policy = {
|
||||
harness: model.harness,
|
||||
workspace: session.workspace,
|
||||
tools: [...resolved.limits.tools],
|
||||
typed: tools.map((t) => t.name),
|
||||
network: resolved.limits.network,
|
||||
needs: Object.keys(resolved.credentials ?? {}),
|
||||
credentials,
|
||||
};
|
||||
const files = {
|
||||
prompt: join(dir, "prompt.md"),
|
||||
policy: join(dir, "policy.json"),
|
||||
tools: join(dir, "tools.json"),
|
||||
manifest: join(dir, "manifest.json"),
|
||||
};
|
||||
const digests = [
|
||||
write("prompt.md", prompt({ contract, resolved, session: { ...session, ...model }, tools, untyped })),
|
||||
write("policy.json", JSON.stringify(policy, null, 2) + "\n"),
|
||||
write("tools.json", JSON.stringify(tools, null, 2) + "\n"),
|
||||
];
|
||||
const sources = { gate: SOURCES.gate, tools: SOURCES.tools };
|
||||
if (model.harness === "pi") {
|
||||
sources.piExtension = SOURCES.piExtension;
|
||||
} else {
|
||||
sources.claudeGate = SOURCES.claudeGate;
|
||||
sources.mcpServer = SOURCES.mcpServer;
|
||||
files.settings = join(dir, "settings.json");
|
||||
files.mcp = join(dir, "mcp.json");
|
||||
const node = process.execPath;
|
||||
const command = `timeout -k ${GATE_TIMEOUT.k} ${GATE_TIMEOUT.n} ${quote(node)} ${quote(SOURCES.claudeGate)} ${quote(files.policy)} || exit 2`;
|
||||
digests.push(write("settings.json", JSON.stringify({
|
||||
hooks: { PreToolUse: [{ matcher: "*", hooks: [{ type: "command", command, timeout: GATE_TIMEOUT.hook }] }] },
|
||||
}, null, 2) + "\n"));
|
||||
digests.push(write("mcp.json", JSON.stringify({
|
||||
mcpServers: { mosaic: { type: "stdio", command: node, args: [SOURCES.mcpServer, files.tools, session.toolSocket] } },
|
||||
}, null, 2) + "\n"));
|
||||
}
|
||||
const manifest = {
|
||||
bundleVersion: BUNDLE_VERSION,
|
||||
business: resolved.business,
|
||||
instance: resolved.instance,
|
||||
definition: resolved.definition,
|
||||
run: session.run,
|
||||
launchedBy: session.launchedBy,
|
||||
resolvedDigest: resolved.digest,
|
||||
...model,
|
||||
piVersion: model.harness === "pi" ? piVersion : null,
|
||||
claudeVersion: model.harness === "claude-code" ? claudeVersion : null,
|
||||
builtins: model.harness === "pi" ? [...resolved.limits.tools] : claudeBuiltins(policy),
|
||||
typed: tools.map((t) => (model.harness === "pi" ? t.name : MCP_PREFIX + t.name)),
|
||||
untyped,
|
||||
skills: [],
|
||||
reliesOn: RELIES_ON[model.harness],
|
||||
gateTimeout: model.harness === "claude-code" ? GATE_TIMEOUT : null,
|
||||
files: Object.fromEntries(digests),
|
||||
sources: Object.fromEntries(Object.entries(sources).map(([k, p]) => [k, { path: p, sha256: sha256(readFileSync(p)) }])),
|
||||
};
|
||||
writeFileSync(files.manifest, JSON.stringify(manifest, null, 2) + "\n", { mode: 0o600, flag: "wx" });
|
||||
return { manifest, files, policy, tools };
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Claude Code PreToolUse command hook for a managed session. The bundle's
|
||||
// settings.json runs it as
|
||||
// timeout -k 2 10 <node> claude-gate.mjs <policy.json> || exit 2
|
||||
// with hook timeout 20 (S0 lines 1-4): exit 0 lets the tool run, exit 2
|
||||
// blocks it with the reason on stderr, and the wrapper turns a crash, a
|
||||
// missing file or a timeout into exit 2.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { decide } from "./gate.mjs";
|
||||
|
||||
function block(reason) {
|
||||
process.stderr.write(`${reason}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
try {
|
||||
const policy = JSON.parse(readFileSync(process.argv[2], "utf8"));
|
||||
if (policy.harness !== "claude-code") block("mosaic gate: policy file is not a claude-code policy");
|
||||
const event = JSON.parse(readFileSync(0, "utf8"));
|
||||
const verdict = decide(policy, event.tool_name, event.tool_input);
|
||||
if (!verdict.allow) block(verdict.reason);
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
block(`mosaic gate: ${error.message}`);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
// The tool gate both harnesses share. decide(policy, tool, input) answers
|
||||
// one tool call: the policy's built-in tools and its typed tools pass,
|
||||
// everything else is blocked, and every path argument of a file tool must
|
||||
// resolve inside the workspace.
|
||||
//
|
||||
// This is a gate on the harness's own tool calls, not a sandbox. `bash`
|
||||
// isn't confined (the role's tool limit decides whether it exists at all),
|
||||
// and anything bash can reach, the session can reach. See the README's
|
||||
// limits.
|
||||
|
||||
import { lstatSync, realpathSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// pi built-in tool -> Claude Code tool. find and ls both map to Glob.
|
||||
export const CLAUDE_TOOLS = Object.freeze({ read: "Read", write: "Write", edit: "Edit", bash: "Bash", grep: "Grep", find: "Glob", ls: "Glob" });
|
||||
export const MCP_PREFIX = "mcp__mosaic__";
|
||||
|
||||
// The input field that holds a path, per tool, per harness. A missing
|
||||
// optional path means the working directory, which is the workspace.
|
||||
const PATH_FIELDS = {
|
||||
pi: { read: "path", write: "path", edit: "path", grep: "path", find: "path", ls: "path" },
|
||||
"claude-code": { Read: "file_path", Write: "file_path", Edit: "file_path", Grep: "path", Glob: "path" },
|
||||
};
|
||||
|
||||
// Claude Code tool names the session may call, for --tools and the gate.
|
||||
export function claudeBuiltins(policy) {
|
||||
return [...new Set(policy.tools.map((t) => CLAUDE_TOOLS[t]))];
|
||||
}
|
||||
|
||||
// Pi's own path normalisation (dist/utils/paths.js normalizePath with the
|
||||
// options resolveToCwd passes): unicode spaces, a leading @, ~ and file://.
|
||||
const UNICODE_SPACES = /[ - ]/g;
|
||||
function normalise(p) {
|
||||
let s = p.replace(UNICODE_SPACES, " ");
|
||||
if (s.startsWith("@")) s = s.slice(1);
|
||||
if (s === "~") return homedir();
|
||||
if (s.startsWith("~/")) return join(homedir(), s.slice(2));
|
||||
if (/^file:\/\//.test(s)) return fileURLToPath(s);
|
||||
return s;
|
||||
}
|
||||
|
||||
// Realpath of the nearest ancestor that exists as a name, with the missing
|
||||
// tail kept. lstat, not exists: a dangling symlink exists as a name, and a
|
||||
// write through it would create its target wherever that is. So a path that
|
||||
// reaches a dangling symlink, at any depth, can't be checked and is refused.
|
||||
function real(p) {
|
||||
let head = p;
|
||||
const tail = [];
|
||||
while (!lstatSync(head, { throwIfNoEntry: false })) {
|
||||
const up = dirname(head);
|
||||
if (up === head) break;
|
||||
tail.unshift(basename(head));
|
||||
head = up;
|
||||
}
|
||||
try {
|
||||
return join(realpathSync(head), ...tail);
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") throw Object.assign(new Error("dangling symlink"), { code: "dangling-symlink" });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function within(workspace, path) {
|
||||
const root = realpathSync(workspace);
|
||||
const target = real(path);
|
||||
return target === root || target.startsWith(root + sep);
|
||||
}
|
||||
|
||||
// A relative path is resolved the way the tool resolves it: against its
|
||||
// cwd. Both adapters cd into the workspace, and a process's cwd is the real
|
||||
// path, so `..` climbs the real path's parents, not those of a workspace or
|
||||
// dataRoot given through a symlink. It must be inside against the path as
|
||||
// given too, so the check doesn't rest on how the harness was started.
|
||||
export function insideWorkspace(workspace, p) {
|
||||
const s = normalise(p);
|
||||
if (isAbsolute(s)) return within(workspace, resolve(s));
|
||||
return within(workspace, resolve(realpathSync(workspace), s)) && within(workspace, resolve(workspace, s));
|
||||
}
|
||||
|
||||
// Pi's read (dist/core/tools/path-utils.js resolveReadPathAsync) opens
|
||||
// another spelling when the resolved path doesn't exist: U+202F before
|
||||
// AM/PM, then NFD, then U+2019 for ', then both. Each applies to the whole
|
||||
// resolved path, directory names included, and Pi resolves against its cwd,
|
||||
// the workspace's real path. So every spelling that exists as a name is
|
||||
// checked, not only the one Pi would pick, and the check doesn't depend on
|
||||
// which of them exists when Pi opens it. Claude Code's Read gets the same
|
||||
// check; its own retries aren't documented.
|
||||
function spellings(workspace, p) {
|
||||
const s = normalise(p);
|
||||
const bases = isAbsolute(s) ? [resolve(s)] : [resolve(workspace, s), resolve(realpathSync(workspace), s)];
|
||||
const curly = (v) => v.replace(/'/g, "\u2019");
|
||||
const out = new Set();
|
||||
for (const r of bases) {
|
||||
const nfd = r.normalize("NFD");
|
||||
for (const v of [r.replace(/ (AM|PM)\./gi, "\u202F$1."), nfd, curly(r), curly(nfd)]) if (v !== r) out.add(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// The first spelling that exists and resolves outside, or through a
|
||||
// dangling link, as a refusal reason; null if there is none.
|
||||
function otherSpelling(workspace, tool, p) {
|
||||
for (const v of spellings(workspace, p)) {
|
||||
let found;
|
||||
try {
|
||||
found = lstatSync(v, { throwIfNoEntry: false });
|
||||
} catch (error) {
|
||||
// A file used as a directory: Pi's access() fails too.
|
||||
if (error.code === "ENOTDIR") continue;
|
||||
return `${tool} path can't be checked under another spelling: ${error.code ?? error.message}`;
|
||||
}
|
||||
if (!found) continue;
|
||||
try {
|
||||
if (!within(workspace, v)) return `${tool} path is outside the workspace under another spelling: ${p} -> ${JSON.stringify(v)}`;
|
||||
} catch (error) {
|
||||
if (error.code === "dangling-symlink") return `${tool} path goes through a dangling symlink under another spelling: ${p} -> ${JSON.stringify(v)}`;
|
||||
return `${tool} path can't be checked under another spelling: ${error.code ?? error.message}`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// decide(policy, tool, input) -> { allow: true } | { allow: false, reason }
|
||||
// policy { harness: "pi" | "claude-code", workspace, tools: [pi names], typed: [typed tool names] }
|
||||
export function decide(policy, tool, input) {
|
||||
const no = (reason) => ({ allow: false, reason: `mosaic gate: ${reason}` });
|
||||
if (typeof tool !== "string") return no("tool call without a name");
|
||||
const claude = policy.harness === "claude-code";
|
||||
if (claude ? tool.startsWith(MCP_PREFIX) && policy.typed.includes(tool.slice(MCP_PREFIX.length)) : policy.typed.includes(tool)) {
|
||||
return { allow: true };
|
||||
}
|
||||
const builtins = claude ? claudeBuiltins(policy) : policy.tools;
|
||||
if (!builtins.includes(tool)) return no(`${tool} isn't in this session's policy`);
|
||||
// A glob pattern is a second way to name a path: Glob and find take it
|
||||
// relative to their root, so an absolute pattern, ~ or a .. segment
|
||||
// could leave the workspace.
|
||||
const pattern = (claude ? tool === "Glob" : tool === "find") ? input?.pattern : undefined;
|
||||
if (pattern !== undefined && (typeof pattern !== "string" || /^[~/@]/.test(normalise(pattern).trim()) || /(^|[/\\])\.\.([/\\]|$)/.test(pattern))) {
|
||||
return no(`${tool} pattern must stay inside the workspace: ${pattern}`);
|
||||
}
|
||||
const field = PATH_FIELDS[policy.harness]?.[tool];
|
||||
if (!field) return { allow: true };
|
||||
const value = input?.[field];
|
||||
if (value === undefined || value === null || value === "") return { allow: true };
|
||||
if (typeof value !== "string") return no(`${tool}.${field} isn't a string`);
|
||||
try {
|
||||
if (!insideWorkspace(policy.workspace, value)) return no(`${tool} path is outside the workspace: ${value}`);
|
||||
} catch (error) {
|
||||
if (error.code === "dangling-symlink") return no(`${tool} path goes through a dangling symlink: ${value}`);
|
||||
return no(`${tool} path can't be checked: ${error.code ?? error.message}`);
|
||||
}
|
||||
if (tool === (claude ? "Read" : "read")) {
|
||||
let other;
|
||||
try {
|
||||
other = otherSpelling(policy.workspace, tool, value);
|
||||
} catch (error) {
|
||||
return no(`${tool} path can't be checked: ${error.code ?? error.message}`);
|
||||
}
|
||||
if (other) return no(other);
|
||||
}
|
||||
return { allow: true };
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Minimal stdio MCP server that gives a Claude Code session its typed
|
||||
// tools: `node mcp-server.mjs <tools.json> <tool socket>`. Newline-delimited
|
||||
// JSON-RPC 2.0 with initialize, tools/list, tools/call and ping. Each call
|
||||
// goes to the runner's tool socket; the server holds no capability.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createInterface } from "node:readline";
|
||||
import { callTool } from "./tools.mjs";
|
||||
|
||||
const [toolsFile, socket] = process.argv.slice(2);
|
||||
if (!toolsFile || !socket) {
|
||||
process.stderr.write("usage: mcp-server.mjs <tools.json> <tool socket>\n");
|
||||
process.exit(2);
|
||||
}
|
||||
const tools = JSON.parse(readFileSync(toolsFile, "utf8"));
|
||||
const byName = new Map(tools.map((t) => [t.name, t]));
|
||||
|
||||
const send = (message) => process.stdout.write(JSON.stringify({ jsonrpc: "2.0", ...message }) + "\n");
|
||||
const reply = (id, result) => send({ id, result });
|
||||
const error = (id, code, message) => send({ id, error: { code, message } });
|
||||
|
||||
async function handle(m) {
|
||||
const id = m.id;
|
||||
switch (m.method) {
|
||||
case "initialize":
|
||||
return reply(id, {
|
||||
protocolVersion: m.params?.protocolVersion ?? "2025-06-18",
|
||||
capabilities: { tools: {} },
|
||||
serverInfo: { name: "mosaic", version: "1" },
|
||||
});
|
||||
case "ping":
|
||||
return reply(id, {});
|
||||
case "tools/list":
|
||||
return reply(id, { tools: tools.map((t) => ({ name: t.name, description: t.description, inputSchema: t.parameters })) });
|
||||
case "tools/call": {
|
||||
const name = m.params?.name;
|
||||
if (!byName.has(name)) return error(id, -32602, `unknown tool: ${name}`);
|
||||
try {
|
||||
const result = await callTool(socket, name, m.params?.arguments ?? {});
|
||||
return reply(id, { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] });
|
||||
} catch (e) {
|
||||
return reply(id, { content: [{ type: "text", text: e.message }], isError: true });
|
||||
}
|
||||
}
|
||||
default:
|
||||
if (id !== undefined) return error(id, -32601, `method not found: ${m.method}`);
|
||||
}
|
||||
}
|
||||
|
||||
createInterface({ input: process.stdin }).on("line", (line) => {
|
||||
if (!line.trim()) return;
|
||||
let m;
|
||||
try {
|
||||
m = JSON.parse(line);
|
||||
} catch {
|
||||
return error(null, -32700, "parse error");
|
||||
}
|
||||
handle(m).catch((e) => m.id !== undefined && error(m.id, -32603, e.message));
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
// Pi extension for a managed session, loaded with `-e` by adapters/pi.
|
||||
// Three jobs:
|
||||
// - register the session's typed tools, which call the runner's tool socket;
|
||||
// - gate every tool call with gate.mjs (S0 lines 1 and 2: a block or a
|
||||
// throw stops the tool);
|
||||
// - write the turn marker on agent_end, so the runner can tell a finished
|
||||
// turn from a pi that exited 0 mid-turn (S0 line 4).
|
||||
// Missing or unreadable configuration throws here, which fails pi's start
|
||||
// (S0 line 3): nothing is defaulted.
|
||||
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { decide } from "./gate.mjs";
|
||||
import { callTool } from "./tools.mjs";
|
||||
|
||||
function need(name) {
|
||||
const value = process.env[name];
|
||||
if (typeof value !== "string" || !value) throw new Error(`mosaic extension: ${name} is not set`);
|
||||
return value;
|
||||
}
|
||||
|
||||
export default function (pi) {
|
||||
const policy = JSON.parse(readFileSync(need("MOSAIC_POLICY_FILE"), "utf8"));
|
||||
const tools = JSON.parse(readFileSync(need("MOSAIC_TOOLS_FILE"), "utf8"));
|
||||
const socket = need("MOSAIC_TOOL_SOCKET");
|
||||
const marker = need("MOSAIC_TURN_MARKER");
|
||||
if (policy.harness !== "pi" || !Array.isArray(policy.tools) || !Array.isArray(policy.typed)) throw new Error("mosaic extension: policy file is not a pi policy");
|
||||
|
||||
pi.on("tool_call", async (event) => {
|
||||
const verdict = decide(policy, event.toolName, event.input);
|
||||
return verdict.allow ? undefined : { block: true, reason: verdict.reason };
|
||||
});
|
||||
pi.on("agent_end", async () => {
|
||||
writeFileSync(marker, `${new Date().toISOString()}\n`);
|
||||
});
|
||||
|
||||
for (const t of tools) {
|
||||
pi.registerTool({
|
||||
name: t.name,
|
||||
label: t.name,
|
||||
description: t.description,
|
||||
parameters: t.parameters,
|
||||
async execute(_toolCallId, params) {
|
||||
// A refusal throws, which pi reports to the model as a tool error.
|
||||
const result = await callTool(socket, t.name, params ?? {});
|
||||
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], details: {} };
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
// The managed session process: `node runner.mjs <run dir>`, with the run's
|
||||
// capability as one JSON line on stdin ({"cap": "..."}). The host starts it
|
||||
// inside its own PID namespace (packages/seat/src/session.mjs) after the
|
||||
// broker bound the launch, and ends the launch when it exits.
|
||||
//
|
||||
// 1. Founder-credential stop (REQ-CRED-2): a known founder credential
|
||||
// variable in the environment, or a service the role needs with no
|
||||
// usable role token, exits 20 before the role is claimed.
|
||||
// 2. role.claim; a refusal exits 21.
|
||||
// 3. The tool socket (0600) serves the harness's typed tools: a name in
|
||||
// tools.json is forwarded with the runner's capability, to the broker
|
||||
// or, for `launch`, to the host's launch socket. The harness never sees
|
||||
// the capability.
|
||||
// 4. One harness turn per received message, through the adapter, in its
|
||||
// own process group under a wall clock (S0 line 4). The turn's stdout
|
||||
// goes back to the sender as a RESULT, except for an incoming RESULT,
|
||||
// which never gets an automatic reply. Then message.read.
|
||||
// 5. SIGTERM ends the turn, releases the role and exits 0; before the
|
||||
// claim it exits 0 without claiming.
|
||||
//
|
||||
// Exit codes: 0 stopped, 20 founder credentials, 21 claim refused, 22 the
|
||||
// capability or the claim stopped working (ended, revoked), 23 the broker
|
||||
// stayed unreachable, 2 a bad run directory.
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { chmodSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, closeSync } from "node:fs";
|
||||
import { createServer, connect } from "node:net";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { Client } from "../../bus/src/client.mjs";
|
||||
import { SOURCES } from "./bundle.mjs";
|
||||
import { claudeBuiltins } from "./gate.mjs";
|
||||
|
||||
export const EXIT = Object.freeze({ stopped: 0, founder: 20, claim: 21, session: 22, broker: 23, usage: 2 });
|
||||
|
||||
// Environment variables that carry a founder (human) credential for a
|
||||
// service the broker brokers. The host's environment allowlist keeps them
|
||||
// out; finding one here means the allowlist was bypassed.
|
||||
export const FOUNDER_ENV = Object.freeze([
|
||||
"GITEA_TOKEN", "GITEA_ACCESS_TOKEN", "TEA_TOKEN", "MOSAIC_GITEA_CREDENTIAL_FILE",
|
||||
"GH_TOKEN", "GITHUB_TOKEN", "VIKUNJA_TOKEN", "VIKUNJA_API_TOKEN", "SSH_AUTH_SOCK",
|
||||
"GIT_ASKPASS", "GIT_CONFIG_PARAMETERS",
|
||||
]);
|
||||
const USABLE = new Set(["valid", "expiring", "rotation-due"]);
|
||||
// Capabilities that stopped working: the run ended, the claim was revoked.
|
||||
const SESSION_GONE = new Set(["unauthenticated", "not-holder", "run-ended"]);
|
||||
const BODY_MAX = 32000;
|
||||
const DEFAULTS = Object.freeze({ turnTimeout: 900, pollInterval: 2000, brokerRetries: 30 });
|
||||
|
||||
// founderCheck(policy, env) -> null | reason
|
||||
export function founderCheck(policy, env) {
|
||||
const found = FOUNDER_ENV.filter((k) => env[k] !== undefined);
|
||||
if (found.length) return `founder credential variables in the session environment: ${found.join(", ")}`;
|
||||
for (const service of policy.needs ?? []) {
|
||||
const own = (policy.credentials ?? []).filter((c) => c.service === service && USABLE.has(c.state));
|
||||
if (!own.length) return `no usable role token for ${service}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// The request text one message becomes.
|
||||
export function turnRequest(m) {
|
||||
const head = [`Message ${m.id} from ${m.from_role}`, `class ${m.class}`];
|
||||
if (m.in_reply_to) head.push(`in reply to ${m.in_reply_to}`);
|
||||
if (m.decision) head.push(`citing decision ${m.decision}`);
|
||||
return `${head.join(", ")}:\n\n${m.body}`;
|
||||
}
|
||||
|
||||
function clip(text) {
|
||||
return text.length > BODY_MAX ? text.slice(0, BODY_MAX) + "\n[answer cut at 32000 characters]" : text;
|
||||
}
|
||||
|
||||
const readLine = (stream) => new Promise((resolve, reject) => {
|
||||
let input = "";
|
||||
stream.setEncoding("utf8");
|
||||
stream.on("data", (s) => {
|
||||
input += s;
|
||||
if (input.length > 4096) reject(new Error("stdin too large"));
|
||||
});
|
||||
stream.on("end", () => resolve(input));
|
||||
stream.on("error", reject);
|
||||
});
|
||||
|
||||
// One JSON line to the host's launch socket.
|
||||
function launchCall(path, request, timeout = 120_000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = connect(path);
|
||||
let input = "";
|
||||
let settled = false;
|
||||
const end = (error, value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
socket.destroy();
|
||||
error ? reject(error) : resolve(value);
|
||||
};
|
||||
socket.setTimeout(timeout, () => end(Object.assign(new Error("launch timed out"), { code: "outcome-unknown" })));
|
||||
socket.on("connect", () => socket.write(JSON.stringify(request) + "\n"));
|
||||
socket.on("error", () => end(Object.assign(new Error("launch socket"), { code: "outcome-unknown" })));
|
||||
socket.on("end", () => end(Object.assign(new Error("launch socket closed"), { code: "outcome-unknown" })));
|
||||
socket.on("data", (b) => {
|
||||
input += b.toString("utf8");
|
||||
if (!input.includes("\n")) return;
|
||||
try {
|
||||
const r = JSON.parse(input);
|
||||
r.ok ? end(null, r.result) : end(Object.assign(new Error(r.error), { code: r.error }));
|
||||
} catch {
|
||||
end(Object.assign(new Error("invalid launch reply"), { code: "invalid-response" }));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Serve the typed tools on `path`. Returns the server.
|
||||
function toolServer({ path, tools, client, launch }) {
|
||||
const byName = new Map(tools.map((t) => [t.name, t]));
|
||||
rmSync(path, { force: true });
|
||||
const server = createServer((socket) => {
|
||||
let input = "";
|
||||
const answer = (r) => socket.end(JSON.stringify(r) + "\n");
|
||||
socket.setTimeout(150_000, () => socket.destroy());
|
||||
socket.on("error", () => {});
|
||||
socket.on("data", async (b) => {
|
||||
input += b.toString("utf8");
|
||||
if (input.length > 65536) return answer({ ok: false, error: "request-too-large" });
|
||||
if (!input.includes("\n")) return;
|
||||
let request;
|
||||
try {
|
||||
request = JSON.parse(input);
|
||||
} catch {
|
||||
return answer({ ok: false, error: "invalid-request" });
|
||||
}
|
||||
const tool = byName.get(request?.tool);
|
||||
if (!tool) return answer({ ok: false, error: "unknown-tool" });
|
||||
const args = request.args && typeof request.args === "object" && !Array.isArray(request.args) ? request.args : {};
|
||||
try {
|
||||
const result = tool.verb === "launch" ? await launch(args) : await client.call(tool.verb, args);
|
||||
answer({ ok: true, result });
|
||||
} catch (e) {
|
||||
answer({ ok: false, error: /^[a-z-]{1,64}$/.test(e.code ?? "") ? e.code : "tool-failed" });
|
||||
}
|
||||
});
|
||||
});
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(path, () => {
|
||||
chmodSync(path, 0o600);
|
||||
resolve(server);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Run one turn. Resolves { ok, text, reason }.
|
||||
function runTurn({ session, request, n, env, onChild }) {
|
||||
const marker = session.turnMarker;
|
||||
rmSync(marker, { force: true });
|
||||
const turns = join(session.runDir, "turns");
|
||||
mkdirSync(turns, { recursive: true, mode: 0o700 });
|
||||
const errFd = openSync(join(turns, `${String(n).padStart(4, "0")}.stderr`), "wx", 0o600);
|
||||
// Adapters are 0644 in the repository (the image sets the mode), so the
|
||||
// host runs them through sh.
|
||||
const child = spawn("/bin/sh", [session.adapter], {
|
||||
cwd: session.workspace,
|
||||
env: { ...env, MOSAIC_REQUEST: request },
|
||||
stdio: ["ignore", "pipe", errFd],
|
||||
detached: true,
|
||||
});
|
||||
closeSync(errFd);
|
||||
onChild(child);
|
||||
let out = "";
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stdout.on("data", (s) => {
|
||||
if (out.length < 4 * BODY_MAX) out += s;
|
||||
});
|
||||
return new Promise((resolve) => {
|
||||
let timedOut = false;
|
||||
const kill = (signal) => {
|
||||
try {
|
||||
process.kill(-child.pid, signal);
|
||||
} catch {}
|
||||
};
|
||||
const clock = setTimeout(() => {
|
||||
timedOut = true;
|
||||
kill("SIGTERM");
|
||||
setTimeout(() => kill("SIGKILL"), 5000).unref();
|
||||
}, session.turnTimeout * 1000);
|
||||
child.on("error", (e) => {
|
||||
clearTimeout(clock);
|
||||
onChild(null);
|
||||
resolve({ ok: false, reason: `adapter didn't start: ${e.code ?? e.message}` });
|
||||
});
|
||||
child.on("close", (code, signal) => {
|
||||
clearTimeout(clock);
|
||||
kill("SIGKILL");
|
||||
onChild(null);
|
||||
if (timedOut) return resolve({ ok: false, reason: `turn passed its ${session.turnTimeout} s wall clock` });
|
||||
if (code !== 0) return resolve({ ok: false, reason: signal ? `adapter killed by ${signal}` : `adapter exited ${code}` });
|
||||
if (session.harness === "pi" && !existsSync(marker)) return resolve({ ok: false, reason: "pi exited before the turn ended (no agent_end)" });
|
||||
resolve({ ok: true, text: out.trim() });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// The adapter's environment: the host's allowlisted environment plus the
|
||||
// bundle. Nothing here is a credential.
|
||||
export function adapterEnv(session, env) {
|
||||
const b = session.bundle;
|
||||
const tools = JSON.parse(readFileSync(b.tools, "utf8"));
|
||||
const policy = JSON.parse(readFileSync(b.policy, "utf8"));
|
||||
const out = {
|
||||
...env,
|
||||
MOSAIC_SYSTEM_PROMPT_FILE: b.prompt,
|
||||
MOSAIC_WORKSPACE: session.workspace,
|
||||
MOSAIC_SESSION_DIR: session.sessionDir,
|
||||
MOSAIC_POLICY_FILE: b.policy,
|
||||
MOSAIC_TOOLS_FILE: b.tools,
|
||||
MOSAIC_TOOL_SOCKET: session.toolSocket,
|
||||
MOSAIC_TURN_MARKER: session.turnMarker,
|
||||
MOSAIC_PROVIDER: session.provider ?? "",
|
||||
MOSAIC_MODEL: session.model,
|
||||
};
|
||||
if (session.harness === "pi") {
|
||||
out.MOSAIC_TOOLS = [...policy.tools, ...tools.map((t) => t.name)].join(",");
|
||||
out.MOSAIC_EXTENSIONS = SOURCES.piExtension;
|
||||
out.PI_PROVIDER = session.provider ?? "";
|
||||
out.PI_MODEL = session.model;
|
||||
} else {
|
||||
out.MOSAIC_TOOLS = claudeBuiltins(policy).join(",");
|
||||
out.MOSAIC_CLAUDE_SETTINGS = b.settings;
|
||||
out.MOSAIC_CLAUDE_MCP_CONFIG = b.mcp;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function main(runDir, { stdin = process.stdin, env = process.env, log = (s) => process.stderr.write(s + "\n") } = {}) {
|
||||
// The handlers go in first. Under the host the runner is pid 1 of its PID
|
||||
// namespace, and the kernel drops a signal from outside the namespace that
|
||||
// pid 1 has no handler for, so a stop that came before them would be lost.
|
||||
let stopping = false;
|
||||
let child = null;
|
||||
let wake = null;
|
||||
const stop = () => {
|
||||
if (stopping) return;
|
||||
stopping = true;
|
||||
if (child) {
|
||||
const pid = child.pid;
|
||||
const kill = (signal) => {
|
||||
try {
|
||||
process.kill(-pid, signal);
|
||||
} catch {}
|
||||
};
|
||||
kill("SIGTERM");
|
||||
setTimeout(() => kill("SIGKILL"), 5000).unref();
|
||||
}
|
||||
wake?.();
|
||||
};
|
||||
process.on("SIGTERM", stop);
|
||||
process.on("SIGINT", stop);
|
||||
|
||||
let session, cap, policy;
|
||||
try {
|
||||
session = { ...DEFAULTS, ...JSON.parse(readFileSync(join(runDir, "session.json"), "utf8")), runDir };
|
||||
cap = JSON.parse(await readLine(stdin)).cap;
|
||||
if (typeof cap !== "string" || !/^[0-9a-f]{64}$/.test(cap)) throw new Error("no capability on stdin");
|
||||
policy = JSON.parse(readFileSync(session.bundle.policy, "utf8"));
|
||||
} catch (e) {
|
||||
log(`runner: ${e.message}`);
|
||||
return EXIT.usage;
|
||||
}
|
||||
if (stopping) {
|
||||
log("runner: stopped before claim");
|
||||
return EXIT.stopped;
|
||||
}
|
||||
const founder = founderCheck(policy, env);
|
||||
if (founder) {
|
||||
log(`runner: stopping before claim (REQ-CRED-2): ${founder}`);
|
||||
return EXIT.founder;
|
||||
}
|
||||
const client = new Client({ path: session.brokerSocket, cap, timeout: 30_000 });
|
||||
try {
|
||||
await client.call("role.claim");
|
||||
} catch (e) {
|
||||
// No answer is not a refusal: the broker is down or the outcome is unknown.
|
||||
if (e.code === "outcome-unknown") {
|
||||
log("runner: role.claim got no answer from the broker");
|
||||
return EXIT.broker;
|
||||
}
|
||||
log(`runner: role.claim refused: ${e.code ?? e.message}`);
|
||||
return SESSION_GONE.has(e.code) ? EXIT.session : EXIT.claim;
|
||||
}
|
||||
log(`runner: ${session.business}/${session.instance} claimed by run ${session.run}`);
|
||||
|
||||
const tools = JSON.parse(readFileSync(session.bundle.tools, "utf8"));
|
||||
const server = await toolServer({
|
||||
path: session.toolSocket,
|
||||
tools,
|
||||
client,
|
||||
launch: (args) => launchCall(session.launchSocket, { cap, instance: args.instance }),
|
||||
});
|
||||
const turnEnv = adapterEnv(session, env);
|
||||
|
||||
let code = EXIT.stopped;
|
||||
let failures = 0;
|
||||
let n = 0;
|
||||
const sleep = (ms) => new Promise((r) => {
|
||||
const t = setTimeout(r, ms);
|
||||
wake = () => {
|
||||
clearTimeout(t);
|
||||
r();
|
||||
};
|
||||
});
|
||||
loop: while (!stopping) {
|
||||
let messages;
|
||||
try {
|
||||
messages = await client.call("message.receive");
|
||||
failures = 0;
|
||||
} catch (e) {
|
||||
if (SESSION_GONE.has(e.code)) {
|
||||
log(`runner: session stopped working: ${e.code}`);
|
||||
code = EXIT.session;
|
||||
break;
|
||||
}
|
||||
if (++failures >= session.brokerRetries) {
|
||||
log(`runner: broker unreachable after ${failures} tries: ${e.code ?? e.message}`);
|
||||
code = EXIT.broker;
|
||||
break;
|
||||
}
|
||||
await sleep(session.pollInterval);
|
||||
continue;
|
||||
}
|
||||
for (const m of messages) {
|
||||
if (stopping) break loop;
|
||||
const turn = await runTurn({ session, request: turnRequest(m), n: ++n, env: turnEnv, onChild: (c) => (child = c) });
|
||||
if (stopping) break loop;
|
||||
if (!turn.ok) log(`runner: turn ${n} for message ${m.id} failed: ${turn.reason}`);
|
||||
try {
|
||||
if (m.class !== "RESULT") {
|
||||
const body = turn.ok ? turn.text || "(the session gave no answer)" : `The turn failed: ${turn.reason}`;
|
||||
await client.call("message.send", { to: m.from_role, body: clip(body), class: "RESULT", in_reply_to: m.id });
|
||||
}
|
||||
await client.call("message.read", { id: m.id });
|
||||
} catch (e) {
|
||||
log(`runner: reply to ${m.id} failed: ${e.code ?? e.message}`);
|
||||
if (SESSION_GONE.has(e.code)) {
|
||||
code = EXIT.session;
|
||||
break loop;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!messages.length) await sleep(session.pollInterval);
|
||||
}
|
||||
server.close();
|
||||
rmSync(session.toolSocket, { force: true });
|
||||
if (code === EXIT.stopped) {
|
||||
try {
|
||||
await client.call("role.release");
|
||||
} catch (e) {
|
||||
log(`runner: role.release: ${e.code ?? e.message}`);
|
||||
}
|
||||
}
|
||||
log(`runner: exiting ${code}`);
|
||||
return code;
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
if (!process.argv[2]) {
|
||||
process.stderr.write("usage: runner.mjs <run dir> (capability on stdin)\n");
|
||||
process.exit(EXIT.usage);
|
||||
}
|
||||
process.exitCode = await main(process.argv[2]);
|
||||
// Pending sockets or timers must not keep a stopped session alive.
|
||||
setTimeout(() => process.exit(process.exitCode), 1000).unref();
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// Typed tools: the only way a managed session acts on the bus. Each tool
|
||||
// maps one-to-one onto a broker verb (or, for `launch`, onto the host's
|
||||
// launch socket), and a session gets a tool only for an action its resolved
|
||||
// instance holds within-role or cross-role, plus the reads and
|
||||
// raise_decision every agent has. The broker still decides every call;
|
||||
// a tool's presence is not permission.
|
||||
//
|
||||
// The harness never sees the capability. Tools call the runner's tool
|
||||
// socket (MOSAIC_TOOL_SOCKET) with `{tool, args}`; the runner checks the
|
||||
// name against this list and forwards the call with its own capability.
|
||||
|
||||
import { connect } from "node:net";
|
||||
import { ACTIONS } from "../../business/src/vocabulary.mjs";
|
||||
|
||||
const str = (description, extra = {}) => ({ type: "string", description, ...extra });
|
||||
const obj = (properties, required = []) => ({ type: "object", properties, required, additionalProperties: false });
|
||||
|
||||
const TASK_REF = str("Task reference, as list_tasks shows it");
|
||||
const COMMON = {
|
||||
task_ref: TASK_REF,
|
||||
decision: str("Id of a resolved decision that approves this call; needed when the action is cross-role or gated for you"),
|
||||
expect: str("The task digest you last saw; the broker refuses if the task changed since", { pattern: "^[0-9a-f]{64}$" }),
|
||||
};
|
||||
const RELATION = obj({
|
||||
kind: str("Relation kind", { enum: ["subtask", "parenttask", "related", "duplicateof", "duplicates", "blocking", "blocked", "precedes", "follows", "copiedfrom", "copiedto"] }),
|
||||
task_ref: TASK_REF,
|
||||
}, ["kind", "task_ref"]);
|
||||
const LABEL_IDS = { type: "array", items: { type: "integer", minimum: 1 }, maxItems: 50, description: "Label ids from the business file" };
|
||||
const DUE = { type: ["string", "null"], description: "Due date as an ISO 8601 UTC timestamp, or null to clear" };
|
||||
const PRIORITY = { type: "integer", minimum: 0, maximum: 5, description: "Priority, 0 (unset) to 5 (do now)" };
|
||||
|
||||
// action -> [tool name, description, properties, required]
|
||||
const TASK_TOOLS = {
|
||||
"task.create": ["task_create", "Create a tracker task. Every task cites the request it came from and a requirement id.", {
|
||||
title: str("Task title, one line"),
|
||||
description: str("Task description"),
|
||||
due_date: DUE,
|
||||
priority: PRIORITY,
|
||||
labels: LABEL_IDS,
|
||||
relations: { type: "array", items: RELATION, maxItems: 50 },
|
||||
request: str("Id of the message or human input this task answers"),
|
||||
requirement: str("Requirement id, such as REQ-CRED-2", { pattern: "^REQ-[A-Z]+-[1-9][0-9]*$" }),
|
||||
decision: COMMON.decision,
|
||||
}, ["title", "request", "requirement"]],
|
||||
"task.assign": ["task_assign", "Assign an unassigned task to a role.", { ...COMMON, role: str("Role instance to assign") }, ["task_ref", "role"]],
|
||||
"task.reassign": ["task_reassign", "Move an assigned task to another role.", { ...COMMON, role: str("Role instance to assign") }, ["task_ref", "role"]],
|
||||
"task.schedule": ["task_schedule", "Change a task's due date, labels or relations.", {
|
||||
...COMMON,
|
||||
due_date: DUE,
|
||||
labels: obj({ add: LABEL_IDS, remove: LABEL_IDS }),
|
||||
relations: obj({ add: { type: "array", items: RELATION, maxItems: 50 }, remove: { type: "array", items: RELATION, maxItems: 50 } }),
|
||||
}, ["task_ref"]],
|
||||
"task.priority.change": ["task_priority_change", "Change a task's priority.", { ...COMMON, priority: PRIORITY }, ["task_ref", "priority"]],
|
||||
"task.scope.change": ["task_scope_change", "Change a task's title or description.", { ...COMMON, title: str("New title"), description: str("New description") }, ["task_ref"]],
|
||||
"task.update.assigned": ["task_update_assigned", "Report progress on a task assigned to you: percent done, state or a comment.", {
|
||||
...COMMON,
|
||||
percent_done: { type: "number", minimum: 0, maximum: 1, description: "Fraction done, 0 to 1" },
|
||||
state: str("Board state", { enum: ["todo", "in-progress", "in-review", "blocked"] }),
|
||||
comment: str("Comment to add"),
|
||||
}, ["task_ref"]],
|
||||
"task.close": ["task_close", "Close a task after its review verdict.", { ...COMMON, verdict: str("Citation of the review verdict") }, ["task_ref", "verdict"]],
|
||||
};
|
||||
|
||||
const MESSAGE_CLASSES = ["REQUEST", "ASSIGNMENT", "REVIEW-REQUEST", "REVIEW-RESULT", "RESULT", "INFO", "DECISION", "REACTION"];
|
||||
|
||||
// typedTools({ resolved, arbiter, tracker }) -> [{ name, verb, action, description, parameters }]
|
||||
// resolved resolveInstance(...) output for the session's instance
|
||||
// arbiter true when the business names this instance as an arbiter
|
||||
// tracker true when the broker has task verbs for the business
|
||||
// `verb` is the broker verb; the `launch` tool has verb "launch" and goes
|
||||
// to the host instead.
|
||||
export function typedTools({ resolved, arbiter = false, tracker = false }) {
|
||||
const held = new Set([...resolved.limits.authority.withinRole, ...resolved.limits.authority.crossRole]);
|
||||
const tools = [];
|
||||
const add = (name, verb, action, description, parameters) => tools.push({ name, verb, action, description, parameters });
|
||||
if (held.has("message.send")) {
|
||||
add("send_message", "message.send", "message.send", "Send a message to another role instance, or to \"human\". Your final answer to a message is sent back to its sender for you; use this for anything else.", obj({
|
||||
to: str("Role instance, or \"human\""),
|
||||
body: str("Message text"),
|
||||
class: str("Message class", { enum: MESSAGE_CLASSES }),
|
||||
in_reply_to: str("Id of the message this answers"),
|
||||
decision: COMMON.decision,
|
||||
}, ["to", "body"]));
|
||||
}
|
||||
if (resolved.launch) {
|
||||
add("launch", "launch", "role.launch", `Launch a managed session for one of these role instances: ${resolved.launch.instances.join(", ")}. The host refuses an instance that is already running, a full model family, or a launch while launches are switched off.`, obj({
|
||||
instance: str("Role instance to launch", { enum: [...resolved.launch.instances] }),
|
||||
}, ["instance"]));
|
||||
}
|
||||
if (tracker) {
|
||||
for (const [action, [name, description, properties, required]] of Object.entries(TASK_TOOLS)) {
|
||||
if (held.has(action)) add(name, action, action, description, obj(properties, required));
|
||||
}
|
||||
}
|
||||
add("raise_decision", "decision.raise", null, "Ask for a decision before a gated or cross-role action, or when you need a choice made. The answer arrives as a message.", obj({
|
||||
action: str("The action the decision is about", { enum: [...ACTIONS] }),
|
||||
question: str("The question, with enough context to answer it"),
|
||||
options: { type: "array", minItems: 2, maxItems: 9, items: obj({ key: str("Short key"), text: str("What this option means") }, ["key", "text"]) },
|
||||
recommendation: str("Your recommended option key, with a reason"),
|
||||
blocking: { type: "boolean", description: "True when you can't continue without the answer" },
|
||||
target: str("What the action applies to, such as a role instance or task reference"),
|
||||
approvalChoice: str("The option key that approves the action"),
|
||||
task_ref: TASK_REF,
|
||||
requirement_ref: str("Requirement id"),
|
||||
}, ["action", "question", "options", "recommendation", "blocking"]));
|
||||
if (arbiter) {
|
||||
add("resolve_decision", "decision.resolve", null, "Resolve a decision routed to you.", obj({
|
||||
id: str("Decision id"),
|
||||
choice: str("The option key you choose"),
|
||||
note: str("Reason, one or two sentences"),
|
||||
}, ["id", "choice"]));
|
||||
}
|
||||
add("list_agents", "agents", null, "List the role instances that hold a claim now.", obj({}));
|
||||
add("inbox", "inbox", null, "List the open decisions routed to you.", obj({}));
|
||||
add("trail", "trail", null, "Show the evidence trail for a subject: a run, message, decision or task.", obj({ subject: str("Subject id") }, ["subject"]));
|
||||
if (tracker) add("list_tasks", "tasks", null, "List the business's tracker tasks as the broker last saw them.", obj({}));
|
||||
return tools;
|
||||
}
|
||||
|
||||
// Held actions that have no typed tool. The manifest lists them so a
|
||||
// reviewer sees what the session can't do through the bus.
|
||||
export function untypedActions(resolved, tools) {
|
||||
const typed = new Set(tools.map((t) => t.action).filter(Boolean));
|
||||
return [...resolved.limits.authority.withinRole, ...resolved.limits.authority.crossRole].filter((a) => !typed.has(a));
|
||||
}
|
||||
|
||||
// One call over a tool socket: a JSON line out, a JSON line back.
|
||||
export function callTool(path, tool, args = {}, { timeout = 60_000 } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = connect(path);
|
||||
let input = "";
|
||||
let settled = false;
|
||||
const end = (error, value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
socket.destroy();
|
||||
error ? reject(error) : resolve(value);
|
||||
};
|
||||
socket.setTimeout(timeout, () => end(new Error("tool call timed out")));
|
||||
socket.on("connect", () => socket.write(JSON.stringify({ tool, args }) + "\n"));
|
||||
socket.on("error", (e) => end(new Error(`tool socket: ${e.code ?? e.message}`)));
|
||||
socket.on("end", () => end(new Error("tool socket closed without a reply")));
|
||||
socket.on("data", (b) => {
|
||||
input += b.toString("utf8");
|
||||
if (input.length > 4 * 1024 * 1024) return end(new Error("tool reply too large"));
|
||||
if (!input.includes("\n")) return;
|
||||
try {
|
||||
const r = JSON.parse(input);
|
||||
r.ok ? end(null, r.result) : end(new Error(`refused: ${r.error}`));
|
||||
} catch {
|
||||
end(new Error("tool reply isn't JSON"));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
Reference in New Issue
Block a user