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

R4 holds. R5: a relative Pi path resolves against the given workspace in
the gate and against the real path in Pi, so a workspace on a symlink lets
.. reach outside. Comment 27032, queue rev 271.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-09 22:19:02 -05:00
co-authored by Claude Opus 5.5
parent 43909c2078
commit 974da6edd5
75 changed files with 5703 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
0a2a452fdb3a4ea68478e54b3ca6694c51d074fa29a0ad8ea3740eb1b44ab780 packages/cli/tests/host.test.mjs
1dbdb8166e8c47290bb4499b330ea32bec9a6f07179eef816edaa35603b1c408 packages/cli/tests/launcher.test.mjs
709bd331bb0d76f28b464e518f4e1fc3bb0b303614e79d7e82479dcd679043cc packages/cli/tests/verbs.test.mjs
570817222c5cc2f195963569e295db11f09f5dd56f19e3c9bdc220d5ee54435f packages/harness/README.md
34ef8212e27f052223443c66830839517f7a54ecb6ef7d85795b3e89c65b4d4c packages/harness/package.json
22efd0bed7991561920ad29f6bf9a1ab2d7384185fab4267684a037e026d8e85 packages/harness/src/bundle.mjs
32b5090760c95eea0e1232ff36ec13a1d9b58335b2eade621196dec1ebdbe784 packages/harness/src/claude-gate.mjs
be416a0c70cd84c2c53e3259296077c28c5d3006e0a151efc950b2ce73354604 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
38111dc604d32486941f9422654cc392fa2c7767e7f02d13ebbb0038b71669ce packages/harness/tests/gate.test.mjs
6e7509cfe6ad909440c25b750528360c0ba617c6c68b05d400bcd94b481c1f76 packages/harness/tests/helpers.mjs
793eb11f970e4a8eecddec4dc48c24331e4de795bc04bfadf806a2fc3a15a8b4 packages/harness/tests/mcp-server.test.mjs
c4798bb5681172e707d41446388e0490ca8a5398e801766988c8daba2646beaf 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/r41c/wt
O=~/darkwing-scratch/r41c/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 DONE >> $O/summary.txt
@@ -0,0 +1,318 @@
--- r2wt/packages/cli/tests/host.test.mjs 2026-10-09 22:01:00.286672229 -0500
+++ wt/packages/cli/tests/host.test.mjs 2026-10-09 22:00:50.898562239 -0500
@@ -262,6 +262,29 @@
}
});
+test("a broker reply with no request waiting breaks the channel and the host exits 1", { timeout: 30000 }, async (t) => {
+ // A request sent to the real broker past the host's queue: its reply
+ // arrives when nothing is waiting (Darkwing round 2, Mr).
+ const children = [];
+ const onChild = ({ process: child }) => children.push(child);
+ subscribe("child_process", onChild);
+ t.after(() => unsubscribe("child_process", onChild));
+ const root = tmp(t);
+ const f = fixture(root);
+ makeDeployment(root);
+ const boot = bootConfig({ system: loadSystem({ env: f.env }), businessId: "acme", env: f.env });
+ const logs = [];
+ const host = await startHost({ boot, business: "acme", log: (l) => logs.push(l) });
+ t.after(() => host.close(0));
+ const record = { business: "acme", role: "coder", run: "coder-run", harness: "pi", pid: process.pid, startTime: startTimeOf(process.pid) };
+ const coder = await host.bindLaunch(record);
+ children.find((c) => c.pid === host.pids.broker).send({ op: "identity", cap: coder.cap, id: 1_000_000 });
+ const late = new Promise((r) => setTimeout(r, 5000, "still running").unref());
+ assert.equal(await Promise.race([host.done, late]), 1);
+ assert.ok(logs.includes("broker channel broken (reply with no request waiting); stopping the host"), logs.join("\n"));
+ await assert.rejects(host.op({ op: "identity", cap: coder.cap }), (e) => e.code === "broker-channel-broken");
+});
+
test("a notifier that refuses stops the broker and the host refuses with exit 3", async (t) => {
const root = tmp(t);
const f = fixture(root);
--- r2wt/packages/cli/tests/launcher.test.mjs 2026-10-09 22:01:00.286672229 -0500
+++ wt/packages/cli/tests/launcher.test.mjs 2026-10-09 22:00:50.898562239 -0500
@@ -103,7 +103,7 @@
const log = (f) => readFileSync(launchLogFile(f.dataRoot, "acme"), "utf8").trim().split("\n").map((l) => JSON.parse(l));
-test("bus start --pm: the PM runs under its own PID namespace, registered, with a manifest", { skip }, async (t) => {
+test("bus start --pm: the PM runs under its own PID namespace, registered, with a manifest", { skip, timeout: 30000 }, async (t) => {
const { f, launcher, host, cto, close } = await setup(t);
const sock = launchSocketPath(f.dataRoot);
assert.equal(statSync(sock).mode & 0o777, 0o600);
@@ -173,7 +173,7 @@
assert.equal(end.exitCode, 0);
});
-test("the PM launches a coder through its launch tool; the coder answers; refusals name their code", { skip }, async (t) => {
+test("the PM launches a coder through its launch tool; the coder answers; refusals name their code", { skip, timeout: 30000 }, async (t) => {
const { f, launcher, client, cto } = await setup(t);
const sock = launchSocketPath(f.dataRoot);
const pm = await launcher.launchPm();
@@ -234,7 +234,7 @@
assert.ok(readSessions(f.dataRoot).some((s) => s.run === pm.run));
});
-test("a runner that stops at once ends its launch with the runner's reason", { skip }, async (t) => {
+test("a runner that stops at once ends its launch with the runner's reason", { skip, timeout: 30000 }, async (t) => {
// pm's tracker token has expired: the runner's founder-credential stop
// exits 20 before role.claim, and the host records founder-credentials.
const { f, launcher } = await setup(t, (doc) => {
@@ -250,7 +250,7 @@
});
for (const exited of [false, true]) {
- test(`a host that died hard leaves its sessions to the next host, which ${exited ? "finds one already exited (23)" : "kills them"} and ends their runs so the role can be launched again`, { skip }, async (t) => {
+ test(`a host that died hard leaves its sessions to the next host, which ${exited ? "finds one already exited (23)" : "kills them"} and ends their runs so the role can be launched again`, { skip, timeout: 30000 }, async (t) => {
const given = prepare(t);
const { f } = given;
const old = spawn(process.execPath, [HOST, given.adapter], { env: f.env, stdio: ["ignore", "pipe", "inherit"] });
@@ -311,7 +311,7 @@
});
}
-test("a malformed sessions.json refuses the host start with exit 3 and stays as it was", async (t) => {
+test("a malformed sessions.json refuses the host start with exit 3 and stays as it was", { timeout: 30000 }, async (t) => {
const given = prepare(t);
const file = sessionsFile(given.f.dataRoot);
mkdirSync(join(given.f.dataRoot, "bus-host"), { mode: 0o700 });
@@ -323,7 +323,7 @@
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) => {
+test("an over-long launch request is refused at once, not at the 10 s idle timeout", { timeout: 30000 }, async (t) => {
const { f } = await setup(t);
const s = connect(launchSocketPath(f.dataRoot));
t.after(() => s.destroy());
--- r2wt/packages/harness/README.md 2026-10-09 22:01:00.286672229 -0500
+++ wt/packages/harness/README.md 2026-10-09 22:00:50.898562239 -0500
@@ -83,6 +83,27 @@
directories first. Pi calls it from the extension, Claude Code from
`claude-gate.mjs`.
+Pi's `read` doesn't always open the name it is given. When that name
+doesn't exist, it tries other spellings of the whole resolved path: a
+narrow no-break space (U+202F) before ` AM.` or ` PM.`, the NFD form, a
+curly apostrophe (U+2019) for `'`, and NFD with the curly apostrophe. So
+for `read`, the gate also checks every one of those spellings that exists
+as a name, built from both the workspace as given and its real path (Pi's
+working directory). If any of them resolves outside the workspace or goes
+through a dangling symlink, the read is refused, whichever one Pi would
+pick. A spelling that doesn't exist is ignored. Claude Code's `Read` gets
+the same check, though in Darkwing's round 2 probe Claude Code's own
+symlink check already stopped the one variant it tries (AM/PM). `write`,
+`edit`, `grep`, `find` and `ls` take the name as given in Pi 0.85.1, so
+they get no such check.
+
+The gate copies Pi 0.85.1 code, so recheck these files on a Pi upgrade:
+`dist/utils/paths.js` (`normalizePath`), `dist/core/tools/path-utils.js`
+(`resolveToCwd`, `resolveReadPathAsync`) and which tools in
+`dist/core/tools/` call `resolveReadPath*`. The `pi` binary runs the
+bundled copy under `dist/bundle/`; in 0.85.1 it holds the same code, and
+only `read` and the CLI's own file arguments call `resolveReadPath*`.
+
## The runner
`node runner.mjs <run dir>`, with `{"cap": "..."}` and a newline on stdin.
@@ -156,6 +177,11 @@
manifest but not applied to either harness.
- **`--restricted`** (Claude Code) is not one of the S0 lines. It is what
keeps `CLAUDE.md` files and auto-memory out of the prompt (above).
+- **Other spellings.** A read is refused when a spelling Pi might open
+ points outside the workspace, even if the name as given exists and is
+ inside, and Pi would open that one. The same holds when a directory
+ next to the workspace has another spelling of its path, for example
+ `jo’s/ws` beside a workspace in `jo's/ws`.
- **The gate checks a path when the call is made.** A link created or
changed between the check and the tool's own open (by `bash`, or by
another process of the same user) isn't seen. That is the same reach as
--- r2wt/packages/harness/src/gate.mjs 2026-10-09 22:01:00.287853049 -0500
+++ wt/packages/harness/src/gate.mjs 2026-10-09 22:00:50.899987075 -0500
@@ -62,13 +62,60 @@
}
}
-export function insideWorkspace(workspace, p) {
+function within(workspace, path) {
const root = realpathSync(workspace);
- const s = normalise(p);
- const target = real(isAbsolute(s) ? resolve(s) : resolve(workspace, s));
+ const target = real(path);
return target === root || target.startsWith(root + sep);
}
+export function insideWorkspace(workspace, p) {
+ const s = normalise(p);
+ return within(workspace, isAbsolute(s) ? resolve(s) : resolve(workspace, s));
+}
+
+// Pi's read (dist/core/tools/path-utils.js resolveReadPathAsync) opens
+// another spelling when the resolved path doesn't exist: U+202F before
+// AM/PM, then NFD, then U+2019 for ', then both. Each applies to the whole
+// resolved path, directory names included, and Pi resolves against its cwd,
+// the workspace's real path. So every spelling that exists as a name is
+// checked, not only the one Pi would pick, and the check doesn't depend on
+// which of them exists when Pi opens it. Claude Code's Read gets the same
+// check; its own retries aren't documented.
+function spellings(workspace, p) {
+ const s = normalise(p);
+ const bases = isAbsolute(s) ? [resolve(s)] : [resolve(workspace, s), resolve(realpathSync(workspace), s)];
+ const curly = (v) => v.replace(/'/g, "\u2019");
+ const out = new Set();
+ for (const r of bases) {
+ const nfd = r.normalize("NFD");
+ for (const v of [r.replace(/ (AM|PM)\./gi, "\u202F$1."), nfd, curly(r), curly(nfd)]) if (v !== r) out.add(v);
+ }
+ return out;
+}
+
+// The first spelling that exists and resolves outside, or through a
+// dangling link, as a refusal reason; null if there is none.
+function otherSpelling(workspace, tool, p) {
+ for (const v of spellings(workspace, p)) {
+ let found;
+ try {
+ found = lstatSync(v, { throwIfNoEntry: false });
+ } catch (error) {
+ // A file used as a directory: Pi's access() fails too.
+ if (error.code === "ENOTDIR") continue;
+ return `${tool} path can't be checked under another spelling: ${error.code ?? error.message}`;
+ }
+ if (!found) continue;
+ try {
+ if (!within(workspace, v)) return `${tool} path is outside the workspace under another spelling: ${p} -> ${JSON.stringify(v)}`;
+ } catch (error) {
+ if (error.code === "dangling-symlink") return `${tool} path goes through a dangling symlink under another spelling: ${p} -> ${JSON.stringify(v)}`;
+ return `${tool} path can't be checked under another spelling: ${error.code ?? error.message}`;
+ }
+ }
+ return null;
+}
+
// decide(policy, tool, input) -> { allow: true } | { allow: false, reason }
// policy { harness: "pi" | "claude-code", workspace, tools: [pi names], typed: [typed tool names] }
export function decide(policy, tool, input) {
@@ -98,5 +145,14 @@
if (error.code === "dangling-symlink") return no(`${tool} path goes through a dangling symlink: ${value}`);
return no(`${tool} path can't be checked: ${error.code ?? error.message}`);
}
+ if (tool === (claude ? "Read" : "read")) {
+ let other;
+ try {
+ other = otherSpelling(policy.workspace, tool, value);
+ } catch (error) {
+ return no(`${tool} path can't be checked: ${error.code ?? error.message}`);
+ }
+ if (other) return no(other);
+ }
return { allow: true };
}
--- r2wt/packages/harness/tests/gate.test.mjs 2026-10-09 22:01:00.288274261 -0500
+++ wt/packages/harness/tests/gate.test.mjs 2026-10-09 22:00:50.900285193 -0500
@@ -102,6 +102,62 @@
}
});
+// Pi's read opens another spelling when the name it's given doesn't exist
+// (path-utils.js resolveReadPathAsync). Asked name -> name on disk.
+const SPELLINGS = [
+ ["quote", "notes's.txt", "notes\u2019s.txt"],
+ ["ampm", "shot 9.41 AM.png", "shot 9.41\u202FAM.png"],
+ ["nfd", "r\u00E9sum\u00E9.txt", "r\u00E9sum\u00E9.txt".normalize("NFD")],
+ ["nfd and quote", "d'\u00E9cran.png", "d\u2019\u00E9cran.png".normalize("NFD")],
+ // Each family alone, on a name with both an apostrophe and an accent.
+ ["nfd, straight quote", "l'\u00E9t\u00E9.txt", "l'\u00E9t\u00E9.txt".normalize("NFD")],
+ ["quote, composed", "l'\u00E9t\u00E9.md", "l\u2019\u00E9t\u00E9.md"],
+];
+
+test("read is checked under every spelling pi's read would open, in both harnesses", (t) => {
+ for (const [harness, read, write, field] of [["pi", "read", "write", "path"], ["claude-code", "Read", "Write", "file_path"]]) {
+ for (const [, asked, disk] of SPELLINGS) {
+ const { dir, workspace, policy } = setup(t, harness, ["read", "write"]);
+ mkdirSync(join(dir, "outside"));
+ writeFileSync(join(dir, "outside", "secret.txt"), "s");
+ symlinkSync(join(dir, "outside", "secret.txt"), join(workspace, disk));
+ const why = new RegExp(`outside the workspace under another spelling: (.*/)?${asked.replace(/[.']/g, "\\$&")}`);
+ blocked(decide(policy, read, { [field]: asked }), why);
+ blocked(decide(policy, read, { [field]: join(workspace, asked) }), why);
+ // Write takes the name as given, so writing the asked name stays inside.
+ allowed(decide(policy, write, { [field]: asked }));
+ // The same spelling pointing inside is fine.
+ const inner = setup(t, harness, ["read"]);
+ symlinkSync(join(inner.workspace, "a.txt"), join(inner.workspace, disk));
+ allowed(decide(inner.policy, read, { [field]: asked }));
+ }
+ }
+});
+
+test("other spellings cover directories, dangling links and pi's cwd", (t) => {
+ const { dir, workspace, policy } = setup(t, "pi", ["read"]);
+ mkdirSync(join(dir, "outside"));
+ writeFileSync(join(dir, "outside", "s.txt"), "s");
+ // A directory name is respelled too.
+ symlinkSync(join(dir, "outside"), join(workspace, "it\u2019s"));
+ blocked(decide(policy, "read", { path: "it's/s.txt" }), /outside the workspace under another spelling/);
+ // A dangling link under another spelling is refused, like the name itself.
+ symlinkSync(join(dir, "nowhere"), join(workspace, "gone\u2019s"));
+ blocked(decide(policy, "read", { path: "gone's" }), /dangling symlink under another spelling/);
+ // A spelling that doesn't exist, or uses a file as a directory, isn't opened.
+ allowed(decide(policy, "read", { path: "nobody's.txt" }));
+ writeFileSync(join(workspace, "f\u2019s"), "f");
+ allowed(decide(policy, "read", { path: "f's/x" }));
+ // Pi resolves against its cwd, the workspace's real path, and respells
+ // that too: here the real path has an apostrophe the given path lacks.
+ mkdirSync(join(dir, "jo's", "ws"), { recursive: true });
+ mkdirSync(join(dir, "jo\u2019s", "ws"), { recursive: true });
+ writeFileSync(join(dir, "jo\u2019s", "ws", "x.txt"), "x");
+ symlinkSync(join(dir, "jo's"), join(dir, "jo"));
+ const viaLink = { ...policy, workspace: join(dir, "jo", "ws") };
+ blocked(decide(viaLink, "read", { path: "x.txt" }), /outside the workspace under another spelling/);
+});
+
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") }));
--- r2wt/packages/harness/tests/pi-session.test.mjs 2026-10-09 22:01:00.288274261 -0500
+++ wt/packages/harness/tests/pi-session.test.mjs 2026-10-09 22:00:50.900285193 -0500
@@ -120,6 +120,39 @@
assert.ok(existsSync(s.turnMarker));
});
+test("pi: a read is refused when pi would open another spelling outside", async (t) => {
+ // Asked name -> link on disk; pi's read opens the link when the asked
+ // name doesn't exist (path-utils.js resolveReadPathAsync).
+ const spellings = [
+ ["notes's.txt", "notes\u2019s.txt"],
+ ["shot 9.41 AM.png", "shot 9.41\u202FAM.png"],
+ ["r\u00E9sum\u00E9.txt", "r\u00E9sum\u00E9.txt".normalize("NFD")],
+ ["d'\u00E9cran.png", "d\u2019\u00E9cran.png".normalize("NFD")],
+ ["l'\u00E9t\u00E9.txt", "l'\u00E9t\u00E9.txt".normalize("NFD")],
+ ["l'\u00E9t\u00E9.md", "l\u2019\u00E9t\u00E9.md"],
+ ];
+ const { dir, workspace, s, env } = await session(t, [
+ ...spellings.map(([asked]) => ({ name: "read", input: { path: asked } })),
+ { name: "read", input: { path: "inside's.txt" } },
+ ]);
+ mkdirSync(join(dir, "outside"));
+ writeFileSync(join(dir, "outside", "secret.txt"), "SECRET-OUTSIDE\n");
+ for (const [, disk] of spellings) symlinkSync(join(dir, "outside", "secret.txt"), join(workspace, disk));
+ // The same respelling pointing inside is still read.
+ symlinkSync(join(workspace, "notes.txt"), join(workspace, "inside\u2019s.txt"));
+ const r = await turn(env, "Message 1 from jason, class REQUEST:\n\nread the files", workspace);
+ assert.equal(r.code, 0, r.stderr);
+ assert.doesNotMatch(r.stdout, /SECRET/);
+ const results = JSON.parse(r.stdout.trim().slice("ANSWER ".length));
+ assert.equal(results.length, spellings.length + 1);
+ spellings.forEach(([asked], i) => {
+ assert.equal(results[i][0], true, asked);
+ assert.match(results[i][1], /outside the workspace under another spelling/, asked);
+ });
+ assert.deepEqual(results.at(-1), [false, "inside\n"]);
+ assert.ok(existsSync(s.turnMarker));
+});
+
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);
@@ -0,0 +1,85 @@
✔ sessionModel: agent vars win, then the system's execution settings (9.358532ms)
✔ a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory (4.398285ms)
✔ a claude-code bundle adds the wrapped gate hook and the MCP config (2.527849ms)
✔ a bundle is written once: an existing file refuses (2.071583ms)
✔ a path with a single quote can't go into the hook command (1.761433ms)
✔ allow exits 0, a deny exits 2 with the reason on stderr (123.554651ms)
✔ a missing or wrong policy, or a bad event, exits 2 (82.057364ms)
✔ the bundle's wrapped command: a missing gate or node still blocks (1089.837021ms)
✔ claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist (737.701356ms)
✔ claude: the hook alone blocks a path outside the workspace (465.176471ms)
✔ claude: a second turn resumes the first turn's session (719.164258ms)
✔ claude adapter: --restricted is always passed (5.526811ms)
✔ claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do (749.125161ms)
✔ claude: a missing hook or MCP file refuses before claude starts (7.493927ms)
✔ pi: policy tools and typed tools pass, anything else is blocked (2.894317ms)
✔ claude: builtins map from pi names, typed tools need the mcp prefix (0.846601ms)
✔ file tool paths must resolve inside the workspace (0.993882ms)
✔ pi's own path normalisation can't be used to step out (0.8916ms)
✔ a symlink inside the workspace that points out is outside (0.801529ms)
✔ a dangling symlink is refused at any depth, in both harnesses (2.788934ms)
✖ read is checked under every spelling pi's read would open, in both harnesses (9.402305ms)
✔ other spellings cover directories, dangling links and pi's cwd (2.380602ms)
✔ claude path fields per tool (0.956169ms)
✔ glob patterns stay inside the workspace (1.035235ms)
✔ a path that can't be checked is blocked (0.530203ms)
✔ initialize, ping and tools/list (39.441298ms)
✔ tools/call goes through the tool socket; a refusal is an isError result (32.39596ms)
✔ unknown tools and methods are JSON-RPC errors and never reach the socket (33.457902ms)
✔ a missing argument is a usage error (33.942319ms)
✔ pi: typed tools reach the socket, the gate blocks, agent_end writes the marker (356.826829ms)
✔ pi: a write through a dangling symlink is blocked, and nothing appears outside (324.927588ms)
✔ pi: a read is refused when pi would open another spelling outside (344.945871ms)
✔ pi: a missing extension refuses before any model call (6.876995ms)
✔ pi: an extension without its configuration fails pi's start (265.829784ms)
✔ founderCheck: founder variables, then a needed service without a usable token (1.210381ms)
✔ turnRequest names the sender, class, reply and decision (0.173436ms)
✔ a message becomes a turn, the answer goes back as a RESULT, SIGTERM releases and exits 0 (254.240602ms)
✔ a SIGTERM before the claim stops the runner with exit 0 and no claim (96.44878ms)
✔ typed tools carry the runner's capability; launch goes to the host's launch socket (392.016492ms)
✔ a RESULT gets no automatic reply; failed turns reply with the reason (1309.272562ms)
✔ SIGTERM during a turn kills the turn's process group and still exits 0 (165.612545ms)
✔ founder credentials stop before the claim (20) (165.730608ms)
✔ a refused claim exits 21; an ended run's capability exits 22 (191.212879ms)
✔ the launch ending under a running session exits 22 (149.35259ms)
✔ a broker that stays unreachable exits 23 after brokerRetries polls (244.991401ms)
✔ a broker that is down at the claim exits 23, not 21 (112.331502ms)
✔ no capability, or a malformed one, on stdin exits 2 (189.681234ms)
✔ a missing or malformed policy exits 2 before the claim (127.377256ms)
✔ the PM gets launch, its task verbs and the reads (6.818221ms)
✔ a coder gets no launch, no resolve_decision, and no task tools without a tracker (2.149355ms)
✔ launch only when the business's launch block names the instance as launcher (1.624567ms)
✔ an action outside the instance's authority has no tool (1.44195ms)
✔ callTool: one JSON line out, the result back, a refusal rejects (9.277411ms)
ℹ tests 53
ℹ suites 0
ℹ pass 52
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 10334.028949
✖ failing tests:
test at packages/harness/tests/gate.test.mjs:117:1
✖ read is checked under every spelling pi's read would open, in both harnesses (9.402305ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
true !== false
at blocked (file:///home/jwoltje/darkwing-scratch/r41c/wt/packages/harness/tests/gate.test.mjs:19:10)
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41c/wt/packages/harness/tests/gate.test.mjs:125: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'
}
@@ -0,0 +1,85 @@
✔ sessionModel: agent vars win, then the system's execution settings (8.566028ms)
✔ a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory (4.074535ms)
✔ a claude-code bundle adds the wrapped gate hook and the MCP config (2.408252ms)
✔ a bundle is written once: an existing file refuses (2.772069ms)
✔ a path with a single quote can't go into the hook command (1.564063ms)
✔ allow exits 0, a deny exits 2 with the reason on stderr (117.170068ms)
✔ a missing or wrong policy, or a bad event, exits 2 (76.439081ms)
✔ the bundle's wrapped command: a missing gate or node still blocks (1092.382926ms)
✔ claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist (723.07052ms)
✔ claude: the hook alone blocks a path outside the workspace (437.790038ms)
✔ claude: a second turn resumes the first turn's session (706.64768ms)
✔ claude adapter: --restricted is always passed (5.065955ms)
✔ claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do (735.740968ms)
✔ claude: a missing hook or MCP file refuses before claude starts (7.422196ms)
✔ pi: policy tools and typed tools pass, anything else is blocked (3.175453ms)
✔ claude: builtins map from pi names, typed tools need the mcp prefix (0.916415ms)
✔ file tool paths must resolve inside the workspace (1.439155ms)
✔ pi's own path normalisation can't be used to step out (1.34261ms)
✔ a symlink inside the workspace that points out is outside (1.126732ms)
✔ a dangling symlink is refused at any depth, in both harnesses (3.825163ms)
✔ read is checked under every spelling pi's read would open, in both harnesses (15.322363ms)
✖ other spellings cover directories, dangling links and pi's cwd (2.385802ms)
✔ claude path fields per tool (1.137465ms)
✔ glob patterns stay inside the workspace (0.670222ms)
✔ a path that can't be checked is blocked (0.36089ms)
✔ initialize, ping and tools/list (39.621731ms)
✔ tools/call goes through the tool socket; a refusal is an isError result (31.219015ms)
✔ unknown tools and methods are JSON-RPC errors and never reach the socket (30.074064ms)
✔ a missing argument is a usage error (26.975524ms)
✔ pi: typed tools reach the socket, the gate blocks, agent_end writes the marker (342.776996ms)
✔ pi: a write through a dangling symlink is blocked, and nothing appears outside (319.23647ms)
✔ pi: a read is refused when pi would open another spelling outside (307.599481ms)
✔ pi: a missing extension refuses before any model call (6.637971ms)
✔ pi: an extension without its configuration fails pi's start (249.230312ms)
✔ founderCheck: founder variables, then a needed service without a usable token (1.184883ms)
✔ turnRequest names the sender, class, reply and decision (0.253545ms)
✔ a message becomes a turn, the answer goes back as a RESULT, SIGTERM releases and exits 0 (240.675801ms)
✔ a SIGTERM before the claim stops the runner with exit 0 and no claim (97.530314ms)
✔ typed tools carry the runner's capability; launch goes to the host's launch socket (366.718888ms)
✔ a RESULT gets no automatic reply; failed turns reply with the reason (1310.292612ms)
✔ SIGTERM during a turn kills the turn's process group and still exits 0 (156.386139ms)
✔ founder credentials stop before the claim (20) (178.388986ms)
✔ a refused claim exits 21; an ended run's capability exits 22 (195.113915ms)
✔ the launch ending under a running session exits 22 (150.862288ms)
✔ a broker that stays unreachable exits 23 after brokerRetries polls (236.983901ms)
✔ a broker that is down at the claim exits 23, not 21 (90.300934ms)
✔ no capability, or a malformed one, on stdin exits 2 (180.286601ms)
✔ a missing or malformed policy exits 2 before the claim (132.858844ms)
✔ the PM gets launch, its task verbs and the reads (6.987897ms)
✔ a coder gets no launch, no resolve_decision, and no task tools without a tracker (2.444742ms)
✔ launch only when the business's launch block names the instance as launcher (2.043716ms)
✔ an action outside the instance's authority has no tool (2.093997ms)
✔ callTool: one JSON line out, the result back, a refusal rejects (11.192947ms)
ℹ tests 53
ℹ suites 0
ℹ pass 52
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 10263.047304
✖ failing tests:
test at packages/harness/tests/gate.test.mjs:137:1
✖ other spellings cover directories, dangling links and pi's cwd (2.385802ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
true !== false
at blocked (file:///home/jwoltje/darkwing-scratch/r41c/wt/packages/harness/tests/gate.test.mjs:19:10)
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41c/wt/packages/harness/tests/gate.test.mjs:158:3)
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,103 @@
✔ sessionModel: agent vars win, then the system's execution settings (10.423796ms)
✔ a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory (3.914344ms)
✔ a claude-code bundle adds the wrapped gate hook and the MCP config (2.407099ms)
✔ a bundle is written once: an existing file refuses (2.032253ms)
✔ a path with a single quote can't go into the hook command (2.335414ms)
✔ allow exits 0, a deny exits 2 with the reason on stderr (116.424322ms)
✔ a missing or wrong policy, or a bad event, exits 2 (82.940953ms)
✔ the bundle's wrapped command: a missing gate or node still blocks (1091.478948ms)
✔ claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist (743.685519ms)
✔ claude: the hook alone blocks a path outside the workspace (431.911335ms)
✔ claude: a second turn resumes the first turn's session (706.06674ms)
✔ claude adapter: --restricted is always passed (5.068282ms)
✔ claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do (755.529214ms)
✔ claude: a missing hook or MCP file refuses before claude starts (7.982961ms)
✔ pi: policy tools and typed tools pass, anything else is blocked (4.11275ms)
✔ claude: builtins map from pi names, typed tools need the mcp prefix (0.956898ms)
✔ file tool paths must resolve inside the workspace (1.06297ms)
✔ pi's own path normalisation can't be used to step out (0.906404ms)
✔ a symlink inside the workspace that points out is outside (0.943109ms)
✔ a dangling symlink is refused at any depth, in both harnesses (2.798636ms)
✖ read is checked under every spelling pi's read would open, in both harnesses (7.371341ms)
✔ other spellings cover directories, dangling links and pi's cwd (1.833526ms)
✔ claude path fields per tool (0.876754ms)
✔ glob patterns stay inside the workspace (0.975302ms)
✔ a path that can't be checked is blocked (0.528582ms)
✔ initialize, ping and tools/list (46.020346ms)
✔ tools/call goes through the tool socket; a refusal is an isError result (31.372102ms)
✔ unknown tools and methods are JSON-RPC errors and never reach the socket (38.784287ms)
✔ a missing argument is a usage error (30.636398ms)
✔ pi: typed tools reach the socket, the gate blocks, agent_end writes the marker (346.29026ms)
✔ pi: a write through a dangling symlink is blocked, and nothing appears outside (338.979029ms)
✖ pi: a read is refused when pi would open another spelling outside (308.833029ms)
✔ pi: a missing extension refuses before any model call (6.965714ms)
✔ pi: an extension without its configuration fails pi's start (250.888668ms)
✔ founderCheck: founder variables, then a needed service without a usable token (1.233953ms)
✔ turnRequest names the sender, class, reply and decision (0.183418ms)
✔ a message becomes a turn, the answer goes back as a RESULT, SIGTERM releases and exits 0 (257.369793ms)
✔ a SIGTERM before the claim stops the runner with exit 0 and no claim (101.543834ms)
✔ typed tools carry the runner's capability; launch goes to the host's launch socket (413.089843ms)
✔ a RESULT gets no automatic reply; failed turns reply with the reason (1287.433399ms)
✔ SIGTERM during a turn kills the turn's process group and still exits 0 (152.333233ms)
✔ founder credentials stop before the claim (20) (178.76855ms)
✔ a refused claim exits 21; an ended run's capability exits 22 (199.319786ms)
✔ the launch ending under a running session exits 22 (141.706732ms)
✔ a broker that stays unreachable exits 23 after brokerRetries polls (252.747209ms)
✔ a broker that is down at the claim exits 23, not 21 (91.79929ms)
✔ no capability, or a malformed one, on stdin exits 2 (188.941134ms)
✔ a missing or malformed policy exits 2 before the claim (142.45418ms)
✔ the PM gets launch, its task verbs and the reads (8.970935ms)
✔ a coder gets no launch, no resolve_decision, and no task tools without a tracker (2.816368ms)
✔ launch only when the business's launch block names the instance as launcher (2.300242ms)
✔ an action outside the instance's authority has no tool (2.202629ms)
✔ callTool: one JSON line out, the result back, a refusal rejects (8.591287ms)
ℹ tests 53
ℹ suites 0
ℹ pass 51
ℹ fail 2
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 10317.199481
✖ failing tests:
test at packages/harness/tests/gate.test.mjs:117:1
✖ read is checked under every spelling pi's read would open, in both harnesses (7.371341ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
true !== false
at blocked (file:///home/jwoltje/darkwing-scratch/r41c/wt/packages/harness/tests/gate.test.mjs:19:10)
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41c/wt/packages/harness/tests/gate.test.mjs:125: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:123:1
✖ pi: a read is refused when pi would open another spelling outside (308.833029ms)
AssertionError [ERR_ASSERTION]: The input was expected to not match the regular expression /SECRET/. Input:
`ANSWER [[true,"mosaic gate: read path is outside the workspace under another spelling: notes's."],[true,"mosaic gate: read path is outside the workspace under another spelling: shot 9.4"],[true,"mosaic gate: read path is outside the workspace under another spelling: résumé.t"],[false,"SECRET-OUTSIDE\\n"],[true,"mosaic gate: read path is outside the workspace under another spelling: l'été.tx"],[true,"mosaic gate: read path is outside the workspace under another spelling: l'été.md"],[false,"inside\\n"]]\n`
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41c/wt/packages/harness/tests/pi-session.test.mjs:145: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: `ANSWER [[true,"mosaic gate: read path is outside the workspace under another spelling: notes's."],[true,"mosaic gate: read path is outside the workspace under another spelling: shot 9.4"],[true,"mosaic gate: read path is outside the workspace under another spelling: résumé.t"],[false,"SECRET-OUTSIDE\\n"],[true,"mosaic gate: read path is outside the workspace under another spelling: l'été.tx"],[true,"mosaic gate: read path is outside the workspace under another spelling: l'été.md"],[false,"inside\\n"]]\n`,
expected: /SECRET/,
operator: 'doesNotMatch',
diff: 'simple'
}
@@ -0,0 +1,115 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (125.098541ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (134.665935ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (96.766909ms)
✔ decide prints a declining choice as declining (108.478466ms)
✔ an unknown outcome is reported once and never resent (83.652622ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (85.260087ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (85.215231ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (47.360022ms)
✔ every human command refuses inside an agent run before it touches the bus (74.792694ms)
✔ usage errors exit 4; no business and no host is a usage error (67.921609ms)
✔ agents and tasks print through the broker (75.297097ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (3.397368ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (42.16644ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (32.376277ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (32.360128ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (28.459949ms)
✔ a business without tracker.baseUrl gets no trackers entry (27.435579ms)
✔ an unknown business and a broken system config refuse with exit 3 (52.425052ms)
✔ empty views say so (1.054835ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (1.31622ms)
✔ tasks print the tracker fields the snapshot carries (0.230229ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (900.777403ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (197.314943ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (106.753089ms)
✔ a broker reply that misses the wait breaks the channel: the late reply answers nothing, later requests refuse, the host exits 1 (1123.524195ms)
✔ a broker reply with another request's id, or none, breaks the channel and the host exits 1 (234.234287ms)
✔ a broker reply with no request waiting breaks the channel and the host exits 1 (113.460887ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (156.637981ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (147.392036ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (113.446985ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (147.381832ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (94.058726ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (159.970362ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (24.968625ms)
✔ bus stop refuses to signal a live pid that is not a bus host (202.36153ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (228.950236ms)
✔ bus start refuses with exit 3 without a notifier config (97.197829ms)
✔ bus start runs until bus stop; status reports it while it runs (656.505643ms)
✔ bus-service.sh renders the unit and installs it into a given directory (26.52731ms)
✖ bus start --pm: the PM runs under its own PID namespace, registered, with a manifest (30264.958209ms)
✖ the PM launches a coder through its launch tool; the coder answers; refusals name their code (18485.409173ms)
✔ a runner that stops at once ends its launch with the runner's reason (194.401086ms)
✖ 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 (30544.742537ms)
✖ 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 (33061.504326ms)
✔ a malformed sessions.json refuses the host start with exit 3 and stays as it was (105.073482ms)
✔ an over-long launch request is refused at once, not at the 10 s idle timeout (108.473075ms)
✔ a launch client that never closes its side doesn't hold the host's close (122.229839ms)
✔ a runner that ignores SIGTERM is killed when the host closes (1224.323487ms)
✔ zoned uses the IANA zone across DST (15.998202ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (106.49839ms)
✔ two blocking decisions get two DMs with different nonces (136.681083ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.247401ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (105.173087ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (91.885581ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (88.046523ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (96.766445ms)
✔ 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 (133.166052ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (121.365828ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (96.241289ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (68.046994ms)
✔ an inbox read failure is logged and the next poll retries (0.652732ms)
✔ no Discord id reaches the journal or the log (52.636044ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (15.640962ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (15.244681ms)
✔ the journal: a whole file that is one torn line truncates to empty (10.205265ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (0.5312ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.565166ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (1.778854ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.310747ms)
✔ 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.451018ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.375168ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.390645ms)
✔ digest content stays within Discord's 2000 characters (0.230013ms)
✔ runLoop never overlaps ticks and stops after the one in flight (111.867714ms)
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 (378.858886ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (38.33439ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2142.861729ms)
✔ busExit and refuseInsideAgent (0.427438ms)
✔ talk sends a REQUEST, prints and reads everything that arrives, and stops at the reply (208.921317ms)
✔ talk --wait 0 only sends; no reply within --wait exits 1 and says where it will show (1129.911757ms)
✔ talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors (45.073004ms)
✔ launches off and on go to the broker and change the business's launch state (54.562449ms)
✔ launches list reads sessions.json and marks a stale entry; stop reports it and refuses a non-runner (55.056715ms)
ℹ tests 83
ℹ suites 0
ℹ pass 79
ℹ fail 1
ℹ cancelled 3
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 114201.684561
✖ 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 (30264.958209ms)
'test timed out after 30000ms'
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 (18485.409173ms)
Error: timed out waiting
at until (file:///home/jwoltje/darkwing-scratch/r41c/wt/packages/cli/tests/launcher.test.mjs:43:9)
at async TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41c/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)
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 (30544.742537ms)
'test timed out after 30000ms'
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 (33061.504326ms)
'test timed out after 30000ms'
@@ -0,0 +1,124 @@
✔ sessionModel: agent vars win, then the system's execution settings (11.140494ms)
✔ a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory (4.048456ms)
✔ a claude-code bundle adds the wrapped gate hook and the MCP config (2.780692ms)
✔ a bundle is written once: an existing file refuses (2.191088ms)
✔ a path with a single quote can't go into the hook command (2.698237ms)
✔ allow exits 0, a deny exits 2 with the reason on stderr (123.019613ms)
✔ a missing or wrong policy, or a bad event, exits 2 (81.316548ms)
✔ the bundle's wrapped command: a missing gate or node still blocks (1102.998811ms)
✔ claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist (745.482939ms)
✔ claude: the hook alone blocks a path outside the workspace (438.308218ms)
✔ claude: a second turn resumes the first turn's session (723.668481ms)
✔ claude adapter: --restricted is always passed (5.009441ms)
✔ claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do (745.660141ms)
✔ claude: a missing hook or MCP file refuses before claude starts (7.497673ms)
✔ pi: policy tools and typed tools pass, anything else is blocked (2.811459ms)
✔ claude: builtins map from pi names, typed tools need the mcp prefix (0.694063ms)
✔ file tool paths must resolve inside the workspace (0.964219ms)
✔ pi's own path normalisation can't be used to step out (0.928349ms)
✔ a symlink inside the workspace that points out is outside (0.820113ms)
✔ a dangling symlink is refused at any depth, in both harnesses (2.912616ms)
✔ read is checked under every spelling pi's read would open, in both harnesses (13.371484ms)
✔ other spellings cover directories, dangling links and pi's cwd (2.084494ms)
✔ claude path fields per tool (0.760987ms)
✔ glob patterns stay inside the workspace (0.711647ms)
✔ a path that can't be checked is blocked (0.351555ms)
✔ initialize, ping and tools/list (45.796686ms)
✔ tools/call goes through the tool socket; a refusal is an isError result (32.037678ms)
✔ unknown tools and methods are JSON-RPC errors and never reach the socket (30.356779ms)
✔ a missing argument is a usage error (26.986749ms)
✔ pi: typed tools reach the socket, the gate blocks, agent_end writes the marker (361.398704ms)
✔ pi: a write through a dangling symlink is blocked, and nothing appears outside (318.044545ms)
✔ pi: a read is refused when pi would open another spelling outside (319.989916ms)
✔ pi: a missing extension refuses before any model call (6.645305ms)
✔ pi: an extension without its configuration fails pi's start (254.355419ms)
✔ founderCheck: founder variables, then a needed service without a usable token (1.161435ms)
✔ turnRequest names the sender, class, reply and decision (0.327442ms)
✖ a message becomes a turn, the answer goes back as a RESULT, SIGTERM releases and exits 0 (247.588857ms)
✖ a SIGTERM before the claim stops the runner with exit 0 and no claim (109.591138ms)
✔ typed tools carry the runner's capability; launch goes to the host's launch socket (392.919759ms)
✔ a RESULT gets no automatic reply; failed turns reply with the reason (1294.268687ms)
✖ SIGTERM during a turn kills the turn's process group and still exits 0 (145.929592ms)
✔ founder credentials stop before the claim (20) (181.0226ms)
✔ a refused claim exits 21; an ended run's capability exits 22 (195.806934ms)
✔ the launch ending under a running session exits 22 (143.244036ms)
✔ a broker that stays unreachable exits 23 after brokerRetries polls (243.51654ms)
✔ a broker that is down at the claim exits 23, not 21 (95.036286ms)
✔ no capability, or a malformed one, on stdin exits 2 (190.597233ms)
✔ a missing or malformed policy exits 2 before the claim (125.336817ms)
✔ the PM gets launch, its task verbs and the reads (6.275456ms)
✔ a coder gets no launch, no resolve_decision, and no task tools without a tracker (2.228187ms)
✔ launch only when the business's launch block names the instance as launcher (1.727758ms)
✔ an action outside the instance's authority has no tool (1.644912ms)
✔ callTool: one JSON line out, the result back, a refusal rejects (7.802134ms)
ℹ tests 53
ℹ suites 0
ℹ pass 50
ℹ fail 3
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 10310.317054
✖ 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 (247.588857ms)
AssertionError [ERR_ASSERTION]: runner: demo/coder claimed by run coder-run
null !== 0
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41c/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 (109.591138ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal:
+ actual - expected
[
- 0,
null,
+ 'SIGTERM'
]
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41c/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 (145.929592ms)
AssertionError [ERR_ASSERTION]: runner: demo/coder claimed by run coder-run
null !== 0
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41c/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 (141.752229ms)
✔ decision classes route from policy; gated resolution is human-only, choice and target must match (247.105736ms)
✔ claim exclusion, holder release, gated revoke and rerouting to a new holder are atomic (232.074913ms)
✔ launch events require a human CLI capability; generic emit cannot forge authority events (163.437494ms)
✔ within-role decisions close atomically and invalid options or blocking omissions refuse (134.182686ms)
✔ observer capabilities read human inbox but cannot mutate or forge launch identity (128.492959ms)
✔ task action subjects and linked decision trail are complete and ordered (141.350634ms)
✔ launch binding is durable and reconnecting requires the identical trusted record (87.271074ms)
✔ business isolation includes inherited object names and cross-business message references (154.949676ms)
✔ authority never transfers between action, run, target, unresolved or replaced role holder (205.599102ms)
✔ task projection uses schema current view, skipping earlier and equal-start polls (105.988045ms)
✔ revocation permanently bars the old run from reclaiming first, including after broker restart (155.526684ms)
✔ empty message references refuse before storage; refusal-evidence failure stays a typed error (103.210466ms)
✔ both arbiters require human resolution when their cross-role route is themselves (168.486214ms)
✔ S1 adapter takes resolved limits and refs, rejects mismatched instance, never mutates input (2.685403ms)
✔ only validated broker references load; returned data and exceptions cannot expose a known token (5.552371ms)
✔ bad file modes, symlinks, repository/data paths, malformed tokens and missing dates refuse (3.259922ms)
✔ expiry refuses use and env references never become client data (1.611144ms)
✔ S1 parsed service refs work, service mismatch refuses, Gitea rotation due is a warning state (2.567589ms)
✔ opaque tokens shorter than 16 characters refuse before use (0.66433ms)
✔ endLaunch writes session.ended, releases the run claim and kills its capabilities (111.925286ms)
✔ endLaunch refuses an unknown run, a second end and a rebind of the ended run (114.03898ms)
✖ a restarted broker refuses to rebind an ended run; a refused rebind leaves the run unbound (143.641995ms)
✔ endLaunch leaves a claim another run took alone (147.819902ms)
✔ refuse records action.refused against the caller with the code only (115.497532ms)
✔ launches off refuses role.launch with launch-revoked until launches on (167.568365ms)
✔ broker process: launch ops authorize role.launch, record refusals and end runs (216.389965ms)
✔ human proof binds CLI entry, process start and nonce; agents and incomplete ancestry refuse (3.390024ms)
✔ process reader gets own kernel identity without exposing environment values (0.921905ms)
✔ EACCES ancestor environments skip only markers; commands and registered launches still refuse (1.105945ms)
✔ real pid 1 remains inspectable when its environment is protected (0.454306ms)
✔ within-role sends cite an open gated launch decision without spending it or naming it in grants (159.790733ms)
✔ missing and foreign-business citations refuse and roll back message and grant (173.665975ms)
✔ cross-role sends still need a matching resolved decision and consume it once (219.510088ms)
✔ broker process binds trusted launches, offers reader capabilities, refuses human mutation, closes cleanly (169.453786ms)
✔ startup token refusal returns safe code without value or partial listening broker (37.810006ms)
✔ loaded fixture token is absent from socket replies and SQLite, including refusal evidence (171.481213ms)
✔ killed broker leaves an explicit stale lock; another process cannot silently reclaim it (165.155134ms)
✔ trusted host registers later launches; socket clients never have a registration verb (162.948817ms)
✔ runtime excludes declared project roots even when host supplies no repoRoots (36.060833ms)
✔ a refused launch binding leaves the broker and existing capabilities alive; bad protocol stops it (136.155747ms)
✔ v3b prototype refusals, views and append-only mutations (931.964108ms)
✔ gated approval authorizes once, survives store reopen, and fresh approval works (222.420689ms)
✔ another run cannot consume an approval; a failed check leaves it usable (208.008517ms)
✔ two scheduled callers have exactly one grant and one consumed refusal (136.335702ms)
✔ failed commit rolls consumption back; cross-role consumes and within-role stays reusable (279.703548ms)
✔ class drift gated to cross-role refuses before consumption (171.91116ms)
✔ class drift cross-role to gated refuses before consumption (174.009782ms)
✔ class drift gated to within-role refuses before consumption (176.876849ms)
✔ class drift cross-role to within-role refuses before consumption (170.039319ms)
✔ class drift within-role to gated refuses before consumption (137.135281ms)
✔ class drift within-role to cross-role refuses before consumption (136.866615ms)
✔ message.send consumes approval and prevents a later send or authorize (167.694291ms)
✔ role.revoke consumes approval and prevents a later revoke or authorize (183.895777ms)
✔ creates private WAL store and excludes a second writer until explicit close (113.952233ms)
✔ rollback is atomic and schema metadata is checked against trusted DDL, not just itself (161.614455ms)
✔ existing empty database and symlink runtime directory refuse, never initialize over damage (172.22129ms)
✔ crash during a transaction recovers no partial event after explicit fixture-only lock removal (142.708781ms)
✔ writer refuses mixed at/read_at forms atomically, even through trusted SQL helpers (97.015376ms)
✔ async transactions refuse before invoking their function (80.146314ms)
✔ recordTask keeps sync reads and a role write apart (156.50087ms)
✔ read_at must be one canonical UTC format, so the projection compares strings safely (94.560023ms)
✔ a bad entry refuses the whole record (101.842108ms)
✔ taskView reads the projection for one business (119.992871ms)
✔ requestTask hands only a holder and a task verb to the handler, and records refusals (205.510593ms)
✔ the server sends task verbs to the adapter with its own timeout; other verbs stay synchronous (394.392771ms)
✔ without an adapter the server refuses every task verb (172.702074ms)
✔ the runtime refuses an invalid adapter and closes a valid one (164.929737ms)
✔ the process loads the S3 adapter from plain-data trackers (228.637175ms)
✔ socket capability stamps launch identity; shared views use wire, no SQL client (131.968242ms)
✔ two wire claims serialize; a lost reply never automatically retries (164.064252ms)
✔ malformed, oversized and identity-forging envelopes refuse without echoing input (111.581399ms)
✔ client preserves UTF-8 when a response divides a multibyte character (11.575875ms)
✔ committed mutation followed by dropped reply reports unknown and is never retried (124.775233ms)
ℹ tests 74
ℹ suites 0
ℹ pass 73
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 2252.198861
✖ 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 (143.641995ms)
AssertionError [ERR_ASSERTION]: Missing expected exception.
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41c/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,93 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (109.326208ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (131.173011ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (95.380715ms)
✔ decide prints a declining choice as declining (101.261199ms)
✔ an unknown outcome is reported once and never resent (90.873521ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (82.553178ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (65.663277ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (63.161817ms)
✔ every human command refuses inside an agent run before it touches the bus (71.517855ms)
✔ usage errors exit 4; no business and no host is a usage error (64.771744ms)
✔ agents and tasks print through the broker (81.235777ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (1.491731ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (42.390466ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (31.817917ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (29.31364ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (29.235237ms)
✔ a business without tracker.baseUrl gets no trackers entry (27.976293ms)
✔ an unknown business and a broken system config refuse with exit 3 (54.504904ms)
✔ empty views say so (0.643051ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (0.820582ms)
✔ tasks print the tracker fields the snapshot carries (0.12945ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (878.852777ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (193.435769ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (109.149738ms)
✔ a broker reply that misses the wait breaks the channel: the late reply answers nothing, later requests refuse, the host exits 1 (1116.029982ms)
✔ a broker reply with another request's id, or none, breaks the channel and the host exits 1 (297.363627ms)
✔ a broker reply with no request waiting breaks the channel and the host exits 1 (136.080863ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (211.117221ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (152.098598ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (105.739164ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (168.47933ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (109.013095ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (161.766518ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (23.205588ms)
✔ bus stop refuses to signal a live pid that is not a bus host (202.038679ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (220.245857ms)
✔ bus start refuses with exit 3 without a notifier config (87.740202ms)
✔ bus start runs until bus stop; status reports it while it runs (648.344607ms)
✔ bus-service.sh renders the unit and installs it into a given directory (29.199759ms)
✔ bus start --pm: the PM runs under its own PID namespace, registered, with a manifest (1236.950849ms)
✔ the PM launches a coder through its launch tool; the coder answers; refusals name their code (590.206319ms)
✔ a runner that stops at once ends its launch with the runner's reason (196.00187ms)
✔ 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 (654.407244ms)
✔ 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 (3602.247558ms)
✔ a malformed sessions.json refuses the host start with exit 3 and stays as it was (114.232121ms)
✔ an over-long launch request is refused at once, not at the 10 s idle timeout (113.625918ms)
✔ a launch client that never closes its side doesn't hold the host's close (123.424341ms)
✔ a runner that ignores SIGTERM is killed when the host closes (1225.669061ms)
✔ zoned uses the IANA zone across DST (13.117943ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (98.860357ms)
✔ two blocking decisions get two DMs with different nonces (116.067216ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.183696ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (100.784484ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (95.212487ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (90.646063ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (84.25297ms)
✔ 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 (132.969396ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (106.092917ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (90.088021ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (75.007703ms)
✔ an inbox read failure is logged and the next poll retries (0.632172ms)
✔ no Discord id reaches the journal or the log (53.143761ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (10.619939ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (14.032303ms)
✔ the journal: a whole file that is one torn line truncates to empty (17.698663ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (0.524247ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.532295ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (1.788243ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.32782ms)
✔ 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.431233ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.343949ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.439468ms)
✔ digest content stays within Discord's 2000 characters (0.243767ms)
✔ runLoop never overlaps ticks and stops after the one in flight (110.696929ms)
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 (364.080148ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (32.109277ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2144.091683ms)
✔ busExit and refuseInsideAgent (0.423576ms)
✔ talk sends a REQUEST, prints and reads everything that arrives, and stops at the reply (204.839812ms)
✔ talk --wait 0 only sends; no reply within --wait exits 1 and says where it will show (1133.088597ms)
✔ talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors (91.219169ms)
✔ launches off and on go to the broker and change the business's launch state (86.894265ms)
✔ launches list reads sessions.json and marks a stale entry; stop reports it and refuses a non-runner (50.775287ms)
ℹ tests 83
ℹ suites 0
ℹ pass 83
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 7932.873171
@@ -0,0 +1,102 @@
✔ launch identity is stamped, payload identity is refused and stale holder cannot send (171.830893ms)
✔ decision classes route from policy; gated resolution is human-only, choice and target must match (266.890225ms)
✔ claim exclusion, holder release, gated revoke and rerouting to a new holder are atomic (268.626968ms)
✔ launch events require a human CLI capability; generic emit cannot forge authority events (176.486715ms)
✔ within-role decisions close atomically and invalid options or blocking omissions refuse (167.734881ms)
✔ observer capabilities read human inbox but cannot mutate or forge launch identity (216.140206ms)
✔ task action subjects and linked decision trail are complete and ordered (198.424735ms)
✔ launch binding is durable and reconnecting requires the identical trusted record (129.416451ms)
✔ business isolation includes inherited object names and cross-business message references (177.414367ms)
✔ authority never transfers between action, run, target, unresolved or replaced role holder (269.053033ms)
✔ task projection uses schema current view, skipping earlier and equal-start polls (118.687513ms)
✔ revocation permanently bars the old run from reclaiming first, including after broker restart (201.774081ms)
✔ empty message references refuse before storage; refusal-evidence failure stays a typed error (113.973709ms)
✔ both arbiters require human resolution when their cross-role route is themselves (214.525848ms)
✔ S1 adapter takes resolved limits and refs, rejects mismatched instance, never mutates input (1.488662ms)
✔ only validated broker references load; returned data and exceptions cannot expose a known token (3.589048ms)
✔ bad file modes, symlinks, repository/data paths, malformed tokens and missing dates refuse (1.753701ms)
✔ expiry refuses use and env references never become client data (0.688026ms)
✔ S1 parsed service refs work, service mismatch refuses, Gitea rotation due is a warning state (1.589672ms)
✔ opaque tokens shorter than 16 characters refuse before use (0.487968ms)
✔ endLaunch writes session.ended, releases the run claim and kills its capabilities (131.846264ms)
✔ endLaunch refuses an unknown run, a second end and a rebind of the ended run (124.349879ms)
✖ a restarted broker refuses to rebind an ended run; a refused rebind leaves the run unbound (150.140782ms)
✔ endLaunch leaves a claim another run took alone (172.127339ms)
✔ refuse records action.refused against the caller with the code only (143.0897ms)
✔ launches off refuses role.launch with launch-revoked until launches on (187.119729ms)
✔ broker process: launch ops authorize role.launch, record refusals and end runs (276.999449ms)
✔ human proof binds CLI entry, process start and nonce; agents and incomplete ancestry refuse (2.245578ms)
✔ process reader gets own kernel identity without exposing environment values (0.58204ms)
✔ EACCES ancestor environments skip only markers; commands and registered launches still refuse (0.855876ms)
✔ real pid 1 remains inspectable when its environment is protected (0.386748ms)
✔ within-role sends cite an open gated launch decision without spending it or naming it in grants (192.364372ms)
✔ missing and foreign-business citations refuse and roll back message and grant (187.548757ms)
✔ cross-role sends still need a matching resolved decision and consume it once (244.60423ms)
✔ broker process binds trusted launches, offers reader capabilities, refuses human mutation, closes cleanly (182.339188ms)
✔ startup token refusal returns safe code without value or partial listening broker (33.831434ms)
✔ loaded fixture token is absent from socket replies and SQLite, including refusal evidence (177.750375ms)
✔ killed broker leaves an explicit stale lock; another process cannot silently reclaim it (169.468399ms)
✔ trusted host registers later launches; socket clients never have a registration verb (185.783447ms)
✔ runtime excludes declared project roots even when host supplies no repoRoots (34.587524ms)
✔ a refused launch binding leaves the broker and existing capabilities alive; bad protocol stops it (162.341403ms)
✔ v3b prototype refusals, views and append-only mutations (1055.017991ms)
✔ gated approval authorizes once, survives store reopen, and fresh approval works (251.168363ms)
✔ another run cannot consume an approval; a failed check leaves it usable (229.971363ms)
✔ two scheduled callers have exactly one grant and one consumed refusal (156.332039ms)
✔ failed commit rolls consumption back; cross-role consumes and within-role stays reusable (324.651503ms)
✔ class drift gated to cross-role refuses before consumption (247.34708ms)
✔ class drift cross-role to gated refuses before consumption (274.520898ms)
✔ class drift gated to within-role refuses before consumption (221.156719ms)
✔ class drift cross-role to within-role refuses before consumption (213.99909ms)
✔ class drift within-role to gated refuses before consumption (175.214444ms)
✔ class drift within-role to cross-role refuses before consumption (171.288068ms)
✔ message.send consumes approval and prevents a later send or authorize (194.291447ms)
✔ role.revoke consumes approval and prevents a later revoke or authorize (251.190733ms)
✔ creates private WAL store and excludes a second writer until explicit close (129.296689ms)
✔ rollback is atomic and schema metadata is checked against trusted DDL, not just itself (173.160026ms)
✔ existing empty database and symlink runtime directory refuse, never initialize over damage (202.099112ms)
✔ crash during a transaction recovers no partial event after explicit fixture-only lock removal (177.503462ms)
✔ writer refuses mixed at/read_at forms atomically, even through trusted SQL helpers (111.657608ms)
✔ async transactions refuse before invoking their function (91.472046ms)
✔ recordTask keeps sync reads and a role write apart (187.97079ms)
✔ read_at must be one canonical UTC format, so the projection compares strings safely (102.181955ms)
✔ a bad entry refuses the whole record (123.207407ms)
✔ taskView reads the projection for one business (127.272093ms)
✔ requestTask hands only a holder and a task verb to the handler, and records refusals (255.029589ms)
✔ the server sends task verbs to the adapter with its own timeout; other verbs stay synchronous (411.452538ms)
✔ without an adapter the server refuses every task verb (257.718944ms)
✔ the runtime refuses an invalid adapter and closes a valid one (209.735419ms)
✔ the process loads the S3 adapter from plain-data trackers (252.195976ms)
✔ socket capability stamps launch identity; shared views use wire, no SQL client (158.941898ms)
✔ two wire claims serialize; a lost reply never automatically retries (171.097842ms)
✔ malformed, oversized and identity-forging envelopes refuse without echoing input (127.164905ms)
✔ client preserves UTF-8 when a response divides a multibyte character (11.630703ms)
✔ committed mutation followed by dropped reply reports unknown and is never retried (126.014105ms)
ℹ tests 74
ℹ suites 0
ℹ pass 73
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 2792.42535
✖ 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 (150.140782ms)
AssertionError [ERR_ASSERTION]: Missing expected exception.
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41c/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,125 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (309.634682ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (118.696368ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (101.662485ms)
✔ decide prints a declining choice as declining (122.444104ms)
✔ an unknown outcome is reported once and never resent (101.437057ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (102.531101ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (78.700896ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (71.444982ms)
✔ every human command refuses inside an agent run before it touches the bus (90.620535ms)
✔ usage errors exit 4; no business and no host is a usage error (98.828565ms)
✔ agents and tasks print through the broker (83.722031ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (1.23847ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (42.249629ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (29.844931ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (30.706006ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (33.528891ms)
✔ a business without tracker.baseUrl gets no trackers entry (32.264963ms)
✔ an unknown business and a broken system config refuse with exit 3 (54.009647ms)
✔ empty views say so (1.298906ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (0.814006ms)
✔ tasks print the tracker fields the snapshot carries (0.126892ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (962.810981ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (234.767814ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (133.006335ms)
✔ a broker reply that misses the wait breaks the channel: the late reply answers nothing, later requests refuse, the host exits 1 (1152.351361ms)
✔ a broker reply with another request's id, or none, breaks the channel and the host exits 1 (268.524267ms)
✔ a broker reply with no request waiting breaks the channel and the host exits 1 (115.563058ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (180.902229ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (146.725849ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (106.066059ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (149.936151ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (101.206974ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (168.784935ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (22.051318ms)
✔ bus stop refuses to signal a live pid that is not a bus host (203.123482ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (206.898871ms)
✔ bus start refuses with exit 3 without a notifier config (88.448546ms)
✔ bus start runs until bus stop; status reports it while it runs (659.579674ms)
✔ bus-service.sh renders the unit and installs it into a given directory (27.585499ms)
✔ bus start --pm: the PM runs under its own PID namespace, registered, with a manifest (1380.180464ms)
✔ the PM launches a coder through its launch tool; the coder answers; refusals name their code (595.124302ms)
✔ a runner that stops at once ends its launch with the runner's reason (197.108079ms)
✖ 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.033001ms)
✖ 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 (3571.042112ms)
✔ a malformed sessions.json refuses the host start with exit 3 and stays as it was (111.741854ms)
✔ an over-long launch request is refused at once, not at the 10 s idle timeout (119.443998ms)
✔ a launch client that never closes its side doesn't hold the host's close (125.254109ms)
✔ a runner that ignores SIGTERM is killed when the host closes (1248.132557ms)
✔ zoned uses the IANA zone across DST (14.293042ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (290.6152ms)
✔ two blocking decisions get two DMs with different nonces (119.187282ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.176503ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (98.07324ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (112.432257ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (101.534598ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (95.906933ms)
✔ 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 (163.15469ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (116.407993ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (118.11592ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (83.370822ms)
✔ an inbox read failure is logged and the next poll retries (0.600193ms)
✔ no Discord id reaches the journal or the log (83.535018ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (12.333322ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (16.341221ms)
✔ the journal: a whole file that is one torn line truncates to empty (20.335716ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (2.557474ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.601419ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (1.870956ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.306264ms)
✔ 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.460335ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.357867ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.416711ms)
✔ digest content stays within Discord's 2000 characters (0.25743ms)
✔ runLoop never overlaps ticks and stops after the one in flight (111.146437ms)
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 (449.222442ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (36.80007ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2199.150483ms)
✔ busExit and refuseInsideAgent (0.395606ms)
✔ talk sends a REQUEST, prints and reads everything that arrives, and stops at the reply (396.000167ms)
✔ talk --wait 0 only sends; no reply within --wait exits 1 and says where it will show (1118.182091ms)
✔ talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors (62.336186ms)
✔ launches off and on go to the broker and change the business's launch state (61.397539ms)
✔ launches list reads sessions.json and marks a stale entry; stop reports it and refuses a non-runner (60.223823ms)
ℹ tests 83
ℹ suites 0
ℹ pass 81
ℹ fail 2
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 8067.334926
✖ 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 (636.033001ms)
AssertionError [ERR_ASSERTION]: run r9499854d6538 (pm) from an earlier host ended: host-lost
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41c/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 (3571.042112ms)
AssertionError [ERR_ASSERTION]: run r77e26813e581 (pm) from an earlier host ended: host-lost
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41c/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,138 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (129.93156ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (123.884746ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (115.843032ms)
✔ decide prints a declining choice as declining (109.117196ms)
✔ an unknown outcome is reported once and never resent (100.830598ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (114.11726ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (102.97002ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (77.223689ms)
✔ every human command refuses inside an agent run before it touches the bus (101.503502ms)
✔ usage errors exit 4; no business and no host is a usage error (106.31844ms)
✔ agents and tasks print through the broker (72.739189ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (1.675268ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (39.385037ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (32.220313ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (30.171371ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (28.720425ms)
✔ a business without tracker.baseUrl gets no trackers entry (27.442203ms)
✔ an unknown business and a broken system config refuse with exit 3 (52.59881ms)
✔ empty views say so (0.645292ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (0.874932ms)
✔ tasks print the tracker fields the snapshot carries (0.164372ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (900.189427ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (219.873017ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (171.323865ms)
✔ a broker reply that misses the wait breaks the channel: the late reply answers nothing, later requests refuse, the host exits 1 (1158.496837ms)
✔ a broker reply with another request's id, or none, breaks the channel and the host exits 1 (258.774993ms)
✔ a broker reply with no request waiting breaks the channel and the host exits 1 (158.586124ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (221.013832ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (146.920394ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (110.590833ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (149.360621ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (99.870717ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (155.494107ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (21.312045ms)
✔ bus stop refuses to signal a live pid that is not a bus host (202.47245ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (204.049139ms)
✔ bus start refuses with exit 3 without a notifier config (88.775695ms)
✔ bus start runs until bus stop; status reports it while it runs (658.132981ms)
✔ bus-service.sh renders the unit and installs it into a given directory (26.081069ms)
✖ bus start --pm: the PM runs under its own PID namespace, registered, with a manifest (254.931601ms)
✖ the PM launches a coder through its launch tool; the coder answers; refusals name their code (565.632459ms)
✔ a runner that stops at once ends its launch with the runner's reason (250.090841ms)
✔ 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 (836.404787ms)
✔ 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.783453ms)
✔ a malformed sessions.json refuses the host start with exit 3 and stays as it was (125.211009ms)
✔ an over-long launch request is refused at once, not at the 10 s idle timeout (126.870758ms)
✔ a launch client that never closes its side doesn't hold the host's close (140.855213ms)
✔ a runner that ignores SIGTERM is killed when the host closes (1228.817507ms)
✔ zoned uses the IANA zone across DST (18.394486ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (120.140473ms)
✔ two blocking decisions get two DMs with different nonces (118.392369ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.185755ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (107.742134ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (105.558334ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (112.281093ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (121.790359ms)
✔ 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 (142.212246ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (136.241889ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (130.127659ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (71.086624ms)
✔ an inbox read failure is logged and the next poll retries (0.656521ms)
✔ no Discord id reaches the journal or the log (90.37866ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (34.647012ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (34.271841ms)
✔ the journal: a whole file that is one torn line truncates to empty (12.166984ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (0.775038ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.556419ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (1.752855ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.306085ms)
✔ 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.449951ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.370948ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.443611ms)
✔ digest content stays within Discord's 2000 characters (0.297002ms)
✔ runLoop never overlaps ticks and stops after the one in flight (110.662184ms)
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.331809ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (34.570222ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2155.142118ms)
✔ busExit and refuseInsideAgent (0.487416ms)
✔ talk sends a REQUEST, prints and reads everything that arrives, and stops at the reply (209.004196ms)
✔ talk --wait 0 only sends; no reply within --wait exits 1 and says where it will show (1134.886407ms)
✔ talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors (76.368041ms)
✔ launches off and on go to the broker and change the business's launch state (93.532466ms)
✔ launches list reads sessions.json and marks a stale entry; stop reports it and refuses a non-runner (244.59335ms)
ℹ tests 83
ℹ suites 0
ℹ pass 81
ℹ fail 2
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 7267.769547
✖ 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.931601ms)
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/r41c/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/r41c/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 (565.632459ms)
AssertionError [ERR_ASSERTION]: coder
+ actual - expected
{
+ error: 'capacity-full',
- error: 'instance-running',
ok: false
}
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41c/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,113 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (110.686948ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (106.884162ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (95.564226ms)
✔ decide prints a declining choice as declining (93.308121ms)
✔ an unknown outcome is reported once and never resent (85.748876ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (111.392918ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (105.034871ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (79.484863ms)
✔ every human command refuses inside an agent run before it touches the bus (87.835327ms)
✔ usage errors exit 4; no business and no host is a usage error (61.843656ms)
✔ agents and tasks print through the broker (90.634341ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (1.22131ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (43.213645ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (31.637132ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (33.482925ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (31.454924ms)
✔ a business without tracker.baseUrl gets no trackers entry (29.454118ms)
✔ an unknown business and a broken system config refuse with exit 3 (49.114362ms)
✔ empty views say so (0.677347ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (0.829279ms)
✔ tasks print the tracker fields the snapshot carries (0.160354ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (870.011264ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (226.229412ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (143.156673ms)
✔ a broker reply that misses the wait breaks the channel: the late reply answers nothing, later requests refuse, the host exits 1 (1148.507506ms)
✔ a broker reply with another request's id, or none, breaks the channel and the host exits 1 (220.406667ms)
✔ a broker reply with no request waiting breaks the channel and the host exits 1 (114.526272ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (167.292875ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (266.663134ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (101.970192ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (151.784469ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (114.271243ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (161.599832ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (23.540049ms)
✔ bus stop refuses to signal a live pid that is not a bus host (203.054233ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (223.307199ms)
✔ bus start refuses with exit 3 without a notifier config (111.482986ms)
✔ bus start runs until bus stop; status reports it while it runs (667.080551ms)
✔ bus-service.sh renders the unit and installs it into a given directory (26.581949ms)
✔ bus start --pm: the PM runs under its own PID namespace, registered, with a manifest (247.145481ms)
✖ the PM launches a coder through its launch tool; the coder answers; refusals name their code (625.779647ms)
✔ a runner that stops at once ends its launch with the runner's reason (258.95991ms)
✔ 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 (655.037464ms)
✔ 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 (3608.481612ms)
✔ a malformed sessions.json refuses the host start with exit 3 and stays as it was (100.961775ms)
✔ an over-long launch request is refused at once, not at the 10 s idle timeout (121.835281ms)
✔ a launch client that never closes its side doesn't hold the host's close (126.622099ms)
✔ a runner that ignores SIGTERM is killed when the host closes (1246.462493ms)
✔ zoned uses the IANA zone across DST (17.863134ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (105.795241ms)
✔ two blocking decisions get two DMs with different nonces (105.323774ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.169749ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (92.153757ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (97.900989ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (95.877773ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (108.83029ms)
✔ 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 (160.561265ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (125.587856ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (110.630567ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (67.640966ms)
✔ an inbox read failure is logged and the next poll retries (0.662238ms)
✔ no Discord id reaches the journal or the log (75.613047ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (11.221849ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (34.184143ms)
✔ the journal: a whole file that is one torn line truncates to empty (27.674215ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (3.778543ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.554957ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (1.878846ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.298944ms)
✔ 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.470225ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.336303ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.433947ms)
✔ digest content stays within Discord's 2000 characters (0.259131ms)
✔ runLoop never overlaps ticks and stops after the one in flight (110.633584ms)
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 (352.423936ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (32.127121ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2130.283949ms)
✔ busExit and refuseInsideAgent (0.423043ms)
✔ talk sends a REQUEST, prints and reads everything that arrives, and stops at the reply (197.999404ms)
✔ talk --wait 0 only sends; no reply within --wait exits 1 and says where it will show (1120.766392ms)
✔ talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors (70.7995ms)
✔ launches off and on go to the broker and change the business's launch state (53.704552ms)
✔ launches list reads sessions.json and marks a stale entry; stop reports it and refuses a non-runner (58.667476ms)
ℹ tests 83
ℹ suites 0
ℹ pass 82
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 7075.234251
✖ 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 (625.779647ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
true !== false
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41c/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,86 @@
✔ sessionModel: agent vars win, then the system's execution settings (12.610037ms)
✔ a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory (4.425938ms)
✖ a claude-code bundle adds the wrapped gate hook and the MCP config (3.569669ms)
✔ a bundle is written once: an existing file refuses (2.483043ms)
✔ a path with a single quote can't go into the hook command (2.194773ms)
✔ allow exits 0, a deny exits 2 with the reason on stderr (134.321564ms)
✔ a missing or wrong policy, or a bad event, exits 2 (94.191866ms)
✔ the bundle's wrapped command: a missing gate or node still blocks (1102.478386ms)
✔ claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist (861.169402ms)
✔ claude: the hook alone blocks a path outside the workspace (463.346559ms)
✔ claude: a second turn resumes the first turn's session (699.909671ms)
✔ claude adapter: --restricted is always passed (5.009911ms)
✔ claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do (731.002227ms)
✔ claude: a missing hook or MCP file refuses before claude starts (7.483698ms)
✔ pi: policy tools and typed tools pass, anything else is blocked (3.524415ms)
✔ claude: builtins map from pi names, typed tools need the mcp prefix (0.859553ms)
✔ file tool paths must resolve inside the workspace (1.61983ms)
✔ pi's own path normalisation can't be used to step out (1.381501ms)
✔ a symlink inside the workspace that points out is outside (1.216654ms)
✔ a dangling symlink is refused at any depth, in both harnesses (4.524108ms)
✔ read is checked under every spelling pi's read would open, in both harnesses (20.186307ms)
✔ other spellings cover directories, dangling links and pi's cwd (2.49432ms)
✔ claude path fields per tool (0.895082ms)
✔ glob patterns stay inside the workspace (0.957675ms)
✔ a path that can't be checked is blocked (0.481683ms)
✔ initialize, ping and tools/list (57.350848ms)
✔ tools/call goes through the tool socket; a refusal is an isError result (37.26344ms)
✔ unknown tools and methods are JSON-RPC errors and never reach the socket (35.653847ms)
✔ a missing argument is a usage error (28.896466ms)
✔ pi: typed tools reach the socket, the gate blocks, agent_end writes the marker (418.424476ms)
✔ pi: a write through a dangling symlink is blocked, and nothing appears outside (379.103331ms)
✔ pi: a read is refused when pi would open another spelling outside (337.38634ms)
✔ pi: a missing extension refuses before any model call (7.295878ms)
✔ pi: an extension without its configuration fails pi's start (259.256401ms)
✔ founderCheck: founder variables, then a needed service without a usable token (1.638607ms)
✔ turnRequest names the sender, class, reply and decision (0.460845ms)
✔ a message becomes a turn, the answer goes back as a RESULT, SIGTERM releases and exits 0 (259.921518ms)
✔ a SIGTERM before the claim stops the runner with exit 0 and no claim (129.664413ms)
✔ typed tools carry the runner's capability; launch goes to the host's launch socket (431.771978ms)
✔ a RESULT gets no automatic reply; failed turns reply with the reason (1312.831889ms)
✔ SIGTERM during a turn kills the turn's process group and still exits 0 (165.959867ms)
✔ founder credentials stop before the claim (20) (174.448279ms)
✔ a refused claim exits 21; an ended run's capability exits 22 (195.01728ms)
✔ the launch ending under a running session exits 22 (149.181744ms)
✔ a broker that stays unreachable exits 23 after brokerRetries polls (245.012982ms)
✔ a broker that is down at the claim exits 23, not 21 (88.047572ms)
✔ no capability, or a malformed one, on stdin exits 2 (184.432872ms)
✔ a missing or malformed policy exits 2 before the claim (129.019332ms)
✔ the PM gets launch, its task verbs and the reads (8.679382ms)
✔ a coder gets no launch, no resolve_decision, and no task tools without a tracker (2.972469ms)
✔ launch only when the business's launch block names the instance as launcher (2.254721ms)
✔ an action outside the instance's authority has no tool (2.314311ms)
✔ callTool: one JSON line out, the result back, a refusal rejects (10.564592ms)
ℹ tests 53
ℹ suites 0
ℹ pass 52
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 10442.013918
✖ 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.569669ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
+ actual - expected
+ "timeout -k 2 10 '/usr/bin/node' '/home/jwoltje/darkwing-scratch/r41c/wt/packages/harness/src/claude-gate.mjs' '/home/jwoltje/darkwing-scratch/tmp/mosaic-harness-A4Asfi/bundle/policy.json'"
- "timeout -k 2 10 '/usr/bin/node' '/home/jwoltje/darkwing-scratch/r41c/wt/packages/harness/src/claude-gate.mjs' '/home/jwoltje/darkwing-scratch/tmp/mosaic-harness-A4Asfi/bundle/policy.json' || exit 2"
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41c/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/r41c/wt/packages/harness/src/claude-gate.mjs' '/home/jwoltje/darkwing-scratch/tmp/mosaic-harness-A4Asfi/bundle/policy.json'",
expected: "timeout -k 2 10 '/usr/bin/node' '/home/jwoltje/darkwing-scratch/r41c/wt/packages/harness/src/claude-gate.mjs' '/home/jwoltje/darkwing-scratch/tmp/mosaic-harness-A4Asfi/bundle/policy.json' || exit 2",
operator: 'strictEqual',
diff: 'simple'
}
@@ -0,0 +1,93 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (136.02924ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (132.966972ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (119.457874ms)
✔ decide prints a declining choice as declining (130.32497ms)
✔ an unknown outcome is reported once and never resent (101.355712ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (90.329289ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (93.008445ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (85.138662ms)
✔ every human command refuses inside an agent run before it touches the bus (78.813706ms)
✔ usage errors exit 4; no business and no host is a usage error (77.799996ms)
✔ agents and tasks print through the broker (80.290372ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (1.201216ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (46.429049ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (33.390555ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (31.116965ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (31.667236ms)
✔ a business without tracker.baseUrl gets no trackers entry (31.072837ms)
✔ an unknown business and a broken system config refuse with exit 3 (51.43796ms)
✔ empty views say so (0.788334ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (0.842352ms)
✔ tasks print the tracker fields the snapshot carries (0.151138ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (918.644102ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (227.493155ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (141.738131ms)
✔ a broker reply that misses the wait breaks the channel: the late reply answers nothing, later requests refuse, the host exits 1 (1143.209858ms)
✔ a broker reply with another request's id, or none, breaks the channel and the host exits 1 (227.331454ms)
✔ a broker reply with no request waiting breaks the channel and the host exits 1 (111.262419ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (161.185092ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (152.757124ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (102.147396ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (148.695414ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (106.338995ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (161.913554ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (22.115732ms)
✔ bus stop refuses to signal a live pid that is not a bus host (202.001141ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (211.547824ms)
✔ bus start refuses with exit 3 without a notifier config (87.064124ms)
✔ bus start runs until bus stop; status reports it while it runs (658.300972ms)
✔ bus-service.sh renders the unit and installs it into a given directory (27.936662ms)
✔ bus start --pm: the PM runs under its own PID namespace, registered, with a manifest (298.245484ms)
✔ the PM launches a coder through its launch tool; the coder answers; refusals name their code (665.262206ms)
✔ a runner that stops at once ends its launch with the runner's reason (235.6999ms)
✔ 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 (660.765268ms)
✔ 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 (3579.756921ms)
✔ a malformed sessions.json refuses the host start with exit 3 and stays as it was (111.070392ms)
✔ an over-long launch request is refused at once, not at the 10 s idle timeout (121.421911ms)
✔ a launch client that never closes its side doesn't hold the host's close (122.58027ms)
✔ a runner that ignores SIGTERM is killed when the host closes (1238.855638ms)
✔ zoned uses the IANA zone across DST (22.164907ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (141.365538ms)
✔ two blocking decisions get two DMs with different nonces (126.774437ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.170395ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (113.15991ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (122.62483ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (110.402208ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (92.432026ms)
✔ 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 (142.723337ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (124.596445ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (111.957527ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (81.387632ms)
✔ an inbox read failure is logged and the next poll retries (0.632008ms)
✔ no Discord id reaches the journal or the log (69.92432ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (17.977804ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (37.617959ms)
✔ the journal: a whole file that is one torn line truncates to empty (17.168561ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (2.301834ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.577697ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (1.847792ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.321022ms)
✔ 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.472741ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.352923ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.419505ms)
✔ digest content stays within Discord's 2000 characters (0.249459ms)
✔ runLoop never overlaps ticks and stops after the one in flight (111.209763ms)
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 (391.009885ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (35.007297ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2150.619757ms)
✔ busExit and refuseInsideAgent (0.446199ms)
✔ talk sends a REQUEST, prints and reads everything that arrives, and stops at the reply (236.272608ms)
✔ talk --wait 0 only sends; no reply within --wait exits 1 and says where it will show (1138.287074ms)
✔ talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors (72.243845ms)
✔ launches off and on go to the broker and change the business's launch state (70.005017ms)
✔ launches list reads sessions.json and marks a stale entry; stop reports it and refuses a non-runner (49.371667ms)
ℹ tests 83
ℹ suites 0
ℹ pass 83
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 7126.804868
@@ -0,0 +1,97 @@
✔ sessionModel: agent vars win, then the system's execution settings (11.177806ms)
✔ a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory (5.252937ms)
✔ a claude-code bundle adds the wrapped gate hook and the MCP config (2.918977ms)
✔ a bundle is written once: an existing file refuses (2.667274ms)
✔ a path with a single quote can't go into the hook command (2.474121ms)
✔ allow exits 0, a deny exits 2 with the reason on stderr (149.860923ms)
✔ a missing or wrong policy, or a bad event, exits 2 (80.513977ms)
✔ the bundle's wrapped command: a missing gate or node still blocks (1087.944389ms)
✔ claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist (756.080075ms)
✔ claude: the hook alone blocks a path outside the workspace (468.03756ms)
✔ claude: a second turn resumes the first turn's session (686.572807ms)
✔ claude adapter: --restricted is always passed (5.208836ms)
✔ claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do (712.744346ms)
✔ claude: a missing hook or MCP file refuses before claude starts (7.437396ms)
✔ pi: policy tools and typed tools pass, anything else is blocked (3.76221ms)
✔ claude: builtins map from pi names, typed tools need the mcp prefix (0.832287ms)
✔ file tool paths must resolve inside the workspace (1.253709ms)
✔ pi's own path normalisation can't be used to step out (1.179842ms)
✔ a symlink inside the workspace that points out is outside (1.019438ms)
✔ a dangling symlink is refused at any depth, in both harnesses (2.701157ms)
✔ read is checked under every spelling pi's read would open, in both harnesses (29.193137ms)
✔ other spellings cover directories, dangling links and pi's cwd (7.676756ms)
✔ claude path fields per tool (0.905772ms)
✔ glob patterns stay inside the workspace (0.978329ms)
✔ a path that can't be checked is blocked (0.637371ms)
✔ initialize, ping and tools/list (54.963032ms)
✔ tools/call goes through the tool socket; a refusal is an isError result (40.53307ms)
✔ unknown tools and methods are JSON-RPC errors and never reach the socket (29.9518ms)
✔ a missing argument is a usage error (26.739266ms)
✔ pi: typed tools reach the socket, the gate blocks, agent_end writes the marker (369.845735ms)
✔ pi: a write through a dangling symlink is blocked, and nothing appears outside (305.775115ms)
✔ pi: a read is refused when pi would open another spelling outside (321.07066ms)
✔ pi: a missing extension refuses before any model call (7.216682ms)
✔ pi: an extension without its configuration fails pi's start (262.950669ms)
✖ founderCheck: founder variables, then a needed service without a usable token (1.768828ms)
✔ turnRequest names the sender, class, reply and decision (0.255406ms)
✔ a message becomes a turn, the answer goes back as a RESULT, SIGTERM releases and exits 0 (254.803001ms)
✔ a SIGTERM before the claim stops the runner with exit 0 and no claim (106.271699ms)
✔ typed tools carry the runner's capability; launch goes to the host's launch socket (409.851372ms)
✔ a RESULT gets no automatic reply; failed turns reply with the reason (1300.337486ms)
✔ SIGTERM during a turn kills the turn's process group and still exits 0 (151.104932ms)
✖ founder credentials stop before the claim (20) (60055.438549ms)
✔ a refused claim exits 21; an ended run's capability exits 22 (205.220258ms)
✔ the launch ending under a running session exits 22 (137.168395ms)
✔ a broker that stays unreachable exits 23 after brokerRetries polls (240.044539ms)
✔ a broker that is down at the claim exits 23, not 21 (103.647221ms)
✔ no capability, or a malformed one, on stdin exits 2 (194.450239ms)
✔ a missing or malformed policy exits 2 before the claim (151.976732ms)
✔ the PM gets launch, its task verbs and the reads (9.204315ms)
✔ a coder gets no launch, no resolve_decision, and no task tools without a tracker (3.319111ms)
✔ launch only when the business's launch block names the instance as launcher (2.395528ms)
✔ an action outside the instance's authority has no tool (2.487739ms)
✔ callTool: one JSON line out, the result back, a refusal rejects (11.73562ms)
ℹ tests 53
ℹ suites 0
ℹ pass 51
ℹ fail 2
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 63410.518602
✖ failing tests:
test at packages/harness/tests/runner.test.mjs:34:1
✖ founderCheck: founder variables, then a needed service without a usable token (1.768828ms)
AssertionError [ERR_ASSERTION]: The "string" argument must be of type string. Received type object (null)
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41c/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) (60055.438549ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
null !== 20
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41c/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,102 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (118.041998ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (131.48971ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (105.398857ms)
✔ decide prints a declining choice as declining (113.679832ms)
✔ an unknown outcome is reported once and never resent (103.463596ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (101.546322ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (117.043323ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (94.753147ms)
✔ every human command refuses inside an agent run before it touches the bus (168.32314ms)
✔ usage errors exit 4; no business and no host is a usage error (116.968773ms)
✔ agents and tasks print through the broker (114.772476ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (3.031321ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (58.653922ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (37.003735ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (36.75054ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (38.472211ms)
✔ a business without tracker.baseUrl gets no trackers entry (31.429728ms)
✔ an unknown business and a broken system config refuse with exit 3 (61.848541ms)
✔ empty views say so (0.773262ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (1.001536ms)
✔ tasks print the tracker fields the snapshot carries (0.168093ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (916.629745ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (282.431038ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (151.459267ms)
✔ a broker reply that misses the wait breaks the channel: the late reply answers nothing, later requests refuse, the host exits 1 (1298.740463ms)
✔ a broker reply with another request's id, or none, breaks the channel and the host exits 1 (267.882667ms)
✔ a broker reply with no request waiting breaks the channel and the host exits 1 (118.937925ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (163.746589ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (162.737307ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (100.238564ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (147.397721ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (99.55411ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (158.126607ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (22.58234ms)
✔ bus stop refuses to signal a live pid that is not a bus host (202.873188ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (216.76327ms)
✔ bus start refuses with exit 3 without a notifier config (89.840034ms)
✔ bus start runs until bus stop; status reports it while it runs (663.801122ms)
✔ bus-service.sh renders the unit and installs it into a given directory (26.911461ms)
✔ bus start --pm: the PM runs under its own PID namespace, registered, with a manifest (1274.104991ms)
✔ the PM launches a coder through its launch tool; the coder answers; refusals name their code (641.495076ms)
✔ a runner that stops at once ends its launch with the runner's reason (193.157972ms)
✔ 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 (808.645751ms)
✔ 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 (3805.213621ms)
✔ a malformed sessions.json refuses the host start with exit 3 and stays as it was (103.523055ms)
✔ an over-long launch request is refused at once, not at the 10 s idle timeout (116.463798ms)
✔ a launch client that never closes its side doesn't hold the host's close (116.200463ms)
✖ a runner that ignores SIGTERM is killed when the host closes (15219.09123ms)
✔ zoned uses the IANA zone across DST (25.789076ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (117.526082ms)
✔ two blocking decisions get two DMs with different nonces (119.271966ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.21734ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (86.435325ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (99.237842ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (102.191522ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (97.68472ms)
✔ 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 (166.19917ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (152.651257ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (187.218066ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (112.051392ms)
✔ an inbox read failure is logged and the next poll retries (0.655555ms)
✔ no Discord id reaches the journal or the log (106.117268ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (22.013075ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (50.417528ms)
✔ the journal: a whole file that is one torn line truncates to empty (29.165771ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (2.409732ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.584473ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (1.741691ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.303514ms)
✔ 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.453615ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.350523ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.414634ms)
✔ digest content stays within Discord's 2000 characters (0.249579ms)
✔ runLoop never overlaps ticks and stops after the one in flight (110.994649ms)
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 (385.255221ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (44.005372ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2163.602099ms)
✔ busExit and refuseInsideAgent (0.399287ms)
✔ talk sends a REQUEST, prints and reads everything that arrives, and stops at the reply (205.64473ms)
✔ talk --wait 0 only sends; no reply within --wait exits 1 and says where it will show (1125.955523ms)
✔ talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors (115.161917ms)
✔ launches off and on go to the broker and change the business's launch state (95.270653ms)
✔ launches list reads sessions.json and marks a stale entry; stop reports it and refuses a non-runner (69.6019ms)
ℹ tests 83
ℹ suites 0
ℹ pass 82
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 22364.750197
✖ failing tests:
test at packages/cli/tests/launcher.test.mjs:369:1
✖ a runner that ignores SIGTERM is killed when the host closes (15219.09123ms)
Error: close took over 15000 ms
at Timeout._onTimeout (file:///home/jwoltje/darkwing-scratch/r41c/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,115 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (106.922712ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (127.453207ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (100.792147ms)
✔ decide prints a declining choice as declining (90.547205ms)
✔ an unknown outcome is reported once and never resent (77.914315ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (74.808302ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (73.514331ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (50.730691ms)
✔ every human command refuses inside an agent run before it touches the bus (57.950143ms)
✔ usage errors exit 4; no business and no host is a usage error (72.623963ms)
✔ agents and tasks print through the broker (70.264188ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (1.760366ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (57.164692ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (43.867609ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (44.641985ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (35.056691ms)
✔ a business without tracker.baseUrl gets no trackers entry (41.701591ms)
✔ an unknown business and a broken system config refuse with exit 3 (87.619271ms)
✔ empty views say so (1.207053ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (1.53345ms)
✔ tasks print the tracker fields the snapshot carries (0.247669ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (951.824825ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (222.425802ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (131.366495ms)
✔ a broker reply that misses the wait breaks the channel: the late reply answers nothing, later requests refuse, the host exits 1 (1192.983731ms)
✔ a broker reply with another request's id, or none, breaks the channel and the host exits 1 (216.583457ms)
✔ a broker reply with no request waiting breaks the channel and the host exits 1 (111.043471ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (166.205089ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (154.807489ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (105.531871ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (156.826593ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (113.11359ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (166.141468ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (24.89589ms)
✔ bus stop refuses to signal a live pid that is not a bus host (203.758946ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (218.040413ms)
✔ bus start refuses with exit 3 without a notifier config (91.472452ms)
✔ bus start runs until bus stop; status reports it while it runs (658.450829ms)
✔ bus-service.sh renders the unit and installs it into a given directory (29.341383ms)
✔ bus start --pm: the PM runs under its own PID namespace, registered, with a manifest (1297.970325ms)
✔ the PM launches a coder through its launch tool; the coder answers; refusals name their code (626.374663ms)
✔ a runner that stops at once ends its launch with the runner's reason (194.610384ms)
✖ 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 (5418.855495ms)
✔ 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 (3579.200989ms)
✔ a malformed sessions.json refuses the host start with exit 3 and stays as it was (100.682148ms)
✔ an over-long launch request is refused at once, not at the 10 s idle timeout (113.819595ms)
✔ a launch client that never closes its side doesn't hold the host's close (118.474258ms)
✔ a runner that ignores SIGTERM is killed when the host closes (1220.180984ms)
✔ zoned uses the IANA zone across DST (22.285939ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (112.820655ms)
✔ two blocking decisions get two DMs with different nonces (113.812033ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.333902ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (106.433816ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (93.823646ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (79.798724ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (69.07935ms)
✔ 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.693102ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (113.252853ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (87.064619ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (51.654076ms)
✔ an inbox read failure is logged and the next poll retries (0.698838ms)
✔ no Discord id reaches the journal or the log (52.085441ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (10.582447ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (28.364707ms)
✔ the journal: a whole file that is one torn line truncates to empty (17.507019ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (1.661067ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.840219ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (2.577946ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.403361ms)
✔ 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.628308ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.421861ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.512618ms)
✔ digest content stays within Discord's 2000 characters (0.313904ms)
✔ runLoop never overlaps ticks and stops after the one in flight (110.272496ms)
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 (412.633737ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (45.45632ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2190.732641ms)
✔ busExit and refuseInsideAgent (0.51342ms)
✔ talk sends a REQUEST, prints and reads everything that arrives, and stops at the reply (197.19843ms)
✔ talk --wait 0 only sends; no reply within --wait exits 1 and says where it will show (1110.244488ms)
✔ talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors (57.906341ms)
✔ launches off and on go to the broker and change the business's launch state (118.525037ms)
✔ launches list reads sessions.json and marks a stale entry; stop reports it and refuses a non-runner (61.227434ms)
ℹ tests 83
ℹ suites 0
ℹ pass 82
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 12781.601532
✖ 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 (5418.855495ms)
AssertionError [ERR_ASSERTION]: the old session process is gone
+ actual - expected
+ '293672659'
- null
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41c/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: '293672659',
expected: null,
operator: 'strictEqual',
diff: 'simple'
}
@@ -0,0 +1,82 @@
✔ sessionModel: agent vars win, then the system's execution settings (10.806166ms)
✔ a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory (5.589457ms)
✔ a claude-code bundle adds the wrapped gate hook and the MCP config (3.22644ms)
✔ a bundle is written once: an existing file refuses (2.363335ms)
✔ a path with a single quote can't go into the hook command (2.224508ms)
✔ allow exits 0, a deny exits 2 with the reason on stderr (122.076692ms)
✔ a missing or wrong policy, or a bad event, exits 2 (83.496256ms)
✔ the bundle's wrapped command: a missing gate or node still blocks (1102.964932ms)
✔ claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist (749.26968ms)
✔ claude: the hook alone blocks a path outside the workspace (553.235913ms)
✔ claude: a second turn resumes the first turn's session (749.117347ms)
✔ claude adapter: --restricted is always passed (5.035892ms)
✔ claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do (747.57096ms)
✔ claude: a missing hook or MCP file refuses before claude starts (7.276715ms)
✔ pi: policy tools and typed tools pass, anything else is blocked (2.883895ms)
✔ claude: builtins map from pi names, typed tools need the mcp prefix (0.67454ms)
✔ file tool paths must resolve inside the workspace (0.923096ms)
✔ pi's own path normalisation can't be used to step out (0.878111ms)
✔ a symlink inside the workspace that points out is outside (0.713036ms)
✔ a dangling symlink is refused at any depth, in both harnesses (2.265306ms)
✔ read is checked under every spelling pi's read would open, in both harnesses (12.755782ms)
✔ other spellings cover directories, dangling links and pi's cwd (2.289035ms)
✔ claude path fields per tool (0.889096ms)
✔ glob patterns stay inside the workspace (0.983727ms)
✔ a path that can't be checked is blocked (0.418088ms)
✔ initialize, ping and tools/list (42.646925ms)
✔ tools/call goes through the tool socket; a refusal is an isError result (32.786393ms)
✔ unknown tools and methods are JSON-RPC errors and never reach the socket (35.510704ms)
✔ a missing argument is a usage error (30.037917ms)
✔ pi: typed tools reach the socket, the gate blocks, agent_end writes the marker (370.391087ms)
✔ pi: a write through a dangling symlink is blocked, and nothing appears outside (333.500162ms)
✔ pi: a read is refused when pi would open another spelling outside (361.491201ms)
✔ pi: a missing extension refuses before any model call (8.198461ms)
✔ pi: an extension without its configuration fails pi's start (333.092688ms)
✔ founderCheck: founder variables, then a needed service without a usable token (1.232397ms)
✔ turnRequest names the sender, class, reply and decision (0.177879ms)
✔ a message becomes a turn, the answer goes back as a RESULT, SIGTERM releases and exits 0 (249.610514ms)
✔ a SIGTERM before the claim stops the runner with exit 0 and no claim (112.946053ms)
✔ typed tools carry the runner's capability; launch goes to the host's launch socket (416.29377ms)
✔ a RESULT gets no automatic reply; failed turns reply with the reason (1374.427902ms)
✔ SIGTERM during a turn kills the turn's process group and still exits 0 (172.084448ms)
✔ founder credentials stop before the claim (20) (172.24744ms)
✔ a refused claim exits 21; an ended run's capability exits 22 (191.294639ms)
✔ the launch ending under a running session exits 22 (143.637834ms)
✔ a broker that stays unreachable exits 23 after brokerRetries polls (246.434944ms)
✔ a broker that is down at the claim exits 23, not 21 (88.931511ms)
✖ no capability, or a malformed one, on stdin exits 2 (60159.927178ms)
✔ a missing or malformed policy exits 2 before the claim (150.006936ms)
✔ the PM gets launch, its task verbs and the reads (8.859602ms)
✔ a coder gets no launch, no resolve_decision, and no task tools without a tracker (3.132664ms)
✔ launch only when the business's launch block names the instance as launcher (2.095966ms)
✔ an action outside the instance's authority has no tool (2.835666ms)
✔ callTool: one JSON line out, the result back, a refusal rejects (8.09316ms)
ℹ tests 53
ℹ suites 0
ℹ pass 52
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 63573.672105
✖ failing tests:
test at packages/harness/tests/runner.test.mjs:313:1
✖ no capability, or a malformed one, on stdin exits 2 (60159.927178ms)
AssertionError [ERR_ASSERTION]: runner: demo/coder claimed by run coder-run
null !== 2
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41c/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,102 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (118.780918ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (123.076137ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (100.463479ms)
✔ decide prints a declining choice as declining (106.312948ms)
✔ an unknown outcome is reported once and never resent (80.512806ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (98.603993ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (82.154826ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (64.674468ms)
✔ every human command refuses inside an agent run before it touches the bus (86.504502ms)
✔ usage errors exit 4; no business and no host is a usage error (106.585889ms)
✔ agents and tasks print through the broker (79.843314ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (1.563511ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (47.033594ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (35.560285ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (37.829148ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (34.228994ms)
✔ a business without tracker.baseUrl gets no trackers entry (27.989445ms)
✔ an unknown business and a broken system config refuse with exit 3 (59.357376ms)
✔ empty views say so (1.003416ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (1.268413ms)
✔ tasks print the tracker fields the snapshot carries (0.192113ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (897.887185ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (231.829637ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (123.871284ms)
✔ a broker reply that misses the wait breaks the channel: the late reply answers nothing, later requests refuse, the host exits 1 (1152.416722ms)
✔ a broker reply with another request's id, or none, breaks the channel and the host exits 1 (233.027102ms)
✔ a broker reply with no request waiting breaks the channel and the host exits 1 (117.315903ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (164.653757ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (151.64516ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (105.118583ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (146.765951ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (107.101812ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (163.429516ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (23.140623ms)
✔ bus stop refuses to signal a live pid that is not a bus host (202.866959ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (208.591307ms)
✔ bus start refuses with exit 3 without a notifier config (85.851102ms)
✔ bus start runs until bus stop; status reports it while it runs (655.59803ms)
✔ bus-service.sh renders the unit and installs it into a given directory (26.938059ms)
✔ bus start --pm: the PM runs under its own PID namespace, registered, with a manifest (276.144899ms)
✔ the PM launches a coder through its launch tool; the coder answers; refusals name their code (660.381461ms)
✔ a runner that stops at once ends its launch with the runner's reason (247.131579ms)
✔ 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 (726.717899ms)
✔ 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 (3608.255401ms)
✔ a malformed sessions.json refuses the host start with exit 3 and stays as it was (103.392709ms)
✖ an over-long launch request is refused at once, not at the 10 s idle timeout (3123.508123ms)
✔ a launch client that never closes its side doesn't hold the host's close (122.848856ms)
✔ a runner that ignores SIGTERM is killed when the host closes (1227.396355ms)
✔ zoned uses the IANA zone across DST (25.44643ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (123.134657ms)
✔ two blocking decisions get two DMs with different nonces (116.563319ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.179118ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (107.062052ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (100.127376ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (90.695894ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (93.29856ms)
✔ 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 (130.339884ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (120.291521ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (110.318126ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (89.460537ms)
✔ an inbox read failure is logged and the next poll retries (0.787951ms)
✔ no Discord id reaches the journal or the log (64.874891ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (11.598635ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (25.355305ms)
✔ the journal: a whole file that is one torn line truncates to empty (13.765272ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (0.789483ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.703771ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (2.84146ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.527478ms)
✔ 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.790389ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.474219ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.568551ms)
✔ digest content stays within Discord's 2000 characters (0.303117ms)
✔ runLoop never overlaps ticks and stops after the one in flight (111.718683ms)
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 (365.892636ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (41.142536ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2155.789347ms)
✔ busExit and refuseInsideAgent (0.425706ms)
✔ talk sends a REQUEST, prints and reads everything that arrives, and stops at the reply (194.548365ms)
✔ talk --wait 0 only sends; no reply within --wait exits 1 and says where it will show (1104.428525ms)
✔ talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors (85.842231ms)
✔ launches off and on go to the broker and change the business's launch state (61.780226ms)
✔ launches list reads sessions.json and marks a stale entry; stop reports it and refuses a non-runner (37.627993ms)
ℹ tests 83
ℹ suites 0
ℹ pass 82
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 10175.91753
✖ 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 (3123.508123ms)
Error: the refusal took over 3000 ms
at Timeout._onTimeout (file:///home/jwoltje/darkwing-scratch/r41c/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,85 @@
✔ sessionModel: agent vars win, then the system's execution settings (11.36832ms)
✔ a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory (4.205659ms)
✔ a claude-code bundle adds the wrapped gate hook and the MCP config (2.452236ms)
✔ a bundle is written once: an existing file refuses (2.530905ms)
✔ a path with a single quote can't go into the hook command (1.901541ms)
✔ allow exits 0, a deny exits 2 with the reason on stderr (111.927229ms)
✔ a missing or wrong policy, or a bad event, exits 2 (81.333733ms)
✔ the bundle's wrapped command: a missing gate or node still blocks (1090.175498ms)
✔ claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist (788.043408ms)
✔ claude: the hook alone blocks a path outside the workspace (472.003038ms)
✔ claude: a second turn resumes the first turn's session (826.243142ms)
✔ claude adapter: --restricted is always passed (6.698491ms)
✔ claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do (786.753172ms)
✔ claude: a missing hook or MCP file refuses before claude starts (8.102132ms)
✔ pi: policy tools and typed tools pass, anything else is blocked (3.244199ms)
✔ claude: builtins map from pi names, typed tools need the mcp prefix (0.890437ms)
✔ file tool paths must resolve inside the workspace (1.14687ms)
✔ pi's own path normalisation can't be used to step out (0.964962ms)
✔ a symlink inside the workspace that points out is outside (0.822858ms)
✔ a dangling symlink is refused at any depth, in both harnesses (2.43826ms)
✔ read is checked under every spelling pi's read would open, in both harnesses (16.468121ms)
✔ other spellings cover directories, dangling links and pi's cwd (1.990483ms)
✔ claude path fields per tool (0.565463ms)
✖ glob patterns stay inside the workspace (1.294802ms)
✔ a path that can't be checked is blocked (0.393672ms)
✔ initialize, ping and tools/list (45.752355ms)
✔ tools/call goes through the tool socket; a refusal is an isError result (31.151707ms)
✔ unknown tools and methods are JSON-RPC errors and never reach the socket (28.389758ms)
✔ a missing argument is a usage error (28.131437ms)
✔ pi: typed tools reach the socket, the gate blocks, agent_end writes the marker (364.598251ms)
✔ pi: a write through a dangling symlink is blocked, and nothing appears outside (358.802531ms)
✔ pi: a read is refused when pi would open another spelling outside (338.681085ms)
✔ pi: a missing extension refuses before any model call (6.860284ms)
✔ pi: an extension without its configuration fails pi's start (261.93823ms)
✔ founderCheck: founder variables, then a needed service without a usable token (1.118099ms)
✔ turnRequest names the sender, class, reply and decision (0.258109ms)
✔ a message becomes a turn, the answer goes back as a RESULT, SIGTERM releases and exits 0 (234.477454ms)
✔ a SIGTERM before the claim stops the runner with exit 0 and no claim (115.414823ms)
✔ typed tools carry the runner's capability; launch goes to the host's launch socket (426.109041ms)
✔ a RESULT gets no automatic reply; failed turns reply with the reason (1298.43309ms)
✔ SIGTERM during a turn kills the turn's process group and still exits 0 (157.45163ms)
✔ founder credentials stop before the claim (20) (199.201503ms)
✔ a refused claim exits 21; an ended run's capability exits 22 (217.716212ms)
✔ the launch ending under a running session exits 22 (150.417351ms)
✔ a broker that stays unreachable exits 23 after brokerRetries polls (257.340121ms)
✔ a broker that is down at the claim exits 23, not 21 (85.225358ms)
✔ no capability, or a malformed one, on stdin exits 2 (193.755794ms)
✔ a missing or malformed policy exits 2 before the claim (130.745018ms)
✔ the PM gets launch, its task verbs and the reads (6.945692ms)
✔ a coder gets no launch, no resolve_decision, and no task tools without a tracker (1.982143ms)
✔ launch only when the business's launch block names the instance as launcher (1.498125ms)
✔ an action outside the instance's authority has no tool (1.658447ms)
✔ callTool: one JSON line out, the result back, a refusal rejects (8.400448ms)
ℹ tests 53
ℹ suites 0
ℹ pass 52
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 10344.683194
✖ failing tests:
test at packages/harness/tests/gate.test.mjs:171:1
✖ glob patterns stay inside the workspace (1.294802ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
true !== false
at blocked (file:///home/jwoltje/darkwing-scratch/r41c/wt/packages/harness/tests/gate.test.mjs:19:10)
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41c/wt/packages/harness/tests/gate.test.mjs:177: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 (145.699342ms)
✔ decision classes route from policy; gated resolution is human-only, choice and target must match (246.753955ms)
✔ claim exclusion, holder release, gated revoke and rerouting to a new holder are atomic (242.099071ms)
✔ launch events require a human CLI capability; generic emit cannot forge authority events (154.800709ms)
✔ within-role decisions close atomically and invalid options or blocking omissions refuse (152.866183ms)
✔ observer capabilities read human inbox but cannot mutate or forge launch identity (143.13816ms)
✔ task action subjects and linked decision trail are complete and ordered (272.254567ms)
✔ launch binding is durable and reconnecting requires the identical trusted record (111.423704ms)
✔ business isolation includes inherited object names and cross-business message references (219.984118ms)
✔ authority never transfers between action, run, target, unresolved or replaced role holder (435.026479ms)
✔ task projection uses schema current view, skipping earlier and equal-start polls (125.65938ms)
✔ revocation permanently bars the old run from reclaiming first, including after broker restart (204.075537ms)
✔ empty message references refuse before storage; refusal-evidence failure stays a typed error (113.966122ms)
✔ both arbiters require human resolution when their cross-role route is themselves (183.453882ms)
✔ S1 adapter takes resolved limits and refs, rejects mismatched instance, never mutates input (2.308315ms)
✔ only validated broker references load; returned data and exceptions cannot expose a known token (5.770669ms)
✔ bad file modes, symlinks, repository/data paths, malformed tokens and missing dates refuse (2.834462ms)
✔ expiry refuses use and env references never become client data (0.858583ms)
✔ S1 parsed service refs work, service mismatch refuses, Gitea rotation due is a warning state (1.94638ms)
✔ opaque tokens shorter than 16 characters refuse before use (0.59084ms)
✔ endLaunch writes session.ended, releases the run claim and kills its capabilities (116.677841ms)
✔ endLaunch refuses an unknown run, a second end and a rebind of the ended run (131.158771ms)
✔ a restarted broker refuses to rebind an ended run; a refused rebind leaves the run unbound (147.524656ms)
✔ endLaunch leaves a claim another run took alone (161.634957ms)
✔ refuse records action.refused against the caller with the code only (125.876016ms)
✖ launches off refuses role.launch with launch-revoked until launches on (142.300893ms)
✔ broker process: launch ops authorize role.launch, record refusals and end runs (218.862179ms)
✔ human proof binds CLI entry, process start and nonce; agents and incomplete ancestry refuse (4.607107ms)
✔ process reader gets own kernel identity without exposing environment values (0.740531ms)
✔ EACCES ancestor environments skip only markers; commands and registered launches still refuse (1.032764ms)
✔ real pid 1 remains inspectable when its environment is protected (0.405112ms)
✔ within-role sends cite an open gated launch decision without spending it or naming it in grants (184.174358ms)
✔ missing and foreign-business citations refuse and roll back message and grant (169.894038ms)
✔ cross-role sends still need a matching resolved decision and consume it once (226.260541ms)
✔ broker process binds trusted launches, offers reader capabilities, refuses human mutation, closes cleanly (169.671805ms)
✔ startup token refusal returns safe code without value or partial listening broker (37.78021ms)
✔ loaded fixture token is absent from socket replies and SQLite, including refusal evidence (175.279268ms)
✔ killed broker leaves an explicit stale lock; another process cannot silently reclaim it (167.28754ms)
✔ trusted host registers later launches; socket clients never have a registration verb (167.034327ms)
✔ runtime excludes declared project roots even when host supplies no repoRoots (36.525098ms)
✔ a refused launch binding leaves the broker and existing capabilities alive; bad protocol stops it (154.714552ms)
✔ v3b prototype refusals, views and append-only mutations (950.500775ms)
✔ gated approval authorizes once, survives store reopen, and fresh approval works (234.901997ms)
✔ another run cannot consume an approval; a failed check leaves it usable (202.680739ms)
✔ two scheduled callers have exactly one grant and one consumed refusal (159.001809ms)
✔ failed commit rolls consumption back; cross-role consumes and within-role stays reusable (284.633069ms)
✔ class drift gated to cross-role refuses before consumption (169.400824ms)
✔ class drift cross-role to gated refuses before consumption (304.313418ms)
✔ class drift gated to within-role refuses before consumption (205.104985ms)
✔ class drift cross-role to within-role refuses before consumption (247.99929ms)
✔ class drift within-role to gated refuses before consumption (366.042302ms)
✔ class drift within-role to cross-role refuses before consumption (170.491063ms)
✔ message.send consumes approval and prevents a later send or authorize (192.831049ms)
✔ role.revoke consumes approval and prevents a later revoke or authorize (206.596535ms)
✔ creates private WAL store and excludes a second writer until explicit close (113.23738ms)
✔ rollback is atomic and schema metadata is checked against trusted DDL, not just itself (162.625023ms)
✔ existing empty database and symlink runtime directory refuse, never initialize over damage (167.236319ms)
✔ crash during a transaction recovers no partial event after explicit fixture-only lock removal (170.327072ms)
✔ writer refuses mixed at/read_at forms atomically, even through trusted SQL helpers (93.442914ms)
✔ async transactions refuse before invoking their function (80.944013ms)
✔ recordTask keeps sync reads and a role write apart (160.829393ms)
✔ read_at must be one canonical UTC format, so the projection compares strings safely (107.353806ms)
✔ a bad entry refuses the whole record (101.799967ms)
✔ taskView reads the projection for one business (116.999697ms)
✔ requestTask hands only a holder and a task verb to the handler, and records refusals (233.715142ms)
✔ the server sends task verbs to the adapter with its own timeout; other verbs stay synchronous (395.045161ms)
✔ without an adapter the server refuses every task verb (314.479348ms)
✔ the runtime refuses an invalid adapter and closes a valid one (253.582773ms)
✔ the process loads the S3 adapter from plain-data trackers (438.173741ms)
✔ socket capability stamps launch identity; shared views use wire, no SQL client (128.222648ms)
✔ two wire claims serialize; a lost reply never automatically retries (174.922863ms)
✔ malformed, oversized and identity-forging envelopes refuse without echoing input (100.447962ms)
✔ client preserves UTF-8 when a response divides a multibyte character (11.6814ms)
✔ committed mutation followed by dropped reply reports unknown and is never retried (126.768521ms)
ℹ tests 74
ℹ suites 0
ℹ pass 73
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 2840.450299
✖ failing tests:
test at packages/bus/tests/end-launch.test.mjs:121:1
✖ launches off refuses role.launch with launch-revoked until launches on (142.300893ms)
AssertionError [ERR_ASSERTION]: Missing expected exception.
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41c/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,93 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (116.084453ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (112.71306ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (88.907075ms)
✔ decide prints a declining choice as declining (102.731262ms)
✔ an unknown outcome is reported once and never resent (82.6584ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (77.896147ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (87.244191ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (64.800019ms)
✔ every human command refuses inside an agent run before it touches the bus (89.189575ms)
✔ usage errors exit 4; no business and no host is a usage error (85.043507ms)
✔ agents and tasks print through the broker (100.592684ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (2.47154ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (40.882137ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (31.727587ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (29.828881ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (30.275658ms)
✔ a business without tracker.baseUrl gets no trackers entry (30.976275ms)
✔ an unknown business and a broken system config refuse with exit 3 (54.670977ms)
✔ empty views say so (0.794286ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (0.809227ms)
✔ tasks print the tracker fields the snapshot carries (0.178193ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (878.377096ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (210.114362ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (136.033147ms)
✔ a broker reply that misses the wait breaks the channel: the late reply answers nothing, later requests refuse, the host exits 1 (1115.382486ms)
✔ a broker reply with another request's id, or none, breaks the channel and the host exits 1 (280.244479ms)
✔ a broker reply with no request waiting breaks the channel and the host exits 1 (119.655705ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (185.49727ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (153.606984ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (96.180988ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (158.579061ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (103.798932ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (236.702273ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (23.447264ms)
✔ bus stop refuses to signal a live pid that is not a bus host (203.033679ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (212.11801ms)
✔ bus start refuses with exit 3 without a notifier config (90.943833ms)
✔ bus start runs until bus stop; status reports it while it runs (652.281238ms)
✔ bus-service.sh renders the unit and installs it into a given directory (26.593011ms)
✔ bus start --pm: the PM runs under its own PID namespace, registered, with a manifest (1258.165054ms)
✔ the PM launches a coder through its launch tool; the coder answers; refusals name their code (600.175105ms)
✔ a runner that stops at once ends its launch with the runner's reason (194.615591ms)
✔ 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 (653.53918ms)
✔ 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 (3577.296328ms)
✔ a malformed sessions.json refuses the host start with exit 3 and stays as it was (112.25964ms)
✔ an over-long launch request is refused at once, not at the 10 s idle timeout (111.277258ms)
✔ a launch client that never closes its side doesn't hold the host's close (117.054582ms)
✔ a runner that ignores SIGTERM is killed when the host closes (1227.896809ms)
✔ zoned uses the IANA zone across DST (16.208878ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (108.609638ms)
✔ two blocking decisions get two DMs with different nonces (111.650159ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.164603ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (100.239534ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (92.743283ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (86.394699ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (84.569098ms)
✔ 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.353265ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (135.165024ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (111.288824ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (90.682654ms)
✔ an inbox read failure is logged and the next poll retries (0.602397ms)
✔ no Discord id reaches the journal or the log (62.968792ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (10.759013ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (17.772911ms)
✔ the journal: a whole file that is one torn line truncates to empty (21.401862ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (2.084797ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.607215ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (4.316928ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.33592ms)
✔ 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.439607ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.366572ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.415756ms)
✔ digest content stays within Discord's 2000 characters (0.260281ms)
✔ runLoop never overlaps ticks and stops after the one in flight (111.735602ms)
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.867034ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (31.810971ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2132.463559ms)
✔ busExit and refuseInsideAgent (0.430704ms)
✔ talk sends a REQUEST, prints and reads everything that arrives, and stops at the reply (198.014175ms)
✔ talk --wait 0 only sends; no reply within --wait exits 1 and says where it will show (1130.381849ms)
✔ talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors (66.758197ms)
✔ launches off and on go to the broker and change the business's launch state (70.160938ms)
✔ launches list reads sessions.json and marks a stale entry; stop reports it and refuses a non-runner (83.38562ms)
ℹ tests 83
ℹ suites 0
ℹ pass 83
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 7927.816552
@@ -0,0 +1,131 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (116.773968ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (122.480011ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (107.737052ms)
✔ decide prints a declining choice as declining (103.777574ms)
✔ an unknown outcome is reported once and never resent (80.798848ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (73.721538ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (94.10683ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (52.03352ms)
✔ every human command refuses inside an agent run before it touches the bus (69.858852ms)
✔ usage errors exit 4; no business and no host is a usage error (62.385421ms)
✔ agents and tasks print through the broker (87.371512ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (1.201847ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (38.45186ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (30.565261ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (32.79128ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (27.593361ms)
✔ a business without tracker.baseUrl gets no trackers entry (30.948979ms)
✔ an unknown business and a broken system config refuse with exit 3 (60.519247ms)
✔ empty views say so (0.635997ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (0.793789ms)
✔ tasks print the tracker fields the snapshot carries (0.123053ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (894.826658ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (210.283579ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (107.310036ms)
✔ a broker reply that misses the wait breaks the channel: the late reply answers nothing, later requests refuse, the host exits 1 (1114.791996ms)
✔ a broker reply with another request's id, or none, breaks the channel and the host exits 1 (246.396166ms)
✔ a broker reply with no request waiting breaks the channel and the host exits 1 (123.225949ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (159.599231ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (148.908413ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (101.621072ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (147.699614ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (98.495281ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (195.909081ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (22.563362ms)
✔ bus stop refuses to signal a live pid that is not a bus host (202.962668ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (209.807648ms)
✔ bus start refuses with exit 3 without a notifier config (86.931375ms)
✔ bus start runs until bus stop; status reports it while it runs (656.245854ms)
✔ bus-service.sh renders the unit and installs it into a given directory (27.440125ms)
✔ bus start --pm: the PM runs under its own PID namespace, registered, with a manifest (1269.168951ms)
✔ the PM launches a coder through its launch tool; the coder answers; refusals name their code (582.127299ms)
✔ a runner that stops at once ends its launch with the runner's reason (194.668315ms)
✖ 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 (411.313142ms)
✖ 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 (3371.831319ms)
✔ a malformed sessions.json refuses the host start with exit 3 and stays as it was (103.943446ms)
✔ an over-long launch request is refused at once, not at the 10 s idle timeout (123.550258ms)
✔ a launch client that never closes its side doesn't hold the host's close (116.110583ms)
✔ a runner that ignores SIGTERM is killed when the host closes (1221.909949ms)
✔ zoned uses the IANA zone across DST (15.464346ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (121.322639ms)
✔ two blocking decisions get two DMs with different nonces (109.849744ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.218512ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (91.817556ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (102.622632ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (104.131159ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (85.344784ms)
✔ 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 (154.050445ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (115.466925ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (116.380387ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (64.225797ms)
✔ an inbox read failure is logged and the next poll retries (0.625354ms)
✔ no Discord id reaches the journal or the log (59.787181ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (11.502098ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (16.81224ms)
✔ the journal: a whole file that is one torn line truncates to empty (10.277491ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (0.572598ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.569772ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (2.09614ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.32081ms)
✔ 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.47107ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.37081ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.41303ms)
✔ digest content stays within Discord's 2000 characters (0.273349ms)
✔ runLoop never overlaps ticks and stops after the one in flight (111.7453ms)
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.676147ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (35.615115ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2133.69872ms)
✔ busExit and refuseInsideAgent (0.424318ms)
✔ talk sends a REQUEST, prints and reads everything that arrives, and stops at the reply (202.963437ms)
✔ talk --wait 0 only sends; no reply within --wait exits 1 and says where it will show (1126.390972ms)
✔ talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors (63.278309ms)
✔ launches off and on go to the broker and change the business's launch state (72.910608ms)
✔ launches list reads sessions.json and marks a stale entry; stop reports it and refuses a non-runner (75.227332ms)
ℹ tests 83
ℹ suites 0
ℹ pass 81
ℹ fail 2
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 7473.412895
✖ 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 (411.313142ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
0 !== 1
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41c/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 (3371.831319ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
0 !== 1
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41c/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,91 @@
✔ sessionModel: agent vars win, then the system's execution settings (9.696603ms)
✔ a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory (4.070273ms)
✔ a claude-code bundle adds the wrapped gate hook and the MCP config (2.415247ms)
✔ a bundle is written once: an existing file refuses (2.650431ms)
✔ a path with a single quote can't go into the hook command (1.629108ms)
✔ allow exits 0, a deny exits 2 with the reason on stderr (117.967314ms)
✔ a missing or wrong policy, or a bad event, exits 2 (88.522719ms)
✔ the bundle's wrapped command: a missing gate or node still blocks (1088.78013ms)
✔ claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist (727.356596ms)
✔ claude: the hook alone blocks a path outside the workspace (441.84677ms)
✔ claude: a second turn resumes the first turn's session (682.249827ms)
✔ claude adapter: --restricted is always passed (5.017916ms)
✔ claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do (734.030368ms)
✔ claude: a missing hook or MCP file refuses before claude starts (7.229436ms)
✔ pi: policy tools and typed tools pass, anything else is blocked (4.088939ms)
✔ claude: builtins map from pi names, typed tools need the mcp prefix (0.919719ms)
✔ file tool paths must resolve inside the workspace (1.440503ms)
✔ pi's own path normalisation can't be used to step out (1.352831ms)
✔ a symlink inside the workspace that points out is outside (1.127897ms)
✔ a dangling symlink is refused at any depth, in both harnesses (3.876064ms)
✔ read is checked under every spelling pi's read would open, in both harnesses (13.39872ms)
✔ other spellings cover directories, dangling links and pi's cwd (1.575674ms)
✔ claude path fields per tool (0.549012ms)
✔ glob patterns stay inside the workspace (0.629861ms)
✔ a path that can't be checked is blocked (0.329056ms)
✔ initialize, ping and tools/list (43.066025ms)
✔ tools/call goes through the tool socket; a refusal is an isError result (31.509032ms)
✔ unknown tools and methods are JSON-RPC errors and never reach the socket (36.999754ms)
✔ a missing argument is a usage error (29.504074ms)
✔ pi: typed tools reach the socket, the gate blocks, agent_end writes the marker (358.687762ms)
✔ pi: a write through a dangling symlink is blocked, and nothing appears outside (307.132959ms)
✔ pi: a read is refused when pi would open another spelling outside (308.486165ms)
✔ pi: a missing extension refuses before any model call (6.865577ms)
✔ pi: an extension without its configuration fails pi's start (250.574566ms)
✔ founderCheck: founder variables, then a needed service without a usable token (1.162396ms)
✔ turnRequest names the sender, class, reply and decision (0.181424ms)
✔ a message becomes a turn, the answer goes back as a RESULT, SIGTERM releases and exits 0 (243.86309ms)
✔ a SIGTERM before the claim stops the runner with exit 0 and no claim (110.32513ms)
✔ typed tools carry the runner's capability; launch goes to the host's launch socket (344.896033ms)
✔ a RESULT gets no automatic reply; failed turns reply with the reason (1295.707408ms)
✔ SIGTERM during a turn kills the turn's process group and still exits 0 (162.081379ms)
✔ founder credentials stop before the claim (20) (184.047182ms)
✔ a refused claim exits 21; an ended run's capability exits 22 (199.883703ms)
✔ the launch ending under a running session exits 22 (144.133097ms)
✔ a broker that stays unreachable exits 23 after brokerRetries polls (245.598286ms)
✔ a broker that is down at the claim exits 23, not 21 (90.217016ms)
✔ no capability, or a malformed one, on stdin exits 2 (198.136102ms)
✖ a missing or malformed policy exits 2 before the claim (99.52997ms)
✔ the PM gets launch, its task verbs and the reads (5.868932ms)
✔ a coder gets no launch, no resolve_decision, and no task tools without a tracker (1.718293ms)
✔ launch only when the business's launch block names the instance as launcher (1.482662ms)
✔ an action outside the instance's authority has no tool (1.66328ms)
✔ callTool: one JSON line out, the result back, a refusal rejects (7.991204ms)
ℹ tests 53
ℹ suites 0
ℹ pass 52
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 10258.988421
✖ failing tests:
test at packages/harness/tests/runner.test.mjs:325:1
✖ a missing or malformed policy exits 2 before the claim (99.52997ms)
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/r41c/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/r41c/wt/packages/harness/src/runner.mjs:369:22
Node.js v26.8.1
1 !== 2
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41c/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,111 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (118.7696ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (112.517582ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (104.734582ms)
✔ decide prints a declining choice as declining (94.219447ms)
✔ an unknown outcome is reported once and never resent (71.320898ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (78.60463ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (89.27756ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (47.489336ms)
✔ every human command refuses inside an agent run before it touches the bus (71.874952ms)
✔ usage errors exit 4; no business and no host is a usage error (80.886603ms)
✔ agents and tasks print through the broker (101.461558ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (1.300893ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (41.15791ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (35.204711ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (31.261236ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (32.584002ms)
✔ a business without tracker.baseUrl gets no trackers entry (29.044532ms)
✔ an unknown business and a broken system config refuse with exit 3 (57.516076ms)
✔ empty views say so (0.957838ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (0.992012ms)
✔ tasks print the tracker fields the snapshot carries (0.170132ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (896.29727ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (196.663348ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (101.419879ms)
✔ a broker reply that misses the wait breaks the channel: the late reply answers nothing, later requests refuse, the host exits 1 (1115.868122ms)
✖ a broker reply with another request's id, or none, breaks the channel and the host exits 1 (116.00259ms)
✔ a broker reply with no request waiting breaks the channel and the host exits 1 (119.922419ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (166.609267ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (171.368584ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (116.325005ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (147.739729ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (104.321743ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (160.96238ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (22.725385ms)
✔ bus stop refuses to signal a live pid that is not a bus host (202.213528ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (216.613384ms)
✔ bus start refuses with exit 3 without a notifier config (88.972158ms)
✔ bus start runs until bus stop; status reports it while it runs (657.485418ms)
✔ bus-service.sh renders the unit and installs it into a given directory (26.640535ms)
✔ bus start --pm: the PM runs under its own PID namespace, registered, with a manifest (1269.229386ms)
✔ the PM launches a coder through its launch tool; the coder answers; refusals name their code (597.546968ms)
✔ a runner that stops at once ends its launch with the runner's reason (190.584908ms)
✔ 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 (655.747301ms)
✔ 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.709876ms)
✔ a malformed sessions.json refuses the host start with exit 3 and stays as it was (110.022099ms)
✔ an over-long launch request is refused at once, not at the 10 s idle timeout (116.680949ms)
✔ a launch client that never closes its side doesn't hold the host's close (124.74906ms)
✔ a runner that ignores SIGTERM is killed when the host closes (1234.875621ms)
✔ zoned uses the IANA zone across DST (17.445426ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (110.15799ms)
✔ two blocking decisions get two DMs with different nonces (120.4423ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.169566ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (97.392298ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (93.827284ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (84.938859ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (89.579454ms)
✔ 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 (136.189279ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (104.363841ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (134.265521ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (64.373716ms)
✔ an inbox read failure is logged and the next poll retries (0.604049ms)
✔ no Discord id reaches the journal or the log (46.22533ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (20.050399ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (13.683308ms)
✔ the journal: a whole file that is one torn line truncates to empty (12.83548ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (0.58997ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.553963ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (1.852277ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.316039ms)
✔ 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.624091ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.43916ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.436619ms)
✔ digest content stays within Discord's 2000 characters (0.275316ms)
✔ runLoop never overlaps ticks and stops after the one in flight (111.09786ms)
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 (357.419838ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (33.15468ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2140.168774ms)
✔ busExit and refuseInsideAgent (0.425387ms)
✔ talk sends a REQUEST, prints and reads everything that arrives, and stops at the reply (201.263318ms)
✔ talk --wait 0 only sends; no reply within --wait exits 1 and says where it will show (1128.405191ms)
✔ talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors (71.625526ms)
✔ launches off and on go to the broker and change the business's launch state (100.734888ms)
✔ launches list reads sessions.json and marks a stale entry; stop reports it and refuses a non-runner (78.664127ms)
ℹ tests 83
ℹ suites 0
ℹ pass 82
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 7970.621241
✖ 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 (116.00259ms)
AssertionError [ERR_ASSERTION]: another id
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41c/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/r41c/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,99 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (117.844202ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (112.051783ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (99.516979ms)
✔ decide prints a declining choice as declining (91.520575ms)
✔ an unknown outcome is reported once and never resent (75.283805ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (94.649371ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (50.873695ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (54.390982ms)
✔ every human command refuses inside an agent run before it touches the bus (72.259359ms)
✔ usage errors exit 4; no business and no host is a usage error (69.265454ms)
✔ agents and tasks print through the broker (76.72627ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (1.18508ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (45.671901ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (40.208247ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (40.892318ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (36.439434ms)
✔ a business without tracker.baseUrl gets no trackers entry (33.651213ms)
✔ an unknown business and a broken system config refuse with exit 3 (63.975733ms)
✔ empty views say so (1.151707ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (1.358693ms)
✔ tasks print the tracker fields the snapshot carries (0.204733ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (892.761758ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (204.03585ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (117.342796ms)
✖ a broker reply that misses the wait breaks the channel: the late reply answers nothing, later requests refuse, the host exits 1 (30008.753719ms)
✔ a broker reply with another request's id, or none, breaks the channel and the host exits 1 (231.390625ms)
✔ a broker reply with no request waiting breaks the channel and the host exits 1 (120.303616ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (154.309413ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (154.119163ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (101.399722ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (150.416295ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (117.674787ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (163.238776ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (26.14268ms)
✔ bus stop refuses to signal a live pid that is not a bus host (202.629888ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (207.528952ms)
✔ bus start refuses with exit 3 without a notifier config (87.103098ms)
✔ bus start runs until bus stop; status reports it while it runs (663.245212ms)
✔ bus-service.sh renders the unit and installs it into a given directory (26.054298ms)
✔ bus start --pm: the PM runs under its own PID namespace, registered, with a manifest (1257.071227ms)
✔ the PM launches a coder through its launch tool; the coder answers; refusals name their code (587.596688ms)
✔ a runner that stops at once ends its launch with the runner's reason (187.544714ms)
✔ 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 (648.454694ms)
✔ 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 (3561.089392ms)
✔ a malformed sessions.json refuses the host start with exit 3 and stays as it was (110.722706ms)
✔ an over-long launch request is refused at once, not at the 10 s idle timeout (148.641787ms)
✔ a launch client that never closes its side doesn't hold the host's close (123.135809ms)
✔ a runner that ignores SIGTERM is killed when the host closes (1228.905979ms)
✔ zoned uses the IANA zone across DST (16.233393ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (119.044583ms)
✔ two blocking decisions get two DMs with different nonces (105.540828ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.205326ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (101.136924ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (95.877067ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (75.987785ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (90.920672ms)
✔ 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 (116.676169ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (112.950101ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (94.390305ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (53.908882ms)
✔ an inbox read failure is logged and the next poll retries (0.63442ms)
✔ no Discord id reaches the journal or the log (72.084211ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (10.343166ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (15.17722ms)
✔ the journal: a whole file that is one torn line truncates to empty (9.904163ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (0.56188ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.534631ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (1.723957ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.292499ms)
✔ 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.437122ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.348808ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.41723ms)
✔ digest content stays within Discord's 2000 characters (0.235635ms)
✔ runLoop never overlaps ticks and stops after the one in flight (110.991265ms)
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 (370.153969ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (39.132227ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2163.396006ms)
✔ busExit and refuseInsideAgent (0.395561ms)
✔ talk sends a REQUEST, prints and reads everything that arrives, and stops at the reply (191.049842ms)
✔ talk --wait 0 only sends; no reply within --wait exits 1 and says where it will show (1129.445914ms)
✔ talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors (75.113246ms)
✔ launches off and on go to the broker and change the business's launch state (73.878699ms)
✔ launches list reads sessions.json and marks a stale entry; stop reports it and refuses a non-runner (66.415468ms)
ℹ tests 83
ℹ suites 0
ℹ pass 82
ℹ fail 0
ℹ cancelled 1
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 33734.324949
✖ 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 (30008.753719ms)
'test timed out after 30000ms'
@@ -0,0 +1,114 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (116.271488ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (124.069222ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (104.243829ms)
✔ decide prints a declining choice as declining (97.196386ms)
✔ an unknown outcome is reported once and never resent (79.174498ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (88.606533ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (87.768697ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (47.324214ms)
✔ every human command refuses inside an agent run before it touches the bus (64.01629ms)
✔ usage errors exit 4; no business and no host is a usage error (60.613922ms)
✔ agents and tasks print through the broker (89.36753ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (1.407524ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (54.927595ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (40.01449ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (36.208705ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (35.601892ms)
✔ a business without tracker.baseUrl gets no trackers entry (34.864903ms)
✔ an unknown business and a broken system config refuse with exit 3 (68.275677ms)
✔ empty views say so (1.01893ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (1.571284ms)
✔ tasks print the tracker fields the snapshot carries (0.274696ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (918.699061ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (191.902414ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (117.63791ms)
✔ a broker reply that misses the wait breaks the channel: the late reply answers nothing, later requests refuse, the host exits 1 (1156.488334ms)
✔ a broker reply with another request's id, or none, breaks the channel and the host exits 1 (235.475124ms)
✖ a broker reply with no request waiting breaks the channel and the host exits 1 (5114.342057ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (170.136813ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (152.483597ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (103.739555ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (160.130914ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (107.057276ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (162.775445ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (24.15224ms)
✔ bus stop refuses to signal a live pid that is not a bus host (202.679021ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (300.192263ms)
✔ bus start refuses with exit 3 without a notifier config (118.208902ms)
✔ bus start runs until bus stop; status reports it while it runs (723.480686ms)
✔ bus-service.sh renders the unit and installs it into a given directory (33.590569ms)
✔ bus start --pm: the PM runs under its own PID namespace, registered, with a manifest (1268.563017ms)
✔ the PM launches a coder through its launch tool; the coder answers; refusals name their code (605.423586ms)
✔ a runner that stops at once ends its launch with the runner's reason (186.260867ms)
✔ 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 (638.409248ms)
✔ 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 (3565.207706ms)
✔ a malformed sessions.json refuses the host start with exit 3 and stays as it was (109.678021ms)
✔ an over-long launch request is refused at once, not at the 10 s idle timeout (113.427619ms)
✔ a launch client that never closes its side doesn't hold the host's close (124.913124ms)
✔ a runner that ignores SIGTERM is killed when the host closes (1224.938589ms)
✔ zoned uses the IANA zone across DST (18.314057ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (118.717327ms)
✔ two blocking decisions get two DMs with different nonces (110.677761ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.300255ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (92.543944ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (95.966315ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (88.423173ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (90.76417ms)
✔ 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 (156.662576ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (100.982989ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (103.052373ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (59.556795ms)
✔ an inbox read failure is logged and the next poll retries (0.580257ms)
✔ no Discord id reaches the journal or the log (72.423056ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (10.998211ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (21.426466ms)
✔ the journal: a whole file that is one torn line truncates to empty (19.539524ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (0.666391ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.640771ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (1.802934ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.299762ms)
✔ 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.471224ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.348548ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.413823ms)
✔ digest content stays within Discord's 2000 characters (0.255603ms)
✔ runLoop never overlaps ticks and stops after the one in flight (110.982927ms)
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 (375.284603ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (42.802247ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2170.769467ms)
✔ busExit and refuseInsideAgent (0.403611ms)
✔ talk sends a REQUEST, prints and reads everything that arrives, and stops at the reply (201.152444ms)
✔ talk --wait 0 only sends; no reply within --wait exits 1 and says where it will show (1129.541481ms)
✔ talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors (78.064968ms)
✔ launches off and on go to the broker and change the business's launch state (71.657225ms)
✔ launches list reads sessions.json and marks a stale entry; stop reports it and refuses a non-runner (68.686108ms)
ℹ tests 83
ℹ suites 0
ℹ pass 82
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 10130.129185
✖ failing tests:
test at packages/cli/tests/host.test.mjs:265:1
✖ a broker reply with no request waiting breaks the channel and the host exits 1 (5114.342057ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
+ actual - expected
+ 'still running'
- 1
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41c/wt/packages/cli/tests/host.test.mjs:283: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: 'still running',
expected: 1,
operator: 'strictEqual',
diff: 'simple'
}
@@ -0,0 +1,125 @@
✔ sessionModel: agent vars win, then the system's execution settings (18.802442ms)
✔ a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory (8.290629ms)
✔ a claude-code bundle adds the wrapped gate hook and the MCP config (4.265218ms)
✔ a bundle is written once: an existing file refuses (4.837663ms)
✔ a path with a single quote can't go into the hook command (2.936744ms)
✔ allow exits 0, a deny exits 2 with the reason on stderr (173.049276ms)
✔ a missing or wrong policy, or a bad event, exits 2 (123.911977ms)
✔ the bundle's wrapped command: a missing gate or node still blocks (1128.878137ms)
✔ claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist (994.036261ms)
✔ claude: the hook alone blocks a path outside the workspace (557.302735ms)
✔ claude: a second turn resumes the first turn's session (832.823388ms)
✔ claude adapter: --restricted is always passed (5.786433ms)
✔ claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do (859.822688ms)
✔ claude: a missing hook or MCP file refuses before claude starts (7.864382ms)
✔ pi: policy tools and typed tools pass, anything else is blocked (4.576156ms)
✔ claude: builtins map from pi names, typed tools need the mcp prefix (1.113849ms)
✔ file tool paths must resolve inside the workspace (2.677582ms)
✔ pi's own path normalisation can't be used to step out (1.510552ms)
✔ a symlink inside the workspace that points out is outside (1.41079ms)
✖ a dangling symlink is refused at any depth, in both harnesses (2.226421ms)
✔ read is checked under every spelling pi's read would open, in both harnesses (26.104867ms)
✖ other spellings cover directories, dangling links and pi's cwd (2.137358ms)
✔ claude path fields per tool (1.05581ms)
✔ glob patterns stay inside the workspace (1.137006ms)
✔ a path that can't be checked is blocked (0.84172ms)
✔ initialize, ping and tools/list (69.514094ms)
✔ tools/call goes through the tool socket; a refusal is an isError result (49.843343ms)
✔ unknown tools and methods are JSON-RPC errors and never reach the socket (39.762264ms)
✔ a missing argument is a usage error (37.288971ms)
✔ pi: typed tools reach the socket, the gate blocks, agent_end writes the marker (495.019496ms)
✖ pi: a write through a dangling symlink is blocked, and nothing appears outside (422.140334ms)
✔ pi: a read is refused when pi would open another spelling outside (433.998971ms)
✔ pi: a missing extension refuses before any model call (8.617714ms)
✔ pi: an extension without its configuration fails pi's start (326.295407ms)
✔ founderCheck: founder variables, then a needed service without a usable token (1.908048ms)
✔ turnRequest names the sender, class, reply and decision (0.300329ms)
✔ a message becomes a turn, the answer goes back as a RESULT, SIGTERM releases and exits 0 (285.748828ms)
✔ a SIGTERM before the claim stops the runner with exit 0 and no claim (113.620011ms)
✔ typed tools carry the runner's capability; launch goes to the host's launch socket (433.85822ms)
✔ a RESULT gets no automatic reply; failed turns reply with the reason (1314.045957ms)
✔ SIGTERM during a turn kills the turn's process group and still exits 0 (150.182168ms)
✔ founder credentials stop before the claim (20) (188.63084ms)
✔ a refused claim exits 21; an ended run's capability exits 22 (238.87844ms)
✔ the launch ending under a running session exits 22 (152.787017ms)
✔ a broker that stays unreachable exits 23 after brokerRetries polls (253.577522ms)
✔ a broker that is down at the claim exits 23, not 21 (100.818778ms)
✔ no capability, or a malformed one, on stdin exits 2 (220.636759ms)
✔ a missing or malformed policy exits 2 before the claim (135.30631ms)
✔ the PM gets launch, its task verbs and the reads (9.883022ms)
✔ a coder gets no launch, no resolve_decision, and no task tools without a tracker (2.967614ms)
✔ launch only when the business's launch block names the instance as launcher (3.548277ms)
✔ an action outside the instance's authority has no tool (2.842622ms)
✔ callTool: one JSON line out, the result back, a refusal rejects (13.441611ms)
ℹ tests 53
ℹ suites 0
ℹ pass 50
ℹ fail 3
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 10443.473356
✖ failing tests:
test at packages/harness/tests/gate.test.mjs:82:1
✖ a dangling symlink is refused at any depth, in both harnesses (2.226421ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
true !== false
at blocked (file:///home/jwoltje/darkwing-scratch/r41c/wt/packages/harness/tests/gate.test.mjs:19:10)
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41c/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/gate.test.mjs:137:1
✖ other spellings cover directories, dangling links and pi's cwd (2.137358ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
true !== false
at blocked (file:///home/jwoltje/darkwing-scratch/r41c/wt/packages/harness/tests/gate.test.mjs:19:10)
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41c/wt/packages/harness/tests/gate.test.mjs:146:3)
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 (422.140334ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
false !== true
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41c/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,102 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (107.574979ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (115.510197ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (91.667832ms)
✔ decide prints a declining choice as declining (82.361002ms)
✔ an unknown outcome is reported once and never resent (88.689894ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (84.215775ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (81.428409ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (47.834263ms)
✔ every human command refuses inside an agent run before it touches the bus (73.603116ms)
✔ usage errors exit 4; no business and no host is a usage error (66.204753ms)
✔ agents and tasks print through the broker (77.16719ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (1.178487ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (50.972913ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (36.204781ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (33.055013ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (31.040273ms)
✔ a business without tracker.baseUrl gets no trackers entry (32.719494ms)
✔ an unknown business and a broken system config refuse with exit 3 (68.293181ms)
✔ empty views say so (0.882453ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (1.508547ms)
✔ tasks print the tracker fields the snapshot carries (0.253034ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (894.631322ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (194.887309ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (114.509565ms)
✔ a broker reply that misses the wait breaks the channel: the late reply answers nothing, later requests refuse, the host exits 1 (1121.999761ms)
✔ a broker reply with another request's id, or none, breaks the channel and the host exits 1 (240.415817ms)
✔ a broker reply with no request waiting breaks the channel and the host exits 1 (113.972436ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (192.657947ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (169.644507ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (108.271037ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (157.044508ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (103.068297ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (166.142799ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (22.908346ms)
✔ bus stop refuses to signal a live pid that is not a bus host (202.408938ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (219.813334ms)
✔ bus start refuses with exit 3 without a notifier config (85.061294ms)
✔ bus start runs until bus stop; status reports it while it runs (689.34616ms)
✔ bus-service.sh renders the unit and installs it into a given directory (30.910722ms)
✔ bus start --pm: the PM runs under its own PID namespace, registered, with a manifest (1270.671704ms)
✔ the PM launches a coder through its launch tool; the coder answers; refusals name their code (589.217311ms)
✔ a runner that stops at once ends its launch with the runner's reason (192.873676ms)
✔ 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.213898ms)
✔ 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 (3607.470054ms)
✔ a malformed sessions.json refuses the host start with exit 3 and stays as it was (136.069657ms)
✔ an over-long launch request is refused at once, not at the 10 s idle timeout (133.759484ms)
✖ a launch client that never closes its side doesn't hold the host's close (5150.040447ms)
✔ a runner that ignores SIGTERM is killed when the host closes (1243.827657ms)
✔ zoned uses the IANA zone across DST (21.377114ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (104.828887ms)
✔ two blocking decisions get two DMs with different nonces (112.202921ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.344568ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (86.639654ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (87.580349ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (87.597711ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (80.082908ms)
✔ 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 (139.586274ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (116.848411ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (102.328643ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (50.901873ms)
✔ an inbox read failure is logged and the next poll retries (0.645418ms)
✔ no Discord id reaches the journal or the log (67.371584ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (11.095924ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (15.767322ms)
✔ the journal: a whole file that is one torn line truncates to empty (10.491987ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (0.624542ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.630051ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (2.18267ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.346859ms)
✔ 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.518325ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.414284ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.471122ms)
✔ digest content stays within Discord's 2000 characters (0.301388ms)
✔ runLoop never overlaps ticks and stops after the one in flight (110.98474ms)
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.207677ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (46.083511ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2163.000133ms)
✔ busExit and refuseInsideAgent (0.392922ms)
✔ talk sends a REQUEST, prints and reads everything that arrives, and stops at the reply (190.534289ms)
✔ talk --wait 0 only sends; no reply within --wait exits 1 and says where it will show (1113.481004ms)
✔ talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors (75.811714ms)
✔ launches off and on go to the broker and change the business's launch state (76.441605ms)
✔ launches list reads sessions.json and marks a stale entry; stop reports it and refuses a non-runner (59.247583ms)
ℹ tests 83
ℹ suites 0
ℹ pass 82
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 13108.501852
✖ 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 (5150.040447ms)
Error: close took over 5000 ms
at Timeout._onTimeout (file:///home/jwoltje/darkwing-scratch/r41c/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,105 @@
✔ sessionModel: agent vars win, then the system's execution settings (14.572512ms)
✔ a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory (6.174951ms)
✔ a claude-code bundle adds the wrapped gate hook and the MCP config (4.600206ms)
✔ a bundle is written once: an existing file refuses (3.074615ms)
✔ a path with a single quote can't go into the hook command (2.569843ms)
✔ allow exits 0, a deny exits 2 with the reason on stderr (174.908763ms)
✔ a missing or wrong policy, or a bad event, exits 2 (103.705751ms)
✔ the bundle's wrapped command: a missing gate or node still blocks (1110.897092ms)
✔ claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist (979.613205ms)
✔ claude: the hook alone blocks a path outside the workspace (566.447612ms)
✔ claude: a second turn resumes the first turn's session (772.777865ms)
✖ claude adapter: --restricted is always passed (5.834159ms)
✖ claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do (419.9886ms)
✔ claude: a missing hook or MCP file refuses before claude starts (8.321942ms)
✔ pi: policy tools and typed tools pass, anything else is blocked (4.740971ms)
✔ claude: builtins map from pi names, typed tools need the mcp prefix (1.104453ms)
✔ file tool paths must resolve inside the workspace (1.531795ms)
✔ pi's own path normalisation can't be used to step out (1.36492ms)
✔ a symlink inside the workspace that points out is outside (1.197825ms)
✔ a dangling symlink is refused at any depth, in both harnesses (3.833957ms)
✔ read is checked under every spelling pi's read would open, in both harnesses (20.828356ms)
✔ other spellings cover directories, dangling links and pi's cwd (3.062402ms)
✔ claude path fields per tool (0.980935ms)
✔ glob patterns stay inside the workspace (1.034332ms)
✔ a path that can't be checked is blocked (0.50853ms)
✔ initialize, ping and tools/list (71.834384ms)
✔ tools/call goes through the tool socket; a refusal is an isError result (40.220197ms)
✔ unknown tools and methods are JSON-RPC errors and never reach the socket (41.041288ms)
✔ a missing argument is a usage error (35.408963ms)
✔ pi: typed tools reach the socket, the gate blocks, agent_end writes the marker (451.856779ms)
✔ pi: a write through a dangling symlink is blocked, and nothing appears outside (424.083966ms)
✔ pi: a read is refused when pi would open another spelling outside (391.039296ms)
✔ pi: a missing extension refuses before any model call (8.084161ms)
✔ pi: an extension without its configuration fails pi's start (309.06089ms)
✔ founderCheck: founder variables, then a needed service without a usable token (1.760127ms)
✔ turnRequest names the sender, class, reply and decision (0.295283ms)
✔ a message becomes a turn, the answer goes back as a RESULT, SIGTERM releases and exits 0 (289.066856ms)
✔ a SIGTERM before the claim stops the runner with exit 0 and no claim (145.925972ms)
✔ typed tools carry the runner's capability; launch goes to the host's launch socket (572.943605ms)
✔ a RESULT gets no automatic reply; failed turns reply with the reason (1382.676577ms)
✔ SIGTERM during a turn kills the turn's process group and still exits 0 (195.488321ms)
✔ founder credentials stop before the claim (20) (266.220648ms)
✔ a refused claim exits 21; an ended run's capability exits 22 (300.197563ms)
✔ the launch ending under a running session exits 22 (189.321104ms)
✔ a broker that stays unreachable exits 23 after brokerRetries polls (294.356597ms)
✔ a broker that is down at the claim exits 23, not 21 (126.069492ms)
✔ no capability, or a malformed one, on stdin exits 2 (225.894325ms)
✔ a missing or malformed policy exits 2 before the claim (172.34215ms)
✔ the PM gets launch, its task verbs and the reads (9.918179ms)
✔ a coder gets no launch, no resolve_decision, and no task tools without a tracker (3.000201ms)
✔ launch only when the business's launch block names the instance as launcher (2.299358ms)
✔ an action outside the instance's authority has no tool (2.45242ms)
✔ callTool: one JSON line out, the result back, a refusal rejects (12.67808ms)
ℹ tests 53
ℹ suites 0
ℹ pass 51
ℹ fail 2
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 10736.672106
✖ failing tests:
test at packages/harness/tests/claude-session.test.mjs:143:1
✖ claude adapter: --restricted is always passed (5.834159ms)
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-W55fmH/settings.json --strict-mcp-config --mcp-config /home/jwoltje/darkwing-scratch/tmp/mosaic-harness-W55fmH/mcp.json --disable-slash-commands --session-id 884e6813-b3f5-4c85-9d05-e7f40f63932e
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41c/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 (419.9886ms)
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/r41c/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 (186.289558ms)
✔ decision classes route from policy; gated resolution is human-only, choice and target must match (304.018727ms)
✔ claim exclusion, holder release, gated revoke and rerouting to a new holder are atomic (312.896779ms)
✔ launch events require a human CLI capability; generic emit cannot forge authority events (176.026053ms)
✔ within-role decisions close atomically and invalid options or blocking omissions refuse (214.016058ms)
✔ observer capabilities read human inbox but cannot mutate or forge launch identity (156.008229ms)
✔ task action subjects and linked decision trail are complete and ordered (186.987338ms)
✔ launch binding is durable and reconnecting requires the identical trusted record (116.187895ms)
✔ business isolation includes inherited object names and cross-business message references (246.496569ms)
✔ authority never transfers between action, run, target, unresolved or replaced role holder (251.859443ms)
✔ task projection uses schema current view, skipping earlier and equal-start polls (122.105337ms)
✔ revocation permanently bars the old run from reclaiming first, including after broker restart (218.667672ms)
✔ empty message references refuse before storage; refusal-evidence failure stays a typed error (164.00194ms)
✔ both arbiters require human resolution when their cross-role route is themselves (257.710544ms)
✔ S1 adapter takes resolved limits and refs, rejects mismatched instance, never mutates input (1.954557ms)
✔ only validated broker references load; returned data and exceptions cannot expose a known token (5.251571ms)
✔ bad file modes, symlinks, repository/data paths, malformed tokens and missing dates refuse (2.375131ms)
✔ expiry refuses use and env references never become client data (0.751766ms)
✔ S1 parsed service refs work, service mismatch refuses, Gitea rotation due is a warning state (1.531434ms)
✔ opaque tokens shorter than 16 characters refuse before use (0.414561ms)
✔ endLaunch writes session.ended, releases the run claim and kills its capabilities (154.694325ms)
✔ endLaunch refuses an unknown run, a second end and a rebind of the ended run (171.902185ms)
✔ a restarted broker refuses to rebind an ended run; a refused rebind leaves the run unbound (174.905205ms)
✔ endLaunch leaves a claim another run took alone (212.217176ms)
✔ refuse records action.refused against the caller with the code only (138.391095ms)
✔ launches off refuses role.launch with launch-revoked until launches on (215.338187ms)
✖ broker process: launch ops authorize role.launch, record refusals and end runs (213.614803ms)
✔ human proof binds CLI entry, process start and nonce; agents and incomplete ancestry refuse (3.386446ms)
✔ process reader gets own kernel identity without exposing environment values (0.689787ms)
✔ EACCES ancestor environments skip only markers; commands and registered launches still refuse (1.079005ms)
✔ real pid 1 remains inspectable when its environment is protected (0.397066ms)
✔ within-role sends cite an open gated launch decision without spending it or naming it in grants (254.253894ms)
✔ missing and foreign-business citations refuse and roll back message and grant (212.627037ms)
✔ cross-role sends still need a matching resolved decision and consume it once (289.310244ms)
✔ broker process binds trusted launches, offers reader capabilities, refuses human mutation, closes cleanly (218.517678ms)
✔ startup token refusal returns safe code without value or partial listening broker (32.491119ms)
✔ loaded fixture token is absent from socket replies and SQLite, including refusal evidence (178.304823ms)
✔ killed broker leaves an explicit stale lock; another process cannot silently reclaim it (191.566072ms)
✔ trusted host registers later launches; socket clients never have a registration verb (189.349722ms)
✔ runtime excludes declared project roots even when host supplies no repoRoots (36.89722ms)
✔ a refused launch binding leaves the broker and existing capabilities alive; bad protocol stops it (175.689007ms)
✔ v3b prototype refusals, views and append-only mutations (1197.281905ms)
✔ gated approval authorizes once, survives store reopen, and fresh approval works (305.159404ms)
✔ another run cannot consume an approval; a failed check leaves it usable (262.492222ms)
✔ two scheduled callers have exactly one grant and one consumed refusal (181.830806ms)
✔ failed commit rolls consumption back; cross-role consumes and within-role stays reusable (346.4383ms)
✔ class drift gated to cross-role refuses before consumption (222.934014ms)
✔ class drift cross-role to gated refuses before consumption (241.225922ms)
✔ class drift gated to within-role refuses before consumption (251.437257ms)
✔ class drift cross-role to within-role refuses before consumption (223.715857ms)
✔ class drift within-role to gated refuses before consumption (178.191594ms)
✔ class drift within-role to cross-role refuses before consumption (157.94191ms)
✔ message.send consumes approval and prevents a later send or authorize (245.832812ms)
✔ role.revoke consumes approval and prevents a later revoke or authorize (292.283314ms)
✔ creates private WAL store and excludes a second writer until explicit close (140.014706ms)
✔ rollback is atomic and schema metadata is checked against trusted DDL, not just itself (212.834441ms)
✔ existing empty database and symlink runtime directory refuse, never initialize over damage (219.258383ms)
✔ crash during a transaction recovers no partial event after explicit fixture-only lock removal (187.366748ms)
✔ writer refuses mixed at/read_at forms atomically, even through trusted SQL helpers (117.888079ms)
✔ async transactions refuse before invoking their function (89.581295ms)
✔ recordTask keeps sync reads and a role write apart (215.864702ms)
✔ read_at must be one canonical UTC format, so the projection compares strings safely (138.896899ms)
✔ a bad entry refuses the whole record (119.620932ms)
✔ taskView reads the projection for one business (146.650022ms)
✔ requestTask hands only a holder and a task verb to the handler, and records refusals (263.17009ms)
✔ the server sends task verbs to the adapter with its own timeout; other verbs stay synchronous (411.847863ms)
✔ without an adapter the server refuses every task verb (196.381669ms)
✔ the runtime refuses an invalid adapter and closes a valid one (227.028171ms)
✔ the process loads the S3 adapter from plain-data trackers (291.300727ms)
✔ socket capability stamps launch identity; shared views use wire, no SQL client (175.428009ms)
✔ two wire claims serialize; a lost reply never automatically retries (203.728929ms)
✔ malformed, oversized and identity-forging envelopes refuse without echoing input (124.215141ms)
✔ client preserves UTF-8 when a response divides a multibyte character (11.588695ms)
✔ committed mutation followed by dropped reply reports unknown and is never retried (170.147838ms)
ℹ tests 74
ℹ suites 0
ℹ pass 73
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 3003.137811
✖ 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 (213.614803ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
undefined !== 7
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41c/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,278 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (131.259713ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (142.085261ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (105.183099ms)
✔ decide prints a declining choice as declining (119.576185ms)
✔ an unknown outcome is reported once and never resent (108.271628ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (117.946118ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (105.265348ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (87.364293ms)
✔ every human command refuses inside an agent run before it touches the bus (92.554678ms)
✔ usage errors exit 4; no business and no host is a usage error (95.690907ms)
✔ agents and tasks print through the broker (106.418073ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (1.152934ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (44.878382ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (33.233402ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (43.460218ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (32.215951ms)
✔ a business without tracker.baseUrl gets no trackers entry (33.560654ms)
✔ an unknown business and a broken system config refuse with exit 3 (57.823466ms)
✔ empty views say so (0.84178ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (0.86245ms)
✔ tasks print the tracker fields the snapshot carries (0.14085ms)
mosaic-notify: notify: poll failed: ENOENT: no such file or directory, open '/home/jwoltje/darkwing-scratch/tmp/mosaic-cli-RUIsFP/data/notify/acme/sent.jsonl'
✖ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (281.600404ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (238.665806ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (157.129686ms)
✖ a broker reply that misses the wait breaks the channel: the late reply answers nothing, later requests refuse, the host exits 1 (165.459948ms)
✖ a broker reply with another request's id, or none, breaks the channel and the host exits 1 (150.458949ms)
✖ a broker reply with no request waiting breaks the channel and the host exits 1 (174.768425ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (215.873785ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (190.646713ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (139.740017ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (174.760894ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (131.745328ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (192.164991ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (22.803458ms)
✔ bus stop refuses to signal a live pid that is not a bus host (202.070147ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (237.099028ms)
✔ bus start refuses with exit 3 without a notifier config (96.586437ms)
✔ bus start runs until bus stop; status reports it while it runs (662.387058ms)
✔ bus-service.sh renders the unit and installs it into a given directory (31.140179ms)
✖ bus start --pm: the PM runs under its own PID namespace, registered, with a manifest (207.214021ms)
✖ the PM launches a coder through its launch tool; the coder answers; refusals name their code (158.789496ms)
✖ a runner that stops at once ends its launch with the runner's reason (163.795055ms)
file:///home/jwoltje/darkwing-scratch/r41c/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/r41c/wt/packages/cli/src/host.mjs:166:46)
at breakChannel (file:///home/jwoltje/darkwing-scratch/r41c/wt/packages/cli/src/host.mjs:172:22)
at ChildProcess.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41c/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
✖ 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 (30002.536805ms)
file:///home/jwoltje/darkwing-scratch/r41c/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/r41c/wt/packages/cli/src/host.mjs:166:46)
at breakChannel (file:///home/jwoltje/darkwing-scratch/r41c/wt/packages/cli/src/host.mjs:172:22)
at ChildProcess.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41c/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
✖ 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 (30030.798149ms)
✔ a malformed sessions.json refuses the host start with exit 3 and stays as it was (108.303111ms)
✖ an over-long launch request is refused at once, not at the 10 s idle timeout (101.198936ms)
✖ a launch client that never closes its side doesn't hold the host's close (104.110192ms)
✖ a runner that ignores SIGTERM is killed when the host closes (114.572483ms)
✔ zoned uses the IANA zone across DST (18.917292ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (134.68347ms)
✔ two blocking decisions get two DMs with different nonces (129.243829ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.198111ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (108.36552ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (111.438702ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (114.682617ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (119.110607ms)
✔ 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 (163.311187ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (123.358299ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (115.878308ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (95.469743ms)
✔ an inbox read failure is logged and the next poll retries (0.651764ms)
✔ no Discord id reaches the journal or the log (85.75436ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (32.250874ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (22.410351ms)
✔ the journal: a whole file that is one torn line truncates to empty (10.338875ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (0.598787ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.583001ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (1.764342ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.348821ms)
✔ 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.596242ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.385734ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.435339ms)
✔ digest content stays within Discord's 2000 characters (0.274173ms)
✔ runLoop never overlaps ticks and stops after the one in flight (111.10812ms)
tasks acme startup tracker-unavailable
✖ bootConfig trackers reach the S3 adapter in the real broker child, which goes ready against a fake Vikunja (254.292384ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (38.221259ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2161.424367ms)
✔ busExit and refuseInsideAgent (0.42253ms)
✔ talk sends a REQUEST, prints and reads everything that arrives, and stops at the reply (223.258164ms)
✔ talk --wait 0 only sends; no reply within --wait exits 1 and says where it will show (1128.520386ms)
✔ talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors (61.74262ms)
✔ launches off and on go to the broker and change the business's launch state (110.05542ms)
✔ launches list reads sessions.json and marks a stale entry; stop reports it and refuses a non-runner (79.707587ms)
ℹ tests 83
ℹ suites 0
ℹ pass 70
ℹ fail 11
ℹ cancelled 2
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 61071.033649
✖ 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 (281.600404ms)
Error [CliError]: broker channel broken: reply for another request
at fail (file:///home/jwoltje/darkwing-scratch/r41c/wt/packages/cli/src/host.mjs:166:46)
at breakChannel (file:///home/jwoltje/darkwing-scratch/r41c/wt/packages/cli/src/host.mjs:172:22)
at ChildProcess.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41c/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 (165.459948ms)
Error [CliError]: broker channel broken: reply for another request
at fail (file:///home/jwoltje/darkwing-scratch/r41c/wt/packages/cli/src/host.mjs:166:46)
at breakChannel (file:///home/jwoltje/darkwing-scratch/r41c/wt/packages/cli/src/host.mjs:172:22)
at ChildProcess.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41c/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 (150.458949ms)
Error [CliError]: broker channel broken: reply for another request
at fail (file:///home/jwoltje/darkwing-scratch/r41c/wt/packages/cli/src/host.mjs:166:46)
at breakChannel (file:///home/jwoltje/darkwing-scratch/r41c/wt/packages/cli/src/host.mjs:172:22)
at ChildProcess.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41c/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:265:1
✖ a broker reply with no request waiting breaks the channel and the host exits 1 (174.768425ms)
Error [CliError]: broker channel broken: reply for another request
at fail (file:///home/jwoltje/darkwing-scratch/r41c/wt/packages/cli/src/host.mjs:166:46)
at breakChannel (file:///home/jwoltje/darkwing-scratch/r41c/wt/packages/cli/src/host.mjs:172:22)
at ChildProcess.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41c/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 (207.214021ms)
Error [CliError]: broker channel broken: reply for another request
at fail (file:///home/jwoltje/darkwing-scratch/r41c/wt/packages/cli/src/host.mjs:166:46)
at breakChannel (file:///home/jwoltje/darkwing-scratch/r41c/wt/packages/cli/src/host.mjs:172:22)
at ChildProcess.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41c/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 (158.789496ms)
Error [CliError]: broker channel broken: reply for another request
at fail (file:///home/jwoltje/darkwing-scratch/r41c/wt/packages/cli/src/host.mjs:166:46)
at breakChannel (file:///home/jwoltje/darkwing-scratch/r41c/wt/packages/cli/src/host.mjs:172:22)
at ChildProcess.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41c/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 (163.795055ms)
Error [CliError]: broker channel broken: reply for another request
at fail (file:///home/jwoltje/darkwing-scratch/r41c/wt/packages/cli/src/host.mjs:166:46)
at breakChannel (file:///home/jwoltje/darkwing-scratch/r41c/wt/packages/cli/src/host.mjs:172:22)
at ChildProcess.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41c/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: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 (30002.536805ms)
'test timed out after 30000ms'
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 (30030.798149ms)
'test timed out after 30000ms'
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 (101.198936ms)
Error [CliError]: broker channel broken: reply for another request
at fail (file:///home/jwoltje/darkwing-scratch/r41c/wt/packages/cli/src/host.mjs:166:46)
at breakChannel (file:///home/jwoltje/darkwing-scratch/r41c/wt/packages/cli/src/host.mjs:172:22)
at ChildProcess.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41c/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:337:1
✖ a launch client that never closes its side doesn't hold the host's close (104.110192ms)
Error [CliError]: broker channel broken: reply for another request
at fail (file:///home/jwoltje/darkwing-scratch/r41c/wt/packages/cli/src/host.mjs:166:46)
at breakChannel (file:///home/jwoltje/darkwing-scratch/r41c/wt/packages/cli/src/host.mjs:172:22)
at ChildProcess.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41c/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:369:1
✖ a runner that ignores SIGTERM is killed when the host closes (114.572483ms)
Error [CliError]: broker channel broken: reply for another request
at fail (file:///home/jwoltje/darkwing-scratch/r41c/wt/packages/cli/src/host.mjs:166:46)
at breakChannel (file:///home/jwoltje/darkwing-scratch/r41c/wt/packages/cli/src/host.mjs:172:22)
at ChildProcess.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41c/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/trackers-boot.test.mjs:15:1
✖ bootConfig trackers reach the S3 adapter in the real broker child, which goes ready against a fake Vikunja (254.292384ms)
Error [CliError]: broker channel broken: reply for another request
at fail (file:///home/jwoltje/darkwing-scratch/r41c/wt/packages/cli/src/host.mjs:166:46)
at breakChannel (file:///home/jwoltje/darkwing-scratch/r41c/wt/packages/cli/src/host.mjs:172:22)
at ChildProcess.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41c/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,61 @@
✔ sessionModel: agent vars win, then the system's execution settings (10.246343ms)
✔ a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory (3.951755ms)
✔ a claude-code bundle adds the wrapped gate hook and the MCP config (2.402535ms)
✔ a bundle is written once: an existing file refuses (1.934816ms)
✔ a path with a single quote can't go into the hook command (2.262614ms)
✔ allow exits 0, a deny exits 2 with the reason on stderr (111.091382ms)
✔ a missing or wrong policy, or a bad event, exits 2 (74.793876ms)
✔ the bundle's wrapped command: a missing gate or node still blocks (1085.327641ms)
✔ claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist (717.967982ms)
✔ claude: the hook alone blocks a path outside the workspace (443.025053ms)
✔ claude: a second turn resumes the first turn's session (695.566229ms)
✔ claude adapter: --restricted is always passed (5.008005ms)
✔ claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do (742.36088ms)
✔ claude: a missing hook or MCP file refuses before claude starts (7.341431ms)
✔ pi: policy tools and typed tools pass, anything else is blocked (2.755031ms)
✔ claude: builtins map from pi names, typed tools need the mcp prefix (0.844161ms)
✔ file tool paths must resolve inside the workspace (1.281207ms)
✔ pi's own path normalisation can't be used to step out (0.910982ms)
✔ a symlink inside the workspace that points out is outside (0.713072ms)
✔ a dangling symlink is refused at any depth, in both harnesses (2.260126ms)
✔ read is checked under every spelling pi's read would open, in both harnesses (15.58884ms)
✔ other spellings cover directories, dangling links and pi's cwd (1.712946ms)
✔ claude path fields per tool (0.739662ms)
✔ glob patterns stay inside the workspace (0.649552ms)
✔ a path that can't be checked is blocked (0.337697ms)
✔ initialize, ping and tools/list (39.889139ms)
✔ tools/call goes through the tool socket; a refusal is an isError result (31.495517ms)
✔ unknown tools and methods are JSON-RPC errors and never reach the socket (32.119051ms)
✔ a missing argument is a usage error (28.000457ms)
✔ pi: typed tools reach the socket, the gate blocks, agent_end writes the marker (349.928644ms)
✔ pi: a write through a dangling symlink is blocked, and nothing appears outside (309.448556ms)
✔ pi: a read is refused when pi would open another spelling outside (320.575498ms)
✔ pi: a missing extension refuses before any model call (6.716815ms)
✔ pi: an extension without its configuration fails pi's start (250.946967ms)
✔ founderCheck: founder variables, then a needed service without a usable token (1.020641ms)
✔ turnRequest names the sender, class, reply and decision (0.176043ms)
✔ a message becomes a turn, the answer goes back as a RESULT, SIGTERM releases and exits 0 (252.118653ms)
✔ a SIGTERM before the claim stops the runner with exit 0 and no claim (102.288037ms)
✔ typed tools carry the runner's capability; launch goes to the host's launch socket (363.281615ms)
✔ a RESULT gets no automatic reply; failed turns reply with the reason (1304.389331ms)
✔ SIGTERM during a turn kills the turn's process group and still exits 0 (149.449049ms)
✔ founder credentials stop before the claim (20) (175.841808ms)
✔ a refused claim exits 21; an ended run's capability exits 22 (194.916355ms)
✔ the launch ending under a running session exits 22 (148.831182ms)
✔ a broker that stays unreachable exits 23 after brokerRetries polls (243.531138ms)
✔ a broker that is down at the claim exits 23, not 21 (94.670369ms)
✔ no capability, or a malformed one, on stdin exits 2 (185.171748ms)
✔ a missing or malformed policy exits 2 before the claim (126.341171ms)
✔ the PM gets launch, its task verbs and the reads (5.976061ms)
✔ a coder gets no launch, no resolve_decision, and no task tools without a tracker (1.657017ms)
✔ launch only when the business's launch block names the instance as launcher (1.401222ms)
✔ an action outside the instance's authority has no tool (1.3887ms)
✔ callTool: one JSON line out, the result back, a refusal rejects (7.749218ms)
ℹ tests 53
ℹ suites 0
ℹ pass 53
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 10274.723417
@@ -0,0 +1,61 @@
✔ sessionModel: agent vars win, then the system's execution settings (14.343425ms)
✔ a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory (5.638537ms)
✔ a claude-code bundle adds the wrapped gate hook and the MCP config (4.721407ms)
✔ a bundle is written once: an existing file refuses (3.071551ms)
✔ a path with a single quote can't go into the hook command (2.859383ms)
✔ allow exits 0, a deny exits 2 with the reason on stderr (127.67789ms)
✔ a missing or wrong policy, or a bad event, exits 2 (85.407757ms)
✔ the bundle's wrapped command: a missing gate or node still blocks (1094.833934ms)
✔ claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist (789.979886ms)
✔ claude: the hook alone blocks a path outside the workspace (511.725871ms)
✔ claude: a second turn resumes the first turn's session (761.824885ms)
✔ claude adapter: --restricted is always passed (6.79173ms)
✔ claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do (804.279094ms)
✔ claude: a missing hook or MCP file refuses before claude starts (7.573808ms)
✔ pi: policy tools and typed tools pass, anything else is blocked (4.810604ms)
✔ claude: builtins map from pi names, typed tools need the mcp prefix (1.0979ms)
✔ file tool paths must resolve inside the workspace (1.851106ms)
✔ pi's own path normalisation can't be used to step out (1.45015ms)
✔ a symlink inside the workspace that points out is outside (1.21636ms)
✔ a dangling symlink is refused at any depth, in both harnesses (3.460188ms)
✔ read is checked under every spelling pi's read would open, in both harnesses (19.092284ms)
✔ other spellings cover directories, dangling links and pi's cwd (2.948261ms)
✔ claude path fields per tool (0.906414ms)
✔ glob patterns stay inside the workspace (0.937361ms)
✔ a path that can't be checked is blocked (0.425306ms)
✔ initialize, ping and tools/list (49.433065ms)
✔ tools/call goes through the tool socket; a refusal is an isError result (34.272357ms)
✔ unknown tools and methods are JSON-RPC errors and never reach the socket (35.004252ms)
✔ a missing argument is a usage error (28.537658ms)
✔ pi: typed tools reach the socket, the gate blocks, agent_end writes the marker (396.249856ms)
✔ pi: a write through a dangling symlink is blocked, and nothing appears outside (361.721059ms)
✔ pi: a read is refused when pi would open another spelling outside (383.040443ms)
✔ pi: a missing extension refuses before any model call (8.147024ms)
✔ pi: an extension without its configuration fails pi's start (291.156445ms)
✔ founderCheck: founder variables, then a needed service without a usable token (1.813443ms)
✔ turnRequest names the sender, class, reply and decision (0.285199ms)
✔ a message becomes a turn, the answer goes back as a RESULT, SIGTERM releases and exits 0 (248.184269ms)
✔ a SIGTERM before the claim stops the runner with exit 0 and no claim (117.786437ms)
✔ typed tools carry the runner's capability; launch goes to the host's launch socket (399.582458ms)
✔ a RESULT gets no automatic reply; failed turns reply with the reason (1340.303202ms)
✔ SIGTERM during a turn kills the turn's process group and still exits 0 (183.784854ms)
✔ founder credentials stop before the claim (20) (217.954933ms)
✔ a refused claim exits 21; an ended run's capability exits 22 (197.257837ms)
✔ the launch ending under a running session exits 22 (142.358893ms)
✔ a broker that stays unreachable exits 23 after brokerRetries polls (259.703652ms)
✔ a broker that is down at the claim exits 23, not 21 (82.791627ms)
✔ no capability, or a malformed one, on stdin exits 2 (187.745213ms)
✔ a missing or malformed policy exits 2 before the claim (135.068031ms)
✔ the PM gets launch, its task verbs and the reads (8.689469ms)
✔ a coder gets no launch, no resolve_decision, and no task tools without a tracker (2.328404ms)
✔ launch only when the business's launch block names the instance as launcher (2.915081ms)
✔ an action outside the instance's authority has no tool (1.95173ms)
✔ callTool: one JSON line out, the result back, a refusal rejects (11.982309ms)
ℹ tests 53
ℹ suites 0
ℹ pass 53
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 10429.882391
@@ -0,0 +1,61 @@
✔ sessionModel: agent vars win, then the system's execution settings (12.555195ms)
✔ a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory (6.170319ms)
✔ a claude-code bundle adds the wrapped gate hook and the MCP config (3.602008ms)
✔ a bundle is written once: an existing file refuses (2.523019ms)
✔ a path with a single quote can't go into the hook command (2.362886ms)
✔ allow exits 0, a deny exits 2 with the reason on stderr (115.462168ms)
✔ a missing or wrong policy, or a bad event, exits 2 (77.136936ms)
✔ the bundle's wrapped command: a missing gate or node still blocks (1083.557198ms)
✔ claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist (722.181402ms)
✔ claude: the hook alone blocks a path outside the workspace (437.74174ms)
✔ claude: a second turn resumes the first turn's session (703.59845ms)
✔ claude adapter: --restricted is always passed (5.054284ms)
✔ claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do (715.323704ms)
✔ claude: a missing hook or MCP file refuses before claude starts (10.669515ms)
✔ pi: policy tools and typed tools pass, anything else is blocked (4.211013ms)
✔ claude: builtins map from pi names, typed tools need the mcp prefix (0.977371ms)
✔ file tool paths must resolve inside the workspace (2.165975ms)
✔ pi's own path normalisation can't be used to step out (1.047375ms)
✔ a symlink inside the workspace that points out is outside (0.807179ms)
✔ a dangling symlink is refused at any depth, in both harnesses (3.000644ms)
✔ read is checked under every spelling pi's read would open, in both harnesses (14.125719ms)
✔ other spellings cover directories, dangling links and pi's cwd (1.641819ms)
✔ claude path fields per tool (0.591653ms)
✔ glob patterns stay inside the workspace (0.682848ms)
✔ a path that can't be checked is blocked (0.354795ms)
✔ initialize, ping and tools/list (44.624203ms)
✔ tools/call goes through the tool socket; a refusal is an isError result (31.050211ms)
✔ unknown tools and methods are JSON-RPC errors and never reach the socket (31.822661ms)
✔ a missing argument is a usage error (28.123451ms)
✔ pi: typed tools reach the socket, the gate blocks, agent_end writes the marker (339.309843ms)
✔ pi: a write through a dangling symlink is blocked, and nothing appears outside (317.613225ms)
✔ pi: a read is refused when pi would open another spelling outside (317.746599ms)
✔ pi: a missing extension refuses before any model call (7.622894ms)
✔ pi: an extension without its configuration fails pi's start (253.087683ms)
✔ founderCheck: founder variables, then a needed service without a usable token (1.192116ms)
✔ turnRequest names the sender, class, reply and decision (0.212611ms)
✔ a message becomes a turn, the answer goes back as a RESULT, SIGTERM releases and exits 0 (251.237059ms)
✔ a SIGTERM before the claim stops the runner with exit 0 and no claim (103.779843ms)
✔ typed tools carry the runner's capability; launch goes to the host's launch socket (372.237563ms)
✔ a RESULT gets no automatic reply; failed turns reply with the reason (1285.353563ms)
✔ SIGTERM during a turn kills the turn's process group and still exits 0 (152.95985ms)
✔ founder credentials stop before the claim (20) (181.193801ms)
✔ a refused claim exits 21; an ended run's capability exits 22 (205.747556ms)
✔ the launch ending under a running session exits 22 (154.417096ms)
✔ a broker that stays unreachable exits 23 after brokerRetries polls (243.556928ms)
✔ a broker that is down at the claim exits 23, not 21 (97.278746ms)
✔ no capability, or a malformed one, on stdin exits 2 (189.373795ms)
✔ a missing or malformed policy exits 2 before the claim (119.365017ms)
✔ the PM gets launch, its task verbs and the reads (6.794905ms)
✔ a coder gets no launch, no resolve_decision, and no task tools without a tracker (2.127387ms)
✔ launch only when the business's launch block names the instance as launcher (1.797105ms)
✔ an action outside the instance's authority has no tool (1.930041ms)
✔ callTool: one JSON line out, the result back, a refusal rejects (8.200943ms)
ℹ tests 53
ℹ suites 0
ℹ pass 53
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 10269.956866
@@ -0,0 +1,61 @@
✔ sessionModel: agent vars win, then the system's execution settings (13.519198ms)
✔ a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory (5.424125ms)
✔ a claude-code bundle adds the wrapped gate hook and the MCP config (3.200913ms)
✔ a bundle is written once: an existing file refuses (3.004429ms)
✔ a path with a single quote can't go into the hook command (2.555366ms)
✔ allow exits 0, a deny exits 2 with the reason on stderr (129.648019ms)
✔ a missing or wrong policy, or a bad event, exits 2 (81.213782ms)
✔ the bundle's wrapped command: a missing gate or node still blocks (1089.079074ms)
✔ claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist (729.439366ms)
✔ claude: the hook alone blocks a path outside the workspace (451.150684ms)
✔ claude: a second turn resumes the first turn's session (787.860007ms)
✔ claude adapter: --restricted is always passed (5.150014ms)
✔ claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do (726.554277ms)
✔ claude: a missing hook or MCP file refuses before claude starts (7.485743ms)
✔ pi: policy tools and typed tools pass, anything else is blocked (3.005067ms)
✔ claude: builtins map from pi names, typed tools need the mcp prefix (0.66828ms)
✔ file tool paths must resolve inside the workspace (1.022193ms)
✔ pi's own path normalisation can't be used to step out (1.155852ms)
✔ a symlink inside the workspace that points out is outside (1.130253ms)
✔ a dangling symlink is refused at any depth, in both harnesses (3.345858ms)
✔ read is checked under every spelling pi's read would open, in both harnesses (16.584438ms)
✔ other spellings cover directories, dangling links and pi's cwd (2.106428ms)
✔ claude path fields per tool (0.644329ms)
✔ glob patterns stay inside the workspace (0.889664ms)
✔ a path that can't be checked is blocked (0.413074ms)
✔ initialize, ping and tools/list (43.586567ms)
✔ tools/call goes through the tool socket; a refusal is an isError result (29.478652ms)
✔ unknown tools and methods are JSON-RPC errors and never reach the socket (30.109949ms)
✔ a missing argument is a usage error (27.033625ms)
✔ pi: typed tools reach the socket, the gate blocks, agent_end writes the marker (350.420799ms)
✔ pi: a write through a dangling symlink is blocked, and nothing appears outside (338.949577ms)
✔ pi: a read is refused when pi would open another spelling outside (314.221744ms)
✔ pi: a missing extension refuses before any model call (6.61959ms)
✔ pi: an extension without its configuration fails pi's start (258.129614ms)
✔ founderCheck: founder variables, then a needed service without a usable token (1.025453ms)
✔ turnRequest names the sender, class, reply and decision (0.157949ms)
✔ a message becomes a turn, the answer goes back as a RESULT, SIGTERM releases and exits 0 (281.203906ms)
✔ a SIGTERM before the claim stops the runner with exit 0 and no claim (96.136612ms)
✔ typed tools carry the runner's capability; launch goes to the host's launch socket (371.393724ms)
✔ a RESULT gets no automatic reply; failed turns reply with the reason (1290.621304ms)
✔ SIGTERM during a turn kills the turn's process group and still exits 0 (157.023919ms)
✔ founder credentials stop before the claim (20) (181.001525ms)
✔ a refused claim exits 21; an ended run's capability exits 22 (234.389133ms)
✔ the launch ending under a running session exits 22 (150.048776ms)
✔ a broker that stays unreachable exits 23 after brokerRetries polls (249.049508ms)
✔ a broker that is down at the claim exits 23, not 21 (103.779436ms)
✔ no capability, or a malformed one, on stdin exits 2 (191.082679ms)
✔ a missing or malformed policy exits 2 before the claim (128.299667ms)
✔ the PM gets launch, its task verbs and the reads (6.617572ms)
✔ a coder gets no launch, no resolve_decision, and no task tools without a tracker (2.273362ms)
✔ launch only when the business's launch block names the instance as launcher (1.795695ms)
✔ an action outside the instance's authority has no tool (1.458454ms)
✔ callTool: one JSON line out, the result back, a refusal rejects (8.824237ms)
ℹ tests 53
ℹ suites 0
ℹ pass 53
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 10303.840732
+33
View File
@@ -0,0 +1,33 @@
#!/bin/bash
R=~/darkwing-scratch/r41c/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
$R/run.sh Mw-ampm-case harness
$R/run.sh Mx-curly-once harness
$R/run.sh My-lstat-error-skips harness
$R/run.sh Mz-spell-no-normalise harness
$R/run.sh MA-pi-only harness
$R/run.sh MB-no-real-base harness
$R/run.sh MC-no-nfd-curly harness
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,73 @@
# 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;"),
# Round 3: the R4 spelling check.
"Mw-ampm-case": ("packages/harness/src/gate.mjs", "/ (AM|PM)\\./gi", "/ (AM|PM)\\./g"),
"Mx-curly-once": ("packages/harness/src/gate.mjs", "v.replace(/'/g, \"\\u2019\")", "v.replace(/'/, \"\\u2019\")"),
"My-lstat-error-skips": ("packages/harness/src/gate.mjs",
"if (error.code === \"ENOTDIR\") continue;\n return `${tool} path can't be checked under another spelling: ${error.code ?? error.message}`;",
"continue;"),
"Mz-spell-no-normalise": ("packages/harness/src/gate.mjs", "const s = normalise(p);\n const bases", "const s = p;\n const bases"),
"MA-pi-only": ("packages/harness/src/gate.mjs", "if (tool === (claude ? \"Read\" : \"read\")) {", "if (tool === \"read\") {"),
"MB-no-real-base": ("packages/harness/src/gate.mjs",
"[resolve(workspace, s), resolve(realpathSync(workspace), s)]", "[resolve(workspace, s)]"),
"MC-no-nfd-curly": ("packages/harness/src/gate.mjs", "curly(r), curly(nfd)]", "curly(r)]"),
}
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/r41c/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
@@ -0,0 +1,87 @@
Ma-late-signals-cli.txt: pass 79 fail 1
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
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
bus start --pm: the PM runs under its own PID namespace, registered, with a manifest
the PM launches a coder through its launch tool; the coder answers; refusals name their code
Ma-late-signals-harness.txt: pass 50 fail 3
a message becomes a turn, the answer goes back as a RESULT, SIGTERM releases and exits 0
a SIGTERM before the claim stops the runner with exit 0 and no claim
SIGTERM during a turn kills the turn's process group and still exits 0
MA-pi-only-harness.txt: pass 52 fail 1
read is checked under every spelling pi's read would open, in both harnesses
MB-no-real-base-harness.txt: pass 52 fail 1
other spellings cover directories, dangling links and pi's cwd
Mb-runs-set-early-bus.txt: pass 73 fail 1
a restarted broker refuses to rebind an ended run; a refused rebind leaves the run unbound
Mb-runs-set-early-cli.txt: pass 83 fail 0
MC-no-nfd-curly-harness.txt: pass 51 fail 2
pi: a read is refused when pi would open another spelling outside
read is checked under every spelling pi's read would open, in both harnesses
Mc-no-run-ended-bus.txt: pass 73 fail 1
a restarted broker refuses to rebind an ended run; a refused rebind leaves the run unbound
Mc-no-run-ended-cli.txt: pass 81 fail 2
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
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
Md-no-instance-running-cli.txt: pass 81 fail 2
bus start --pm: the PM runs under its own PID namespace, registered, with a manifest
the PM launches a coder through its launch tool; the coder answers; refusals name their code
Me-no-authorize-cli.txt: pass 82 fail 1
the PM launches a coder through its launch tool; the coder answers; refusals name their code
Mf-no-exit2-wrapper-harness.txt: pass 52 fail 1
a claude-code bundle adds the wrapped gate hook and the MCP config
Mg-no-founder-check-cli.txt: pass 83 fail 0
Mg-no-founder-check-harness.txt: pass 51 fail 2
founderCheck: founder variables, then a needed service without a usable token
founder credentials stop before the claim (20)
Mh-no-close-sigkill-cli.txt: pass 82 fail 1
a runner that ignores SIGTERM is killed when the host closes
Mi-recover-no-kill-cli.txt: pass 82 fail 1
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
Mj-no-stdin-cap-harness.txt: pass 52 fail 1
no capability, or a malformed one, on stdin exits 2
Mk-launch-line-max-cli.txt: pass 82 fail 1
an over-long launch request is refused at once, not at the 10 s idle timeout
Ml-gate-pattern-harness.txt: pass 52 fail 1
glob patterns stay inside the workspace
Mm-no-revoke-bus.txt: pass 73 fail 1
launches off refuses role.launch with launch-revoked until launches on
Mm-no-revoke-cli.txt: pass 83 fail 0
Mn-recover-no-end-cli.txt: pass 81 fail 2
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
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
Mo-policy-outside-try-harness.txt: pass 52 fail 1
a missing or malformed policy exits 2 before the claim
Mp-any-reply-cli.txt: pass 82 fail 1
a broker reply with another request's id, or none, breaks the channel and the host exits 1
Mq-no-timer-cli.txt: pass 82 fail 0
Mr-ignore-unsolicited-cli.txt: pass 82 fail 1
a broker reply with no request waiting breaks the channel and the host exits 1
Ms-follow-walk-harness.txt: pass 50 fail 3
a dangling symlink is refused at any depth, in both harnesses
other spellings cover directories, dangling links and pi's cwd
pi: a write through a dangling symlink is blocked, and nothing appears outside
Mt-no-socket-destroy-cli.txt: pass 82 fail 1
a launch client that never closes its side doesn't hold the host's close
Mu-no-restricted-harness.txt: pass 51 fail 2
claude adapter: --restricted is always passed
claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do
Mv-no-tag-bus.txt: pass 73 fail 1
broker process: launch ops authorize role.launch, record refusals and end runs
Mv-no-tag-cli.txt: pass 70 fail 11
a broker reply that misses the wait breaks the channel: the late reply answers nothing, later requests refuse, the host exits 1
a broker reply with another request's id, or none, breaks the channel and the host exits 1
a broker reply with no request waiting breaks the channel and the host exits 1
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
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
a launch client that never closes its side doesn't hold the host's close
an over-long launch request is refused at once, not at the 10 s idle timeout
a runner that ignores SIGTERM is killed when the host closes
a runner that stops at once ends its launch with the runner's reason
bootConfig trackers reach the S3 adapter in the real broker child, which goes ready against a fake Vikunja
bus start --pm: the PM runs under its own PID namespace, registered, with a manifest
the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once
the PM launches a coder through its launch tool; the coder answers; refusals name their code
Mw-ampm-case-harness.txt: pass 53 fail 0
Mx-curly-once-harness.txt: pass 53 fail 0
My-lstat-error-skips-harness.txt: pass 53 fail 0
Mz-spell-no-normalise-harness.txt: pass 53 fail 0
+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/r41c/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,30 @@
Ma-late-signals (packages/harness/src/runner.mjs): harness rc 1 pass 50 fail 3; cli rc 1 pass 79 fail 1;
Mb-runs-set-early (packages/bus/src/broker.mjs): bus rc 1 pass 73 fail 1; cli rc 0 pass 83 fail 0;
Mc-no-run-ended (packages/bus/src/broker.mjs): bus rc 1 pass 73 fail 1; cli rc 1 pass 81 fail 2;
Md-no-instance-running (packages/cli/src/launcher.mjs): cli rc 1 pass 81 fail 2;
Me-no-authorize (packages/cli/src/launcher.mjs): cli rc 1 pass 82 fail 1;
Mf-no-exit2-wrapper (packages/harness/src/bundle.mjs): harness rc 1 pass 52 fail 1;
Mg-no-founder-check (packages/harness/src/runner.mjs): harness rc 1 pass 51 fail 2; cli rc 0 pass 83 fail 0;
Mh-no-close-sigkill (packages/cli/src/launcher.mjs): cli rc 1 pass 82 fail 1;
Mi-recover-no-kill (packages/cli/src/launcher.mjs): cli rc 1 pass 82 fail 1;
Mj-no-stdin-cap (packages/harness/src/runner.mjs): harness rc 1 pass 52 fail 1;
Mk-launch-line-max (packages/cli/src/launcher.mjs): cli rc 1 pass 82 fail 1;
Ml-gate-pattern (packages/harness/src/gate.mjs): harness rc 1 pass 52 fail 1;
Mm-no-revoke (packages/bus/src/broker.mjs): bus rc 1 pass 73 fail 1; cli rc 0 pass 83 fail 0;
Mn-recover-no-end (packages/cli/src/launcher.mjs): cli rc 1 pass 81 fail 2;
Mo-policy-outside-try (packages/harness/src/runner.mjs): harness rc 1 pass 52 fail 1;
Mp-any-reply (packages/cli/src/host.mjs): cli rc 1 pass 82 fail 1;
Mq-no-timer (packages/cli/src/host.mjs): cli rc 1 pass 82 fail 0;
Mr-ignore-unsolicited (packages/cli/src/host.mjs): cli rc 1 pass 82 fail 1;
Ms-follow-walk (packages/harness/src/gate.mjs): harness rc 1 pass 50 fail 3;
Mt-no-socket-destroy (packages/cli/src/launcher.mjs): cli rc 1 pass 82 fail 1;
Mu-no-restricted (adapters/claude/adapter.sh): harness rc 1 pass 51 fail 2;
Mv-no-tag (packages/bus/src/process.mjs): bus rc 1 pass 73 fail 1; cli rc 1 pass 70 fail 11;
Mw-ampm-case (packages/harness/src/gate.mjs): harness rc 0 pass 53 fail 0;
Mx-curly-once (packages/harness/src/gate.mjs): harness rc 0 pass 53 fail 0;
My-lstat-error-skips (packages/harness/src/gate.mjs): harness rc 0 pass 53 fail 0;
Mz-spell-no-normalise (packages/harness/src/gate.mjs): harness rc 0 pass 53 fail 0;
MA-pi-only (packages/harness/src/gate.mjs): harness rc 1 pass 52 fail 1;
MB-no-real-base (packages/harness/src/gate.mjs): harness rc 1 pass 52 fail 1;
MC-no-nfd-curly (packages/harness/src/gate.mjs): harness rc 1 pass 51 fail 2;
DONE
+13
View File
@@ -0,0 +1,13 @@
#!/bin/bash
# survivors.sh: probe/spell-edges.mjs under each R4 mutant the suite misses,
# printing only the cases whose decision changed (BAD lines).
cd ~/darkwing-scratch/r41c/wt
export TMPDIR=~/darkwing-scratch/tmp
for m in Mw-ampm-case Mx-curly-once My-lstat-error-skips Mz-spell-no-normalise; do
f=$(python3 ../mut/mutate.py "$m" --file-only)
cp -p "$f" ../mut/backup.tmp
python3 ../mut/mutate.py "$m" > /dev/null
echo "== $m"
WT=$PWD node ../probe/spell-edges.mjs | grep '^BAD' || echo " no case changed"
cp -p ../mut/backup.tmp "$f" && rm ../mut/backup.tmp
done
@@ -0,0 +1,82 @@
✔ launch identity is stamped, payload identity is refused and stale holder cannot send (220.957629ms)
✔ decision classes route from policy; gated resolution is human-only, choice and target must match (227.441268ms)
✔ claim exclusion, holder release, gated revoke and rerouting to a new holder are atomic (256.477003ms)
✔ launch events require a human CLI capability; generic emit cannot forge authority events (152.974929ms)
✔ within-role decisions close atomically and invalid options or blocking omissions refuse (153.019134ms)
✔ observer capabilities read human inbox but cannot mutate or forge launch identity (148.24436ms)
✔ task action subjects and linked decision trail are complete and ordered (134.557177ms)
✔ launch binding is durable and reconnecting requires the identical trusted record (86.508128ms)
✔ business isolation includes inherited object names and cross-business message references (145.63958ms)
✔ authority never transfers between action, run, target, unresolved or replaced role holder (209.966328ms)
✔ task projection uses schema current view, skipping earlier and equal-start polls (103.089921ms)
✔ revocation permanently bars the old run from reclaiming first, including after broker restart (179.260873ms)
✔ empty message references refuse before storage; refusal-evidence failure stays a typed error (106.37895ms)
✔ both arbiters require human resolution when their cross-role route is themselves (175.276721ms)
✔ S1 adapter takes resolved limits and refs, rejects mismatched instance, never mutates input (2.23085ms)
✔ only validated broker references load; returned data and exceptions cannot expose a known token (5.510591ms)
✔ bad file modes, symlinks, repository/data paths, malformed tokens and missing dates refuse (2.644612ms)
✔ expiry refuses use and env references never become client data (1.017741ms)
✔ S1 parsed service refs work, service mismatch refuses, Gitea rotation due is a warning state (2.409037ms)
✔ opaque tokens shorter than 16 characters refuse before use (0.585379ms)
✔ endLaunch writes session.ended, releases the run claim and kills its capabilities (190.502933ms)
✔ endLaunch refuses an unknown run, a second end and a rebind of the ended run (122.686404ms)
✔ a restarted broker refuses to rebind an ended run; a refused rebind leaves the run unbound (131.085157ms)
✔ endLaunch leaves a claim another run took alone (167.025936ms)
✔ refuse records action.refused against the caller with the code only (128.483213ms)
✔ launches off refuses role.launch with launch-revoked until launches on (166.469245ms)
✔ broker process: launch ops authorize role.launch, record refusals and end runs (246.29235ms)
✔ human proof binds CLI entry, process start and nonce; agents and incomplete ancestry refuse (2.214793ms)
✔ process reader gets own kernel identity without exposing environment values (1.485511ms)
✔ EACCES ancestor environments skip only markers; commands and registered launches still refuse (1.31949ms)
✔ real pid 1 remains inspectable when its environment is protected (0.399904ms)
✔ within-role sends cite an open gated launch decision without spending it or naming it in grants (244.629581ms)
✔ missing and foreign-business citations refuse and roll back message and grant (164.388067ms)
✔ cross-role sends still need a matching resolved decision and consume it once (240.284956ms)
✔ broker process binds trusted launches, offers reader capabilities, refuses human mutation, closes cleanly (200.539371ms)
✔ startup token refusal returns safe code without value or partial listening broker (37.144047ms)
✔ loaded fixture token is absent from socket replies and SQLite, including refusal evidence (154.358827ms)
✔ killed broker leaves an explicit stale lock; another process cannot silently reclaim it (147.005744ms)
✔ trusted host registers later launches; socket clients never have a registration verb (180.844124ms)
✔ runtime excludes declared project roots even when host supplies no repoRoots (37.498785ms)
✔ a refused launch binding leaves the broker and existing capabilities alive; bad protocol stops it (163.513745ms)
✔ v3b prototype refusals, views and append-only mutations (1032.775158ms)
✔ gated approval authorizes once, survives store reopen, and fresh approval works (288.534795ms)
✔ another run cannot consume an approval; a failed check leaves it usable (203.213167ms)
✔ two scheduled callers have exactly one grant and one consumed refusal (163.083798ms)
✔ failed commit rolls consumption back; cross-role consumes and within-role stays reusable (287.356285ms)
✔ class drift gated to cross-role refuses before consumption (195.331539ms)
✔ class drift cross-role to gated refuses before consumption (173.765127ms)
✔ class drift gated to within-role refuses before consumption (152.22207ms)
✔ class drift cross-role to within-role refuses before consumption (173.73842ms)
✔ class drift within-role to gated refuses before consumption (150.684848ms)
✔ class drift within-role to cross-role refuses before consumption (139.457096ms)
✔ message.send consumes approval and prevents a later send or authorize (174.93086ms)
✔ role.revoke consumes approval and prevents a later revoke or authorize (189.975834ms)
✔ creates private WAL store and excludes a second writer until explicit close (177.263297ms)
✔ rollback is atomic and schema metadata is checked against trusted DDL, not just itself (160.612044ms)
✔ existing empty database and symlink runtime directory refuse, never initialize over damage (168.487262ms)
✔ crash during a transaction recovers no partial event after explicit fixture-only lock removal (162.687402ms)
✔ writer refuses mixed at/read_at forms atomically, even through trusted SQL helpers (96.912755ms)
✔ async transactions refuse before invoking their function (83.602723ms)
✔ recordTask keeps sync reads and a role write apart (210.413533ms)
✔ read_at must be one canonical UTC format, so the projection compares strings safely (102.424219ms)
✔ a bad entry refuses the whole record (91.011323ms)
✔ taskView reads the projection for one business (125.015906ms)
✔ requestTask hands only a holder and a task verb to the handler, and records refusals (242.31508ms)
✔ the server sends task verbs to the adapter with its own timeout; other verbs stay synchronous (387.647203ms)
✔ without an adapter the server refuses every task verb (176.993898ms)
✔ the runtime refuses an invalid adapter and closes a valid one (155.975359ms)
✔ the process loads the S3 adapter from plain-data trackers (222.273341ms)
✔ socket capability stamps launch identity; shared views use wire, no SQL client (195.530481ms)
✔ two wire claims serialize; a lost reply never automatically retries (156.979483ms)
✔ malformed, oversized and identity-forging envelopes refuse without echoing input (105.724575ms)
✔ client preserves UTF-8 when a response divides a multibyte character (12.436632ms)
✔ committed mutation followed by dropped reply reports unknown and is never retried (141.636338ms)
ℹ tests 74
ℹ suites 0
ℹ pass 74
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 2380.39928
@@ -0,0 +1,68 @@
✔ config directory and file path follow MOSAIC_CONFIG (1.24599ms)
✔ the fixture business validates and comes back frozen (3.477502ms)
✔ two instances may share a definition (1.074218ms)
✔ top-level refusals (3.353058ms)
✔ arbiters and projects (4.747791ms)
✔ role instances (2.45371ms)
✔ Vikunja bots (5.617531ms)
✔ a role without Vikunja takes no tracker block (1.915168ms)
✔ credential references match the definition's services (2.246049ms)
✔ launch (6.396058ms)
✔ loadBusiness: file checks (1.582897ms)
✔ loadBusiness: not a regular file (37.489832ms)
✔ loading writes nothing (1.21926ms)
✔ names that are Object.prototype properties don't count as declared (2.611388ms)
✔ the shipped example refuses as written and validates once filled in (0.527522ms)
✔ usage errors exit 4 (273.981222ms)
✔ validate: a good business exits 0 and prints instance digests (65.020757ms)
✔ validate: project files (317.996951ms)
✔ validate: missing files and a broken system config (238.31255ms)
✔ validate: credential reference problems exit 2 and name each one (64.603872ms)
✔ validate: a token file inside the repository is refused (63.835692ms)
✔ validate: role definitions come from MOSAIC_ROLES_DIR (193.82671ms)
✔ resolve: prints one instance's record (189.018649ms)
✔ resolve: refusals (377.972892ms)
✔ parse: exactly one of file or env, plus the service's date (2.240667ms)
✔ check: a good file has no problems (0.652014ms)
✔ check never opens the file: a write-only token passes (0.291079ms)
✔ check: file problems (0.74892ms)
✔ check: token files can't live in the repository or dataRoot, even through a linked directory (0.687946ms)
✔ check: dates and environment references (0.338234ms)
✔ path and load (1.87955ms)
✔ refusals (1.021785ms)
✔ systemVars flattens the validated config (1.633904ms)
✔ precedence: system, business, project, project role, agent (4.522979ms)
✔ limits narrow the definition and never widen it (2.315791ms)
✔ role.launch stays within-role only for the instance the launch block names (4.27587ms)
✔ limits.authority without role.launch leaves the launcher with no launch block (1.543593ms)
✔ limits.authority narrows cross-role actions too (0.920622ms)
✔ classify (0.948873ms)
✔ the record carries what the broker and launcher need (0.853528ms)
✔ digest: key order doesn't matter, any value change does (6.282612ms)
✔ refusals (2.103082ms)
✔ the four shipped version 2 roles load (3.778863ms)
✔ shipped role scopes match addendum B section 2 and the SR runbook (1.259827ms)
✔ shipped authority follows the note's table (0.696413ms)
✔ version 1 files keep loading with no authority (1.18105ms)
✔ the conductor policy isn't a role (0.30637ms)
✔ a missing role file is exit 4, a symbolic link too (0.520046ms)
✔ version 2 refusals (1.75435ms)
✔ authority: closed vocabulary, no gated-only action, no overlap (3.02658ms)
✔ credentials: Gitea scopes (1.209818ms)
✔ credentials: Vikunja scopes are a group-to-verbs map from the grantable list (1.62027ms)
✔ credentials: services (0.786607ms)
✔ contract: a non-empty regular Markdown file beside the role file (1.253439ms)
✔ every key names known layers and a merge rule (0.716601ms)
✔ unknown keys and wrong layers refuse (0.606192ms)
✔ types (1.471881ms)
✔ merge: defaults, then the most specific layer wins (0.284825ms)
✔ merge: limits only narrow, and provenance lists each source (0.334036ms)
✔ merge doesn't change its inputs (0.116565ms)
ℹ tests 60
ℹ suites 0
ℹ pass 60
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 1840.166773
@@ -0,0 +1,93 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (150.116076ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (143.318043ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (112.64667ms)
✔ decide prints a declining choice as declining (123.3612ms)
✔ an unknown outcome is reported once and never resent (104.492112ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (95.978405ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (103.096033ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (62.80251ms)
✔ every human command refuses inside an agent run before it touches the bus (87.799277ms)
✔ usage errors exit 4; no business and no host is a usage error (98.098188ms)
✔ agents and tasks print through the broker (97.471494ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (2.758848ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (46.779971ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (29.787435ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (35.958922ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (33.101958ms)
✔ a business without tracker.baseUrl gets no trackers entry (30.721242ms)
✔ an unknown business and a broken system config refuse with exit 3 (54.748785ms)
✔ empty views say so (0.969908ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (1.121415ms)
✔ tasks print the tracker fields the snapshot carries (0.156025ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (928.595541ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (214.938702ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (152.527975ms)
✔ a broker reply that misses the wait breaks the channel: the late reply answers nothing, later requests refuse, the host exits 1 (1135.79737ms)
✔ a broker reply with another request's id, or none, breaks the channel and the host exits 1 (244.109429ms)
✔ a broker reply with no request waiting breaks the channel and the host exits 1 (145.911091ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (222.107069ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (183.54173ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (110.395017ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (153.393648ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (99.687122ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (159.092763ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (23.422141ms)
✔ bus stop refuses to signal a live pid that is not a bus host (202.394443ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (213.690305ms)
✔ bus start refuses with exit 3 without a notifier config (91.015135ms)
✔ bus start runs until bus stop; status reports it while it runs (760.738611ms)
✔ bus-service.sh renders the unit and installs it into a given directory (27.073693ms)
✔ bus start --pm: the PM runs under its own PID namespace, registered, with a manifest (1322.432308ms)
✔ the PM launches a coder through its launch tool; the coder answers; refusals name their code (579.474875ms)
✔ a runner that stops at once ends its launch with the runner's reason (353.845585ms)
✔ 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 (698.635983ms)
✔ 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 (3623.043627ms)
✔ a malformed sessions.json refuses the host start with exit 3 and stays as it was (105.759853ms)
✔ an over-long launch request is refused at once, not at the 10 s idle timeout (120.992564ms)
✔ a launch client that never closes its side doesn't hold the host's close (122.451343ms)
✔ a runner that ignores SIGTERM is killed when the host closes (1229.126441ms)
✔ zoned uses the IANA zone across DST (23.875846ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (143.487992ms)
✔ two blocking decisions get two DMs with different nonces (141.62964ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.165734ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (121.478802ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (110.794449ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (104.552061ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (104.544234ms)
✔ 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 (161.012674ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (128.581453ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (125.688972ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (67.89824ms)
✔ an inbox read failure is logged and the next poll retries (0.653093ms)
✔ no Discord id reaches the journal or the log (99.433677ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (17.950527ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (22.786258ms)
✔ the journal: a whole file that is one torn line truncates to empty (12.167995ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (0.595513ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.563968ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (1.762883ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.312306ms)
✔ 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.460445ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.364422ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.421262ms)
✔ digest content stays within Discord's 2000 characters (0.255207ms)
✔ runLoop never overlaps ticks and stops after the one in flight (110.794729ms)
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 (414.716896ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (38.443911ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2145.005169ms)
✔ busExit and refuseInsideAgent (0.411218ms)
✔ talk sends a REQUEST, prints and reads everything that arrives, and stops at the reply (257.046804ms)
✔ talk --wait 0 only sends; no reply within --wait exits 1 and says where it will show (1145.387541ms)
✔ talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors (84.449481ms)
✔ launches off and on go to the broker and change the business's launch state (80.802272ms)
✔ launches list reads sessions.json and marks a stale entry; stop reports it and refuses a non-runner (80.436899ms)
ℹ tests 83
ℹ suites 0
ℹ pass 83
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 8247.060518
@@ -0,0 +1,61 @@
✔ sessionModel: agent vars win, then the system's execution settings (13.637197ms)
✔ a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory (4.731475ms)
✔ a claude-code bundle adds the wrapped gate hook and the MCP config (3.48024ms)
✔ a bundle is written once: an existing file refuses (2.275533ms)
✔ a path with a single quote can't go into the hook command (2.368935ms)
✔ allow exits 0, a deny exits 2 with the reason on stderr (140.177324ms)
✔ a missing or wrong policy, or a bad event, exits 2 (89.826311ms)
✔ the bundle's wrapped command: a missing gate or node still blocks (1101.434813ms)
✔ claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist (835.263927ms)
✔ claude: the hook alone blocks a path outside the workspace (490.443322ms)
✔ claude: a second turn resumes the first turn's session (729.198843ms)
✔ claude adapter: --restricted is always passed (5.086875ms)
✔ claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do (764.394402ms)
✔ claude: a missing hook or MCP file refuses before claude starts (7.677881ms)
✔ pi: policy tools and typed tools pass, anything else is blocked (3.616418ms)
✔ claude: builtins map from pi names, typed tools need the mcp prefix (0.937758ms)
✔ file tool paths must resolve inside the workspace (1.922599ms)
✔ pi's own path normalisation can't be used to step out (1.648528ms)
✔ a symlink inside the workspace that points out is outside (1.157723ms)
✔ a dangling symlink is refused at any depth, in both harnesses (3.588518ms)
✔ read is checked under every spelling pi's read would open, in both harnesses (16.238233ms)
✔ other spellings cover directories, dangling links and pi's cwd (1.689302ms)
✔ claude path fields per tool (0.684809ms)
✔ glob patterns stay inside the workspace (1.116364ms)
✔ a path that can't be checked is blocked (0.413247ms)
✔ initialize, ping and tools/list (54.370572ms)
✔ tools/call goes through the tool socket; a refusal is an isError result (39.516544ms)
✔ unknown tools and methods are JSON-RPC errors and never reach the socket (34.076324ms)
✔ a missing argument is a usage error (32.619108ms)
✔ pi: typed tools reach the socket, the gate blocks, agent_end writes the marker (395.399077ms)
✔ pi: a write through a dangling symlink is blocked, and nothing appears outside (348.63014ms)
✔ pi: a read is refused when pi would open another spelling outside (351.77974ms)
✔ pi: a missing extension refuses before any model call (6.947637ms)
✔ pi: an extension without its configuration fails pi's start (283.349035ms)
✔ founderCheck: founder variables, then a needed service without a usable token (1.258161ms)
✔ turnRequest names the sender, class, reply and decision (0.250471ms)
✔ a message becomes a turn, the answer goes back as a RESULT, SIGTERM releases and exits 0 (232.046776ms)
✔ a SIGTERM before the claim stops the runner with exit 0 and no claim (99.703854ms)
✔ typed tools carry the runner's capability; launch goes to the host's launch socket (408.859537ms)
✔ a RESULT gets no automatic reply; failed turns reply with the reason (1315.829693ms)
✔ SIGTERM during a turn kills the turn's process group and still exits 0 (163.540947ms)
✔ founder credentials stop before the claim (20) (259.39438ms)
✔ a refused claim exits 21; an ended run's capability exits 22 (239.798363ms)
✔ the launch ending under a running session exits 22 (170.336663ms)
✔ a broker that stays unreachable exits 23 after brokerRetries polls (244.161737ms)
✔ a broker that is down at the claim exits 23, not 21 (88.459239ms)
✔ no capability, or a malformed one, on stdin exits 2 (199.612126ms)
✔ a missing or malformed policy exits 2 before the claim (129.299183ms)
✔ the PM gets launch, its task verbs and the reads (8.114929ms)
✔ a coder gets no launch, no resolve_decision, and no task tools without a tracker (2.636439ms)
✔ launch only when the business's launch block names the instance as launcher (1.931234ms)
✔ an action outside the instance's authority has no tool (1.800391ms)
✔ callTool: one JSON line out, the result back, a refusal rejects (8.964112ms)
ℹ tests 53
ℹ suites 0
ℹ pass 53
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 10356.533
@@ -0,0 +1,35 @@
✔ resolveSeat: by name under --repo resolves the repo layout (1.236158ms)
✔ resolveSeat: by path resolves the fleet layout (0.327416ms)
✔ resolveSeat: refusals for missing dir, missing launch.sh, non-executable launch.sh, invalid name, and unknown layout (0.720241ms)
✔ tmuxContext: outside tmux, default socket, custom socket, and exec failure (0.661439ms)
✔ makeRegistration produces a record that validates; each shape violation throws SeatError (0.797975ms)
✔ writeRegistration/readRegistration: round trip, permissions, absence, and malformed records (1.246313ms)
✔ updateTask: changes task, taskSetBy and updatedAt only, and refuses appropriately (1.116987ms)
✔ CLI launch: registers, execs the fake launch script, and passes args through (27.532187ms)
✔ CLI launch: --harness lands in the record (32.857114ms)
✔ CLI launch: the launch script's own exit code passes through (31.387682ms)
✔ CLI launch: relaunching a seat rewrites the one registration record (59.054508ms)
✔ CLI launch: omitting --task records an empty string, not null (30.243268ms)
✔ CLI seat task: updates only the task after a launch, and refuses on an unlaunched seat (85.145837ms)
✔ CLI refusals: no args, unknown flag, missing config, already-registered env, and exec failure (133.457854ms)
✔ samePath: equal paths, symlinked dirs, distinct dirs, and non-strings (0.397508ms)
✔ resolveSetBy: explicit --by wins over the environment; absent or empty environment gives unknown; invalid explicit or environment values refuse with exit 4 (0.572516ms)
✔ 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.728897ms)
✔ 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.866342ms)
✔ 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 (271.441109ms)
✔ family: exactly one launch.max key in the model name, else null (0.663184ms)
✔ sessionEnv passes only the allowlist, the repo's bin on PATH, and the run id (0.653981ms)
✔ newRun: short ids, 0700 directories, and a refusal when the socket path won't fit (1.49126ms)
✔ session file and launch log: 0600, the session file written once (0.962772ms)
✔ endReason maps the runner's exit codes; a signal is killed (0.119269ms)
✔ Registry mirrors to sessions.json; readSessions marks live entries; bad files refuse (1.234913ms)
✔ stopSession refuses an unknown run, reports a stale one, and won't signal a pid that isn't a runner (1.007553ms)
✔ a session runs under unshare as pid 1 of its namespace, claims, answers, and stops on mosaic stop (311.632155ms)
ℹ tests 27
ℹ suites 0
ℹ pass 27
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 730.855867
@@ -0,0 +1,12 @@
== MODE=read given workspace <R>/data/ws, real <R>/deep/store/ws
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/r41c/wt2/packages/harness/src/claude-gate.mjs <R>/bundle/policy.json || exit 2]: mosaic gate: Read path is outside the workspace: <R>/deep/data/ws/secret.txt\n"}]
outside after the run: secret.txt
workspace after the run:
== MODE=write given workspace <R>/data/ws, real <R>/deep/store/ws
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/r41c/wt2/packages/harness/src/claude-gate.mjs <R>/bundle/policy.json || exit 2]: mosaic gate: Write path is outside the workspace: <R>/deep/data/ws/planted.txt\n"}]
outside after the run: secret.txt
workspace after the run:
@@ -0,0 +1,13 @@
== MODE=read given workspace <R>/data/ws, real <R>/deep/store/ws
gate decide: {"allow":true}
exit 0; stderr:
tool_result sent back: [{"is_error":false,"content":"SECRET-OUTSIDE-THE-WORKSPACE\n"}]
outside after the run: secret.txt
workspace after the run:
== MODE=write given workspace <R>/data/ws, real <R>/deep/store/ws
gate decide: {"allow":true}
exit 0; stderr:
tool_result sent back: [{"is_error":false,"content":"Successfully wrote to ../../data/ws/planted.txt"}]
outside after the run: planted.txt
secret.txt
workspace after the run:
@@ -0,0 +1,13 @@
== MODE=read given workspace <R>/data/ws, real <R>/deep/store/ws
gate decide: {"allow":true}
exit 0; stderr:
tool_result sent back: [{"is_error":false,"content":"SECRET-OUTSIDE-THE-WORKSPACE\n"}]
outside after the run: secret.txt
workspace after the run:
== MODE=write given workspace <R>/data/ws, real <R>/deep/store/ws
gate decide: {"allow":true}
exit 0; stderr:
tool_result sent back: [{"is_error":false,"content":"Successfully wrote to ../../data/ws/planted.txt"}]
outside after the run: planted.txt
secret.txt
workspace after the run:
@@ -0,0 +1,16 @@
== MODE=quote asked n o t e s ' s . t x t
gate decide: {"allow":false,"reason":"mosaic gate: read path is outside the workspace under another spelling: notes's.txt -> \"/home/jwoltje/darkwing-scratch/tmp/r41-pvariant-L0q0/ws/notes’s.txt\""}
exit 0; stderr:
tool_result sent back: [{"is_error":true,"content":"mosaic gate: read path is outside the workspace under another spelling: notes's.txt -> \"/home/jwoltje/darkwing-scratch/tmp/r41-pvariant-L0q0/ws/notes’s.txt\""}]
== MODE=ampm asked s h o t 9 . 4 1 A M . p n g
gate decide: {"allow":false,"reason":"mosaic gate: read path is outside the workspace under another spelling: shot 9.41 AM.png -> \"/home/jwoltje/darkwing-scratch/tmp/r41-pvariant-7MBP/ws/shot 9.41 AM.png\""}
exit 0; stderr:
tool_result sent back: [{"is_error":true,"content":"mosaic gate: read path is outside the workspace under another spelling: shot 9.41 AM.png -> \"/home/jwoltje/darkwing-scratch/tmp/r41-pvariant-7MBP/ws/shot 9.41 AM.png\""}]
== MODE=nfd asked r 303 251 s u m 303 251 . t x t
gate decide: {"allow":false,"reason":"mosaic gate: read path is outside the workspace under another spelling: résumé.txt -> \"/home/jwoltje/darkwing-scratch/tmp/r41-pvariant-BBxV/ws/résumé.txt\""}
exit 0; stderr:
tool_result sent back: [{"is_error":true,"content":"mosaic gate: read path is outside the workspace under another spelling: résumé.txt -> \"/home/jwoltje/darkwing-scratch/tmp/r41-pvariant-BBxV/ws/résumé.txt\""}]
== 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"}]
@@ -0,0 +1,18 @@
== Mw-ampm-case
BAD 1 lowercase am [pi]: allow
BAD 1 lowercase am [claude-code]: allow
== Mx-curly-once
BAD 2 two apostrophes [pi]: allow
BAD 2 two apostrophes [claude-code]: allow
== My-lstat-error-skips
BAD 10 path below a respelled self-loop [pi]: allow
BAD 10 path below a respelled self-loop [claude-code]: allow
== Mz-spell-no-normalise
BAD 3 @ prefix [pi]: allow
BAD 4 NBSP before AM in the asked name [pi]: allow
BAD 5 file:// URL [pi]: allow
BAD 6 file:// URL, %27 [pi]: allow
BAD 3 @ prefix [claude-code]: allow
BAD 4 NBSP before AM in the asked name [claude-code]: allow
BAD 5 file:// URL [claude-code]: allow
BAD 6 file:// URL, %27 [claude-code]: allow
@@ -0,0 +1,56 @@
ok 1 lowercase am [pi]: refuse (mosaic gate: read path is outside the workspace under another spelling: shot 9.41 am.png -> "<ws>/shot 9.41 am)
pi would open "<ws>/shot 9.41 am.png"
ok 2 two apostrophes [pi]: refuse (mosaic gate: read path is outside the workspace under another spelling: a'b'c.txt -> "<ws>/a’b’c.txt")
pi would open "<ws>/a’b’c.txt"
ok 3 @ prefix [pi]: refuse (mosaic gate: read path is outside the workspace under another spelling: @notes's.txt -> "<ws>/notes’s.txt")
pi would open "<ws>/notes’s.txt"
ok 4 NBSP before AM in the asked name [pi]: refuse (mosaic gate: read path is outside the workspace under another spelling: shot 9.41 AM.png -> "<ws>/shot 9.41 AM)
pi would open "<ws>/shot 9.41 AM.png"
ok 5 file:// URL [pi]: refuse (mosaic gate: read path is outside the workspace under another spelling: file://<ws>/notes's.txt -> "/home/jwol)
pi would open "<ws>/notes’s.txt"
ok 6 file:// URL, %27 [pi]: refuse (mosaic gate: read path is outside the workspace under another spelling: file://<ws>/notes%27s.txt -> "/home/jw)
pi would open "<ws>/notes’s.txt"
ok 7 respelled dir inside, link out below it [pi]: refuse (mosaic gate: read path is outside the workspace under another spelling: dir's/x -> "<ws>/dir’s/x")
pi would open "<ws>/dir’s/x"
ok 8 respelled dir inside, plain file [pi]: allow
pi would open "<ws>/dir’s/x"
ok 9 respelled self-loop [pi]: refuse (mosaic gate: read path can't be checked under another spelling: ELOOP)
pi would open "<ws>/loop's"
ok 10 path below a respelled self-loop [pi]: refuse (mosaic gate: read path can't be checked under another spelling: ELOOP)
pi would open "<ws>/loop's/x"
ok 11 asked name exists inside, other spelling out [pi]: refuse (mosaic gate: read path is outside the workspace under another spelling: notes's.txt -> "<ws>/notes’s.txt")
pi would open "<ws>/notes's.txt"
ok 12 all families in one name, only AM/PM+NFD+curly on disk [pi]: allow
pi would open "<ws>/it's résumé 9.41 PM.txt"
ok 13 NFD+curly with a plain PM [pi]: refuse (mosaic gate: read path is outside the workspace under another spelling: it's résumé 9.41 PM.txt -> "<ws>/it’s )
pi would open "<ws>/it’s résumé 9.41 PM.txt"
ok 14 ../ws/ form [pi]: refuse (mosaic gate: read path is outside the workspace under another spelling: ../ws/x's.txt -> "<ws>/x’s.txt")
pi would open "<ws>/x’s.txt"
ok 1 lowercase am [claude-code]: refuse (mosaic gate: Read path is outside the workspace under another spelling: shot 9.41 am.png -> "<ws>/shot 9.41 am)
pi would open "<ws>/shot 9.41 am.png"
ok 2 two apostrophes [claude-code]: refuse (mosaic gate: Read path is outside the workspace under another spelling: a'b'c.txt -> "<ws>/a’b’c.txt")
pi would open "<ws>/a’b’c.txt"
ok 3 @ prefix [claude-code]: refuse (mosaic gate: Read path is outside the workspace under another spelling: @notes's.txt -> "<ws>/notes’s.txt")
pi would open "<ws>/notes’s.txt"
ok 4 NBSP before AM in the asked name [claude-code]: refuse (mosaic gate: Read path is outside the workspace under another spelling: shot 9.41 AM.png -> "<ws>/shot 9.41 AM)
pi would open "<ws>/shot 9.41 AM.png"
ok 5 file:// URL [claude-code]: refuse (mosaic gate: Read path is outside the workspace under another spelling: file://<ws>/notes's.txt -> "/home/jwol)
pi would open "<ws>/notes’s.txt"
ok 6 file:// URL, %27 [claude-code]: refuse (mosaic gate: Read path is outside the workspace under another spelling: file://<ws>/notes%27s.txt -> "/home/jw)
pi would open "<ws>/notes’s.txt"
ok 7 respelled dir inside, link out below it [claude-code]: refuse (mosaic gate: Read path is outside the workspace under another spelling: dir's/x -> "<ws>/dir’s/x")
pi would open "<ws>/dir’s/x"
ok 8 respelled dir inside, plain file [claude-code]: allow
pi would open "<ws>/dir’s/x"
ok 9 respelled self-loop [claude-code]: refuse (mosaic gate: Read path can't be checked under another spelling: ELOOP)
pi would open "<ws>/loop's"
ok 10 path below a respelled self-loop [claude-code]: refuse (mosaic gate: Read path can't be checked under another spelling: ELOOP)
pi would open "<ws>/loop's/x"
ok 11 asked name exists inside, other spelling out [claude-code]: refuse (mosaic gate: Read path is outside the workspace under another spelling: notes's.txt -> "<ws>/notes’s.txt")
pi would open "<ws>/notes's.txt"
ok 12 all families in one name, only AM/PM+NFD+curly on disk [claude-code]: allow
pi would open "<ws>/it's résumé 9.41 PM.txt"
ok 13 NFD+curly with a plain PM [claude-code]: refuse (mosaic gate: Read path is outside the workspace under another spelling: it's résumé 9.41 PM.txt -> "<ws>/it’s )
pi would open "<ws>/it’s résumé 9.41 PM.txt"
ok 14 ../ws/ form [claude-code]: refuse (mosaic gate: Read path is outside the workspace under another spelling: ../ws/x's.txt -> "<ws>/x’s.txt")
pi would open "<ws>/x’s.txt"
@@ -0,0 +1,15 @@
node-harness exit=0 ℹ pass 53 ℹ fail 0
node-seat exit=0 ℹ pass 27 ℹ fail 0
node-cli exit=0 ℹ pass 83 ℹ 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
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 '66b9e082e93f94317a4b7a9701ce01dc75cde49d'.
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 '66b9e082e93f94317a4b7a9701ce01dc75cde49d'.
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: 2333447 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/r41c/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
@@ -0,0 +1,46 @@
#!/bin/sh
# pi-cwd-dotdot.sh through a real Claude Code session: the same layout
# ($R/data -> $R/deep/store, workspace given as $R/data/ws), the candidate
# adapter, a settings.json in the bundle's form running the real
# claude-gate.mjs, and a mock API whose first answer calls Read or Write on
# "../../data/ws/X". Dummy key, 127.0.0.1 only. MODE is read or write.
set -eu
WT=$1; OUT=$2
R=$(mktemp -d "$TMPDIR/r41-ccwd-XXXX")
mkdir -p "$R/home" "$R/sess" "$R/bundle" "$R/deep/store/ws" "$R/deep/data/ws"
ln -s "$R/deep/store" "$R/data"
echo "SECRET-OUTSIDE-THE-WORKSPACE" > "$R/deep/data/ws/secret.txt"
WS="$R/data/ws"
case "$MODE" in
read) TOOL='{"name":"Read","input":{"file_path":"../../data/ws/secret.txt"}}'; TOOLS=Read ;;
write) TOOL='{"name":"Write","input":{"file_path":"../../data/ws/planted.txt","content":"PLANTED\n"}}'; TOOLS=Write ;;
esac
echo "== MODE=$MODE given workspace <R>/data/ws, real <R>/deep/store/ws" | tee -a "$OUT"
echo "the generated prompt" > "$R/bundle/prompt.md"
printf '{"harness":"claude-code","workspace":"%s","tools":["read","write"],"typed":[]}\n' "$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=$TOOL 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=$TOOLS \
MOSAIC_SYSTEM_PROMPT_FILE="$R/bundle/prompt.md" MOSAIC_REQUEST="Message m1 from jason: read it" \
MOSAIC_WORKSPACE="$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}))).split(process.argv[2]).join("<R>"));' "$R/api.log" "$R")"
echo "outside after the run: $(cd "$R/deep/data/ws" && ls)"
echo "workspace after the run: $(cd "$R/deep/store/ws" && ls)"
} | tee -a "$OUT"
rm -rf "$R"
@@ -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));
@@ -0,0 +1,56 @@
#!/bin/sh
# The primary check resolves a relative path against the workspace as
# given; Pi resolves it against its cwd, which is the workspace's real path
# (the adapter cds into it and process.cwd() is getcwd). When the given path
# runs through a link whose target sits elsewhere, ".." walks up different
# directories. Layout:
# $R/data -> $R/deep/store (the link, like a symlinked dataRoot)
# $R/deep/store/ws the workspace's real path
# $R/deep/data/ws/secret.txt outside the workspace
# Given workspace $R/data/ws. "../../data/ws/X" is $R/data/ws/X to the
# gate (inside) and $R/deep/data/ws/X to Pi (outside).
# A real Pi session, the candidate adapter and pi-extension.mjs, a mock
# Messages API on 127.0.0.1 and a dummy key. MODE is read or write.
set -eu
WT=$1; OUT=$2
R=$(mktemp -d "$TMPDIR/r41-cwd-XXXX")
mkdir -p "$R/home" "$R/agent" "$R/bundle" "$R/deep/store/ws" "$R/deep/data/ws"
ln -s "$R/deep/store" "$R/data"
echo "SECRET-OUTSIDE-THE-WORKSPACE" > "$R/deep/data/ws/secret.txt"
WS="$R/data/ws"
case "$MODE" in
read) TOOL='{"name":"read","input":{"path":"../../data/ws/secret.txt"}}' ;;
write) TOOL='{"name":"write","input":{"path":"../../data/ws/planted.txt","content":"PLANTED\n"}}' ;;
esac
echo "== MODE=$MODE given workspace <R>/data/ws, real <R>/deep/store/ws" | tee -a "$OUT"
echo "the generated prompt" > "$R/bundle/prompt.md"
printf '{"harness":"pi","workspace":"%s","tools":["read","write"],"typed":[]}\n' "$WS" > "$R/bundle/policy.json"
echo '[]' > "$R/bundle/tools.json"
TOOL_USE=$TOOL 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 --input-type=module -e '
const { decide } = await import(process.env.WT + "/packages/harness/src/gate.mjs");
const t = JSON.parse(process.argv[2]);
console.log(JSON.stringify(decide({harness:"pi",workspace:process.argv[1],tools:["read","write"],typed:[]},t.name,t.input)));' "$WS" "$TOOL")" | 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: do it" \
MOSAIC_WORKSPACE="$WS" MOSAIC_TOOLS=read,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"); 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}))).split(process.argv[2]).join("<R>"));' "$R/api.log" "$R")"
echo "outside after the run: $(cd "$R/deep/data/ws" && ls)"
echo "workspace after the run: $(cd "$R/deep/store/ws" && ls)"
} | tee -a "$OUT"
rm -rf "$R"
+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"
@@ -0,0 +1,51 @@
// Round 3 edges of the R4 spelling check, on decide() alone. Each case
// builds a fresh workspace with one link (or directory) on disk under the
// spelling Pi's read would open, then asks for the plain spelling. Prints
// the decision and, for comparison, what Pi 0.85.1's own resolveReadPath
// would open (run with the workspace's real path as cwd). Imports from the
// review worktree by absolute path (WT).
import { mkdirSync, mkdtempSync, realpathSync, symlinkSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
const WT = process.env.WT;
const { decide } = await import(`${WT}/packages/harness/src/gate.mjs`);
const { resolveReadPath } = await import(`${WT}/node_modules/@earendil-works/pi-coding-agent/dist/core/tools/path-utils.js`);
function fresh() {
const dir = mkdtempSync(join(process.env.TMPDIR, "r41c-spell-"));
const ws = join(dir, "ws");
mkdirSync(ws);
mkdirSync(join(dir, "outside"));
writeFileSync(join(dir, "outside", "secret.txt"), "s");
writeFileSync(join(ws, "a.txt"), "a");
return { dir, ws };
}
function show(name, harness, ws, asked, expect) {
const tool = harness === "pi" ? "read" : "Read";
const field = harness === "pi" ? "path" : "file_path";
const d = decide({ harness, workspace: ws, tools: ["read"], typed: [] }, tool, { [field]: asked });
let opens;
try { opens = resolveReadPath(asked, realpathSync(ws)); } catch (e) { opens = `throws ${e.code}`; }
const got = d.allow ? "allow" : "refuse";
console.log(`${got === expect ? "ok " : "BAD "} ${name} [${harness}]: ${got}${d.reason ? ` (${d.reason.replace(ws, "<ws>").slice(0, 110)})` : ""}`);
console.log(` pi would open ${JSON.stringify(opens.replace?.(ws, "<ws>") ?? opens)}`);
}
const out = (dir) => join(dir, "outside", "secret.txt");
for (const harness of ["pi", "claude-code"]) {
{ const { dir, ws } = fresh(); symlinkSync(out(dir), join(ws, "shot 9.41 am.png")); show("1 lowercase am", harness, ws, "shot 9.41 am.png", "refuse"); }
{ const { dir, ws } = fresh(); symlinkSync(out(dir), join(ws, "a’b’c.txt")); show("2 two apostrophes", harness, ws, "a'b'c.txt", "refuse"); }
{ const { dir, ws } = fresh(); symlinkSync(out(dir), join(ws, "notes’s.txt")); show("3 @ prefix", harness, ws, "@notes's.txt", "refuse"); }
{ const { dir, ws } = fresh(); symlinkSync(out(dir), join(ws, "shot 9.41 AM.png")); show("4 NBSP before AM in the asked name", harness, ws, "shot 9.41 AM.png", "refuse"); }
{ const { dir, ws } = fresh(); symlinkSync(out(dir), join(ws, "notes’s.txt")); show("5 file:// URL", harness, ws, pathToFileURL(join(ws, "notes's.txt")).href, "refuse"); }
{ const { dir, ws } = fresh(); symlinkSync(out(dir), join(ws, "notes’s.txt")); show("6 file:// URL, %27", harness, ws, `file://${ws}/notes%27s.txt`, "refuse"); }
{ const { dir, ws } = fresh(); mkdirSync(join(ws, "dir’s")); symlinkSync(out(dir), join(ws, "dir’s", "x")); show("7 respelled dir inside, link out below it", harness, ws, "dir's/x", "refuse"); }
{ const { dir, ws } = fresh(); mkdirSync(join(ws, "dir’s")); writeFileSync(join(ws, "dir’s", "x"), "x"); show("8 respelled dir inside, plain file", harness, ws, "dir's/x", "allow"); }
{ const { ws } = fresh(); symlinkSync("loop’s", join(ws, "loop’s")); show("9 respelled self-loop", harness, ws, "loop's", "refuse"); }
{ const { ws } = fresh(); symlinkSync("loop’s", join(ws, "loop’s")); show("10 path below a respelled self-loop", harness, ws, "loop's/x", "refuse"); }
{ const { dir, ws } = fresh(); writeFileSync(join(ws, "notes's.txt"), "n"); symlinkSync(out(dir), join(ws, "notes’s.txt")); show("11 asked name exists inside, other spelling out", harness, ws, "notes's.txt", "refuse"); }
{ const { dir, ws } = fresh(); symlinkSync(out(dir), join(ws, "it’s résumé 9.41 PM.txt".normalize("NFD"))); show("12 all families in one name, only AM/PM+NFD+curly on disk", harness, ws, "it's résumé 9.41 PM.txt", "allow"); }
{ const { dir, ws } = fresh(); symlinkSync(out(dir), join(ws, "it’s résumé 9.41 PM.txt".normalize("NFD"))); show("13 NFD+curly with a plain PM", harness, ws, "it's résumé 9.41 PM.txt", "refuse"); }
{ const { dir, ws } = fresh(); symlinkSync(out(dir), join(ws, "x’s.txt")); show("14 ../ws/ form", harness, ws, "../ws/x's.txt", "refuse"); }
}
process.exit(0);
+279
View File
@@ -0,0 +1,279 @@
# Row 41, slice 1 S6 (meta-harness and launching), round 3 review (Darkwing)
Issue #1523, request comment 27012, queue revs 262 and 263 (`a8ee6cc1`).
Packet: `agents/filbert/work/s6/` at `0f38236f`, base `915e00e5`, gate at
`66b9e082`, 42 files. Candidate manifest sha256
`9f0593af17902e6ec084987175a1e48affd9c71568cb2eeacc7b4cd1ef165cbe`.
Six files changed since round 2: `gate.mjs`, the harness README,
`gate.test.mjs`, `pi-session.test.mjs`, `host.test.mjs` and
`launcher.test.mjs`. Round 2: `review-r2.md`, comment 27010. Sage ruled
that round 3 fixes R4 only and that R1 to R3 hold.
Verdict: **changes**, comment 27032. R4 is fixed. I checked it against
Pi's code, with 28 edge cases and with a real Pi session, and all of it
holds. My round 2 notes are dealt with. One new finding blocks, R5, and
it isn't in the round 3 code. The gate resolves a relative Pi path against
the workspace path as given, and Pi resolves it against its cwd, which is
the workspace's real path. When the given path runs through a symlink,
`..` climbs different directories. A real Pi session read a file outside
the workspace and wrote a new one there. Round 2's candidate does the
same, and I missed it in both earlier rounds. It sits in the R1 to R3 code
Sage ruled on, so whether it goes in this row is Sage's call. The fix is
small.
## Method
- A detached worktree at `66b9e082`, then `git apply --index build.patch`
and `sha256sum -c candidate-manifest.sha256`: 42 OK. After the probes and
mutants I checked again: 42 OK (`r3/mut/manifest-after.txt`). The probes
ran on a second worktree built the same way, so the mutants could run on
the first.
- I read the interdiff against round 2 (`r3/interdiff.patch`, 6 files,
+193/−9), and `gate.mjs` in full. For R4 I reread the pinned Pi 0.85.1's
`dist/utils/paths.js` (`normalizePath`, `resolvePath`),
`dist/core/tools/path-utils.js` (`resolveReadPathAsync`) and
`dist/core/tools/read.js`, and checked that `read` and the CLI's file
arguments are the only callers of the variant lookup.
- Probes in `r3/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 `r3/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
from the repository's `node_modules`.
- 29 mutants (`r3/mut/mutate.py`, `run.sh`, `all.sh`): round 2's 22 and
seven on the R4 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 | 53/0 |
| seat | 27/0 |
| cli | 83/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 (`r3/out/test-release-docker.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, as in
round 2: a real Pi worker in Docker against zai, which would be a paid
call. I didn't make it.
## R5: a relative Pi path climbs from a different directory (blocking)
`gate.mjs:71-74`:
```js
export function insideWorkspace(workspace, p) {
const s = normalise(p);
return within(workspace, isAbsolute(s) ? resolve(s) : resolve(workspace, s));
}
```
`resolve(workspace, s)` is lexical, against the workspace path as the
policy gives it. Pi's path tools resolve with `resolveToCwd`
(`dist/utils/paths.js`), also lexical, but against Pi's cwd. The adapter
runs `cd "$MOSAIC_WORKSPACE"` (`adapters/pi/adapter.sh:26`), and Node's
`process.cwd()` is `getcwd()`, the real path. When the two differ, `..`
climbs different parents. `within()` then compares the real path of what
the gate resolved, which is inside, while Pi opens a path that is outside.
`spellings()` (line 86) already builds both bases for R4. The primary
check uses one.
`probe/pi-cwd-dotdot.sh` runs a real Pi session through the candidate
adapter and `pi-extension.mjs`, with the mock API asking for one call.
The layout:
```
$R/data -> $R/deep/store a symlink, like a symlinked dataRoot
$R/deep/store/ws the workspace's real path
$R/deep/data/ws/secret.txt outside the workspace
policy and MOSAIC_WORKSPACE $R/data/ws
```
`../../data/ws/X` is `$R/data/ws/X` to the gate, inside, and
`$R/deep/data/ws/X` to Pi, outside (`r3/out/probe-pi-cwd-dotdot.txt`):
```
read gate {"allow":true}; tool_result "SECRET-OUTSIDE-THE-WORKSPACE"
write gate {"allow":true}; "Successfully wrote to ../../data/ws/planted.txt"
outside after the run: planted.txt secret.txt
```
The write needs nothing on disk beforehand except the outside directory.
Every Pi tool with a path field goes through `insideWorkspace`, so edit,
grep, find and ls have it too. The round 2 candidate gives the same two
results (`r3/out/probe-pi-cwd-dotdot-r2.txt`). In round 2 I wrote that
`link/../x` is allowed rightly because Pi resolves `..` the same way the
gate does. That holds only when both start from the same directory, and I
didn't check that they do.
Claude Code doesn't have it. `probe/claude-cwd-dotdot.sh`, the same layout
through the hook (`r3/out/probe-claude-cwd-dotdot.txt`): Claude Code makes
the path absolute against its real cwd before the hook runs, and the gate
refuses both calls as outside, `<R>/deep/data/ws/...`.
The precondition is a workspace path that runs through a symlink to a
directory with different parents. `workspaceDir()`
(`packages/seat/src/session.mjs:46`) joins the configured `dataRoot`
without resolving it, and the runner passes that path as the policy's
workspace and as `MOSAIC_WORKSPACE`. A `dataRoot` on a symlink, such as
`~/.mosaic-dev` pointing at a bigger disk, is enough. On this host
`~/.mosaic-dev` is a real directory, so it isn't open here today.
Fix, either of:
1. In `insideWorkspace`, resolve a relative path against both the given
and the real workspace path, as `spellings()` does, and require both to
be inside. Checking only the real base would also match Pi, but both
keeps the gate right for a harness whose cwd is the given path.
2. Resolve the workspace once with `realpathSync` where the seat or the
runner builds it, so the policy, the cwd and `MOSAIC_WORKSPACE` all
carry the real path.
I'd do 1, since the gate shouldn't rely on its caller, and 2 is worth
doing as well. A `gate.test.mjs` case with the layout above in both
harnesses, and a `pi-session.test.mjs` write through it, would hold it.
## R4: fixed
`spellings()` (`gate.mjs:84-94`) normalises the path as Pi does, resolves
it against the given and the real workspace path, and builds the four
variants of `resolveReadPathAsync` from each: U+202F before ` AM.` or
` PM.` (case-insensitive, global, like Pi's regex), NFD, `'` to U+2019
everywhere, and NFD with U+2019. `otherSpelling()` refuses if any variant
exists and resolves outside, goes through a dangling link, or can't be
checked; `ENOTDIR` means the name doesn't exist and is skipped. The check
runs for Pi `read` and Claude `Read`. The other Pi path tools use
`resolveToCwd`, with no variants, so they don't need it.
It checks every variant that exists, not only the one Pi would open. That
is stricter than Pi in one case: when the asked name exists inside and a
variant links out, Pi opens the asked name and the gate still refuses. I
think that's the right side to err on.
`probe/spell-edges.mjs` (`r3/out/probe-spell-edges.txt`) runs 14 cases in
each harness and prints what Pi's own `resolveReadPath` would open
alongside the decision. 28 of 28 come out as I expected:
| Case | Decision |
|---|---|
| lowercase `am`; two apostrophes; an `@` prefix; NBSP before `AM` | refused |
| a `file://` URL, plain and with `%27` | refused |
| a respelled directory inside with a link out below it | refused |
| the same directory holding a plain file | allowed |
| a respelled self-loop, and a path below it | refused, ELOOP |
| the asked name exists inside, a variant links out | refused |
| a name with all three families, only the combined form on disk | allowed; Pi never builds that form |
| NFD and U+2019 with a plain `PM` | refused |
| `../ws/x's.txt` | refused |
`probe/pi-variant.sh`, round 2's real Pi session, rerun
(`r3/out/probe-pi-variant.txt`): `quote`, `ampm`, `nfd` and `plain` are all
refused, and the tool result Pi sends back is the gate's error. In round 2
the first three returned the outside file.
The README's respelling paragraph, the list of Pi files to recheck on an
upgrade and the "Other spellings" limit read correctly.
## Round 2 notes
1. **Mr** now has a test: a broker child sends one reply with nothing
waiting, and the host exits 1 with `broker-channel-broken`. It fails
under Mr (below).
2. **Mv**: every launcher test has a 30 s timeout. Under Mv the cli suite now ends in 61 s, with 11 failures and 2 tests cancelled at their timeout, where round 2 hung until my 900 s limit.
3. **Claude `Read`** gets the R4 check, and `spell-edges.mjs` runs every
case through it.
4. **The Ma leftover** isn't traced; Filbert lists it as a follow-up.
It happened again: after the Ma mutant's harness run, a `fake-adapter.mjs` was left running under the user systemd manager. I killed it by PID.
## Mutants
`r3/mut/summary.txt` has the raw lines.
| Mutant | Change | Result | Killed by |
|---|---|---|---|
| Ma | signal handlers installed after setup | harness 50/3, cli 79/1 | the runner's SIGTERM tests |
| Mb | broker records the run before its checks | bus 73/1, cli 83/0 | a restarted broker refuses to rebind an ended run |
| Mc | no `run-ended` refusal on rebind | bus 73/1, cli 81/2 | the rebind test, both host-died-hard tests |
| Md | no instance-running check | cli 81/2 | `bus start --pm`, the PM launches a coder |
| Me | no `authorizeLaunch` before start | cli 82/1 | the PM launches a coder |
| Mf | hook command without `\|\| exit 2` | harness 52/1 | the claude-code bundle test |
| Mg | `founderCheck` finds no founder variables | harness 51/2, cli 83/0 | the unit test and "founder credentials stop before the claim" |
| Mh | no SIGKILL after 30 s in `close()` | cli 82/1 | a runner that ignores SIGTERM |
| Mi | no SIGKILL in `recover()` | cli 82/1 | the host-died-hard test |
| Mj | no 4096-byte stdin cap | harness 52/1 | the stdin capability test |
| Mk | no `LINE_MAX` on `launch.sock` | cli 82/1 | an over-long launch request |
| Ml | no `..` check on glob patterns | harness 52/1 | glob patterns stay inside the workspace |
| Mm | no `launch-revoked` refusal | bus 73/1, cli 83/0 | launches off refuses role.launch |
| Mn | `recover()` doesn't end the run | cli 81/2 | both host-died-hard tests |
| Mo | policy parse back outside the `try` | harness 52/1 | a missing or malformed policy exits 2 |
| Mp | host takes any reply, whatever its id | cli 82/1 | a reply with another id, or none |
| Mq | no 10 s request timer | cli rc 1, 82/0, 1 cancelled | the stall test times out at 30 s, as in round 2 |
| Mr | a reply with nothing waiting is ignored | cli 82/1 | the new test: a reply with no request waiting |
| Ms | `real()` walks with a link-following check | harness 50/3 | the dangling tests and the real Pi write |
| Mt | `close()` doesn't destroy connections | cli 82/1 | a launch client that never closes its side |
| Mu | adapter without `--restricted` | harness 51/2 | both `--restricted` tests |
| Mv | `tag()` doesn't echo the id | bus 73/1, cli 70/11 | 11 fail and 2 are cancelled at their 30 s timeout; the suite ends in 61 s |
| Mw | the AM/PM regex without `i` | harness 53/0 | **survives** |
| Mx | `'` to U+2019 without `g` | harness 53/0 | **survives** |
| My | any `lstat` error skips the spelling | harness 53/0 | **survives** |
| Mz | `spellings()` skips `normalise` | harness 53/0 | **survives** |
| MA | the spelling check for Pi `read` only | harness 52/1 | every spelling, in both harnesses |
| MB | no real-path base in `spellings()` | harness 52/1 | the directories and Pi's cwd test |
| MC | no NFD plus U+2019 | harness 51/2 | the spelling test and the real Pi session |
25 of 29 killed. All 22 round 2 mutants are killed now, Mr included.
`r3/out/probe-spell-edges-survivors.txt` reruns `spell-edges.mjs` under
each survivor (`r3/mut/survivors.sh`). Each turns a refusal into an
allow, and the cases it flips are the ones I'd add to `gate.test.mjs`:
| Mutant | Cases that flip to allow, both harnesses |
|---|---|
| Mw | 1, lowercase `am` |
| Mx | 2, two apostrophes |
| My | 10, a path below a respelled self-loop |
| Mz | 3, 4, 5 and 6: `@`, NBSP, `file://` and `%27` |
Under Mw, Mx and Mz Pi would open the outside file, so these are real
gaps in the tests, not in the code. My is milder: on ELOOP Pi's `access()`
fails too and nothing opens, but the same skip would also pass EACCES.
## Notes (not blocking)
1. **Four R4 mutants survive** (above). The code is right, and four
`gate.test.mjs` cases (lowercase `am`, two apostrophes, an `@` or
`file://` path, a path below a respelled loop) would hold it. Since
R5 sends this row back anyway, they're cheap to add in the same round.
2. **Mq** is unchanged: no test fails under it, but the stall test is
cancelled at its 30 s timeout and the suite exits 1. Filbert left it
as it is, and I agree it's caught.
3. **The Ma leftover** reproduced (round 2 note 4). It's a mutant's
side effect, listed as a follow-up.
## Files
Round 1 and 2 files stay where they were. Round 3's are under `r3/`.
- `review-r3.md`: this file.
- `r3/files.txt`, `r3/candidate-manifest.sha256`: the candidate as
reviewed.
- `r3/interdiff.patch`: round 2 against round 3.
- `r3/gate.sh`, `r3/out/`: the suite runs (`summary.txt` first) and every
probe output (`probe-*.txt`).
- `r3/probe/`: `spell-edges.mjs` and round 2's `pi-variant.sh` (R4),
`pi-cwd-dotdot.sh` and `claude-cwd-dotdot.sh` (R5), `mock-api.mjs`.
- `r3/mut/`: `mutate.py`, `run.sh`, `all.sh`, `parse.sh`, `summary.txt`,
one output per mutant and package, `survivors.sh` and
`manifest-after.txt`.