Files
stack/agents/filbert/work/s6/build.patch
T
jason.woltjeandClaude Opus 5.5 55bff3b274 docs(s6): row 41 round 4 candidate packet, R5 fix (filbert)
Candidate manifest 08a78972 (42 files). Gate at 0a4c8f13.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 22:30:16 -05:00

5601 lines
269 KiB
Diff
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
diff --git a/adapters/README.md b/adapters/README.md
index 33ed6ded..a9460c3f 100644
--- a/adapters/README.md
+++ b/adapters/README.md
@@ -43,8 +43,11 @@ Optional, adapter-specific (documented per adapter):
1. Adapters print ONLY the response on stdout. Status lines go to stderr.
2. Adapters never read configuration files; the resolved settings arrive via environment.
3. Adapters never write outside `/var/lib/mosaic`.
-4. Adding an adapter requires: a new directory, the contract implementation, and
- adding the name to the allowlist in `scripts/mosaic-config.mjs`.
+4. Adding a worker adapter requires: a new directory, the contract
+ implementation, and adding the name to the allowlist in
+ `scripts/mosaic-config.mjs`. A managed-session adapter (below) is selected
+ by the launch bundle, not by `execution.adapter`, and is not on that
+ allowlist unless it also works as a worker adapter.
## Included adapters
@@ -52,3 +55,27 @@ Optional, adapter-specific (documented per adapter):
print mode (`-p`), ambient discovery disabled, stdin detached.
- `mock` — deterministic echo of `MOSAIC_MOCK_RESPONSE`. Test-only: never use
it where a real model response is required.
+- `claude` — the host's `claude` CLI, for managed sessions only (below).
+ Not a worker adapter: the image has no `claude`, and the adapter refuses
+ without the bundle's hook and MCP files.
+
+## Managed sessions (slice 1 S6)
+
+The session runner (`packages/harness/src/runner.mjs`) runs one adapter
+call per turn on the host, as `/bin/sh <adapters/<name>/adapter.sh>` (the
+repository keeps adapters 0644; only the image sets the mode), in the
+session's workspace and its own process group. Same contract, plus:
+
+| Variable | Meaning |
+|---|---|
+| `MOSAIC_WORKSPACE` | The session's workspace; the adapter runs there. |
+| `MOSAIC_SESSION_DIR` | The session's persistent directory; later turns resume the session kept there. |
+| `MOSAIC_TOOLS` | The built-in tool limit (pi names for `pi`, Claude Code names for `claude`), comma-separated. |
+| `MOSAIC_POLICY_FILE`, `MOSAIC_TOOLS_FILE` | The bundle's gate policy and typed tools. |
+| `MOSAIC_TOOL_SOCKET` | The runner's tool socket, which the typed tools call. |
+| `MOSAIC_TURN_MARKER` | `pi`: the extension writes it at `agent_end`; a pi turn that exits 0 without it failed. |
+| `MOSAIC_EXTENSIONS` | `pi` only: extension files loaded with `-e`; a missing one exits 2. |
+| `MOSAIC_CLAUDE_SETTINGS`, `MOSAIC_CLAUDE_MCP_CONFIG` | `claude` only: the bundle's gate hook and MCP server; required. |
+
+The layers each adapter relies on, and what they don't cover, are in
+`packages/harness/README.md`.
diff --git a/adapters/claude/adapter.sh b/adapters/claude/adapter.sh
new file mode 100644
index 00000000..8a259540
--- /dev/null
+++ b/adapters/claude/adapter.sh
@@ -0,0 +1,79 @@
+#!/bin/sh
+# Claude Code adapter: implements the Mosaic adapter contract for the host's
+# `claude` CLI, for managed sessions (slice 1 S6). Headless only.
+#
+# Contract: see adapters/README.md.
+# stdout = the turn's answer only; stderr = diagnostics; exit 0 on success.
+#
+# The layers this relies on are row S0 lines 1-5 (docs/plans/2026-10-04_slice-1.md,
+# "What S6 can rely on"): the PreToolUse command hook in MOSAIC_CLAUDE_SETTINGS,
+# wrapped as `timeout -k 2 10 <gate> || exit 2`, and the tool limit (--tools).
+# --bare is never passed: it turns settings hooks off.
+set -eu
+
+need() {
+ eval "v=\${$1:-}"
+ [ -n "$v" ] || { echo "claude adapter: $1 is required" >&2; exit 2; }
+}
+need MOSAIC_SYSTEM_PROMPT_FILE
+need MOSAIC_REQUEST
+need MOSAIC_WORKSPACE
+need MOSAIC_SESSION_DIR
+need MOSAIC_MODEL
+need MOSAIC_CLAUDE_SETTINGS
+need MOSAIC_CLAUDE_MCP_CONFIG
+for f in "$MOSAIC_SYSTEM_PROMPT_FILE" "$MOSAIC_CLAUDE_SETTINGS" "$MOSAIC_CLAUDE_MCP_CONFIG"; do
+ [ -r "$f" ] || { echo "claude adapter: not readable: $f" >&2; exit 2; }
+done
+[ "${MOSAIC_INTERACTIVE:-}" != "1" ] || { echo "claude adapter: interactive mode isn't supported" >&2; exit 2; }
+
+mkdir -p "$MOSAIC_WORKSPACE" "$MOSAIC_SESSION_DIR"
+cd "$MOSAIC_WORKSPACE"
+
+# Session: the first turn names a new session id, later turns resume it.
+# The id is kept only after a turn succeeds, so a failed first turn never
+# leaves a --resume of a session that doesn't exist.
+ID_FILE="$MOSAIC_SESSION_DIR/claude-session-id"
+if [ -s "$ID_FILE" ]; then
+ SESSION_FLAG=--resume
+ SESSION_ID=$(cat "$ID_FILE")
+else
+ SESSION_FLAG=--session-id
+ SESSION_ID=$(cat /proc/sys/kernel/random/uuid)
+fi
+
+# Flags:
+# -p one-shot: print the answer and exit
+# --output-format text stdout is the answer only
+# --system-prompt the generated prompt replaces the default
+# --restricted no user/project/local settings files; --settings
+# (the gate hook) still applies; no code-running
+# tool unless --tools names it; file tools confined
+# to the working directory; no CLAUDE.md file or
+# auto-memory in the prompt. The S0 lines above
+# don't depend on it, but it is what keeps founder
+# and repository memory out; a test fails without it.
+# --tools the built-in tool limit (S0 line 5); empty = none
+# --allowedTools the same tools plus the mosaic MCP server, so
+# --permission-mode dontAsk nothing waits on a prompt and anything else is denied
+# --settings the gate hook (S0 lines 1-4)
+# --strict-mcp-config only the bundle's MCP server, which serves the
+# --mcp-config typed bus tools
+# --disable-slash-commands no skills (the bundle lists none)
+ALLOWED=mcp__mosaic
+[ -z "${MOSAIC_TOOLS:-}" ] || ALLOWED="$MOSAIC_TOOLS,mcp__mosaic"
+PROMPT_CONTENT="$(cat "$MOSAIC_SYSTEM_PROMPT_FILE")"
+claude -p "$MOSAIC_REQUEST" \
+ --output-format text \
+ --system-prompt "$PROMPT_CONTENT" \
+ --model "$MOSAIC_MODEL" \
+ --restricted \
+ --tools "${MOSAIC_TOOLS:-}" \
+ --allowedTools "$ALLOWED" \
+ --permission-mode dontAsk \
+ --settings "$MOSAIC_CLAUDE_SETTINGS" \
+ --strict-mcp-config \
+ --mcp-config "$MOSAIC_CLAUDE_MCP_CONFIG" \
+ --disable-slash-commands \
+ "$SESSION_FLAG" "$SESSION_ID" || exit $?
+[ "$SESSION_FLAG" = --resume ] || printf '%s\n' "$SESSION_ID" > "$ID_FILE"
diff --git a/adapters/pi/adapter.sh b/adapters/pi/adapter.sh
index 13c9da51..3ebb1efe 100644
--- a/adapters/pi/adapter.sh
+++ b/adapters/pi/adapter.sh
@@ -60,6 +60,19 @@ if [ -n "${MOSAIC_SKILLS:-}" ]; then
IFS=$OLDIFS
fi
+# Extensions (S6): explicitly provided extension files, loaded with -e
+# after --no-extensions turns discovery off. A missing file refuses here;
+# pi itself also refuses to start on a missing or broken -e (row S0 line 3).
+EXT_FLAGS=""
+if [ -n "${MOSAIC_EXTENSIONS:-}" ]; then
+ OLDIFS=$IFS; IFS=','
+ for e in $MOSAIC_EXTENSIONS; do
+ [ -f "$e" ] || { echo "pi adapter: extension missing: $e" >&2; exit 2; }
+ EXT_FLAGS="$EXT_FLAGS -e $e"
+ done
+ IFS=$OLDIFS
+fi
+
# Mode (M13): interactive TUI or one-shot print.
PRINT_MODE="-p"
REQUEST_ARG=""
@@ -74,13 +87,18 @@ fi
# interactive TUI mode)
# --system-prompt replace the default prompt with the generated one
# --no-* no ambient context/skills/extensions/templates/themes
+# EXT_FLAGS the explicitly provided extensions only (-e)
# SESSION_FLAGS ephemeral | persistent | forked (per env)
# TOOLS_FLAG per capabilities
# --offline no startup network operations (update checks/telemetry)
+# --no-approve ignore project-local .pi/ files (settings, SYSTEM.md)
+# whatever trust is saved for the workspace
PROMPT_CONTENT="$(cat "$MOSAIC_SYSTEM_PROMPT_FILE")"
set -- \
--offline \
+ --no-approve \
--no-extensions \
+ $EXT_FLAGS \
$SKILLS_FLAG \
--no-prompt-templates \
--no-themes \
diff --git a/docs/TOOLS.md b/docs/TOOLS.md
index e07eabfa..b96e9012 100644
--- a/docs/TOOLS.md
+++ b/docs/TOOLS.md
@@ -196,7 +196,11 @@ config problem · `4` usage or a required file missing. Details:
scripts/mosaic inbox | tasks | agents [--business <id>] [--json]
scripts/mosaic decide <decision> <option> [--note <text>] [--yes]
scripts/mosaic trail <task|decision> [--json]
-scripts/mosaic bus start <business> | stop | status [--json]
+scripts/mosaic bus start <business> [--pm] | stop | status [--json]
+scripts/mosaic talk <role> <text> [--wait SECONDS] [--business <id>]
+scripts/mosaic stop <run>
+scripts/mosaic launches off | on [--business <id>]
+scripts/mosaic launches list [--json]
scripts/bus-service.sh render | install [--dir DIR] [--no-reload] | uninstall | status <business>
```
@@ -209,6 +213,19 @@ directory must be 0700: it also holds the notifier's journal `sent.jsonl`
(0600, never a symlink). On open the notifier copies a torn final line to
`torn-<UTC stamp>.bin` and truncates the journal to its last newline; a
malformed complete line refuses with exit 3.
+
+`bus start` also serves the session launcher (slice 1 S6): a PM session's
+`launch` tool starts role sessions through it, each a runner under its own
+PID namespace with a generated bundle, and `--pm` launches the business's
+`launch.by` instance at start. A host that starts ends the launches a
+crashed host left as `host-lost` and refuses with exit 3 on a
+`sessions.json` it can't read. `talk` sends a REQUEST and prints what
+arrives until the reply (polls every 2 s, `--wait` default 900; `0` only
+sends). `stop` sends SIGTERM to a run's runner after checking it in
+`sessions.json`. `launches off|on` revokes and restores `role.launch` for
+the business; running sessions keep running. `launches list` reads
+`sessions.json` and needs no broker. `talk`, `stop` and `launches off|on`
+refuse inside an agent run.
Exit codes: `0` ok · `1` failed or outcome unknown · `2` invalid input ·
`3` refused or config problem · `4` usage. Details: `packages/cli/README.md`.
diff --git a/packages/bus/README.md b/packages/bus/README.md
index 20d3f9a0..fff96910 100644
--- a/packages/bus/README.md
+++ b/packages/bus/README.md
@@ -60,6 +60,31 @@ The embedded runtime offers the same `bindLaunch(record)` wrapper, which also
updates the human ancestry registry. S6 must use that wrapper. Do not use the
underlying Broker's test-level binding API to bypass runtime process checks.
+A rebind of a run that already has `session.ended` refuses with `run-ended`,
+also after a restart, and a refused bind leaves the run unbound.
+
+A request that carries a safe-integer `id` gets the same `id` on its reply,
+refusals included, so the host can match each reply to its request
+(`packages/cli/README.md`). A request without one gets a reply without one.
+
+S6 adds launch ops on the same IPC channel, one reply each
+(`{ok:true,result}` or `{ok:false,error}`), none of them socket verbs:
+
+- `{op:'identity',cap}`: the business, role and run a capability names.
+- `{op:'authorizeLaunch',cap,instance}`: `authorize(cap,'role.launch',
+ {target:instance})`; a refusal is also recorded as `action.refused`.
+- `{op:'refuse',cap,code}`: records a refusal the host decided itself
+ (instance list, capacity, a failed spawn) as `action.refused` against the
+ caller, the code only.
+- `{op:'endLaunch',record}`: `{business,run,reason,exitCode?}` for a run
+ this broker bound. It writes `session.ended` `{reason,exitCode,released}`,
+ releases the run's claim if it still holds it, and drops every capability
+ of the run. A second end refuses with `run-ended`, an unbound run with
+ `unknown-run`. `reason` is an identifier the launcher picks
+ (`packages/cli/README.md`, "Sessions").
+- `{op:'credentialStatus',business,role}`: the role's `{service,date,state}`
+ rows, never a token.
+
`{op:'close'}`, SIGTERM and SIGINT close the socket and DB and remove the owned
writer lock. Losing the host IPC channel closes with exit 2. Startup or host
protocol failures return a static error code and exit 2, never a raw exception.
diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs
index a22b5950..0ebc8ff8 100644
--- a/packages/bus/src/broker.mjs
+++ b/packages/bus/src/broker.mjs
@@ -81,7 +81,8 @@ export class Broker {
#store;
#businesses;
#sessions = new Map();
- #runs = new Set();
+ #runs = new Map();
+ #ended = new Set();
#secretCheck;
#clock = 0;
constructor({ store, businesses, secretCheck = () => {} }) {
@@ -169,7 +170,6 @@ export class Broker {
if (record.address !== undefined) string(record.address, 1024);
const key = record.business + ':' + record.run;
if (this.#runs.has(key)) fail('duplicate-run');
- this.#runs.add(key);
const session = { ...record, address: record.address ?? null, human: false };
this.#secretCheck(record);
const old = this.#store.get(
@@ -185,9 +185,43 @@ export class Broker {
};
if (old) {
if (old.actor_role !== record.role || old.body !== JSON.stringify(body)) fail('launch-record-mismatch');
+ // An ended run stays ended across broker restarts.
+ if (this.#store.get("SELECT 1 FROM events WHERE business=? AND kind='session.ended' AND subject=?", record.business, record.run))
+ fail('run-ended');
} else this.#store.transaction(() => this.#event(session, 'session.launched', body, record.run));
+ this.#runs.set(key, session);
return this.#cap(session);
}
+ // Trusted launcher API, the counterpart of bindLaunch and likewise NOT a socket verb. The S6 host
+ // calls it once when a launched run's process exits: session.ended is written, the run's claim is
+ // released if it still holds one, and every capability of the run stops working.
+ endLaunch(record) {
+ keys(record, ['business', 'run', 'reason', 'exitCode'], ['business', 'run', 'reason']);
+ this.#business(identifier(record.business));
+ const key = record.business + ':' + identifier(record.run);
+ const session = this.#runs.get(key) ?? fail('unknown-run');
+ if (this.#ended.has(key)) fail('run-ended');
+ identifier(record.reason);
+ const exitCode = record.exitCode ?? null;
+ if (exitCode !== null && !Number.isSafeInteger(exitCode)) fail('invalid-request');
+ this.#ended.add(key);
+ for (const [cap, s] of this.#sessions)
+ if (!s.human && !s.reader && s.business === record.business && s.run === record.run) this.#sessions.delete(cap);
+ return this.#store.transaction(() => {
+ const h = this.#holder(session.business, session.role);
+ const released = h?.op === 'claim' && h.holder_run === session.run;
+ if (released) this.#claimEnd(session, h, 'release', null);
+ this.#event(session, 'session.ended', { reason: record.reason, exitCode, released }, session.run);
+ return { released };
+ });
+ }
+ // Trusted launcher API: records a refusal the host decided itself (instance list, capacity,
+ // credentials) as action.refused against the caller, the way request() records its own.
+ refuse(cap, code) {
+ const s = this.#session(cap);
+ if (!/^[a-z-]{1,64}$/.test(code)) fail('invalid-request');
+ return this.#refused(s, new BusError(code), null).code;
+ }
// Trusted human transport invokes only AFTER checking its CLI process and launch ancestry.
bindHuman(record) {
keys(record, ['business', 'human', 'via', 'outsideAgent'], ['business', 'human', 'via', 'outsideAgent']);
diff --git a/packages/bus/src/process.mjs b/packages/bus/src/process.mjs
index 562e6a9e..68276a03 100644
--- a/packages/bus/src/process.mjs
+++ b/packages/bus/src/process.mjs
@@ -2,6 +2,17 @@
// never command arguments, stdout or service-token-bearing environment variables.
import { startBroker } from './runtime.mjs';
import { BusError } from './broker.mjs';
+const LAUNCH_OPS = new Set(['identity', 'authorizeLaunch', 'refuse', 'endLaunch', 'credentialStatus']);
+function launchOp(m) {
+ if (m.op === 'identity') return runtime.identity(m.cap);
+ if (m.op === 'authorizeLaunch') return runtime.authorizeLaunch(m.cap, m.instance);
+ if (m.op === 'refuse') return runtime.refuse(m.cap, m.code);
+ if (m.op === 'endLaunch') return runtime.endLaunch(m.record);
+ return runtime.credentialStatus(m.business, m.role);
+}
+// The host's request id comes back on the reply, so a late reply can't
+// answer a later request (host.mjs). A message without one gets none.
+const tag = (message, reply) => (Number.isSafeInteger(message?.id) ? { ...reply, id: message.id } : reply);
let runtime,
booted = false,
closing = false;
@@ -25,9 +36,18 @@ if (!process.send) {
try {
if (message?.op === 'bindLaunch' && runtime) {
try {
- process.send({ ok: true, launch: runtime.bindLaunch(message.record) });
+ process.send(tag(message, { ok: true, launch: runtime.bindLaunch(message.record) }));
+ } catch (e) {
+ process.send(tag(message, { ok: false, error: e instanceof BusError ? e.code : 'bind-refused' }));
+ }
+ return;
+ }
+ // S6 launcher ops, one reply each, like bindLaunch.
+ if (LAUNCH_OPS.has(message?.op) && runtime) {
+ try {
+ process.send(tag(message, { ok: true, result: launchOp(message) }));
} catch (e) {
- process.send({ ok: false, error: e instanceof BusError ? e.code : 'bind-refused' });
+ process.send(tag(message, { ok: false, error: e instanceof BusError ? e.code : 'launch-op-refused' }));
}
return;
}
@@ -52,7 +72,7 @@ if (!process.send) {
}
process.send({ ok: true, path: runtime.path, launches: runtime.launches, readers: runtime.readers });
} catch (e) {
- process.send?.({ ok: false, error: e instanceof BusError ? e.code : 'startup-refused' }, () =>
+ process.send?.(tag(message, { ok: false, error: e instanceof BusError ? e.code : 'startup-refused' }), () =>
close(2),
);
}
diff --git a/packages/bus/src/runtime.mjs b/packages/bus/src/runtime.mjs
index be39c0f9..ebc07cf3 100644
--- a/packages/bus/src/runtime.mjs
+++ b/packages/bus/src/runtime.mjs
@@ -5,7 +5,7 @@ import { Broker, BusError } from './broker.mjs';
import { Credentials } from './credentials.mjs';
import { serve } from './server.mjs';
import { verifyHuman } from './human.mjs';
-// Trusted host API. S1 supplies resolved definitions, S6 supplies launch records.
+// Trusted host API. S1 supplies resolved definitions, S6 supplies launch records and ends them.
// Neither a business-file writer nor a socket-accessible configuration endpoint.
// `tasks` is S3's adapter factory: ({broker, credentials, businesses}) => {handle, timeout, close}.
export async function startBroker({
@@ -68,11 +68,34 @@ export async function startBroker({
return broker.bindHuman({ business, human, via: 'cli', outsideAgent: true });
},
});
+ // S6 launcher operations. The host decides capacity and the instance list; the broker
+ // authorizes role.launch and keeps the evidence, refusals included.
+ function authorizeLaunch(cap, instance) {
+ try {
+ return broker.authorize(cap, 'role.launch', { target: instance });
+ } catch (e) {
+ if (!(e instanceof BusError)) throw e;
+ broker.refuse(cap, e.code);
+ throw e;
+ }
+ }
+ // Metadata only (service, date, state); the token stays in Credentials.
+ function credentialStatus(business, role) {
+ return credentials
+ .status()
+ .filter((c) => c.instance === business + '/' + role)
+ .map(({ service, date, state }) => ({ service, date, state }));
+ }
return {
broker,
credentials,
path,
bindLaunch,
+ endLaunch: (r) => broker.endLaunch(r),
+ identity: (cap) => broker.identity(cap),
+ authorizeLaunch,
+ refuse: (cap, code) => broker.refuse(cap, code),
+ credentialStatus,
launches: bound,
readers: readCaps,
async close() {
diff --git a/packages/bus/tests/end-launch.test.mjs b/packages/bus/tests/end-launch.test.mjs
new file mode 100644
index 00000000..68720d71
--- /dev/null
+++ b/packages/bus/tests/end-launch.test.mjs
@@ -0,0 +1,192 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { fork } from 'node:child_process';
+import { once } from 'node:events';
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { Store } from '../src/store.mjs';
+import { Broker } from '../src/broker.mjs';
+import { Client } from '../src/client.mjs';
+
+const businesses = {
+ demo: {
+ id: 'demo',
+ human: 'jason',
+ arbiters: { technical: 'cto', delivery: 'pm' },
+ launch: { by: 'pm', instances: ['coder'], max: { opus: 2, sonnet: 2 } },
+ roles: {
+ pm: { authority: { withinRole: ['message.send', 'role.launch'], crossRole: [] } },
+ cto: { authority: { withinRole: ['message.send'], crossRole: [] } },
+ coder: { authority: { withinRole: ['message.send'], crossRole: [] } },
+ },
+ },
+};
+function setup(t) {
+ const root = mkdtempSync(join(tmpdir(), 'bus-end-'));
+ const store = new Store(root);
+ const b = new Broker({ store, businesses });
+ t.after(() => {
+ store.close();
+ rmSync(root, { recursive: true, force: true });
+ });
+ const agent = (role, run = role + '-run') => b.bindLaunch({ business: 'demo', role, run, harness: 'pi' });
+ return { b, store, agent };
+}
+const call = (b, cap, verb, args = {}) => b.request(cap, { verb, args });
+
+test('endLaunch writes session.ended, releases the run claim and kills its capabilities', (t) => {
+ const { b, store, agent } = setup(t);
+ const c = agent('coder');
+ call(b, c, 'role.claim');
+ assert.deepEqual(b.endLaunch({ business: 'demo', run: 'coder-run', reason: 'stopped', exitCode: 0 }), {
+ released: true,
+ });
+ const ended = store.get("SELECT * FROM events WHERE kind='session.ended'");
+ assert.equal(ended.subject, 'coder-run');
+ assert.equal(ended.actor_role, 'coder');
+ assert.equal(ended.actor_run, 'coder-run');
+ assert.deepEqual(JSON.parse(ended.body), { reason: 'stopped', exitCode: 0, released: true });
+ const claim = store.get("SELECT * FROM role_claims WHERE role='coder' ORDER BY seq DESC LIMIT 1");
+ assert.equal(claim.op, 'release');
+ assert.equal(claim.by, 'coder-run');
+ assert.throws(() => call(b, c, 'message.send', { to: 'pm', body: 'still here?' }), /unauthenticated/);
+ assert.throws(() => b.identity(c), /unauthenticated/);
+});
+
+test('endLaunch refuses an unknown run, a second end and a rebind of the ended run', (t) => {
+ const { b, agent } = setup(t);
+ agent('coder');
+ assert.throws(() => b.endLaunch({ business: 'demo', run: 'nope', reason: 'exited' }), /unknown-run/);
+ assert.throws(() => b.endLaunch({ business: 'demo', run: 'coder-run', reason: 'bad reason' }), /invalid-request/);
+ assert.throws(
+ () => b.endLaunch({ business: 'demo', run: 'coder-run', reason: 'exited', exitCode: 1.5 }),
+ /invalid-request/,
+ );
+ assert.deepEqual(b.endLaunch({ business: 'demo', run: 'coder-run', reason: 'exited', exitCode: null }), {
+ released: false,
+ });
+ assert.throws(() => b.endLaunch({ business: 'demo', run: 'coder-run', reason: 'exited' }), /run-ended/);
+ assert.throws(() => agent('coder'), /duplicate-run/);
+});
+
+test('a restarted broker refuses to rebind an ended run; a refused rebind leaves the run unbound', (t) => {
+ const { b, store, agent } = setup(t);
+ agent('coder');
+ agent('pm');
+ b.endLaunch({ business: 'demo', run: 'coder-run', reason: 'stopped', exitCode: 0 });
+ const again = new Broker({ store, businesses });
+ assert.throws(() => again.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'pi' }), /run-ended/);
+ assert.throws(() => again.endLaunch({ business: 'demo', run: 'coder-run', reason: 'host-lost' }), /unknown-run/);
+ assert.throws(() => again.bindLaunch({ business: 'demo', role: 'cto', run: 'pm-run', harness: 'pi' }), /launch-record-mismatch/);
+ again.bindLaunch({ business: 'demo', role: 'pm', run: 'pm-run', harness: 'pi' });
+ assert.deepEqual(again.endLaunch({ business: 'demo', run: 'pm-run', reason: 'host-lost' }), { released: false });
+ assert.equal(store.get("SELECT count(*) n FROM events WHERE kind='session.ended'").n, 2);
+});
+
+test('endLaunch leaves a claim another run took alone', (t) => {
+ const { b, store, agent } = setup(t);
+ const first = agent('coder', 'c1');
+ call(b, first, 'role.claim');
+ call(b, first, 'role.release');
+ call(b, agent('coder', 'c2'), 'role.claim');
+ assert.deepEqual(b.endLaunch({ business: 'demo', run: 'c1', reason: 'exited', exitCode: 0 }), { released: false });
+ assert.equal(store.get("SELECT holder_run FROM role_claims ORDER BY seq DESC LIMIT 1").holder_run, 'c2');
+});
+
+test('refuse records action.refused against the caller with the code only', (t) => {
+ const { b, store, agent } = setup(t);
+ const pm = agent('pm');
+ assert.equal(b.refuse(pm, 'capacity-full'), 'capacity-full');
+ const e = store.get("SELECT * FROM events WHERE kind='action.refused'");
+ assert.equal(e.actor_role, 'pm');
+ assert.deepEqual(JSON.parse(e.body), { code: 'capacity-full' });
+ assert.throws(() => b.refuse(pm, 'Not A Code'), /invalid-request/);
+ assert.throws(() => b.refuse('f'.repeat(64), 'capacity-full'), /unauthenticated/);
+});
+
+function launch(t) {
+ const child = fork(new URL('../src/process.mjs', import.meta.url), [], { stdio: ['ignore', 'pipe', 'pipe', 'ipc'] });
+ t.after(() => {
+ if (child.exitCode === null) child.kill('SIGKILL');
+ });
+ return child;
+}
+async function ask(child, message) {
+ const reply = once(child, 'message');
+ child.send(message);
+ return (await reply)[0];
+}
+
+test('launches off refuses role.launch with launch-revoked until launches on', (t) => {
+ const { b, agent } = setup(t);
+ const pm = agent('pm');
+ call(b, pm, 'role.claim');
+ const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
+ assert.equal(b.authorize(pm, 'role.launch', { target: 'coder' }).class, 'within-role');
+ call(b, human, 'launch.revoke');
+ assert.throws(() => b.authorize(pm, 'role.launch', { target: 'coder' }), { code: 'launch-revoked' });
+ // Other actions are untouched; only launching stops.
+ assert.equal(b.authorize(pm, 'message.send').class, 'within-role');
+ call(b, human, 'launch.restore');
+ assert.equal(b.authorize(pm, 'role.launch', { target: 'coder' }).class, 'within-role');
+});
+
+test('broker process: launch ops authorize role.launch, record refusals and end runs', async (t) => {
+ const root = mkdtempSync(join(tmpdir(), 'bus-launch-ops-'));
+ t.after(() => rmSync(root, { recursive: true, force: true }));
+ const token = join(root, 'token');
+ writeFileSync(token, 'fixture-token-6c1d', { mode: 0o600 });
+ const config = structuredClone(businesses);
+ config.demo.roles.coder.credentials = { vikunja: { file: token, expires: '2099-01-01' } };
+ const child = launch(t);
+ const data = join(root, 'data');
+ mkdirSync(data);
+ const ready = await ask(child, {
+ op: 'boot',
+ config: { dataRoot: data, businesses: config, launches: [], readers: [] },
+ });
+ assert.equal(ready.ok, true, ready.error);
+ const record = (role, run) => ({ business: 'demo', role, run, harness: 'pi', pid: process.pid, startTime: '1' });
+ const pm = (await ask(child, { op: 'bindLaunch', record: record('pm', 'pm-1') })).launch.cap;
+ const cto = (await ask(child, { op: 'bindLaunch', record: record('cto', 'cto-1') })).launch.cap;
+ assert.deepEqual((await ask(child, { op: 'identity', cap: pm })).result, {
+ business: 'demo',
+ role: 'pm',
+ run: 'pm-1',
+ human: null,
+ });
+ assert.equal((await ask(child, { op: 'identity', cap: 'f'.repeat(64) })).error, 'unauthenticated');
+ // A launch is authorized for the role's holder only, so both runs claim first, as the runner does.
+ const reader = new Client({ path: ready.path, cap: cto });
+ await new Client({ path: ready.path, cap: pm }).call('role.claim');
+ await reader.call('role.claim');
+ const ok = await ask(child, { op: 'authorizeLaunch', cap: pm, instance: 'coder' });
+ assert.equal(ok.ok, true);
+ assert.equal(ok.result.class, 'within-role');
+ // The cto is not the business's launcher, so role.launch is gated for it and the refusal is evidence.
+ const no = await ask(child, { op: 'authorizeLaunch', cap: cto, instance: 'coder' });
+ assert.equal(no.ok, false);
+ assert.equal(no.error, 'decision-required');
+ assert.equal((await ask(child, { op: 'refuse', cap: pm, code: 'capacity-full' })).result, 'capacity-full');
+ assert.deepEqual((await ask(child, { op: 'credentialStatus', business: 'demo', role: 'coder' })).result, [
+ { service: 'vikunja', date: '2099-01-01', state: 'valid' },
+ ]);
+ assert.deepEqual((await ask(child, { op: 'credentialStatus', business: 'demo', role: 'pm' })).result, []);
+ const end = await ask(child, { op: 'endLaunch', record: { business: 'demo', run: 'pm-1', reason: 'stopped', exitCode: 0 } });
+ assert.deepEqual(end, { ok: true, result: { released: true } });
+ // The host's request id comes back on every launch-op and bind reply, refusals too.
+ assert.equal((await ask(child, { op: 'identity', cap: cto, id: 7 })).id, 7);
+ assert.deepEqual(await ask(child, { op: 'identity', cap: 'f'.repeat(64), id: 8 }), { ok: false, error: 'unauthenticated', id: 8 });
+ assert.equal((await ask(child, { op: 'bindLaunch', record: record('coder', 'coder-1'), id: 9 })).id, 9);
+ assert.deepEqual(await ask(child, { op: 'bindLaunch', record: record('coder', 'coder-1'), id: 10 }), { ok: false, error: 'duplicate-run', id: 10 });
+ await assert.rejects(new Client({ path: ready.path, cap: pm }).call('agents'), /unauthenticated/);
+ const trail = await reader.call('trail', { subject: 'pm-1' });
+ assert.deepEqual(
+ trail.filter((r) => r.table === 'events').map((r) => r.kind),
+ ['session.launched', 'session.ended'],
+ );
+ const exit = once(child, 'exit');
+ child.send({ op: 'close' });
+ assert.equal((await exit)[0], 0);
+});
diff --git a/packages/cli/README.md b/packages/cli/README.md
index 0eb1423c..32977afa 100644
--- a/packages/cli/README.md
+++ b/packages/cli/README.md
@@ -2,9 +2,10 @@
Jason's front door to the bus (slice 1 row S4, #1521): `mosaic inbox`,
`decide`, `tasks`, `agents` and `trail`, and the bus host `mosaic bus
-start|stop|status` with its Discord notifier. Brief:
-`docs/plans/2026-10-04_slice-1.md`, row S4. Rulings: lead decision 70 in
-`docs/plans/2026-09-26_lead-decisions.md`.
+start|stop|status` with its Discord notifier (row S4, #1521); the session
+launcher in the host and `mosaic talk`, `stop` and `launches` (row S6,
+#1523). Brief: `docs/plans/2026-10-04_slice-1.md`, rows S4 and S6. Rulings:
+lead decisions 70 and 77 in `docs/plans/2026-09-26_lead-decisions.md`.
Every command reads through the broker. None opens the SQLite file.
@@ -24,6 +25,10 @@ mosaic decide <decision> <option> [--note <text>] [--yes] [--business <id>]
mosaic tasks [--business <id>] [--json]
mosaic agents [--business <id>] [--json]
mosaic trail <task|decision> [--business <id>] [--json]
+mosaic talk <role> <text> [--wait <seconds>] [--business <id>]
+mosaic stop <run>
+mosaic launches off|on [--business <id>]
+mosaic launches list [--json]
```
- The business is `--business`, or else the business of the bus host running
@@ -49,11 +54,28 @@ mosaic trail <task|decision> [--business <id>] [--json]
- `trail` prints rows in the broker's order (at, table, seq). A decision's
trail ends with its `task_ref` and `follow with: mosaic trail <task>`. It
does not pull in the task's rows.
+- `talk` sends `<text>` to the role as a REQUEST, then polls the human's
+ inbox every 2 s for up to `--wait` seconds (default 900). It prints every
+ message that arrives and marks it read, and stops at the reply to its
+ REQUEST. No reply in time exits 1; the reply still lands in the inbox.
+ `--wait 0` only sends.
+- `launches off` and `on` are the broker's `launch.revoke` and
+ `launch.restore`. While off, every `role.launch` refuses with
+ `launch-revoked`; running sessions keep running. Stop them with `stop`.
+ `bus start --pm` doesn't ask the broker (`launchPm` skips
+ `authorizeLaunch`): the human launches the PM, so `launches off` doesn't
+ stop it. The other checks in "Sessions" below still apply.
+- `stop` and `launches list` read `<dataRoot>/bus-host/sessions.json`, not
+ the bus (see "Sessions" below). `stop` refuses inside an agent run;
+ `launches list` does not, because the file is readable to the same user
+ anyway.
## Bus host
```
-mosaic bus start <business> the host; run by the unit, not by hand
+mosaic bus start <business> [--pm]
+ the host; run by the unit, not by hand.
+ --pm also launches the business's PM
mosaic bus stop SIGTERM to the recorded host, then wait
mosaic bus status [--json] host state file, socket, writer.lock
```
@@ -76,12 +98,23 @@ mosaic bus status [--json] host state file, socket, writer.lock
5. It writes `<dataRoot>/bus-host/host.json` (0600): the pid, the process
start time, the business, the start time as text and the notifier
binding. The file holds no capability.
+6. It starts the session launcher (`src/launcher.mjs`, "Sessions" below)
+ and, with `--pm`, launches the business's `launch.by` instance. A PM
+ launch that refuses closes the host with the refusal's exit code.
No capability appears in argv, stdout, the environment or a file.
-`startHost()` in `src/host.mjs` is also the in-process API S6 uses:
-`bindLaunch(record)` binds a launched run's process identity
-(`pid`, `startTime`) and returns `{business, run, cap}`. Requests to the
-broker process go one at a time, because its replies carry no request id.
+`startHost()` in `src/host.mjs` is also the in-process API the launcher
+uses: `bindLaunch(record)` binds a launched run's process identity
+(`pid`, `startTime`) and returns `{business, run, cap}`; `op(message)` sends
+one of the broker process's launch ops (`identity`, `authorizeLaunch`,
+`refuse`, `endLaunch`, `credentialStatus`); `beforeClose(fn)` runs `fn`
+before the broker closes, so the launcher stops its sessions first.
+Requests to the broker process go one at a time. Each carries an id, and
+the broker echoes it on the reply. If a reply doesn't arrive within 10 s, or
+arrives with an id no request is waiting for, the channel is broken: the
+waiting request refuses with `broker-channel-broken`, so does every later
+one, and the host stops with exit 1 for the unit to restart. A late reply
+never answers a later request, and a launch waiting on one refuses.
SIGTERM or SIGINT stops the notifier after its poll in flight, then closes
the broker and removes `host.json`. If either child dies on its own, the
@@ -96,6 +129,70 @@ and whose command line is `…/packages/cli/src/cli.mjs bus start`.
a broker was killed hard. Check, then remove the lock by hand
(`packages/bus/README.md`).
+### Sessions
+
+The launcher (`src/launcher.mjs`) starts managed role sessions
+(`packages/seat/src/session.mjs`, `packages/harness`). It serves
+`<dataRoot>/bus-host/launch.sock` (0600), which the PM's `launch` tool
+calls through its runner. One request is one line,
+`{"cap": "…", "instance": "<role instance>"}`, at most 4096 bytes; the reply
+is `{"ok": true, "result": {run, instance, harness, model}}` or
+`{"ok": false, "error": "<code>"}`. Launches run one at a time. In order:
+
+| Check | Refusal |
+|---|---|
+| the request is one JSON line with a string instance | `invalid-request` |
+| the capability is live and names a role of this business | `unauthenticated`, `not-a-role` |
+| the business file has a `launch` block | `launch-not-configured` |
+| `launch.instances` lists the instance (for `--pm`: it is `launch.by`) | `instance-not-listed` |
+| the instance isn't running | `instance-running` |
+| the instance resolves to `pi` or `claude-code` and a model | `unsupported-harness` |
+| its role has a contract file | `no-role-contract` |
+| its model names exactly one `launch.max` family | `unknown-model-family` |
+| that family has room; every running session counts, the PM's too | `capacity-full` |
+| the broker authorizes `role.launch` for the caller | the broker's code (`launch-revoked`, `decision-required`, …) |
+| the bundle builds and the runner starts | `launch-failed` |
+
+Every refusal is an `action.refused` event (the broker's `refuse`, or its
+own refusal for the last broker check) and a line in the launch log. Then
+the launcher reads the instance's credential status from the broker
+(metadata only), builds the bundle in the run directory, spawns the runner
+under `unshare`, binds the outer process's pid and start time, adds the
+session to `sessions.json`, and writes the capability as one line on the
+runner's stdin. The capability is never on disk, in argv or in the
+environment. When the runner exits, the launcher sends `endLaunch` with the
+end reason from its exit code (`packages/harness/README.md`), which writes
+`session.ended`, releases the role and kills the run's capabilities.
+
+The manifest records the Pi version from the repository's pin (the
+`@earendil-works/pi-coding-agent` dependency in the root `package.json`)
+and, for Claude Code, `claude --version`.
+
+On disk, under the data root: `launches/<business>/<run>/` (0700) is the run
+directory; `launches/<business>.jsonl` (0600, append-only) logs every launch,
+refusal and end; `workspaces/<business>/<instance>/` (0700) is kept across
+launches; `bus-host/sessions.json` (0600) lists the running sessions.
+
+When the host closes, the launcher stops taking requests and drops every
+open `launch.sock` connection, so a client that never ends its side can't
+hold the close. It sends SIGTERM to
+every runner (again each second, see "Limits"), waits up to 30 s, then
+SIGKILLs what is left and ends those launches as `killed`.
+
+A host that starts reads the `sessions.json` the last host left. An entry
+for this business means that host died without ending the launch, and its
+run would keep holding the role. Before the launch socket opens, the
+launcher SIGKILLs each such session that still runs (same start time,
+`unshare` on its command line), binds the run again with the recorded
+identity and ends it as `host-lost`, which releases the role. A
+`sessions.json` that can't be read refuses the host start with exit 3; move
+it aside after checking which sessions still run.
+
+`mosaic stop <run>` checks that the runner in `sessions.json` still runs with
+the recorded start time and that its command line is the runner's, then
+sends SIGTERM until it exits (30 s). A stale entry prints "not running" and
+exits 0; an unknown run exits 2.
+
### Trackers
`config.trackers[<business>]` is `{baseUrl, project, pollSeconds,
@@ -223,7 +320,7 @@ exit 3, so a host never starts until someone decides whether it DMs.
|---|---|
| 0 | ok |
| 1 | failed, or outcome unknown: check before retrying |
-| 2 | invalid input: unknown option, no such open decision, decision already closed |
+| 2 | invalid input: unknown option, no such open decision, decision already closed, no such run, not a runner |
| 3 | refused or config problem: inside an agent run, human proof failed, unknown business, bad config, broker or notifier refused to start |
| 4 | usage |
@@ -238,9 +335,22 @@ exit 3, so a host never starts until someone decides whether it DMs.
- **The real human transport is untested here.** No test runs the real
`human-cli.mjs`, because its proof needs a human shell. The live run
covers it.
+- **`stop` is same-UID process control.** It checks the start time and the
+ command line, not authority. Any process of the same user can signal a
+ runner directly.
+- **Signals before the runner's handlers.** A session's runner is pid 1 of
+ its PID namespace, and pid 1 ignores a signal it has no handler for. The
+ runner installs its handlers first, but Node's startup leaves a short
+ window, so the launcher and `stop` resend SIGTERM every second.
+- **If the host dies hard,** its sessions keep running until their broker
+ polls fail, then exit 23, and their launches stay open in the bus until
+ the next host starts and ends them as `host-lost` ("Sessions" above).
+ With no next host, nothing ends them.
+- **`talk` and the launches verbs are tested only with the in-process
+ transport,** as the other human commands are.
- **Tracker boot is tested only without trackers.** The `trackers` boot case
is tested once S3's broker change lands.
## Not in this piece
-`mosaic talk` (S6) and the WebUI (S5).
+The WebUI (S5).
diff --git a/packages/cli/src/cli.mjs b/packages/cli/src/cli.mjs
index 65ae9634..0b48c7a7 100644
--- a/packages/cli/src/cli.mjs
+++ b/packages/cli/src/cli.mjs
@@ -1,10 +1,12 @@
#!/usr/bin/env node
-// mosaic inbox | decide | tasks | agents | trail, and mosaic bus start|stop|status.
-// See packages/cli/README.md. Exit codes are in src/errors.mjs.
+// mosaic inbox | decide | tasks | agents | trail | talk | stop | launches,
+// and mosaic bus start|stop|status. See packages/cli/README.md. Exit codes
+// are in src/errors.mjs.
//
// The human commands read and resolve through the broker's human transport
// (src/transport.mjs), never the SQLite file. `bus start` is the trusted
-// host (src/host.mjs) the systemd unit mosaic-bus@<business> runs.
+// host (src/host.mjs) the systemd unit mosaic-bus@<business> runs; with
+// --pm it also launches the business's PM (src/launcher.mjs).
import { lstatSync, readFileSync } from "node:fs";
import { join } from "node:path";
@@ -16,6 +18,9 @@ import { bootConfig, loadSystem, socketPath } from "./config.mjs";
import { humanTransport, refuseInsideAgent } from "./transport.mjs";
import { formatAgents, formatDecision, formatInbox, formatTasks, formatTrail, shortId } from "./format.mjs";
import { hostStatus, readHostState, startHost, stopHost } from "./host.mjs";
+import { createLauncher } from "./launcher.mjs";
+import { readSessions, stopSession } from "../../seat/src/session.mjs";
+import { SeatError } from "../../seat/src/seat.mjs";
export const USAGE = `usage:
mosaic inbox [--business <id>] [--json]
@@ -23,7 +28,11 @@ export const USAGE = `usage:
mosaic tasks [--business <id>] [--json]
mosaic agents [--business <id>] [--json]
mosaic trail <task|decision> [--business <id>] [--json]
- mosaic bus start <business>
+ mosaic talk <role> <text> [--wait <seconds>] [--business <id>]
+ mosaic stop <run>
+ mosaic launches off|on [--business <id>]
+ mosaic launches list [--json]
+ mosaic bus start <business> [--pm]
mosaic bus stop
mosaic bus status [--json]`;
@@ -146,17 +155,112 @@ async function human(verb, rest, io, deps) {
return print(await call("agents"), formatAgents);
}
+const TALK_WAIT_S = 900;
+const TALK_POLL_MS = 2000;
+
+function formatMessage(m) {
+ const meta = [m.class, m.in_reply_to ? `re ${shortId(m.in_reply_to)}` : null].filter(Boolean).join(", ");
+ return `${m.from_role} (${meta}) ${shortId(m.id)}:\n${m.body.trimEnd()}\n`;
+}
+
+// `mosaic talk <role> <text>`: a REQUEST to the role, then the human's inbox
+// until the reply arrives or --wait runs out. Every message received on the
+// way is printed and marked read, so a late reply shows up on the next talk.
+async function talk(rest, io, deps) {
+ const parsed = parse(rest, { values: ["--business", "--wait"] });
+ if (parsed.positional.length !== 2) throw usage("talk takes a role and one quoted text");
+ const wait = parsed.values["--wait"] === undefined ? TALK_WAIT_S : Number(parsed.values["--wait"]);
+ if (!Number.isInteger(wait) || wait < 0) throw usage("--wait takes whole seconds, 0 or more");
+ refuseInsideAgent(io.env);
+ const system = deps.system ?? loadSystem({ env: io.env });
+ const business = businessFor(parsed, system.dataRoot);
+ const call = deps.transport ? deps.transport(business) : humanTransport({ socket: socketPath(system.dataRoot), business, env: io.env });
+ const [to, body] = parsed.positional;
+ const sent = await call("message.send", { to, body, class: "REQUEST" });
+ io.stdout.write(`sent ${shortId(sent.id)} to ${to}\n`);
+ if (wait === 0) return;
+ const pollMs = deps.talkPollMs ?? TALK_POLL_MS;
+ const deadline = Date.now() + wait * 1000;
+ for (;;) {
+ let answered = false;
+ for (const m of await call("message.receive")) {
+ io.stdout.write(formatMessage(m));
+ await call("message.read", { id: m.id });
+ if (m.in_reply_to === sent.id) answered = true;
+ }
+ if (answered) return;
+ if (Date.now() >= deadline) throw new CliError(`no reply from ${to} within ${wait} s; it may still come, and the next mosaic talk or mosaic inbox shows it`, 1);
+ await new Promise((r) => setTimeout(r, pollMs));
+ }
+}
+
+// Seat errors carry the seat's exit codes; here they become CLI errors.
+async function seat(fn) {
+ try {
+ return await fn();
+ } catch (e) {
+ if (e instanceof SeatError) throw new CliError(e.message, e.exitCode === 2 ? 2 : 1);
+ throw e;
+ }
+}
+
+async function stop(rest, io, deps) {
+ const parsed = parse(rest, {});
+ if (parsed.positional.length !== 1) throw usage("stop takes one run id; see mosaic launches list");
+ refuseInsideAgent(io.env);
+ const system = deps.system ?? loadSystem({ env: io.env });
+ const r = await seat(() => stopSession(system.dataRoot, parsed.positional[0], deps.stopTimeoutMs ? { timeoutMs: deps.stopTimeoutMs } : {}));
+ io.stdout.write(r.stopped ? `stopped ${r.business}/${r.instance} run ${r.run}\n` : `${r.reason}\n`);
+}
+
+async function launches(rest, io, deps) {
+ const [sub, ...args] = rest;
+ if (sub === "off" || sub === "on") {
+ const parsed = parse(args, { values: ["--business"] });
+ if (parsed.positional.length !== 0) throw usage(`launches ${sub} takes no arguments`);
+ refuseInsideAgent(io.env);
+ const system = deps.system ?? loadSystem({ env: io.env });
+ const business = businessFor(parsed, system.dataRoot);
+ const call = deps.transport ? deps.transport(business) : humanTransport({ socket: socketPath(system.dataRoot), business, env: io.env });
+ await call(sub === "off" ? "launch.revoke" : "launch.restore");
+ io.stdout.write(sub === "off" ? `launches off for ${business}: the PM's launch requests are refused until mosaic launches on; running sessions keep running\n` : `launches on for ${business}\n`);
+ return;
+ }
+ if (sub === "list") {
+ const parsed = parse(args, { flags: ["--json"] });
+ if (parsed.positional.length !== 0) throw usage("launches list takes no arguments");
+ const system = deps.system ?? loadSystem({ env: io.env });
+ const list = await seat(() => readSessions(system.dataRoot));
+ if (parsed.flags.has("--json")) return io.stdout.write(`${JSON.stringify(list, null, 2)}\n`);
+ if (list.length === 0) return io.stdout.write("no sessions\n");
+ const lines = list.map((s) => `${s.run} ${s.business}/${s.instance} ${s.harness} ${s.model} by ${s.launchedBy} since ${s.startedAt} ${s.live ? "running" : "not running (stale)"}`);
+ return io.stdout.write(`${lines.join("\n")}\n`);
+ }
+ throw usage();
+}
+
async function bus(rest, io, deps) {
const [sub, ...args] = rest;
if (sub === "start") {
- const parsed = parse(args, {});
+ const parsed = parse(args, { flags: ["--pm"] });
if (parsed.positional.length !== 1) throw usage("bus start takes one business");
const businessId = parsed.positional[0];
const system = deps.system ?? loadSystem({ env: io.env });
const boot = bootConfig({ system, businessId, env: io.env, warn: (w) => io.stderr.write(`mosaic-bus: warning: ${w}\n`) });
const binding = readNotifyConfig(system.dataRoot, businessId);
const host = await startHost({ boot, business: businessId, notifier: binding ? { binding } : null });
- io.stdout.write(`bus host up: business ${businessId}, socket ${host.path}, notifier ${binding ?? "off"}\n`);
+ try {
+ const launcher = createLauncher({ host, system, env: io.env, tracker: Boolean(boot.trackers?.[businessId]), ...(deps.launcher ?? {}) });
+ await launcher.listen();
+ io.stdout.write(`bus host up: business ${businessId}, socket ${host.path}, notifier ${binding ?? "off"}\n`);
+ if (parsed.flags.has("--pm")) {
+ const pm = await launcher.launchPm();
+ io.stdout.write(`launched ${pm.instance}: run ${pm.run}, ${pm.harness} ${pm.model}\n`);
+ }
+ } catch (e) {
+ await host.close(1);
+ throw e;
+ }
const stop = () => host.close(0);
process.on("SIGTERM", stop);
process.on("SIGINT", stop);
@@ -195,6 +299,9 @@ async function bus(rest, io, deps) {
export async function main(argv, io = { env: process.env, stdin: process.stdin, stdout: process.stdout, stderr: process.stderr }, deps = {}) {
const [verb, ...rest] = argv;
if (["inbox", "decide", "tasks", "agents", "trail"].includes(verb)) return human(verb, rest, io, deps);
+ if (verb === "talk") return talk(rest, io, deps);
+ if (verb === "stop") return stop(rest, io, deps);
+ if (verb === "launches") return launches(rest, io, deps);
if (verb === "bus") return bus(rest, io, deps);
if (verb === "-h" || verb === "--help") return io.stdout.write(`${USAGE}\n`);
throw usage();
diff --git a/packages/cli/src/host.mjs b/packages/cli/src/host.mjs
index 083f8f1d..0073091b 100644
--- a/packages/cli/src/host.mjs
+++ b/packages/cli/src/host.mjs
@@ -5,8 +5,12 @@
// host runs, `<dataRoot>/bus-host/host.json` (0600) names it for `mosaic bus
// stop|status` and for the human commands' default business.
//
-// startHost() is also the in-process API S6 uses: bindLaunch(record) binds
-// a launched run's process identity and returns its capability.
+// startHost() is also the in-process API the S6 launcher (src/launcher.mjs)
+// uses: bindLaunch(record) binds a launched run's process identity and
+// returns its capability; op(message) runs one of the broker's launch ops
+// (identity, authorizeLaunch, refuse, endLaunch, credentialStatus); and
+// beforeClose(fn) runs fn before the host stops its children, while those
+// ops still work.
import { fork } from "node:child_process";
import { once } from "node:events";
@@ -14,35 +18,19 @@ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { CliError } from "./errors.mjs";
+import { cmdlineOf, startTimeOf } from "../../seat/src/proc.mjs";
const BROKER = fileURLToPath(new URL("../../bus/src/process.mjs", import.meta.url));
const NOTIFIER = fileURLToPath(new URL("./notifier-process.mjs", import.meta.url));
export const BOOT_TIMEOUT_MS = 30000;
const START_TIMEOUT_MS = 15000;
const CLOSE_TIMEOUT_MS = 20000;
+const REQUEST_TIMEOUT_MS = 10000;
export const hostDir = (dataRoot) => join(dataRoot, "bus-host");
export const hostFile = (dataRoot) => join(hostDir(dataRoot), "host.json");
-// `/proc/<pid>/stat` field 22, the start time in clock ticks. Null when the
-// process is gone or has exited and waits to be reaped (state Z).
-export function startTimeOf(pid) {
- try {
- const raw = readFileSync(`/proc/${pid}/stat`, "utf8");
- const fields = raw.slice(raw.lastIndexOf(")") + 2).split(" ");
- return fields[0] === "Z" ? null : fields[19];
- } catch {
- return null;
- }
-}
-
-export function cmdlineOf(pid) {
- try {
- return readFileSync(`/proc/${pid}/cmdline`, "utf8").split("\0").filter(Boolean);
- } catch {
- return null;
- }
-}
+export { cmdlineOf, startTimeOf };
// The state file, or null. `live` is true only when the pid still runs with
// the recorded start time, so a recycled pid never counts as the host.
@@ -107,8 +95,9 @@ export function watchChildren(children, onDeath) {
// boot: the {op:'boot'} config from bootConfig(). notifier: null, or
// {binding, base?, pollMs?}; base and pollMs exist for the tests, and the
-// command line never sets them. Resolves once both children are up.
-export async function startHost({ boot, business, notifier = null, bootTimeoutMs = BOOT_TIMEOUT_MS, log = (l) => process.stderr.write(`mosaic-bus: ${l}\n`) }) {
+// command line never sets them, nor requestTimeoutMs. Resolves once both
+// children are up.
+export async function startHost({ boot, business, notifier = null, bootTimeoutMs = BOOT_TIMEOUT_MS, requestTimeoutMs = REQUEST_TIMEOUT_MS, log = (l) => process.stderr.write(`mosaic-bus: ${l}\n`) }) {
const dataRoot = boot.dataRoot;
const prior = readHostState(dataRoot);
if (prior?.live) throw new CliError(`a bus host already runs for ${prior.business} (pid ${prior.pid})`, 3);
@@ -157,27 +146,83 @@ export async function startHost({ boot, business, notifier = null, bootTimeoutMs
rmSync(hostFile(dataRoot), { force: true });
writeHostState(dataRoot, state);
+ // stopping: close() has started (no new close, child deaths are expected).
+ // closing: the before-close hooks have run, so no more broker requests.
+ let stopping = false;
let closing = false;
let finish;
const done = new Promise((r) => (finish = r));
+ const hooks = [];
- // Replies from process.mjs carry no request id, so requests go one at a time.
+ // Requests go one at a time, each with an id that process.mjs echoes. A
+ // reply that misses the wait, or carries an id no request is waiting for,
+ // breaks the channel: the waiting request and every later one refuse, and
+ // the host stops with exit 1 so the unit restarts it. A late reply can
+ // never answer a later request.
let queue = Promise.resolve();
- function bindLaunch(record) {
+ let seq = 0;
+ let pending = null;
+ let broken = null;
+ const fail = (code, text) => Object.assign(new CliError(text, 1), { code });
+ function breakChannel(why) {
+ if (broken) return;
+ broken = why;
+ if (pending) {
+ clearTimeout(pending.timer);
+ pending.reject(fail("broker-channel-broken", `broker channel broken: ${why}`));
+ pending = null;
+ }
+ if (stopping) return;
+ log(`broker channel broken (${why}); stopping the host`);
+ close(1);
+ }
+ broker.on("message", (m) => {
+ if (pending && m?.id === pending.id) {
+ clearTimeout(pending.timer);
+ const { resolve } = pending;
+ pending = null;
+ resolve(m);
+ } else breakChannel(pending ? "reply for another request" : "reply with no request waiting");
+ });
+ broker.once("exit", () => {
+ if (!pending) return;
+ clearTimeout(pending.timer);
+ pending.reject(fail("broker-exited", "broker exited before it replied"));
+ pending = null;
+ });
+ function request(message, { what, pick }) {
const run = queue.then(async () => {
- if (closing) throw new CliError("bus host is closing", 1);
- const r = firstReply(broker, 10000, "broker");
- broker.send({ op: "bindLaunch", record });
- const m = await r;
- if (m?.ok !== true) throw new CliError(`launch bind refused: ${m?.error ?? "bind-refused"}`, 3);
- return m.launch;
+ if (broken) throw fail("broker-channel-broken", `broker channel broken: ${broken}`);
+ if (closing || !broker.connected) throw fail("host-closing", "bus host is closing");
+ const id = ++seq;
+ const m = await new Promise((resolve, reject) => {
+ const timer = setTimeout(() => breakChannel(`no reply within ${Math.round(requestTimeoutMs / 1000)} s`), requestTimeoutMs);
+ pending = { id, resolve, reject, timer };
+ broker.send({ ...message, id });
+ });
+ if (m?.ok !== true) {
+ const code = typeof m?.error === "string" ? m.error : `${what}-refused`;
+ throw Object.assign(new CliError(`${what} refused: ${code}`, 3), { code });
+ }
+ return pick(m);
});
queue = run.catch(() => {});
return run;
}
+ const bindLaunch = (record) => request({ op: "bindLaunch", record }, { what: "launch bind", pick: (m) => m.launch });
+ const op = (message) => request(message, { what: message.op, pick: (m) => m.result });
+ const beforeClose = (fn) => void hooks.push(fn);
async function close(code = 0) {
- if (closing) return done;
+ if (stopping) return done;
+ stopping = true;
+ for (const fn of hooks) {
+ try {
+ await fn();
+ } catch (e) {
+ log(`before close: ${e.message}`);
+ }
+ }
closing = true;
let result = code;
if (notify) {
@@ -197,12 +242,12 @@ export async function startHost({ boot, business, notifier = null, bootTimeoutMs
// restarts the pair rather than run a broker without its notifier.
// Runs after `queue` exists, since close() awaits it.
watchChildren({ broker, notifier: notify }, (name, code, signal) => {
- if (closing) return;
+ if (stopping) return;
log(`${name} exited (${signal ?? code}); stopping the host`);
close(1);
});
- return Object.freeze({ path: ready.path, business, bindLaunch, close, done, pids: Object.freeze({ broker: broker.pid, notifier: notify?.pid ?? null }) });
+ return Object.freeze({ path: ready.path, dataRoot, business, bindLaunch, op, beforeClose, close, done, pids: Object.freeze({ broker: broker.pid, notifier: notify?.pid ?? null }) });
}
// `mosaic bus stop`: SIGTERM to the recorded host, after checking that the
diff --git a/packages/cli/src/launcher.mjs b/packages/cli/src/launcher.mjs
new file mode 100644
index 00000000..b07ae333
--- /dev/null
+++ b/packages/cli/src/launcher.mjs
@@ -0,0 +1,420 @@
+// The S6 launcher, inside the trusted bus host: it starts managed role
+// sessions (packages/seat/src/session.mjs) and serves the launch socket the
+// PM's `launch` tool calls. One launch at a time.
+//
+// A session asks over `<dataRoot>/bus-host/launch.sock` (0600) with one line,
+// `{"cap": "<its capability>", "instance": "<role instance>"}`, and gets one
+// line back: `{"ok": true, "result": {run, instance, harness, model}}` or
+// `{"ok": false, "error": "<code>"}`. The host checks, in order:
+//
+// 1. identity: the capability names a role of this business
+// 2. the business file's launch.instances lists the instance
+// (instance-not-listed)
+// 3. the instance isn't already running (instance-running)
+// 4. it resolves, has a role contract (no-role-contract) and a supported
+// harness and model (unsupported-harness)
+// 5. its model is in exactly one launch.max family (unknown-model-family)
+// 6. that family has room; every running session counts, the PM's too
+// (capacity-full)
+// 7. the broker authorizes role.launch for the caller (its own codes:
+// launch-revoked, gated, ...)
+//
+// Steps 2 to 6 refuse through the broker's refuse(), so every refusal is an
+// action.refused event; step 7's refusals the broker records itself. Then
+// the host builds the bundle, spawns the runner, binds the run's process
+// identity (the outer unshare pid) and writes the new capability on the
+// runner's stdin. When the runner exits, the host ends the launch.
+//
+// `bus start <business> --pm` launches launch.by the same way with
+// launchedBy = the business's human and no caller capability.
+
+import { spawnSync } from "node:child_process";
+import { createServer } from "node:net";
+import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, rmSync } from "node:fs";
+import { join } from "node:path";
+import { configDir, loadBusiness, resolveInstance, systemVars } from "../../business/src/index.mjs";
+import { buildBundle, sessionModel } from "../../harness/src/bundle.mjs";
+import {
+ ADAPTERS,
+ appendLaunchLog,
+ endReason,
+ family,
+ launchSocketPath,
+ newRun,
+ readSessions,
+ Registry,
+ runnerOf,
+ sessionEnv,
+ spawnSession,
+ UNSHARE,
+ workspaceDir,
+ writeSessionFile,
+} from "../../seat/src/session.mjs";
+import { cmdlineOf, startTimeOf } from "../../seat/src/proc.mjs";
+import { REPO, rolesDir } from "./config.mjs";
+import { CliError } from "./errors.mjs";
+
+const PI_PACKAGE = "@earendil-works/pi-coding-agent";
+const STOP_TIMEOUT_MS = 30000;
+const LINE_MAX = 4096;
+
+// A refusal the host decided; `code` goes back to the caller and to the broker.
+class Refusal extends Error {
+ constructor(code, message = code) {
+ super(message);
+ this.code = code;
+ }
+}
+
+// The exact pi pin from the repository's package.json.
+export function piVersion(repo = REPO) {
+ try {
+ const pkg = JSON.parse(readFileSync(join(repo, "package.json"), "utf8"));
+ return pkg.dependencies?.[PI_PACKAGE] ?? pkg.devDependencies?.[PI_PACKAGE] ?? null;
+ } catch {
+ return null;
+ }
+}
+
+// `claude --version`, or null when it doesn't answer.
+export function claudeVersion(env = process.env) {
+ const r = spawnSync("claude", ["--version"], { encoding: "utf8", timeout: 10000, env });
+ return r.status === 0 ? r.stdout.trim().split("\n")[0] || null : null;
+}
+
+// host: startHost()'s handle. tracker: true when the broker has task verbs for
+// the business. adapters, namespace, sessionDefaults, versions and
+// stopTimeoutMs exist for the tests; the command line never sets them.
+export function createLauncher({
+ host,
+ system,
+ env = process.env,
+ tracker = false,
+ adapters = ADAPTERS,
+ namespace = true,
+ sessionDefaults = {},
+ versions = null,
+ stopTimeoutMs = STOP_TIMEOUT_MS,
+ log = (l) => process.stderr.write(`mosaic-bus: ${l}\n`),
+}) {
+ const dataRoot = system.dataRoot;
+ const b = loadBusiness(host.business, { dir: configDir(env), rolesDir: rolesDir(env) });
+ const vars = systemVars(system);
+
+ // Sessions an earlier host left in sessions.json: it died without ending
+ // them, so their runs still hold their roles. listen() ends them. A file
+ // that can't be read refuses the host rather than drop them unseen.
+ let leftover;
+ try {
+ leftover = readSessions(dataRoot).filter((s) => s.business === b.id);
+ } catch (e) {
+ throw new CliError(`${e.message}; check which sessions still run, then move the file aside`, 3);
+ }
+ const registry = new Registry(dataRoot);
+ const children = new Map();
+ let chain = Promise.resolve();
+ let closed = false;
+ let server = null;
+ const sockets = new Set();
+
+ const record = (entry) => {
+ try {
+ appendLaunchLog(dataRoot, b.id, entry);
+ } catch (e) {
+ log(`launch log: ${e.message}`);
+ }
+ };
+
+ function serialize(fn) {
+ const run = chain.then(fn);
+ chain = run.catch(() => {});
+ return run;
+ }
+
+ // Steps 2 to 6. Returns what the launch needs. Only `--pm` may name
+ // launch.by itself.
+ function check(instance, { pm = false } = {}) {
+ if (!b.launch) throw new Refusal("launch-not-configured", `business ${b.id} has no launch block`);
+ if (!(pm ? instance === b.launch.by : b.launch.instances.includes(instance))) throw new Refusal("instance-not-listed");
+ if (registry.running(b.id, instance)) throw new Refusal("instance-running");
+ let resolved, model;
+ try {
+ resolved = resolveInstance({ system: vars, business: b, instance });
+ model = sessionModel(resolved);
+ } catch (e) {
+ throw new Refusal("unsupported-harness", e.message);
+ }
+ if (!resolved.contract) throw new Refusal("no-role-contract", `role ${resolved.definition} has no contract file`);
+ const fam = family(model.model, b.launch.max);
+ if (!fam) throw new Refusal("unknown-model-family", `model ${model.model} is in none, or more than one, of ${Object.keys(b.launch.max).join(", ")}`);
+ if (registry.count(b.id, fam) >= b.launch.max[fam]) throw new Refusal("capacity-full", `${fam} sessions: ${registry.count(b.id, fam)} of ${b.launch.max[fam]}`);
+ return { resolved, model, fam };
+ }
+
+ // Bundle, spawn, bind, register, hand over the capability.
+ async function start({ instance, launchedBy, resolved, model, fam }) {
+ const credentials = await host.op({ op: "credentialStatus", business: b.id, role: instance });
+ const { run, runDir } = newRun(dataRoot, b.id);
+ const workspace = workspaceDir(dataRoot, b.id, instance);
+ mkdirSync(workspace, { recursive: true, mode: 0o700 });
+ const sessionDir = join(runDir, "session");
+ mkdirSync(sessionDir, { mode: 0o700 });
+ const toolSocket = join(runDir, "tools.sock");
+ const turnMarker = join(runDir, "turn.done");
+ const v = versions ?? { pi: piVersion(), claude: model.harness === "claude-code" ? claudeVersion(env) : null };
+ const bundle = buildBundle({
+ dir: join(runDir, "bundle"),
+ resolved,
+ contract: readFileSync(resolved.contract, "utf8"),
+ arbiter: Object.values(b.arbiters).includes(instance),
+ tracker,
+ credentials,
+ session: { run, launchedBy, workspace, toolSocket, turnMarker },
+ piVersion: v.pi,
+ claudeVersion: v.claude,
+ });
+ const { manifest, ...files } = bundle.files;
+ writeSessionFile(runDir, {
+ ...sessionDefaults,
+ business: b.id,
+ instance,
+ run,
+ launchedBy,
+ harness: model.harness,
+ provider: model.provider,
+ model: model.model,
+ brokerSocket: host.path,
+ launchSocket: launchSocketPath(dataRoot),
+ workspace,
+ sessionDir,
+ adapter: adapters[model.harness],
+ bundle: files,
+ toolSocket,
+ turnMarker,
+ });
+
+ const child = spawnSession({ runDir, env: sessionEnv(env, run), namespace });
+ let bound = false;
+ let entry = null;
+ const exited = new Promise((resolve) => child.once("close", (code, signal) => resolve({ code, signal })));
+ child.on("error", (e) => log(`run ${run}: ${e.message}`));
+ child.stdin.on("error", () => {});
+ try {
+ if (child.pid === undefined) throw new Error("the session process did not start");
+ const outerStart = startTimeOf(child.pid);
+ const runner = await runnerOf(child, { namespace });
+ if (!outerStart) throw new Error("the session process exited at once");
+ const launch = await host.bindLaunch({ business: b.id, role: instance, run, harness: model.harness, pid: child.pid, startTime: outerStart });
+ bound = true;
+ entry = {
+ business: b.id, instance, run, launchedBy, harness: model.harness, model: model.model, family: fam,
+ pid: child.pid, startTime: outerStart, runnerPid: runner.pid, runnerStartTime: runner.startTime, runDir, startedAt: new Date().toISOString(),
+ };
+ registry.add(entry);
+ child.stdin.end(`${JSON.stringify({ cap: launch.cap })}\n`);
+ } catch (e) {
+ child.kill("SIGKILL");
+ await exited;
+ if (bound) await endRun(run, "failed", null);
+ throw new Refusal("launch-failed", e.message);
+ }
+ children.set(run, exited);
+ exited.then(async ({ code, signal }) => {
+ const reason = endReason(code, signal);
+ try {
+ await endRun(run, reason, signal ? null : code);
+ registry.remove(b.id, run);
+ record({ event: "end", instance, run, reason, exitCode: code, signal });
+ log(`run ${run} (${instance}) ended: ${reason}`);
+ } catch (e) {
+ log(`run ${run}: ${e.message}`);
+ } finally {
+ children.delete(run);
+ }
+ });
+ record({ event: "launch", instance, run, launchedBy, harness: model.harness, model: model.model, family: fam, pid: child.pid, runnerPid: entry.runnerPid, manifest });
+ return { run, instance, harness: model.harness, model: model.model };
+ }
+
+ async function endRun(run, reason, exitCode) {
+ try {
+ await host.op({ op: "endLaunch", record: { business: b.id, run, reason, exitCode } });
+ } catch (e) {
+ log(`run ${run}: endLaunch refused: ${e.code ?? e.message}`);
+ }
+ }
+
+ // A launch a session asked for. Resolves to the reply line's object.
+ function fromSession(cap, instance) {
+ return serialize(async () => {
+ if (closed) return { ok: false, error: "host-closing" };
+ let who;
+ try {
+ who = await host.op({ op: "identity", cap });
+ } catch (e) {
+ return { ok: false, error: e.code ?? "unauthenticated" };
+ }
+ if (!who.role || who.business !== b.id) {
+ await host.op({ op: "refuse", cap, code: "not-a-role" }).catch(() => {});
+ return { ok: false, error: "not-a-role" };
+ }
+ let checked;
+ try {
+ if (typeof instance !== "string") throw new Refusal("invalid-request");
+ checked = check(instance);
+ } catch (e) {
+ if (!(e instanceof Refusal)) throw e;
+ await host.op({ op: "refuse", cap, code: e.code }).catch(() => {});
+ record({ event: "refused", by: who.role, instance, code: e.code, why: e.message });
+ return { ok: false, error: e.code };
+ }
+ try {
+ await host.op({ op: "authorizeLaunch", cap, instance });
+ } catch (e) {
+ record({ event: "refused", by: who.role, instance, code: e.code ?? "not-authorized" });
+ return { ok: false, error: e.code ?? "not-authorized" };
+ }
+ try {
+ return { ok: true, result: await start({ instance, launchedBy: who.role, ...checked }) };
+ } catch (e) {
+ const code = e instanceof Refusal ? e.code : "launch-failed";
+ log(`launch of ${instance} for ${who.role} failed: ${e.message}`);
+ await host.op({ op: "refuse", cap, code }).catch(() => {});
+ record({ event: "refused", by: who.role, instance, code, why: e.message });
+ return { ok: false, error: code };
+ }
+ });
+ }
+
+ // `bus start --pm`: the business's launcher instance, launched by the human.
+ function launchPm() {
+ return serialize(async () => {
+ if (!b.launch) throw new CliError(`business ${b.id} has no launch block, so --pm has nothing to launch`, 3);
+ try {
+ return await start({ instance: b.launch.by, launchedBy: b.human, ...check(b.launch.by, { pm: true }) });
+ } catch (e) {
+ if (!(e instanceof Refusal)) throw e;
+ record({ event: "refused", by: b.human, instance: b.launch.by, code: e.code, why: e.message });
+ throw new CliError(`PM launch refused: ${e.code}${e.message !== e.code ? ` (${e.message})` : ""}`, 3);
+ }
+ });
+ }
+
+ // The earlier host's sessions. Their capabilities died with its broker, so
+ // one that still runs can do nothing: kill it (the namespace takes its
+ // turn with it). Then end each run in the bus, which releases its role.
+ // This broker never bound those runs, so each is bound again first with
+ // its identical launch record, which the broker accepts.
+ async function recover() {
+ for (const s of leftover) {
+ if (startTimeOf(s.pid) === s.startTime && (cmdlineOf(s.pid) ?? [])[0] === UNSHARE[0]) {
+ try {
+ process.kill(s.pid, "SIGKILL");
+ } catch {}
+ const end = Date.now() + 5000;
+ while (startTimeOf(s.pid) === s.startTime && Date.now() < end) await new Promise((r) => setTimeout(r, 20));
+ }
+ try {
+ await host.bindLaunch({ business: b.id, role: s.instance, run: s.run, harness: s.harness, pid: s.pid, startTime: s.startTime });
+ } catch (e) {
+ // A host that died after ending the run but before updating sessions.json.
+ if (e.code === "run-ended") log(`run ${s.run} (${s.instance}) from an earlier host was already ended`);
+ else log(`run ${s.run} (${s.instance}) from an earlier host: not ended, bindLaunch refused: ${e.code ?? e.message}`);
+ continue;
+ }
+ await endRun(s.run, "host-lost", null);
+ record({ event: "end", instance: s.instance, run: s.run, reason: "host-lost", exitCode: null, signal: null });
+ log(`run ${s.run} (${s.instance}) from an earlier host ended: host-lost`);
+ }
+ leftover = [];
+ }
+
+ async function listen() {
+ await recover();
+ const path = launchSocketPath(dataRoot);
+ if (existsSync(path)) {
+ if (!lstatSync(path).isSocket()) throw new CliError(`${path} exists and isn't a socket`, 3);
+ rmSync(path);
+ }
+ server = createServer((socket) => {
+ sockets.add(socket);
+ socket.once("close", () => sockets.delete(socket));
+ let buf = "";
+ let answered = false;
+ const reply = (obj) => {
+ if (answered) return;
+ answered = true;
+ socket.end(`${JSON.stringify(obj)}\n`);
+ };
+ socket.setTimeout(10000, () => reply({ ok: false, error: "invalid-request" }));
+ socket.on("error", () => {});
+ socket.on("data", (chunk) => {
+ if (answered) return;
+ buf += chunk;
+ if (buf.length > LINE_MAX) return reply({ ok: false, error: "invalid-request" });
+ const nl = buf.indexOf("\n");
+ if (nl < 0) return;
+ socket.setTimeout(0);
+ let req;
+ try {
+ req = JSON.parse(buf.slice(0, nl));
+ } catch {
+ return reply({ ok: false, error: "invalid-request" });
+ }
+ if (typeof req?.cap !== "string" || !/^[0-9a-f]{64}$/.test(req.cap)) return reply({ ok: false, error: "unauthenticated" });
+ fromSession(req.cap, req.instance).then(reply, (e) => {
+ log(`launch socket: ${e.message}`);
+ reply({ ok: false, error: "launch-failed" });
+ });
+ });
+ });
+ // bus-host/ is 0700, so the moment before the chmod exposes nothing.
+ await new Promise((resolve, reject) => {
+ server.once("error", reject);
+ server.listen(path, resolve);
+ });
+ chmodSync(path, 0o600);
+ }
+
+ // SIGTERM every runner, wait, then end what's left. Runs before the host
+ // stops the broker, so endLaunch still works.
+ async function close() {
+ closed = true;
+ if (server) {
+ // server.close waits for every connection, and a client that never
+ // ends its side (or never sends) would hold it; so they go first.
+ const stopped = new Promise((r) => server.close(r));
+ for (const socket of sockets) socket.destroy();
+ await stopped;
+ rmSync(launchSocketPath(dataRoot), { force: true });
+ }
+ await chain;
+ // Again every second: a runner still starting has no handler yet, and as
+ // pid 1 of its namespace it never sees a signal sent before it has one.
+ const term = () => {
+ for (const s of registry.sessions) {
+ try {
+ if (startTimeOf(s.runnerPid) === s.runnerStartTime) process.kill(s.runnerPid, "SIGTERM");
+ } catch {}
+ }
+ };
+ term();
+ const again = setInterval(term, 1000);
+ const pending = [...children.values()];
+ const timer = setTimeout(() => {
+ for (const s of registry.sessions) {
+ try {
+ if (startTimeOf(s.pid) === s.startTime) process.kill(s.pid, "SIGKILL");
+ } catch {}
+ }
+ }, stopTimeoutMs);
+ await Promise.all(pending);
+ // The exit handlers run on the same tick as the close events; let them finish.
+ while (children.size) await new Promise((r) => setTimeout(r, 20));
+ clearInterval(again);
+ clearTimeout(timer);
+ }
+
+ host.beforeClose(close);
+ return { business: b, registry, listen, launchPm, fromSession, close };
+}
diff --git a/packages/cli/tests/fixtures/launch-host.mjs b/packages/cli/tests/fixtures/launch-host.mjs
new file mode 100644
index 00000000..0acc8424
--- /dev/null
+++ b/packages/cli/tests/fixtures/launch-host.mjs
@@ -0,0 +1,23 @@
+// A bus host with its launcher in a process of its own, so a test can kill
+// it hard. It launches the PM, prints the launch result as one JSON line and
+// runs until it is killed. argv: the adapter to use for both harnesses.
+
+import { bootConfig, loadSystem } from "../../src/config.mjs";
+import { startHost } from "../../src/host.mjs";
+import { createLauncher } from "../../src/launcher.mjs";
+
+const [adapter] = process.argv.slice(2);
+const system = loadSystem({ env: process.env });
+const boot = bootConfig({ system, businessId: "acme", env: process.env });
+const host = await startHost({ boot, business: "acme", log: () => {} });
+const launcher = createLauncher({
+ host,
+ system,
+ env: process.env,
+ adapters: { pi: adapter, "claude-code": adapter },
+ sessionDefaults: { pollInterval: 100 },
+ versions: { pi: "0.85.1", claude: null },
+ log: () => {},
+});
+await launcher.listen();
+process.stdout.write(`${JSON.stringify(await launcher.launchPm())}\n`);
diff --git a/packages/cli/tests/host.test.mjs b/packages/cli/tests/host.test.mjs
index f8f11ec9..50db2473 100644
--- a/packages/cli/tests/host.test.mjs
+++ b/packages/cli/tests/host.test.mjs
@@ -203,6 +203,88 @@ test("a second host for the same data root refuses with exit 3 while the first r
assert.equal(await host.close(0), 0);
});
+test("a broker reply that misses the wait breaks the channel: the late reply answers nothing, later requests refuse, the host exits 1", { timeout: 30000 }, async (t) => {
+ const root = tmp(t);
+ const f = fixture(root);
+ makeDeployment(root);
+ const boot = bootConfig({ system: loadSystem({ env: f.env }), businessId: "acme", env: f.env });
+ const logs = [];
+ const host = await startHost({ boot, business: "acme", requestTimeoutMs: 1000, log: (l) => logs.push(l) });
+ // Hooks run in order: the broker resumes before the close, which a
+ // stopped broker would hold.
+ t.after(() => {
+ try {
+ process.kill(host.pids.broker, "SIGCONT");
+ } catch {}
+ });
+ t.after(() => host.close(0));
+ const record = (role, run) => ({ business: "acme", role, run, harness: "pi", pid: process.pid, startTime: startTimeOf(process.pid) });
+ const coder = await host.bindLaunch(record("coder", "coder-run"));
+ assert.equal((await host.op({ op: "identity", cap: coder.cap })).role, "coder");
+
+ // Stall the broker with a request waiting and a bind queued behind it.
+ process.kill(host.pids.broker, "SIGSTOP");
+ const code = (p) => p.then((v) => ({ answered: v }), (e) => e.code);
+ const first = code(host.op({ op: "identity", cap: "bogus" }));
+ const second = code(host.bindLaunch(record("reviewer", "reviewer-run")));
+ assert.equal(await first, "broker-channel-broken");
+ assert.equal(await second, "broker-channel-broken", "a queued request never reaches the broker");
+ process.kill(host.pids.broker, "SIGCONT");
+
+ // The broker answers the stalled request late; nothing takes that reply.
+ assert.equal(await host.done, 1);
+ assert.ok(logs.some((l) => /^broker channel broken \(no reply within 1 s\); stopping the host$/.test(l)), logs.join("\n"));
+ assert.equal(await code(host.op({ op: "identity", cap: coder.cap })), "broker-channel-broken");
+});
+
+test("a broker reply with another request's id, or none, breaks the channel and the host exits 1", { timeout: 30000 }, async (t) => {
+ // The broker echoes whatever id it's sent, so changing the id on the way
+ // out gives the host the reply a confused broker would send.
+ let what, tamper;
+ spySends(t, (m) => m?.cap === "tamper" && tamper(m));
+ for ([what, tamper] of [
+ ["another id", (m) => (m.id += 1000)],
+ ["no id", (m) => delete m.id],
+ ]) {
+ const root = tmp(t);
+ const f = fixture(root);
+ makeDeployment(root);
+ const boot = bootConfig({ system: loadSystem({ env: f.env }), businessId: "acme", env: f.env });
+ const logs = [];
+ const host = await startHost({ boot, business: "acme", log: (l) => logs.push(l) });
+ t.after(() => host.close(0));
+ const record = (role, run) => ({ business: "acme", role, run, harness: "pi", pid: process.pid, startTime: startTimeOf(process.pid) });
+ const coder = await host.bindLaunch(record("coder", "coder-run"));
+ await assert.rejects(host.op({ op: "identity", cap: "tamper" }), (e) => e.code === "broker-channel-broken", what);
+ await assert.rejects(host.op({ op: "identity", cap: coder.cap }), (e) => e.code === "broker-channel-broken", what);
+ assert.equal(await host.done, 1, what);
+ assert.ok(logs.includes("broker channel broken (reply for another request); stopping the host"), `${what}: ${logs.join("\n")}`);
+ }
+});
+
+test("a broker reply with no request waiting breaks the channel and the host exits 1", { timeout: 30000 }, async (t) => {
+ // A request sent to the real broker past the host's queue: its reply
+ // arrives when nothing is waiting (Darkwing round 2, Mr).
+ const children = [];
+ const onChild = ({ process: child }) => children.push(child);
+ subscribe("child_process", onChild);
+ t.after(() => unsubscribe("child_process", onChild));
+ const root = tmp(t);
+ const f = fixture(root);
+ makeDeployment(root);
+ const boot = bootConfig({ system: loadSystem({ env: f.env }), businessId: "acme", env: f.env });
+ const logs = [];
+ const host = await startHost({ boot, business: "acme", log: (l) => logs.push(l) });
+ t.after(() => host.close(0));
+ const record = { business: "acme", role: "coder", run: "coder-run", harness: "pi", pid: process.pid, startTime: startTimeOf(process.pid) };
+ const coder = await host.bindLaunch(record);
+ children.find((c) => c.pid === host.pids.broker).send({ op: "identity", cap: coder.cap, id: 1_000_000 });
+ const late = new Promise((r) => setTimeout(r, 5000, "still running").unref());
+ assert.equal(await Promise.race([host.done, late]), 1);
+ assert.ok(logs.includes("broker channel broken (reply with no request waiting); stopping the host"), logs.join("\n"));
+ await assert.rejects(host.op({ op: "identity", cap: coder.cap }), (e) => e.code === "broker-channel-broken");
+});
+
test("a notifier that refuses stops the broker and the host refuses with exit 3", async (t) => {
const root = tmp(t);
const f = fixture(root);
diff --git a/packages/cli/tests/launcher.test.mjs b/packages/cli/tests/launcher.test.mjs
new file mode 100644
index 00000000..c9b589e3
--- /dev/null
+++ b/packages/cli/tests/launcher.test.mjs
@@ -0,0 +1,394 @@
+// The S6 launcher inside a real bus host: real broker child, real runners
+// under unshare, the fake adapter from packages/harness standing in for the
+// harness. The PM launches a coder through its `launch` tool; every host
+// refusal comes back over the launch socket and lands in the launch log.
+
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { spawn, spawnSync } from "node:child_process";
+import { once } from "node:events";
+import { createInterface } from "node:readline";
+import { connect } from "node:net";
+import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
+import { join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { Client } from "../../bus/src/client.mjs";
+import { launchLogFile, launchSocketPath, readSessions, Registry, sessionsFile, UNSHARE } from "../../seat/src/session.mjs";
+import { bootConfig, loadSystem } from "../src/config.mjs";
+import { startHost, startTimeOf } from "../src/host.mjs";
+import { createLauncher } from "../src/launcher.mjs";
+import { fixture, tmp } from "./helpers.mjs";
+
+const FAKE = fileURLToPath(new URL("../../harness/tests/fixtures/fake-adapter.mjs", import.meta.url));
+const HOST = fileURLToPath(new URL("./fixtures/launch-host.mjs", import.meta.url));
+const NAMESPACES = spawnSync(UNSHARE[0], [...UNSHARE.slice(1), "true"]).status === 0;
+const skip = NAMESPACES ? false : "unprivileged user and PID namespaces are unavailable here";
+
+// pm opus, coder and reviewer sonnet, one of each family; cto is listed but
+// runs the system model, which is in no family.
+function edit(doc) {
+ doc.roles.coder.vars.model = "claude-sonnet-5-5";
+ doc.roles.reviewer.vars = { harness: "pi", model: "claude-sonnet-5-5" };
+ doc.launch = { by: "pm", instances: ["coder", "reviewer", "cto"], max: { opus: 1, sonnet: 1 } };
+ return doc;
+}
+
+async function until(fn, ms = 15000) {
+ const end = Date.now() + ms;
+ while (Date.now() < end) {
+ const v = await fn();
+ if (v) return v;
+ await new Promise((r) => setTimeout(r, 50));
+ }
+ throw new Error("timed out waiting");
+}
+
+// A config and data root with the fake adapter; setup() starts a host on it.
+function prepare(t, more = (doc) => doc) {
+ const root = tmp(t, "mosaic-launch-");
+ const f = fixture(root, "acme", (doc) => more(edit(doc)));
+ mkdirSync(f.dataRoot, { mode: 0o700 });
+ const adapter = join(root, "adapter.sh");
+ writeFileSync(adapter, `exec '${process.execPath}' '${FAKE}'\n`, { mode: 0o644 });
+ return { root, f, adapter };
+}
+
+async function setup(t, more = (doc) => doc, { root, f, adapter } = prepare(t, more), options = {}) {
+ const system = loadSystem({ env: f.env });
+ const boot = bootConfig({ system, businessId: "acme", env: f.env });
+ const logs = [];
+ const host = await startHost({ boot, business: "acme", log: (l) => logs.push(l) });
+ let launcher;
+ try {
+ launcher = createLauncher({
+ host,
+ system,
+ env: f.env,
+ adapters: { pi: adapter, "claude-code": adapter },
+ sessionDefaults: { pollInterval: 100 },
+ versions: { pi: "0.85.1", claude: null },
+ log: (l) => logs.push(l),
+ ...options,
+ });
+ await launcher.listen();
+ } catch (e) {
+ await host.close(1);
+ throw e;
+ }
+ let closed = false;
+ const close = async () => {
+ if (closed) return host.done;
+ closed = true;
+ return host.close(0);
+ };
+ t.after(close);
+ // A test caller: a cto launch bound to this process, claimed.
+ const cto = await host.bindLaunch({ business: "acme", role: "cto", run: "cto-test", harness: "claude-code", pid: process.pid, startTime: startTimeOf(process.pid) });
+ const client = new Client({ path: host.path, cap: cto.cap });
+ await client.call("role.claim");
+ return { root, f, host, launcher, cto, client, logs, close };
+}
+
+// One request on the launch socket, one reply line.
+function ask(path, line) {
+ return new Promise((resolve, reject) => {
+ const s = connect(path);
+ let buf = "";
+ s.on("connect", () => s.write(line));
+ s.on("data", (b) => (buf += b));
+ s.on("end", () => resolve(JSON.parse(buf)));
+ s.on("error", reject);
+ });
+}
+
+const log = (f) => readFileSync(launchLogFile(f.dataRoot, "acme"), "utf8").trim().split("\n").map((l) => JSON.parse(l));
+
+test("bus start --pm: the PM runs under its own PID namespace, registered, with a manifest", { skip, timeout: 30000 }, async (t) => {
+ const { f, launcher, host, cto, close } = await setup(t);
+ const sock = launchSocketPath(f.dataRoot);
+ assert.equal(statSync(sock).mode & 0o777, 0o600);
+
+ const pm = await launcher.launchPm();
+ assert.deepEqual({ ...pm, run: undefined }, { run: undefined, instance: "pm", harness: "pi", model: "claude-opus-5-5" });
+ assert.match(pm.run, /^r[0-9a-f]{12}$/);
+
+ const [s] = readSessions(f.dataRoot);
+ assert.equal(s.run, pm.run);
+ assert.equal(s.live, true);
+ assert.equal(s.family, "opus");
+ assert.equal(s.launchedBy, "jason");
+ assert.notEqual(s.pid, s.runnerPid, "the runner is the unshare process's child");
+ assert.equal(statSync(sessionsFile(f.dataRoot)).mode & 0o777, 0o600);
+
+ const runDir = s.runDir;
+ assert.equal(statSync(runDir).mode & 0o777, 0o700);
+ const session = JSON.parse(readFileSync(join(runDir, "session.json"), "utf8"));
+ assert.equal(session.launchedBy, "jason");
+ assert.equal(session.brokerSocket, host.path);
+ assert.equal(session.launchSocket, sock);
+ const manifest = JSON.parse(readFileSync(join(runDir, "bundle", "manifest.json"), "utf8"));
+ assert.equal(manifest.piVersion, "0.85.1");
+ assert.equal(manifest.claudeVersion, null);
+ const [launched] = log(f).filter((e) => e.event === "launch");
+ assert.equal(launched.run, pm.run);
+ assert.equal(launched.manifest, join(runDir, "bundle", "manifest.json"));
+ assert.equal(manifest.model, "claude-opus-5-5");
+
+ // A second PM is refused before anything spawns.
+ await assert.rejects(launcher.launchPm(), /PM launch refused: instance-running/);
+
+ // The session's environment carries the run id and no capability; the
+ // capability is nowhere on disk or on any session process's command line.
+ await until(() => existsSync(join(runDir, "runner.log")) && readSessions(f.dataRoot)[0]?.live);
+ const proc = (pid, what) => {
+ try {
+ return readFileSync(`/proc/${pid}/${what}`, "utf8");
+ } catch {
+ return "";
+ }
+ };
+ const environ = proc(s.runnerPid, "environ").split("\0");
+ assert.ok(environ.includes(`MOSAIC_RUN_ID=${pm.run}`));
+ assert.ok(!environ.some((v) => v.startsWith("MOSAIC_CONFIG=")), "only allowlisted variables pass");
+ const files = (dir) => readdirSync(dir, { recursive: true, withFileTypes: true }).filter((d) => d.isFile()).map((d) => join(d.parentPath, d.name));
+ const capOf = cto.cap; // a known capability shape, to prove the scan finds nothing like it
+ for (const file of files(f.dataRoot)) {
+ const text = readFileSync(file, "latin1");
+ assert.ok(!text.includes(capOf), `${file} holds no capability`);
+ }
+ for (const pid of [s.pid, s.runnerPid]) {
+ assert.notEqual(proc(pid, "cmdline"), "");
+ assert.ok(!/[0-9a-f]{64}/.test(proc(pid, "cmdline")), "no capability in argv");
+ assert.ok(!/[0-9a-f]{64}/.test(proc(pid, "environ")), "no capability in the environment");
+ }
+
+ // Closing the host stops the session and ends the launch.
+ assert.equal(await close(), 0);
+ assert.equal(startTimeOf(s.runnerPid), null);
+ assert.deepEqual(readSessions(f.dataRoot), []);
+ assert.equal(existsSync(sock), false);
+ const end = log(f).find((e) => e.event === "end");
+ assert.equal(end.run, pm.run);
+ assert.equal(end.reason, "stopped");
+ assert.equal(end.exitCode, 0);
+});
+
+test("the PM launches a coder through its launch tool; the coder answers; refusals name their code", { skip, timeout: 30000 }, async (t) => {
+ const { f, launcher, client, cto } = await setup(t);
+ const sock = launchSocketPath(f.dataRoot);
+ const pm = await launcher.launchPm();
+
+ // cto asks pm to launch a coder; pm's turn calls the launch tool.
+ const sent = await client.call("message.send", { to: "pm", class: "REQUEST", body: 'TOOL launch {"instance":"coder"}' });
+ const reply = await until(async () => (await client.call("message.receive")).find((m) => m.in_reply_to === sent.id));
+ assert.equal(reply.from_role, "pm");
+ assert.equal(reply.class, "RESULT");
+ const result = JSON.parse(reply.body);
+ assert.equal(result.instance, "coder");
+ assert.equal(result.model, "claude-sonnet-5-5");
+ const coder = readSessions(f.dataRoot).find((s) => s.instance === "coder");
+ assert.equal(coder.run, result.run);
+ assert.equal(coder.launchedBy, "pm");
+ assert.equal(coder.live, true);
+
+ // The coder claimed its role and answers its own messages.
+ const hello = await client.call("message.send", { to: "coder", class: "REQUEST", body: "hello" });
+ const echo = await until(async () => (await client.call("message.receive")).find((m) => m.in_reply_to === hello.id));
+ assert.equal(echo.from_role, "coder");
+ assert.match(echo.body, /^echo: Message .* from cto, class REQUEST:\n\nhello/);
+
+ // Host refusals, asked with the cto capability (steps 2 to 6 come before
+ // the broker's role.launch check, which cto would fail).
+ const refusals = [
+ ["pm", "instance-not-listed"],
+ ["nobody", "instance-not-listed"],
+ ["coder", "instance-running"],
+ ["reviewer", "capacity-full"],
+ ["cto", "unknown-model-family"],
+ ];
+ for (const [instance, code] of refusals) {
+ assert.deepEqual(await ask(sock, `${JSON.stringify({ cap: cto.cap, instance })}\n`), { ok: false, error: code }, instance);
+ }
+ // A listed, free instance with room still needs role.launch: cto lacks it.
+ // Stop the coder first so sonnet has room again.
+ const coderRun = coder.run;
+ process.kill(coder.runnerPid, "SIGTERM");
+ await until(() => !readSessions(f.dataRoot).some((s) => s.run === coderRun) && log(f).some((e) => e.event === "end" && e.run === coderRun));
+ const denied = await ask(sock, `${JSON.stringify({ cap: cto.cap, instance: "reviewer" })}\n`);
+ assert.equal(denied.ok, false);
+ assert.notEqual(denied.error, "capacity-full");
+
+ // Malformed requests.
+ assert.deepEqual(await ask(sock, "not json\n"), { ok: false, error: "invalid-request" });
+ assert.deepEqual(await ask(sock, `${JSON.stringify({ cap: "short", instance: "coder" })}\n`), { ok: false, error: "unauthenticated" });
+ assert.deepEqual(await ask(sock, `${JSON.stringify({ cap: "0".repeat(64), instance: "coder" })}\n`), { ok: false, error: "unauthenticated" });
+ assert.deepEqual(await ask(sock, `${JSON.stringify({ cap: cto.cap, instance: 7 })}\n`), { ok: false, error: "invalid-request" });
+
+ // The launch log has the launches, every refusal, and the coder's end.
+ const entries = log(f);
+ assert.deepEqual(entries.filter((e) => e.event === "launch").map((e) => [e.instance, e.launchedBy]), [["pm", "jason"], ["coder", "pm"]]);
+ const refused = entries.filter((e) => e.event === "refused").map((e) => [e.instance, e.code]);
+ for (const r of refusals) assert.ok(refused.some(([i, c]) => i === r[0] && c === r[1]), `${r} is logged`);
+ const end = entries.find((e) => e.event === "end" && e.run === coderRun);
+ assert.equal(end.reason, "stopped");
+ assert.ok(readSessions(f.dataRoot).some((s) => s.run === pm.run));
+});
+
+test("a runner that stops at once ends its launch with the runner's reason", { skip, timeout: 30000 }, async (t) => {
+ // pm's tracker token has expired: the runner's founder-credential stop
+ // exits 20 before role.claim, and the host records founder-credentials.
+ const { f, launcher } = await setup(t, (doc) => {
+ doc.roles.pm.credentials.vikunja.expires = "2000-01-01";
+ return doc;
+ });
+ const pm = await launcher.launchPm();
+ const end = await until(() => existsSync(launchLogFile(f.dataRoot, "acme")) && log(f).find((e) => e.event === "end" && e.run === pm.run));
+ assert.equal(end.reason, "founder-credentials");
+ assert.equal(end.exitCode, 20);
+ assert.deepEqual(readSessions(f.dataRoot), []);
+ assert.match(readFileSync(join(f.dataRoot, "launches", "acme", pm.run, "runner.log"), "utf8"), /vikunja/);
+});
+
+for (const exited of [false, true]) {
+ test(`a host that died hard leaves its sessions to the next host, which ${exited ? "finds one already exited (23)" : "kills them"} and ends their runs so the role can be launched again`, { skip, timeout: 30000 }, async (t) => {
+ const given = prepare(t);
+ const { f } = given;
+ const old = spawn(process.execPath, [HOST, given.adapter], { env: f.env, stdio: ["ignore", "pipe", "inherit"] });
+ t.after(() => old.kill("SIGKILL"));
+ const [line] = await once(createInterface({ input: old.stdout }), "line");
+ const first = JSON.parse(line);
+ const [left] = readSessions(f.dataRoot);
+ assert.equal(left.run, first.run);
+ // Killed before the claim, the runner would exit 23 on its own.
+ const runnerLog = join(f.dataRoot, "launches", "acme", first.run, "runner.log");
+ await until(() => existsSync(runnerLog) && readFileSync(runnerLog, "utf8").includes("claimed by run"));
+ assert.equal(left.live, true);
+ old.kill("SIGKILL");
+ await once(old, "exit");
+ // The broker child exits on the lost IPC channel; the session runs on.
+ await until(() => !existsSync(join(f.dataRoot, "bus", "writer.lock")));
+ assert.equal(startTimeOf(left.runnerPid), left.runnerStartTime);
+ if (!exited) {
+ // Stopped, the leftover neither polls the dead broker nor answers
+ // SIGTERM: only the next host's SIGKILL ends it.
+ for (const pid of [left.pid, left.runnerPid]) process.kill(pid, "SIGSTOP");
+ t.after(() => {
+ for (const [pid, start] of [[left.pid, left.startTime], [left.runnerPid, left.runnerStartTime]]) {
+ if (startTimeOf(pid) === start) process.kill(pid, "SIGKILL");
+ }
+ });
+ }
+ if (exited) {
+ await until(() => startTimeOf(left.pid) === null);
+ assert.match(readFileSync(runnerLog, "utf8"), /broker unreachable after 30 tries[\s\S]*exiting 23/);
+ assert.equal(readSessions(f.dataRoot)[0].live, false);
+ }
+
+ const s = await setup(t, undefined, given);
+ assert.equal(startTimeOf(left.pid), null, "the old session process is gone");
+ assert.equal(startTimeOf(left.runnerPid), null, "and its runner with it");
+ assert.ok(s.logs.includes(`run ${first.run} (pm) from an earlier host ended: host-lost`), s.logs.join("\n"));
+ const ended = (await s.client.call("trail", { subject: first.run })).filter((r) => r.kind === "session.ended");
+ assert.equal(ended.length, 1);
+ assert.deepEqual(ended[0].body, { reason: "host-lost", exitCode: null, released: true });
+ const log = readFileSync(launchLogFile(f.dataRoot, "acme"), "utf8").trim().split("\n").map((l) => JSON.parse(l));
+ assert.deepEqual(log.filter((e) => e.event === "end").map((e) => [e.run, e.reason]), [[first.run, "host-lost"]]);
+ assert.deepEqual(readSessions(f.dataRoot), []);
+
+ // Without the end, the old run would still hold pm and this runner would exit 21.
+ const again = await s.launcher.launchPm();
+ await until(async () => (await s.client.call("agents")).some((a) => a.role === "pm" && a.holder_run === again.run));
+ await s.client.call("role.release"); // the test's own cto claim, so the next host's can claim again
+ assert.equal(await s.close(), 0);
+
+ // Died after the end but before sessions.json changed: the run stays ended.
+ const { live, ...entry } = left;
+ new Registry(f.dataRoot).add(entry);
+ const third = await setup(t, undefined, given);
+ assert.ok(third.logs.includes(`run ${first.run} (pm) from an earlier host was already ended`), third.logs.join("\n"));
+ assert.equal((await third.client.call("trail", { subject: first.run })).filter((r) => r.kind === "session.ended").length, 1);
+ assert.equal(await third.close(), 0);
+ });
+}
+
+test("a malformed sessions.json refuses the host start with exit 3 and stays as it was", { timeout: 30000 }, async (t) => {
+ const given = prepare(t);
+ const file = sessionsFile(given.f.dataRoot);
+ mkdirSync(join(given.f.dataRoot, "bus-host"), { mode: 0o700 });
+ writeFileSync(file, "{", { mode: 0o600 });
+ await assert.rejects(setup(t, undefined, given), (e) => e.exitCode === 3 && /sessions file is unreadable.*move the file aside/.test(e.message));
+ assert.equal(readFileSync(file, "utf8"), "{");
+ assert.equal(existsSync(join(given.f.dataRoot, "bus", "writer.lock")), false, "the host closed");
+});
+
+const within = (p, ms, what) => Promise.race([p, new Promise((_, reject) => setTimeout(() => reject(new Error(`${what} took over ${ms} ms`)), ms).unref())]);
+
+test("an over-long launch request is refused at once, not at the 10 s idle timeout", { timeout: 30000 }, async (t) => {
+ const { f } = await setup(t);
+ const s = connect(launchSocketPath(f.dataRoot));
+ t.after(() => s.destroy());
+ let buf = "";
+ s.on("data", (b) => (buf += b));
+ s.write("x".repeat(5000));
+ await within(once(s, "end"), 3000, "the refusal");
+ assert.deepEqual(JSON.parse(buf), { ok: false, error: "invalid-request" });
+});
+
+test("a launch client that never closes its side doesn't hold the host's close", { timeout: 30000 }, async (t) => {
+ const { f, close } = await setup(t);
+ const path = launchSocketPath(f.dataRoot);
+ // One got its reply and never ends; one never sends anything.
+ const answered = connect({ path, allowHalfOpen: true });
+ const silent = connect({ path, allowHalfOpen: true });
+ const connected = once(silent, "connect");
+ const gone = Promise.all([once(answered, "close"), once(silent, "close")]);
+ const drop = () => {
+ answered.destroy();
+ silent.destroy();
+ };
+ t.after(drop);
+ let buf = "";
+ answered.on("data", (b) => (buf += b));
+ answered.write("not json\n");
+ await once(answered, "end");
+ assert.deepEqual(JSON.parse(buf), { ok: false, error: "invalid-request" });
+ await within(connected, 2000, "connect");
+ // On a failure the clients go, so the host can still close and the test
+ // fails instead of hanging.
+ const code = await within(close(), 5000, "close").catch((e) => {
+ drop();
+ throw e;
+ });
+ assert.equal(code, 0);
+ assert.equal(existsSync(path), false);
+ answered.end();
+ silent.end();
+ await within(gone, 2000, "the clients' close");
+});
+
+test("a runner that ignores SIGTERM is killed when the host closes", { skip, timeout: 60000 }, async (t) => {
+ const { f, launcher, close } = await setup(t, undefined, undefined, { stopTimeoutMs: 1000 });
+ const pm = await launcher.launchPm();
+ const runnerLog = join(f.dataRoot, "launches", "acme", pm.run, "runner.log");
+ await until(() => existsSync(runnerLog) && readFileSync(runnerLog, "utf8").includes("claimed by run"));
+ const [s] = readSessions(f.dataRoot);
+ // Stopped from outside its namespace, it can't act on SIGTERM.
+ process.kill(s.runnerPid, "SIGSTOP");
+ const kill = () => {
+ if (startTimeOf(s.runnerPid) === s.runnerStartTime) process.kill(s.runnerPid, "SIGKILL");
+ };
+ t.after(kill);
+ const started = Date.now();
+ const code = await within(close(), 15000, "close").catch((e) => {
+ kill();
+ throw e;
+ });
+ assert.equal(code, 0);
+ assert.ok(Date.now() - started >= 1000, "it waited for the stop timeout");
+ assert.equal(startTimeOf(s.pid), null);
+ assert.equal(startTimeOf(s.runnerPid), null);
+ assert.deepEqual(readSessions(f.dataRoot), []);
+ const ends = log(f).filter((e) => e.event === "end" && e.run === pm.run);
+ assert.equal(ends.length, 1);
+ assert.equal(ends[0].signal, "SIGKILL");
+});
diff --git a/packages/cli/tests/verbs.test.mjs b/packages/cli/tests/verbs.test.mjs
new file mode 100644
index 00000000..41432793
--- /dev/null
+++ b/packages/cli/tests/verbs.test.mjs
@@ -0,0 +1,132 @@
+// The S6 human verbs: talk, stop and launches. The human transport is the
+// in-process broker from helpers.mjs, because the real one refuses to run
+// under an agent (src/transport.mjs). A real stop of a real runner is in
+// packages/seat/tests/session.test.mjs and tests/launcher.test.mjs.
+
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { main } from "../src/cli.mjs";
+import { CliError } from "../src/errors.mjs";
+import { startTimeOf } from "../src/host.mjs";
+import { Registry } from "../../seat/src/session.mjs";
+import { broker, io, tmp } from "./helpers.mjs";
+
+function setup(t, deps = {}) {
+ const bus = broker(t);
+ const dataRoot = tmp(t);
+ const run = async (argv, x = io(), more = {}) => {
+ try {
+ await main(argv, x, { system: { dataRoot }, transport: bus.transport, talkPollMs: 20, ...deps, ...more });
+ return { code: 0, io: x };
+ } catch (e) {
+ if (!(e instanceof CliError)) throw e;
+ return { code: e.exitCode, error: e.message, io: x };
+ }
+ };
+ return { ...bus, dataRoot, run };
+}
+
+const call = (b, cap, verb, args = {}) => b.request(cap, { verb, args });
+
+test("talk sends a REQUEST, prints and reads everything that arrives, and stops at the reply", async (t) => {
+ const s = setup(t);
+ const cto = s.b.bindLaunch({ business: "demo", role: "cto", run: "cto-run", harness: "pi" });
+ call(s.b, cto, "role.claim");
+ // The coder answers on the second poll; a cto note arrives first and is printed too.
+ let polls = 0;
+ const transport = (business) => {
+ const inner = s.transport(business);
+ return async (verb, args) => {
+ if (verb === "message.receive" && ++polls === 2) {
+ const [m] = call(s.b, s.coder, "message.receive");
+ call(s.b, cto, "message.send", { to: m.from_role, body: "fyi", class: "INFO" });
+ call(s.b, s.coder, "message.send", { to: m.from_role, body: "done: 3 files", class: "RESULT", in_reply_to: m.id });
+ }
+ return inner(verb, args);
+ };
+ };
+ const r = await s.run(["talk", "coder", "fix the build", "--business", "demo"], io(), { transport });
+ assert.equal(r.code, 0, r.error);
+ const sent = s.calls.find((c) => c.verb === "message.send");
+ assert.deepEqual(sent.args, { to: "coder", body: "fix the build", class: "REQUEST" });
+ assert.match(r.io.out.text, /^sent [0-9a-f]{8} to coder\n/);
+ assert.match(r.io.out.text, /cto \(INFO\) [0-9a-f]{8}:\nfyi\n/);
+ assert.match(r.io.out.text, /coder \(RESULT, re [0-9a-f]{8}\) [0-9a-f]{8}:\ndone: 3 files\n$/);
+ assert.equal(s.calls.filter((c) => c.verb === "message.read").length, 2);
+ assert.deepEqual(await s.transport("demo")("message.receive"), [], "both were marked read");
+});
+
+test("talk --wait 0 only sends; no reply within --wait exits 1 and says where it will show", async (t) => {
+ const s = setup(t);
+ const quick = await s.run(["talk", "coder", "hello", "--wait", "0", "--business", "demo"]);
+ assert.equal(quick.code, 0);
+ assert.deepEqual(s.calls.map((c) => c.verb), ["message.send"]);
+ const slow = await s.run(["talk", "coder", "hello", "--wait", "1", "--business", "demo"]);
+ assert.equal(slow.code, 1);
+ assert.match(slow.error, /no reply from coder within 1 s; .*mosaic inbox/);
+});
+
+test("talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors", async (t) => {
+ const s = setup(t);
+ const agent = () => io({ ...io().env, CLAUDECODE: "1" });
+ for (const argv of [["talk", "coder", "hi", "--business", "demo"], ["stop", "r0123456789ab"], ["launches", "off", "--business", "demo"], ["launches", "on", "--business", "demo"]]) {
+ assert.equal((await s.run(argv, agent())).code, 3, argv.join(" "));
+ }
+ const usage = [
+ ["talk", "coder", "--business", "demo"],
+ ["talk", "coder", "a", "b", "--business", "demo"],
+ ["talk", "coder", "hi", "--wait", "-1", "--business", "demo"],
+ ["talk", "coder", "hi", "--wait", "1.5", "--business", "demo"],
+ ["stop"],
+ ["stop", "a", "b"],
+ ["stop", "a", "--business", "demo"],
+ ["launches"],
+ ["launches", "maybe"],
+ ["launches", "off", "x", "--business", "demo"],
+ ["launches", "list", "x"],
+ ];
+ for (const argv of usage) assert.equal((await s.run(argv)).code, 4, argv.join(" "));
+ assert.equal(s.calls.length, 0);
+});
+
+test("launches off and on go to the broker and change the business's launch state", async (t) => {
+ const s = setup(t);
+ const state = () => s.store.get("SELECT state FROM launch_state WHERE business=?", "demo")?.state;
+ const off = await s.run(["launches", "off", "--business", "demo"]);
+ assert.equal(off.code, 0, off.error);
+ assert.match(off.io.out.text, /^launches off for demo: .*running sessions keep running\n$/);
+ assert.equal(state(), "revoked");
+ const on = await s.run(["launches", "on", "--business", "demo"]);
+ assert.equal(on.io.out.text, "launches on for demo\n");
+ assert.notEqual(state(), "revoked");
+ assert.deepEqual(s.calls.map((c) => c.verb), ["launch.revoke", "launch.restore"]);
+});
+
+test("launches list reads sessions.json and marks a stale entry; stop reports it and refuses a non-runner", async (t) => {
+ const s = setup(t);
+ assert.equal((await s.run(["launches", "list"])).io.out.text, "no sessions\n");
+ const r = new Registry(s.dataRoot);
+ const base = { business: "demo", harness: "pi", model: "claude-sonnet-5-5", launchedBy: "pm", startedAt: "2026-10-09T00:00:00.000Z", runnerPid: process.pid };
+ r.add({ ...base, instance: "coder", run: "r000000000001", runnerStartTime: startTimeOf(process.pid) });
+ r.add({ ...base, instance: "reviewer", run: "r000000000002", runnerStartTime: "1" });
+ const list = await s.run(["launches", "list"]);
+ assert.equal(
+ list.io.out.text,
+ "r000000000001 demo/coder pi claude-sonnet-5-5 by pm since 2026-10-09T00:00:00.000Z running\n" +
+ "r000000000002 demo/reviewer pi claude-sonnet-5-5 by pm since 2026-10-09T00:00:00.000Z not running (stale)\n",
+ );
+ const json = JSON.parse((await s.run(["launches", "list", "--json"])).io.out.text);
+ assert.deepEqual(json.map((e) => [e.run, e.live]), [["r000000000001", true], ["r000000000002", false]]);
+
+ const stale = await s.run(["stop", "r000000000002"]);
+ assert.equal(stale.code, 0);
+ assert.equal(stale.io.out.text, "run r000000000002 is not running (stale entry)\n");
+ const unknown = await s.run(["stop", "nope"]);
+ assert.equal(unknown.code, 2);
+ assert.match(unknown.error, /no running session has run id nope/);
+ // This test process is live with the recorded start time but is not a runner.
+ const notRunner = await s.run(["stop", "r000000000001"]);
+ assert.equal(notRunner.code, 2);
+ assert.match(notRunner.error, /is not a session runner; refusing to signal it/);
+ assert.equal(s.calls.length, 0, "stop and list never touch the bus");
+});
diff --git a/packages/harness/README.md b/packages/harness/README.md
new file mode 100644
index 00000000..7598a573
--- /dev/null
+++ b/packages/harness/README.md
@@ -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.
diff --git a/packages/harness/package.json b/packages/harness/package.json
new file mode 100644
index 00000000..081a76a3
--- /dev/null
+++ b/packages/harness/package.json
@@ -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" }
+}
diff --git a/packages/harness/src/bundle.mjs b/packages/harness/src/bundle.mjs
new file mode 100644
index 00000000..2f16b1c8
--- /dev/null
+++ b/packages/harness/src/bundle.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 };
+}
diff --git a/packages/harness/src/claude-gate.mjs b/packages/harness/src/claude-gate.mjs
new file mode 100644
index 00000000..ecada305
--- /dev/null
+++ b/packages/harness/src/claude-gate.mjs
@@ -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}`);
+}
diff --git a/packages/harness/src/gate.mjs b/packages/harness/src/gate.mjs
new file mode 100644
index 00000000..e68e8f92
--- /dev/null
+++ b/packages/harness/src/gate.mjs
@@ -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 };
+}
diff --git a/packages/harness/src/mcp-server.mjs b/packages/harness/src/mcp-server.mjs
new file mode 100644
index 00000000..0f43948e
--- /dev/null
+++ b/packages/harness/src/mcp-server.mjs
@@ -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));
+});
diff --git a/packages/harness/src/pi-extension.mjs b/packages/harness/src/pi-extension.mjs
new file mode 100644
index 00000000..2b3b0044
--- /dev/null
+++ b/packages/harness/src/pi-extension.mjs
@@ -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: {} };
+ },
+ });
+ }
+}
diff --git a/packages/harness/src/runner.mjs b/packages/harness/src/runner.mjs
new file mode 100644
index 00000000..ccf6ca43
--- /dev/null
+++ b/packages/harness/src/runner.mjs
@@ -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();
+}
diff --git a/packages/harness/src/tools.mjs b/packages/harness/src/tools.mjs
new file mode 100644
index 00000000..903f3bef
--- /dev/null
+++ b/packages/harness/src/tools.mjs
@@ -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"));
+ }
+ });
+ });
+}
diff --git a/packages/harness/tests/bundle.test.mjs b/packages/harness/tests/bundle.test.mjs
new file mode 100644
index 00000000..23deabf2
--- /dev/null
+++ b/packages/harness/tests/bundle.test.mjs
@@ -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));
+});
diff --git a/packages/harness/tests/claude-gate.test.mjs b/packages/harness/tests/claude-gate.test.mjs
new file mode 100644
index 00000000..b5d24e17
--- /dev/null
+++ b/packages/harness/tests/claude-gate.test.mjs
@@ -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);
+});
diff --git a/packages/harness/tests/claude-session.test.mjs b/packages/harness/tests/claude-session.test.mjs
new file mode 100644
index 00000000..e45c5d69
--- /dev/null
+++ b/packages/harness/tests/claude-session.test.mjs
@@ -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);
+});
diff --git a/packages/harness/tests/fixtures/fake-adapter.mjs b/packages/harness/tests/fixtures/fake-adapter.mjs
new file mode 100644
index 00000000..4024172d
--- /dev/null
+++ b/packages/harness/tests/fixtures/fake-adapter.mjs
@@ -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();
+}
diff --git a/packages/harness/tests/gate.test.mjs b/packages/harness/tests/gate.test.mjs
new file mode 100644
index 00000000..85862fcc
--- /dev/null
+++ b/packages/harness/tests/gate.test.mjs
@@ -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/);
+});
diff --git a/packages/harness/tests/helpers.mjs b/packages/harness/tests/helpers.mjs
new file mode 100644
index 00000000..96c1923f
--- /dev/null
+++ b/packages/harness/tests/helpers.mjs
@@ -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 };
+}
diff --git a/packages/harness/tests/mcp-server.test.mjs b/packages/harness/tests/mcp-server.test.mjs
new file mode 100644
index 00000000..72992dde
--- /dev/null
+++ b/packages/harness/tests/mcp-server.test.mjs
@@ -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);
+});
diff --git a/packages/harness/tests/pi-session.test.mjs b/packages/harness/tests/pi-session.test.mjs
new file mode 100644
index 00000000..32db2aa3
--- /dev/null
+++ b/packages/harness/tests/pi-session.test.mjs
@@ -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);
+});
diff --git a/packages/harness/tests/runner.test.mjs b/packages/harness/tests/runner.test.mjs
new file mode 100644
index 00000000..25b4d8a9
--- /dev/null
+++ b/packages/harness/tests/runner.test.mjs
@@ -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);
+});
diff --git a/packages/harness/tests/tools.test.mjs b/packages/harness/tests/tools.test.mjs
new file mode 100644
index 00000000..b520dd4c
--- /dev/null
+++ b/packages/harness/tests/tools.test.mjs
@@ -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"]);
+});
diff --git a/packages/seat/README.md b/packages/seat/README.md
index f0701708..a9af6517 100644
--- a/packages/seat/README.md
+++ b/packages/seat/README.md
@@ -150,6 +150,47 @@ Fleet seats under `~/.mosaic` stay on their own launchers (Jason's ruling,
2026-09-12); a fleet seat can still be registered by hand with
`scripts/mosaic launch <seat-dir>`.
+## Managed role sessions (slice 1 S6, #1523)
+
+Separate from seats: `src/session.mjs` holds what the bus host's launcher
+(`packages/cli/src/launcher.mjs`) uses to start and stop a managed role
+session, and `src/proc.mjs` the `/proc` reads (start time, command line,
+children) that the host, the launcher and `mosaic stop` share. Nothing here
+decides authority or capacity; `packages/cli/README.md` ("Sessions") has the
+launch flow and the on-disk layout.
+
+- `newRun` picks a run id (`r` and 12 hex digits) and makes its 0700
+ directory, refusing a data root too deep for the run's socket path.
+- `sessionEnv` builds the session's environment from an allowlist
+ (`ENV_ALLOW`), the repository's `node_modules/.bin` on `PATH`, and
+ `MOSAIC_RUN_ID`. Founder credential variables never pass.
+- `spawnSession` starts `packages/harness/src/runner.mjs` under
+ `unshare --user --map-current-user --pid --fork --kill-child --mount-proc`,
+ detached, with stdin open for the capability line and output to
+ `runner.log` (0600). `runnerOf` finds the runner inside it.
+- `Registry` is the host's list of running sessions, mirrored to
+ `bus-host/sessions.json` (0600); `readSessions` reads it and marks each
+ entry live or stale by the runner's start time, and refuses a file it
+ can't read.
+- `stopSession` is `mosaic stop`: SIGTERM to a live runner, resent every
+ second until it exits.
+
+Limits of the PID namespace, which are limits and not a sandbox:
+
+- The runner is pid 1 of its namespace. A detached child is reparented to
+ it, not to the user's init, so it stays under the launch's pid. Pid 1
+ ignores a signal it has no handler for; the runner installs its handlers
+ first, and the senders resend SIGTERM to cover Node's startup.
+- The namespace isolates pids only. With its own `/proc`, a session sees
+ no process outside it, so it can't signal or `ptrace` another session or
+ the host by pid (checked on this host, Yama `ptrace_scope` 1). It still
+ runs as the host's user, with that user's files, `/tmp`, network and
+ sockets.
+- A session can still ask a process outside its namespace (a systemd user
+ manager, an existing tmux server) to run a command for it.
+- The runner doesn't reap zombies other than its own turns; they go when
+ the namespace does.
+
## Exit codes
`launch` exits with the launch script's own code once the script runs.
diff --git a/packages/seat/src/proc.mjs b/packages/seat/src/proc.mjs
new file mode 100644
index 00000000..6588ed16
--- /dev/null
+++ b/packages/seat/src/proc.mjs
@@ -0,0 +1,33 @@
+// What /proc says about one process. Same-UID process identity: a pid plus
+// its start time names one process, so a recycled pid never matches.
+
+import { readFileSync } from "node:fs";
+
+// `/proc/<pid>/stat` field 22, the start time in clock ticks. Null when the
+// process is gone or has exited and waits to be reaped (state Z).
+export function startTimeOf(pid) {
+ try {
+ const raw = readFileSync(`/proc/${pid}/stat`, "utf8");
+ const fields = raw.slice(raw.lastIndexOf(")") + 2).split(" ");
+ return fields[0] === "Z" ? null : fields[19];
+ } catch {
+ return null;
+ }
+}
+
+export function cmdlineOf(pid) {
+ try {
+ return readFileSync(`/proc/${pid}/cmdline`, "utf8").split("\0").filter(Boolean);
+ } catch {
+ return null;
+ }
+}
+
+// The pids of pid's direct children, or [] when it has none or is gone.
+export function childrenOf(pid) {
+ try {
+ return readFileSync(`/proc/${pid}/task/${pid}/children`, "utf8").trim().split(/\s+/).filter(Boolean).map(Number);
+ } catch {
+ return [];
+ }
+}
diff --git a/packages/seat/src/session.mjs b/packages/seat/src/session.mjs
new file mode 100644
index 00000000..527e3fdc
--- /dev/null
+++ b/packages/seat/src/session.mjs
@@ -0,0 +1,221 @@
+// Managed role sessions (slice 1 S6): where a launch lives on disk, how its
+// runner starts, and how the human stops it. The bus host
+// (packages/cli/src/launcher.mjs) is the only caller that starts one; it
+// decides authority and capacity first, and this module only does what it
+// is told.
+//
+// A session is `node packages/harness/src/runner.mjs <run dir>` inside
+// `unshare --user --map-current-user --pid --fork --kill-child --mount-proc`.
+// The runner is pid 1 of its own PID namespace, so a child that detaches
+// (`setsid -f`) is reparented to the runner and stays under the launch's
+// pid, which the human CLI's ancestry check refuses. unshare itself ignores
+// SIGTERM and passes the runner's exit status on; `stop` signals the runner.
+//
+// On disk, under the data root:
+// launches/<business>/<run>/ the run directory (0700): session.json,
+// bundle/, session/, turns/, runner.log,
+// tools.sock, turn.done
+// launches/<business>.jsonl the launch log (0600, append-only):
+// every launch, refusal and end
+// workspaces/<business>/<instance>/ the instance's workspace (0700), kept
+// across launches
+// bus-host/sessions.json the running sessions (0600), written
+// only by the host; `mosaic stop` reads it
+
+import { spawn } from "node:child_process";
+import { randomBytes } from "node:crypto";
+import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync } from "node:fs";
+import { dirname, join, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import { childrenOf, cmdlineOf, startTimeOf } from "./proc.mjs";
+import { SeatError } from "./seat.mjs";
+
+export const REPO = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
+export const RUNNER = join(REPO, "packages", "harness", "src", "runner.mjs");
+export const ADAPTERS = Object.freeze({
+ pi: join(REPO, "adapters", "pi", "adapter.sh"),
+ "claude-code": join(REPO, "adapters", "claude", "adapter.sh"),
+});
+export const UNSHARE = Object.freeze(["unshare", "--user", "--map-current-user", "--pid", "--fork", "--kill-child", "--mount-proc"]);
+export const SESSIONS_VERSION = 1;
+
+export const sessionsFile = (dataRoot) => join(dataRoot, "bus-host", "sessions.json");
+export const launchSocketPath = (dataRoot) => join(dataRoot, "bus-host", "launch.sock");
+export const runsDir = (dataRoot, business) => join(dataRoot, "launches", business);
+export const launchLogFile = (dataRoot, business) => join(dataRoot, "launches", `${business}.jsonl`);
+export const workspaceDir = (dataRoot, business, instance) => join(dataRoot, "workspaces", business, instance);
+
+// The capacity family of a model: the one key of the business file's
+// launch.max that the model name contains (`claude-opus-5-5` is `opus`).
+// None, or more than one, is null, and the launch refuses.
+export function family(model, max) {
+ const hits = Object.keys(max).filter((f) => model.toLowerCase().includes(f.toLowerCase()));
+ return hits.length === 1 ? hits[0] : null;
+}
+
+// The session's environment is an allowlist: nothing else from the host's
+// environment passes, so a founder credential variable (GITEA_TOKEN,
+// GH_TOKEN, SSH_AUTH_SOCK, ...) never reaches a session (REQ-CRED-2).
+// Provider variables pass because the harness needs them to reach a model.
+export const ENV_ALLOW = Object.freeze([
+ "HOME", "USER", "LOGNAME", "LANG", "LC_ALL", "TZ", "TERM",
+ "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL", "CLAUDE_CODE_OAUTH_TOKEN",
+ "OPENAI_API_KEY", "OPENROUTER_API_KEY",
+]);
+
+export function sessionEnv(env, run) {
+ const out = {};
+ for (const k of ENV_ALLOW) if (env[k] !== undefined) out[k] = env[k];
+ out.PATH = [join(REPO, "node_modules", ".bin"), env.PATH ?? "/usr/bin:/bin"].join(":");
+ out.MOSAIC_RUN_ID = run;
+ return out;
+}
+
+// A new run id and its directory. Ids are short because the run directory
+// holds a Unix socket (tools.sock), and socket paths stop at 107 bytes.
+export function newRun(dataRoot, business) {
+ const root = runsDir(dataRoot, business);
+ mkdirSync(root, { recursive: true, mode: 0o700 });
+ for (let i = 0; i < 5; i++) {
+ const run = `r${randomBytes(6).toString("hex")}`;
+ const runDir = join(root, run);
+ try {
+ mkdirSync(runDir, { mode: 0o700 });
+ } catch (e) {
+ if (e.code === "EEXIST") continue;
+ throw e;
+ }
+ if (Buffer.byteLength(join(runDir, "tools.sock")) > 107) throw new SeatError(`data root path too long for a socket: ${runDir}`, 2);
+ return { run, runDir };
+ }
+ throw new SeatError("could not pick a free run id", 1);
+}
+
+export function writeSessionFile(runDir, session) {
+ writeFileSync(join(runDir, "session.json"), `${JSON.stringify(session, null, 2)}\n`, { mode: 0o600, flag: "wx" });
+}
+
+export function appendLaunchLog(dataRoot, business, entry) {
+ const file = launchLogFile(dataRoot, business);
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
+ appendFileSync(file, `${JSON.stringify({ at: new Date().toISOString(), ...entry })}\n`, { mode: 0o600 });
+}
+
+// Start the runner. stdin stays open for the capability line the host writes
+// once the broker has bound the launch; stdout and stderr go to runner.log.
+// `namespace: false` exists for hosts without unprivileged user namespaces
+// in the tests only; the host always passes true.
+export function spawnSession({ runDir, env, namespace = true, node = process.execPath }) {
+ const out = openSync(join(runDir, "runner.log"), "a", 0o600);
+ const argv = [...(namespace ? UNSHARE : []), node, RUNNER, runDir];
+ try {
+ return spawn(argv[0], argv.slice(1), { cwd: runDir, env, stdio: ["pipe", out, out], detached: true });
+ } finally {
+ closeSync(out);
+ }
+}
+
+// A runner's command line is `<node> <RUNNER> <run dir>`. Matching argv[1]
+// rather than any argument skips unshare's forked child before its exec,
+// whose argv still names the runner.
+export const isRunner = (pid) => (cmdlineOf(pid) ?? [])[1] === RUNNER;
+
+// The runner's pid and start time as seen from here. Under unshare the
+// runner is the outer process's only child; without a namespace it is the
+// process itself.
+export async function runnerOf(child, { namespace = true, timeoutMs = 5000 } = {}) {
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ if (child.exitCode !== null || child.signalCode !== null) break;
+ const pid = namespace ? childrenOf(child.pid)[0] : child.pid;
+ const start = pid ? startTimeOf(pid) : null;
+ if (start && isRunner(pid)) return { pid, startTime: start };
+ await new Promise((r) => setTimeout(r, 20));
+ }
+ throw new SeatError(`runner for ${child.pid} did not start`, 1);
+}
+
+// The launch end reason the broker records for a runner's exit
+// (packages/harness/README.md, "The runner").
+const REASONS = { 0: "stopped", 2: "failed", 20: "founder-credentials", 21: "claim-refused", 22: "session-invalid", 23: "broker-unreachable" };
+export function endReason(code, signal) {
+ if (signal) return "killed";
+ return REASONS[code] ?? "failed";
+}
+
+// The host's view of its running sessions, kept in memory and mirrored to
+// sessions.json for `mosaic stop` and `mosaic launches list`.
+export class Registry {
+ #dataRoot;
+ #sessions = [];
+ constructor(dataRoot) {
+ this.#dataRoot = dataRoot;
+ this.#write();
+ }
+ get sessions() {
+ return this.#sessions.map((s) => ({ ...s }));
+ }
+ add(entry) {
+ this.#sessions.push({ ...entry });
+ this.#write();
+ }
+ remove(business, run) {
+ this.#sessions = this.#sessions.filter((s) => !(s.business === business && s.run === run));
+ this.#write();
+ }
+ count(business, fam) {
+ return this.#sessions.filter((s) => s.business === business && s.family === fam).length;
+ }
+ running(business, instance) {
+ return this.#sessions.find((s) => s.business === business && s.instance === instance) ?? null;
+ }
+ #write() {
+ const file = sessionsFile(this.#dataRoot);
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
+ const tmp = `${file}.${process.pid}.tmp`;
+ writeFileSync(tmp, `${JSON.stringify({ sessionsVersion: SESSIONS_VERSION, sessions: this.#sessions }, null, 2)}\n`, { mode: 0o600 });
+ renameSync(tmp, file);
+ }
+}
+
+// sessions.json as `mosaic stop` sees it. `live` is true only when the
+// runner still runs with the recorded start time.
+export function readSessions(dataRoot) {
+ const file = sessionsFile(dataRoot);
+ if (!existsSync(file)) return [];
+ let doc;
+ try {
+ doc = JSON.parse(readFileSync(file, "utf8"));
+ } catch {
+ throw new SeatError(`sessions file is unreadable: ${file}`, 2);
+ }
+ if (doc?.sessionsVersion !== SESSIONS_VERSION || !Array.isArray(doc.sessions)) throw new SeatError(`sessions file is malformed: ${file}`, 2);
+ return doc.sessions.map((s) => ({ ...s, live: startTimeOf(s.runnerPid) === s.runnerStartTime }));
+}
+
+// `mosaic stop <run>`: SIGTERM to the run's runner, after checking that the
+// pid still has the recorded start time and runs the runner. Same-UID
+// process control, not an authority check. The runner releases its role
+// and the host records the end.
+export async function stopSession(dataRoot, run, { timeoutMs = 30000 } = {}) {
+ const hits = readSessions(dataRoot).filter((s) => s.run === run);
+ if (hits.length === 0) throw new SeatError(`no running session has run id ${run}; see mosaic launches list`, 2);
+ const s = hits[0];
+ if (!s.live) return { stopped: false, reason: `run ${run} is not running (stale entry)` };
+ if (!isRunner(s.runnerPid)) throw new SeatError(`pid ${s.runnerPid} is not a session runner; refusing to signal it`, 2);
+ // Sent again every second: a runner that is still starting has no handler
+ // yet, and as pid 1 of its namespace it never sees a signal sent before then.
+ const deadline = Date.now() + timeoutMs;
+ let next = 0;
+ while (Date.now() < deadline) {
+ if (startTimeOf(s.runnerPid) !== s.runnerStartTime) return { stopped: true, business: s.business, instance: s.instance, run };
+ if (Date.now() >= next) {
+ try {
+ process.kill(s.runnerPid, "SIGTERM");
+ } catch {}
+ next = Date.now() + 1000;
+ }
+ await new Promise((r) => setTimeout(r, 100));
+ }
+ throw new SeatError(`run ${run} did not stop within ${Math.round(timeoutMs / 1000)} s`, 1);
+}
diff --git a/packages/seat/tests/session.test.mjs b/packages/seat/tests/session.test.mjs
new file mode 100644
index 00000000..f0606257
--- /dev/null
+++ b/packages/seat/tests/session.test.mjs
@@ -0,0 +1,214 @@
+// Managed role sessions on disk and as processes (src/session.mjs). The
+// runner is the real one, under the real unshare, against an in-process
+// broker on a real socket; the harness turn is packages/harness's fake
+// adapter. The launcher's checks are tested in packages/cli.
+
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { spawnSync } from "node:child_process";
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { Broker } from "../../bus/src/broker.mjs";
+import { serve } from "../../bus/src/server.mjs";
+import { Store } from "../../bus/src/store.mjs";
+import { cmdlineOf, startTimeOf } from "../src/proc.mjs";
+import { SeatError } from "../src/seat.mjs";
+import {
+ appendLaunchLog,
+ endReason,
+ ENV_ALLOW,
+ family,
+ isRunner,
+ launchLogFile,
+ newRun,
+ readSessions,
+ Registry,
+ runnerOf,
+ sessionEnv,
+ sessionsFile,
+ spawnSession,
+ stopSession,
+ UNSHARE,
+ writeSessionFile,
+} from "../src/session.mjs";
+
+const FAKE = new URL("../../harness/tests/fixtures/fake-adapter.mjs", import.meta.url).pathname;
+const NAMESPACES = spawnSync(UNSHARE[0], [...UNSHARE.slice(1), "true"]).status === 0;
+const skip = NAMESPACES ? false : "unprivileged user and PID namespaces are unavailable here";
+
+function scratch(t) {
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), "mosaic-session-")));
+ t.after(() => rmSync(dir, { recursive: true, force: true }));
+ return dir;
+}
+
+const mode = (p) => statSync(p).mode & 0o777;
+
+test("family: exactly one launch.max key in the model name, else null", () => {
+ const max = { opus: 2, sonnet: 4 };
+ assert.equal(family("claude-opus-5-5", max), "opus");
+ assert.equal(family("Claude-Sonnet-5-5", max), "sonnet");
+ assert.equal(family("glm-5.3-flash", max), null);
+ assert.equal(family("opus-sonnet-mix", max), null);
+});
+
+test("sessionEnv passes only the allowlist, the repo's bin on PATH, and the run id", () => {
+ const env = sessionEnv({ HOME: "/h", ANTHROPIC_API_KEY: "k", GITEA_TOKEN: "t", GH_TOKEN: "t", SSH_AUTH_SOCK: "/s", MOSAIC_CONFIG: "/c", PATH: "/usr/bin" }, "r0123456789ab");
+ assert.deepEqual(Object.keys(env).sort(), ["ANTHROPIC_API_KEY", "HOME", "MOSAIC_RUN_ID", "PATH"]);
+ assert.match(env.PATH, /node_modules\/\.bin:\/usr\/bin$/);
+ assert.equal(env.MOSAIC_RUN_ID, "r0123456789ab");
+ for (const k of ["GITEA_TOKEN", "GH_TOKEN", "SSH_AUTH_SOCK"]) assert.ok(!ENV_ALLOW.includes(k));
+});
+
+test("newRun: short ids, 0700 directories, and a refusal when the socket path won't fit", (t) => {
+ const root = scratch(t);
+ const a = newRun(join(root, "data"), "acme");
+ const b = newRun(join(root, "data"), "acme");
+ assert.match(a.run, /^r[0-9a-f]{12}$/);
+ assert.notEqual(a.run, b.run);
+ assert.equal(mode(a.runDir), 0o700);
+ assert.equal(mode(join(root, "data", "launches", "acme")), 0o700);
+ const deep = join(root, "x".repeat(100));
+ assert.throws(() => newRun(deep, "acme"), (e) => e instanceof SeatError && /too long for a socket/.test(e.message));
+});
+
+test("session file and launch log: 0600, the session file written once", (t) => {
+ const root = scratch(t);
+ const { runDir } = newRun(root, "acme");
+ writeSessionFile(runDir, { run: "x" });
+ assert.equal(mode(join(runDir, "session.json")), 0o600);
+ assert.throws(() => writeSessionFile(runDir, { run: "y" }), /EEXIST/);
+ appendLaunchLog(root, "acme", { event: "launch", run: "x" });
+ appendLaunchLog(root, "acme", { event: "end", run: "x" });
+ const file = launchLogFile(root, "acme");
+ assert.equal(mode(file), 0o600);
+ const lines = readFileSync(file, "utf8").trim().split("\n").map((l) => JSON.parse(l));
+ assert.deepEqual(lines.map((l) => l.event), ["launch", "end"]);
+ assert.ok(lines.every((l) => !Number.isNaN(Date.parse(l.at))));
+});
+
+test("endReason maps the runner's exit codes; a signal is killed", () => {
+ assert.deepEqual(
+ [0, 2, 20, 21, 22, 23, 7].map((c) => endReason(c, null)),
+ ["stopped", "failed", "founder-credentials", "claim-refused", "session-invalid", "broker-unreachable", "failed"],
+ );
+ assert.equal(endReason(null, "SIGKILL"), "killed");
+});
+
+test("Registry mirrors to sessions.json; readSessions marks live entries; bad files refuse", (t) => {
+ const root = scratch(t);
+ const r = new Registry(root);
+ assert.equal(mode(sessionsFile(root)), 0o600);
+ assert.deepEqual(readSessions(root), []);
+ const me = { business: "acme", instance: "coder", run: "r1", family: "sonnet", runnerPid: process.pid, runnerStartTime: startTimeOf(process.pid) };
+ r.add(me);
+ r.add({ ...me, instance: "pm", run: "r2", family: "opus", runnerStartTime: "1" });
+ assert.equal(r.count("acme", "sonnet"), 1);
+ assert.equal(r.count("other", "sonnet"), 0);
+ assert.equal(r.running("acme", "coder").run, "r1");
+ assert.deepEqual(readSessions(root).map((s) => [s.run, s.live]), [["r1", true], ["r2", false]]);
+ r.remove("acme", "r1");
+ assert.equal(r.running("acme", "coder"), null);
+ assert.deepEqual(readSessions(root).map((s) => s.run), ["r2"]);
+
+ writeFileSync(sessionsFile(root), "{");
+ assert.throws(() => readSessions(root), (e) => e instanceof SeatError && /unreadable/.test(e.message));
+ writeFileSync(sessionsFile(root), JSON.stringify({ sessionsVersion: 9, sessions: [] }));
+ assert.throws(() => readSessions(root), (e) => e instanceof SeatError && /malformed/.test(e.message));
+});
+
+test("stopSession refuses an unknown run, reports a stale one, and won't signal a pid that isn't a runner", async (t) => {
+ const root = scratch(t);
+ const r = new Registry(root);
+ await assert.rejects(stopSession(root, "nope"), /no running session has run id nope/);
+ r.add({ business: "acme", instance: "coder", run: "stale", runnerPid: process.pid, runnerStartTime: "1" });
+ assert.deepEqual(await stopSession(root, "stale"), { stopped: false, reason: "run stale is not running (stale entry)" });
+ r.add({ business: "acme", instance: "pm", run: "me", runnerPid: process.pid, runnerStartTime: startTimeOf(process.pid) });
+ await assert.rejects(stopSession(root, "me"), /is not a session runner; refusing to signal it/);
+});
+
+test("a session runs under unshare as pid 1 of its namespace, claims, answers, and stops on mosaic stop", { skip }, async (t) => {
+ const root = scratch(t);
+ const store = new Store(root);
+ const broker = new Broker({
+ store,
+ businesses: {
+ acme: {
+ id: "acme",
+ human: "jason",
+ arbiters: { technical: "pm", delivery: "pm" },
+ roles: {
+ pm: { authority: { withinRole: ["message.send"], crossRole: [] } },
+ coder: { authority: { withinRole: ["message.send"], crossRole: [] } },
+ },
+ },
+ },
+ });
+ const brokerSocket = join(root, "bus.sock");
+ const server = await serve({ broker, path: brokerSocket });
+ t.after(async () => {
+ await server.close().catch(() => {});
+ store.close();
+ });
+
+ const dataRoot = join(root, "data");
+ const { run, runDir } = newRun(dataRoot, "acme");
+ const bundle = join(runDir, "bundle");
+ const ws = join(root, "ws");
+ mkdirSync(bundle, { mode: 0o700 });
+ mkdirSync(ws);
+ writeFileSync(join(bundle, "prompt.md"), "# test\n");
+ writeFileSync(join(bundle, "policy.json"), JSON.stringify({ harness: "pi", workspace: ws, tools: [], typed: [], network: "none", needs: [], credentials: [] }));
+ writeFileSync(join(bundle, "tools.json"), "[]");
+ const adapter = join(root, "adapter.sh");
+ writeFileSync(adapter, `exec '${process.execPath}' '${FAKE}'\n`, { mode: 0o644 });
+ writeSessionFile(runDir, {
+ business: "acme", instance: "coder", run, launchedBy: "pm", harness: "pi", provider: "fake", model: "fake-model",
+ brokerSocket, launchSocket: join(root, "launch.sock"), workspace: 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,
+ });
+
+ const child = spawnSession({ runDir, env: sessionEnv({ HOME: process.env.HOME, PATH: process.env.PATH, GITEA_TOKEN: "x" }, run) });
+ t.after(() => {
+ try {
+ process.kill(child.pid, "SIGKILL");
+ } catch {}
+ });
+ const exited = new Promise((r) => child.once("close", (code, signal) => r({ code, signal })));
+ const runner = await runnerOf(child);
+ assert.notEqual(runner.pid, child.pid);
+ assert.deepEqual(cmdlineOf(child.pid).slice(0, UNSHARE.length), UNSHARE);
+ assert.ok(isRunner(runner.pid));
+ assert.equal(isRunner(child.pid), false, "unshare's argv names the runner too");
+ // Inside its namespace the runner is pid 1.
+ const nspid = readFileSync(`/proc/${runner.pid}/status`, "utf8").match(/^NSpid:\s*(.*)$/m)[1].trim().split(/\s+/);
+ assert.equal(nspid.at(-1), "1");
+ const environ = readFileSync(`/proc/${runner.pid}/environ`, "utf8").split("\0");
+ assert.ok(environ.includes(`MOSAIC_RUN_ID=${run}`));
+ assert.ok(!environ.some((v) => v.startsWith("GITEA_TOKEN=")));
+
+ const cap = broker.bindLaunch({ business: "acme", role: "coder", run, harness: "pi", pid: child.pid, startTime: startTimeOf(child.pid) });
+ const pm = broker.bindLaunch({ business: "acme", role: "pm", run: "pm-run", harness: "pi" });
+ broker.request(pm, { verb: "role.claim" });
+ child.stdin.end(`${JSON.stringify({ cap })}\n`);
+ const sent = broker.request(pm, { verb: "message.send", args: { to: "coder", body: "hello", class: "REQUEST" } });
+ let reply;
+ const end = Date.now() + 15000;
+ while (!reply && Date.now() < end) {
+ reply = broker.request(pm, { verb: "message.receive" }).find((m) => m.in_reply_to === sent.id);
+ if (!reply) await new Promise((r) => setTimeout(r, 50));
+ }
+ assert.ok(reply, "the session answered");
+ assert.match(reply.body, /^echo: Message .* from pm, class REQUEST:\n\nhello/);
+
+ const registry = new Registry(dataRoot);
+ registry.add({ business: "acme", instance: "coder", run, pid: child.pid, startTime: startTimeOf(child.pid), runnerPid: runner.pid, runnerStartTime: runner.startTime });
+ const stopped = await stopSession(dataRoot, run, { timeoutMs: 10000 });
+ assert.deepEqual(stopped, { stopped: true, business: "acme", instance: "coder", run });
+ assert.deepEqual(await exited, { code: 0, signal: null }, "unshare passes the runner's exit 0 on");
+ assert.equal(store.get("SELECT op FROM role_claims WHERE role='coder' ORDER BY seq DESC LIMIT 1").op, "release");
+ assert.equal(existsSync(join(runDir, "tools.sock")), false);
+ assert.equal(mode(join(runDir, "runner.log")), 0o600);
+});
diff --git a/scripts/agent-host-dev.sh b/scripts/agent-host-dev.sh
index f65fbc88..5ea10815 100755
--- a/scripts/agent-host-dev.sh
+++ b/scripts/agent-host-dev.sh
@@ -132,7 +132,9 @@ unset MOSAIC_LAUNCH_INCARNATION
export MOSAIC_AGENT_NAME="$AGENT"
# Explicit resources only; preserve Pi's built-in coding prompt and tool guidance.
# goal_report must be in the tool allowlist as well as the extension being loaded.
-exec "$PI" --approve --offline --no-context-files --no-extensions --no-skills \
+# --no-approve ignores project-local .pi/ files (settings.json, SYSTEM.md,
+# APPEND_SYSTEM.md); the -e and --skill paths given here still load.
+exec "$PI" --no-approve --offline --no-context-files --no-extensions --no-skills \
--no-prompt-templates --no-themes \
--extension "$REPO/.pi/extensions/goal/index.ts" \
"${SKILL_ARGS[@]}" \
diff --git a/scripts/mosaic b/scripts/mosaic
index 41b9900c..b4e3a614 100755
--- a/scripts/mosaic
+++ b/scripts/mosaic
@@ -4,8 +4,9 @@
# `mosaic queue <verb>`: the work queue. See packages/queue/README.md.
# `mosaic business <verb>`: business files and role instances. See
# packages/business/README.md.
-# `mosaic inbox|decide|tasks|agents|trail` and `mosaic bus start|stop|status`:
-# the human commands and the bus host. See packages/cli/README.md.
+# `mosaic inbox|decide|tasks|agents|trail|talk|stop|launches` and
+# `mosaic bus start|stop|status`: the human commands, managed sessions and
+# the bus host. See packages/cli/README.md.
# Not the npm-global `mosaic` CLI from the estate tooling; this one is
# repository-local and only reachable as scripts/mosaic.
set -euo pipefail
@@ -19,6 +20,6 @@ if [ "${1:-}" = business ]; then
exec node "$REPO/packages/business/src/cli.mjs" "$@"
fi
case "${1:-}" in
- inbox|decide|tasks|agents|trail|bus) exec node "$REPO/packages/cli/src/cli.mjs" "$@" ;;
+ inbox|decide|tasks|agents|trail|talk|stop|launches|bus) exec node "$REPO/packages/cli/src/cli.mjs" "$@" ;;
esac
exec node "$REPO/packages/seat/src/cli.mjs" "$@"