docs: row 41 round 2 review packet, changes (darkwing)

R4: Pi read opens a spelling variant of a missing name, which the gate
never checked; a variant-named workspace link reads outside. Comment 27010.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-09 21:30:14 -05:00
co-authored by Claude Opus 5.5
parent c26feca7c3
commit 2d36c6ff61
82 changed files with 5686 additions and 0 deletions
@@ -0,0 +1,42 @@
863640ee44598a5bffd6d37d328dcddfd4bdc671f792d029ae2ee18b1055b28b adapters/README.md
8121e4e05b0559471e0d44fc0325fb08c8a883ab3db1bca451a956753cccdfe3 adapters/claude/adapter.sh
962255271e95cb30788e97cd9e86e17f384dd5f74cead435692e8b30e47af9cf adapters/pi/adapter.sh
009059ea86e1d14c5b12ed0fdad64e8cd501e19021ef6f4148a1463d55860125 docs/TOOLS.md
49dc3cf603358fdbce768faaa9ebe8b5e4359a1aa813c3ffeef0d96a01d37035 packages/bus/README.md
aa71088d656455de83d9c1a42745852041ee61e2d5eb6f4c1e48e8ae29623433 packages/bus/src/broker.mjs
0b51592777d2b035e79e2864d061615e81591bf99d43820ea9b3e52f8cf56559 packages/bus/src/process.mjs
12634ff6b6ef5b5a282bb306b30c9f02929d1fb6597e149d47d21069201399b5 packages/bus/src/runtime.mjs
5b06f799e18deba2ee2eb737322f0dcfdd4a8eeae8c92e465f5acdbb894483dd packages/bus/tests/end-launch.test.mjs
e5e41c8bef670daac0507d860f7104c446c736a3897d247cbacb5ab36b440d41 packages/cli/README.md
bd207b81a2b9965b9e46da66ab31ed9a587b87e8669e8293184d4b842e45bff5 packages/cli/src/cli.mjs
e9eeec4bef3384b5b15d1e7a2c9d913d2f3e329228c3e3e2cacb8f741aa21379 packages/cli/src/host.mjs
2d0b075982f295a88161607d2795aadc86f286994ab6cc31873b83b2daebf7fa packages/cli/src/launcher.mjs
9b30a3bfe96a5d7c6956b6c75196c08bc35ceec302859337915ca1cfe2a76b76 packages/cli/tests/fixtures/launch-host.mjs
436c15af25c48a8135d6b4bbb7df26d318b87da32fc6432455f23043a07e791c packages/cli/tests/host.test.mjs
670e3a8dd3e57b96b96f6932693853e1149e53da577299931cbe3d428ddb89da packages/cli/tests/launcher.test.mjs
709bd331bb0d76f28b464e518f4e1fc3bb0b303614e79d7e82479dcd679043cc packages/cli/tests/verbs.test.mjs
07f8033da769b18c194520356f900dd10a12d146d4d10f1a80e3c40a2a60655e packages/harness/README.md
34ef8212e27f052223443c66830839517f7a54ecb6ef7d85795b3e89c65b4d4c packages/harness/package.json
22efd0bed7991561920ad29f6bf9a1ab2d7384185fab4267684a037e026d8e85 packages/harness/src/bundle.mjs
32b5090760c95eea0e1232ff36ec13a1d9b58335b2eade621196dec1ebdbe784 packages/harness/src/claude-gate.mjs
570a1e64db8624b57eb972fd7599c7543e3c41324d0e9666cb5701d5491ec39b packages/harness/src/gate.mjs
71e00113b8e5b55b410c7aae6232f76e972fc77aa68afa893da45c6b78d297e2 packages/harness/src/mcp-server.mjs
d19753fa72f0b57e751749359644093713bcb650bfac566f55d2bc05cb817121 packages/harness/src/pi-extension.mjs
1047aa092080e92efde2044dddaf82bacf428ed4b143c3846693471481f3612e packages/harness/src/runner.mjs
92153d9e2161ceef4ee76f2ff992014760a8c62b0ea709b51e2ea0ce965d3edb packages/harness/src/tools.mjs
96796238b7effab78cc6356ed8ea163f02aa39999d8dfc97fef83d45309d6574 packages/harness/tests/bundle.test.mjs
96524da43b212e84d78108cbbf05eef324c934f3ad9e0d4cb2edcb0f3df256d4 packages/harness/tests/claude-gate.test.mjs
197ec0f6c842552bea65fb2ab4c8cb8615fd6e691a2d0a592e1465d18a45d75f packages/harness/tests/claude-session.test.mjs
8392172b243356932c974bdca9b4c449a312ae7a042ee7a22e0f22fbb98dbec9 packages/harness/tests/fixtures/fake-adapter.mjs
8576749388b3f962ed6cbd694d9af814665f3f491f278a3cdf9b9c5435388460 packages/harness/tests/gate.test.mjs
6e7509cfe6ad909440c25b750528360c0ba617c6c68b05d400bcd94b481c1f76 packages/harness/tests/helpers.mjs
793eb11f970e4a8eecddec4dc48c24331e4de795bc04bfadf806a2fc3a15a8b4 packages/harness/tests/mcp-server.test.mjs
ba907e3bee4547c97c8700b4b19febc46c19074a259dd113b727522d68115273 packages/harness/tests/pi-session.test.mjs
c8f23144316501c3e163cf5a5566ddee552b3c93dee13594ae8c1eac66aedc64 packages/harness/tests/runner.test.mjs
ef9130a3cb1ca3e278a8ed370060afd1145d1ceb211a915e83d75c8346b04931 packages/harness/tests/tools.test.mjs
4e34456187387b02fa6d996c270dea4076b5d57952cddaa01f7a04843b351a02 packages/seat/README.md
382cbf7e0ff336911e288ce858bdbfbec693bc2b69879720d1f0b4c7d8455198 packages/seat/src/proc.mjs
5e181204de871d4f1098f5a63b5f80d87a44f5c01bf150101a6690bb1ccdca3d packages/seat/src/session.mjs
9a505d255c61034b3be738d359bc4a10acf08916c65675ee14dab2e29c060b04 packages/seat/tests/session.test.mjs
482ad6167fc96bf86928e0b467b3e173b174508fd913e297a8164d73f334d031 scripts/agent-host-dev.sh
b37d673aa5ce72c10b49018d8ffd9460f393eff7b638f1aa2065eecd608dde77 scripts/mosaic
@@ -0,0 +1,42 @@
adapters/README.md
adapters/claude/adapter.sh
adapters/pi/adapter.sh
docs/TOOLS.md
packages/bus/README.md
packages/bus/src/broker.mjs
packages/bus/src/process.mjs
packages/bus/src/runtime.mjs
packages/bus/tests/end-launch.test.mjs
packages/cli/README.md
packages/cli/src/cli.mjs
packages/cli/src/host.mjs
packages/cli/src/launcher.mjs
packages/cli/tests/fixtures/launch-host.mjs
packages/cli/tests/host.test.mjs
packages/cli/tests/launcher.test.mjs
packages/cli/tests/verbs.test.mjs
packages/harness/README.md
packages/harness/package.json
packages/harness/src/bundle.mjs
packages/harness/src/claude-gate.mjs
packages/harness/src/gate.mjs
packages/harness/src/mcp-server.mjs
packages/harness/src/pi-extension.mjs
packages/harness/src/runner.mjs
packages/harness/src/tools.mjs
packages/harness/tests/bundle.test.mjs
packages/harness/tests/claude-gate.test.mjs
packages/harness/tests/claude-session.test.mjs
packages/harness/tests/fixtures/fake-adapter.mjs
packages/harness/tests/gate.test.mjs
packages/harness/tests/helpers.mjs
packages/harness/tests/mcp-server.test.mjs
packages/harness/tests/pi-session.test.mjs
packages/harness/tests/runner.test.mjs
packages/harness/tests/tools.test.mjs
packages/seat/README.md
packages/seat/src/proc.mjs
packages/seat/src/session.mjs
packages/seat/tests/session.test.mjs
scripts/agent-host-dev.sh
scripts/mosaic
+14
View File
@@ -0,0 +1,14 @@
#!/bin/bash
export TMPDIR=~/darkwing-scratch/tmp DOCKER_HOST=unix:///nonexistent.sock
cd ~/darkwing-scratch/r41b/wt
O=~/darkwing-scratch/r41b/out
: > $O/summary.txt
for p in harness seat cli bus business; do
node --test "packages/$p/tests/*.test.mjs" > $O/node-$p.txt 2>&1; e=$?
echo "node-$p exit=$e $(grep -E '^ℹ (pass|fail)' $O/node-$p.txt | tr '\n' ' ')" >> $O/summary.txt
done
for s in test-auth test-config test-conductor test-queue test-foundation test-extension-package test-release test-discord test-task; do
scripts/$s.sh > $O/$s.txt 2>&1; e=$?
echo "$s exit=$e $(grep -E 'passed, [0-9]+ failed' $O/$s.txt | tail -1)" >> $O/summary.txt
done
echo GATE-DONE >> $O/summary.txt
@@ -0,0 +1,834 @@
diff --git a/adapters/claude/adapter.sh b/adapters/claude/adapter.sh
index 2e29fe25..8a259540 100644
--- a/adapters/claude/adapter.sh
+++ b/adapters/claude/adapter.sh
@@ -49,8 +49,10 @@ fi
# --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. Defence in depth: the
-# S0 lines above don't depend on it.
+# 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
diff --git a/packages/bus/README.md b/packages/bus/README.md
index 29b4add7..fff96910 100644
--- a/packages/bus/README.md
+++ b/packages/bus/README.md
@@ -63,6 +63,10 @@ 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:
diff --git a/packages/bus/src/process.mjs b/packages/bus/src/process.mjs
index bf0ce4f1..68276a03 100644
--- a/packages/bus/src/process.mjs
+++ b/packages/bus/src/process.mjs
@@ -10,6 +10,9 @@ function launchOp(m) {
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;
@@ -33,18 +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({ ok: false, error: e instanceof BusError ? e.code : 'bind-refused' });
+ process.send(tag(message, { ok: false, error: e instanceof BusError ? e.code : 'bind-refused' }));
}
return;
}
- // S6 launcher ops, one reply each; the host sends them one at a time like bindLaunch.
+ // S6 launcher ops, one reply each, like bindLaunch.
if (LAUNCH_OPS.has(message?.op) && runtime) {
try {
- process.send({ ok: true, result: launchOp(message) });
+ process.send(tag(message, { ok: true, result: launchOp(message) }));
} catch (e) {
- process.send({ ok: false, error: e instanceof BusError ? e.code : 'launch-op-refused' });
+ process.send(tag(message, { ok: false, error: e instanceof BusError ? e.code : 'launch-op-refused' }));
}
return;
}
@@ -69,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/tests/end-launch.test.mjs b/packages/bus/tests/end-launch.test.mjs
index 574129b7..68720d71 100644
--- a/packages/bus/tests/end-launch.test.mjs
+++ b/packages/bus/tests/end-launch.test.mjs
@@ -175,6 +175,11 @@ test('broker process: launch ops authorize role.launch, record refusals and end
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(
diff --git a/packages/cli/README.md b/packages/cli/README.md
index be9e6531..32977afa 100644
--- a/packages/cli/README.md
+++ b/packages/cli/README.md
@@ -62,6 +62,9 @@ mosaic launches list [--json]
- `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
@@ -106,8 +109,12 @@ uses: `bindLaunch(record)` binds a launched run's process identity
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, because its replies carry
-no request id.
+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
@@ -166,7 +173,9 @@ 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, sends SIGTERM to
+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`.
diff --git a/packages/cli/src/host.mjs b/packages/cli/src/host.mjs
index 35c44f58..0073091b 100644
--- a/packages/cli/src/host.mjs
+++ b/packages/cli/src/host.mjs
@@ -25,6 +25,7 @@ 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");
@@ -94,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);
@@ -152,14 +154,52 @@ export async function startHost({ boot, business, notifier = null, bootTimeoutMs
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();
+ 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 || !broker.connected) throw Object.assign(new CliError("bus host is closing", 1), { code: "host-closing" });
- const r = firstReply(broker, 10000, "broker");
- broker.send(message);
- const m = await r;
+ 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 });
diff --git a/packages/cli/src/launcher.mjs b/packages/cli/src/launcher.mjs
index ec01a2c3..b07ae333 100644
--- a/packages/cli/src/launcher.mjs
+++ b/packages/cli/src/launcher.mjs
@@ -83,8 +83,8 @@ export function claudeVersion(env = process.env) {
}
// host: startHost()'s handle. tracker: true when the broker has task verbs for
-// the business. adapters, namespace, sessionDefaults and versions exist for
-// the tests; the command line never sets them.
+// the business. adapters, namespace, sessionDefaults, versions and
+// stopTimeoutMs exist for the tests; the command line never sets them.
export function createLauncher({
host,
system,
@@ -94,6 +94,7 @@ export function createLauncher({
namespace = true,
sessionDefaults = {},
versions = null,
+ stopTimeoutMs = STOP_TIMEOUT_MS,
log = (l) => process.stderr.write(`mosaic-bus: ${l}\n`),
}) {
const dataRoot = system.dataRoot;
@@ -114,6 +115,7 @@ export function createLauncher({
let chain = Promise.resolve();
let closed = false;
let server = null;
+ const sockets = new Set();
const record = (entry) => {
try {
@@ -335,6 +337,8 @@ export function createLauncher({
rmSync(path);
}
server = createServer((socket) => {
+ sockets.add(socket);
+ socket.once("close", () => sockets.delete(socket));
let buf = "";
let answered = false;
const reply = (obj) => {
@@ -377,7 +381,11 @@ export function createLauncher({
async function close() {
closed = true;
if (server) {
- await new Promise((r) => server.close(r));
+ // 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;
@@ -399,7 +407,7 @@ export function createLauncher({
if (startTimeOf(s.pid) === s.startTime) process.kill(s.pid, "SIGKILL");
} catch {}
}
- }, STOP_TIMEOUT_MS);
+ }, 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));
diff --git a/packages/cli/tests/host.test.mjs b/packages/cli/tests/host.test.mjs
index f8f11ec9..f8914479 100644
--- a/packages/cli/tests/host.test.mjs
+++ b/packages/cli/tests/host.test.mjs
@@ -203,6 +203,65 @@ 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 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
index b0c0ba09..75ee91b8 100644
--- a/packages/cli/tests/launcher.test.mjs
+++ b/packages/cli/tests/launcher.test.mjs
@@ -53,7 +53,7 @@ function prepare(t, more = (doc) => doc) {
return { root, f, adapter };
}
-async function setup(t, more = (doc) => doc, { root, f, adapter } = prepare(t, more)) {
+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 = [];
@@ -68,6 +68,7 @@ async function setup(t, more = (doc) => doc, { root, f, adapter } = prepare(t, m
sessionDefaults: { pollInterval: 100 },
versions: { pi: "0.85.1", claude: null },
log: (l) => logs.push(l),
+ ...options,
});
await launcher.listen();
} catch (e) {
@@ -267,6 +268,16 @@ for (const exited of [false, true]) {
// 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/);
@@ -309,3 +320,75 @@ test("a malformed sessions.json refuses the host start with exit 3 and stays as
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", 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/harness/README.md b/packages/harness/README.md
index bc2dcc85..79ff829a 100644
--- a/packages/harness/README.md
+++ b/packages/harness/README.md
@@ -26,10 +26,13 @@ bundle's `manifest.json` names the lines it relies on (`reliesOn`):
| 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). That is defence
-in depth: none of the S0 lines depend on it, and no test treats it as the
-layer that holds.
+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
@@ -74,7 +77,10 @@ refusal comes back to the model as `refused: <code>`.
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. Pi calls it from the extension, Claude Code from
+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`.
## The runner
@@ -148,7 +154,14 @@ These are limits, not bugs, and nothing in this package claims otherwise.
- **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 an extra layer, not one the S0 lines
- prove.
+- **`--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).
+- **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/src/gate.mjs b/packages/harness/src/gate.mjs
index 322a84ce..47cc029e 100644
--- a/packages/harness/src/gate.mjs
+++ b/packages/harness/src/gate.mjs
@@ -8,7 +8,7 @@
// and anything bash can reach, the session can reach. See the README's
// limits.
-import { existsSync, realpathSync } from "node:fs";
+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";
@@ -41,17 +41,25 @@ function normalise(p) {
return s;
}
-// Realpath of the nearest existing ancestor, with the missing tail kept.
+// 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 (!existsSync(head)) {
+ while (!lstatSync(head, { throwIfNoEntry: false })) {
const up = dirname(head);
if (up === head) break;
tail.unshift(basename(head));
head = up;
}
- return join(realpathSync(head), ...tail);
+ try {
+ return join(realpathSync(head), ...tail);
+ } catch (error) {
+ if (error.code === "ENOENT") throw Object.assign(new Error("dangling symlink"), { code: "dangling-symlink" });
+ throw error;
+ }
}
export function insideWorkspace(workspace, p) {
@@ -87,6 +95,7 @@ export function decide(policy, tool, input) {
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}`);
}
return { allow: true };
diff --git a/packages/harness/src/runner.mjs b/packages/harness/src/runner.mjs
index e2e02d4d..ccf6ca43 100644
--- a/packages/harness/src/runner.mjs
+++ b/packages/harness/src/runner.mjs
@@ -256,16 +256,16 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
process.on("SIGTERM", stop);
process.on("SIGINT", stop);
- let session, cap;
+ 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;
}
- const policy = JSON.parse(readFileSync(session.bundle.policy, "utf8"));
if (stopping) {
log("runner: stopped before claim");
return EXIT.stopped;
diff --git a/packages/harness/tests/claude-session.test.mjs b/packages/harness/tests/claude-session.test.mjs
index 823a31ee..e45c5d69 100644
--- a/packages/harness/tests/claude-session.test.mjs
+++ b/packages/harness/tests/claude-session.test.mjs
@@ -1,13 +1,14 @@
// 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.
+// 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 { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
-import { dirname, join } from "node:path";
+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";
@@ -57,9 +58,9 @@ async function session(t, script, tools = ["read", "bash"]) {
return { dir, workspace, api, sock, s, env, manifest };
}
-function turn(env, request, cwd) {
+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 });
+ 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));
@@ -136,6 +137,67 @@ test("claude: a second turn resumes the first turn's session", { skip }, async (
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"]) {
diff --git a/packages/harness/tests/gate.test.mjs b/packages/harness/tests/gate.test.mjs
index 66e00bb3..c32394c2 100644
--- a/packages/harness/tests/gate.test.mjs
+++ b/packages/harness/tests/gate.test.mjs
@@ -79,6 +79,29 @@ test("a symlink inside the workspace that points out is outside", (t) => {
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/);
+ }
+});
+
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") }));
diff --git a/packages/harness/tests/helpers.mjs b/packages/harness/tests/helpers.mjs
index 19f13a64..96c1923f 100644
--- a/packages/harness/tests/helpers.mjs
+++ b/packages/harness/tests/helpers.mjs
@@ -79,7 +79,7 @@ export async function mockAnthropic(t, { script = [], done = (r) => `DONE ${JSON
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 });
+ 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";
diff --git a/packages/harness/tests/pi-session.test.mjs b/packages/harness/tests/pi-session.test.mjs
index 8fc8b4e4..0486857f 100644
--- a/packages/harness/tests/pi-session.test.mjs
+++ b/packages/harness/tests/pi-session.test.mjs
@@ -4,7 +4,7 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
-import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
+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";
@@ -96,6 +96,30 @@ test("pi: typed tools reach the socket, the gate blocks, agent_end writes the ma
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 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);
diff --git a/packages/harness/tests/runner.test.mjs b/packages/harness/tests/runner.test.mjs
index 77027f36..25b4d8a9 100644
--- a/packages/harness/tests/runner.test.mjs
+++ b/packages/harness/tests/runner.test.mjs
@@ -110,15 +110,22 @@ async function setup(t, { session = {}, policy = {}, tools } = {}) {
return { dir, runDir, store, broker, server, cap, pm, call, launches, pids: join(dir, "pids") };
}
-function startRunner(runDir, cap, env = {}) {
+// 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.end(cap === null ? "" : JSON.stringify({ cap }) + "\n");
- const exited = once(child, "exit").then(([code, signal]) => ({ code, signal, stderr }));
+ 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 };
}
@@ -308,5 +315,23 @@ test("no capability, or a malformed one, on stdin exits 2", async (t) => {
assert.equal((await startRunner(ctx.runDir, null).exited).code, EXIT.usage);
assert.equal((await startRunner(ctx.runDir, "not-hex").exited).code, EXIT.usage);
assert.equal((await startRunner(join(ctx.dir, "nope"), ctx.cap).exited).code, EXIT.usage);
+ // A valid capability inside an over-long line: the 4096-byte cap refuses it.
+ const long = await startRunner(ctx.runDir, ctx.cap, {}, JSON.stringify({ cap: ctx.cap, pad: "x".repeat(5000) }) + "\n").exited;
+ assert.equal(long.code, EXIT.usage, long.stderr);
+ assert.match(long.stderr, /stdin too large/);
+ assert.equal(ctx.store.get("SELECT 1 FROM role_claims WHERE role='coder'"), undefined);
+});
+
+test("a missing or malformed policy exits 2 before the claim", async (t) => {
+ const ctx = await setup(t);
+ const policy = join(ctx.runDir, "bundle", "policy.json");
+ writeFileSync(policy, "{");
+ const bad = await startRunner(ctx.runDir, ctx.cap).exited;
+ assert.equal(bad.code, EXIT.usage, bad.stderr);
+ assert.match(bad.stderr, /^runner: /m);
+ rmSync(policy);
+ const missing = await startRunner(ctx.runDir, ctx.cap).exited;
+ assert.equal(missing.code, EXIT.usage, missing.stderr);
+ assert.match(missing.stderr, /runner: ENOENT/);
assert.equal(ctx.store.get("SELECT 1 FROM role_claims WHERE role='coder'"), undefined);
});
@@ -0,0 +1,122 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (110.029105ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (127.182151ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (90.074289ms)
✔ decide prints a declining choice as declining (87.917671ms)
✔ an unknown outcome is reported once and never resent (67.972919ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (85.047445ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (84.334304ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (54.163126ms)
✔ every human command refuses inside an agent run before it touches the bus (72.449462ms)
✔ usage errors exit 4; no business and no host is a usage error (63.336747ms)
✔ agents and tasks print through the broker (74.755065ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (1.122452ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (40.0065ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (31.606125ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (29.688897ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (31.090093ms)
✔ a business without tracker.baseUrl gets no trackers entry (32.140689ms)
✔ an unknown business and a broken system config refuse with exit 3 (51.529886ms)
✔ empty views say so (0.635026ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (0.775294ms)
✔ tasks print the tracker fields the snapshot carries (0.129113ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (876.266621ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (192.48304ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (102.20185ms)
✔ a broker reply that misses the wait breaks the channel: the late reply answers nothing, later requests refuse, the host exits 1 (1124.590982ms)
✔ a broker reply with another request's id, or none, breaks the channel and the host exits 1 (217.725039ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (159.104717ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (153.220614ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (116.793662ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (149.287775ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (100.247002ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (159.42089ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (24.787551ms)
✔ bus stop refuses to signal a live pid that is not a bus host (202.129277ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (206.277085ms)
✔ bus start refuses with exit 3 without a notifier config (85.342418ms)
✔ bus start runs until bus stop; status reports it while it runs (660.515393ms)
✔ bus-service.sh renders the unit and installs it into a given directory (26.962627ms)
✖ bus start --pm: the PM runs under its own PID namespace, registered, with a manifest (30252.541812ms)
✖ the PM launches a coder through its launch tool; the coder answers; refusals name their code (18474.63404ms)
✔ a runner that stops at once ends its launch with the runner's reason (196.187752ms)
✔ a host that died hard leaves its sessions to the next host, which kills them and ends their runs so the role can be launched again (30642.592854ms)
✔ a host that died hard leaves its sessions to the next host, which finds one already exited (23) and ends their runs so the role can be launched again (33574.283695ms)
✔ a malformed sessions.json refuses the host start with exit 3 and stays as it was (107.106866ms)
✔ an over-long launch request is refused at once, not at the 10 s idle timeout (119.211249ms)
✔ a launch client that never closes its side doesn't hold the host's close (120.72773ms)
✔ a runner that ignores SIGTERM is killed when the host closes (1248.394467ms)
✔ zoned uses the IANA zone across DST (14.024497ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (112.493755ms)
✔ two blocking decisions get two DMs with different nonces (115.68539ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.178117ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (101.811353ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (82.118324ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (77.476963ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (90.720043ms)
✔ polled every POLL_MS against a permanent 403, a DM is sent at 0, 30, 60, 90 and 120 min and gives up only then (141.339002ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (101.892643ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (104.335536ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (62.128633ms)
✔ an inbox read failure is logged and the next poll retries (0.611742ms)
✔ no Discord id reaches the journal or the log (54.54028ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (10.885491ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (25.147422ms)
✔ the journal: a whole file that is one torn line truncates to empty (9.885294ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (0.497851ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.543709ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (1.78457ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.303594ms)
✔ the journal: a dangling directory link, a parent that is a file and a journal that is a directory each refuse with exit 3 (0.453696ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.36621ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.431344ms)
✔ digest content stays within Discord's 2000 characters (0.243198ms)
✔ runLoop never overlaps ticks and stops after the one in flight (109.722623ms)
task.close {} answered: invalid-request; fake saw 18 requests, first GET /info 200, GET /projects/1 200, GET /projects/1/views 200
task.close on a missing task answered: task-not-found; it made GET /tasks/999 404
✔ bootConfig trackers reach the S3 adapter in the real broker child, which goes ready against a fake Vikunja (349.549925ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (30.388492ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2142.249461ms)
✔ busExit and refuseInsideAgent (0.39274ms)
✔ talk sends a REQUEST, prints and reads everything that arrives, and stops at the reply (198.187775ms)
✔ talk --wait 0 only sends; no reply within --wait exits 1 and says where it will show (1119.413433ms)
✔ talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors (44.366985ms)
✔ launches off and on go to the broker and change the business's launch state (51.829893ms)
✔ launches list reads sessions.json and marks a stale entry; stop reports it and refuses a non-runner (42.907284ms)
ℹ tests 82
ℹ suites 0
ℹ pass 80
ℹ fail 2
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 114813.584398
✖ failing tests:
test at packages/cli/tests/launcher.test.mjs:106:1
✖ bus start --pm: the PM runs under its own PID namespace, registered, with a manifest (30252.541812ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
+ actual - expected
+ 'killed'
- 'stopped'
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41b/wt/packages/cli/tests/launcher.test.mjs:172:10)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Test.run (node:internal/test_runner/test:1409:7)
at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: 'killed',
expected: 'stopped',
operator: 'strictEqual',
diff: 'simple'
}
test at packages/cli/tests/launcher.test.mjs:176:1
✖ the PM launches a coder through its launch tool; the coder answers; refusals name their code (18474.63404ms)
Error: timed out waiting
at until (file:///home/jwoltje/darkwing-scratch/r41b/wt/packages/cli/tests/launcher.test.mjs:43:9)
at async TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41b/wt/packages/cli/tests/launcher.test.mjs:216:3)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7)
@@ -0,0 +1,121 @@
✔ sessionModel: agent vars win, then the system's execution settings (9.279787ms)
✔ a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory (5.502328ms)
✔ a claude-code bundle adds the wrapped gate hook and the MCP config (3.994908ms)
✔ a bundle is written once: an existing file refuses (3.057356ms)
✔ a path with a single quote can't go into the hook command (2.604266ms)
✔ allow exits 0, a deny exits 2 with the reason on stderr (121.145971ms)
✔ a missing or wrong policy, or a bad event, exits 2 (89.481595ms)
✔ the bundle's wrapped command: a missing gate or node still blocks (1089.811585ms)
✔ claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist (775.622561ms)
✔ claude: the hook alone blocks a path outside the workspace (444.026047ms)
✔ claude: a second turn resumes the first turn's session (705.498813ms)
✔ claude adapter: --restricted is always passed (4.999097ms)
✔ claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do (724.038133ms)
✔ claude: a missing hook or MCP file refuses before claude starts (7.432483ms)
✔ pi: policy tools and typed tools pass, anything else is blocked (3.209431ms)
✔ claude: builtins map from pi names, typed tools need the mcp prefix (0.758653ms)
✔ file tool paths must resolve inside the workspace (1.276799ms)
✔ pi's own path normalisation can't be used to step out (1.112402ms)
✔ a symlink inside the workspace that points out is outside (0.79875ms)
✔ a dangling symlink is refused at any depth, in both harnesses (3.283396ms)
✔ claude path fields per tool (0.765194ms)
✔ glob patterns stay inside the workspace (1.053637ms)
✔ a path that can't be checked is blocked (0.61845ms)
✔ initialize, ping and tools/list (43.855051ms)
✔ tools/call goes through the tool socket; a refusal is an isError result (32.971154ms)
✔ unknown tools and methods are JSON-RPC errors and never reach the socket (35.596237ms)
✔ a missing argument is a usage error (32.888006ms)
✔ pi: typed tools reach the socket, the gate blocks, agent_end writes the marker (376.367274ms)
✔ pi: a write through a dangling symlink is blocked, and nothing appears outside (331.603555ms)
✔ pi: a missing extension refuses before any model call (6.804337ms)
✔ pi: an extension without its configuration fails pi's start (263.892582ms)
✔ founderCheck: founder variables, then a needed service without a usable token (1.687495ms)
✔ turnRequest names the sender, class, reply and decision (0.277446ms)
✖ a message becomes a turn, the answer goes back as a RESULT, SIGTERM releases and exits 0 (229.484324ms)
✖ a SIGTERM before the claim stops the runner with exit 0 and no claim (113.336966ms)
✔ typed tools carry the runner's capability; launch goes to the host's launch socket (419.718475ms)
✔ a RESULT gets no automatic reply; failed turns reply with the reason (1330.519052ms)
✖ SIGTERM during a turn kills the turn's process group and still exits 0 (137.913224ms)
✔ founder credentials stop before the claim (20) (231.142468ms)
✔ a refused claim exits 21; an ended run's capability exits 22 (213.791143ms)
✔ the launch ending under a running session exits 22 (154.16654ms)
✔ a broker that stays unreachable exits 23 after brokerRetries polls (245.559046ms)
✔ a broker that is down at the claim exits 23, not 21 (97.240524ms)
✔ no capability, or a malformed one, on stdin exits 2 (186.30622ms)
✔ a missing or malformed policy exits 2 before the claim (133.157005ms)
✔ the PM gets launch, its task verbs and the reads (7.216258ms)
✔ a coder gets no launch, no resolve_decision, and no task tools without a tracker (1.92585ms)
✔ launch only when the business's launch block names the instance as launcher (2.124721ms)
✔ an action outside the instance's authority has no tool (1.677154ms)
✔ callTool: one JSON line out, the result back, a refusal rejects (8.825896ms)
ℹ tests 50
ℹ suites 0
ℹ pass 47
ℹ fail 3
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 10356.726177
✖ failing tests:
test at packages/harness/tests/runner.test.mjs:148:1
✖ a message becomes a turn, the answer goes back as a RESULT, SIGTERM releases and exits 0 (229.484324ms)
AssertionError [ERR_ASSERTION]: runner: demo/coder claimed by run coder-run
null !== 0
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41b/wt/packages/harness/tests/runner.test.mjs:167:10)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: null,
expected: 0,
operator: 'strictEqual',
diff: 'simple'
}
test at packages/harness/tests/runner.test.mjs:173:1
✖ a SIGTERM before the claim stops the runner with exit 0 and no claim (113.336966ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal:
+ actual - expected
[
- 0,
null,
+ 'SIGTERM'
]
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41b/wt/packages/harness/tests/runner.test.mjs:192:10)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: [ null, 'SIGTERM' ],
expected: [ 0, null ],
operator: 'deepStrictEqual',
diff: 'simple'
}
test at packages/harness/tests/runner.test.mjs:239:1
✖ SIGTERM during a turn kills the turn's process group and still exits 0 (137.913224ms)
AssertionError [ERR_ASSERTION]: runner: demo/coder claimed by run coder-run
null !== 0
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41b/wt/packages/harness/tests/runner.test.mjs:249:10)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: null,
expected: 0,
operator: 'strictEqual',
diff: 'simple'
}
@@ -0,0 +1,102 @@
✔ launch identity is stamped, payload identity is refused and stale holder cannot send (153.236192ms)
✔ decision classes route from policy; gated resolution is human-only, choice and target must match (233.592917ms)
✔ claim exclusion, holder release, gated revoke and rerouting to a new holder are atomic (232.842814ms)
✔ launch events require a human CLI capability; generic emit cannot forge authority events (151.298667ms)
✔ within-role decisions close atomically and invalid options or blocking omissions refuse (140.874719ms)
✔ observer capabilities read human inbox but cannot mutate or forge launch identity (132.246197ms)
✔ task action subjects and linked decision trail are complete and ordered (130.571048ms)
✔ launch binding is durable and reconnecting requires the identical trusted record (82.083997ms)
✔ business isolation includes inherited object names and cross-business message references (148.470847ms)
✔ authority never transfers between action, run, target, unresolved or replaced role holder (200.947154ms)
✔ task projection uses schema current view, skipping earlier and equal-start polls (107.936316ms)
✔ revocation permanently bars the old run from reclaiming first, including after broker restart (155.37918ms)
✔ empty message references refuse before storage; refusal-evidence failure stays a typed error (101.120038ms)
✔ both arbiters require human resolution when their cross-role route is themselves (180.901724ms)
✔ S1 adapter takes resolved limits and refs, rejects mismatched instance, never mutates input (2.515467ms)
✔ only validated broker references load; returned data and exceptions cannot expose a known token (5.903004ms)
✔ bad file modes, symlinks, repository/data paths, malformed tokens and missing dates refuse (2.612809ms)
✔ expiry refuses use and env references never become client data (0.859583ms)
✔ S1 parsed service refs work, service mismatch refuses, Gitea rotation due is a warning state (1.833087ms)
✔ opaque tokens shorter than 16 characters refuse before use (0.41244ms)
✔ endLaunch writes session.ended, releases the run claim and kills its capabilities (131.151975ms)
✔ endLaunch refuses an unknown run, a second end and a rebind of the ended run (123.410748ms)
✖ a restarted broker refuses to rebind an ended run; a refused rebind leaves the run unbound (139.678678ms)
✔ endLaunch leaves a claim another run took alone (150.585789ms)
✔ refuse records action.refused against the caller with the code only (116.404715ms)
✔ launches off refuses role.launch with launch-revoked until launches on (152.663293ms)
✔ broker process: launch ops authorize role.launch, record refusals and end runs (219.363656ms)
✔ human proof binds CLI entry, process start and nonce; agents and incomplete ancestry refuse (2.330335ms)
✔ process reader gets own kernel identity without exposing environment values (1.487254ms)
✔ EACCES ancestor environments skip only markers; commands and registered launches still refuse (1.079579ms)
✔ real pid 1 remains inspectable when its environment is protected (0.400235ms)
✔ within-role sends cite an open gated launch decision without spending it or naming it in grants (183.830723ms)
✔ missing and foreign-business citations refuse and roll back message and grant (163.312567ms)
✔ cross-role sends still need a matching resolved decision and consume it once (213.148623ms)
✔ broker process binds trusted launches, offers reader capabilities, refuses human mutation, closes cleanly (178.767627ms)
✔ startup token refusal returns safe code without value or partial listening broker (50.75275ms)
✔ loaded fixture token is absent from socket replies and SQLite, including refusal evidence (167.027984ms)
✔ killed broker leaves an explicit stale lock; another process cannot silently reclaim it (156.059938ms)
✔ trusted host registers later launches; socket clients never have a registration verb (168.365601ms)
✔ runtime excludes declared project roots even when host supplies no repoRoots (39.743749ms)
✔ a refused launch binding leaves the broker and existing capabilities alive; bad protocol stops it (138.564671ms)
✔ v3b prototype refusals, views and append-only mutations (918.279812ms)
✔ gated approval authorizes once, survives store reopen, and fresh approval works (236.896357ms)
✔ another run cannot consume an approval; a failed check leaves it usable (197.000805ms)
✔ two scheduled callers have exactly one grant and one consumed refusal (152.926706ms)
✔ failed commit rolls consumption back; cross-role consumes and within-role stays reusable (266.34715ms)
✔ class drift gated to cross-role refuses before consumption (178.53185ms)
✔ class drift cross-role to gated refuses before consumption (174.651506ms)
✔ class drift gated to within-role refuses before consumption (152.725015ms)
✔ class drift cross-role to within-role refuses before consumption (178.756218ms)
✔ class drift within-role to gated refuses before consumption (144.162896ms)
✔ class drift within-role to cross-role refuses before consumption (137.602584ms)
✔ message.send consumes approval and prevents a later send or authorize (165.574578ms)
✔ role.revoke consumes approval and prevents a later revoke or authorize (173.815805ms)
✔ creates private WAL store and excludes a second writer until explicit close (116.705782ms)
✔ rollback is atomic and schema metadata is checked against trusted DDL, not just itself (163.826818ms)
✔ existing empty database and symlink runtime directory refuse, never initialize over damage (160.951856ms)
✔ crash during a transaction recovers no partial event after explicit fixture-only lock removal (161.506551ms)
✔ writer refuses mixed at/read_at forms atomically, even through trusted SQL helpers (93.498994ms)
✔ async transactions refuse before invoking their function (79.085984ms)
✔ recordTask keeps sync reads and a role write apart (164.271125ms)
✔ read_at must be one canonical UTC format, so the projection compares strings safely (95.318007ms)
✔ a bad entry refuses the whole record (98.577209ms)
✔ taskView reads the projection for one business (118.66996ms)
✔ requestTask hands only a holder and a task verb to the handler, and records refusals (218.873429ms)
✔ the server sends task verbs to the adapter with its own timeout; other verbs stay synchronous (386.321385ms)
✔ without an adapter the server refuses every task verb (159.312839ms)
✔ the runtime refuses an invalid adapter and closes a valid one (169.538564ms)
✔ the process loads the S3 adapter from plain-data trackers (223.02781ms)
✔ socket capability stamps launch identity; shared views use wire, no SQL client (141.76185ms)
✔ two wire claims serialize; a lost reply never automatically retries (160.373022ms)
✔ malformed, oversized and identity-forging envelopes refuse without echoing input (103.410412ms)
✔ client preserves UTF-8 when a response divides a multibyte character (11.977052ms)
✔ committed mutation followed by dropped reply reports unknown and is never retried (123.012441ms)
ℹ tests 74
ℹ suites 0
ℹ pass 73
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 2261.712706
✖ failing tests:
test at packages/bus/tests/end-launch.test.mjs:73:1
✖ a restarted broker refuses to rebind an ended run; a refused rebind leaves the run unbound (139.678678ms)
AssertionError [ERR_ASSERTION]: Missing expected exception.
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41b/wt/packages/bus/tests/end-launch.test.mjs:80:10)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: undefined,
expected: /unknown-run/,
operator: 'throws',
diff: 'simple'
}
@@ -0,0 +1,92 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (122.984118ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (131.094926ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (96.333839ms)
✔ decide prints a declining choice as declining (103.065679ms)
✔ an unknown outcome is reported once and never resent (74.886646ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (90.146061ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (70.70686ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (53.526247ms)
✔ every human command refuses inside an agent run before it touches the bus (67.982352ms)
✔ usage errors exit 4; no business and no host is a usage error (68.086699ms)
✔ agents and tasks print through the broker (79.053379ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (1.209663ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (46.711574ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (35.139018ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (29.906609ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (37.871648ms)
✔ a business without tracker.baseUrl gets no trackers entry (34.187488ms)
✔ an unknown business and a broken system config refuse with exit 3 (56.611491ms)
✔ empty views say so (0.648387ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (0.818466ms)
✔ tasks print the tracker fields the snapshot carries (0.134716ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (881.329614ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (199.98338ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (130.986447ms)
✔ a broker reply that misses the wait breaks the channel: the late reply answers nothing, later requests refuse, the host exits 1 (1120.775638ms)
✔ a broker reply with another request's id, or none, breaks the channel and the host exits 1 (264.870447ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (163.79319ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (156.35937ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (96.251171ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (161.579016ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (101.04276ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (180.50377ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (22.230676ms)
✔ bus stop refuses to signal a live pid that is not a bus host (202.179248ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (218.502736ms)
✔ bus start refuses with exit 3 without a notifier config (88.405387ms)
✔ bus start runs until bus stop; status reports it while it runs (655.129305ms)
✔ bus-service.sh renders the unit and installs it into a given directory (26.173049ms)
✔ bus start --pm: the PM runs under its own PID namespace, registered, with a manifest (1273.166494ms)
✔ the PM launches a coder through its launch tool; the coder answers; refusals name their code (582.798535ms)
✔ a runner that stops at once ends its launch with the runner's reason (189.561799ms)
✔ a host that died hard leaves its sessions to the next host, which kills them and ends their runs so the role can be launched again (640.78783ms)
✔ a host that died hard leaves its sessions to the next host, which finds one already exited (23) and ends their runs so the role can be launched again (3598.083011ms)
✔ a malformed sessions.json refuses the host start with exit 3 and stays as it was (108.133788ms)
✔ an over-long launch request is refused at once, not at the 10 s idle timeout (117.774053ms)
✔ a launch client that never closes its side doesn't hold the host's close (122.939583ms)
✔ a runner that ignores SIGTERM is killed when the host closes (1249.204108ms)
✔ zoned uses the IANA zone across DST (16.725813ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (117.682316ms)
✔ two blocking decisions get two DMs with different nonces (116.988568ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.171321ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (98.658152ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (96.515541ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (89.289262ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (101.376665ms)
✔ polled every POLL_MS against a permanent 403, a DM is sent at 0, 30, 60, 90 and 120 min and gives up only then (127.353916ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (105.85298ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (99.002352ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (76.035595ms)
✔ an inbox read failure is logged and the next poll retries (0.67435ms)
✔ no Discord id reaches the journal or the log (50.4712ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (19.298507ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (15.863839ms)
✔ the journal: a whole file that is one torn line truncates to empty (15.787738ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (0.733316ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.624512ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (1.886773ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.332506ms)
✔ the journal: a dangling directory link, a parent that is a file and a journal that is a directory each refuse with exit 3 (0.479975ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.398142ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.448945ms)
✔ digest content stays within Discord's 2000 characters (0.294064ms)
✔ runLoop never overlaps ticks and stops after the one in flight (111.979436ms)
task.close {} answered: invalid-request; fake saw 18 requests, first GET /info 200, GET /projects/1 200, GET /projects/1/views 200
task.close on a missing task answered: task-not-found; it made GET /tasks/999 404
✔ bootConfig trackers reach the S3 adapter in the real broker child, which goes ready against a fake Vikunja (368.869723ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (39.457721ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2166.323928ms)
✔ busExit and refuseInsideAgent (0.395387ms)
✔ talk sends a REQUEST, prints and reads everything that arrives, and stops at the reply (187.472309ms)
✔ talk --wait 0 only sends; no reply within --wait exits 1 and says where it will show (1122.277389ms)
✔ talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors (79.595789ms)
✔ launches off and on go to the broker and change the business's launch state (76.556003ms)
✔ launches list reads sessions.json and marks a stale entry; stop reports it and refuses a non-runner (68.706144ms)
ℹ tests 82
ℹ suites 0
ℹ pass 82
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 7964.388585
@@ -0,0 +1,102 @@
✔ launch identity is stamped, payload identity is refused and stale holder cannot send (175.826567ms)
✔ decision classes route from policy; gated resolution is human-only, choice and target must match (268.860961ms)
✔ claim exclusion, holder release, gated revoke and rerouting to a new holder are atomic (271.683247ms)
✔ launch events require a human CLI capability; generic emit cannot forge authority events (186.811712ms)
✔ within-role decisions close atomically and invalid options or blocking omissions refuse (242.416615ms)
✔ observer capabilities read human inbox but cannot mutate or forge launch identity (226.884635ms)
✔ task action subjects and linked decision trail are complete and ordered (240.516482ms)
✔ launch binding is durable and reconnecting requires the identical trusted record (112.625817ms)
✔ business isolation includes inherited object names and cross-business message references (214.472286ms)
✔ authority never transfers between action, run, target, unresolved or replaced role holder (315.031447ms)
✔ task projection uses schema current view, skipping earlier and equal-start polls (179.983563ms)
✔ revocation permanently bars the old run from reclaiming first, including after broker restart (200.959345ms)
✔ empty message references refuse before storage; refusal-evidence failure stays a typed error (125.747465ms)
✔ both arbiters require human resolution when their cross-role route is themselves (190.776876ms)
✔ S1 adapter takes resolved limits and refs, rejects mismatched instance, never mutates input (1.892175ms)
✔ only validated broker references load; returned data and exceptions cannot expose a known token (5.049967ms)
✔ bad file modes, symlinks, repository/data paths, malformed tokens and missing dates refuse (2.019704ms)
✔ expiry refuses use and env references never become client data (0.588128ms)
✔ S1 parsed service refs work, service mismatch refuses, Gitea rotation due is a warning state (1.311728ms)
✔ opaque tokens shorter than 16 characters refuse before use (0.369558ms)
✔ endLaunch writes session.ended, releases the run claim and kills its capabilities (119.49428ms)
✔ endLaunch refuses an unknown run, a second end and a rebind of the ended run (122.730917ms)
✖ a restarted broker refuses to rebind an ended run; a refused rebind leaves the run unbound (153.743346ms)
✔ endLaunch leaves a claim another run took alone (170.522948ms)
✔ refuse records action.refused against the caller with the code only (138.954956ms)
✔ launches off refuses role.launch with launch-revoked until launches on (208.024904ms)
✔ broker process: launch ops authorize role.launch, record refusals and end runs (339.242223ms)
✔ human proof binds CLI entry, process start and nonce; agents and incomplete ancestry refuse (2.568342ms)
✔ process reader gets own kernel identity without exposing environment values (0.724472ms)
✔ EACCES ancestor environments skip only markers; commands and registered launches still refuse (1.076629ms)
✔ real pid 1 remains inspectable when its environment is protected (0.337982ms)
✔ within-role sends cite an open gated launch decision without spending it or naming it in grants (185.856069ms)
✔ missing and foreign-business citations refuse and roll back message and grant (193.266271ms)
✔ cross-role sends still need a matching resolved decision and consume it once (246.414013ms)
✔ broker process binds trusted launches, offers reader capabilities, refuses human mutation, closes cleanly (193.313699ms)
✔ startup token refusal returns safe code without value or partial listening broker (34.722496ms)
✔ loaded fixture token is absent from socket replies and SQLite, including refusal evidence (198.309309ms)
✔ killed broker leaves an explicit stale lock; another process cannot silently reclaim it (162.012783ms)
✔ trusted host registers later launches; socket clients never have a registration verb (178.600888ms)
✔ runtime excludes declared project roots even when host supplies no repoRoots (37.296474ms)
✔ a refused launch binding leaves the broker and existing capabilities alive; bad protocol stops it (224.977314ms)
✔ v3b prototype refusals, views and append-only mutations (1141.883601ms)
✔ gated approval authorizes once, survives store reopen, and fresh approval works (251.83052ms)
✔ another run cannot consume an approval; a failed check leaves it usable (229.201842ms)
✔ two scheduled callers have exactly one grant and one consumed refusal (168.502218ms)
✔ failed commit rolls consumption back; cross-role consumes and within-role stays reusable (368.781137ms)
✔ class drift gated to cross-role refuses before consumption (303.716619ms)
✔ class drift cross-role to gated refuses before consumption (305.8573ms)
✔ class drift gated to within-role refuses before consumption (210.931ms)
✔ class drift cross-role to within-role refuses before consumption (256.176102ms)
✔ class drift within-role to gated refuses before consumption (237.786434ms)
✔ class drift within-role to cross-role refuses before consumption (213.51124ms)
✔ message.send consumes approval and prevents a later send or authorize (205.615916ms)
✔ role.revoke consumes approval and prevents a later revoke or authorize (201.284131ms)
✔ creates private WAL store and excludes a second writer until explicit close (123.676876ms)
✔ rollback is atomic and schema metadata is checked against trusted DDL, not just itself (173.964344ms)
✔ existing empty database and symlink runtime directory refuse, never initialize over damage (173.329183ms)
✔ crash during a transaction recovers no partial event after explicit fixture-only lock removal (180.89419ms)
✔ writer refuses mixed at/read_at forms atomically, even through trusted SQL helpers (110.220299ms)
✔ async transactions refuse before invoking their function (90.935183ms)
✔ recordTask keeps sync reads and a role write apart (178.34485ms)
✔ read_at must be one canonical UTC format, so the projection compares strings safely (105.066353ms)
✔ a bad entry refuses the whole record (115.43348ms)
✔ taskView reads the projection for one business (124.036913ms)
✔ requestTask hands only a holder and a task verb to the handler, and records refusals (259.12632ms)
✔ the server sends task verbs to the adapter with its own timeout; other verbs stay synchronous (456.306481ms)
✔ without an adapter the server refuses every task verb (274.270368ms)
✔ the runtime refuses an invalid adapter and closes a valid one (247.228167ms)
✔ the process loads the S3 adapter from plain-data trackers (288.195365ms)
✔ socket capability stamps launch identity; shared views use wire, no SQL client (155.955841ms)
✔ two wire claims serialize; a lost reply never automatically retries (177.626273ms)
✔ malformed, oversized and identity-forging envelopes refuse without echoing input (118.252926ms)
✔ client preserves UTF-8 when a response divides a multibyte character (11.92216ms)
✔ committed mutation followed by dropped reply reports unknown and is never retried (151.54177ms)
ℹ tests 74
ℹ suites 0
ℹ pass 73
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 3036.102819
✖ failing tests:
test at packages/bus/tests/end-launch.test.mjs:73:1
✖ a restarted broker refuses to rebind an ended run; a refused rebind leaves the run unbound (153.743346ms)
AssertionError [ERR_ASSERTION]: Missing expected exception.
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41b/wt/packages/bus/tests/end-launch.test.mjs:79:10)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: undefined,
expected: /run-ended/,
operator: 'throws',
diff: 'simple'
}
@@ -0,0 +1,124 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (126.26257ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (122.587629ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (113.385796ms)
✔ decide prints a declining choice as declining (108.2869ms)
✔ an unknown outcome is reported once and never resent (114.02891ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (105.470563ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (76.845315ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (83.969966ms)
✔ every human command refuses inside an agent run before it touches the bus (100.618886ms)
✔ usage errors exit 4; no business and no host is a usage error (95.987002ms)
✔ agents and tasks print through the broker (92.102341ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (1.398543ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (43.136097ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (29.771523ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (31.208799ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (30.285756ms)
✔ a business without tracker.baseUrl gets no trackers entry (27.639752ms)
✔ an unknown business and a broken system config refuse with exit 3 (55.442178ms)
✔ empty views say so (0.701293ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (0.90513ms)
✔ tasks print the tracker fields the snapshot carries (0.149924ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (893.502822ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (236.939433ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (155.87989ms)
✔ a broker reply that misses the wait breaks the channel: the late reply answers nothing, later requests refuse, the host exits 1 (1161.248086ms)
✔ a broker reply with another request's id, or none, breaks the channel and the host exits 1 (272.620752ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (159.700918ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (162.774057ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (101.226481ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (162.527232ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (109.231631ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (161.452378ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (24.382363ms)
✔ bus stop refuses to signal a live pid that is not a bus host (203.125876ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (209.759823ms)
✔ bus start refuses with exit 3 without a notifier config (86.551547ms)
✔ bus start runs until bus stop; status reports it while it runs (658.850114ms)
✔ bus-service.sh renders the unit and installs it into a given directory (27.00563ms)
✔ bus start --pm: the PM runs under its own PID namespace, registered, with a manifest (1275.854375ms)
✔ the PM launches a coder through its launch tool; the coder answers; refusals name their code (602.096082ms)
✔ a runner that stops at once ends its launch with the runner's reason (207.426418ms)
✖ a host that died hard leaves its sessions to the next host, which kills them and ends their runs so the role can be launched again (652.976913ms)
✖ a host that died hard leaves its sessions to the next host, which finds one already exited (23) and ends their runs so the role can be launched again (3585.184654ms)
✔ a malformed sessions.json refuses the host start with exit 3 and stays as it was (122.253724ms)
✔ an over-long launch request is refused at once, not at the 10 s idle timeout (141.531945ms)
✔ a launch client that never closes its side doesn't hold the host's close (129.094621ms)
✔ a runner that ignores SIGTERM is killed when the host closes (1241.989909ms)
✔ zoned uses the IANA zone across DST (18.203626ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (120.846349ms)
✔ two blocking decisions get two DMs with different nonces (118.305315ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.26266ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (101.750783ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (92.510492ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (95.279304ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (110.580511ms)
✔ polled every POLL_MS against a permanent 403, a DM is sent at 0, 30, 60, 90 and 120 min and gives up only then (159.679295ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (149.947664ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (130.129762ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (83.888655ms)
✔ an inbox read failure is logged and the next poll retries (0.649593ms)
✔ no Discord id reaches the journal or the log (86.854487ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (27.125526ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (29.794834ms)
✔ the journal: a whole file that is one torn line truncates to empty (11.779919ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (0.57699ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.733241ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (1.817568ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.303436ms)
✔ the journal: a dangling directory link, a parent that is a file and a journal that is a directory each refuse with exit 3 (0.470677ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.352871ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.440279ms)
✔ digest content stays within Discord's 2000 characters (0.253447ms)
✔ runLoop never overlaps ticks and stops after the one in flight (110.694729ms)
task.close {} answered: invalid-request; fake saw 18 requests, first GET /info 200, GET /projects/1 200, GET /projects/1/views 200
task.close on a missing task answered: task-not-found; it made GET /tasks/999 404
✔ bootConfig trackers reach the S3 adapter in the real broker child, which goes ready against a fake Vikunja (355.095149ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (35.17381ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2138.478585ms)
✔ busExit and refuseInsideAgent (0.401007ms)
✔ talk sends a REQUEST, prints and reads everything that arrives, and stops at the reply (210.447374ms)
✔ talk --wait 0 only sends; no reply within --wait exits 1 and says where it will show (1143.25547ms)
✔ talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors (103.818557ms)
✔ launches off and on go to the broker and change the business's launch state (85.560269ms)
✔ launches list reads sessions.json and marks a stale entry; stop reports it and refuses a non-runner (72.47853ms)
ℹ tests 82
ℹ suites 0
ℹ pass 80
ℹ fail 2
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 8048.227984
✖ failing tests:
test at packages/cli/tests/launcher.test.mjs:253:3
✖ a host that died hard leaves its sessions to the next host, which kills them and ends their runs so the role can be launched again (652.976913ms)
AssertionError [ERR_ASSERTION]: run rfd10ac1e8ea4 (pm) from an earlier host ended: host-lost
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41b/wt/packages/cli/tests/launcher.test.mjs:308:12)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: false,
expected: true,
operator: '==',
diff: 'simple'
}
test at packages/cli/tests/launcher.test.mjs:253:3
✖ a host that died hard leaves its sessions to the next host, which finds one already exited (23) and ends their runs so the role can be launched again (3585.184654ms)
AssertionError [ERR_ASSERTION]: run r8f9ea986fe09 (pm) from an earlier host ended: host-lost
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41b/wt/packages/cli/tests/launcher.test.mjs:308:12)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: false,
expected: true,
operator: '==',
diff: 'simple'
}
@@ -0,0 +1,137 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (114.924706ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (139.856708ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (164.979267ms)
✔ decide prints a declining choice as declining (104.117279ms)
✔ an unknown outcome is reported once and never resent (84.975621ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (77.461823ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (85.020978ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (72.178605ms)
✔ every human command refuses inside an agent run before it touches the bus (73.770936ms)
✔ usage errors exit 4; no business and no host is a usage error (88.478931ms)
✔ agents and tasks print through the broker (92.537743ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (1.125131ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (39.654827ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (32.193424ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (30.69513ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (34.294892ms)
✔ a business without tracker.baseUrl gets no trackers entry (30.904946ms)
✔ an unknown business and a broken system config refuse with exit 3 (57.72543ms)
✔ empty views say so (0.930348ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (0.916593ms)
✔ tasks print the tracker fields the snapshot carries (0.19946ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (912.063406ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (218.563894ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (139.935075ms)
✔ a broker reply that misses the wait breaks the channel: the late reply answers nothing, later requests refuse, the host exits 1 (1122.68701ms)
✔ a broker reply with another request's id, or none, breaks the channel and the host exits 1 (224.906111ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (156.620082ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (144.463327ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (102.863039ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (152.969174ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (108.27205ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (176.554181ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (23.0238ms)
✔ bus stop refuses to signal a live pid that is not a bus host (202.545734ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (211.140698ms)
✔ bus start refuses with exit 3 without a notifier config (87.559733ms)
✔ bus start runs until bus stop; status reports it while it runs (657.201834ms)
✔ bus-service.sh renders the unit and installs it into a given directory (26.508444ms)
✖ bus start --pm: the PM runs under its own PID namespace, registered, with a manifest (254.585334ms)
✖ the PM launches a coder through its launch tool; the coder answers; refusals name their code (590.273225ms)
✔ a runner that stops at once ends its launch with the runner's reason (232.427964ms)
✔ a host that died hard leaves its sessions to the next host, which kills them and ends their runs so the role can be launched again (673.190299ms)
✔ a host that died hard leaves its sessions to the next host, which finds one already exited (23) and ends their runs so the role can be launched again (3568.794721ms)
✔ a malformed sessions.json refuses the host start with exit 3 and stays as it was (110.916678ms)
✔ an over-long launch request is refused at once, not at the 10 s idle timeout (115.609679ms)
✔ a launch client that never closes its side doesn't hold the host's close (122.872825ms)
✔ a runner that ignores SIGTERM is killed when the host closes (1220.581529ms)
✔ zoned uses the IANA zone across DST (17.027978ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (115.264119ms)
✔ two blocking decisions get two DMs with different nonces (140.488003ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.176949ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (153.766588ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (93.240357ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (96.146403ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (80.318033ms)
✔ polled every POLL_MS against a permanent 403, a DM is sent at 0, 30, 60, 90 and 120 min and gives up only then (143.67716ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (117.870439ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (102.613945ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (69.197617ms)
✔ an inbox read failure is logged and the next poll retries (0.602564ms)
✔ no Discord id reaches the journal or the log (64.714634ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (23.527415ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (33.810355ms)
✔ the journal: a whole file that is one torn line truncates to empty (19.310715ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (2.017871ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.600636ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (1.703496ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.415299ms)
✔ the journal: a dangling directory link, a parent that is a file and a journal that is a directory each refuse with exit 3 (0.470597ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.374525ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.435053ms)
✔ digest content stays within Discord's 2000 characters (0.251933ms)
✔ runLoop never overlaps ticks and stops after the one in flight (110.674121ms)
task.close {} answered: invalid-request; fake saw 18 requests, first GET /info 200, GET /projects/1 200, GET /projects/1/views 200
task.close on a missing task answered: task-not-found; it made GET /tasks/999 404
✔ bootConfig trackers reach the S3 adapter in the real broker child, which goes ready against a fake Vikunja (392.840776ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (32.321136ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2145.259182ms)
✔ busExit and refuseInsideAgent (0.431683ms)
✔ talk sends a REQUEST, prints and reads everything that arrives, and stops at the reply (226.799451ms)
✔ talk --wait 0 only sends; no reply within --wait exits 1 and says where it will show (1186.533855ms)
✔ talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors (76.542107ms)
✔ launches off and on go to the broker and change the business's launch state (64.933849ms)
✔ launches list reads sessions.json and marks a stale entry; stop reports it and refuses a non-runner (58.812077ms)
ℹ tests 82
ℹ suites 0
ℹ pass 80
ℹ fail 2
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 6968.504134
✖ failing tests:
test at packages/cli/tests/launcher.test.mjs:106:1
✖ bus start --pm: the PM runs under its own PID namespace, registered, with a manifest (254.585334ms)
AssertionError [ERR_ASSERTION]: The input did not match the regular expression /PM launch refused: instance-running/. Input:
'CliError: PM launch refused: capacity-full (opus sessions: 1 of 1)'
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41b/wt/packages/cli/tests/launcher.test.mjs:138:3)
at async Test.run (node:internal/test_runner/test:1409:7)
at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: CliError: PM launch refused: capacity-full (opus sessions: 1 of 1)
at file:///home/jwoltje/darkwing-scratch/r41b/wt/packages/cli/src/launcher.mjs:297:15
at process.processTicksAndRejections (node:internal/process/task_queues:104:5),
expected: /PM launch refused: instance-running/,
operator: 'rejects',
diff: 'simple'
}
test at packages/cli/tests/launcher.test.mjs:176:1
✖ the PM launches a coder through its launch tool; the coder answers; refusals name their code (590.273225ms)
AssertionError [ERR_ASSERTION]: coder
+ actual - expected
{
+ error: 'capacity-full',
- error: 'instance-running',
ok: false
}
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41b/wt/packages/cli/tests/launcher.test.mjs:210:12)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: { ok: false, error: 'capacity-full' },
expected: { ok: false, error: 'instance-running' },
operator: 'deepStrictEqual',
diff: 'simple'
}
@@ -0,0 +1,112 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (111.255138ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (125.508938ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (91.896149ms)
✔ decide prints a declining choice as declining (106.175686ms)
✔ an unknown outcome is reported once and never resent (74.094549ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (71.417455ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (74.132084ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (47.139946ms)
✔ every human command refuses inside an agent run before it touches the bus (69.971054ms)
✔ usage errors exit 4; no business and no host is a usage error (62.578864ms)
✔ agents and tasks print through the broker (80.062189ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (1.098569ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (43.309013ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (30.748981ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (29.483062ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (31.191949ms)
✔ a business without tracker.baseUrl gets no trackers entry (28.935574ms)
✔ an unknown business and a broken system config refuse with exit 3 (52.639812ms)
✔ empty views say so (0.808333ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (0.909846ms)
✔ tasks print the tracker fields the snapshot carries (0.130227ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (876.127063ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (190.204189ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (108.089833ms)
✔ a broker reply that misses the wait breaks the channel: the late reply answers nothing, later requests refuse, the host exits 1 (1119.713427ms)
✔ a broker reply with another request's id, or none, breaks the channel and the host exits 1 (238.83377ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (157.529766ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (149.067871ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (101.662917ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (150.941681ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (116.969654ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (156.019112ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (21.18589ms)
✔ bus stop refuses to signal a live pid that is not a bus host (203.049794ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (209.851989ms)
✔ bus start refuses with exit 3 without a notifier config (87.657993ms)
✔ bus start runs until bus stop; status reports it while it runs (654.730775ms)
✔ bus-service.sh renders the unit and installs it into a given directory (26.75261ms)
✔ bus start --pm: the PM runs under its own PID namespace, registered, with a manifest (1259.731975ms)
✖ the PM launches a coder through its launch tool; the coder answers; refusals name their code (589.610248ms)
✔ a runner that stops at once ends its launch with the runner's reason (194.190384ms)
✔ a host that died hard leaves its sessions to the next host, which kills them and ends their runs so the role can be launched again (640.14369ms)
✔ a host that died hard leaves its sessions to the next host, which finds one already exited (23) and ends their runs so the role can be launched again (3580.02733ms)
✔ a malformed sessions.json refuses the host start with exit 3 and stays as it was (109.908691ms)
✔ an over-long launch request is refused at once, not at the 10 s idle timeout (120.591714ms)
✔ a launch client that never closes its side doesn't hold the host's close (131.283386ms)
✔ a runner that ignores SIGTERM is killed when the host closes (1233.962544ms)
✔ zoned uses the IANA zone across DST (17.874488ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (97.570286ms)
✔ two blocking decisions get two DMs with different nonces (113.227481ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.26896ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (95.38752ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (102.453301ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (87.404188ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (84.295629ms)
✔ polled every POLL_MS against a permanent 403, a DM is sent at 0, 30, 60, 90 and 120 min and gives up only then (118.959061ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (110.50822ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (92.011524ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (52.275838ms)
✔ an inbox read failure is logged and the next poll retries (0.636761ms)
✔ no Discord id reaches the journal or the log (64.498208ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (10.043392ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (14.377898ms)
✔ the journal: a whole file that is one torn line truncates to empty (9.560266ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (0.717191ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.820422ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (2.612272ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.436825ms)
✔ the journal: a dangling directory link, a parent that is a file and a journal that is a directory each refuse with exit 3 (0.682133ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.575002ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.637491ms)
✔ digest content stays within Discord's 2000 characters (0.433103ms)
✔ runLoop never overlaps ticks and stops after the one in flight (111.362252ms)
task.close {} answered: invalid-request; fake saw 18 requests, first GET /info 200, GET /projects/1 200, GET /projects/1/views 200
task.close on a missing task answered: task-not-found; it made GET /tasks/999 404
✔ bootConfig trackers reach the S3 adapter in the real broker child, which goes ready against a fake Vikunja (353.608227ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (41.611495ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2145.34634ms)
✔ busExit and refuseInsideAgent (0.470134ms)
✔ talk sends a REQUEST, prints and reads everything that arrives, and stops at the reply (193.65513ms)
✔ talk --wait 0 only sends; no reply within --wait exits 1 and says where it will show (1112.280055ms)
✔ talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors (69.811538ms)
✔ launches off and on go to the broker and change the business's launch state (73.371508ms)
✔ launches list reads sessions.json and marks a stale entry; stop reports it and refuses a non-runner (67.252505ms)
ℹ tests 82
ℹ suites 0
ℹ pass 81
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 7942.958651
✖ failing tests:
test at packages/cli/tests/launcher.test.mjs:176:1
✖ the PM launches a coder through its launch tool; the coder answers; refusals name their code (589.610248ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
true !== false
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41b/wt/packages/cli/tests/launcher.test.mjs:218:10)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: true,
expected: false,
operator: 'strictEqual',
diff: 'simple'
}
@@ -0,0 +1,83 @@
✔ sessionModel: agent vars win, then the system's execution settings (13.755567ms)
✔ a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory (5.041707ms)
✖ a claude-code bundle adds the wrapped gate hook and the MCP config (3.303408ms)
✔ a bundle is written once: an existing file refuses (2.427304ms)
✔ a path with a single quote can't go into the hook command (1.929757ms)
✔ allow exits 0, a deny exits 2 with the reason on stderr (124.002277ms)
✔ a missing or wrong policy, or a bad event, exits 2 (90.738011ms)
✔ the bundle's wrapped command: a missing gate or node still blocks (1091.680831ms)
✔ claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist (788.259361ms)
✔ claude: the hook alone blocks a path outside the workspace (451.984188ms)
✔ claude: a second turn resumes the first turn's session (695.642152ms)
✔ claude adapter: --restricted is always passed (5.390974ms)
✔ claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do (741.569967ms)
✔ claude: a missing hook or MCP file refuses before claude starts (8.422858ms)
✔ pi: policy tools and typed tools pass, anything else is blocked (2.716548ms)
✔ claude: builtins map from pi names, typed tools need the mcp prefix (0.775047ms)
✔ file tool paths must resolve inside the workspace (0.898308ms)
✔ pi's own path normalisation can't be used to step out (1.037538ms)
✔ a symlink inside the workspace that points out is outside (0.809608ms)
✔ a dangling symlink is refused at any depth, in both harnesses (3.478412ms)
✔ claude path fields per tool (0.915426ms)
✔ glob patterns stay inside the workspace (0.869385ms)
✔ a path that can't be checked is blocked (0.542139ms)
✔ initialize, ping and tools/list (45.777057ms)
✔ tools/call goes through the tool socket; a refusal is an isError result (32.557805ms)
✔ unknown tools and methods are JSON-RPC errors and never reach the socket (33.320422ms)
✔ a missing argument is a usage error (29.77781ms)
✔ pi: typed tools reach the socket, the gate blocks, agent_end writes the marker (398.451117ms)
✔ pi: a write through a dangling symlink is blocked, and nothing appears outside (341.426143ms)
✔ pi: a missing extension refuses before any model call (6.625067ms)
✔ pi: an extension without its configuration fails pi's start (254.365203ms)
✔ founderCheck: founder variables, then a needed service without a usable token (1.33242ms)
✔ turnRequest names the sender, class, reply and decision (0.273994ms)
✔ a message becomes a turn, the answer goes back as a RESULT, SIGTERM releases and exits 0 (259.532962ms)
✔ a SIGTERM before the claim stops the runner with exit 0 and no claim (126.492501ms)
✔ typed tools carry the runner's capability; launch goes to the host's launch socket (430.083188ms)
✔ a RESULT gets no automatic reply; failed turns reply with the reason (1291.975499ms)
✔ SIGTERM during a turn kills the turn's process group and still exits 0 (161.287704ms)
✔ founder credentials stop before the claim (20) (178.200231ms)
✔ a refused claim exits 21; an ended run's capability exits 22 (193.500108ms)
✔ the launch ending under a running session exits 22 (159.092117ms)
✔ a broker that stays unreachable exits 23 after brokerRetries polls (245.273136ms)
✔ a broker that is down at the claim exits 23, not 21 (87.433738ms)
✔ no capability, or a malformed one, on stdin exits 2 (193.512304ms)
✔ a missing or malformed policy exits 2 before the claim (132.080472ms)
✔ the PM gets launch, its task verbs and the reads (7.388611ms)
✔ a coder gets no launch, no resolve_decision, and no task tools without a tracker (2.737055ms)
✔ launch only when the business's launch block names the instance as launcher (1.442548ms)
✔ an action outside the instance's authority has no tool (2.266333ms)
✔ callTool: one JSON line out, the result back, a refusal rejects (8.989301ms)
ℹ tests 50
ℹ suites 0
ℹ pass 49
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 10395.052827
✖ failing tests:
test at packages/harness/tests/bundle.test.mjs:70:1
✖ a claude-code bundle adds the wrapped gate hook and the MCP config (3.303408ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
+ actual - expected
+ "timeout -k 2 10 '/usr/bin/node' '/home/jwoltje/darkwing-scratch/r41b/wt/packages/harness/src/claude-gate.mjs' '/home/jwoltje/darkwing-scratch/tmp/mosaic-harness-OBpfCk/bundle/policy.json'"
- "timeout -k 2 10 '/usr/bin/node' '/home/jwoltje/darkwing-scratch/r41b/wt/packages/harness/src/claude-gate.mjs' '/home/jwoltje/darkwing-scratch/tmp/mosaic-harness-OBpfCk/bundle/policy.json' || exit 2"
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41b/wt/packages/harness/tests/bundle.test.mjs:78:10)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: "timeout -k 2 10 '/usr/bin/node' '/home/jwoltje/darkwing-scratch/r41b/wt/packages/harness/src/claude-gate.mjs' '/home/jwoltje/darkwing-scratch/tmp/mosaic-harness-OBpfCk/bundle/policy.json'",
expected: "timeout -k 2 10 '/usr/bin/node' '/home/jwoltje/darkwing-scratch/r41b/wt/packages/harness/src/claude-gate.mjs' '/home/jwoltje/darkwing-scratch/tmp/mosaic-harness-OBpfCk/bundle/policy.json' || exit 2",
operator: 'strictEqual',
diff: 'simple'
}
@@ -0,0 +1,92 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (111.845866ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (146.30493ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (162.511453ms)
✔ decide prints a declining choice as declining (134.343106ms)
✔ an unknown outcome is reported once and never resent (155.120921ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (110.249306ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (162.445035ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (154.95565ms)
✔ every human command refuses inside an agent run before it touches the bus (113.663842ms)
✔ usage errors exit 4; no business and no host is a usage error (167.959898ms)
✔ agents and tasks print through the broker (101.072847ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (1.600865ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (36.975588ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (29.590365ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (28.818021ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (32.286288ms)
✔ a business without tracker.baseUrl gets no trackers entry (27.839286ms)
✔ an unknown business and a broken system config refuse with exit 3 (49.857332ms)
✔ empty views say so (0.696065ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (0.769945ms)
✔ tasks print the tracker fields the snapshot carries (0.130196ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (926.134033ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (262.732258ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (203.540492ms)
✔ a broker reply that misses the wait breaks the channel: the late reply answers nothing, later requests refuse, the host exits 1 (1151.811701ms)
✔ a broker reply with another request's id, or none, breaks the channel and the host exits 1 (241.844428ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (163.764777ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (166.480486ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (119.825338ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (150.916296ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (102.807974ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (173.317435ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (21.703006ms)
✔ bus stop refuses to signal a live pid that is not a bus host (202.903416ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (203.87514ms)
✔ bus start refuses with exit 3 without a notifier config (88.386894ms)
✔ bus start runs until bus stop; status reports it while it runs (664.97357ms)
✔ bus-service.sh renders the unit and installs it into a given directory (26.464996ms)
✔ bus start --pm: the PM runs under its own PID namespace, registered, with a manifest (271.460122ms)
✔ the PM launches a coder through its launch tool; the coder answers; refusals name their code (822.114219ms)
✔ a runner that stops at once ends its launch with the runner's reason (264.778461ms)
✔ a host that died hard leaves its sessions to the next host, which kills them and ends their runs so the role can be launched again (710.712905ms)
✔ a host that died hard leaves its sessions to the next host, which finds one already exited (23) and ends their runs so the role can be launched again (3586.191732ms)
✔ a malformed sessions.json refuses the host start with exit 3 and stays as it was (109.435485ms)
✔ an over-long launch request is refused at once, not at the 10 s idle timeout (119.93556ms)
✔ a launch client that never closes its side doesn't hold the host's close (117.106423ms)
✔ a runner that ignores SIGTERM is killed when the host closes (1230.796161ms)
✔ zoned uses the IANA zone across DST (13.655927ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (115.040495ms)
✔ two blocking decisions get two DMs with different nonces (144.766928ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.16895ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (154.750768ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (135.255437ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (165.891369ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (123.211822ms)
✔ polled every POLL_MS against a permanent 403, a DM is sent at 0, 30, 60, 90 and 120 min and gives up only then (210.647519ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (174.013402ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (199.365824ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (106.522535ms)
✔ an inbox read failure is logged and the next poll retries (0.623885ms)
✔ no Discord id reaches the journal or the log (98.496427ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (37.499023ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (47.087475ms)
✔ the journal: a whole file that is one torn line truncates to empty (21.766055ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (2.391083ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.832608ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (4.216815ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.396706ms)
✔ the journal: a dangling directory link, a parent that is a file and a journal that is a directory each refuse with exit 3 (0.491873ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.397125ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.434077ms)
✔ digest content stays within Discord's 2000 characters (0.28782ms)
✔ runLoop never overlaps ticks and stops after the one in flight (111.466363ms)
task.close {} answered: invalid-request; fake saw 18 requests, first GET /info 200, GET /projects/1 200, GET /projects/1/views 200
task.close on a missing task answered: task-not-found; it made GET /tasks/999 404
✔ bootConfig trackers reach the S3 adapter in the real broker child, which goes ready against a fake Vikunja (379.099406ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (30.405542ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2139.665054ms)
✔ busExit and refuseInsideAgent (0.575151ms)
✔ talk sends a REQUEST, prints and reads everything that arrives, and stops at the reply (227.00156ms)
✔ talk --wait 0 only sends; no reply within --wait exits 1 and says where it will show (1194.435801ms)
✔ talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors (110.717728ms)
✔ launches off and on go to the broker and change the business's launch state (111.76061ms)
✔ launches list reads sessions.json and marks a stale entry; stop reports it and refuses a non-runner (88.44437ms)
ℹ tests 82
ℹ suites 0
ℹ pass 82
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 7312.207082
@@ -0,0 +1,94 @@
✔ sessionModel: agent vars win, then the system's execution settings (13.222054ms)
✔ a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory (4.78026ms)
✔ a claude-code bundle adds the wrapped gate hook and the MCP config (2.683832ms)
✔ a bundle is written once: an existing file refuses (1.889732ms)
✔ a path with a single quote can't go into the hook command (2.459797ms)
✔ allow exits 0, a deny exits 2 with the reason on stderr (116.145984ms)
✔ a missing or wrong policy, or a bad event, exits 2 (81.895431ms)
✔ the bundle's wrapped command: a missing gate or node still blocks (1091.425201ms)
✔ claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist (739.790986ms)
✔ claude: the hook alone blocks a path outside the workspace (435.342932ms)
✔ claude: a second turn resumes the first turn's session (693.268054ms)
✔ claude adapter: --restricted is always passed (5.160173ms)
✔ claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do (749.610877ms)
✔ claude: a missing hook or MCP file refuses before claude starts (7.62828ms)
✔ pi: policy tools and typed tools pass, anything else is blocked (3.07337ms)
✔ claude: builtins map from pi names, typed tools need the mcp prefix (0.709143ms)
✔ file tool paths must resolve inside the workspace (1.022203ms)
✔ pi's own path normalisation can't be used to step out (1.200294ms)
✔ a symlink inside the workspace that points out is outside (1.003455ms)
✔ a dangling symlink is refused at any depth, in both harnesses (2.426274ms)
✔ claude path fields per tool (0.831067ms)
✔ glob patterns stay inside the workspace (0.981382ms)
✔ a path that can't be checked is blocked (0.437563ms)
✔ initialize, ping and tools/list (44.417769ms)
✔ tools/call goes through the tool socket; a refusal is an isError result (35.50608ms)
✔ unknown tools and methods are JSON-RPC errors and never reach the socket (31.848439ms)
✔ a missing argument is a usage error (32.572346ms)
✔ pi: typed tools reach the socket, the gate blocks, agent_end writes the marker (355.138802ms)
✔ pi: a write through a dangling symlink is blocked, and nothing appears outside (325.286355ms)
✔ pi: a missing extension refuses before any model call (6.428672ms)
✔ pi: an extension without its configuration fails pi's start (252.02911ms)
✖ founderCheck: founder variables, then a needed service without a usable token (1.499708ms)
✔ turnRequest names the sender, class, reply and decision (0.26081ms)
✔ a message becomes a turn, the answer goes back as a RESULT, SIGTERM releases and exits 0 (246.005721ms)
✔ a SIGTERM before the claim stops the runner with exit 0 and no claim (112.478101ms)
✔ typed tools carry the runner's capability; launch goes to the host's launch socket (367.945897ms)
✔ a RESULT gets no automatic reply; failed turns reply with the reason (1303.943908ms)
✔ SIGTERM during a turn kills the turn's process group and still exits 0 (140.961595ms)
✖ founder credentials stop before the claim (20) (60049.519604ms)
✔ a refused claim exits 21; an ended run's capability exits 22 (190.97907ms)
✔ the launch ending under a running session exits 22 (147.854938ms)
✔ a broker that stays unreachable exits 23 after brokerRetries polls (252.983893ms)
✔ a broker that is down at the claim exits 23, not 21 (92.036605ms)
✔ no capability, or a malformed one, on stdin exits 2 (183.523093ms)
✔ a missing or malformed policy exits 2 before the claim (124.350561ms)
✔ the PM gets launch, its task verbs and the reads (6.555341ms)
✔ a coder gets no launch, no resolve_decision, and no task tools without a tracker (2.985532ms)
✔ launch only when the business's launch block names the instance as launcher (1.758363ms)
✔ an action outside the instance's authority has no tool (1.720371ms)
✔ callTool: one JSON line out, the result back, a refusal rejects (8.05331ms)
ℹ tests 50
ℹ suites 0
ℹ pass 48
ℹ fail 2
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 63313.063221
✖ failing tests:
test at packages/harness/tests/runner.test.mjs:34:1
✖ founderCheck: founder variables, then a needed service without a usable token (1.499708ms)
AssertionError [ERR_ASSERTION]: The "string" argument must be of type string. Received type object (null)
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41b/wt/packages/harness/tests/runner.test.mjs:38:10)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.start (node:internal/test_runner/test:1262:17)
at startSubtestAfterBootstrap (node:internal/test_runner/harness:387:17) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: null,
expected: /GITEA_TOKEN, SSH_AUTH_SOCK/,
operator: 'match',
diff: 'simple'
}
test at packages/harness/tests/runner.test.mjs:261:1
✖ founder credentials stop before the claim (20) (60049.519604ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
null !== 20
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41b/wt/packages/harness/tests/runner.test.mjs:264:10)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: null,
expected: 20,
operator: 'strictEqual',
diff: 'simple'
}
@@ -0,0 +1,101 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (117.884395ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (142.011649ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (120.565159ms)
✔ decide prints a declining choice as declining (120.18831ms)
✔ an unknown outcome is reported once and never resent (94.348464ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (110.154499ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (136.631424ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (119.43147ms)
✔ every human command refuses inside an agent run before it touches the bus (107.417145ms)
✔ usage errors exit 4; no business and no host is a usage error (101.306696ms)
✔ agents and tasks print through the broker (99.437974ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (2.687822ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (37.224575ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (32.166755ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (31.699704ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (28.709599ms)
✔ a business without tracker.baseUrl gets no trackers entry (26.272125ms)
✔ an unknown business and a broken system config refuse with exit 3 (55.207227ms)
✔ empty views say so (0.896314ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (0.95593ms)
✔ tasks print the tracker fields the snapshot carries (0.129415ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (892.156207ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (236.422577ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (168.772559ms)
✔ a broker reply that misses the wait breaks the channel: the late reply answers nothing, later requests refuse, the host exits 1 (1156.932237ms)
✔ a broker reply with another request's id, or none, breaks the channel and the host exits 1 (236.897622ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (166.220305ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (143.076602ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (101.342069ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (148.698243ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (109.610181ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (165.57878ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (22.954287ms)
✔ bus stop refuses to signal a live pid that is not a bus host (202.699908ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (207.538009ms)
✔ bus start refuses with exit 3 without a notifier config (86.933925ms)
✔ bus start runs until bus stop; status reports it while it runs (649.393107ms)
✔ bus-service.sh renders the unit and installs it into a given directory (26.650687ms)
✔ bus start --pm: the PM runs under its own PID namespace, registered, with a manifest (286.741186ms)
✔ the PM launches a coder through its launch tool; the coder answers; refusals name their code (718.573572ms)
✔ a runner that stops at once ends its launch with the runner's reason (249.070572ms)
✔ a host that died hard leaves its sessions to the next host, which kills them and ends their runs so the role can be launched again (681.150741ms)
✔ a host that died hard leaves its sessions to the next host, which finds one already exited (23) and ends their runs so the role can be launched again (3554.604244ms)
✔ a malformed sessions.json refuses the host start with exit 3 and stays as it was (112.953704ms)
✔ an over-long launch request is refused at once, not at the 10 s idle timeout (115.357289ms)
✔ a launch client that never closes its side doesn't hold the host's close (118.67376ms)
✖ a runner that ignores SIGTERM is killed when the host closes (15218.208953ms)
✔ zoned uses the IANA zone across DST (13.770034ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (118.558601ms)
✔ two blocking decisions get two DMs with different nonces (135.781266ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.283942ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (101.862997ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (115.11545ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (113.676503ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (104.222471ms)
✔ polled every POLL_MS against a permanent 403, a DM is sent at 0, 30, 60, 90 and 120 min and gives up only then (150.320875ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (151.404666ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (134.742524ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (91.995184ms)
✔ an inbox read failure is logged and the next poll retries (0.639087ms)
✔ no Discord id reaches the journal or the log (101.837031ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (10.873146ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (16.645806ms)
✔ the journal: a whole file that is one torn line truncates to empty (31.302415ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (2.42336ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.6205ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (1.740171ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.367992ms)
✔ the journal: a dangling directory link, a parent that is a file and a journal that is a directory each refuse with exit 3 (0.447342ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.340741ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.402334ms)
✔ digest content stays within Discord's 2000 characters (0.245244ms)
✔ runLoop never overlaps ticks and stops after the one in flight (110.380418ms)
task.close {} answered: invalid-request; fake saw 18 requests, first GET /info 200, GET /projects/1 200, GET /projects/1/views 200
task.close on a missing task answered: task-not-found; it made GET /tasks/999 404
✔ bootConfig trackers reach the S3 adapter in the real broker child, which goes ready against a fake Vikunja (372.420053ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (31.887808ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2129.199091ms)
✔ busExit and refuseInsideAgent (0.410951ms)
✔ talk sends a REQUEST, prints and reads everything that arrives, and stops at the reply (218.257861ms)
✔ talk --wait 0 only sends; no reply within --wait exits 1 and says where it will show (1129.14848ms)
✔ talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors (112.196687ms)
✔ launches off and on go to the broker and change the business's launch state (74.865699ms)
✔ launches list reads sessions.json and marks a stale entry; stop reports it and refuses a non-runner (45.96292ms)
ℹ tests 82
ℹ suites 0
ℹ pass 81
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 21130.146212
✖ failing tests:
test at packages/cli/tests/launcher.test.mjs:369:1
✖ a runner that ignores SIGTERM is killed when the host closes (15218.208953ms)
Error: close took over 15000 ms
at Timeout._onTimeout (file:///home/jwoltje/darkwing-scratch/r41b/wt/packages/cli/tests/launcher.test.mjs:324:101)
at listOnTimeout (node:internal/timers:685:17)
at process.processTimers (node:internal/timers:618:7)
@@ -0,0 +1,114 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (109.913293ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (118.003851ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (112.137885ms)
✔ decide prints a declining choice as declining (89.265726ms)
✔ an unknown outcome is reported once and never resent (89.912343ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (85.265998ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (52.238147ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (68.233399ms)
✔ every human command refuses inside an agent run before it touches the bus (67.127441ms)
✔ usage errors exit 4; no business and no host is a usage error (71.682669ms)
✔ agents and tasks print through the broker (79.521375ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (1.228315ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (38.366322ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (31.004033ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (32.779757ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (35.905233ms)
✔ a business without tracker.baseUrl gets no trackers entry (26.900284ms)
✔ an unknown business and a broken system config refuse with exit 3 (50.20401ms)
✔ empty views say so (0.627154ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (0.788734ms)
✔ tasks print the tracker fields the snapshot carries (0.125733ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (880.818489ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (212.656955ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (107.839478ms)
✔ a broker reply that misses the wait breaks the channel: the late reply answers nothing, later requests refuse, the host exits 1 (1114.602676ms)
✔ a broker reply with another request's id, or none, breaks the channel and the host exits 1 (237.868839ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (165.633158ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (196.857419ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (106.479174ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (154.774028ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (112.580721ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (164.633695ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (22.667092ms)
✔ bus stop refuses to signal a live pid that is not a bus host (202.90696ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (220.56323ms)
✔ bus start refuses with exit 3 without a notifier config (91.521228ms)
✔ bus start runs until bus stop; status reports it while it runs (654.540544ms)
✔ bus-service.sh renders the unit and installs it into a given directory (28.054852ms)
✔ bus start --pm: the PM runs under its own PID namespace, registered, with a manifest (255.366834ms)
✔ the PM launches a coder through its launch tool; the coder answers; refusals name their code (633.879827ms)
✔ a runner that stops at once ends its launch with the runner's reason (238.334932ms)
✖ a host that died hard leaves its sessions to the next host, which kills them and ends their runs so the role can be launched again (5389.23578ms)
✔ a host that died hard leaves its sessions to the next host, which finds one already exited (23) and ends their runs so the role can be launched again (3591.216651ms)
✔ a malformed sessions.json refuses the host start with exit 3 and stays as it was (109.661685ms)
✔ an over-long launch request is refused at once, not at the 10 s idle timeout (109.2473ms)
✔ a launch client that never closes its side doesn't hold the host's close (117.02329ms)
✔ a runner that ignores SIGTERM is killed when the host closes (1223.335505ms)
✔ zoned uses the IANA zone across DST (24.090291ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (107.844441ms)
✔ two blocking decisions get two DMs with different nonces (108.944801ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.180553ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (114.135107ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (92.814309ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (89.814961ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (91.252846ms)
✔ polled every POLL_MS against a permanent 403, a DM is sent at 0, 30, 60, 90 and 120 min and gives up only then (107.781293ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (111.991959ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (112.477383ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (90.654855ms)
✔ an inbox read failure is logged and the next poll retries (0.62123ms)
✔ no Discord id reaches the journal or the log (57.000052ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (18.664385ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (20.990527ms)
✔ the journal: a whole file that is one torn line truncates to empty (10.493841ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (0.584681ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.551121ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (1.801933ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.307542ms)
✔ the journal: a dangling directory link, a parent that is a file and a journal that is a directory each refuse with exit 3 (0.475672ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.351519ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.426221ms)
✔ digest content stays within Discord's 2000 characters (0.257949ms)
✔ runLoop never overlaps ticks and stops after the one in flight (110.73641ms)
task.close {} answered: invalid-request; fake saw 18 requests, first GET /info 200, GET /projects/1 200, GET /projects/1/views 200
task.close on a missing task answered: task-not-found; it made GET /tasks/999 404
✔ bootConfig trackers reach the S3 adapter in the real broker child, which goes ready against a fake Vikunja (346.30492ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (34.874451ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2130.964328ms)
✔ busExit and refuseInsideAgent (0.412311ms)
✔ talk sends a REQUEST, prints and reads everything that arrives, and stops at the reply (195.456423ms)
✔ talk --wait 0 only sends; no reply within --wait exits 1 and says where it will show (1130.733553ms)
✔ talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors (47.180538ms)
✔ launches off and on go to the broker and change the business's launch state (56.131748ms)
✔ launches list reads sessions.json and marks a stale entry; stop reports it and refuses a non-runner (54.65592ms)
ℹ tests 82
ℹ suites 0
ℹ pass 81
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 11744.146829
✖ failing tests:
test at packages/cli/tests/launcher.test.mjs:253:3
✖ a host that died hard leaves its sessions to the next host, which kills them and ends their runs so the role can be launched again (5389.23578ms)
AssertionError [ERR_ASSERTION]: the old session process is gone
+ actual - expected
+ '293295372'
- null
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41b/wt/packages/cli/tests/launcher.test.mjs:288:12)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: '293295372',
expected: null,
operator: 'strictEqual',
diff: 'simple'
}
@@ -0,0 +1,79 @@
✔ sessionModel: agent vars win, then the system's execution settings (9.560686ms)
✔ a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory (4.533445ms)
✔ a claude-code bundle adds the wrapped gate hook and the MCP config (2.559218ms)
✔ a bundle is written once: an existing file refuses (1.805363ms)
✔ a path with a single quote can't go into the hook command (1.619328ms)
✔ allow exits 0, a deny exits 2 with the reason on stderr (114.276019ms)
✔ a missing or wrong policy, or a bad event, exits 2 (79.671029ms)
✔ the bundle's wrapped command: a missing gate or node still blocks (1090.201585ms)
✔ claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist (733.488775ms)
✔ claude: the hook alone blocks a path outside the workspace (434.280065ms)
✔ claude: a second turn resumes the first turn's session (693.516361ms)
✔ claude adapter: --restricted is always passed (4.81615ms)
✔ claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do (718.692902ms)
✔ claude: a missing hook or MCP file refuses before claude starts (7.611874ms)
✔ pi: policy tools and typed tools pass, anything else is blocked (2.51607ms)
✔ claude: builtins map from pi names, typed tools need the mcp prefix (0.642592ms)
✔ file tool paths must resolve inside the workspace (0.832544ms)
✔ pi's own path normalisation can't be used to step out (0.888885ms)
✔ a symlink inside the workspace that points out is outside (0.752018ms)
✔ a dangling symlink is refused at any depth, in both harnesses (2.350641ms)
✔ claude path fields per tool (0.682112ms)
✔ glob patterns stay inside the workspace (0.928635ms)
✔ a path that can't be checked is blocked (0.448888ms)
✔ initialize, ping and tools/list (44.092584ms)
✔ tools/call goes through the tool socket; a refusal is an isError result (32.070127ms)
✔ unknown tools and methods are JSON-RPC errors and never reach the socket (34.879671ms)
✔ a missing argument is a usage error (27.189043ms)
✔ pi: typed tools reach the socket, the gate blocks, agent_end writes the marker (349.671657ms)
✔ pi: a write through a dangling symlink is blocked, and nothing appears outside (326.468976ms)
✔ pi: a missing extension refuses before any model call (6.621016ms)
✔ pi: an extension without its configuration fails pi's start (254.717079ms)
✔ founderCheck: founder variables, then a needed service without a usable token (1.048867ms)
✔ turnRequest names the sender, class, reply and decision (0.218323ms)
✔ a message becomes a turn, the answer goes back as a RESULT, SIGTERM releases and exits 0 (242.781802ms)
✔ a SIGTERM before the claim stops the runner with exit 0 and no claim (100.03186ms)
✔ typed tools carry the runner's capability; launch goes to the host's launch socket (390.07147ms)
✔ a RESULT gets no automatic reply; failed turns reply with the reason (1296.269843ms)
✔ SIGTERM during a turn kills the turn's process group and still exits 0 (148.02031ms)
✔ founder credentials stop before the claim (20) (163.958466ms)
✔ a refused claim exits 21; an ended run's capability exits 22 (189.269553ms)
✔ the launch ending under a running session exits 22 (136.865684ms)
✔ a broker that stays unreachable exits 23 after brokerRetries polls (239.430024ms)
✔ a broker that is down at the claim exits 23, not 21 (90.747737ms)
✖ no capability, or a malformed one, on stdin exits 2 (60155.411728ms)
✔ a missing or malformed policy exits 2 before the claim (177.561752ms)
✔ the PM gets launch, its task verbs and the reads (6.482302ms)
✔ a coder gets no launch, no resolve_decision, and no task tools without a tracker (1.9674ms)
✔ launch only when the business's launch block names the instance as launcher (1.697755ms)
✔ an action outside the instance's authority has no tool (1.405452ms)
✔ callTool: one JSON line out, the result back, a refusal rejects (8.133694ms)
ℹ tests 50
ℹ suites 0
ℹ pass 49
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 63428.846975
✖ failing tests:
test at packages/harness/tests/runner.test.mjs:313:1
✖ no capability, or a malformed one, on stdin exits 2 (60155.411728ms)
AssertionError [ERR_ASSERTION]: runner: demo/coder claimed by run coder-run
null !== 2
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41b/wt/packages/harness/tests/runner.test.mjs:320:10)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: null,
expected: 2,
operator: 'strictEqual',
diff: 'simple'
}
@@ -0,0 +1,101 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (162.500315ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (177.934119ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (127.764402ms)
✔ decide prints a declining choice as declining (160.868866ms)
✔ an unknown outcome is reported once and never resent (150.658446ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (128.152304ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (141.319517ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (94.198963ms)
✔ every human command refuses inside an agent run before it touches the bus (99.586936ms)
✔ usage errors exit 4; no business and no host is a usage error (94.825105ms)
✔ agents and tasks print through the broker (102.257686ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (1.073338ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (46.435449ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (42.161475ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (32.692764ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (30.95545ms)
✔ a business without tracker.baseUrl gets no trackers entry (32.995143ms)
✔ an unknown business and a broken system config refuse with exit 3 (53.96509ms)
✔ empty views say so (0.726597ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (0.882059ms)
✔ tasks print the tracker fields the snapshot carries (0.14236ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (952.372413ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (223.228615ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (154.759164ms)
✔ a broker reply that misses the wait breaks the channel: the late reply answers nothing, later requests refuse, the host exits 1 (1166.978507ms)
✔ a broker reply with another request's id, or none, breaks the channel and the host exits 1 (329.162296ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (205.653188ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (202.208886ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (154.339398ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (191.76797ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (153.078047ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (215.285555ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (24.175919ms)
✔ bus stop refuses to signal a live pid that is not a bus host (202.931688ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (208.721094ms)
✔ bus start refuses with exit 3 without a notifier config (96.433156ms)
✔ bus start runs until bus stop; status reports it while it runs (660.990525ms)
✔ bus-service.sh renders the unit and installs it into a given directory (27.951547ms)
✔ bus start --pm: the PM runs under its own PID namespace, registered, with a manifest (1307.802994ms)
✔ the PM launches a coder through its launch tool; the coder answers; refusals name their code (664.453124ms)
✔ a runner that stops at once ends its launch with the runner's reason (255.681961ms)
✔ a host that died hard leaves its sessions to the next host, which kills them and ends their runs so the role can be launched again (774.826194ms)
✔ a host that died hard leaves its sessions to the next host, which finds one already exited (23) and ends their runs so the role can be launched again (3654.939053ms)
✔ a malformed sessions.json refuses the host start with exit 3 and stays as it was (144.004039ms)
✖ an over-long launch request is refused at once, not at the 10 s idle timeout (3412.359464ms)
✔ a launch client that never closes its side doesn't hold the host's close (179.960494ms)
✔ a runner that ignores SIGTERM is killed when the host closes (1248.097834ms)
✔ zoned uses the IANA zone across DST (19.34815ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (162.974921ms)
✔ two blocking decisions get two DMs with different nonces (177.914722ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.170738ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (128.140766ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (152.973701ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (148.988982ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (128.584675ms)
✔ polled every POLL_MS against a permanent 403, a DM is sent at 0, 30, 60, 90 and 120 min and gives up only then (197.312731ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (147.562084ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (114.916739ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (102.016613ms)
✔ an inbox read failure is logged and the next poll retries (0.618221ms)
✔ no Discord id reaches the journal or the log (119.100513ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (25.171504ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (44.075897ms)
✔ the journal: a whole file that is one torn line truncates to empty (27.713509ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (2.376026ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.639962ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (1.802ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.340471ms)
✔ the journal: a dangling directory link, a parent that is a file and a journal that is a directory each refuse with exit 3 (0.45465ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.362259ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.42642ms)
✔ digest content stays within Discord's 2000 characters (0.255232ms)
✔ runLoop never overlaps ticks and stops after the one in flight (110.560954ms)
task.close {} answered: invalid-request; fake saw 18 requests, first GET /info 200, GET /projects/1 200, GET /projects/1/views 200
task.close on a missing task answered: task-not-found; it made GET /tasks/999 404
✔ bootConfig trackers reach the S3 adapter in the real broker child, which goes ready against a fake Vikunja (403.836981ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (44.720137ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2133.889595ms)
✔ busExit and refuseInsideAgent (0.513786ms)
✔ talk sends a REQUEST, prints and reads everything that arrives, and stops at the reply (295.836242ms)
✔ talk --wait 0 only sends; no reply within --wait exits 1 and says where it will show (1145.102558ms)
✔ talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors (118.588127ms)
✔ launches off and on go to the broker and change the business's launch state (114.265524ms)
✔ launches list reads sessions.json and marks a stale entry; stop reports it and refuses a non-runner (100.172121ms)
ℹ tests 82
ℹ suites 0
ℹ pass 81
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 11734.232731
✖ failing tests:
test at packages/cli/tests/launcher.test.mjs:326:1
✖ an over-long launch request is refused at once, not at the 10 s idle timeout (3412.359464ms)
Error: the refusal took over 3000 ms
at Timeout._onTimeout (file:///home/jwoltje/darkwing-scratch/r41b/wt/packages/cli/tests/launcher.test.mjs:324:101)
at listOnTimeout (node:internal/timers:685:17)
at process.processTimers (node:internal/timers:618:7)
@@ -0,0 +1,82 @@
✔ sessionModel: agent vars win, then the system's execution settings (11.194724ms)
✔ a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory (4.286547ms)
✔ a claude-code bundle adds the wrapped gate hook and the MCP config (2.403036ms)
✔ a bundle is written once: an existing file refuses (2.1512ms)
✔ a path with a single quote can't go into the hook command (1.731022ms)
✔ allow exits 0, a deny exits 2 with the reason on stderr (122.217615ms)
✔ a missing or wrong policy, or a bad event, exits 2 (96.735555ms)
✔ the bundle's wrapped command: a missing gate or node still blocks (1104.500034ms)
✔ claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist (946.946735ms)
✔ claude: the hook alone blocks a path outside the workspace (545.123592ms)
✔ claude: a second turn resumes the first turn's session (885.302087ms)
✔ claude adapter: --restricted is always passed (5.281479ms)
✔ claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do (956.377577ms)
✔ claude: a missing hook or MCP file refuses before claude starts (8.885071ms)
✔ pi: policy tools and typed tools pass, anything else is blocked (2.661395ms)
✔ claude: builtins map from pi names, typed tools need the mcp prefix (0.67723ms)
✔ file tool paths must resolve inside the workspace (0.957626ms)
✔ pi's own path normalisation can't be used to step out (0.993466ms)
✔ a symlink inside the workspace that points out is outside (0.791344ms)
✔ a dangling symlink is refused at any depth, in both harnesses (2.51196ms)
✔ claude path fields per tool (0.729902ms)
✖ glob patterns stay inside the workspace (1.92067ms)
✔ a path that can't be checked is blocked (0.824426ms)
✔ initialize, ping and tools/list (40.904169ms)
✔ tools/call goes through the tool socket; a refusal is an isError result (33.839576ms)
✔ unknown tools and methods are JSON-RPC errors and never reach the socket (38.184751ms)
✔ a missing argument is a usage error (32.677945ms)
✔ pi: typed tools reach the socket, the gate blocks, agent_end writes the marker (392.965267ms)
✔ pi: a write through a dangling symlink is blocked, and nothing appears outside (470.375412ms)
✔ pi: a missing extension refuses before any model call (7.163298ms)
✔ pi: an extension without its configuration fails pi's start (276.898461ms)
✔ founderCheck: founder variables, then a needed service without a usable token (1.16047ms)
✔ turnRequest names the sender, class, reply and decision (0.27114ms)
✔ a message becomes a turn, the answer goes back as a RESULT, SIGTERM releases and exits 0 (235.398042ms)
✔ a SIGTERM before the claim stops the runner with exit 0 and no claim (110.823755ms)
✔ typed tools carry the runner's capability; launch goes to the host's launch socket (561.541245ms)
✔ a RESULT gets no automatic reply; failed turns reply with the reason (1449.259176ms)
✔ SIGTERM during a turn kills the turn's process group and still exits 0 (218.54095ms)
✔ founder credentials stop before the claim (20) (242.729037ms)
✔ a refused claim exits 21; an ended run's capability exits 22 (244.054228ms)
✔ the launch ending under a running session exits 22 (127.55566ms)
✔ a broker that stays unreachable exits 23 after brokerRetries polls (297.466543ms)
✔ a broker that is down at the claim exits 23, not 21 (131.291616ms)
✔ no capability, or a malformed one, on stdin exits 2 (255.582554ms)
✔ a missing or malformed policy exits 2 before the claim (149.982176ms)
✔ the PM gets launch, its task verbs and the reads (6.991981ms)
✔ a coder gets no launch, no resolve_decision, and no task tools without a tracker (2.515836ms)
✔ launch only when the business's launch block names the instance as launcher (1.864572ms)
✔ an action outside the instance's authority has no tool (1.539859ms)
✔ callTool: one JSON line out, the result back, a refusal rejects (8.903688ms)
ℹ tests 50
ℹ suites 0
ℹ pass 49
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 10684.506761
✖ failing tests:
test at packages/harness/tests/gate.test.mjs:115:1
✖ glob patterns stay inside the workspace (1.92067ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
true !== false
at blocked (file:///home/jwoltje/darkwing-scratch/r41b/wt/packages/harness/tests/gate.test.mjs:19:10)
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41b/wt/packages/harness/tests/gate.test.mjs:121:5)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: true,
expected: false,
operator: 'strictEqual',
diff: 'simple'
}
@@ -0,0 +1,102 @@
✔ launch identity is stamped, payload identity is refused and stale holder cannot send (177.229803ms)
✔ decision classes route from policy; gated resolution is human-only, choice and target must match (275.752672ms)
✔ claim exclusion, holder release, gated revoke and rerouting to a new holder are atomic (278.809307ms)
✔ launch events require a human CLI capability; generic emit cannot forge authority events (179.057449ms)
✔ within-role decisions close atomically and invalid options or blocking omissions refuse (166.815435ms)
✔ observer capabilities read human inbox but cannot mutate or forge launch identity (167.264193ms)
✔ task action subjects and linked decision trail are complete and ordered (180.406752ms)
✔ launch binding is durable and reconnecting requires the identical trusted record (109.64095ms)
✔ business isolation includes inherited object names and cross-business message references (252.119335ms)
✔ authority never transfers between action, run, target, unresolved or replaced role holder (424.591029ms)
✔ task projection uses schema current view, skipping earlier and equal-start polls (139.347503ms)
✔ revocation permanently bars the old run from reclaiming first, including after broker restart (246.764458ms)
✔ empty message references refuse before storage; refusal-evidence failure stays a typed error (157.256723ms)
✔ both arbiters require human resolution when their cross-role route is themselves (256.737356ms)
✔ S1 adapter takes resolved limits and refs, rejects mismatched instance, never mutates input (1.815371ms)
✔ only validated broker references load; returned data and exceptions cannot expose a known token (5.505282ms)
✔ bad file modes, symlinks, repository/data paths, malformed tokens and missing dates refuse (2.675771ms)
✔ expiry refuses use and env references never become client data (0.928186ms)
✔ S1 parsed service refs work, service mismatch refuses, Gitea rotation due is a warning state (1.806798ms)
✔ opaque tokens shorter than 16 characters refuse before use (0.423882ms)
✔ endLaunch writes session.ended, releases the run claim and kills its capabilities (134.40541ms)
✔ endLaunch refuses an unknown run, a second end and a rebind of the ended run (126.056749ms)
✔ a restarted broker refuses to rebind an ended run; a refused rebind leaves the run unbound (173.037613ms)
✔ endLaunch leaves a claim another run took alone (186.025336ms)
✔ refuse records action.refused against the caller with the code only (132.051699ms)
✖ launches off refuses role.launch with launch-revoked until launches on (167.479733ms)
✔ broker process: launch ops authorize role.launch, record refusals and end runs (251.260579ms)
✔ human proof binds CLI entry, process start and nonce; agents and incomplete ancestry refuse (3.799433ms)
✔ process reader gets own kernel identity without exposing environment values (0.636082ms)
✔ EACCES ancestor environments skip only markers; commands and registered launches still refuse (0.788808ms)
✔ real pid 1 remains inspectable when its environment is protected (0.341787ms)
✔ within-role sends cite an open gated launch decision without spending it or naming it in grants (211.319193ms)
✔ missing and foreign-business citations refuse and roll back message and grant (195.215344ms)
✔ cross-role sends still need a matching resolved decision and consume it once (266.86907ms)
✔ broker process binds trusted launches, offers reader capabilities, refuses human mutation, closes cleanly (186.52044ms)
✔ startup token refusal returns safe code without value or partial listening broker (37.446938ms)
✔ loaded fixture token is absent from socket replies and SQLite, including refusal evidence (173.835214ms)
✔ killed broker leaves an explicit stale lock; another process cannot silently reclaim it (188.388532ms)
✔ trusted host registers later launches; socket clients never have a registration verb (190.013239ms)
✔ runtime excludes declared project roots even when host supplies no repoRoots (37.739358ms)
✔ a refused launch binding leaves the broker and existing capabilities alive; bad protocol stops it (163.945389ms)
✔ v3b prototype refusals, views and append-only mutations (1083.019917ms)
✔ gated approval authorizes once, survives store reopen, and fresh approval works (263.722973ms)
✔ another run cannot consume an approval; a failed check leaves it usable (243.701826ms)
✔ two scheduled callers have exactly one grant and one consumed refusal (168.502436ms)
✔ failed commit rolls consumption back; cross-role consumes and within-role stays reusable (325.314036ms)
✔ class drift gated to cross-role refuses before consumption (208.156247ms)
✔ class drift cross-role to gated refuses before consumption (216.120898ms)
✔ class drift gated to within-role refuses before consumption (235.048484ms)
✔ class drift cross-role to within-role refuses before consumption (328.948633ms)
✔ class drift within-role to gated refuses before consumption (281.360844ms)
✔ class drift within-role to cross-role refuses before consumption (206.289663ms)
✔ message.send consumes approval and prevents a later send or authorize (240.808783ms)
✔ role.revoke consumes approval and prevents a later revoke or authorize (280.998053ms)
✔ creates private WAL store and excludes a second writer until explicit close (138.150282ms)
✔ rollback is atomic and schema metadata is checked against trusted DDL, not just itself (182.019336ms)
✔ existing empty database and symlink runtime directory refuse, never initialize over damage (211.271904ms)
✔ crash during a transaction recovers no partial event after explicit fixture-only lock removal (168.894848ms)
✔ writer refuses mixed at/read_at forms atomically, even through trusted SQL helpers (119.46873ms)
✔ async transactions refuse before invoking their function (96.187261ms)
✔ recordTask keeps sync reads and a role write apart (185.827602ms)
✔ read_at must be one canonical UTC format, so the projection compares strings safely (108.208596ms)
✔ a bad entry refuses the whole record (126.728342ms)
✔ taskView reads the projection for one business (141.375996ms)
✔ requestTask hands only a holder and a task verb to the handler, and records refusals (253.38447ms)
✔ the server sends task verbs to the adapter with its own timeout; other verbs stay synchronous (409.317736ms)
✔ without an adapter the server refuses every task verb (201.178983ms)
✔ the runtime refuses an invalid adapter and closes a valid one (250.764772ms)
✔ the process loads the S3 adapter from plain-data trackers (322.736733ms)
✔ socket capability stamps launch identity; shared views use wire, no SQL client (160.441912ms)
✔ two wire claims serialize; a lost reply never automatically retries (176.70387ms)
✔ malformed, oversized and identity-forging envelopes refuse without echoing input (133.427523ms)
✔ client preserves UTF-8 when a response divides a multibyte character (11.974517ms)
✔ committed mutation followed by dropped reply reports unknown and is never retried (136.481387ms)
ℹ tests 74
ℹ suites 0
ℹ pass 73
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 3088.587313
✖ failing tests:
test at packages/bus/tests/end-launch.test.mjs:121:1
✖ launches off refuses role.launch with launch-revoked until launches on (167.479733ms)
AssertionError [ERR_ASSERTION]: Missing expected exception.
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41b/wt/packages/bus/tests/end-launch.test.mjs:128:10)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: undefined,
expected: { code: 'launch-revoked' },
operator: 'throws',
diff: 'simple'
}
@@ -0,0 +1,92 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (148.613606ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (145.321931ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (110.010972ms)
✔ decide prints a declining choice as declining (128.53301ms)
✔ an unknown outcome is reported once and never resent (110.119445ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (108.850385ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (109.345423ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (58.036577ms)
✔ every human command refuses inside an agent run before it touches the bus (109.877031ms)
✔ usage errors exit 4; no business and no host is a usage error (110.264523ms)
✔ agents and tasks print through the broker (108.43468ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (2.011908ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (51.555735ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (32.021543ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (34.442353ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (32.395198ms)
✔ a business without tracker.baseUrl gets no trackers entry (27.413436ms)
✔ an unknown business and a broken system config refuse with exit 3 (67.657477ms)
✔ empty views say so (1.09354ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (1.292231ms)
✔ tasks print the tracker fields the snapshot carries (0.223616ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (920.104044ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (232.203266ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (194.33404ms)
✔ a broker reply that misses the wait breaks the channel: the late reply answers nothing, later requests refuse, the host exits 1 (1236.474176ms)
✔ a broker reply with another request's id, or none, breaks the channel and the host exits 1 (491.628366ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (191.72114ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (202.467254ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (338.898401ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (173.462864ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (134.641506ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (198.871316ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (22.522396ms)
✔ bus stop refuses to signal a live pid that is not a bus host (202.309444ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (210.829679ms)
✔ bus start refuses with exit 3 without a notifier config (86.248558ms)
✔ bus start runs until bus stop; status reports it while it runs (654.76759ms)
✔ bus-service.sh renders the unit and installs it into a given directory (26.581017ms)
✔ bus start --pm: the PM runs under its own PID namespace, registered, with a manifest (1314.913345ms)
✔ the PM launches a coder through its launch tool; the coder answers; refusals name their code (748.260563ms)
✔ a runner that stops at once ends its launch with the runner's reason (262.075043ms)
✔ a host that died hard leaves its sessions to the next host, which kills them and ends their runs so the role can be launched again (852.154376ms)
✔ a host that died hard leaves its sessions to the next host, which finds one already exited (23) and ends their runs so the role can be launched again (3664.261675ms)
✔ a malformed sessions.json refuses the host start with exit 3 and stays as it was (110.17587ms)
✔ an over-long launch request is refused at once, not at the 10 s idle timeout (117.558237ms)
✔ a launch client that never closes its side doesn't hold the host's close (121.739664ms)
✔ a runner that ignores SIGTERM is killed when the host closes (1231.82696ms)
✔ zoned uses the IANA zone across DST (17.450684ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (153.728134ms)
✔ two blocking decisions get two DMs with different nonces (130.565097ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.213946ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (112.875167ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (105.336718ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (115.425186ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (108.88511ms)
✔ polled every POLL_MS against a permanent 403, a DM is sent at 0, 30, 60, 90 and 120 min and gives up only then (170.201461ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (145.665616ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (139.969675ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (151.983372ms)
✔ an inbox read failure is logged and the next poll retries (0.603488ms)
✔ no Discord id reaches the journal or the log (155.240832ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (48.414629ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (85.619058ms)
✔ the journal: a whole file that is one torn line truncates to empty (32.586082ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (2.330476ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.560581ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (3.954422ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.306099ms)
✔ the journal: a dangling directory link, a parent that is a file and a journal that is a directory each refuse with exit 3 (0.413956ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.35932ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.46591ms)
✔ digest content stays within Discord's 2000 characters (0.236425ms)
✔ runLoop never overlaps ticks and stops after the one in flight (110.673108ms)
task.close {} answered: invalid-request; fake saw 18 requests, first GET /info 200, GET /projects/1 200, GET /projects/1/views 200
task.close on a missing task answered: task-not-found; it made GET /tasks/999 404
✔ bootConfig trackers reach the S3 adapter in the real broker child, which goes ready against a fake Vikunja (390.682228ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (44.959117ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2149.177237ms)
✔ busExit and refuseInsideAgent (0.407839ms)
✔ talk sends a REQUEST, prints and reads everything that arrives, and stops at the reply (248.520404ms)
✔ talk --wait 0 only sends; no reply within --wait exits 1 and says where it will show (1140.169503ms)
✔ talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors (173.388107ms)
✔ launches off and on go to the broker and change the business's launch state (163.252289ms)
✔ launches list reads sessions.json and marks a stale entry; stop reports it and refuses a non-runner (87.164337ms)
ℹ tests 82
ℹ suites 0
ℹ pass 82
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 8513.932829
@@ -0,0 +1,130 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (170.153283ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (205.099579ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (143.381162ms)
✔ decide prints a declining choice as declining (113.377221ms)
✔ an unknown outcome is reported once and never resent (106.830715ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (120.492756ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (135.211353ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (140.606626ms)
✔ every human command refuses inside an agent run before it touches the bus (149.46296ms)
✔ usage errors exit 4; no business and no host is a usage error (151.038488ms)
✔ agents and tasks print through the broker (305.111452ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (1.415264ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (38.26399ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (31.251151ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (29.181767ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (30.554803ms)
✔ a business without tracker.baseUrl gets no trackers entry (29.180603ms)
✔ an unknown business and a broken system config refuse with exit 3 (56.241292ms)
✔ empty views say so (0.630648ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (1.065816ms)
✔ tasks print the tracker fields the snapshot carries (0.192748ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (952.038923ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (298.115946ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (189.918809ms)
✔ a broker reply that misses the wait breaks the channel: the late reply answers nothing, later requests refuse, the host exits 1 (1368.931825ms)
✔ a broker reply with another request's id, or none, breaks the channel and the host exits 1 (228.256463ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (161.103544ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (168.844007ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (97.165146ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (150.408035ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (115.415106ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (164.795368ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (23.584241ms)
✔ bus stop refuses to signal a live pid that is not a bus host (203.000157ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (215.496046ms)
✔ bus start refuses with exit 3 without a notifier config (90.18942ms)
✔ bus start runs until bus stop; status reports it while it runs (668.720827ms)
✔ bus-service.sh renders the unit and installs it into a given directory (27.238879ms)
✔ bus start --pm: the PM runs under its own PID namespace, registered, with a manifest (309.549055ms)
✔ the PM launches a coder through its launch tool; the coder answers; refusals name their code (753.366044ms)
✔ a runner that stops at once ends its launch with the runner's reason (354.246578ms)
✖ a host that died hard leaves its sessions to the next host, which kills them and ends their runs so the role can be launched again (658.963011ms)
✖ a host that died hard leaves its sessions to the next host, which finds one already exited (23) and ends their runs so the role can be launched again (3389.078968ms)
✔ a malformed sessions.json refuses the host start with exit 3 and stays as it was (140.258185ms)
✔ an over-long launch request is refused at once, not at the 10 s idle timeout (155.002531ms)
✔ a launch client that never closes its side doesn't hold the host's close (151.488343ms)
✔ a runner that ignores SIGTERM is killed when the host closes (1265.877319ms)
✔ zoned uses the IANA zone across DST (18.332147ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (169.368237ms)
✔ two blocking decisions get two DMs with different nonces (172.821188ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.281242ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (180.272496ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (113.603914ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (116.05131ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (110.290546ms)
✔ polled every POLL_MS against a permanent 403, a DM is sent at 0, 30, 60, 90 and 120 min and gives up only then (183.295434ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (224.239563ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (171.948181ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (304.595918ms)
✔ an inbox read failure is logged and the next poll retries (0.614526ms)
✔ no Discord id reaches the journal or the log (89.273535ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (19.528131ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (25.43311ms)
✔ the journal: a whole file that is one torn line truncates to empty (20.934135ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (2.158187ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.614145ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (1.770104ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.326489ms)
✔ the journal: a dangling directory link, a parent that is a file and a journal that is a directory each refuse with exit 3 (0.448782ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.362714ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.394327ms)
✔ digest content stays within Discord's 2000 characters (0.24259ms)
✔ runLoop never overlaps ticks and stops after the one in flight (111.023465ms)
task.close {} answered: invalid-request; fake saw 18 requests, first GET /info 200, GET /projects/1 200, GET /projects/1/views 200
task.close on a missing task answered: task-not-found; it made GET /tasks/999 404
✔ bootConfig trackers reach the S3 adapter in the real broker child, which goes ready against a fake Vikunja (411.146569ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (38.602183ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2139.679108ms)
✔ busExit and refuseInsideAgent (0.405233ms)
✔ talk sends a REQUEST, prints and reads everything that arrives, and stops at the reply (289.638597ms)
✔ talk --wait 0 only sends; no reply within --wait exits 1 and says where it will show (1199.364246ms)
✔ talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors (299.910632ms)
✔ launches off and on go to the broker and change the business's launch state (91.086127ms)
✔ launches list reads sessions.json and marks a stale entry; stop reports it and refuses a non-runner (56.458221ms)
ℹ tests 82
ℹ suites 0
ℹ pass 80
ℹ fail 2
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 7246.785077
✖ failing tests:
test at packages/cli/tests/launcher.test.mjs:253:3
✖ a host that died hard leaves its sessions to the next host, which kills them and ends their runs so the role can be launched again (658.963011ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
0 !== 1
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41b/wt/packages/cli/tests/launcher.test.mjs:292:12)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: 0,
expected: 1,
operator: 'strictEqual',
diff: 'simple'
}
test at packages/cli/tests/launcher.test.mjs:253:3
✖ a host that died hard leaves its sessions to the next host, which finds one already exited (23) and ends their runs so the role can be launched again (3389.078968ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
0 !== 1
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41b/wt/packages/cli/tests/launcher.test.mjs:292:12)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: 0,
expected: 1,
operator: 'strictEqual',
diff: 'simple'
}
@@ -0,0 +1,88 @@
✔ sessionModel: agent vars win, then the system's execution settings (10.072066ms)
✔ a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory (4.464194ms)
✔ a claude-code bundle adds the wrapped gate hook and the MCP config (3.041137ms)
✔ a bundle is written once: an existing file refuses (1.693357ms)
✔ a path with a single quote can't go into the hook command (1.641576ms)
✔ allow exits 0, a deny exits 2 with the reason on stderr (121.237419ms)
✔ a missing or wrong policy, or a bad event, exits 2 (87.516879ms)
✔ the bundle's wrapped command: a missing gate or node still blocks (1110.448001ms)
✔ claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist (793.508268ms)
✔ claude: the hook alone blocks a path outside the workspace (585.02418ms)
✔ claude: a second turn resumes the first turn's session (909.649643ms)
✔ claude adapter: --restricted is always passed (8.689451ms)
✔ claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do (876.923862ms)
✔ claude: a missing hook or MCP file refuses before claude starts (10.398933ms)
✔ pi: policy tools and typed tools pass, anything else is blocked (2.649943ms)
✔ claude: builtins map from pi names, typed tools need the mcp prefix (0.662426ms)
✔ file tool paths must resolve inside the workspace (0.900944ms)
✔ pi's own path normalisation can't be used to step out (0.928954ms)
✔ a symlink inside the workspace that points out is outside (0.793623ms)
✔ a dangling symlink is refused at any depth, in both harnesses (2.377096ms)
✔ claude path fields per tool (0.761812ms)
✔ glob patterns stay inside the workspace (0.941867ms)
✔ a path that can't be checked is blocked (0.485438ms)
✔ initialize, ping and tools/list (48.18657ms)
✔ tools/call goes through the tool socket; a refusal is an isError result (35.517959ms)
✔ unknown tools and methods are JSON-RPC errors and never reach the socket (31.251428ms)
✔ a missing argument is a usage error (43.83764ms)
✔ pi: typed tools reach the socket, the gate blocks, agent_end writes the marker (398.761733ms)
✔ pi: a write through a dangling symlink is blocked, and nothing appears outside (340.73565ms)
✔ pi: a missing extension refuses before any model call (6.705612ms)
✔ pi: an extension without its configuration fails pi's start (282.254531ms)
✔ founderCheck: founder variables, then a needed service without a usable token (1.589327ms)
✔ turnRequest names the sender, class, reply and decision (0.235049ms)
✔ a message becomes a turn, the answer goes back as a RESULT, SIGTERM releases and exits 0 (304.492209ms)
✔ a SIGTERM before the claim stops the runner with exit 0 and no claim (100.17469ms)
✔ typed tools carry the runner's capability; launch goes to the host's launch socket (384.345215ms)
✔ a RESULT gets no automatic reply; failed turns reply with the reason (1297.7852ms)
✔ SIGTERM during a turn kills the turn's process group and still exits 0 (230.321537ms)
✔ founder credentials stop before the claim (20) (301.916569ms)
✔ a refused claim exits 21; an ended run's capability exits 22 (323.890546ms)
✔ the launch ending under a running session exits 22 (166.444389ms)
✔ a broker that stays unreachable exits 23 after brokerRetries polls (305.527659ms)
✔ a broker that is down at the claim exits 23, not 21 (141.535353ms)
✔ no capability, or a malformed one, on stdin exits 2 (266.319545ms)
✖ a missing or malformed policy exits 2 before the claim (152.302277ms)
✔ the PM gets launch, its task verbs and the reads (8.291622ms)
✔ a coder gets no launch, no resolve_decision, and no task tools without a tracker (1.846874ms)
✔ launch only when the business's launch block names the instance as launcher (1.468387ms)
✔ an action outside the instance's authority has no tool (1.482613ms)
✔ callTool: one JSON line out, the result back, a refusal rejects (7.935313ms)
ℹ tests 50
ℹ suites 0
ℹ pass 49
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 10428.347094
✖ failing tests:
test at packages/harness/tests/runner.test.mjs:325:1
✖ a missing or malformed policy exits 2 before the claim (152.302277ms)
AssertionError [ERR_ASSERTION]: <anonymous_script>:1
{
SyntaxError: Expected property name or '}' in JSON at position 1 (line 1 column 2)
at JSON.parse (<anonymous>)
at main (file:///home/jwoltje/darkwing-scratch/r41b/wt/packages/harness/src/runner.mjs:268:17)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async file:///home/jwoltje/darkwing-scratch/r41b/wt/packages/harness/src/runner.mjs:369:22
Node.js v26.8.1
1 !== 2
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41b/wt/packages/harness/tests/runner.test.mjs:330:10)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: 1,
expected: 2,
operator: 'strictEqual',
diff: 'simple'
}
@@ -0,0 +1,110 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (148.697373ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (149.700256ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (128.806462ms)
✔ decide prints a declining choice as declining (120.583876ms)
✔ an unknown outcome is reported once and never resent (137.338274ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (127.075516ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (128.145326ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (121.555421ms)
✔ every human command refuses inside an agent run before it touches the bus (121.159027ms)
✔ usage errors exit 4; no business and no host is a usage error (114.317995ms)
✔ agents and tasks print through the broker (130.258556ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (1.201353ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (51.334717ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (37.112733ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (35.596109ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (33.392489ms)
✔ a business without tracker.baseUrl gets no trackers entry (31.250826ms)
✔ an unknown business and a broken system config refuse with exit 3 (51.127857ms)
✔ empty views say so (0.939438ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (1.310236ms)
✔ tasks print the tracker fields the snapshot carries (0.218466ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (938.914142ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (246.786456ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (190.983502ms)
✔ a broker reply that misses the wait breaks the channel: the late reply answers nothing, later requests refuse, the host exits 1 (1191.036938ms)
✖ a broker reply with another request's id, or none, breaks the channel and the host exits 1 (160.601147ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (230.076697ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (190.261213ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (131.603939ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (195.26257ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (726.01043ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (1625.636559ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (208.572521ms)
✔ bus stop refuses to signal a live pid that is not a bus host (226.062986ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (433.248251ms)
✔ bus start refuses with exit 3 without a notifier config (192.266722ms)
✔ bus start runs until bus stop; status reports it while it runs (893.270829ms)
✔ bus-service.sh renders the unit and installs it into a given directory (29.435844ms)
✔ bus start --pm: the PM runs under its own PID namespace, registered, with a manifest (300.480969ms)
✔ the PM launches a coder through its launch tool; the coder answers; refusals name their code (684.354163ms)
✔ a runner that stops at once ends its launch with the runner's reason (273.925464ms)
✔ a host that died hard leaves its sessions to the next host, which kills them and ends their runs so the role can be launched again (802.080225ms)
✔ a host that died hard leaves its sessions to the next host, which finds one already exited (23) and ends their runs so the role can be launched again (4630.122168ms)
✔ a malformed sessions.json refuses the host start with exit 3 and stays as it was (254.910333ms)
✔ an over-long launch request is refused at once, not at the 10 s idle timeout (210.969484ms)
✔ a launch client that never closes its side doesn't hold the host's close (148.695994ms)
✔ a runner that ignores SIGTERM is killed when the host closes (1266.625366ms)
✔ zoned uses the IANA zone across DST (21.67197ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (144.729882ms)
✔ two blocking decisions get two DMs with different nonces (155.268269ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.297991ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (126.071063ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (122.754724ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (131.978289ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (117.924008ms)
✔ polled every POLL_MS against a permanent 403, a DM is sent at 0, 30, 60, 90 and 120 min and gives up only then (176.265305ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (156.975988ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (145.563662ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (122.564352ms)
✔ an inbox read failure is logged and the next poll retries (0.603809ms)
✔ no Discord id reaches the journal or the log (122.743751ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (34.661862ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (54.673675ms)
✔ the journal: a whole file that is one torn line truncates to empty (29.981829ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (2.234882ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.621975ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (1.834696ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.466476ms)
✔ the journal: a dangling directory link, a parent that is a file and a journal that is a directory each refuse with exit 3 (0.510543ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.383721ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.427926ms)
✔ digest content stays within Discord's 2000 characters (0.255025ms)
✔ runLoop never overlaps ticks and stops after the one in flight (111.486218ms)
task.close {} answered: invalid-request; fake saw 18 requests, first GET /info 200, GET /projects/1 200, GET /projects/1/views 200
task.close on a missing task answered: task-not-found; it made GET /tasks/999 404
✔ bootConfig trackers reach the S3 adapter in the real broker child, which goes ready against a fake Vikunja (428.349483ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (46.508343ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2169.509112ms)
✔ busExit and refuseInsideAgent (0.4227ms)
✔ talk sends a REQUEST, prints and reads everything that arrives, and stops at the reply (244.465517ms)
✔ talk --wait 0 only sends; no reply within --wait exits 1 and says where it will show (1151.118912ms)
✔ talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors (112.967802ms)
✔ launches off and on go to the broker and change the business's launch state (140.237907ms)
✔ launches list reads sessions.json and marks a stale entry; stop reports it and refuses a non-runner (140.61441ms)
ℹ tests 82
ℹ suites 0
ℹ pass 81
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 8661.544704
✖ failing tests:
test at packages/cli/tests/host.test.mjs:240:1
✖ a broker reply with another request's id, or none, breaks the channel and the host exits 1 (160.601147ms)
AssertionError [ERR_ASSERTION]: another id
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41b/wt/packages/cli/tests/host.test.mjs:258:5)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: CliError: identity refused: unauthenticated
at file:///home/jwoltje/darkwing-scratch/r41b/wt/packages/cli/src/host.mjs:205:29
at process.processTicksAndRejections (node:internal/process/task_queues:104:5),
operator: 'rejects',
diff: 'simple'
}
@@ -0,0 +1,98 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (161.282919ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (216.406574ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (101.711895ms)
✔ decide prints a declining choice as declining (118.770223ms)
✔ an unknown outcome is reported once and never resent (88.180161ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (133.631087ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (89.273035ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (64.242211ms)
✔ every human command refuses inside an agent run before it touches the bus (81.769885ms)
✔ usage errors exit 4; no business and no host is a usage error (76.046858ms)
✔ agents and tasks print through the broker (98.61662ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (1.639647ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (74.144883ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (45.990257ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (37.454954ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (33.945313ms)
✔ a business without tracker.baseUrl gets no trackers entry (31.546787ms)
✔ an unknown business and a broken system config refuse with exit 3 (59.210979ms)
✔ empty views say so (1.234024ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (1.528788ms)
✔ tasks print the tracker fields the snapshot carries (0.260352ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (1110.739563ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (212.314627ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (117.462697ms)
✖ a broker reply that misses the wait breaks the channel: the late reply answers nothing, later requests refuse, the host exits 1 (30011.627316ms)
✔ a broker reply with another request's id, or none, breaks the channel and the host exits 1 (361.14127ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (238.779627ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (209.344431ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (142.395402ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (201.707236ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (140.62722ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (211.583262ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (23.170158ms)
✔ bus stop refuses to signal a live pid that is not a bus host (201.927537ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (212.818094ms)
✔ bus start refuses with exit 3 without a notifier config (97.691899ms)
✔ bus start runs until bus stop; status reports it while it runs (712.239824ms)
✔ bus-service.sh renders the unit and installs it into a given directory (33.025617ms)
✔ bus start --pm: the PM runs under its own PID namespace, registered, with a manifest (1408.528387ms)
✔ the PM launches a coder through its launch tool; the coder answers; refusals name their code (678.289224ms)
✔ a runner that stops at once ends its launch with the runner's reason (232.87958ms)
✔ a host that died hard leaves its sessions to the next host, which kills them and ends their runs so the role can be launched again (636.773128ms)
✔ a host that died hard leaves its sessions to the next host, which finds one already exited (23) and ends their runs so the role can be launched again (3685.682808ms)
✔ a malformed sessions.json refuses the host start with exit 3 and stays as it was (107.723084ms)
✔ an over-long launch request is refused at once, not at the 10 s idle timeout (147.617186ms)
✔ a launch client that never closes its side doesn't hold the host's close (165.141582ms)
✔ a runner that ignores SIGTERM is killed when the host closes (1256.881502ms)
✔ zoned uses the IANA zone across DST (24.975618ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (118.650939ms)
✔ two blocking decisions get two DMs with different nonces (236.384189ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.254913ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (108.217406ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (102.65163ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (104.429391ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (126.330216ms)
✔ polled every POLL_MS against a permanent 403, a DM is sent at 0, 30, 60, 90 and 120 min and gives up only then (152.955109ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (114.784564ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (111.484479ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (96.945293ms)
✔ an inbox read failure is logged and the next poll retries (0.635993ms)
✔ no Discord id reaches the journal or the log (59.412086ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (21.056217ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (26.922879ms)
✔ the journal: a whole file that is one torn line truncates to empty (10.716354ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (0.524492ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.548308ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (5.00263ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.321553ms)
✔ the journal: a dangling directory link, a parent that is a file and a journal that is a directory each refuse with exit 3 (0.472191ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.36437ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.44078ms)
✔ digest content stays within Discord's 2000 characters (0.281593ms)
✔ runLoop never overlaps ticks and stops after the one in flight (111.647453ms)
task.close {} answered: invalid-request; fake saw 18 requests, first GET /info 200, GET /projects/1 200, GET /projects/1/views 200
task.close on a missing task answered: task-not-found; it made GET /tasks/999 404
✔ bootConfig trackers reach the S3 adapter in the real broker child, which goes ready against a fake Vikunja (533.872157ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (56.300634ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2175.145866ms)
✔ busExit and refuseInsideAgent (0.405042ms)
✔ talk sends a REQUEST, prints and reads everything that arrives, and stops at the reply (321.44385ms)
✔ talk --wait 0 only sends; no reply within --wait exits 1 and says where it will show (1112.326063ms)
✔ talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors (86.567989ms)
✔ launches off and on go to the broker and change the business's launch state (119.61121ms)
✔ launches list reads sessions.json and marks a stale entry; stop reports it and refuses a non-runner (119.535284ms)
ℹ tests 82
ℹ suites 0
ℹ pass 81
ℹ fail 0
ℹ cancelled 1
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 34369.220268
✖ failing tests:
test at packages/cli/tests/host.test.mjs:206:1
✖ a broker reply that misses the wait breaks the channel: the late reply answers nothing, later requests refuse, the host exits 1 (30011.627316ms)
'test timed out after 30000ms'
@@ -0,0 +1,92 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (138.638924ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (132.941019ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (116.731662ms)
✔ decide prints a declining choice as declining (109.132344ms)
✔ an unknown outcome is reported once and never resent (117.897707ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (122.014581ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (100.722744ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (77.9869ms)
✔ every human command refuses inside an agent run before it touches the bus (83.470336ms)
✔ usage errors exit 4; no business and no host is a usage error (89.721984ms)
✔ agents and tasks print through the broker (86.401911ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (1.10934ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (55.168327ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (39.926919ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (33.523335ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (30.393071ms)
✔ a business without tracker.baseUrl gets no trackers entry (27.532027ms)
✔ an unknown business and a broken system config refuse with exit 3 (57.895953ms)
✔ empty views say so (0.929157ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (0.990349ms)
✔ tasks print the tracker fields the snapshot carries (0.155053ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (908.592046ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (215.303058ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (145.876256ms)
✔ a broker reply that misses the wait breaks the channel: the late reply answers nothing, later requests refuse, the host exits 1 (1165.049844ms)
✔ a broker reply with another request's id, or none, breaks the channel and the host exits 1 (389.403888ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (238.871198ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (215.473048ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (164.522219ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (243.423454ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (136.171525ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (198.742801ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (26.600601ms)
✔ bus stop refuses to signal a live pid that is not a bus host (203.603644ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (227.895553ms)
✔ bus start refuses with exit 3 without a notifier config (91.022479ms)
✔ bus start runs until bus stop; status reports it while it runs (761.049696ms)
✔ bus-service.sh renders the unit and installs it into a given directory (35.781901ms)
✔ bus start --pm: the PM runs under its own PID namespace, registered, with a manifest (1298.198148ms)
✔ the PM launches a coder through its launch tool; the coder answers; refusals name their code (655.756425ms)
✔ a runner that stops at once ends its launch with the runner's reason (253.622608ms)
✔ a host that died hard leaves its sessions to the next host, which kills them and ends their runs so the role can be launched again (810.912019ms)
✔ a host that died hard leaves its sessions to the next host, which finds one already exited (23) and ends their runs so the role can be launched again (3789.366254ms)
✔ a malformed sessions.json refuses the host start with exit 3 and stays as it was (150.305011ms)
✔ an over-long launch request is refused at once, not at the 10 s idle timeout (156.429058ms)
✔ a launch client that never closes its side doesn't hold the host's close (182.414216ms)
✔ a runner that ignores SIGTERM is killed when the host closes (1284.428961ms)
✔ zoned uses the IANA zone across DST (26.607121ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (124.92784ms)
✔ two blocking decisions get two DMs with different nonces (127.300122ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.186263ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (114.696626ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (105.643281ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (127.026089ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (104.520561ms)
✔ polled every POLL_MS against a permanent 403, a DM is sent at 0, 30, 60, 90 and 120 min and gives up only then (176.552447ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (124.599483ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (106.56747ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (96.82751ms)
✔ an inbox read failure is logged and the next poll retries (0.616406ms)
✔ no Discord id reaches the journal or the log (110.898763ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (22.412109ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (38.367565ms)
✔ the journal: a whole file that is one torn line truncates to empty (31.163172ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (3.4225ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.619509ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (1.778816ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.459667ms)
✔ the journal: a dangling directory link, a parent that is a file and a journal that is a directory each refuse with exit 3 (0.464156ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.361591ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.425791ms)
✔ digest content stays within Discord's 2000 characters (0.259698ms)
✔ runLoop never overlaps ticks and stops after the one in flight (112.028169ms)
task.close {} answered: invalid-request; fake saw 18 requests, first GET /info 200, GET /projects/1 200, GET /projects/1/views 200
task.close on a missing task answered: task-not-found; it made GET /tasks/999 404
✔ bootConfig trackers reach the S3 adapter in the real broker child, which goes ready against a fake Vikunja (380.372573ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (48.608719ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2154.943017ms)
✔ busExit and refuseInsideAgent (0.460744ms)
✔ talk sends a REQUEST, prints and reads everything that arrives, and stops at the reply (214.990431ms)
✔ talk --wait 0 only sends; no reply within --wait exits 1 and says where it will show (1155.659087ms)
✔ talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors (102.063205ms)
✔ launches off and on go to the broker and change the business's launch state (117.097829ms)
✔ launches list reads sessions.json and marks a stale entry; stop reports it and refuses a non-runner (95.414427ms)
ℹ tests 82
ℹ suites 0
ℹ pass 82
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 8670.126908
@@ -0,0 +1,100 @@
✔ sessionModel: agent vars win, then the system's execution settings (14.267841ms)
✔ a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory (5.331756ms)
✔ a claude-code bundle adds the wrapped gate hook and the MCP config (3.063225ms)
✔ a bundle is written once: an existing file refuses (2.3933ms)
✔ a path with a single quote can't go into the hook command (2.229339ms)
✔ allow exits 0, a deny exits 2 with the reason on stderr (149.790714ms)
✔ a missing or wrong policy, or a bad event, exits 2 (114.816308ms)
✔ the bundle's wrapped command: a missing gate or node still blocks (1113.267018ms)
✔ claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist (1052.590796ms)
✔ claude: the hook alone blocks a path outside the workspace (566.552275ms)
✔ claude: a second turn resumes the first turn's session (735.772562ms)
✔ claude adapter: --restricted is always passed (5.210642ms)
✔ claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do (752.794353ms)
✔ claude: a missing hook or MCP file refuses before claude starts (7.481333ms)
✔ pi: policy tools and typed tools pass, anything else is blocked (4.137095ms)
✔ claude: builtins map from pi names, typed tools need the mcp prefix (1.087122ms)
✔ file tool paths must resolve inside the workspace (1.830471ms)
✔ pi's own path normalisation can't be used to step out (1.461666ms)
✔ a symlink inside the workspace that points out is outside (1.387513ms)
✖ a dangling symlink is refused at any depth, in both harnesses (2.238046ms)
✔ claude path fields per tool (1.183856ms)
✔ glob patterns stay inside the workspace (1.290554ms)
✔ a path that can't be checked is blocked (0.757566ms)
✔ initialize, ping and tools/list (55.169248ms)
✔ tools/call goes through the tool socket; a refusal is an isError result (42.192702ms)
✔ unknown tools and methods are JSON-RPC errors and never reach the socket (40.690799ms)
✔ a missing argument is a usage error (45.831681ms)
✔ pi: typed tools reach the socket, the gate blocks, agent_end writes the marker (447.976546ms)
✖ pi: a write through a dangling symlink is blocked, and nothing appears outside (460.290575ms)
✔ pi: a missing extension refuses before any model call (8.272109ms)
✔ pi: an extension without its configuration fails pi's start (317.932286ms)
✔ founderCheck: founder variables, then a needed service without a usable token (1.482243ms)
✔ turnRequest names the sender, class, reply and decision (0.191539ms)
✔ a message becomes a turn, the answer goes back as a RESULT, SIGTERM releases and exits 0 (335.253633ms)
✔ a SIGTERM before the claim stops the runner with exit 0 and no claim (218.308665ms)
✔ typed tools carry the runner's capability; launch goes to the host's launch socket (578.967725ms)
✔ a RESULT gets no automatic reply; failed turns reply with the reason (1419.210919ms)
✔ SIGTERM during a turn kills the turn's process group and still exits 0 (200.913598ms)
✔ founder credentials stop before the claim (20) (278.03445ms)
✔ a refused claim exits 21; an ended run's capability exits 22 (255.703953ms)
✔ the launch ending under a running session exits 22 (170.51835ms)
✔ a broker that stays unreachable exits 23 after brokerRetries polls (285.056323ms)
✔ a broker that is down at the claim exits 23, not 21 (141.580326ms)
✔ no capability, or a malformed one, on stdin exits 2 (220.456576ms)
✔ a missing or malformed policy exits 2 before the claim (170.29542ms)
✔ the PM gets launch, its task verbs and the reads (10.850788ms)
✔ a coder gets no launch, no resolve_decision, and no task tools without a tracker (2.930345ms)
✔ launch only when the business's launch block names the instance as launcher (2.233962ms)
✔ an action outside the instance's authority has no tool (2.476419ms)
✔ callTool: one JSON line out, the result back, a refusal rejects (13.327592ms)
ℹ tests 50
ℹ suites 0
ℹ pass 48
ℹ fail 2
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 10898.485035
✖ failing tests:
test at packages/harness/tests/gate.test.mjs:82:1
✖ a dangling symlink is refused at any depth, in both harnesses (2.238046ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
true !== false
at blocked (file:///home/jwoltje/darkwing-scratch/r41b/wt/packages/harness/tests/gate.test.mjs:19:10)
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41b/wt/packages/harness/tests/gate.test.mjs:92:7)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: true,
expected: false,
operator: 'strictEqual',
diff: 'simple'
}
test at packages/harness/tests/pi-session.test.mjs:99:1
✖ pi: a write through a dangling symlink is blocked, and nothing appears outside (460.290575ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
false !== true
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41b/wt/packages/harness/tests/pi-session.test.mjs:112:10)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: false,
expected: true,
operator: 'strictEqual',
diff: 'simple'
}
@@ -0,0 +1,101 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (131.528352ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (142.345308ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (129.209329ms)
✔ decide prints a declining choice as declining (126.056946ms)
✔ an unknown outcome is reported once and never resent (264.240021ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (183.909761ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (209.444872ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (113.38368ms)
✔ every human command refuses inside an agent run before it touches the bus (107.245204ms)
✔ usage errors exit 4; no business and no host is a usage error (133.581676ms)
✔ agents and tasks print through the broker (130.930415ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (1.69181ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (59.595479ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (42.730476ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (37.233092ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (34.758544ms)
✔ a business without tracker.baseUrl gets no trackers entry (32.376378ms)
✔ an unknown business and a broken system config refuse with exit 3 (59.265798ms)
✔ empty views say so (1.36445ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (1.627688ms)
✔ tasks print the tracker fields the snapshot carries (0.27856ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (946.036696ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (325.488492ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (185.16169ms)
✔ a broker reply that misses the wait breaks the channel: the late reply answers nothing, later requests refuse, the host exits 1 (1218.491359ms)
✔ a broker reply with another request's id, or none, breaks the channel and the host exits 1 (335.746778ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (216.683428ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (242.774968ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (155.236033ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (191.091803ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (169.814045ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (240.918562ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (23.585391ms)
✔ bus stop refuses to signal a live pid that is not a bus host (202.733928ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (246.621308ms)
✔ bus start refuses with exit 3 without a notifier config (108.533333ms)
✔ bus start runs until bus stop; status reports it while it runs (718.328651ms)
✔ bus-service.sh renders the unit and installs it into a given directory (29.399566ms)
✔ bus start --pm: the PM runs under its own PID namespace, registered, with a manifest (1322.484728ms)
✔ the PM launches a coder through its launch tool; the coder answers; refusals name their code (708.944623ms)
✔ a runner that stops at once ends its launch with the runner's reason (238.787251ms)
✔ a host that died hard leaves its sessions to the next host, which kills them and ends their runs so the role can be launched again (796.066461ms)
✔ a host that died hard leaves its sessions to the next host, which finds one already exited (23) and ends their runs so the role can be launched again (3644.773768ms)
✔ a malformed sessions.json refuses the host start with exit 3 and stays as it was (131.691436ms)
✔ an over-long launch request is refused at once, not at the 10 s idle timeout (134.617147ms)
✖ a launch client that never closes its side doesn't hold the host's close (5141.26814ms)
✔ a runner that ignores SIGTERM is killed when the host closes (1245.744729ms)
✔ zoned uses the IANA zone across DST (21.727344ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (135.654962ms)
✔ two blocking decisions get two DMs with different nonces (134.029654ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.219983ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (127.900132ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (118.574638ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (253.517992ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (187.1812ms)
✔ polled every POLL_MS against a permanent 403, a DM is sent at 0, 30, 60, 90 and 120 min and gives up only then (245.139275ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (150.086073ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (154.204532ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (126.531257ms)
✔ an inbox read failure is logged and the next poll retries (0.638941ms)
✔ no Discord id reaches the journal or the log (138.385811ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (25.990241ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (16.992018ms)
✔ the journal: a whole file that is one torn line truncates to empty (24.49927ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (2.181954ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.610406ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (1.787671ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.294652ms)
✔ the journal: a dangling directory link, a parent that is a file and a journal that is a directory each refuse with exit 3 (0.444251ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.362729ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.494578ms)
✔ digest content stays within Discord's 2000 characters (0.253667ms)
✔ runLoop never overlaps ticks and stops after the one in flight (110.515683ms)
task.close {} answered: invalid-request; fake saw 18 requests, first GET /info 200, GET /projects/1 200, GET /projects/1/views 200
task.close on a missing task answered: task-not-found; it made GET /tasks/999 404
✔ bootConfig trackers reach the S3 adapter in the real broker child, which goes ready against a fake Vikunja (403.373726ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (45.853ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2149.662427ms)
✔ busExit and refuseInsideAgent (0.413335ms)
✔ talk sends a REQUEST, prints and reads everything that arrives, and stops at the reply (237.6072ms)
✔ talk --wait 0 only sends; no reply within --wait exits 1 and says where it will show (1139.313934ms)
✔ talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors (128.151994ms)
✔ launches off and on go to the broker and change the business's launch state (152.199859ms)
✔ launches list reads sessions.json and marks a stale entry; stop reports it and refuses a non-runner (121.178327ms)
ℹ tests 82
ℹ suites 0
ℹ pass 81
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 13457.09326
✖ failing tests:
test at packages/cli/tests/launcher.test.mjs:337:1
✖ a launch client that never closes its side doesn't hold the host's close (5141.26814ms)
Error: close took over 5000 ms
at Timeout._onTimeout (file:///home/jwoltje/darkwing-scratch/r41b/wt/packages/cli/tests/launcher.test.mjs:324:101)
at listOnTimeout (node:internal/timers:685:17)
at process.processTimers (node:internal/timers:618:7)
@@ -0,0 +1,102 @@
✔ sessionModel: agent vars win, then the system's execution settings (10.94696ms)
✔ a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory (4.717387ms)
✔ a claude-code bundle adds the wrapped gate hook and the MCP config (2.654246ms)
✔ a bundle is written once: an existing file refuses (2.265185ms)
✔ a path with a single quote can't go into the hook command (2.444314ms)
✔ allow exits 0, a deny exits 2 with the reason on stderr (125.8015ms)
✔ a missing or wrong policy, or a bad event, exits 2 (95.187298ms)
✔ the bundle's wrapped command: a missing gate or node still blocks (1095.106113ms)
✔ claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist (819.040254ms)
✔ claude: the hook alone blocks a path outside the workspace (460.007196ms)
✔ claude: a second turn resumes the first turn's session (742.523714ms)
✖ claude adapter: --restricted is always passed (6.097611ms)
✖ claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do (455.591086ms)
✔ claude: a missing hook or MCP file refuses before claude starts (7.562261ms)
✔ pi: policy tools and typed tools pass, anything else is blocked (2.64706ms)
✔ claude: builtins map from pi names, typed tools need the mcp prefix (0.790916ms)
✔ file tool paths must resolve inside the workspace (1.195488ms)
✔ pi's own path normalisation can't be used to step out (1.046526ms)
✔ a symlink inside the workspace that points out is outside (0.779909ms)
✔ a dangling symlink is refused at any depth, in both harnesses (2.947901ms)
✔ claude path fields per tool (0.952429ms)
✔ glob patterns stay inside the workspace (1.047138ms)
✔ a path that can't be checked is blocked (0.452905ms)
✔ initialize, ping and tools/list (46.485032ms)
✔ tools/call goes through the tool socket; a refusal is an isError result (35.386331ms)
✔ unknown tools and methods are JSON-RPC errors and never reach the socket (32.536619ms)
✔ a missing argument is a usage error (32.23212ms)
✔ pi: typed tools reach the socket, the gate blocks, agent_end writes the marker (382.741035ms)
✔ pi: a write through a dangling symlink is blocked, and nothing appears outside (328.302617ms)
✔ pi: a missing extension refuses before any model call (7.445419ms)
✔ pi: an extension without its configuration fails pi's start (262.696262ms)
✔ founderCheck: founder variables, then a needed service without a usable token (1.634907ms)
✔ turnRequest names the sender, class, reply and decision (0.272949ms)
✔ a message becomes a turn, the answer goes back as a RESULT, SIGTERM releases and exits 0 (277.257539ms)
✔ a SIGTERM before the claim stops the runner with exit 0 and no claim (114.949ms)
✔ typed tools carry the runner's capability; launch goes to the host's launch socket (437.01996ms)
✔ a RESULT gets no automatic reply; failed turns reply with the reason (1287.409236ms)
✔ SIGTERM during a turn kills the turn's process group and still exits 0 (194.407345ms)
✔ founder credentials stop before the claim (20) (203.734693ms)
✔ a refused claim exits 21; an ended run's capability exits 22 (216.376213ms)
✔ the launch ending under a running session exits 22 (170.260876ms)
✔ a broker that stays unreachable exits 23 after brokerRetries polls (282.678026ms)
✔ a broker that is down at the claim exits 23, not 21 (110.208765ms)
✔ no capability, or a malformed one, on stdin exits 2 (220.855985ms)
✔ a missing or malformed policy exits 2 before the claim (152.929041ms)
✔ the PM gets launch, its task verbs and the reads (7.712731ms)
✔ a coder gets no launch, no resolve_decision, and no task tools without a tracker (2.59188ms)
✔ launch only when the business's launch block names the instance as launcher (1.494155ms)
✔ an action outside the instance's authority has no tool (1.530795ms)
✔ callTool: one JSON line out, the result back, a refusal rejects (9.705728ms)
ℹ tests 50
ℹ suites 0
ℹ pass 48
ℹ fail 2
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 10435.898259
✖ failing tests:
test at packages/harness/tests/claude-session.test.mjs:143:1
✖ claude adapter: --restricted is always passed (6.097611ms)
AssertionError [ERR_ASSERTION]: -p x --output-format text --system-prompt {} --model claude-sonnet-5-5 --tools --allowedTools mcp__mosaic --permission-mode dontAsk --settings /home/jwoltje/darkwing-scratch/tmp/mosaic-harness-4l0w4H/settings.json --strict-mcp-config --mcp-config /home/jwoltje/darkwing-scratch/tmp/mosaic-harness-4l0w4H/mcp.json --disable-slash-commands --session-id a3f9e9f9-910b-4c70-83f6-95e95f157e7b
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41b/wt/packages/harness/tests/claude-session.test.mjs:166:10)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: false,
expected: true,
operator: '==',
diff: 'simple'
}
test at packages/harness/tests/claude-session.test.mjs:170:1
✖ claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do (455.591086ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal:
+ actual - expected
+ [
+ 'MARKER-USER',
+ 'MARKER-PARENT',
+ 'MARKER-WS',
+ 'MARKER-MEMORY'
+ ]
- []
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41b/wt/packages/harness/tests/claude-session.test.mjs:193:10)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: [ 'MARKER-USER', 'MARKER-PARENT', 'MARKER-WS', 'MARKER-MEMORY' ],
expected: [],
operator: 'deepStrictEqual',
diff: 'simple'
}
@@ -0,0 +1,102 @@
✔ launch identity is stamped, payload identity is refused and stale holder cannot send (156.688073ms)
✔ decision classes route from policy; gated resolution is human-only, choice and target must match (254.990034ms)
✔ claim exclusion, holder release, gated revoke and rerouting to a new holder are atomic (263.4071ms)
✔ launch events require a human CLI capability; generic emit cannot forge authority events (161.541146ms)
✔ within-role decisions close atomically and invalid options or blocking omissions refuse (168.597858ms)
✔ observer capabilities read human inbox but cannot mutate or forge launch identity (163.557403ms)
✔ task action subjects and linked decision trail are complete and ordered (196.811971ms)
✔ launch binding is durable and reconnecting requires the identical trusted record (98.107431ms)
✔ business isolation includes inherited object names and cross-business message references (184.203812ms)
✔ authority never transfers between action, run, target, unresolved or replaced role holder (264.871253ms)
✔ task projection uses schema current view, skipping earlier and equal-start polls (151.601254ms)
✔ revocation permanently bars the old run from reclaiming first, including after broker restart (208.945298ms)
✔ empty message references refuse before storage; refusal-evidence failure stays a typed error (132.772431ms)
✔ both arbiters require human resolution when their cross-role route is themselves (206.679273ms)
✔ S1 adapter takes resolved limits and refs, rejects mismatched instance, never mutates input (1.721977ms)
✔ only validated broker references load; returned data and exceptions cannot expose a known token (4.468229ms)
✔ bad file modes, symlinks, repository/data paths, malformed tokens and missing dates refuse (2.381998ms)
✔ expiry refuses use and env references never become client data (0.725347ms)
✔ S1 parsed service refs work, service mismatch refuses, Gitea rotation due is a warning state (1.585195ms)
✔ opaque tokens shorter than 16 characters refuse before use (0.367562ms)
✔ endLaunch writes session.ended, releases the run claim and kills its capabilities (115.594666ms)
✔ endLaunch refuses an unknown run, a second end and a rebind of the ended run (125.580874ms)
✔ a restarted broker refuses to rebind an ended run; a refused rebind leaves the run unbound (149.334492ms)
✔ endLaunch leaves a claim another run took alone (179.262151ms)
✔ refuse records action.refused against the caller with the code only (123.396171ms)
✔ launches off refuses role.launch with launch-revoked until launches on (182.423177ms)
✖ broker process: launch ops authorize role.launch, record refusals and end runs (255.509266ms)
✔ human proof binds CLI entry, process start and nonce; agents and incomplete ancestry refuse (2.798434ms)
✔ process reader gets own kernel identity without exposing environment values (0.564421ms)
✔ EACCES ancestor environments skip only markers; commands and registered launches still refuse (0.86072ms)
✔ real pid 1 remains inspectable when its environment is protected (0.307777ms)
✔ within-role sends cite an open gated launch decision without spending it or naming it in grants (165.580215ms)
✔ missing and foreign-business citations refuse and roll back message and grant (179.045078ms)
✔ cross-role sends still need a matching resolved decision and consume it once (248.113233ms)
✔ broker process binds trusted launches, offers reader capabilities, refuses human mutation, closes cleanly (178.726728ms)
✔ startup token refusal returns safe code without value or partial listening broker (36.961343ms)
✔ loaded fixture token is absent from socket replies and SQLite, including refusal evidence (179.862873ms)
✔ killed broker leaves an explicit stale lock; another process cannot silently reclaim it (201.608434ms)
✔ trusted host registers later launches; socket clients never have a registration verb (188.507925ms)
✔ runtime excludes declared project roots even when host supplies no repoRoots (50.946893ms)
✔ a refused launch binding leaves the broker and existing capabilities alive; bad protocol stops it (202.870546ms)
✔ v3b prototype refusals, views and append-only mutations (996.668482ms)
✔ gated approval authorizes once, survives store reopen, and fresh approval works (230.014396ms)
✔ another run cannot consume an approval; a failed check leaves it usable (219.424366ms)
✔ two scheduled callers have exactly one grant and one consumed refusal (163.386829ms)
✔ failed commit rolls consumption back; cross-role consumes and within-role stays reusable (313.530515ms)
✔ class drift gated to cross-role refuses before consumption (203.402278ms)
✔ class drift cross-role to gated refuses before consumption (231.111744ms)
✔ class drift gated to within-role refuses before consumption (205.601926ms)
✔ class drift cross-role to within-role refuses before consumption (207.540289ms)
✔ class drift within-role to gated refuses before consumption (227.612879ms)
✔ class drift within-role to cross-role refuses before consumption (182.67593ms)
✔ message.send consumes approval and prevents a later send or authorize (205.696724ms)
✔ role.revoke consumes approval and prevents a later revoke or authorize (223.52629ms)
✔ creates private WAL store and excludes a second writer until explicit close (106.384558ms)
✔ rollback is atomic and schema metadata is checked against trusted DDL, not just itself (172.585017ms)
✔ existing empty database and symlink runtime directory refuse, never initialize over damage (194.975755ms)
✔ crash during a transaction recovers no partial event after explicit fixture-only lock removal (191.459506ms)
✔ writer refuses mixed at/read_at forms atomically, even through trusted SQL helpers (110.751067ms)
✔ async transactions refuse before invoking their function (97.662881ms)
✔ recordTask keeps sync reads and a role write apart (161.995121ms)
✔ read_at must be one canonical UTC format, so the projection compares strings safely (99.394876ms)
✔ a bad entry refuses the whole record (105.139574ms)
✔ taskView reads the projection for one business (144.68784ms)
✔ requestTask hands only a holder and a task verb to the handler, and records refusals (234.262551ms)
✔ the server sends task verbs to the adapter with its own timeout; other verbs stay synchronous (429.558263ms)
✔ without an adapter the server refuses every task verb (209.208603ms)
✔ the runtime refuses an invalid adapter and closes a valid one (211.154568ms)
✔ the process loads the S3 adapter from plain-data trackers (304.261499ms)
✔ socket capability stamps launch identity; shared views use wire, no SQL client (130.132782ms)
✔ two wire claims serialize; a lost reply never automatically retries (167.754097ms)
✔ malformed, oversized and identity-forging envelopes refuse without echoing input (119.612349ms)
✔ client preserves UTF-8 when a response divides a multibyte character (13.392997ms)
✔ committed mutation followed by dropped reply reports unknown and is never retried (137.570475ms)
ℹ tests 74
ℹ suites 0
ℹ pass 73
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 2705.19541
✖ failing tests:
test at packages/bus/tests/end-launch.test.mjs:135:1
✖ broker process: launch ops authorize role.launch, record refusals and end runs (255.509266ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
undefined !== 7
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41b/wt/packages/bus/tests/end-launch.test.mjs:179:10)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: undefined,
expected: 7,
operator: 'strictEqual',
diff: 'simple'
}
@@ -0,0 +1,208 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (136.584442ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (141.940734ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (132.039183ms)
✔ decide prints a declining choice as declining (143.587352ms)
✔ an unknown outcome is reported once and never resent (124.761265ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (121.794483ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (135.996842ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (101.111215ms)
✔ every human command refuses inside an agent run before it touches the bus (109.364301ms)
✔ usage errors exit 4; no business and no host is a usage error (96.177626ms)
✔ agents and tasks print through the broker (112.915537ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (1.21964ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (49.164257ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (38.064735ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (32.805116ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (33.123203ms)
✔ a business without tracker.baseUrl gets no trackers entry (32.302573ms)
✔ an unknown business and a broken system config refuse with exit 3 (60.310168ms)
✔ empty views say so (1.146721ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (1.493659ms)
✔ tasks print the tracker fields the snapshot carries (0.226234ms)
mosaic-notify: notify: poll failed: ENOENT: no such file or directory, open '/home/jwoltje/darkwing-scratch/tmp/mosaic-cli-5w2KeE/data/notify/acme/sent.jsonl'
✖ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (273.165302ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (284.22541ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (183.834661ms)
✖ a broker reply that misses the wait breaks the channel: the late reply answers nothing, later requests refuse, the host exits 1 (171.069067ms)
✖ a broker reply with another request's id, or none, breaks the channel and the host exits 1 (164.966958ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (217.716459ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (198.431849ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (144.888946ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (199.525503ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (146.507475ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (213.318879ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (26.316119ms)
✔ bus stop refuses to signal a live pid that is not a bus host (202.981804ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (226.439466ms)
✔ bus start refuses with exit 3 without a notifier config (91.492051ms)
✔ bus start runs until bus stop; status reports it while it runs (746.921036ms)
✔ bus-service.sh renders the unit and installs it into a given directory (29.344451ms)
Interrupted while running:
⚠ packages/cli/tests/launcher.test.mjs (packages/cli/tests/launcher.test.mjs:1:1)
⚠ packages/cli/tests/notifier.test.mjs (packages/cli/tests/notifier.test.mjs:1:1)
⚠ packages/cli/tests/trackers-boot.test.mjs (packages/cli/tests/trackers-boot.test.mjs:1:1)
⚠ packages/cli/tests/transport.test.mjs (packages/cli/tests/transport.test.mjs:1:1)
⚠ packages/cli/tests/verbs.test.mjs (packages/cli/tests/verbs.test.mjs:1:1)
✖ bus start --pm: the PM runs under its own PID namespace, registered, with a manifest (217.752184ms)
✖ the PM launches a coder through its launch tool; the coder answers; refusals name their code (187.022254ms)
✖ a runner that stops at once ends its launch with the runner's reason (196.677194ms)
file:///home/jwoltje/darkwing-scratch/r41b/wt/packages/cli/src/host.mjs:166
const fail = (code, text) => Object.assign(new CliError(text, 1), { code });
^
CliError: broker channel broken: reply for another request
at fail (file:///home/jwoltje/darkwing-scratch/r41b/wt/packages/cli/src/host.mjs:166:46)
at breakChannel (file:///home/jwoltje/darkwing-scratch/r41b/wt/packages/cli/src/host.mjs:172:22)
at ChildProcess.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41b/wt/packages/cli/src/host.mjs:185:12)
at ChildProcess.emit (node:events:514:20)
at emit (node:internal/child_process:973:14)
at process.processTicksAndRejections (node:internal/process/task_queues:91:21) {
exitCode: 1,
code: 'broker-channel-broken'
}
Node.js v26.8.1
✖ packages/cli/tests/launcher.test.mjs (899959.906082ms)
✔ zoned uses the IANA zone across DST (23.264734ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (138.651891ms)
✔ two blocking decisions get two DMs with different nonces (135.324908ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.186002ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (130.279935ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (118.259498ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (131.903789ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (130.558414ms)
✔ polled every POLL_MS against a permanent 403, a DM is sent at 0, 30, 60, 90 and 120 min and gives up only then (194.25207ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (143.608044ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (117.165811ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (110.833235ms)
✔ an inbox read failure is logged and the next poll retries (0.698135ms)
✔ no Discord id reaches the journal or the log (102.537652ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (25.475607ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (41.724142ms)
✔ the journal: a whole file that is one torn line truncates to empty (20.888812ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (5.607382ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.76875ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (4.591218ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.385496ms)
✔ the journal: a dangling directory link, a parent that is a file and a journal that is a directory each refuse with exit 3 (0.611992ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.588504ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.554506ms)
✔ digest content stays within Discord's 2000 characters (0.297253ms)
✔ runLoop never overlaps ticks and stops after the one in flight (110.70324ms)
tasks acme startup tracker-unavailable
✖ bootConfig trackers reach the S3 adapter in the real broker child, which goes ready against a fake Vikunja (250.626352ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (37.535565ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2165.50339ms)
✔ busExit and refuseInsideAgent (0.453975ms)
✔ talk sends a REQUEST, prints and reads everything that arrives, and stops at the reply (235.634935ms)
✔ talk --wait 0 only sends; no reply within --wait exits 1 and says where it will show (1137.945416ms)
✔ talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors (97.50083ms)
✔ launches off and on go to the broker and change the business's launch state (102.966001ms)
✔ launches list reads sessions.json and marks a stale entry; stop reports it and refuses a non-runner (98.360172ms)
ℹ tests 77
ℹ suites 0
ℹ pass 69
ℹ fail 7
ℹ cancelled 1
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 899978.394176
✖ failing tests:
test at packages/cli/tests/host.test.mjs:124:1
✖ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (273.165302ms)
Error [CliError]: broker channel broken: reply for another request
at fail (file:///home/jwoltje/darkwing-scratch/r41b/wt/packages/cli/src/host.mjs:166:46)
at breakChannel (file:///home/jwoltje/darkwing-scratch/r41b/wt/packages/cli/src/host.mjs:172:22)
at ChildProcess.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41b/wt/packages/cli/src/host.mjs:185:12)
at ChildProcess.emit (node:events:514:20)
at emit (node:internal/child_process:973:14)
at process.processTicksAndRejections (node:internal/process/task_queues:91:21) {
exitCode: 1,
code: 'broker-channel-broken'
}
test at packages/cli/tests/host.test.mjs:206:1
✖ a broker reply that misses the wait breaks the channel: the late reply answers nothing, later requests refuse, the host exits 1 (171.069067ms)
Error [CliError]: broker channel broken: reply for another request
at fail (file:///home/jwoltje/darkwing-scratch/r41b/wt/packages/cli/src/host.mjs:166:46)
at breakChannel (file:///home/jwoltje/darkwing-scratch/r41b/wt/packages/cli/src/host.mjs:172:22)
at ChildProcess.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41b/wt/packages/cli/src/host.mjs:185:12)
at ChildProcess.emit (node:events:514:20)
at emit (node:internal/child_process:973:14)
at process.processTicksAndRejections (node:internal/process/task_queues:91:21) {
exitCode: 1,
code: 'broker-channel-broken'
}
test at packages/cli/tests/host.test.mjs:240:1
✖ a broker reply with another request's id, or none, breaks the channel and the host exits 1 (164.966958ms)
Error [CliError]: broker channel broken: reply for another request
at fail (file:///home/jwoltje/darkwing-scratch/r41b/wt/packages/cli/src/host.mjs:166:46)
at breakChannel (file:///home/jwoltje/darkwing-scratch/r41b/wt/packages/cli/src/host.mjs:172:22)
at ChildProcess.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41b/wt/packages/cli/src/host.mjs:185:12)
at ChildProcess.emit (node:events:514:20)
at emit (node:internal/child_process:973:14)
at process.processTicksAndRejections (node:internal/process/task_queues:91:21) {
exitCode: 1,
code: 'broker-channel-broken'
}
test at packages/cli/tests/launcher.test.mjs:106:1
✖ bus start --pm: the PM runs under its own PID namespace, registered, with a manifest (217.752184ms)
Error [CliError]: broker channel broken: reply for another request
at fail (file:///home/jwoltje/darkwing-scratch/r41b/wt/packages/cli/src/host.mjs:166:46)
at breakChannel (file:///home/jwoltje/darkwing-scratch/r41b/wt/packages/cli/src/host.mjs:172:22)
at ChildProcess.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41b/wt/packages/cli/src/host.mjs:185:12)
at ChildProcess.emit (node:events:514:20)
at emit (node:internal/child_process:973:14)
at process.processTicksAndRejections (node:internal/process/task_queues:91:21) {
exitCode: 1,
code: 'broker-channel-broken'
}
test at packages/cli/tests/launcher.test.mjs:176:1
✖ the PM launches a coder through its launch tool; the coder answers; refusals name their code (187.022254ms)
Error [CliError]: broker channel broken: reply for another request
at fail (file:///home/jwoltje/darkwing-scratch/r41b/wt/packages/cli/src/host.mjs:166:46)
at breakChannel (file:///home/jwoltje/darkwing-scratch/r41b/wt/packages/cli/src/host.mjs:172:22)
at ChildProcess.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41b/wt/packages/cli/src/host.mjs:185:12)
at ChildProcess.emit (node:events:514:20)
at emit (node:internal/child_process:973:14)
at process.processTicksAndRejections (node:internal/process/task_queues:91:21) {
exitCode: 1,
code: 'broker-channel-broken'
}
test at packages/cli/tests/launcher.test.mjs:237:1
✖ a runner that stops at once ends its launch with the runner's reason (196.677194ms)
Error [CliError]: broker channel broken: reply for another request
at fail (file:///home/jwoltje/darkwing-scratch/r41b/wt/packages/cli/src/host.mjs:166:46)
at breakChannel (file:///home/jwoltje/darkwing-scratch/r41b/wt/packages/cli/src/host.mjs:172:22)
at ChildProcess.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41b/wt/packages/cli/src/host.mjs:185:12)
at ChildProcess.emit (node:events:514:20)
at emit (node:internal/child_process:973:14)
at process.processTicksAndRejections (node:internal/process/task_queues:91:21) {
exitCode: 1,
code: 'broker-channel-broken'
}
test at packages/cli/tests/launcher.test.mjs:1:1
✖ packages/cli/tests/launcher.test.mjs (899959.906082ms)
'Promise resolution is still pending but the event loop has already resolved'
test at packages/cli/tests/trackers-boot.test.mjs:15:1
✖ bootConfig trackers reach the S3 adapter in the real broker child, which goes ready against a fake Vikunja (250.626352ms)
Error [CliError]: broker channel broken: reply for another request
at fail (file:///home/jwoltje/darkwing-scratch/r41b/wt/packages/cli/src/host.mjs:166:46)
at breakChannel (file:///home/jwoltje/darkwing-scratch/r41b/wt/packages/cli/src/host.mjs:172:22)
at ChildProcess.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41b/wt/packages/cli/src/host.mjs:185:12)
at ChildProcess.emit (node:events:514:20)
at emit (node:internal/child_process:973:14)
at process.processTicksAndRejections (node:internal/process/task_queues:91:21) {
exitCode: 1,
code: 'broker-channel-broken'
}
@@ -0,0 +1,22 @@
Ma-late-signals (packages/harness/src/runner.mjs): harness rc 1 pass 47 fail 3; cli rc 1 pass 80 fail 2;
Mb-runs-set-early (packages/bus/src/broker.mjs): bus rc 1 pass 73 fail 1; cli rc 0 pass 82 fail 0;
Mc-no-run-ended (packages/bus/src/broker.mjs): bus rc 1 pass 73 fail 1; cli rc 1 pass 80 fail 2;
Md-no-instance-running (packages/cli/src/launcher.mjs): cli rc 1 pass 80 fail 2;
Me-no-authorize (packages/cli/src/launcher.mjs): cli rc 1 pass 81 fail 1;
Mf-no-exit2-wrapper (packages/harness/src/bundle.mjs): harness rc 1 pass 49 fail 1;
Mg-no-founder-check (packages/harness/src/runner.mjs): harness rc 1 pass 48 fail 2; cli rc 0 pass 82 fail 0;
Mh-no-close-sigkill (packages/cli/src/launcher.mjs): cli rc 1 pass 81 fail 1;
Mi-recover-no-kill (packages/cli/src/launcher.mjs): cli rc 1 pass 81 fail 1;
Mj-no-stdin-cap (packages/harness/src/runner.mjs): harness rc 1 pass 49 fail 1;
Mk-launch-line-max (packages/cli/src/launcher.mjs): cli rc 1 pass 81 fail 1;
Ml-gate-pattern (packages/harness/src/gate.mjs): harness rc 1 pass 49 fail 1;
Mm-no-revoke (packages/bus/src/broker.mjs): bus rc 1 pass 73 fail 1; cli rc 0 pass 82 fail 0;
Mn-recover-no-end (packages/cli/src/launcher.mjs): cli rc 1 pass 80 fail 2;
Mo-policy-outside-try (packages/harness/src/runner.mjs): harness rc 1 pass 49 fail 1;
Mp-any-reply (packages/cli/src/host.mjs): cli rc 1 pass 81 fail 1;
Mq-no-timer (packages/cli/src/host.mjs): cli rc 1 pass 81 fail 0;
Mr-ignore-unsolicited (packages/cli/src/host.mjs): cli rc 0 pass 82 fail 0;
Ms-follow-walk (packages/harness/src/gate.mjs): harness rc 1 pass 48 fail 2;
Mt-no-socket-destroy (packages/cli/src/launcher.mjs): cli rc 1 pass 81 fail 1;
Mu-no-restricted (adapters/claude/adapter.sh): harness rc 1 pass 48 fail 2;
Mv-no-tag (packages/bus/src/process.mjs): bus rc 1 pass 73 fail 1; cli rc 124 pass 69 fail 7;
+26
View File
@@ -0,0 +1,26 @@
#!/bin/bash
R=~/darkwing-scratch/r41b/mut
rm -f $R/summary.txt $R/M*-*.txt
$R/run.sh Ma-late-signals harness cli
$R/run.sh Mb-runs-set-early bus cli
$R/run.sh Mc-no-run-ended bus cli
$R/run.sh Md-no-instance-running cli
$R/run.sh Me-no-authorize cli
$R/run.sh Mf-no-exit2-wrapper harness
$R/run.sh Mg-no-founder-check harness cli
$R/run.sh Mh-no-close-sigkill cli
$R/run.sh Mi-recover-no-kill cli
$R/run.sh Mj-no-stdin-cap harness
$R/run.sh Mk-launch-line-max cli
$R/run.sh Ml-gate-pattern harness
$R/run.sh Mm-no-revoke bus cli
$R/run.sh Mn-recover-no-end cli
$R/run.sh Mo-policy-outside-try harness
$R/run.sh Mp-any-reply cli
$R/run.sh Mq-no-timer cli
$R/run.sh Mr-ignore-unsolicited cli
$R/run.sh Ms-follow-walk harness
$R/run.sh Mt-no-socket-destroy cli
$R/run.sh Mu-no-restricted harness
$R/run.sh Mv-no-tag bus cli
echo DONE >> $R/summary.txt
@@ -0,0 +1,42 @@
adapters/README.md: OK
adapters/claude/adapter.sh: OK
adapters/pi/adapter.sh: OK
docs/TOOLS.md: OK
packages/bus/README.md: OK
packages/bus/src/broker.mjs: OK
packages/bus/src/process.mjs: OK
packages/bus/src/runtime.mjs: OK
packages/bus/tests/end-launch.test.mjs: OK
packages/cli/README.md: OK
packages/cli/src/cli.mjs: OK
packages/cli/src/host.mjs: OK
packages/cli/src/launcher.mjs: OK
packages/cli/tests/fixtures/launch-host.mjs: OK
packages/cli/tests/host.test.mjs: OK
packages/cli/tests/launcher.test.mjs: OK
packages/cli/tests/verbs.test.mjs: OK
packages/harness/README.md: OK
packages/harness/package.json: OK
packages/harness/src/bundle.mjs: OK
packages/harness/src/claude-gate.mjs: OK
packages/harness/src/gate.mjs: OK
packages/harness/src/mcp-server.mjs: OK
packages/harness/src/pi-extension.mjs: OK
packages/harness/src/runner.mjs: OK
packages/harness/src/tools.mjs: OK
packages/harness/tests/bundle.test.mjs: OK
packages/harness/tests/claude-gate.test.mjs: OK
packages/harness/tests/claude-session.test.mjs: OK
packages/harness/tests/fixtures/fake-adapter.mjs: OK
packages/harness/tests/gate.test.mjs: OK
packages/harness/tests/helpers.mjs: OK
packages/harness/tests/mcp-server.test.mjs: OK
packages/harness/tests/pi-session.test.mjs: OK
packages/harness/tests/runner.test.mjs: OK
packages/harness/tests/tools.test.mjs: OK
packages/seat/README.md: OK
packages/seat/src/proc.mjs: OK
packages/seat/src/session.mjs: OK
packages/seat/tests/session.test.mjs: OK
scripts/agent-host-dev.sh: OK
scripts/mosaic: OK
@@ -0,0 +1,62 @@
# mutate.py <file> <id>: apply one named mutant (exact text, must match once).
import sys
M = {
"Ma-late-signals": ("packages/harness/src/runner.mjs",
' process.on("SIGTERM", stop);\n process.on("SIGINT", stop);\n\n let session, cap, policy;',
' let session, cap, policy;', ),
"Mb-runs-set-early": ("packages/bus/src/broker.mjs",
" const session = { ...record, address: record.address ?? null, human: false };\n",
" const session = { ...record, address: record.address ?? null, human: false };\n this.#runs.set(key, session);\n"),
"Mc-no-run-ended": ("packages/bus/src/broker.mjs",
" fail('run-ended');\n } else", " void 0;\n } else"),
"Md-no-instance-running": ("packages/cli/src/launcher.mjs",
' if (registry.running(b.id, instance)) throw new Refusal("instance-running");\n', ""),
"Me-no-authorize": ("packages/cli/src/launcher.mjs",
' await host.op({ op: "authorizeLaunch", cap, instance });\n', ""),
"Mf-no-exit2-wrapper": ("packages/harness/src/bundle.mjs",
"${quote(files.policy)} || exit 2`", "${quote(files.policy)}`"),
"Mg-no-founder-check": ("packages/harness/src/runner.mjs",
" const found = FOUNDER_ENV.filter((k) => env[k] !== undefined);", " const found = [];"),
"Mh-no-close-sigkill": ("packages/cli/src/launcher.mjs",
' if (startTimeOf(s.pid) === s.startTime) process.kill(s.pid, "SIGKILL");', " void 0;"),
"Mi-recover-no-kill": ("packages/cli/src/launcher.mjs",
' process.kill(s.pid, "SIGKILL");', " void 0;"),
"Mj-no-stdin-cap": ("packages/harness/src/runner.mjs",
" if (input.length > 4096) reject", " if (false) reject"),
"Mk-launch-line-max": ("packages/cli/src/launcher.mjs",
" if (buf.length > LINE_MAX) return reply", " if (false) return reply"),
"Ml-gate-pattern": ("packages/harness/src/gate.mjs",
"/(^|[/\\\\])\\.\\.([/\\\\]|$)/.test(pattern)", "false"),
"Mm-no-revoke": ("packages/bus/src/broker.mjs",
" fail('launch-revoked');", " void 0;"),
"Mn-recover-no-end": ("packages/cli/src/launcher.mjs",
' await endRun(s.run, "host-lost", null);\n', ""),
"Mo-policy-outside-try": ("packages/harness/src/runner.mjs",
' policy = JSON.parse(readFileSync(session.bundle.policy, "utf8"));\n } catch (e) {\n log(`runner: ${e.message}`);\n return EXIT.usage;\n }\n',
' } catch (e) {\n log(`runner: ${e.message}`);\n return EXIT.usage;\n }\n policy = JSON.parse(readFileSync(session.bundle.policy, "utf8"));\n'),
"Mp-any-reply": ("packages/cli/src/host.mjs",
" if (pending && m?.id === pending.id) {", " if (pending) {"),
"Mq-no-timer": ("packages/cli/src/host.mjs",
" const timer = setTimeout(() => breakChannel(`no reply within ${Math.round(requestTimeoutMs / 1000)} s`), requestTimeoutMs);",
" const timer = null;"),
"Mr-ignore-unsolicited": ("packages/cli/src/host.mjs",
' } else breakChannel(pending ? "reply for another request" : "reply with no request waiting");',
' } else if (pending) breakChannel("reply for another request");'),
"Ms-follow-walk": ("packages/harness/src/gate.mjs",
" while (!lstatSync(head, { throwIfNoEntry: false })) {",
" while (!(() => { try { return realpathSync(head); } catch { return null; } })()) {"),
"Mt-no-socket-destroy": ("packages/cli/src/launcher.mjs",
" for (const socket of sockets) socket.destroy();", " void sockets;"),
"Mu-no-restricted": ("adapters/claude/adapter.sh",
" --restricted \\\n", ""),
"Mv-no-tag": ("packages/bus/src/process.mjs",
"const tag = (message, reply) => (Number.isSafeInteger(message?.id) ? { ...reply, id: message.id } : reply);",
"const tag = (message, reply) => reply;"),
}
f, old, new = M[sys.argv[1]]
if "--file-only" in sys.argv: print(f); sys.exit(0)
s = open(f).read()
n = s.count(old)
if n != 1: sys.exit(f"{sys.argv[1]}: {n} matches in {f}")
open(f, "w").write(s.replace(old, new))
print(f)
+8
View File
@@ -0,0 +1,8 @@
#!/bin/bash
# parse.sh: pass/fail and failing test names per mutant output (spec reporter).
cd ~/darkwing-scratch/r41b/mut
for f in M*-*.txt; do
p=$(grep -E '^ℹ pass' "$f" | awk '{print $3}'); x=$(grep -E '^ℹ fail' "$f" | awk '{print $3}')
echo "$f: pass $p fail $x"
[ "$x" != 0 ] && awk '/^✖ failing tests:/{on=1;next} on && /^✖ /{sub(/^✖ /," "); sub(/ \([0-9.]+ms\)$/,""); print}' "$f" | sort -u
done
+19
View File
@@ -0,0 +1,19 @@
#!/bin/bash
# run.sh <mutant> <pkg>...: mutate, run each package's node suite, restore from a backup copy.
set -u
cd ~/darkwing-scratch/r41b/wt
export TMPDIR=~/darkwing-scratch/tmp DOCKER_HOST=unix:///nonexistent.sock
m=$1; shift
f=$(python3 ../mut/mutate.py "$m" --file-only) || { echo "$m: no file" | tee -a ../mut/summary.txt; exit 1; }
cp -p "$f" ../mut/backup.tmp
python3 ../mut/mutate.py "$m" > /dev/null || { echo "$m: no match" | tee -a ../mut/summary.txt; exit 1; }
line="$m ($f):"
for p in "$@"; do
out=../mut/$m-$p.txt
timeout 900 node --test "packages/$p/tests/*.test.mjs" > "$out" 2>&1
rc=$?
pass=$(grep -E '^ℹ pass' "$out" | awk '{print $3}'); fail=$(grep -E '^ℹ fail' "$out" | awk '{print $3}')
line="$line $p rc $rc pass ${pass:-?} fail ${fail:-?};"
done
cp -p ../mut/backup.tmp "$f" && rm ../mut/backup.tmp
echo "$line" | tee -a ../mut/summary.txt
@@ -0,0 +1,23 @@
Ma-late-signals (packages/harness/src/runner.mjs): harness rc 1 pass 47 fail 3; cli rc 1 pass 80 fail 2;
Mb-runs-set-early (packages/bus/src/broker.mjs): bus rc 1 pass 73 fail 1; cli rc 0 pass 82 fail 0;
Mc-no-run-ended (packages/bus/src/broker.mjs): bus rc 1 pass 73 fail 1; cli rc 1 pass 80 fail 2;
Md-no-instance-running (packages/cli/src/launcher.mjs): cli rc 1 pass 80 fail 2;
Me-no-authorize (packages/cli/src/launcher.mjs): cli rc 1 pass 81 fail 1;
Mf-no-exit2-wrapper (packages/harness/src/bundle.mjs): harness rc 1 pass 49 fail 1;
Mg-no-founder-check (packages/harness/src/runner.mjs): harness rc 1 pass 48 fail 2; cli rc 0 pass 82 fail 0;
Mh-no-close-sigkill (packages/cli/src/launcher.mjs): cli rc 1 pass 81 fail 1;
Mi-recover-no-kill (packages/cli/src/launcher.mjs): cli rc 1 pass 81 fail 1;
Mj-no-stdin-cap (packages/harness/src/runner.mjs): harness rc 1 pass 49 fail 1;
Mk-launch-line-max (packages/cli/src/launcher.mjs): cli rc 1 pass 81 fail 1;
Ml-gate-pattern (packages/harness/src/gate.mjs): harness rc 1 pass 49 fail 1;
Mm-no-revoke (packages/bus/src/broker.mjs): bus rc 1 pass 73 fail 1; cli rc 0 pass 82 fail 0;
Mn-recover-no-end (packages/cli/src/launcher.mjs): cli rc 1 pass 80 fail 2;
Mo-policy-outside-try (packages/harness/src/runner.mjs): harness rc 1 pass 49 fail 1;
Mp-any-reply (packages/cli/src/host.mjs): cli rc 1 pass 81 fail 1;
Mq-no-timer (packages/cli/src/host.mjs): cli rc 1 pass 81 fail 0;
Mr-ignore-unsolicited (packages/cli/src/host.mjs): cli rc 0 pass 82 fail 0;
Ms-follow-walk (packages/harness/src/gate.mjs): harness rc 1 pass 48 fail 2;
Mt-no-socket-destroy (packages/cli/src/launcher.mjs): cli rc 1 pass 81 fail 1;
Mu-no-restricted (adapters/claude/adapter.sh): harness rc 1 pass 48 fail 2;
Mv-no-tag (packages/bus/src/process.mjs): bus rc 1 pass 73 fail 1; cli rc 124 pass 69 fail 7;
DONE
@@ -0,0 +1,82 @@
✔ launch identity is stamped, payload identity is refused and stale holder cannot send (153.57822ms)
✔ decision classes route from policy; gated resolution is human-only, choice and target must match (233.251054ms)
✔ claim exclusion, holder release, gated revoke and rerouting to a new holder are atomic (232.549664ms)
✔ launch events require a human CLI capability; generic emit cannot forge authority events (142.766856ms)
✔ within-role decisions close atomically and invalid options or blocking omissions refuse (147.003548ms)
✔ observer capabilities read human inbox but cannot mutate or forge launch identity (132.873809ms)
✔ task action subjects and linked decision trail are complete and ordered (160.969646ms)
✔ launch binding is durable and reconnecting requires the identical trusted record (93.358656ms)
✔ business isolation includes inherited object names and cross-business message references (140.491985ms)
✔ authority never transfers between action, run, target, unresolved or replaced role holder (205.648152ms)
✔ task projection uses schema current view, skipping earlier and equal-start polls (107.864049ms)
✔ revocation permanently bars the old run from reclaiming first, including after broker restart (171.325451ms)
✔ empty message references refuse before storage; refusal-evidence failure stays a typed error (103.424423ms)
✔ both arbiters require human resolution when their cross-role route is themselves (169.523241ms)
✔ S1 adapter takes resolved limits and refs, rejects mismatched instance, never mutates input (2.083743ms)
✔ only validated broker references load; returned data and exceptions cannot expose a known token (4.333241ms)
✔ bad file modes, symlinks, repository/data paths, malformed tokens and missing dates refuse (2.462563ms)
✔ expiry refuses use and env references never become client data (0.737079ms)
✔ S1 parsed service refs work, service mismatch refuses, Gitea rotation due is a warning state (1.529768ms)
✔ opaque tokens shorter than 16 characters refuse before use (0.295748ms)
✔ endLaunch writes session.ended, releases the run claim and kills its capabilities (111.484486ms)
✔ endLaunch refuses an unknown run, a second end and a rebind of the ended run (116.229163ms)
✔ a restarted broker refuses to rebind an ended run; a refused rebind leaves the run unbound (148.98598ms)
✔ endLaunch leaves a claim another run took alone (148.061365ms)
✔ refuse records action.refused against the caller with the code only (113.320123ms)
✔ launches off refuses role.launch with launch-revoked until launches on (153.185093ms)
✔ broker process: launch ops authorize role.launch, record refusals and end runs (217.699001ms)
✔ human proof binds CLI entry, process start and nonce; agents and incomplete ancestry refuse (1.831586ms)
✔ process reader gets own kernel identity without exposing environment values (1.438508ms)
✔ EACCES ancestor environments skip only markers; commands and registered launches still refuse (0.699815ms)
✔ real pid 1 remains inspectable when its environment is protected (0.281586ms)
✔ within-role sends cite an open gated launch decision without spending it or naming it in grants (179.22726ms)
✔ missing and foreign-business citations refuse and roll back message and grant (170.311287ms)
✔ cross-role sends still need a matching resolved decision and consume it once (216.397859ms)
✔ broker process binds trusted launches, offers reader capabilities, refuses human mutation, closes cleanly (168.892638ms)
✔ startup token refusal returns safe code without value or partial listening broker (38.749597ms)
✔ loaded fixture token is absent from socket replies and SQLite, including refusal evidence (158.844199ms)
✔ killed broker leaves an explicit stale lock; another process cannot silently reclaim it (160.791573ms)
✔ trusted host registers later launches; socket clients never have a registration verb (155.929935ms)
✔ runtime excludes declared project roots even when host supplies no repoRoots (36.514497ms)
✔ a refused launch binding leaves the broker and existing capabilities alive; bad protocol stops it (148.478418ms)
✔ v3b prototype refusals, views and append-only mutations (913.068827ms)
✔ gated approval authorizes once, survives store reopen, and fresh approval works (213.052692ms)
✔ another run cannot consume an approval; a failed check leaves it usable (203.386428ms)
✔ two scheduled callers have exactly one grant and one consumed refusal (142.717505ms)
✔ failed commit rolls consumption back; cross-role consumes and within-role stays reusable (270.900196ms)
✔ class drift gated to cross-role refuses before consumption (175.746188ms)
✔ class drift cross-role to gated refuses before consumption (176.865975ms)
✔ class drift gated to within-role refuses before consumption (171.251501ms)
✔ class drift cross-role to within-role refuses before consumption (165.453616ms)
✔ class drift within-role to gated refuses before consumption (143.916835ms)
✔ class drift within-role to cross-role refuses before consumption (138.282316ms)
✔ message.send consumes approval and prevents a later send or authorize (167.342176ms)
✔ role.revoke consumes approval and prevents a later revoke or authorize (200.544381ms)
✔ creates private WAL store and excludes a second writer until explicit close (105.170279ms)
✔ rollback is atomic and schema metadata is checked against trusted DDL, not just itself (154.965708ms)
✔ existing empty database and symlink runtime directory refuse, never initialize over damage (175.037347ms)
✔ crash during a transaction recovers no partial event after explicit fixture-only lock removal (139.630438ms)
✔ writer refuses mixed at/read_at forms atomically, even through trusted SQL helpers (87.286236ms)
✔ async transactions refuse before invoking their function (67.714362ms)
✔ recordTask keeps sync reads and a role write apart (141.833366ms)
✔ read_at must be one canonical UTC format, so the projection compares strings safely (93.241958ms)
✔ a bad entry refuses the whole record (90.849219ms)
✔ taskView reads the projection for one business (126.126217ms)
✔ requestTask hands only a holder and a task verb to the handler, and records refusals (207.739779ms)
✔ the server sends task verbs to the adapter with its own timeout; other verbs stay synchronous (379.996046ms)
✔ without an adapter the server refuses every task verb (169.786113ms)
✔ the runtime refuses an invalid adapter and closes a valid one (160.121287ms)
✔ the process loads the S3 adapter from plain-data trackers (220.255592ms)
✔ socket capability stamps launch identity; shared views use wire, no SQL client (121.194023ms)
✔ two wire claims serialize; a lost reply never automatically retries (145.399406ms)
✔ malformed, oversized and identity-forging envelopes refuse without echoing input (115.902591ms)
✔ client preserves UTF-8 when a response divides a multibyte character (12.084874ms)
✔ committed mutation followed by dropped reply reports unknown and is never retried (119.289433ms)
ℹ tests 74
ℹ suites 0
ℹ pass 74
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 2272.713014
@@ -0,0 +1,68 @@
✔ config directory and file path follow MOSAIC_CONFIG (1.288784ms)
✔ the fixture business validates and comes back frozen (3.843478ms)
✔ two instances may share a definition (1.311579ms)
✔ top-level refusals (3.921793ms)
✔ arbiters and projects (5.392636ms)
✔ role instances (2.746848ms)
✔ Vikunja bots (5.802152ms)
✔ a role without Vikunja takes no tracker block (2.084588ms)
✔ credential references match the definition's services (2.490105ms)
✔ launch (7.39285ms)
✔ loadBusiness: file checks (1.823287ms)
✔ loadBusiness: not a regular file (40.089512ms)
✔ loading writes nothing (1.597522ms)
✔ names that are Object.prototype properties don't count as declared (2.680279ms)
✔ the shipped example refuses as written and validates once filled in (0.565546ms)
✔ usage errors exit 4 (276.884025ms)
✔ validate: a good business exits 0 and prints instance digests (66.628426ms)
✔ validate: project files (319.917868ms)
✔ validate: missing files and a broken system config (239.090243ms)
✔ validate: credential reference problems exit 2 and name each one (68.819484ms)
✔ validate: a token file inside the repository is refused (67.972803ms)
✔ validate: role definitions come from MOSAIC_ROLES_DIR (201.938073ms)
✔ resolve: prints one instance's record (207.019929ms)
✔ resolve: refusals (393.297475ms)
✔ parse: exactly one of file or env, plus the service's date (2.376694ms)
✔ check: a good file has no problems (0.805413ms)
✔ check never opens the file: a write-only token passes (0.386944ms)
✔ check: file problems (0.820838ms)
✔ check: token files can't live in the repository or dataRoot, even through a linked directory (0.798258ms)
✔ check: dates and environment references (0.360025ms)
✔ path and load (2.210416ms)
✔ refusals (1.397312ms)
✔ systemVars flattens the validated config (2.390461ms)
✔ precedence: system, business, project, project role, agent (5.838965ms)
✔ limits narrow the definition and never widen it (2.129322ms)
✔ role.launch stays within-role only for the instance the launch block names (4.008966ms)
✔ limits.authority without role.launch leaves the launcher with no launch block (1.712887ms)
✔ limits.authority narrows cross-role actions too (0.965188ms)
✔ classify (1.013227ms)
✔ the record carries what the broker and launcher need (0.923596ms)
✔ digest: key order doesn't matter, any value change does (5.724959ms)
✔ refusals (2.138413ms)
✔ the four shipped version 2 roles load (2.771848ms)
✔ shipped role scopes match addendum B section 2 and the SR runbook (0.850429ms)
✔ shipped authority follows the note's table (0.431978ms)
✔ version 1 files keep loading with no authority (0.936277ms)
✔ the conductor policy isn't a role (0.206819ms)
✔ a missing role file is exit 4, a symbolic link too (0.347551ms)
✔ version 2 refusals (1.221265ms)
✔ authority: closed vocabulary, no gated-only action, no overlap (1.937817ms)
✔ credentials: Gitea scopes (0.799515ms)
✔ credentials: Vikunja scopes are a group-to-verbs map from the grantable list (1.032066ms)
✔ credentials: services (0.543365ms)
✔ contract: a non-empty regular Markdown file beside the role file (0.745177ms)
✔ every key names known layers and a merge rule (0.779561ms)
✔ unknown keys and wrong layers refuse (0.649575ms)
✔ types (1.745899ms)
✔ merge: defaults, then the most specific layer wins (0.24119ms)
✔ merge: limits only narrow, and provenance lists each source (0.333722ms)
✔ merge doesn't change its inputs (0.12342ms)
ℹ tests 60
ℹ suites 0
ℹ pass 60
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 1906.398934
@@ -0,0 +1,92 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (343.61827ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (133.792832ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (148.06927ms)
✔ decide prints a declining choice as declining (127.504848ms)
✔ an unknown outcome is reported once and never resent (106.107949ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (93.315085ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (93.042323ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (68.945295ms)
✔ every human command refuses inside an agent run before it touches the bus (244.089211ms)
✔ usage errors exit 4; no business and no host is a usage error (262.099651ms)
✔ agents and tasks print through the broker (131.131098ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (2.869193ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (52.024623ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (35.071569ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (30.399739ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (31.51699ms)
✔ a business without tracker.baseUrl gets no trackers entry (31.518112ms)
✔ an unknown business and a broken system config refuse with exit 3 (51.49228ms)
✔ empty views say so (1.159883ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (1.43033ms)
✔ tasks print the tracker fields the snapshot carries (0.239584ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (1039.616011ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (512.790126ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (201.119259ms)
✔ a broker reply that misses the wait breaks the channel: the late reply answers nothing, later requests refuse, the host exits 1 (1179.825432ms)
✔ a broker reply with another request's id, or none, breaks the channel and the host exits 1 (262.567619ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (200.710136ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (156.479626ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (103.006338ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (155.902856ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (103.353313ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (179.278505ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (21.943377ms)
✔ bus stop refuses to signal a live pid that is not a bus host (202.925198ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (216.969284ms)
✔ bus start refuses with exit 3 without a notifier config (92.624695ms)
✔ bus start runs until bus stop; status reports it while it runs (657.984685ms)
✔ bus-service.sh renders the unit and installs it into a given directory (26.925921ms)
✔ bus start --pm: the PM runs under its own PID namespace, registered, with a manifest (1605.063976ms)
✔ the PM launches a coder through its launch tool; the coder answers; refusals name their code (669.314746ms)
✔ a runner that stops at once ends its launch with the runner's reason (196.090978ms)
✔ a host that died hard leaves its sessions to the next host, which kills them and ends their runs so the role can be launched again (681.636067ms)
✔ a host that died hard leaves its sessions to the next host, which finds one already exited (23) and ends their runs so the role can be launched again (3598.000873ms)
✔ a malformed sessions.json refuses the host start with exit 3 and stays as it was (106.218367ms)
✔ an over-long launch request is refused at once, not at the 10 s idle timeout (125.190289ms)
✔ a launch client that never closes its side doesn't hold the host's close (134.040076ms)
✔ a runner that ignores SIGTERM is killed when the host closes (1235.782492ms)
✔ zoned uses the IANA zone across DST (25.296472ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (352.795182ms)
✔ two blocking decisions get two DMs with different nonces (143.002964ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.170322ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (139.42675ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (104.567349ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (102.080946ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (94.413777ms)
✔ polled every POLL_MS against a permanent 403, a DM is sent at 0, 30, 60, 90 and 120 min and gives up only then (151.405656ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (274.241216ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (301.07668ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (135.172634ms)
✔ an inbox read failure is logged and the next poll retries (0.629219ms)
✔ no Discord id reaches the journal or the log (135.178436ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (19.527863ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (24.655186ms)
✔ the journal: a whole file that is one torn line truncates to empty (10.737649ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (0.647645ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.840409ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (1.865056ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.299845ms)
✔ the journal: a dangling directory link, a parent that is a file and a journal that is a directory each refuse with exit 3 (0.467725ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.353944ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.425559ms)
✔ digest content stays within Discord's 2000 characters (0.273199ms)
✔ runLoop never overlaps ticks and stops after the one in flight (110.506996ms)
task.close {} answered: invalid-request; fake saw 18 requests, first GET /info 200, GET /projects/1 200, GET /projects/1/views 200
task.close on a missing task answered: task-not-found; it made GET /tasks/999 404
✔ bootConfig trackers reach the S3 adapter in the real broker child, which goes ready against a fake Vikunja (514.094892ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (43.225726ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2218.736453ms)
✔ busExit and refuseInsideAgent (0.402628ms)
✔ talk sends a REQUEST, prints and reads everything that arrives, and stops at the reply (423.616401ms)
✔ talk --wait 0 only sends; no reply within --wait exits 1 and says where it will show (1146.422026ms)
✔ talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors (133.213809ms)
✔ launches off and on go to the broker and change the business's launch state (161.595821ms)
✔ launches list reads sessions.json and marks a stale entry; stop reports it and refuses a non-runner (87.827055ms)
ℹ tests 82
ℹ suites 0
ℹ pass 82
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 8437.60923
@@ -0,0 +1,58 @@
✔ sessionModel: agent vars win, then the system's execution settings (16.351458ms)
✔ a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory (8.486143ms)
✔ a claude-code bundle adds the wrapped gate hook and the MCP config (4.924205ms)
✔ a bundle is written once: an existing file refuses (7.714847ms)
✔ a path with a single quote can't go into the hook command (2.914831ms)
✔ allow exits 0, a deny exits 2 with the reason on stderr (181.225521ms)
✔ a missing or wrong policy, or a bad event, exits 2 (98.543767ms)
✔ the bundle's wrapped command: a missing gate or node still blocks (1103.411534ms)
✔ claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist (1129.58691ms)
✔ claude: the hook alone blocks a path outside the workspace (591.639549ms)
✔ claude: a second turn resumes the first turn's session (766.438591ms)
✔ claude adapter: --restricted is always passed (5.009647ms)
✔ claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do (835.863042ms)
✔ claude: a missing hook or MCP file refuses before claude starts (7.995462ms)
✔ pi: policy tools and typed tools pass, anything else is blocked (5.256191ms)
✔ claude: builtins map from pi names, typed tools need the mcp prefix (1.416363ms)
✔ file tool paths must resolve inside the workspace (1.80134ms)
✔ pi's own path normalisation can't be used to step out (1.464984ms)
✔ a symlink inside the workspace that points out is outside (1.213263ms)
✔ a dangling symlink is refused at any depth, in both harnesses (4.554847ms)
✔ claude path fields per tool (1.932074ms)
✔ glob patterns stay inside the workspace (2.028059ms)
✔ a path that can't be checked is blocked (1.256848ms)
✔ initialize, ping and tools/list (66.673477ms)
✔ tools/call goes through the tool socket; a refusal is an isError result (42.240996ms)
✔ unknown tools and methods are JSON-RPC errors and never reach the socket (46.54748ms)
✔ a missing argument is a usage error (51.277827ms)
✔ pi: typed tools reach the socket, the gate blocks, agent_end writes the marker (504.50749ms)
✔ pi: a write through a dangling symlink is blocked, and nothing appears outside (494.672447ms)
✔ pi: a missing extension refuses before any model call (17.138359ms)
✔ pi: an extension without its configuration fails pi's start (370.307009ms)
✔ founderCheck: founder variables, then a needed service without a usable token (2.439718ms)
✔ turnRequest names the sender, class, reply and decision (0.393876ms)
✔ a message becomes a turn, the answer goes back as a RESULT, SIGTERM releases and exits 0 (356.294943ms)
✔ a SIGTERM before the claim stops the runner with exit 0 and no claim (179.594114ms)
✔ typed tools carry the runner's capability; launch goes to the host's launch socket (599.210351ms)
✔ a RESULT gets no automatic reply; failed turns reply with the reason (1476.753805ms)
✔ SIGTERM during a turn kills the turn's process group and still exits 0 (213.92013ms)
✔ founder credentials stop before the claim (20) (209.305788ms)
✔ a refused claim exits 21; an ended run's capability exits 22 (250.403664ms)
✔ the launch ending under a running session exits 22 (167.505496ms)
✔ a broker that stays unreachable exits 23 after brokerRetries polls (271.078035ms)
✔ a broker that is down at the claim exits 23, not 21 (101.373681ms)
✔ no capability, or a malformed one, on stdin exits 2 (216.397926ms)
✔ a missing or malformed policy exits 2 before the claim (166.370584ms)
✔ the PM gets launch, its task verbs and the reads (11.256865ms)
✔ a coder gets no launch, no resolve_decision, and no task tools without a tracker (6.134091ms)
✔ launch only when the business's launch block names the instance as launcher (4.965631ms)
✔ an action outside the instance's authority has no tool (2.448784ms)
✔ callTool: one JSON line out, the result back, a refusal rejects (12.464112ms)
ℹ tests 50
ℹ suites 0
ℹ pass 50
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 10983.011443
@@ -0,0 +1,35 @@
✔ resolveSeat: by name under --repo resolves the repo layout (1.258144ms)
✔ resolveSeat: by path resolves the fleet layout (0.352115ms)
✔ resolveSeat: refusals for missing dir, missing launch.sh, non-executable launch.sh, invalid name, and unknown layout (0.727948ms)
✔ tmuxContext: outside tmux, default socket, custom socket, and exec failure (0.671869ms)
✔ makeRegistration produces a record that validates; each shape violation throws SeatError (0.804549ms)
✔ writeRegistration/readRegistration: round trip, permissions, absence, and malformed records (0.84661ms)
✔ updateTask: changes task, taskSetBy and updatedAt only, and refuses appropriately (1.138897ms)
✔ CLI launch: registers, execs the fake launch script, and passes args through (28.298559ms)
✔ CLI launch: --harness lands in the record (30.253119ms)
✔ CLI launch: the launch script's own exit code passes through (30.837052ms)
✔ CLI launch: relaunching a seat rewrites the one registration record (56.802231ms)
✔ CLI launch: omitting --task records an empty string, not null (30.890374ms)
✔ CLI seat task: updates only the task after a launch, and refuses on an unlaunched seat (81.947471ms)
✔ CLI refusals: no args, unknown flag, missing config, already-registered env, and exec failure (131.5649ms)
✔ samePath: equal paths, symlinked dirs, distinct dirs, and non-strings (0.398639ms)
✔ resolveSetBy: explicit --by wins over the environment; absent or empty environment gives unknown; invalid explicit or environment values refuse with exit 4 (0.569271ms)
✔ validateRegistration/readRegistration: taskSetBy is optional; a record without it (written before #1511) still loads unchanged; an invalid one is refused; the version does not change (0.731113ms)
✔ updateTask: records setBy, preserves startedAt and every unrelated field, upgrades an old record in place only when the task is set, and replaces a previous attribution (0.84775ms)
✔ CLI seat task: --by beats MOSAIC_AGENT_NAME, the environment beats nothing, empty environment is unknown, invalid --by or environment refuses with exit 4 and leaves the record byte for byte (283.675969ms)
✔ family: exactly one launch.max key in the model name, else null (0.644531ms)
✔ sessionEnv passes only the allowlist, the repo's bin on PATH, and the run id (0.651633ms)
✔ newRun: short ids, 0700 directories, and a refusal when the socket path won't fit (1.478567ms)
✔ session file and launch log: 0600, the session file written once (0.962995ms)
✔ endReason maps the runner's exit codes; a signal is killed (0.111409ms)
✔ Registry mirrors to sessions.json; readSessions marks live entries; bad files refuse (1.123149ms)
✔ stopSession refuses an unknown run, reports a stale one, and won't signal a pid that isn't a runner (1.037453ms)
✔ a session runs under unshare as pid 1 of its namespace, claims, answers, and stops on mosaic stop (256.863792ms)
ℹ tests 27
ℹ suites 0
ℹ pass 27
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 736.276249
@@ -0,0 +1,4 @@
exit 0; stdout: mock answer; stderr:
POST /v1/messages: 2
tool_result sent back: [{"is_error":true,"content":"PreToolUse:Write hook error: [timeout -k 2 10 /usr/bin/node /home/jwoltje/darkwing-scratch/r41b/wt/packages/harness/src/claude-gate.mjs /home/jwoltje/darkwing-scratch/tmp/r41-cdangle-Sq7M/bundle/policy.json || exit 2]: mosaic gate: Write path goes through a dangling symlink: /home/jwoltje/darkwing-scratch/tmp/r41-cdangle-Sq7M/ws/notes.md\n"}]
outside file: absent
@@ -0,0 +1,14 @@
variant candidate: exit 0; stdout: mock answer; stderr:
requests: 1; POST /v1/messages: 1
MARKER-USER-CLAUDE-MD 0 request(s)
MARKER-PARENT-CLAUDE-MD 0 request(s)
MARKER-WS-CLAUDE-MD 0 request(s)
MARKER-AUTOMEMORY 0 request(s)
the generated prompt 1 request(s)
variant no-restricted: exit 0; stdout: mock answer; stderr:
requests: 1; POST /v1/messages: 1
MARKER-USER-CLAUDE-MD 1 request(s)
MARKER-PARENT-CLAUDE-MD 1 request(s)
MARKER-WS-CLAUDE-MD 1 request(s)
MARKER-AUTOMEMORY 1 request(s)
the generated prompt 1 request(s)
@@ -0,0 +1,2 @@
/home/jwoltje/darkwing-scratch/tmp/r41-claude-5vAv
/home/jwoltje/darkwing-scratch/tmp/r41-claude-0Nos
@@ -0,0 +1,16 @@
== MODE=plain
exit 0; stdout: mock answer; stderr:
POST /v1/messages: 2
tool_result sent back: [{"is_error":true,"content":"PreToolUse:Read hook error: [timeout -k 2 10 /usr/bin/node /home/jwoltje/darkwing-scratch/r41b/wt/packages/harness/src/claude-gate.mjs /home/jwoltje/darkwing-scratch/tmp/r41-cvariant-M3Xf/bundle/policy.json || exit 2]: mosaic gate: Read path is outside the workspace: /home/jwoltje/darkwing-scratch/tmp/r41-cvariant-M3Xf/ws/plain.txt\n"}]
== MODE=quote
exit 0; stdout: mock answer; stderr:
POST /v1/messages: 2
tool_result sent back: [{"is_error":true,"content":"File does not exist. Note: your current working directory is /home/jwoltje/darkwing-scratch/tmp/r41-cvariant-t09n/ws."}]
== MODE=ampm
exit 0; stdout: mock answer; stderr:
POST /v1/messages: 2
tool_result sent back: [{"is_error":true,"content":"Refusing to read /home/jwoltje/darkwing-scratch/tmp/r41-cvariant-KAsy/ws/shot 9.41 AM.png: its symlink resolution changed after permission was checked (it now resolves to a path the check did not see). If a link in the working directory is being rewritten concurrently, stop that and retry."}]
== MODE=nfd
exit 0; stdout: mock answer; stderr:
POST /v1/messages: 2
tool_result sent back: [{"is_error":true,"content":"File does not exist. Note: your current working directory is /home/jwoltje/darkwing-scratch/tmp/r41-cvariant-nerL/ws."}]
@@ -0,0 +1,6 @@
client got {"ok":false,"error":"unauthenticated"} at 2 ms
client got FIN at 2 ms (not ending its side)
host.close(0) at 200 ms
closed 0 at 210 ms
client destroyed; closed 0 at 210 ms
exit 0
@@ -0,0 +1,5 @@
host.close(0) at 200 ms
client got FIN at 201 ms (not ending its side)
closed 0 at 225 ms
client destroyed; closed 0 at 225 ms
exit 0
@@ -0,0 +1,8 @@
pm run ra2fcb11efc74: session pid 1180584, runner pid 1180585, claimed
runner SIGSTOPped
host.close -> 0 after 30032 ms
session pid alive: false; runner pid alive: false
launch-log end lines: [{"run":"ra2fcb11efc74","reason":"killed","exitCode":null,"signal":"SIGKILL"}]
sessions.json after: []
launcher: run ra2fcb11efc74 (pm) ended: killed
exit 0
@@ -0,0 +1,5 @@
pi write {path: "notes.md"}: {"allow":false,"reason":"mosaic gate: write path goes through a dangling symlink: notes.md"}
claude-code Write {file_path: "notes.md"}: {"allow":false,"reason":"mosaic gate: Write path goes through a dangling symlink: /home/jwoltje/darkwing-scratch/tmp/r41-dangle-YCWI0j/ws/notes.md"}
outside file exists before: false
outside file after pi's write calls: "written through the gate\n"
exit 0
@@ -0,0 +1,26 @@
baseline, no stall:
authorizeLaunch(pm, coder): {"ok":{"class":"within-role"}}
authorizeLaunch(cto, coder): {"err":"decision-required"}
identity(bogus): {"err":"unauthenticated"}
SIGSTOP broker 1172438
host: broker channel broken (no reply within 10 s); stopping the host
p1 authorizeLaunch(pm) after 10025 ms: {"err":"broker-channel-broken"}
SIGCONT
p2 authorizeLaunch(cto), should refuse: {"err":"broker-channel-broken"}
p3 identity(bogus), should refuse: {"err":"broker-channel-broken"}
p4 identity(cto), should be cto: {"err":"broker-channel-broken"}
--- bind case: two binds queued during a stall
node:internal/process/per_thread:277
throw new ErrnoException(err, 'kill');
^
Error: kill ESRCH
at process.kill (node:internal/process/per_thread:277:13)
at file:///home/jwoltje/darkwing-scratch/r41b/probe/desync.mjs:53:9 {
errno: -3,
code: 'ESRCH',
syscall: 'kill'
}
Node.js v26.8.1
exit 1
@@ -0,0 +1,27 @@
pi read {"path":"sub/../../x"} deny: read path is outside the workspace: sub/../../x
pi read {"path":"@/etc/passwd"} deny: read path is outside the workspace: @/etc/passwd
pi read {"path":"~/x"} deny: read path is outside the workspace: ~/x
pi read {"path":"file:///etc/passwd"} deny: read path is outside the workspace: file:///etc/passwd
pi read {"path":" /etc/passwd"} ALLOW
pi read {"path":"etc-link/passwd"} deny: read path is outside the workspace: etc-link/passwd
pi write {"path":"dangling/x"} deny: write path goes through a dangling symlink: dangling/x
pi read {"path":"sub/./x"} ALLOW
pi grep {"path":"..","pattern":"x"} deny: grep path is outside the workspace: ..
pi find {"pattern":"{..,x}/*"} ALLOW
pi find {"pattern":"*","path":"/"} deny: find path is outside the workspace: /
pi ls {} ALLOW
pi bash {"command":"cat /etc/passwd"} deny: bash isn't in this session's policy
pi message.send {} ALLOW
claude-code Read {"file_path":"/etc/passwd"} deny: Read path is outside the workspace: /etc/passwd
claude-code Read {"file_path":"/home/jwoltje/darkwing-scratch/tmp/r41-gate-eayfBf/ws/sub/x"} ALLOW
claude-code Glob {"pattern":"/etc/*"} deny: Glob pattern must stay inside the workspace: /etc/*
claude-code Glob {"pattern":"**/x","path":"/"} deny: Glob path is outside the workspace: /
claude-code Grep {"pattern":"x","path":"/etc"} deny: Grep path is outside the workspace: /etc
claude-code Grep {"pattern":"x","glob":"../*"} ALLOW
claude-code Bash {"command":"id"} deny: Bash isn't in this session's policy
claude-code WebFetch {"url":"http://x"} deny: WebFetch isn't in this session's policy
claude-code mcp__mosaic__message.send {} ALLOW
claude-code mcp__other__message.send {} deny: mcp__other__message.send isn't in this session's policy
claude-code message.send {} deny: message.send isn't in this session's policy
claude-code Read {"file_path":7} deny: Read.file_path isn't a string
exit 0
@@ -0,0 +1,16 @@
pi read {"path":"loop-a"} deny: read path can't be checked: ELOOP
pi write {"path":"loop-a/x"} deny: write path can't be checked: ELOOP
pi write {"path":"chain-1"} deny: write path goes through a dangling symlink: chain-1
pi write {"path":"sub/rel-dangling"} deny: write path goes through a dangling symlink: sub/rel-dangling
pi read {"path":"up/x"} deny: read path is outside the workspace: up/x
pi write {"path":"up/ws/x"} ALLOW
pi read {"path":"link/../x"} ALLOW
pi write {"path":"deep-dangling"} deny: write path goes through a dangling symlink: deep-dangling
pi edit {"path":"chain-1"} deny: edit path goes through a dangling symlink: chain-1
pi read {"path":"chain-1"} deny: read path goes through a dangling symlink: chain-1
claude-code Edit {"file_path":"<ws>/chain-1"} deny: Edit path goes through a dangling symlink: <ws>/chain-1
claude-code Read {"file_path":"<ws>/loop-b"} deny: Read path can't be checked: ELOOP
pi read {"path":"notes's.txt"} ALLOW
pi read {"path":"résumé.txt"} ALLOW
pi read {"path":"shot 9.41 AM.png"} ALLOW
exit 0
@@ -0,0 +1,12 @@
== MODE=dangling
exit 0; stdout: mock answer; stderr:
POST /v1/messages: 2
tool_result sent back: [{"is_error":true,"content":"mosaic gate: write path goes through a dangling symlink: notes.md"}]
turn marker: written
outside file: absent
== MODE=existing
exit 0; stdout: mock answer; stderr:
POST /v1/messages: 2
tool_result sent back: [{"is_error":true,"content":"mosaic gate: write path is outside the workspace: notes.md"}]
turn marker: written
outside file: was here before
@@ -0,0 +1,16 @@
== MODE=plain asked p l a i n . t x t
gate decide: {"allow":false,"reason":"mosaic gate: read path is outside the workspace: plain.txt"}
exit 0; stderr:
tool_result sent back: [{"is_error":true,"content":"mosaic gate: read path is outside the workspace: plain.txt"}]
== MODE=quote asked n o t e s ' s . t x t
gate decide: {"allow":true}
exit 0; stderr:
tool_result sent back: [{"is_error":false,"content":"SECRET-OUTSIDE-THE-WORKSPACE\n"}]
== MODE=ampm asked s h o t 9 . 4 1 A M . p n g
gate decide: {"allow":true}
exit 0; stderr:
tool_result sent back: [{"is_error":false,"content":"SECRET-OUTSIDE-THE-WORKSPACE\n"}]
== MODE=nfd asked r 303 251 s u m 303 251 . t x t
gate decide: {"allow":true}
exit 0; stderr:
tool_result sent back: [{"is_error":false,"content":"SECRET-OUTSIDE-THE-WORKSPACE\n"}]
@@ -0,0 +1,15 @@
node-harness exit=0 ℹ pass 50 ℹ fail 0
node-seat exit=0 ℹ pass 27 ℹ fail 0
node-cli exit=0 ℹ pass 82 ℹ fail 0
node-bus exit=0 ℹ pass 74 ℹ fail 0
node-business exit=0 ℹ pass 60 ℹ fail 0
test-auth exit=0 selftest: 15 passed, 0 failed
test-config exit=0 selftest: 24 passed, 0 failed
test-conductor exit=0 selftest: 17 passed, 0 failed
test-queue exit=0 queue suite: 27 passed, 0 failed
test-foundation exit=0 selftest: 44 passed, 0 failed
test-extension-package exit=0 extension package selftest: 18 passed, 0 failed
test-release exit=0 selftest: 4 passed, 0 failed
test-discord exit=0 discord suite: 66 passed, 0 failed
test-task exit=1 selftest: 26 passed, 2 failed
GATE-DONE
@@ -0,0 +1,17 @@
OK status with missing harness credential exits 3 and still lists accounts
OK status reports harness credential (read-only) + mosaic accounts
OK api key material never reaches output
OK oauth token material never reaches output
OK unparseable credential file exits 2
OK symlinked credential file exits 4
OK env-side credential names reported
OK env var values never reach output
OK accounts without an accounts dir reports none and creates nothing
OK accounts lists files and marks the active one
OK loose account perms flagged in listing
OK agent --auth with missing account file refuses (exit 4)
OK agent --auth with non-0600 account file refuses
OK agent --auth with invalid account name refuses
OK auth.sh without valid config refuses
selftest: 15 passed, 0 failed
@@ -0,0 +1,55 @@
Note: switching to '8dd5ff004b0ce6157446ef78523a1036ed86f42c'.
You are in 'detached HEAD' state. You can look around, make experimental
changes and commit them, and you can discard any commits you make in this
state without impacting any branches by switching back to a branch.
If you want to create a new branch to retain commits you create, you may
do so (now or later) by using -c with the switch command. Example:
git switch -c <new-branch-name>
Or undo this operation with:
git switch -
Turn off this advice by setting config variable advice.detachedHead to false
Not currently on any branch.
nothing to commit, working tree clean
Note: switching to '8dd5ff004b0ce6157446ef78523a1036ed86f42c'.
You are in 'detached HEAD' state. You can look around, make experimental
changes and commit them, and you can discard any commits you make in this
state without impacting any branches by switching back to a branch.
If you want to create a new branch to retain commits you create, you may
do so (now or later) by using -c with the switch command. Example:
git switch -c <new-branch-name>
Or undo this operation with:
git switch -
Turn off this advice by setting config variable advice.detachedHead to false
OK dry-run: allowed change, exit 0, nothing committed (exit 0)
OK dry-run committed nothing
OK apply: allowed change exits 0 (exit 0)
OK apply: attribution in commit subject
OK apply: target tree clean after commit
OK disallowed path refused (exit 1)
OK disallowed path: target untouched
OK syntax gate refused broken .mjs (exit 1)
OK syntax gate: target untouched
OK suite failure refused (exit 1)
OK suite failure: target reverted to clean
OK disabled policy refused (exit 2)
OK disabled policy: target untouched
OK failed run refused (exit 1)
OK failed run: target untouched
OK missing run exits 4 (exit 4)
OK invalid policy exits 2 (exit 2)
selftest: 17 passed, 0 failed
@@ -0,0 +1,26 @@
OK absent adapter defaults to pi
OK adapter mock validates (exit 0)
OK unsupported adapter exits 2 (exit 2)
OK env exports adapter
OK bootstrap creates default when absent (exit 0)
OK bootstrap wrote config file
OK bootstrap is idempotent on existing config (exit 0)
OK bootstrap did not rewrite existing config
OK validate missing config exits 3 (exit 3)
OK malformed JSON exits 2 (exit 2)
OK unsupported configVersion exits 2 (exit 2)
OK unknown top-level key exits 2 (exit 2)
OK unknown execution key exits 2 (exit 2)
OK unsupported backend exits 2 (exit 2)
OK unsupported environment exits 2 (exit 2)
OK relative dataRoot exits 2 (exit 2)
OK non-canonical dataRoot exits 2 (exit 2)
OK filesystem root dataRoot exits 2 (exit 2)
OK home directory dataRoot exits 2 (exit 2)
OK dataRoot containing config dir exits 2 (exit 2)
OK control character in provider exits 2 (exit 2)
OK symlinked config file exits 2 (exit 2)
OK env exports resolve correctly
OK failed validation modified nothing
selftest: 24 passed, 0 failed
@@ -0,0 +1,70 @@
toolchain: node v26.8.1
OK syntax: packages/discord/src/approvals.mjs
OK syntax: packages/discord/src/authorize.mjs
OK syntax: packages/discord/src/binding.mjs
OK syntax: packages/discord/src/cli.mjs
OK syntax: packages/discord/src/connector.mjs
OK syntax: packages/discord/src/context.mjs
OK syntax: packages/discord/src/engine-pi.mjs
OK syntax: packages/discord/src/errors.mjs
OK syntax: packages/discord/src/gateway.mjs
OK syntax: packages/discord/src/git.mjs
OK syntax: packages/discord/src/journal.mjs
OK syntax: packages/discord/src/notify.mjs
OK syntax: packages/discord/src/rest.mjs
OK syntax: packages/discord/src/setspark.mjs
OK syntax: packages/discord/src/tools.mjs
OK syntax: packages/discord/src/web.mjs
OK syntax: packages/discord/bin/git-credential.mjs
OK syntax: packages/discord/extension/tools.mjs
OK syntax: packages/discord/tests/approvals.test.mjs
OK syntax: packages/discord/tests/authorize.test.mjs
OK syntax: packages/discord/tests/binding.test.mjs
OK syntax: packages/discord/tests/connector.test.mjs
OK syntax: packages/discord/tests/context.test.mjs
OK syntax: packages/discord/tests/engine.test.mjs
OK syntax: packages/discord/tests/fake-pi.mjs
OK syntax: packages/discord/tests/gateway.test.mjs
OK syntax: packages/discord/tests/git.test.mjs
OK syntax: packages/discord/tests/helpers.mjs
OK syntax: packages/discord/tests/journal.test.mjs
OK syntax: packages/discord/tests/notify.test.mjs
OK syntax: packages/discord/tests/recover.test.mjs
OK syntax: packages/discord/tests/rest.test.mjs
OK syntax: packages/discord/tests/setspark.test.mjs
OK syntax: packages/discord/tests/tools.test.mjs
OK syntax: packages/discord/tests/web.test.mjs
OK syntax: packages/discord/fixtures/claim-worker.mjs
OK syntax: packages/discord/fixtures/legacy-owner-worker.mjs
OK syntax: scripts/discord.sh
OK syntax: scripts/discord-service.sh
OK packages/discord declares no dependencies
OK no bot-token-shaped string in packages/discord
OK fixture binding uses placeholder ids only
OK fixture binding validates
OK real pi with the extension exposes exactly list_dir, read_file, search and no built-in tool
OK real pi with a writable root exposes exactly the three reads plus write_file and edit_file, and writes nothing at start
OK real pi with a web key exposes the three reads plus web_fetch and web_search, and no write tool without a writable root
OK real pi with a git root exposes the reads, writes and the four git verbs, commits nothing at start, and never shows the token
OK real pi with protocol vault adds reserve_id to the git verbs
OK real pi with a setspark key exposes the reads and the eight record verbs, no counters, and never shows the key
OK real pi refuses a git key on a read-only root (fail closed)
OK real pi with the pilot flags (--no-tools) exposes no tool at all
OK real pi exits non-zero without MOSAIC_DISCORD_TOOLS: no session, no tools (fail closed)
OK a failing nested test fails the run under a parent runner's NODE_TEST_CONTEXT
OK node --test packages/discord/tests/ (ℹ pass 178)
OK scripts/discord.sh --help exits 0
OK scripts/discord.sh check without a binding exits 4
OK scripts/discord.sh recover without a binding exits 4
OK scripts/discord.sh reload without a binding exits 4
OK scripts/discord-service.sh without a command exits 4
OK service unit renders with the repository path, a supervised run as the main process, exit 3 never retried, and reload as SIGHUP
OK service install writes the rendered unit (0644) and leaves no temp file
OK service install a second time reports unchanged
OK systemd-analyze verify accepts the rendered unit
OK service uninstall removes the unit file
OK service install with an unknown flag exits 4
OK service install with USER unset finishes and names the account for lingering
discord suite: 66 passed, 0 failed
@@ -0,0 +1,21 @@
OK initial ordinary-file install
OK installed tree matches canonical source
OK installed tree has no symlinks
OK check detects installation drift
OK sync refuses to overwrite installation drift
OK check detects an extra destination file
OK check detects an extra destination directory
OK check rejects a destination symlink
OK sync accepts a canonical source update
OK updated installation matches canonical source
scripts/test-extension-package.sh: line 14: 1092507 Killed "$@" > /dev/null 2>&1
OK forced interruption kills the replacing process
OK next invocation recovers old consistent installation
OK interrupted replacement rolled back
OK sync succeeds after interruption recovery
OK unlocked stale lock file does not block
OK active lock refuses a concurrent sync
OK source symlink fails closed
OK nested second entrypoint fails closed
extension package selftest: 18 passed, 0 failed
@@ -0,0 +1,53 @@
toolchain: node v26.8.1, python 3.12.8, jsonschema 4.26.0
OK syntax: scripts/foundation-inspect.mjs
OK syntax: scripts/foundation/strict-json.mjs
OK syntax: scripts/foundation/canonical.mjs
OK syntax: scripts/foundation/resolve.mjs
OK syntax: scripts/foundation/validate-record.mjs
OK syntax: scripts/foundation/fixtures/build-fixtures.mjs
OK syntax: scripts/foundation/canonical.test.mjs
OK syntax: scripts/foundation/cli.test.mjs
OK syntax: scripts/foundation/fixtures.test.mjs
OK syntax: scripts/foundation/resolve.test.mjs
OK syntax: scripts/foundation/strict-json.test.mjs
OK syntax: scripts/foundation/verify-schema.py (ast only; no bytecode written)
OK fixture generator runs
OK checked-in fixtures/bundles equal a fresh generation
OK checked-in fixtures/raw equal a fresh generation
OK checked-in fixtures/index.json equal a fresh generation
OK checked-in demo bundles equal a fresh generation
OK a failing nested test fails the run under a parent runner's NODE_TEST_CONTEXT
OK node --test scripts/foundation/ (ℹ pass 80)
OK differential schema oracle: PASS: differential schema oracle (finite corpus; compatibility evidence, not equivalence proof)
platform witness: strftime('%Y') for year 999 -> '999' (pinned checker refuses years 0001..0999)
node v26.8.1; corpus 1568 records (38 pinned fixtures, 478 unique bundle records, 1052 typeCase/mutation/lexical cases)
schema column: agree-valid 540, agree-invalid 991, DISAGREEMENTS 0; strict-only (parser-bound) cases: 27; unsupported-kind records not schema-assessed by the inspector: 10
profile column (schema-valid records only): profile-valid 510, profile-invalid 30
profile refusals asserted: 30 schema-agreed-valid records refused only by the strict typed-string profile (rule profile-pattern-mismatch), 12 declared by name; 73 named probes verified against declared schema/profile columns
OK oracle: zero schema-column disagreements with the pinned checker
OK oracle: strict-only profile refusals are counted and asserted
OK demo: permitted read preview exits 0 (exit 0)
OK demo: permitted file.change preview exits 0 (exit 0)
OK demo: assignment.change proposal is unresolved (exit 3) (exit 3)
OK demo: revoked registration is refused (exit 3) (exit 3)
OK demo: message is not authority (exit 3) (exit 3)
OK usage: no arguments exits 2 (exit 2)
OK io: missing file exits 4 (exit 4)
OK io: directory exits 4 (exit 4)
OK io: symlink exits 4 (O_NOFOLLOW) (exit 4)
OK bound: oversize fixture exits 2 (exit 2)
OK profile: one final LF in a typed selection id is refused before admission (exit 2) (exit 2)
OK profile: two final LFs fail the schema pattern itself (exit 2) (exit 2)
OK profile: escaped newlines in free-form text stay allowed (exit 0) (exit 0)
OK profile refusal is invalid-request/profile-pattern-mismatch with selection and operation withheld, value not echoed
OK text output starts with the disclaimer
OK json output is valid JSON with result allowed and exactly the charter §7 fields
OK json golden matches byte-for-byte
OK sandboxed bundle run (env -i, PATH=/nonexistent) produced the unresolved proposal
OK sandbox inventory (path/type/size/mode/uid/gid/inode/mtime/sha256) unchanged by runs
OK canary never printed (bundle run and credential-file run)
OK a non-bundle JSON file is refused at the shape gate, not read into output
OK no field of the non-bundle file is echoed
selftest: 44 passed, 0 failed
@@ -0,0 +1,35 @@
toolchain: node v26.8.1, git version 2.55.0
OK syntax: packages/queue/src/cli.mjs
OK syntax: packages/queue/src/errors.mjs
OK syntax: packages/queue/src/io.mjs
OK syntax: packages/queue/src/lock.mjs
OK syntax: packages/queue/src/queue.mjs
OK syntax: packages/queue/src/review.mjs
OK syntax: packages/queue/src/store.mjs
OK syntax: packages/queue/tests/commit.test.mjs
OK syntax: packages/queue/tests/data.test.mjs
OK syntax: packages/queue/tests/dispatch.test.mjs
OK syntax: packages/queue/tests/helpers.mjs
OK syntax: packages/queue/tests/lock.test.mjs
OK syntax: packages/queue/tests/migration.test.mjs
OK syntax: packages/queue/tests/review.test.mjs
OK syntax: packages/queue/tests/store.test.mjs
OK syntax: packages/queue/tests/write.test.mjs
OK syntax: packages/queue/tests/fixtures/fake-gitea.mjs
OK syntax: packages/queue/tests/fixtures/kill-at.mjs
OK syntax: packages/queue/tests/fixtures/lock-child.mjs
OK syntax: packages/queue/tests/fixtures/mosaic-pre-a2.sh
OK syntax: scripts/queue-commit.sh
OK syntax: scripts/git-hooks/pre-commit
OK syntax: scripts/mosaic
OK queue-commit.sh, the guard and scripts/mosaic are executable
OK packages/queue declares no dependencies
ℹ tests 148
ℹ pass 148
ℹ fail 0
OK node --test packages/queue/tests/
OK scripts/mosaic queue help
skip queue verify and render --check: this checkout (/home/jwoltje/darkwing-scratch/r41b/wt) is not the queue's canonical root (/mnt/storage/src/mosaic-stack)
queue suite: 27 passed, 0 failed
@@ -0,0 +1,16 @@
OK valid RELEASE resolves (exit 0)
OK invalid RELEASE exits 1 (exit 1)
OK missing RELEASE exits 1 (exit 1)
OK valid RELEASE leaves image tag consistent with version
OK status safe on empty state (exit 0)
OK status created no pointer
OK fault-injected activation refuses (exit 1)
OK refused activation wrote no pointer
OK refusal logged exactly once with valid fields
OK healthy activation succeeds (exit 0)
OK pointer written with valid fields
OK repeat activation succeeds (log grows) (exit 0)
OK log is append-only across activations
OK rollback without previous refuses (exit 1)
selftest: 14 passed, 0 failed
@@ -0,0 +1,7 @@
OK valid RELEASE resolves (exit 0)
OK invalid RELEASE exits 1 (exit 1)
OK missing RELEASE exits 1 (exit 1)
OK valid RELEASE leaves image tag consistent with version
skip state-machine cases (docker daemon unavailable)
selftest: 4 passed, 0 failed
@@ -0,0 +1,33 @@
OK valid task validates (exit 0)
OK unknown task key exits 2 (exit 2)
OK unsupported taskVersion exits 2 (exit 2)
OK invalid task id exits 2 (exit 2)
OK empty prompt exits 2 (exit 2)
OK NUL in expectExact exits 2 (exit 2)
OK out-of-range timeout exits 2 (exit 2)
OK missing mission file exits 4 (exit 4)
OK task with valid mission validates (exit 0)
OK invalid mission exits 2 (exit 2)
OK validate missing task exits 4 (exit 4)
OK validation does not modify the task file
OK prune dry-run exits 0 (exit 0)
OK dry-run deleted nothing
OK prune --keep=2 --yes removes oldest (exit 0)
OK kept exactly 2 newest runs
OK newest run kept, oldest pruned
OK append-only receipt written (3 entries)
OK sessions/workspaces untouched by prune
OK prune with invalid keep exits 4 (exit 4)
skip adapter seam cases (docker daemon unavailable)
skip workspace/capability cases (docker daemon unavailable)
skip live task cases (docker unavailable)
OK onboard without name exits 4 (non-interactive) (exit 4)
OK onboard --name renders profile (exit 0)
OK profile written
OK canon structure: required filled, optional placeholdered
OK canon sections present
FAIL user recall run succeeds (exit 1)
FAIL recalled user name (response: )
OK no agent identity on headless run
selftest: 26 passed, 2 failed
+37
View File
@@ -0,0 +1,37 @@
#!/bin/sh
# The dangling-symlink write through a real Claude Code session: the
# candidate adapter, a settings.json in the bundle's form running the real
# claude-gate.mjs, --tools Write, and a mock API whose first answer calls
# Write on the link. Dummy key, 127.0.0.1 only.
set -eu
WT=$1; OUT=$2
R=$(mktemp -d "$TMPDIR/r41-cdangle-XXXX")
mkdir -p "$R/home" "$R/ws" "$R/sess" "$R/bundle" "$R/outside"
ln -s "$R/outside/planted.txt" "$R/ws/notes.md"
echo "the generated prompt" > "$R/bundle/prompt.md"
printf '{"harness":"claude-code","workspace":"%s","tools":["write"],"typed":[]}\n' "$R/ws" > "$R/bundle/policy.json"
printf '{"hooks":{"PreToolUse":[{"matcher":"*","hooks":[{"type":"command","command":"timeout -k 2 10 %s %s %s || exit 2","timeout":20}]}]}}\n' \
"$(command -v node)" "$WT/packages/harness/src/claude-gate.mjs" "$R/bundle/policy.json" > "$R/bundle/settings.json"
echo '{"mcpServers":{}}' > "$R/bundle/mcp.json"
TOOL_USE=$(printf '{"name":"Write","input":{"file_path":"%s","content":"written through the gate\\n"}}' "$R/ws/notes.md") \
node "$(dirname "$0")/mock-api.mjs" "$R/api.log" > "$R/port" & MOCK=$!
while [ ! -s "$R/port" ]; do sleep 0.1; done
set +e
env -i PATH="$PATH" HOME="$R/home" USER="$USER" LANG=C.UTF-8 \
ANTHROPIC_BASE_URL="http://127.0.0.1:$(cat "$R/port")" ANTHROPIC_API_KEY=sk-ant-dummy-not-a-key \
DISABLE_TELEMETRY=1 CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 MOSAIC_TOOLS=Write \
MOSAIC_SYSTEM_PROMPT_FILE="$R/bundle/prompt.md" MOSAIC_REQUEST="Message m1 from jason: write notes" \
MOSAIC_WORKSPACE="$R/ws" MOSAIC_SESSION_DIR="$R/sess" MOSAIC_MODEL=claude-sonnet-5-5 \
MOSAIC_CLAUDE_SETTINGS="$R/bundle/settings.json" MOSAIC_CLAUDE_MCP_CONFIG="$R/bundle/mcp.json" \
timeout 90 /bin/sh "$WT/adapters/claude/adapter.sh" < /dev/null > "$R/stdout" 2> "$R/stderr"
echo "exit $?" > "$R/exit"
kill $MOCK
{
echo "$(cat "$R/exit"); stdout: $(head -c 200 "$R/stdout"); stderr: $(head -c 300 "$R/stderr")"
echo "POST /v1/messages: $(grep -c '"url":"/v1/messages' "$R/api.log")"
echo "tool_result sent back: $(node -e '
const ls=require("fs").readFileSync(process.argv[1],"utf8").trim().split("\n").map(l=>JSON.parse(l)).filter(l=>l.url.startsWith("/v1/messages"));
const b=JSON.parse(ls.at(-1).body); const r=b.messages.flatMap(m=>Array.isArray(m.content)?m.content:[]).filter(c=>c.type==="tool_result");
console.log(JSON.stringify(r.map(c=>({is_error:c.is_error??false,content:c.content}))));' "$R/api.log")"
echo "outside file: $( [ -e "$R/outside/planted.txt" ] && cat "$R/outside/planted.txt" || echo absent)"
} | tee -a "$OUT"
+39
View File
@@ -0,0 +1,39 @@
#!/bin/sh
# Does a Claude Code session started by adapters/claude/adapter.sh load
# CLAUDE.md files (fake HOME, parent and workspace) and auto-memory?
# Runs the host's claude against the mock API with a dummy key.
set -eu
WT=$1; OUT=$2; VARIANT=$3 # VARIANT: candidate | no-restricted
R=$(mktemp -d "$TMPDIR/r41-claude-XXXX")
mkdir -p "$R/home/.claude" "$R/work/ws" "$R/sess" "$R/bundle"
echo "MARKER-USER-CLAUDE-MD" > "$R/home/.claude/CLAUDE.md"
echo "MARKER-PARENT-CLAUDE-MD" > "$R/work/CLAUDE.md"
echo "MARKER-WS-CLAUDE-MD" > "$R/work/ws/CLAUDE.md"
SLUG=$(printf '%s' "$R/work/ws" | sed 's|[/.]|-|g')
mkdir -p "$R/home/.claude/projects/$SLUG/memory"
echo "MARKER-AUTOMEMORY" > "$R/home/.claude/projects/$SLUG/memory/MEMORY.md"
echo "the generated prompt" > "$R/bundle/prompt.md"
echo '{}' > "$R/bundle/settings.json"
echo '{"mcpServers":{}}' > "$R/bundle/mcp.json"
cp "$WT/adapters/claude/adapter.sh" "$R/adapter.sh"
[ "$VARIANT" = candidate ] || sed -i '/--restricted \\/d' "$R/adapter.sh"
node "$(dirname "$0")/mock-api.mjs" "$R/api.log" > "$R/port" & MOCK=$!
while [ ! -s "$R/port" ]; do sleep 0.1; done
set +e
env -i PATH="$PATH" HOME="$R/home" USER="$USER" LANG=C.UTF-8 \
ANTHROPIC_BASE_URL="http://127.0.0.1:$(cat "$R/port")" ANTHROPIC_API_KEY=sk-ant-dummy-not-a-key \
DISABLE_TELEMETRY=1 CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 \
MOSAIC_SYSTEM_PROMPT_FILE="$R/bundle/prompt.md" MOSAIC_REQUEST="Message m1 from jason: hi" \
MOSAIC_WORKSPACE="$R/work/ws" MOSAIC_SESSION_DIR="$R/sess" MOSAIC_MODEL=claude-sonnet-5-5 \
MOSAIC_CLAUDE_SETTINGS="$R/bundle/settings.json" MOSAIC_CLAUDE_MCP_CONFIG="$R/bundle/mcp.json" \
timeout 60 /bin/sh "$R/adapter.sh" > "$R/stdout" 2> "$R/stderr"
echo "exit $?" > "$R/exit"
kill $MOCK
{
echo "variant $VARIANT: $(cat "$R/exit"); stdout: $(head -c 200 "$R/stdout"); stderr: $(head -c 300 "$R/stderr")"
echo "requests: $(wc -l < "$R/api.log"); POST /v1/messages: $(grep -c '"url":"/v1/messages' "$R/api.log")"
for m in MARKER-USER-CLAUDE-MD MARKER-PARENT-CLAUDE-MD MARKER-WS-CLAUDE-MD MARKER-AUTOMEMORY "the generated prompt"; do
printf ' %-26s %s\n' "$m" "$(grep -c "$m" "$R/api.log" || true) request(s)"
done
} | tee -a "$OUT"
echo "$R" >> "$OUT.dirs"
@@ -0,0 +1,41 @@
#!/bin/sh
# Pi's read-variant probe (pi-variant.sh) through a real Claude Code session:
# candidate adapter, a settings.json in the bundle's form running the real
# claude-gate.mjs, --tools Read, and a mock API whose first answer calls
# Read on the name Pi would not find as given. Dummy key, 127.0.0.1 only.
set -eu
WT=$1; OUT=$2
R=$(mktemp -d "$TMPDIR/r41-cvariant-XXXX")
mkdir -p "$R/home" "$R/ws" "$R/sess" "$R/bundle" "$R/outside"
echo "SECRET-OUTSIDE-THE-WORKSPACE" > "$R/outside/secret.txt"
ASKED=$(node -e 'console.log({quote:"notes\x27s.txt",ampm:"shot 9.41 AM.png",nfd:"r\u00e9sum\u00e9.txt",plain:"plain.txt"}[process.argv[1]])' "$MODE")
DISK=$(node -e 'console.log({quote:"notes\u2019s.txt",ampm:"shot 9.41\u202fAM.png",nfd:"r\u00e9sum\u00e9.txt".normalize("NFD"),plain:"plain.txt"}[process.argv[1]])' "$MODE")
ln -s "$R/outside/secret.txt" "$R/ws/$DISK"
echo "== MODE=$MODE" | tee -a "$OUT"
echo "the generated prompt" > "$R/bundle/prompt.md"
printf '{"harness":"claude-code","workspace":"%s","tools":["read"],"typed":[]}\n' "$R/ws" > "$R/bundle/policy.json"
printf '{"hooks":{"PreToolUse":[{"matcher":"*","hooks":[{"type":"command","command":"timeout -k 2 10 %s %s %s || exit 2","timeout":20}]}]}}\n' \
"$(command -v node)" "$WT/packages/harness/src/claude-gate.mjs" "$R/bundle/policy.json" > "$R/bundle/settings.json"
echo '{"mcpServers":{}}' > "$R/bundle/mcp.json"
TOOL_USE=$(node -e 'console.log(JSON.stringify({name:"Read",input:{file_path:process.argv[1]}}))' "$R/ws/$ASKED") \
node "$(dirname "$0")/mock-api.mjs" "$R/api.log" > "$R/port" & MOCK=$!
while [ ! -s "$R/port" ]; do sleep 0.1; done
set +e
env -i PATH="$PATH" HOME="$R/home" USER="$USER" LANG=C.UTF-8 \
ANTHROPIC_BASE_URL="http://127.0.0.1:$(cat "$R/port")" ANTHROPIC_API_KEY=sk-ant-dummy-not-a-key \
DISABLE_TELEMETRY=1 CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 MOSAIC_TOOLS=Read \
MOSAIC_SYSTEM_PROMPT_FILE="$R/bundle/prompt.md" MOSAIC_REQUEST="Message m1 from jason: read it" \
MOSAIC_WORKSPACE="$R/ws" MOSAIC_SESSION_DIR="$R/sess" MOSAIC_MODEL=claude-sonnet-5-5 \
MOSAIC_CLAUDE_SETTINGS="$R/bundle/settings.json" MOSAIC_CLAUDE_MCP_CONFIG="$R/bundle/mcp.json" \
timeout 90 /bin/sh "$WT/adapters/claude/adapter.sh" < /dev/null > "$R/stdout" 2> "$R/stderr"
echo "exit $?" > "$R/exit"
kill $MOCK
{
echo "$(cat "$R/exit"); stdout: $(head -c 200 "$R/stdout"); stderr: $(head -c 300 "$R/stderr")"
echo "POST /v1/messages: $(grep -c '"url":"/v1/messages' "$R/api.log")"
echo "tool_result sent back: $(node -e '
const ls=require("fs").readFileSync(process.argv[1],"utf8").trim().split("\n").map(l=>JSON.parse(l)).filter(l=>l.url.startsWith("/v1/messages"));
const b=JSON.parse(ls.at(-1).body); const r=b.messages.flatMap(m=>Array.isArray(m.content)?m.content:[]).filter(c=>c.type==="tool_result");
console.log(JSON.stringify(r.map(c=>({is_error:c.is_error??false,content:c.content}))));' "$R/api.log")"
} | tee -a "$OUT"
rm -rf "$R"
@@ -0,0 +1,37 @@
// Does launcher.close() (a beforeClose hook) wait on a launch.sock client
// that never closes its side? MODE=silent sends nothing; MODE=line sends a
// request line and keeps the socket open after the reply.
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { connect } from "node:net";
import { join } from "node:path";
import { tmpdir } from "node:os";
const WT = process.env.WT, MODE = process.env.MODE ?? "silent";
const { bootConfig, loadSystem } = await import(`${WT}/packages/cli/src/config.mjs`);
const { startHost } = await import(`${WT}/packages/cli/src/host.mjs`);
const { createLauncher } = await import(`${WT}/packages/cli/src/launcher.mjs`);
const { launchSocketPath } = await import(`${WT}/packages/seat/src/session.mjs`);
const { fixture } = await import(`${WT}/packages/cli/tests/helpers.mjs`);
const root = mkdtempSync(join(tmpdir(), "r41-close-"));
const f = fixture(root, "acme", (doc) => ((doc.launch = { by: "pm", instances: ["coder"], max: { opus: 1, sonnet: 1 } }), doc));
mkdirSync(f.dataRoot, { mode: 0o700 });
const adapter = join(root, "adapter.sh");
writeFileSync(adapter, `exec '${process.execPath}' '${WT}/packages/harness/tests/fixtures/fake-adapter.mjs'\n`);
const system = loadSystem({ env: f.env });
const host = await startHost({ boot: bootConfig({ system, businessId: "acme", env: f.env }), business: "acme", log: (l) => console.log("host:", l) });
const 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) => console.log("launcher:", l) });
await launcher.listen();
const s = connect(launchSocketPath(f.dataRoot));
s.allowHalfOpen = true;
await new Promise((r) => s.on("connect", r));
s.on("data", (b) => console.log(`client got ${b.toString().trim()} at ${Date.now() - t0} ms`));
s.on("end", () => console.log(`client got FIN at ${Date.now() - t0} ms (not ending its side)`));
const t0 = Date.now();
if (MODE === "line") s.write(`{"cap":"${"0".repeat(64)}","instance":"coder"}\n`);
await new Promise((r) => setTimeout(r, 200));
console.log(`host.close(0) at ${Date.now() - t0} ms`);
const closing = host.close(0).then((c) => `closed ${c}`);
const limit = new Promise((r) => setTimeout(() => r("still not closed"), Number(process.env.LIMIT ?? 25000)));
console.log(`${await Promise.race([closing, limit])} at ${Date.now() - t0} ms`);
s.destroy();
console.log(`client destroyed; ${await closing} at ${Date.now() - t0} ms`);
rmSync(root, { recursive: true, force: true });
@@ -0,0 +1,43 @@
// Does launcher.close() SIGKILL a session whose runner never answers its
// SIGTERM? Launch the PM, SIGSTOP its runner from outside the namespace (a
// stopped process handles no signal but SIGKILL), close the host, and time
// it. Imports from the review worktree by absolute path.
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
const WT = process.env.WT;
const { bootConfig, loadSystem } = await import(`${WT}/packages/cli/src/config.mjs`);
const { startHost, startTimeOf } = await import(`${WT}/packages/cli/src/host.mjs`);
const { createLauncher } = await import(`${WT}/packages/cli/src/launcher.mjs`);
const { readSessions, launchLogFile } = await import(`${WT}/packages/seat/src/session.mjs`);
const { fixture } = await import(`${WT}/packages/cli/tests/helpers.mjs`);
const root = mkdtempSync(join(tmpdir(), "r41-kill-"));
const f = fixture(root, "acme", (doc) => {
doc.roles.coder.vars.model = "claude-sonnet-5-5";
doc.launch = { by: "pm", instances: ["coder"], max: { opus: 1, sonnet: 1 } };
return doc;
});
mkdirSync(f.dataRoot, { mode: 0o700 });
const adapter = join(root, "adapter.sh");
writeFileSync(adapter, `exec '${process.execPath}' '${WT}/packages/harness/tests/fixtures/fake-adapter.mjs'\n`);
const system = loadSystem({ env: f.env });
const logs = [];
const host = await startHost({ boot: bootConfig({ system, businessId: "acme", env: f.env }), business: "acme", log: (l) => logs.push(`host: ${l}`) });
const 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(`launcher: ${l}`) });
await launcher.listen();
const pm = await launcher.launchPm();
const [s] = readSessions(f.dataRoot);
const runnerLog = join(f.dataRoot, "launches", "acme", pm.run, "runner.log");
for (let i = 0; i < 300 && !readFileSync(runnerLog, "utf8").includes("claimed by run"); i++) await new Promise((r) => setTimeout(r, 50));
console.log(`pm run ${pm.run}: session pid ${s.pid}, runner pid ${s.runnerPid}, claimed`);
process.kill(s.runnerPid, "SIGSTOP");
console.log("runner SIGSTOPped");
const t0 = Date.now();
const code = await host.close(0);
console.log(`host.close -> ${code} after ${Date.now() - t0} ms`);
console.log(`session pid alive: ${startTimeOf(s.pid) === s.startTime}; runner pid alive: ${startTimeOf(s.runnerPid) === s.runnerStartTime}`);
const ends = readFileSync(launchLogFile(f.dataRoot, "acme"), "utf8").trim().split("\n").map((l) => JSON.parse(l)).filter((e) => e.event === "end");
console.log("launch-log end lines:", JSON.stringify(ends.map((e) => ({ run: e.run, reason: e.reason, exitCode: e.exitCode, signal: e.signal }))));
console.log("sessions.json after:", JSON.stringify(readSessions(f.dataRoot)));
console.log(logs.filter((l) => /ended|run /.test(l)).join("\n"));
rmSync(root, { recursive: true, force: true });
@@ -0,0 +1,27 @@
// A dangling symlink in the workspace that points outside it. decide() walks
// up to the nearest existing ancestor with existsSync, which follows links,
// so the link itself counts as "missing" and the path passes. Then the same
// two calls pi's write tool makes (core/tools/write.js:45-48: mkdir of the
// dirname, recursive, then writeFile) create the file outside.
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync } from "node:fs";
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { tmpdir } from "node:os";
const { decide } = await import(`${process.env.WT}/packages/harness/src/gate.mjs`);
const root = mkdtempSync(join(tmpdir(), "r41-dangle-"));
const ws = join(root, "ws");
const outside = join(root, "outside", "planted.txt");
mkdirSync(dirname(outside), { recursive: true });
mkdirSync(ws);
symlinkSync(outside, join(ws, "notes.md")); // as a checked-out repo could carry it
for (const harness of ["pi", "claude-code"]) {
const policy = { harness, workspace: ws, tools: ["write", "edit"], typed: [] };
const [tool, field] = harness === "pi" ? ["write", "path"] : ["Write", "file_path"];
console.log(`${harness} ${tool} {${field}: "notes.md"}:`, JSON.stringify(decide(policy, tool, { [field]: harness === "pi" ? "notes.md" : join(ws, "notes.md") })));
}
console.log("outside file exists before:", existsSync(outside));
const target = join(ws, "notes.md");
await mkdir(dirname(target), { recursive: true });
await writeFile(target, "written through the gate\n", "utf-8");
console.log("outside file after pi's write calls:", existsSync(outside) ? JSON.stringify(readFileSync(outside, "utf8")) : "absent");
rmSync(root, { recursive: true, force: true });
@@ -0,0 +1,69 @@
// R1: host->broker IPC replies carry no id. Stall the broker past the 10 s
// firstReply wait with two requests queued, resume it, and see which reply
// each request gets. Imports from the review worktree by absolute path.
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
const WT = process.env.WT;
const { Client } = await import(`${WT}/packages/bus/src/client.mjs`);
const { bootConfig, loadSystem } = await import(`${WT}/packages/cli/src/config.mjs`);
const { startHost, startTimeOf } = await import(`${WT}/packages/cli/src/host.mjs`);
const { fixture } = await import(`${WT}/packages/cli/tests/helpers.mjs`);
const root = mkdtempSync(join(tmpdir(), "r41-desync-"));
const f = fixture(root, "acme", (doc) => {
doc.launch = { by: "pm", instances: ["coder", "reviewer", "cto"], max: { opus: 1, sonnet: 1 } };
return doc;
});
mkdirSync(f.dataRoot, { mode: 0o700 });
const system = loadSystem({ env: f.env });
const boot = bootConfig({ system, businessId: "acme", env: f.env });
const host = await startHost({ boot, business: "acme", log: (l) => console.log("host:", l) });
const bind = async (role, run) => {
const b = await host.bindLaunch({ business: "acme", role, run, harness: "pi", pid: process.pid, startTime: startTimeOf(process.pid) });
await new Client({ path: host.path, cap: b.cap }).call("role.claim");
return b.cap;
};
const pm = await bind("pm", "pm-run");
const cto = await bind("cto", "cto-run");
const show = (p) => p.then((v) => ({ ok: v }), (e) => ({ err: e.code ?? e.message }));
console.log("baseline, no stall:");
console.log(" authorizeLaunch(pm, coder):", JSON.stringify(await show(host.op({ op: "authorizeLaunch", cap: pm, instance: "coder" }))));
console.log(" authorizeLaunch(cto, coder):", JSON.stringify(await show(host.op({ op: "authorizeLaunch", cap: cto, instance: "coder" }))));
console.log(" identity(bogus):", JSON.stringify(await show(host.op({ op: "identity", cap: "bogus" }))));
console.log(`SIGSTOP broker ${host.pids.broker}`);
process.kill(host.pids.broker, "SIGSTOP");
const t0 = Date.now();
const p1 = show(host.op({ op: "authorizeLaunch", cap: pm, instance: "coder" }));
const p2 = show(host.op({ op: "authorizeLaunch", cap: cto, instance: "coder" }));
const p3 = show(host.op({ op: "identity", cap: "bogus" }));
const r1 = await p1;
console.log(`p1 authorizeLaunch(pm) after ${Date.now() - t0} ms:`, JSON.stringify(r1));
process.kill(host.pids.broker, "SIGCONT");
console.log("SIGCONT");
console.log("p2 authorizeLaunch(cto), should refuse:", JSON.stringify(await p2));
console.log("p3 identity(bogus), should refuse:", JSON.stringify(await p3));
const p4 = await show(host.op({ op: "identity", cap: cto }));
console.log("p4 identity(cto), should be cto:", JSON.stringify(p4));
await new Promise((r) => setTimeout(r, 500));
console.log("--- bind case: two binds queued during a stall");
process.kill(host.pids.broker, "SIGSTOP");
const rec = (run) => ({ business: "acme", role: "coder", run, harness: "pi", pid: process.pid, startTime: startTimeOf(process.pid) });
const b1 = show(host.bindLaunch(rec("run-x")));
const b2 = show(host.bindLaunch({ ...rec("run-y"), role: "reviewer" }));
console.log("b1 bind run-x:", JSON.stringify(await b1));
process.kill(host.pids.broker, "SIGCONT");
const r2 = await b2;
console.log("b2 bind run-y (reviewer) got:", r2.ok ? JSON.stringify({ run: r2.ok.run, role: r2.ok.role }) : JSON.stringify(r2));
if (r2.ok?.cap) {
const who = await show(new Client({ path: host.path, cap: r2.ok.cap }).call("role.claim"));
console.log("claim with b2's cap:", JSON.stringify(who));
}
await new Promise((r) => setTimeout(r, 3000));
console.log("after 3 s idle, identity(pm), should be pm:", JSON.stringify(await show(host.op({ op: "identity", cap: pm }))));
console.log("then identity(pm) again:", JSON.stringify(await show(host.op({ op: "identity", cap: pm }))));
await host.close(0);
rmSync(root, { recursive: true, force: true });
@@ -0,0 +1,28 @@
// decide() on path spellings that might leave the workspace.
import { mkdirSync, mkdtempSync, symlinkSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
const { decide } = await import(`${process.env.WT}/packages/harness/src/gate.mjs`);
const root = mkdtempSync(join(tmpdir(), "r41-gate-"));
const ws = join(root, "ws");
mkdirSync(join(ws, "sub"), { recursive: true });
symlinkSync("/etc", join(ws, "etc-link"));
symlinkSync(join(root, "not-yet"), join(ws, "dangling"));
const pi = { harness: "pi", workspace: ws, tools: ["read", "write", "grep", "find", "ls"], typed: ["message.send"] };
const cc = { ...pi, harness: "claude-code" };
const cases = [
[pi, "read", { path: "sub/../../x" }], [pi, "read", { path: "@/etc/passwd" }], [pi, "read", { path: "~/x" }],
[pi, "read", { path: "file:///etc/passwd" }], [pi, "read", { path: " /etc/passwd" }], [pi, "read", { path: "etc-link/passwd" }],
[pi, "write", { path: "dangling/x" }], [pi, "read", { path: "sub/./x" }], [pi, "grep", { path: "..", pattern: "x" }],
[pi, "find", { pattern: "{..,x}/*" }], [pi, "find", { pattern: "*", path: "/" }], [pi, "ls", {}],
[pi, "bash", { command: "cat /etc/passwd" }], [pi, "message.send", {}],
[cc, "Read", { file_path: "/etc/passwd" }], [cc, "Read", { file_path: `${ws}/sub/x` }], [cc, "Glob", { pattern: "/etc/*" }],
[cc, "Glob", { pattern: "**/x", path: "/" }], [cc, "Grep", { pattern: "x", path: "/etc" }], [cc, "Grep", { pattern: "x", glob: "../*" }],
[cc, "Bash", { command: "id" }], [cc, "WebFetch", { url: "http://x" }], [cc, "mcp__mosaic__message.send", {}],
[cc, "mcp__other__message.send", {}], [cc, "message.send", {}], [cc, "Read", { file_path: 7 }],
];
for (const [p, tool, input] of cases) {
const d = decide(p, tool, input);
console.log(`${p.harness.padEnd(11)} ${tool.padEnd(28)} ${JSON.stringify(input).padEnd(40)} ${d.allow ? "ALLOW" : "deny: " + d.reason.replace("mosaic gate: ", "")}`);
}
rmSync(root, { recursive: true, force: true });
@@ -0,0 +1,35 @@
// decide() on the round 2 lstat walk: loops, chains, relative and parent links,
// plus the read names Pi 0.85.1 would fall back to (R4) at the gate level.
import { mkdirSync, mkdtempSync, symlinkSync, writeFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
const { decide } = await import(`${process.env.WT}/packages/harness/src/gate.mjs`);
const root = mkdtempSync(join(tmpdir(), "r41-gate2-"));
const ws = join(root, "ws");
mkdirSync(join(ws, "sub"), { recursive: true });
writeFileSync(join(root, "secret.txt"), "outside\n");
symlinkSync("loop-b", join(ws, "loop-a")); symlinkSync("loop-a", join(ws, "loop-b"));
symlinkSync("chain-2", join(ws, "chain-1")); symlinkSync(join(root, "nowhere"), join(ws, "chain-2"));
symlinkSync("../../missing", join(ws, "sub", "rel-dangling"));
symlinkSync("..", join(ws, "up"));
symlinkSync("sub", join(ws, "link"));
symlinkSync(join(root, "no-dir", "no-file"), join(ws, "deep-dangling"));
symlinkSync(join(root, "secret.txt"), join(ws, "notes’s.txt"));
symlinkSync(join(root, "secret.txt"), join(ws, "résumé.txt".normalize("NFD")));
symlinkSync(join(root, "secret.txt"), join(ws, "shot 9.41 AM.png"));
const pi = { harness: "pi", workspace: ws, tools: ["read", "write", "edit"], typed: [] };
const cc = { ...pi, harness: "claude-code" };
const cases = [
[pi, "read", { path: "loop-a" }], [pi, "write", { path: "loop-a/x" }],
[pi, "write", { path: "chain-1" }], [pi, "write", { path: "sub/rel-dangling" }],
[pi, "read", { path: "up/x" }], [pi, "write", { path: "up/ws/x" }], [pi, "read", { path: "link/../x" }],
[pi, "write", { path: "deep-dangling" }], [pi, "edit", { path: "chain-1" }], [pi, "read", { path: "chain-1" }],
[cc, "Edit", { file_path: join(ws, "chain-1") }], [cc, "Read", { file_path: join(ws, "loop-b") }],
[pi, "read", { path: "notes's.txt" }], [pi, "read", { path: "résumé.txt" }], [pi, "read", { path: "shot 9.41 AM.png" }],
];
for (const [p, tool, input] of cases) {
const d = decide(p, tool, input);
const shown = JSON.stringify(input).replace(ws, "<ws>");
console.log(`${p.harness.padEnd(11)} ${tool.padEnd(6)} ${shown.padEnd(36)} ${d.allow ? "ALLOW" : "deny: " + d.reason.replace("mosaic gate: ", "").replace(ws, "<ws>")}`);
}
rmSync(root, { recursive: true, force: true });
@@ -0,0 +1,45 @@
// A mock Messages API on 127.0.0.1: logs each request body to LOG and
// streams back one short text answer. No real model is reached.
import { createServer } from "node:http";
import { appendFileSync } from "node:fs";
const LOG = process.argv[2];
// TOOL_USE: optional JSON {name, input}; the first streamed answer calls it.
let toolUse = process.env.TOOL_USE ? JSON.parse(process.env.TOOL_USE) : null;
const sse = (res, ev, data) => res.write(`event: ${ev}\ndata: ${JSON.stringify(data)}\n\n`);
const server = createServer((req, res) => {
let body = "";
req.on("data", (b) => (body += b));
req.on("end", () => {
appendFileSync(LOG, `${JSON.stringify({ method: req.method, url: req.url, body })}\n`);
if (req.method !== "POST" || !req.url.startsWith("/v1/messages") || req.url.includes("count_tokens")) {
res.writeHead(200, { "content-type": "application/json" });
return res.end(req.url.includes("count_tokens") ? '{"input_tokens":1}' : "{}");
}
let stream = false;
try { stream = JSON.parse(body).stream === true; } catch {}
const msg = { id: "msg_mock", type: "message", role: "assistant", model: "claude-sonnet-5-5", content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 1, output_tokens: 1 } };
if (!stream) {
res.writeHead(200, { "content-type": "application/json" });
return res.end(JSON.stringify({ ...msg, content: [{ type: "text", text: "mock answer" }], stop_reason: "end_turn" }));
}
res.writeHead(200, { "content-type": "text/event-stream" });
sse(res, "message_start", { type: "message_start", message: msg });
if (toolUse) {
const t = toolUse;
toolUse = null;
sse(res, "content_block_start", { type: "content_block_start", index: 0, content_block: { type: "tool_use", id: "toolu_mock1", name: t.name, input: {} } });
sse(res, "content_block_delta", { type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: JSON.stringify(t.input) } });
sse(res, "content_block_stop", { type: "content_block_stop", index: 0 });
sse(res, "message_delta", { type: "message_delta", delta: { stop_reason: "tool_use", stop_sequence: null }, usage: { output_tokens: 2 } });
sse(res, "message_stop", { type: "message_stop" });
return res.end();
}
sse(res, "content_block_start", { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } });
sse(res, "content_block_delta", { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "mock answer" } });
sse(res, "content_block_stop", { type: "content_block_stop", index: 0 });
sse(res, "message_delta", { type: "message_delta", delta: { stop_reason: "end_turn", stop_sequence: null }, usage: { output_tokens: 2 } });
sse(res, "message_stop", { type: "message_stop" });
res.end();
});
});
server.listen(0, "127.0.0.1", () => console.log(server.address().port));
+41
View File
@@ -0,0 +1,41 @@
#!/bin/sh
# R3 through a real Pi 0.85.1 session: the candidate adapters/pi/adapter.sh,
# the candidate pi-extension.mjs (the gate) loaded with -e, --tools write,
# and a mock Messages API on 127.0.0.1 whose first answer calls write on a
# dangling symlink in the workspace. Dummy key; no real model.
set -eu
WT=$1; OUT=$2
R=$(mktemp -d "$TMPDIR/r41-pdangle-XXXX")
mkdir -p "$R/home" "$R/agent" "$R/ws" "$R/bundle" "$R/outside"
ln -s "$R/outside/planted.txt" "$R/ws/notes.md"
# MODE=existing: the control, the link target exists, so the link isn't dangling.
if [ "${MODE:-dangling}" = existing ]; then echo "was here before" > "$R/outside/planted.txt"; fi
echo "== MODE=${MODE:-dangling}" | tee -a "$OUT"
echo "the generated prompt" > "$R/bundle/prompt.md"
printf '{"harness":"pi","workspace":"%s","tools":["write"],"typed":[]}\n' "$R/ws" > "$R/bundle/policy.json"
echo '[]' > "$R/bundle/tools.json"
TOOL_USE='{"name":"write","input":{"path":"notes.md","content":"written through the gate\n"}}' \
node "$(dirname "$0")/mock-api.mjs" "$R/api.log" > "$R/port" & MOCK=$!
while [ ! -s "$R/port" ]; do sleep 0.1; done
printf '{"providers":{"mock":{"baseUrl":"http://127.0.0.1:%s","api":"anthropic-messages","apiKey":"dummy-not-a-key","models":[{"id":"claude-sonnet-5-5"}]}}}\n' "$(cat "$R/port")" > "$R/agent/models.json"
set +e
env -i PATH="$WT/node_modules/.bin:$PATH" HOME="$R/home" USER="$USER" LANG=C.UTF-8 \
PI_CODING_AGENT_DIR="$R/agent" PI_PROVIDER=mock PI_MODEL=claude-sonnet-5-5 \
MOSAIC_SYSTEM_PROMPT_FILE="$R/bundle/prompt.md" MOSAIC_REQUEST="Message m1 from jason: write notes" \
MOSAIC_WORKSPACE="$R/ws" MOSAIC_TOOLS=write \
MOSAIC_EXTENSIONS="$WT/packages/harness/src/pi-extension.mjs" \
MOSAIC_POLICY_FILE="$R/bundle/policy.json" MOSAIC_TOOLS_FILE="$R/bundle/tools.json" \
MOSAIC_TOOL_SOCKET="$R/no-socket" MOSAIC_TURN_MARKER="$R/marker" \
timeout 90 /bin/sh "$WT/adapters/pi/adapter.sh" < /dev/null > "$R/stdout" 2> "$R/stderr"
echo "exit $?" > "$R/exit"
kill $MOCK
{
echo "$(cat "$R/exit"); stdout: $(head -c 200 "$R/stdout"); stderr: $(head -c 300 "$R/stderr")"
echo "POST /v1/messages: $(grep -c '"url":"/v1/messages' "$R/api.log")"
echo "tool_result sent back: $(node -e '
const ls=require("fs").readFileSync(process.argv[1],"utf8").trim().split("\n").map(l=>JSON.parse(l)).filter(l=>l.url.startsWith("/v1/messages"));
const b=JSON.parse(ls.at(-1).body); const r=b.messages.flatMap(m=>Array.isArray(m.content)?m.content:[]).filter(c=>c.type==="tool_result");
console.log(JSON.stringify(r.map(c=>({is_error:c.is_error??false,content:c.content}))));' "$R/api.log")"
echo "turn marker: $( [ -e "$R/marker" ] && echo written || echo absent)"
echo "outside file: $( [ -e "$R/outside/planted.txt" ] && cat "$R/outside/planted.txt" || echo absent)"
} | tee -a "$OUT"
+46
View File
@@ -0,0 +1,46 @@
#!/bin/sh
# Pi's read tool (0.85.1 dist/core/tools/path-utils.js resolveReadPathAsync)
# retries a missing path with macOS spellings: a narrow no-break space
# before AM/PM, NFD, and a curly apostrophe. The gate checks only the path
# as given. A real Pi session, the candidate adapter and pi-extension.mjs,
# --tools read, and a mock Messages API on 127.0.0.1 whose first answer
# reads NAME_ASKED. The workspace holds a link NAME_ON_DISK pointing at a
# file outside. Dummy key; no real model.
set -eu
WT=$1; OUT=$2
R=$(mktemp -d "$TMPDIR/r41-pvariant-XXXX")
mkdir -p "$R/home" "$R/agent" "$R/ws" "$R/bundle" "$R/outside"
echo "SECRET-OUTSIDE-THE-WORKSPACE" > "$R/outside/secret.txt"
ASKED=$(node -e 'console.log({quote:"notes'"'"'s.txt",ampm:"shot 9.41 AM.png",nfd:"résumé.txt",plain:"plain.txt"}[process.argv[1]])' "$MODE")
DISK=$(node -e 'console.log({quote:"notes’s.txt",ampm:"shot 9.41 AM.png",nfd:"résumé.txt".normalize("NFD"),plain:"plain.txt"}[process.argv[1]])' "$MODE")
ln -s "$R/outside/secret.txt" "$R/ws/$DISK"
echo "== MODE=$MODE asked $(printf %s "$ASKED" | od -An -c | tr -s ' ' | head -c 120)" | tee -a "$OUT"
echo "the generated prompt" > "$R/bundle/prompt.md"
printf '{"harness":"pi","workspace":"%s","tools":["read"],"typed":[]}\n' "$R/ws" > "$R/bundle/policy.json"
echo '[]' > "$R/bundle/tools.json"
TOOL_USE=$(node -e 'console.log(JSON.stringify({name:"read",input:{path:process.argv[1]}}))' "$ASKED") \
node "$(dirname "$0")/mock-api.mjs" "$R/api.log" > "$R/port" & MOCK=$!
while [ ! -s "$R/port" ]; do sleep 0.1; done
printf '{"providers":{"mock":{"baseUrl":"http://127.0.0.1:%s","api":"anthropic-messages","apiKey":"dummy-not-a-key","models":[{"id":"claude-sonnet-5-5"}]}}}\n' "$(cat "$R/port")" > "$R/agent/models.json"
echo "gate decide: $(WT=$WT node -e '
const { decide } = await import(process.env.WT + "/packages/harness/src/gate.mjs");
console.log(JSON.stringify(decide({harness:"pi",workspace:process.argv[1],tools:["read"],typed:[]},"read",{path:process.argv[2]})));' --input-type=module "$R/ws" "$ASKED")" | tee -a "$OUT"
set +e
env -i PATH="$WT/node_modules/.bin:$PATH" HOME="$R/home" USER="$USER" LANG=C.UTF-8 \
PI_CODING_AGENT_DIR="$R/agent" PI_PROVIDER=mock PI_MODEL=claude-sonnet-5-5 \
MOSAIC_SYSTEM_PROMPT_FILE="$R/bundle/prompt.md" MOSAIC_REQUEST="Message m1 from jason: read it" \
MOSAIC_WORKSPACE="$R/ws" MOSAIC_TOOLS=read \
MOSAIC_EXTENSIONS="$WT/packages/harness/src/pi-extension.mjs" \
MOSAIC_POLICY_FILE="$R/bundle/policy.json" MOSAIC_TOOLS_FILE="$R/bundle/tools.json" \
MOSAIC_TOOL_SOCKET="$R/no-socket" MOSAIC_TURN_MARKER="$R/marker" \
timeout 90 /bin/sh "$WT/adapters/pi/adapter.sh" < /dev/null > "$R/stdout" 2> "$R/stderr"
echo "exit $?" > "$R/exit"
kill $MOCK
{
echo "$(cat "$R/exit"); stderr: $(head -c 300 "$R/stderr")"
echo "tool_result sent back: $(node -e '
const ls=require("fs").readFileSync(process.argv[1],"utf8").trim().split("\n").map(l=>JSON.parse(l)).filter(l=>l.url.startsWith("/v1/messages"));
const b=JSON.parse(ls.at(-1).body); const r=b.messages.flatMap(m=>Array.isArray(m.content)?m.content:[]).filter(c=>c.type==="tool_result");
console.log(JSON.stringify(r.map(c=>({is_error:c.is_error??false,content:c.content}))));' "$R/api.log")"
} | tee -a "$OUT"
rm -rf "$R"
+241
View File
@@ -0,0 +1,241 @@
# Row 41, slice 1 S6 (meta-harness and launching), round 2 review (Darkwing)
Issue #1523, request comment 27004, queue revs 253 and 254 (`b887ee1e`).
Packet: `agents/filbert/work/s6/` at `779e9780`, base `915e00e5`, gate at
`8dd5ff00`, 42 files, +4679/−63. Candidate manifest sha256
`fd21bdc27573f080d965da3121a390b148a3370acd8d9dfa5d34fcd59460eb02`,
`build.patch` sha256
`f8cbf7bde355312253265d2071e5756f4b04d116bac06cfcf0a4a2c0474da942`.
Round 1: `review-r1.md`, comment 26995.
Verdict: **changes**, comment 27010. R1, R2 and R3 are fixed, and
notes 1 to 5 are dealt with. One new finding blocks. R4: Pi's `read` tool
doesn't open the path the gate checked when that path doesn't exist. It
tries three spelling variants of the name first (a narrow no-break space
before AM/PM, NFD, a curly apostrophe). A workspace symlink with a variant
name that points outside the workspace is read through the gate. A real Pi
session returned the outside file's content in all three variants. The
same flaw was in round 1, and I missed it then. Claude Code's `Read` does
not have it.
## Method
- A detached worktree at `8dd5ff00`, then `git apply --index build.patch`
and `sha256sum -c candidate-manifest.sha256`: 42 OK. After the probes and
mutants I checked again: 42 OK (`r2/mut/manifest-after.txt`), and `git diff --name-only` empty.
- I read the interdiff against round 1 (`r2/interdiff.patch`, 17 files,
+415/−46): `host.mjs`, `process.mjs`, `gate.mjs`, `launcher.mjs`,
`runner.mjs`, `adapters/claude/adapter.sh`, the READMEs and every new
test. For R4 I read the pinned Pi 0.85.1's `dist/core/tools/read.js` and
`path-utils.js`, and the other path tools.
- Probes in `r2/probe/`. They import from my scratch worktree by absolute
path (`WT`), so they won't run as committed without setting it. Model
calls go to `r2/probe/mock-api.mjs`, a mock Messages API on 127.0.0.1 with
a dummy key; nothing was spent. Claude Code is 2.1.296, Pi is 0.85.1.
I reran every round 1 probe against this round and added
`pi-variant.sh`, `claude-variant.sh` and `gate-edges2.mjs`.
- Twenty-two mutants (`r2/mut/mutate.py`, `r2/mut/run.sh`): the fourteen from
round 1, updated where the code moved, and eight for the round 2 code.
Each ran the node suites of the packages it touches, and the file was
restored from a backup copy.
Node is v26.8.1, `TMPDIR=~/darkwing-scratch/tmp`. `gate.sh` set
`DOCKER_HOST=unix:///nonexistent.sock`.
## Suites
| Suite | Result |
|---|---|
| harness | 50/0 |
| seat | 27/0 |
| cli | 82/0 |
| bus | 74/0 |
| business | 60/0 |
| test-auth | 15/0 |
| test-config | 24/0 |
| test-conductor | 17/0 |
| test-queue | 27/0 |
| test-foundation | 44/0 |
| test-extension-package | 18/0 |
| test-discord | 66/0 |
| test-release | 4/0 with Docker blocked; 14/0 run alone with Docker (`r2/out/test-release-rerun.txt`) |
| test-task | 26/2: "user recall run succeeds" and "recalled user name" |
No test was skipped. The two test-task failures are the live recall, a
real Pi worker in Docker against zai. With Docker blocked they can't pass,
and with Docker they'd be a paid call I didn't make. Filbert reports the
base failing them the same way. cli and harness match Filbert's counts.
## R4: Pi's `read` opens a name the gate never checked (blocking)
`packages/harness/src/gate.mjs:65-70` checks the path as given.
Pi 0.85.1's `read` (`dist/core/tools/read.js:56`) resolves it with
`resolveReadPathAsync` (`dist/core/tools/path-utils.js:72-98`). When the
resolved path doesn't exist, that function tries, in order, and takes the
first that exists:
1. ` AM.` or ` PM.` with the space replaced by U+202F;
2. the NFD form of the whole path;
3. `'` replaced by U+2019;
4. NFD plus U+2019.
The gate sees a name that doesn't exist. `real()` walks up to the
workspace, so the path counts as inside and is allowed. Pi then opens the
variant. If the variant is a symlink that points outside the workspace,
the session reads the outside file. Write, edit, grep, find and ls use
`resolveToCwd`, which is lexical with no variants, so only `read` is
affected.
`probe/pi-variant.sh` runs a real Pi session through the candidate
adapter and `pi-extension.mjs`, `--tools read`, with the mock API asking
for one `read`. The workspace holds a link with the variant name that
points to `outside/secret.txt` (`r2/out/probe-pi-variant.txt`):
```
plain asked plain.txt, link plain.txt gate: deny, "read path is outside the workspace"
quote asked notes's.txt, link notes’s.txt gate: allow; tool_result "SECRET-OUTSIDE-THE-WORKSPACE"
ampm asked "shot 9.41 AM.png", link with U+202F gate: allow; tool_result "SECRET-OUTSIDE-THE-WORKSPACE"
nfd asked NFC résumé.txt, link in NFD gate: allow; tool_result "SECRET-OUTSIDE-THE-WORKSPACE"
```
`probe/gate-edges2.mjs` shows the same three allows from `decide()`
alone.
The precondition is a link already in the workspace, for example from a
cloned repository. Pi's `write` can't create one. That is the threat the
gate already tests for ("a symlink inside the workspace that points out is
outside"), and the `plain` line shows it holding for the exact name. A
prompt in the repository only has to ask for the ASCII spelling.
Claude Code doesn't have this. `probe/claude-variant.sh`, the same setup
through the hook (`r2/out/probe-claude-variant.txt`): `quote` and `nfd` come
back "File does not exist". For `ampm`, Claude Code tries the U+202F name
itself but refuses: "its symlink resolution changed after permission was
checked". The hook still allowed the call; Claude Code's own check stopped
it.
Fix: for Pi `read`, the gate builds the same candidate list from the
resolved path, in Pi's order, and refuses if any candidate that exists
resolves outside the workspace. Checking every existing candidate, not
only the one Pi would pick, is simpler and doesn't depend on which one
exists at open time. The variants apply to the whole path, directory
names included, so the check has to run on the full resolved path. Add a
`gate.test.mjs` case per variant and a `pi-session.test.mjs` read through
a curly-apostrophe link. This depends on Pi's code, so the pin note in the
harness README should name `path-utils.js` as something to recheck on a
Pi upgrade.
## What holds
- **R1, request ids.** `host.mjs:160-205`: `request()` sends one message
at a time with `id = ++seq`, and the message handler resolves only when
the reply's id matches the waiting one. Anything else breaks the
channel: a reply with another id or none, a reply with nothing waiting,
or no reply within 10 s. Once broken, the waiting request and every
later one refuse with `broker-channel-broken`, and the host closes with
exit 1. `process.mjs:15` `tag()` echoes a safe-integer id on every
launch-op reply and on the startup error. All five launch ops are
synchronous in `runtime.mjs`, so one reply per request holds. Close
hooks during a break refuse at once, and a late reply after a break
returns early. `probe/desync.mjs` (`r2/out/probe-desync.txt`): after a 10 s
SIGSTOP, p1 to p4 all refuse with `broker-channel-broken`. Round 1 gave
p2 "allowed" for a refused CTO launch. The probe's bind half then ends
in `kill ESRCH`, because the host had already stopped the broker; the
stall test in `host.test.mjs` covers the queued bind.
- **R2, half-open clients.** `launcher.mjs:118, 340-341, 381-387`: the
launcher tracks `launch.sock` connections and destroys them after
`server.close`, before it waits. `probe/close-hang.mjs`: `closed 0` 25 ms
after `host.close(0)` in `silent` mode and 10 ms after in `line` mode.
Round 1 waited until the probe gave up at 25 s.
- **R3, dangling links.** `gate.mjs:48-63`: `real()` walks up with
`lstat`, so a dangling link is an existing name, and `realpathSync` on it
gives ENOENT, refused as "goes through a dangling symlink". Rerun:
`pi-dangling.sh` refuses the write and leaves the outside file absent,
and with the target present it refuses as outside and leaves the content
alone. `claude-dangling.sh` refuses through the hook. `dangling-write.mjs`
refuses in both harnesses. `gate-edges.mjs` matches Filbert's account:
the only change from round 1 is `write dangling/x`, now refused.
`gate-edges2.mjs` adds the edges I wanted to see: a symlink loop is
refused as ELOOP, final or in the middle; a chain ending in a dangling
link, a relative dangling link and a link to a missing directory are
refused; `up -> ..` refuses `up/x` and allows `up/ws/x`, which really is
inside. `link/../x` is allowed, and that's right, because Pi resolves
`..` lexically the same way the gate does.
- **Note 1, `--restricted`.** `claude-memory.sh`: none of the four markers
reaches the request with the candidate, all four without the flag. The
README and adapter comment say what it does, and both new tests are
real.
- **Note 3, policy parse.** `runner.mjs:259-268`: the parse is inside the
`try`, exit 2 before the claim, with a test.
- **Notes 2 and 4** are README lines, and they read correctly.
- **The 30 s close.** `close-kill.mjs` gives the same result as round 1:
a SIGSTOPped runner is SIGKILLed at 30 s, the launch log says `killed`,
and `sessions.json` is empty.
## Mutants
`r2/mut/summary.txt` has the raw lines.
| Mutant | Change | Result | Killed by |
|---|---|---|---|
| Ma | signal handlers installed after setup | harness 47/3, cli 80/2 | the runner's SIGTERM tests |
| Mb | broker records the run before its checks | bus 73/1, cli 82/0 | a restarted broker refuses to rebind an ended run |
| Mc | no `run-ended` refusal on rebind | bus 73/1, cli 80/2 | the ended-run rebind test, both host-died-hard tests |
| Md | no instance-running check | cli 80/2 | `bus start --pm`, the PM launches a coder |
| Me | no `authorizeLaunch` before start | cli 81/1 | the PM launches a coder; refusals name their code |
| Mf | hook command without `\|\| exit 2` | harness 49/1 | a claude-code bundle adds the wrapped gate hook |
| Mg | `founderCheck` finds no founder variables | harness 48/2, cli 82/0 | the unit test and "founder credentials stop before the claim"; no hang now |
| Mh | no SIGKILL after 30 s in `close()` | cli 81/1 | a runner that ignores SIGTERM is killed when the host closes |
| Mi | no SIGKILL in `recover()` | cli 81/1 | the host-died-hard test, which now kills leftovers |
| Mj | no 4096-byte stdin cap | harness 49/1 | no capability, or a malformed one, on stdin exits 2 |
| Mk | no `LINE_MAX` on `launch.sock` | cli 81/1 | an over-long launch request is refused at once |
| Ml | no `..` check on glob patterns | harness 49/1 | glob patterns stay inside the workspace |
| Mm | no `launch-revoked` refusal | bus 73/1, cli 82/0 | launches off refuses role.launch |
| Mn | `recover()` doesn't end the run | cli 80/2 | both host-died-hard tests |
| Mo | policy parse back outside the `try` | harness 49/1 | a missing or malformed policy exits 2 |
| Mp | host takes any reply, whatever its id | cli 81/1 | a reply with another id, or none, breaks the channel |
| Mq | no 10 s request timer | cli 81/0, 1 cancelled | the stall test times out at 30 s |
| Mr | a reply with nothing waiting is ignored | cli 82/0 | **survives** |
| Ms | `real()` walks with a link-following check | harness 48/2 | the dangling-at-any-depth test and the real Pi write |
| Mt | `close()` doesn't destroy connections | cli 81/1 | a launch client that never closes its side |
| Mu | adapter without `--restricted` | harness 48/2 | both `--restricted` tests |
| Mv | `tag()` doesn't echo the id | bus 73/1, cli 69/7 | the broker launch-ops test, both new host tests, five more; `launcher.test.mjs` hangs (below) |
Twenty-one of 22 killed. Round 1's four survivors (Mh, Mi, Mj, Mk) are
now killed, and Mg fails instead of hanging.
## Notes (not blocking)
1. **Mr survives.** No test sends a reply when nothing is waiting. The
ids already stop such a reply from answering a later request: it would
arrive with the wrong id and break the channel then. So it's a second
layer, and the code is right. A test with a broker child that sends one
extra message would hold it.
2. **Under Mv, `launcher.test.mjs` hangs** instead of failing. With no id
echo, every request breaks the channel and the host exits 1, and some
test in that file then waits forever; my 900 s limit killed it. Mv is
still killed by the other tests. A `timeout` on the launcher tests, as
round 1 suggested for Mg, would turn a hang into a failure.
3. **Claude Code's `Read` has the same blind spot in the gate.** It's
Claude Code's own symlink check that stops the AM/PM case, not the
hook. It's worth giving `Read` the same variant check if it's cheap,
so the gate doesn't depend on that.
4. **A leftover after Ma.** After the Ma mutant's harness run, a
`fake-adapter.mjs` process was still running, reparented to init. I
killed it by PID. The mutant caused it. I didn't trace which test
spawned it, so I can't say whether that test cleans up when it fails.
## Files
Round 1's files stay where they were. Round 2's are under `r2/`.
- `review-r2.md`: this file.
- `r2/files.txt`, `r2/candidate-manifest.sha256`: the candidate as
reviewed.
- `r2/gate.sh`, `r2/out/`: the suite runs (`summary.txt` first) and every
probe output (`probe-*.txt`).
- `r2/probe/`: the round 1 probes as rerun, plus `pi-variant.sh` and
`claude-variant.sh` (R4) and `gate-edges2.mjs`.
- `r2/mut/`: `mutate.py`, `run.sh`, `all.sh`, `summary.txt`, one output
per mutant and package, and `manifest-after.txt`.
- `r2/interdiff.patch`: round 1 against round 2.