Candidate manifest 08a78972 (42 files). Gate at 0a4c8f13.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
5601 lines
269 KiB
Diff
5601 lines
269 KiB
Diff
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" "$@"
|