docs(review): row 41 round 4 review packet, approve (darkwing)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-09 22:39:59 -05:00
co-authored by Claude Opus 5.5
parent 9f761f126d
commit c38bc54663
52 changed files with 2970 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
f996119e0afe972516408c8058c49d93192cabebbc48778cf9da5a9bbfa94a7c packages/harness/README.md
34ef8212e27f052223443c66830839517f7a54ecb6ef7d85795b3e89c65b4d4c packages/harness/package.json
22efd0bed7991561920ad29f6bf9a1ab2d7384185fab4267684a037e026d8e85 packages/harness/src/bundle.mjs
32b5090760c95eea0e1232ff36ec13a1d9b58335b2eade621196dec1ebdbe784 packages/harness/src/claude-gate.mjs
38219ee9ea8bc9239e6de375a79e98a431338ec51eee0b71e865c4efea620765 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
c406566d92f79dc74f52588549303309e6d843bc8013885c33fb8e5e12d89195 packages/harness/tests/gate.test.mjs
6e7509cfe6ad909440c25b750528360c0ba617c6c68b05d400bcd94b481c1f76 packages/harness/tests/helpers.mjs
793eb11f970e4a8eecddec4dc48c24331e4de795bc04bfadf806a2fc3a15a8b4 packages/harness/tests/mcp-server.test.mjs
6d31a44a8e9835406df201e111d88fb3e6307c5993e471cce0d533153237e451 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/r41d/wt
O=~/darkwing-scratch/r41d/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,191 @@
--- r3wt/packages/harness/README.md 2026-10-09 22:31:22.166070702 -0500
+++ wt/packages/harness/README.md 2026-10-09 22:31:15.039068214 -0500
@@ -83,6 +83,19 @@
directories first. Pi calls it from the extension, Claude Code from
`claude-gate.mjs`.
+A relative path is resolved the way Pi resolves it: against its working
+directory. Both adapters `cd` into the workspace, and a process's working
+directory is the real path, so `..` climbs the real path's parents. With
+the workspace or the dataRoot behind a symlink, those differ from the
+parents of the path as given, and `../../data/ws/x` can be inside as given
+but outside for Pi. The gate requires a relative path to be inside from
+both the real path and the path as given. The second check is stricter
+than Pi: a path that only climbs out and back in through the real path's
+parents is refused. That keeps the gate fail-closed whichever directory it
+runs in. Claude Code isn't affected the same way: it makes a path absolute
+against its own (real) working directory before the `PreToolUse` hook
+sees it, so the gate gets the path Claude Code will open.
+
Pi's `read` doesn't always open the name it is given. When that name
doesn't exist, it tries other spellings of the whole resolved path: a
narrow no-break space (U+202F) before ` AM.` or ` PM.`, the NFD form, a
--- r3wt/packages/harness/src/gate.mjs 2026-10-09 22:31:22.166883857 -0500
+++ wt/packages/harness/src/gate.mjs 2026-10-09 22:31:15.039927044 -0500
@@ -68,9 +68,15 @@
return target === root || target.startsWith(root + sep);
}
+// A relative path is resolved the way the tool resolves it: against its
+// cwd. Both adapters cd into the workspace, and a process's cwd is the real
+// path, so `..` climbs the real path's parents, not those of a workspace or
+// dataRoot given through a symlink. It must be inside against the path as
+// given too, so the check doesn't rest on how the harness was started.
export function insideWorkspace(workspace, p) {
const s = normalise(p);
- return within(workspace, isAbsolute(s) ? resolve(s) : resolve(workspace, s));
+ if (isAbsolute(s)) return within(workspace, resolve(s));
+ return within(workspace, resolve(realpathSync(workspace), s)) && within(workspace, resolve(workspace, s));
}
// Pi's read (dist/core/tools/path-utils.js resolveReadPathAsync) opens
--- r3wt/packages/harness/tests/gate.test.mjs 2026-10-09 22:31:22.167166446 -0500
+++ wt/packages/harness/tests/gate.test.mjs 2026-10-09 22:31:15.040230821 -0500
@@ -1,8 +1,8 @@
import { test } from "node:test";
import assert from "node:assert/strict";
-import { mkdirSync, symlinkSync, writeFileSync } from "node:fs";
+import { mkdirSync, realpathSync, symlinkSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
-import { join } from "node:path";
+import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { claudeBuiltins, decide, insideWorkspace } from "../src/gate.mjs";
import { scratch } from "./helpers.mjs";
@@ -158,6 +158,53 @@
blocked(decide(viaLink, "read", { path: "x.txt" }), /outside the workspace under another spelling/);
});
+// Both adapters cd into the workspace, and the tool's cwd is its real path,
+// so `..` climbs the real path's parents. Through a symlinked workspace or
+// dataRoot those differ from the given path's.
+const CLIMBS = {
+ // <dir>/a/ws -> <dir>/deep/store/ws: up two is <dir> as given, <dir>/deep for pi.
+ workspace(dir) {
+ mkdirSync(join(dir, "deep", "store", "ws"), { recursive: true });
+ mkdirSync(join(dir, "a"));
+ symlinkSync(join(dir, "deep", "store", "ws"), join(dir, "a", "ws"));
+ return { workspace: join(dir, "a", "ws"), outside: join(dir, "deep", "a", "ws"), escape: "../../a/ws", stay: "../ws", strict: "../../store/ws" };
+ },
+ // dataRoot <dir>/data -> <dir>/deep/store, the workspace under it as the
+ // launcher builds it: up four is <dir> as given, <dir>/deep for pi.
+ dataRoot(dir) {
+ mkdirSync(join(dir, "deep", "store", "workspaces", "b", "i"), { recursive: true });
+ symlinkSync(join(dir, "deep", "store"), join(dir, "data"));
+ return { workspace: join(dir, "data", "workspaces", "b", "i"), outside: join(dir, "deep", "data", "workspaces", "b", "i"), escape: "../../../../data/workspaces/b/i", stay: "../i", strict: "../../../../store/workspaces/b/i" };
+ },
+};
+
+test("a relative path climbs from the workspace's real path, in both harnesses", (t) => {
+ for (const [name, build] of Object.entries(CLIMBS)) {
+ for (const harness of ["pi", "claude-code"]) {
+ const dir = scratch(t);
+ const { workspace, outside, escape, stay, strict } = build(dir);
+ writeFileSync(join(workspace, "a.txt"), "a");
+ mkdirSync(outside, { recursive: true });
+ writeFileSync(join(outside, "secret.txt"), "s");
+ const policy = { harness, workspace, tools: ["read", "write"], typed: [] };
+ const [read, write, field] = harness === "pi" ? ["read", "write", "path"] : ["Read", "Write", "file_path"];
+ const at = `${name}, ${harness}`;
+ // As given, the escape is inside; from the real path it is outside.
+ assert.equal(resolve(workspace, escape), workspace, at);
+ assert.equal(resolve(realpathSync(workspace), escape), outside, at);
+ blocked(decide(policy, read, { [field]: `${escape}/secret.txt` }), /outside the workspace/);
+ blocked(decide(policy, write, { [field]: `${escape}/planted.txt` }), /outside the workspace/);
+ // Climbing out and back in by the same name stays inside on both paths.
+ allowed(decide(policy, read, { [field]: `${stay}/a.txt` }));
+ allowed(decide(policy, write, { [field]: `${stay}/new.txt` }));
+ // The other way round, inside for pi but outside as given, is refused
+ // too: the gate doesn't rest on the cwd the harness started in.
+ assert.equal(resolve(realpathSync(workspace), strict), realpathSync(workspace), at);
+ blocked(decide(policy, read, { [field]: `${strict}/a.txt` }), /outside the workspace/);
+ }
+ }
+});
+
test("claude path fields per tool", (t) => {
const { workspace, policy } = setup(t, "claude-code", ["read", "write", "edit", "grep", "find"]);
allowed(decide(policy, "Read", { file_path: join(workspace, "a.txt") }));
--- r3wt/packages/harness/tests/pi-session.test.mjs 2026-10-09 22:31:22.167166446 -0500
+++ wt/packages/harness/tests/pi-session.test.mjs 2026-10-09 22:31:15.040230821 -0500
@@ -12,11 +12,11 @@
const ADAPTER = join(REPO, "adapters", "pi", "adapter.sh");
-async function session(t, script) {
+async function session(t, script, place = (dir) => join(dir, "ws")) {
const dir = scratch(t);
- const workspace = join(dir, "ws");
+ const workspace = place(dir);
const agentDir = join(dir, "pi-agent");
- mkdirSync(workspace);
+ mkdirSync(workspace, { recursive: true });
mkdirSync(agentDir);
writeFileSync(join(workspace, "notes.txt"), "inside\n");
writeFileSync(join(dir, "secret.txt"), "outside\n");
@@ -153,6 +153,65 @@
assert.ok(existsSync(s.turnMarker));
});
+// The adapter cds into the workspace, and pi resolves `..` from that real
+// path. Each layout: where the workspace is given, and where `escape` lands
+// for pi (as given, it is the workspace itself).
+const CLIMBS = {
+ workspace: {
+ place(dir) {
+ mkdirSync(join(dir, "deep", "store", "ws"), { recursive: true });
+ mkdirSync(join(dir, "a"));
+ symlinkSync(join(dir, "deep", "store", "ws"), join(dir, "a", "ws"));
+ return join(dir, "a", "ws");
+ },
+ outside: (dir) => join(dir, "deep", "a", "ws"),
+ escape: "../../a/ws",
+ stay: "../ws",
+ },
+ dataRoot: {
+ place(dir) {
+ mkdirSync(join(dir, "deep", "store", "workspaces", "b", "i"), { recursive: true });
+ symlinkSync(join(dir, "deep", "store"), join(dir, "data"));
+ return join(dir, "data", "workspaces", "b", "i");
+ },
+ outside: (dir) => join(dir, "deep", "data", "workspaces", "b", "i"),
+ escape: "../../../../data/workspaces/b/i",
+ stay: "../i",
+ },
+};
+
+for (const [name, { place, outside, escape, stay }] of Object.entries(CLIMBS)) {
+ test(`pi: a relative path climbs from the real path of a ${name} behind a symlink`, async (t) => {
+ const { dir, workspace, s, env } = await session(
+ t,
+ [
+ { name: "read", input: { path: `${escape}/secret.txt` } },
+ { name: "write", input: { path: `${escape}/planted.txt`, content: "planted\n" } },
+ { name: "read", input: { path: `${stay}/notes.txt` } },
+ { name: "write", input: { path: `${stay}/fine.md`, content: "inside\n" } },
+ ],
+ place,
+ );
+ mkdirSync(outside(dir), { recursive: true });
+ writeFileSync(join(outside(dir), "secret.txt"), "SECRET-OUTSIDE\n");
+ const r = await turn(env, "Message 1 from jason, class REQUEST:\n\nread and write", workspace);
+ assert.equal(r.code, 0, r.stderr);
+ assert.doesNotMatch(r.stdout, /SECRET/);
+ const results = JSON.parse(r.stdout.trim().slice("ANSWER ".length));
+ assert.equal(results.length, 4);
+ for (const i of [0, 1]) {
+ assert.equal(results[i][0], true);
+ assert.match(results[i][1], /outside the workspace/);
+ }
+ assert.deepEqual(readdirSync(outside(dir)), ["secret.txt"]);
+ assert.ok(!existsSync(join(workspace, "planted.txt")));
+ assert.deepEqual(results[2], [false, "inside\n"]);
+ assert.equal(results[3][0], false);
+ assert.ok(existsSync(join(workspace, "fine.md")), "the write inside landed");
+ assert.ok(existsSync(s.turnMarker));
+ });
+}
+
test("pi: a missing extension refuses before any model call", async (t) => {
const { dir, api, s, env } = await session(t, []);
const r = await turn({ ...env, MOSAIC_EXTENSIONS: join(dir, "missing.mjs") }, "x", s.workspace);
@@ -0,0 +1,88 @@
✔ sessionModel: agent vars win, then the system's execution settings (9.580256ms)
✔ a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory (4.875628ms)
✔ a claude-code bundle adds the wrapped gate hook and the MCP config (3.264411ms)
✔ a bundle is written once: an existing file refuses (2.413773ms)
✔ a path with a single quote can't go into the hook command (2.401045ms)
✔ allow exits 0, a deny exits 2 with the reason on stderr (120.752066ms)
✔ a missing or wrong policy, or a bad event, exits 2 (89.898924ms)
✔ the bundle's wrapped command: a missing gate or node still blocks (1094.43891ms)
✔ claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist (771.164098ms)
✔ claude: the hook alone blocks a path outside the workspace (475.143096ms)
✔ claude: a second turn resumes the first turn's session (752.227287ms)
✔ claude adapter: --restricted is always passed (5.06431ms)
✔ claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do (705.819831ms)
✔ claude: a missing hook or MCP file refuses before claude starts (7.500421ms)
✔ pi: policy tools and typed tools pass, anything else is blocked (3.919269ms)
✔ claude: builtins map from pi names, typed tools need the mcp prefix (0.736461ms)
✔ file tool paths must resolve inside the workspace (1.265928ms)
✔ pi's own path normalisation can't be used to step out (0.898539ms)
✔ a symlink inside the workspace that points out is outside (0.774188ms)
✔ a dangling symlink is refused at any depth, in both harnesses (2.936794ms)
✖ read is checked under every spelling pi's read would open, in both harnesses (9.787623ms)
✔ other spellings cover directories, dangling links and pi's cwd (1.349726ms)
✔ a relative path climbs from the workspace's real path, in both harnesses (4.730941ms)
✔ claude path fields per tool (0.641296ms)
✔ glob patterns stay inside the workspace (0.59568ms)
✔ a path that can't be checked is blocked (0.503483ms)
✔ initialize, ping and tools/list (43.12883ms)
✔ tools/call goes through the tool socket; a refusal is an isError result (33.600682ms)
✔ unknown tools and methods are JSON-RPC errors and never reach the socket (31.281717ms)
✔ a missing argument is a usage error (27.760206ms)
✔ pi: typed tools reach the socket, the gate blocks, agent_end writes the marker (352.619197ms)
✔ pi: a write through a dangling symlink is blocked, and nothing appears outside (339.727109ms)
✔ pi: a read is refused when pi would open another spelling outside (325.182629ms)
✔ pi: a relative path climbs from the real path of a workspace behind a symlink (326.926759ms)
✔ pi: a relative path climbs from the real path of a dataRoot behind a symlink (315.373513ms)
✔ pi: a missing extension refuses before any model call (6.74773ms)
✔ pi: an extension without its configuration fails pi's start (259.176735ms)
✔ founderCheck: founder variables, then a needed service without a usable token (1.390225ms)
✔ turnRequest names the sender, class, reply and decision (0.201472ms)
✔ a message becomes a turn, the answer goes back as a RESULT, SIGTERM releases and exits 0 (307.083097ms)
✔ a SIGTERM before the claim stops the runner with exit 0 and no claim (139.973071ms)
✔ typed tools carry the runner's capability; launch goes to the host's launch socket (499.17115ms)
✔ a RESULT gets no automatic reply; failed turns reply with the reason (1380.389384ms)
✔ SIGTERM during a turn kills the turn's process group and still exits 0 (167.182531ms)
✔ founder credentials stop before the claim (20) (188.155837ms)
✔ a refused claim exits 21; an ended run's capability exits 22 (225.922157ms)
✔ the launch ending under a running session exits 22 (146.624644ms)
✔ a broker that stays unreachable exits 23 after brokerRetries polls (260.038018ms)
✔ a broker that is down at the claim exits 23, not 21 (89.780883ms)
✔ no capability, or a malformed one, on stdin exits 2 (225.852707ms)
✔ a missing or malformed policy exits 2 before the claim (120.417573ms)
✔ the PM gets launch, its task verbs and the reads (9.376178ms)
✔ a coder gets no launch, no resolve_decision, and no task tools without a tracker (2.997801ms)
✔ launch only when the business's launch block names the instance as launcher (1.988666ms)
✔ an action outside the instance's authority has no tool (2.235635ms)
✔ callTool: one JSON line out, the result back, a refusal rejects (8.109859ms)
ℹ tests 56
ℹ suites 0
ℹ pass 55
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 10598.778693
✖ 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.787623ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
true !== false
at blocked (file:///home/jwoltje/darkwing-scratch/r41d/wt/packages/harness/tests/gate.test.mjs:19:10)
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41d/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,88 @@
✔ sessionModel: agent vars win, then the system's execution settings (9.719171ms)
✔ a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory (4.201435ms)
✔ a claude-code bundle adds the wrapped gate hook and the MCP config (2.431407ms)
✔ a bundle is written once: an existing file refuses (2.102264ms)
✔ a path with a single quote can't go into the hook command (1.718452ms)
✔ allow exits 0, a deny exits 2 with the reason on stderr (116.794862ms)
✔ a missing or wrong policy, or a bad event, exits 2 (90.748979ms)
✔ the bundle's wrapped command: a missing gate or node still blocks (1101.993408ms)
✔ claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist (786.049055ms)
✔ claude: the hook alone blocks a path outside the workspace (469.934367ms)
✔ claude: a second turn resumes the first turn's session (742.318763ms)
✔ claude adapter: --restricted is always passed (4.883102ms)
✔ claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do (751.358808ms)
✔ claude: a missing hook or MCP file refuses before claude starts (7.625571ms)
✔ pi: policy tools and typed tools pass, anything else is blocked (2.881547ms)
✔ claude: builtins map from pi names, typed tools need the mcp prefix (0.744331ms)
✔ file tool paths must resolve inside the workspace (1.13976ms)
✔ pi's own path normalisation can't be used to step out (0.896935ms)
✔ a symlink inside the workspace that points out is outside (0.854833ms)
✔ a dangling symlink is refused at any depth, in both harnesses (2.812294ms)
✔ read is checked under every spelling pi's read would open, in both harnesses (12.88844ms)
✖ other spellings cover directories, dangling links and pi's cwd (1.916041ms)
✔ a relative path climbs from the workspace's real path, in both harnesses (3.618899ms)
✔ claude path fields per tool (0.621012ms)
✔ glob patterns stay inside the workspace (0.605101ms)
✔ a path that can't be checked is blocked (0.30633ms)
✔ initialize, ping and tools/list (46.391592ms)
✔ tools/call goes through the tool socket; a refusal is an isError result (35.489581ms)
✔ unknown tools and methods are JSON-RPC errors and never reach the socket (33.741286ms)
✔ a missing argument is a usage error (37.09461ms)
✔ pi: typed tools reach the socket, the gate blocks, agent_end writes the marker (366.303357ms)
✔ pi: a write through a dangling symlink is blocked, and nothing appears outside (320.685111ms)
✔ pi: a read is refused when pi would open another spelling outside (325.031972ms)
✔ pi: a relative path climbs from the real path of a workspace behind a symlink (314.408294ms)
✔ pi: a relative path climbs from the real path of a dataRoot behind a symlink (321.338996ms)
✔ pi: a missing extension refuses before any model call (7.253326ms)
✔ pi: an extension without its configuration fails pi's start (275.660021ms)
✔ founderCheck: founder variables, then a needed service without a usable token (1.305136ms)
✔ turnRequest names the sender, class, reply and decision (0.219951ms)
✔ a message becomes a turn, the answer goes back as a RESULT, SIGTERM releases and exits 0 (261.588566ms)
✔ a SIGTERM before the claim stops the runner with exit 0 and no claim (109.294993ms)
✔ typed tools carry the runner's capability; launch goes to the host's launch socket (437.963508ms)
✔ a RESULT gets no automatic reply; failed turns reply with the reason (1324.780229ms)
✔ SIGTERM during a turn kills the turn's process group and still exits 0 (197.730832ms)
✔ founder credentials stop before the claim (20) (200.574545ms)
✔ a refused claim exits 21; an ended run's capability exits 22 (199.070544ms)
✔ the launch ending under a running session exits 22 (147.394187ms)
✔ a broker that stays unreachable exits 23 after brokerRetries polls (285.77662ms)
✔ a broker that is down at the claim exits 23, not 21 (90.9201ms)
✔ no capability, or a malformed one, on stdin exits 2 (205.906255ms)
✔ a missing or malformed policy exits 2 before the claim (124.520866ms)
✔ the PM gets launch, its task verbs and the reads (8.575108ms)
✔ a coder gets no launch, no resolve_decision, and no task tools without a tracker (2.715941ms)
✔ launch only when the business's launch block names the instance as launcher (2.251489ms)
✔ an action outside the instance's authority has no tool (2.805615ms)
✔ callTool: one JSON line out, the result back, a refusal rejects (7.58421ms)
ℹ tests 56
ℹ suites 0
ℹ pass 55
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 10444.401443
✖ failing tests:
test at packages/harness/tests/gate.test.mjs:137:1
✖ other spellings cover directories, dangling links and pi's cwd (1.916041ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
true !== false
at blocked (file:///home/jwoltje/darkwing-scratch/r41d/wt/packages/harness/tests/gate.test.mjs:19:10)
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41d/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,106 @@
✔ sessionModel: agent vars win, then the system's execution settings (14.299541ms)
✔ a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory (5.780738ms)
✔ a claude-code bundle adds the wrapped gate hook and the MCP config (3.604431ms)
✔ a bundle is written once: an existing file refuses (2.734256ms)
✔ a path with a single quote can't go into the hook command (2.413831ms)
✔ allow exits 0, a deny exits 2 with the reason on stderr (110.199454ms)
✔ a missing or wrong policy, or a bad event, exits 2 (78.793969ms)
✔ the bundle's wrapped command: a missing gate or node still blocks (1088.879771ms)
✔ claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist (714.853734ms)
✔ claude: the hook alone blocks a path outside the workspace (470.857176ms)
✔ claude: a second turn resumes the first turn's session (784.713208ms)
✔ claude adapter: --restricted is always passed (5.282364ms)
✔ claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do (758.968543ms)
✔ claude: a missing hook or MCP file refuses before claude starts (7.18475ms)
✔ pi: policy tools and typed tools pass, anything else is blocked (3.258612ms)
✔ claude: builtins map from pi names, typed tools need the mcp prefix (0.984727ms)
✔ file tool paths must resolve inside the workspace (1.751711ms)
✔ pi's own path normalisation can't be used to step out (1.275253ms)
✔ a symlink inside the workspace that points out is outside (1.244755ms)
✔ a dangling symlink is refused at any depth, in both harnesses (3.65246ms)
✖ read is checked under every spelling pi's read would open, in both harnesses (8.041349ms)
✔ other spellings cover directories, dangling links and pi's cwd (1.519293ms)
✔ a relative path climbs from the workspace's real path, in both harnesses (4.833057ms)
✔ claude path fields per tool (0.725909ms)
✔ glob patterns stay inside the workspace (0.611487ms)
✔ a path that can't be checked is blocked (0.668466ms)
✔ initialize, ping and tools/list (44.543011ms)
✔ tools/call goes through the tool socket; a refusal is an isError result (30.236293ms)
✔ unknown tools and methods are JSON-RPC errors and never reach the socket (27.704757ms)
✔ a missing argument is a usage error (28.079649ms)
✔ pi: typed tools reach the socket, the gate blocks, agent_end writes the marker (341.746079ms)
✔ pi: a write through a dangling symlink is blocked, and nothing appears outside (320.111724ms)
✖ pi: a read is refused when pi would open another spelling outside (314.753427ms)
✔ pi: a relative path climbs from the real path of a workspace behind a symlink (331.960128ms)
✔ pi: a relative path climbs from the real path of a dataRoot behind a symlink (334.444342ms)
✔ pi: a missing extension refuses before any model call (6.529418ms)
✔ pi: an extension without its configuration fails pi's start (266.191834ms)
✔ founderCheck: founder variables, then a needed service without a usable token (1.131708ms)
✔ turnRequest names the sender, class, reply and decision (0.183711ms)
✔ a message becomes a turn, the answer goes back as a RESULT, SIGTERM releases and exits 0 (280.144771ms)
✔ a SIGTERM before the claim stops the runner with exit 0 and no claim (100.874182ms)
✔ typed tools carry the runner's capability; launch goes to the host's launch socket (366.9824ms)
✔ a RESULT gets no automatic reply; failed turns reply with the reason (1296.333652ms)
✔ SIGTERM during a turn kills the turn's process group and still exits 0 (187.993487ms)
✔ founder credentials stop before the claim (20) (216.463256ms)
✔ a refused claim exits 21; an ended run's capability exits 22 (265.774441ms)
✔ the launch ending under a running session exits 22 (165.246864ms)
✔ a broker that stays unreachable exits 23 after brokerRetries polls (289.815346ms)
✔ a broker that is down at the claim exits 23, not 21 (129.57063ms)
✔ no capability, or a malformed one, on stdin exits 2 (223.535501ms)
✔ a missing or malformed policy exits 2 before the claim (155.040694ms)
✔ the PM gets launch, its task verbs and the reads (6.487345ms)
✔ a coder gets no launch, no resolve_decision, and no task tools without a tracker (2.088694ms)
✔ launch only when the business's launch block names the instance as launcher (1.70404ms)
✔ an action outside the instance's authority has no tool (1.440948ms)
✔ callTool: one JSON line out, the result back, a refusal rejects (9.461132ms)
ℹ tests 56
ℹ suites 0
ℹ pass 54
ℹ fail 2
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 10346.50573
✖ 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 (8.041349ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
true !== false
at blocked (file:///home/jwoltje/darkwing-scratch/r41d/wt/packages/harness/tests/gate.test.mjs:19:10)
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41d/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 (314.753427ms)
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/r41d/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,124 @@
✔ sessionModel: agent vars win, then the system's execution settings (12.960219ms)
✔ a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory (5.112444ms)
✔ a claude-code bundle adds the wrapped gate hook and the MCP config (3.679235ms)
✔ a bundle is written once: an existing file refuses (2.812263ms)
✔ a path with a single quote can't go into the hook command (2.517504ms)
✔ allow exits 0, a deny exits 2 with the reason on stderr (136.603784ms)
✔ a missing or wrong policy, or a bad event, exits 2 (82.034771ms)
✔ the bundle's wrapped command: a missing gate or node still blocks (1094.979161ms)
✔ claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist (777.212211ms)
✔ claude: the hook alone blocks a path outside the workspace (472.515691ms)
✔ claude: a second turn resumes the first turn's session (762.499074ms)
✔ claude adapter: --restricted is always passed (4.735663ms)
✔ claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do (746.837511ms)
✔ claude: a missing hook or MCP file refuses before claude starts (7.681726ms)
✔ pi: policy tools and typed tools pass, anything else is blocked (3.542476ms)
✔ claude: builtins map from pi names, typed tools need the mcp prefix (0.71412ms)
✔ file tool paths must resolve inside the workspace (1.156309ms)
✔ pi's own path normalisation can't be used to step out (1.080151ms)
✔ a symlink inside the workspace that points out is outside (1.165845ms)
✔ a dangling symlink is refused at any depth, in both harnesses (4.072642ms)
✔ read is checked under every spelling pi's read would open, in both harnesses (20.693322ms)
✔ other spellings cover directories, dangling links and pi's cwd (2.800193ms)
✖ a relative path climbs from the workspace's real path, in both harnesses (3.279667ms)
✔ claude path fields per tool (2.601182ms)
✔ glob patterns stay inside the workspace (1.257995ms)
✔ a path that can't be checked is blocked (0.55942ms)
✔ initialize, ping and tools/list (52.537718ms)
✔ tools/call goes through the tool socket; a refusal is an isError result (40.673685ms)
✔ unknown tools and methods are JSON-RPC errors and never reach the socket (35.993545ms)
✔ a missing argument is a usage error (29.947767ms)
✔ pi: typed tools reach the socket, the gate blocks, agent_end writes the marker (384.717068ms)
✔ pi: a write through a dangling symlink is blocked, and nothing appears outside (329.198526ms)
✔ pi: a read is refused when pi would open another spelling outside (337.808371ms)
✖ pi: a relative path climbs from the real path of a workspace behind a symlink (340.250878ms)
✖ pi: a relative path climbs from the real path of a dataRoot behind a symlink (318.836655ms)
✔ pi: a missing extension refuses before any model call (6.434745ms)
✔ pi: an extension without its configuration fails pi's start (272.153021ms)
✔ founderCheck: founder variables, then a needed service without a usable token (1.55466ms)
✔ turnRequest names the sender, class, reply and decision (0.996447ms)
✔ a message becomes a turn, the answer goes back as a RESULT, SIGTERM releases and exits 0 (246.016744ms)
✔ a SIGTERM before the claim stops the runner with exit 0 and no claim (101.349298ms)
✔ typed tools carry the runner's capability; launch goes to the host's launch socket (398.221516ms)
✔ a RESULT gets no automatic reply; failed turns reply with the reason (1294.309301ms)
✔ SIGTERM during a turn kills the turn's process group and still exits 0 (145.992858ms)
✔ founder credentials stop before the claim (20) (173.49626ms)
✔ a refused claim exits 21; an ended run's capability exits 22 (185.73365ms)
✔ the launch ending under a running session exits 22 (141.709901ms)
✔ a broker that stays unreachable exits 23 after brokerRetries polls (243.783028ms)
✔ a broker that is down at the claim exits 23, not 21 (105.666355ms)
✔ no capability, or a malformed one, on stdin exits 2 (184.835076ms)
✔ a missing or malformed policy exits 2 before the claim (137.674261ms)
✔ the PM gets launch, its task verbs and the reads (9.441748ms)
✔ a coder gets no launch, no resolve_decision, and no task tools without a tracker (2.792932ms)
✔ launch only when the business's launch block names the instance as launcher (2.844521ms)
✔ an action outside the instance's authority has no tool (2.336641ms)
✔ callTool: one JSON line out, the result back, a refusal rejects (14.398564ms)
ℹ tests 56
ℹ suites 0
ℹ pass 53
ℹ fail 3
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 10316.014664
✖ failing tests:
test at packages/harness/tests/gate.test.mjs:181:1
✖ a relative path climbs from the workspace's real path, in both harnesses (3.279667ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
true !== false
at blocked (file:///home/jwoltje/darkwing-scratch/r41d/wt/packages/harness/tests/gate.test.mjs:19:10)
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41d/wt/packages/harness/tests/gate.test.mjs:195: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:184:3
✖ pi: a relative path climbs from the real path of a workspace behind a symlink (340.250878ms)
AssertionError [ERR_ASSERTION]: The input was expected to not match the regular expression /SECRET/. Input:
'ANSWER [[false,"SECRET-OUTSIDE\\n"],[false,"Successfully wrote to ../../a/ws/planted.txt"],[false,"inside\\n"],[false,"Successfully wrote to ../ws/fine.md"]]\n'
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41d/wt/packages/harness/tests/pi-session.test.mjs:199: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: 'ANSWER [[false,"SECRET-OUTSIDE\\n"],[false,"Successfully wrote to ../../a/ws/planted.txt"],[false,"inside\\n"],[false,"Successfully wrote to ../ws/fine.md"]]\n',
expected: /SECRET/,
operator: 'doesNotMatch',
diff: 'simple'
}
test at packages/harness/tests/pi-session.test.mjs:184:3
✖ pi: a relative path climbs from the real path of a dataRoot behind a symlink (318.836655ms)
AssertionError [ERR_ASSERTION]: The input was expected to not match the regular expression /SECRET/. Input:
'ANSWER [[false,"SECRET-OUTSIDE\\n"],[false,"Successfully wrote to ../../../../data/workspaces/b/i/planted.txt"],[false,"inside\\n"],[false,"Successfully wrote to ../i/fine.md"]]\n'
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41d/wt/packages/harness/tests/pi-session.test.mjs:199: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: 'ANSWER [[false,"SECRET-OUTSIDE\\n"],[false,"Successfully wrote to ../../../../data/workspaces/b/i/planted.txt"],[false,"inside\\n"],[false,"Successfully wrote to ../i/fine.md"]]\n',
expected: /SECRET/,
operator: 'doesNotMatch',
diff: 'simple'
}
@@ -0,0 +1,88 @@
✔ sessionModel: agent vars win, then the system's execution settings (13.166504ms)
✔ a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory (6.830098ms)
✔ a claude-code bundle adds the wrapped gate hook and the MCP config (6.181177ms)
✔ a bundle is written once: an existing file refuses (7.252436ms)
✔ a path with a single quote can't go into the hook command (5.229841ms)
✔ allow exits 0, a deny exits 2 with the reason on stderr (142.913603ms)
✔ a missing or wrong policy, or a bad event, exits 2 (82.446831ms)
✔ the bundle's wrapped command: a missing gate or node still blocks (1095.78911ms)
✔ claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist (784.398303ms)
✔ claude: the hook alone blocks a path outside the workspace (450.223193ms)
✔ claude: a second turn resumes the first turn's session (712.010899ms)
✔ claude adapter: --restricted is always passed (5.340269ms)
✔ claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do (755.954009ms)
✔ claude: a missing hook or MCP file refuses before claude starts (8.520012ms)
✔ pi: policy tools and typed tools pass, anything else is blocked (4.15195ms)
✔ claude: builtins map from pi names, typed tools need the mcp prefix (1.21445ms)
✔ file tool paths must resolve inside the workspace (2.766564ms)
✔ pi's own path normalisation can't be used to step out (2.158376ms)
✔ a symlink inside the workspace that points out is outside (1.458484ms)
✔ a dangling symlink is refused at any depth, in both harnesses (6.620982ms)
✔ read is checked under every spelling pi's read would open, in both harnesses (26.532986ms)
✔ other spellings cover directories, dangling links and pi's cwd (2.795343ms)
✖ a relative path climbs from the workspace's real path, in both harnesses (2.936662ms)
✔ claude path fields per tool (0.717094ms)
✔ glob patterns stay inside the workspace (0.725578ms)
✔ a path that can't be checked is blocked (0.352763ms)
✔ initialize, ping and tools/list (61.47675ms)
✔ tools/call goes through the tool socket; a refusal is an isError result (33.510512ms)
✔ unknown tools and methods are JSON-RPC errors and never reach the socket (37.845858ms)
✔ a missing argument is a usage error (33.268183ms)
✔ pi: typed tools reach the socket, the gate blocks, agent_end writes the marker (376.033974ms)
✔ pi: a write through a dangling symlink is blocked, and nothing appears outside (349.931587ms)
✔ pi: a read is refused when pi would open another spelling outside (327.087561ms)
✔ pi: a relative path climbs from the real path of a workspace behind a symlink (307.436623ms)
✔ pi: a relative path climbs from the real path of a dataRoot behind a symlink (310.483057ms)
✔ pi: a missing extension refuses before any model call (6.451642ms)
✔ pi: an extension without its configuration fails pi's start (253.374058ms)
✔ founderCheck: founder variables, then a needed service without a usable token (1.798476ms)
✔ turnRequest names the sender, class, reply and decision (0.274713ms)
✔ a message becomes a turn, the answer goes back as a RESULT, SIGTERM releases and exits 0 (255.701856ms)
✔ a SIGTERM before the claim stops the runner with exit 0 and no claim (93.390217ms)
✔ typed tools carry the runner's capability; launch goes to the host's launch socket (431.337337ms)
✔ a RESULT gets no automatic reply; failed turns reply with the reason (1309.037288ms)
✔ SIGTERM during a turn kills the turn's process group and still exits 0 (151.031862ms)
✔ founder credentials stop before the claim (20) (176.839142ms)
✔ a refused claim exits 21; an ended run's capability exits 22 (212.325895ms)
✔ the launch ending under a running session exits 22 (150.674328ms)
✔ a broker that stays unreachable exits 23 after brokerRetries polls (244.375556ms)
✔ a broker that is down at the claim exits 23, not 21 (110.434316ms)
✔ no capability, or a malformed one, on stdin exits 2 (191.896952ms)
✔ a missing or malformed policy exits 2 before the claim (125.187036ms)
✔ the PM gets launch, its task verbs and the reads (11.317213ms)
✔ a coder gets no launch, no resolve_decision, and no task tools without a tracker (3.732743ms)
✔ launch only when the business's launch block names the instance as launcher (3.338526ms)
✔ an action outside the instance's authority has no tool (2.391658ms)
✔ callTool: one JSON line out, the result back, a refusal rejects (11.414071ms)
ℹ tests 56
ℹ suites 0
ℹ pass 55
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 10364.03871
✖ failing tests:
test at packages/harness/tests/gate.test.mjs:181:1
✖ a relative path climbs from the workspace's real path, in both harnesses (2.936662ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
true !== false
at blocked (file:///home/jwoltje/darkwing-scratch/r41d/wt/packages/harness/tests/gate.test.mjs:19:10)
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41d/wt/packages/harness/tests/gate.test.mjs:203: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,124 @@
✔ sessionModel: agent vars win, then the system's execution settings (10.26592ms)
✔ a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory (4.407655ms)
✔ a claude-code bundle adds the wrapped gate hook and the MCP config (2.439738ms)
✔ a bundle is written once: an existing file refuses (2.565066ms)
✔ a path with a single quote can't go into the hook command (2.449872ms)
✔ allow exits 0, a deny exits 2 with the reason on stderr (116.969789ms)
✔ a missing or wrong policy, or a bad event, exits 2 (86.932395ms)
✔ the bundle's wrapped command: a missing gate or node still blocks (1093.719288ms)
✔ claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist (742.776859ms)
✔ claude: the hook alone blocks a path outside the workspace (454.012193ms)
✔ claude: a second turn resumes the first turn's session (725.734225ms)
✔ claude adapter: --restricted is always passed (5.519519ms)
✔ claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do (731.914825ms)
✔ claude: a missing hook or MCP file refuses before claude starts (7.393044ms)
✔ pi: policy tools and typed tools pass, anything else is blocked (4.344071ms)
✔ claude: builtins map from pi names, typed tools need the mcp prefix (0.939757ms)
✔ file tool paths must resolve inside the workspace (1.700489ms)
✔ pi's own path normalisation can't be used to step out (1.418286ms)
✔ a symlink inside the workspace that points out is outside (1.406512ms)
✔ a dangling symlink is refused at any depth, in both harnesses (5.263714ms)
✔ read is checked under every spelling pi's read would open, in both harnesses (14.890815ms)
✔ other spellings cover directories, dangling links and pi's cwd (1.603789ms)
✖ a relative path climbs from the workspace's real path, in both harnesses (1.570464ms)
✔ claude path fields per tool (0.61444ms)
✔ glob patterns stay inside the workspace (0.603014ms)
✔ a path that can't be checked is blocked (0.35295ms)
✔ initialize, ping and tools/list (45.416085ms)
✔ tools/call goes through the tool socket; a refusal is an isError result (34.045402ms)
✔ unknown tools and methods are JSON-RPC errors and never reach the socket (33.532246ms)
✔ a missing argument is a usage error (30.32903ms)
✔ pi: typed tools reach the socket, the gate blocks, agent_end writes the marker (370.652891ms)
✔ pi: a write through a dangling symlink is blocked, and nothing appears outside (314.580098ms)
✔ pi: a read is refused when pi would open another spelling outside (312.164008ms)
✖ pi: a relative path climbs from the real path of a workspace behind a symlink (318.0124ms)
✖ pi: a relative path climbs from the real path of a dataRoot behind a symlink (309.389775ms)
✔ pi: a missing extension refuses before any model call (6.672674ms)
✔ pi: an extension without its configuration fails pi's start (252.480031ms)
✔ founderCheck: founder variables, then a needed service without a usable token (1.648398ms)
✔ turnRequest names the sender, class, reply and decision (0.265695ms)
✔ a message becomes a turn, the answer goes back as a RESULT, SIGTERM releases and exits 0 (275.015356ms)
✔ a SIGTERM before the claim stops the runner with exit 0 and no claim (97.595768ms)
✔ typed tools carry the runner's capability; launch goes to the host's launch socket (388.631163ms)
✔ a RESULT gets no automatic reply; failed turns reply with the reason (1274.623193ms)
✔ SIGTERM during a turn kills the turn's process group and still exits 0 (166.728434ms)
✔ founder credentials stop before the claim (20) (172.532777ms)
✔ a refused claim exits 21; an ended run's capability exits 22 (192.895858ms)
✔ the launch ending under a running session exits 22 (148.216836ms)
✔ a broker that stays unreachable exits 23 after brokerRetries polls (240.516646ms)
✔ a broker that is down at the claim exits 23, not 21 (106.237078ms)
✔ no capability, or a malformed one, on stdin exits 2 (205.691137ms)
✔ a missing or malformed policy exits 2 before the claim (129.43942ms)
✔ the PM gets launch, its task verbs and the reads (6.44726ms)
✔ a coder gets no launch, no resolve_decision, and no task tools without a tracker (2.776687ms)
✔ launch only when the business's launch block names the instance as launcher (1.913587ms)
✔ an action outside the instance's authority has no tool (1.525496ms)
✔ callTool: one JSON line out, the result back, a refusal rejects (10.830046ms)
ℹ tests 56
ℹ suites 0
ℹ pass 53
ℹ fail 3
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 10309.973247
✖ failing tests:
test at packages/harness/tests/gate.test.mjs:181:1
✖ a relative path climbs from the workspace's real path, in both harnesses (1.570464ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
true !== false
at blocked (file:///home/jwoltje/darkwing-scratch/r41d/wt/packages/harness/tests/gate.test.mjs:19:10)
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41d/wt/packages/harness/tests/gate.test.mjs:195: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:184:3
✖ pi: a relative path climbs from the real path of a workspace behind a symlink (318.0124ms)
AssertionError [ERR_ASSERTION]: The input was expected to not match the regular expression /SECRET/. Input:
'ANSWER [[false,"SECRET-OUTSIDE\\n"],[false,"Successfully wrote to ../../a/ws/planted.txt"],[false,"inside\\n"],[false,"Successfully wrote to ../ws/fine.md"]]\n'
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41d/wt/packages/harness/tests/pi-session.test.mjs:199: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: 'ANSWER [[false,"SECRET-OUTSIDE\\n"],[false,"Successfully wrote to ../../a/ws/planted.txt"],[false,"inside\\n"],[false,"Successfully wrote to ../ws/fine.md"]]\n',
expected: /SECRET/,
operator: 'doesNotMatch',
diff: 'simple'
}
test at packages/harness/tests/pi-session.test.mjs:184:3
✖ pi: a relative path climbs from the real path of a dataRoot behind a symlink (309.389775ms)
AssertionError [ERR_ASSERTION]: The input was expected to not match the regular expression /SECRET/. Input:
'ANSWER [[false,"SECRET-OUTSIDE\\n"],[false,"Successfully wrote to ../../../../data/workspaces/b/i/planted.txt"],[false,"inside\\n"],[false,"Successfully wrote to ../i/fine.md"]]\n'
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41d/wt/packages/harness/tests/pi-session.test.mjs:199: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: 'ANSWER [[false,"SECRET-OUTSIDE\\n"],[false,"Successfully wrote to ../../../../data/workspaces/b/i/planted.txt"],[false,"inside\\n"],[false,"Successfully wrote to ../i/fine.md"]]\n',
expected: /SECRET/,
operator: 'doesNotMatch',
diff: 'simple'
}
@@ -0,0 +1,88 @@
✔ sessionModel: agent vars win, then the system's execution settings (9.87935ms)
✔ a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory (4.875106ms)
✔ a claude-code bundle adds the wrapped gate hook and the MCP config (3.619702ms)
✔ a bundle is written once: an existing file refuses (3.115892ms)
✔ a path with a single quote can't go into the hook command (1.729883ms)
✔ allow exits 0, a deny exits 2 with the reason on stderr (121.876014ms)
✔ a missing or wrong policy, or a bad event, exits 2 (88.51807ms)
✔ the bundle's wrapped command: a missing gate or node still blocks (1092.263287ms)
✔ claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist (759.309882ms)
✔ claude: the hook alone blocks a path outside the workspace (472.195897ms)
✔ claude: a second turn resumes the first turn's session (724.679605ms)
✔ claude adapter: --restricted is always passed (5.303066ms)
✔ claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do (741.258526ms)
✔ claude: a missing hook or MCP file refuses before claude starts (7.418726ms)
✔ pi: policy tools and typed tools pass, anything else is blocked (3.858675ms)
✔ claude: builtins map from pi names, typed tools need the mcp prefix (0.887281ms)
✔ file tool paths must resolve inside the workspace (1.564652ms)
✖ pi's own path normalisation can't be used to step out (1.732209ms)
✔ a symlink inside the workspace that points out is outside (1.069819ms)
✔ a dangling symlink is refused at any depth, in both harnesses (3.765969ms)
✔ read is checked under every spelling pi's read would open, in both harnesses (18.477165ms)
✔ other spellings cover directories, dangling links and pi's cwd (1.36167ms)
✔ a relative path climbs from the workspace's real path, in both harnesses (4.836506ms)
✔ claude path fields per tool (0.684994ms)
✔ glob patterns stay inside the workspace (0.582159ms)
✔ a path that can't be checked is blocked (0.325911ms)
✔ initialize, ping and tools/list (40.58713ms)
✔ tools/call goes through the tool socket; a refusal is an isError result (31.77131ms)
✔ unknown tools and methods are JSON-RPC errors and never reach the socket (30.183541ms)
✔ a missing argument is a usage error (31.533311ms)
✔ pi: typed tools reach the socket, the gate blocks, agent_end writes the marker (354.180455ms)
✔ pi: a write through a dangling symlink is blocked, and nothing appears outside (327.591937ms)
✔ pi: a read is refused when pi would open another spelling outside (318.33446ms)
✔ pi: a relative path climbs from the real path of a workspace behind a symlink (338.669318ms)
✔ pi: a relative path climbs from the real path of a dataRoot behind a symlink (309.963678ms)
✔ pi: a missing extension refuses before any model call (7.225595ms)
✔ pi: an extension without its configuration fails pi's start (261.687691ms)
✔ founderCheck: founder variables, then a needed service without a usable token (1.0956ms)
✔ turnRequest names the sender, class, reply and decision (0.219588ms)
✔ a message becomes a turn, the answer goes back as a RESULT, SIGTERM releases and exits 0 (234.062414ms)
✔ a SIGTERM before the claim stops the runner with exit 0 and no claim (111.15252ms)
✔ typed tools carry the runner's capability; launch goes to the host's launch socket (368.79309ms)
✔ a RESULT gets no automatic reply; failed turns reply with the reason (1285.997552ms)
✔ SIGTERM during a turn kills the turn's process group and still exits 0 (158.901003ms)
✔ founder credentials stop before the claim (20) (185.91479ms)
✔ a refused claim exits 21; an ended run's capability exits 22 (187.351958ms)
✔ the launch ending under a running session exits 22 (154.766842ms)
✔ a broker that stays unreachable exits 23 after brokerRetries polls (263.620185ms)
✔ a broker that is down at the claim exits 23, not 21 (95.091753ms)
✔ no capability, or a malformed one, on stdin exits 2 (200.368017ms)
✔ a missing or malformed policy exits 2 before the claim (132.846006ms)
✔ the PM gets launch, its task verbs and the reads (6.507392ms)
✔ a coder gets no launch, no resolve_decision, and no task tools without a tracker (2.297129ms)
✔ launch only when the business's launch block names the instance as launcher (2.20434ms)
✔ an action outside the instance's authority has no tool (2.706779ms)
✔ callTool: one JSON line out, the result back, a refusal rejects (9.810235ms)
ℹ tests 56
ℹ suites 0
ℹ pass 55
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 10262.068026
✖ failing tests:
test at packages/harness/tests/gate.test.mjs:60:1
✖ pi's own path normalisation can't be used to step out (1.732209ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
true !== false
at blocked (file:///home/jwoltje/darkwing-scratch/r41d/wt/packages/harness/tests/gate.test.mjs:19:10)
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41d/wt/packages/harness/tests/gate.test.mjs:62: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,88 @@
✔ sessionModel: agent vars win, then the system's execution settings (10.369128ms)
✔ a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory (4.101077ms)
✔ a claude-code bundle adds the wrapped gate hook and the MCP config (2.522222ms)
✔ a bundle is written once: an existing file refuses (2.480421ms)
✔ a path with a single quote can't go into the hook command (2.208256ms)
✔ allow exits 0, a deny exits 2 with the reason on stderr (125.228714ms)
✔ a missing or wrong policy, or a bad event, exits 2 (99.007065ms)
✔ the bundle's wrapped command: a missing gate or node still blocks (1096.273556ms)
✔ claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist (751.155725ms)
✔ claude: the hook alone blocks a path outside the workspace (441.135402ms)
✔ claude: a second turn resumes the first turn's session (735.082802ms)
✔ claude adapter: --restricted is always passed (5.239573ms)
✔ claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do (739.084559ms)
✔ claude: a missing hook or MCP file refuses before claude starts (7.810106ms)
✔ pi: policy tools and typed tools pass, anything else is blocked (3.098661ms)
✔ claude: builtins map from pi names, typed tools need the mcp prefix (0.72834ms)
✔ file tool paths must resolve inside the workspace (1.366765ms)
✔ pi's own path normalisation can't be used to step out (1.135173ms)
✔ a symlink inside the workspace that points out is outside (1.183478ms)
✔ a dangling symlink is refused at any depth, in both harnesses (3.06165ms)
✔ read is checked under every spelling pi's read would open, in both harnesses (17.10114ms)
✔ other spellings cover directories, dangling links and pi's cwd (1.481463ms)
✔ a relative path climbs from the workspace's real path, in both harnesses (3.849039ms)
✔ claude path fields per tool (0.575647ms)
✖ glob patterns stay inside the workspace (1.300289ms)
✔ a path that can't be checked is blocked (0.383337ms)
✔ initialize, ping and tools/list (43.723869ms)
✔ tools/call goes through the tool socket; a refusal is an isError result (33.899289ms)
✔ unknown tools and methods are JSON-RPC errors and never reach the socket (32.230065ms)
✔ a missing argument is a usage error (28.925962ms)
✔ pi: typed tools reach the socket, the gate blocks, agent_end writes the marker (369.215026ms)
✔ pi: a write through a dangling symlink is blocked, and nothing appears outside (320.066306ms)
✔ pi: a read is refused when pi would open another spelling outside (312.239331ms)
✔ pi: a relative path climbs from the real path of a workspace behind a symlink (306.779191ms)
✔ pi: a relative path climbs from the real path of a dataRoot behind a symlink (312.126907ms)
✔ pi: a missing extension refuses before any model call (6.510548ms)
✔ pi: an extension without its configuration fails pi's start (268.831889ms)
✔ founderCheck: founder variables, then a needed service without a usable token (1.279785ms)
✔ turnRequest names the sender, class, reply and decision (0.19562ms)
✔ a message becomes a turn, the answer goes back as a RESULT, SIGTERM releases and exits 0 (251.909305ms)
✔ a SIGTERM before the claim stops the runner with exit 0 and no claim (105.575145ms)
✔ typed tools carry the runner's capability; launch goes to the host's launch socket (376.329399ms)
✔ a RESULT gets no automatic reply; failed turns reply with the reason (1308.851736ms)
✔ SIGTERM during a turn kills the turn's process group and still exits 0 (167.787107ms)
✔ founder credentials stop before the claim (20) (170.54228ms)
✔ a refused claim exits 21; an ended run's capability exits 22 (198.618677ms)
✔ the launch ending under a running session exits 22 (143.274433ms)
✔ a broker that stays unreachable exits 23 after brokerRetries polls (241.465266ms)
✔ a broker that is down at the claim exits 23, not 21 (91.778621ms)
✔ no capability, or a malformed one, on stdin exits 2 (191.158646ms)
✔ a missing or malformed policy exits 2 before the claim (138.321017ms)
✔ the PM gets launch, its task verbs and the reads (6.713097ms)
✔ a coder gets no launch, no resolve_decision, and no task tools without a tracker (2.657396ms)
✔ launch only when the business's launch block names the instance as launcher (1.954434ms)
✔ an action outside the instance's authority has no tool (1.581718ms)
✔ callTool: one JSON line out, the result back, a refusal rejects (8.690613ms)
ℹ tests 56
ℹ suites 0
ℹ pass 55
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 10327.132921
✖ failing tests:
test at packages/harness/tests/gate.test.mjs:218:1
✖ glob patterns stay inside the workspace (1.300289ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
true !== false
at blocked (file:///home/jwoltje/darkwing-scratch/r41d/wt/packages/harness/tests/gate.test.mjs:19:10)
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41d/wt/packages/harness/tests/gate.test.mjs:224: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,128 @@
✔ sessionModel: agent vars win, then the system's execution settings (13.951592ms)
✔ a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory (5.155778ms)
✔ a claude-code bundle adds the wrapped gate hook and the MCP config (3.474296ms)
✔ a bundle is written once: an existing file refuses (2.365986ms)
✔ a path with a single quote can't go into the hook command (2.112037ms)
✔ allow exits 0, a deny exits 2 with the reason on stderr (142.840425ms)
✔ a missing or wrong policy, or a bad event, exits 2 (94.614087ms)
✔ the bundle's wrapped command: a missing gate or node still blocks (1113.358863ms)
✔ claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist (901.454306ms)
✔ claude: the hook alone blocks a path outside the workspace (511.779436ms)
✔ claude: a second turn resumes the first turn's session (824.794016ms)
✔ claude adapter: --restricted is always passed (5.078018ms)
✔ claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do (748.925681ms)
✔ claude: a missing hook or MCP file refuses before claude starts (7.923018ms)
✔ pi: policy tools and typed tools pass, anything else is blocked (3.70504ms)
✔ claude: builtins map from pi names, typed tools need the mcp prefix (0.748106ms)
✔ file tool paths must resolve inside the workspace (1.600925ms)
✔ pi's own path normalisation can't be used to step out (1.056965ms)
✔ a symlink inside the workspace that points out is outside (1.456909ms)
✖ a dangling symlink is refused at any depth, in both harnesses (2.352632ms)
✔ read is checked under every spelling pi's read would open, in both harnesses (28.519068ms)
✖ other spellings cover directories, dangling links and pi's cwd (1.248655ms)
✔ a relative path climbs from the workspace's real path, in both harnesses (5.971957ms)
✔ claude path fields per tool (0.756134ms)
✔ glob patterns stay inside the workspace (0.716178ms)
✔ a path that can't be checked is blocked (0.432944ms)
✔ initialize, ping and tools/list (50.004724ms)
✔ tools/call goes through the tool socket; a refusal is an isError result (41.438932ms)
✔ unknown tools and methods are JSON-RPC errors and never reach the socket (36.295209ms)
✔ a missing argument is a usage error (34.276915ms)
✔ pi: typed tools reach the socket, the gate blocks, agent_end writes the marker (453.423865ms)
✖ pi: a write through a dangling symlink is blocked, and nothing appears outside (388.523149ms)
✔ pi: a read is refused when pi would open another spelling outside (364.03261ms)
✔ pi: a relative path climbs from the real path of a workspace behind a symlink (333.182119ms)
✔ pi: a relative path climbs from the real path of a dataRoot behind a symlink (376.888665ms)
✔ pi: a missing extension refuses before any model call (7.262079ms)
✔ pi: an extension without its configuration fails pi's start (280.114519ms)
✔ founderCheck: founder variables, then a needed service without a usable token (2.043332ms)
✔ turnRequest names the sender, class, reply and decision (0.270957ms)
✔ a message becomes a turn, the answer goes back as a RESULT, SIGTERM releases and exits 0 (266.915596ms)
✔ a SIGTERM before the claim stops the runner with exit 0 and no claim (104.932711ms)
✔ typed tools carry the runner's capability; launch goes to the host's launch socket (457.164815ms)
✔ a RESULT gets no automatic reply; failed turns reply with the reason (1342.709056ms)
✔ SIGTERM during a turn kills the turn's process group and still exits 0 (150.189063ms)
✔ founder credentials stop before the claim (20) (199.636928ms)
✔ a refused claim exits 21; an ended run's capability exits 22 (195.51418ms)
✔ the launch ending under a running session exits 22 (142.652928ms)
✔ a broker that stays unreachable exits 23 after brokerRetries polls (242.859152ms)
✔ a broker that is down at the claim exits 23, not 21 (91.526522ms)
✔ no capability, or a malformed one, on stdin exits 2 (187.48731ms)
✔ a missing or malformed policy exits 2 before the claim (131.742592ms)
✔ the PM gets launch, its task verbs and the reads (9.873905ms)
✔ a coder gets no launch, no resolve_decision, and no task tools without a tracker (2.67407ms)
✔ launch only when the business's launch block names the instance as launcher (2.385567ms)
✔ an action outside the instance's authority has no tool (2.079737ms)
✔ callTool: one JSON line out, the result back, a refusal rejects (10.585924ms)
ℹ tests 56
ℹ suites 0
ℹ pass 53
ℹ fail 3
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 10444.499104
✖ failing tests:
test at packages/harness/tests/gate.test.mjs:82:1
✖ a dangling symlink is refused at any depth, in both harnesses (2.352632ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
true !== false
at blocked (file:///home/jwoltje/darkwing-scratch/r41d/wt/packages/harness/tests/gate.test.mjs:19:10)
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41d/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 (1.248655ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
true !== false
at blocked (file:///home/jwoltje/darkwing-scratch/r41d/wt/packages/harness/tests/gate.test.mjs:19:10)
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41d/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 (388.523149ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
false !== true
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41d/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,64 @@
✔ sessionModel: agent vars win, then the system's execution settings (12.908249ms)
✔ a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory (6.241662ms)
✔ a claude-code bundle adds the wrapped gate hook and the MCP config (3.802171ms)
✔ a bundle is written once: an existing file refuses (2.286689ms)
✔ a path with a single quote can't go into the hook command (1.792504ms)
✔ allow exits 0, a deny exits 2 with the reason on stderr (146.942107ms)
✔ a missing or wrong policy, or a bad event, exits 2 (98.035985ms)
✔ the bundle's wrapped command: a missing gate or node still blocks (1098.836348ms)
✔ claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist (802.483352ms)
✔ claude: the hook alone blocks a path outside the workspace (499.584226ms)
✔ claude: a second turn resumes the first turn's session (707.490069ms)
✔ claude adapter: --restricted is always passed (5.085363ms)
✔ claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do (742.856032ms)
✔ claude: a missing hook or MCP file refuses before claude starts (7.308119ms)
✔ pi: policy tools and typed tools pass, anything else is blocked (3.454864ms)
✔ claude: builtins map from pi names, typed tools need the mcp prefix (1.036551ms)
✔ file tool paths must resolve inside the workspace (1.903732ms)
✔ pi's own path normalisation can't be used to step out (1.156018ms)
✔ a symlink inside the workspace that points out is outside (1.086308ms)
✔ a dangling symlink is refused at any depth, in both harnesses (2.923023ms)
✔ read is checked under every spelling pi's read would open, in both harnesses (17.977038ms)
✔ other spellings cover directories, dangling links and pi's cwd (2.409777ms)
✔ a relative path climbs from the workspace's real path, in both harnesses (6.172826ms)
✔ claude path fields per tool (0.815392ms)
✔ glob patterns stay inside the workspace (0.622669ms)
✔ a path that can't be checked is blocked (0.373151ms)
✔ initialize, ping and tools/list (57.281691ms)
✔ tools/call goes through the tool socket; a refusal is an isError result (37.215756ms)
✔ unknown tools and methods are JSON-RPC errors and never reach the socket (39.649493ms)
✔ a missing argument is a usage error (34.344617ms)
✔ pi: typed tools reach the socket, the gate blocks, agent_end writes the marker (405.306493ms)
✔ pi: a write through a dangling symlink is blocked, and nothing appears outside (320.655773ms)
✔ pi: a read is refused when pi would open another spelling outside (307.185492ms)
✔ pi: a relative path climbs from the real path of a workspace behind a symlink (322.675224ms)
✔ pi: a relative path climbs from the real path of a dataRoot behind a symlink (311.126719ms)
✔ pi: a missing extension refuses before any model call (6.209092ms)
✔ pi: an extension without its configuration fails pi's start (247.97222ms)
✔ founderCheck: founder variables, then a needed service without a usable token (1.741181ms)
✔ turnRequest names the sender, class, reply and decision (0.26822ms)
✔ a message becomes a turn, the answer goes back as a RESULT, SIGTERM releases and exits 0 (294.273799ms)
✔ a SIGTERM before the claim stops the runner with exit 0 and no claim (119.187454ms)
✔ typed tools carry the runner's capability; launch goes to the host's launch socket (418.835251ms)
✔ a RESULT gets no automatic reply; failed turns reply with the reason (1478.017629ms)
✔ SIGTERM during a turn kills the turn's process group and still exits 0 (147.14452ms)
✔ founder credentials stop before the claim (20) (173.27824ms)
✔ a refused claim exits 21; an ended run's capability exits 22 (205.366219ms)
✔ the launch ending under a running session exits 22 (153.348717ms)
✔ a broker that stays unreachable exits 23 after brokerRetries polls (246.306522ms)
✔ a broker that is down at the claim exits 23, not 21 (90.996741ms)
✔ no capability, or a malformed one, on stdin exits 2 (186.732832ms)
✔ a missing or malformed policy exits 2 before the claim (133.298297ms)
✔ the PM gets launch, its task verbs and the reads (8.329872ms)
✔ a coder gets no launch, no resolve_decision, and no task tools without a tracker (3.876034ms)
✔ launch only when the business's launch block names the instance as launcher (2.80248ms)
✔ an action outside the instance's authority has no tool (2.310834ms)
✔ callTool: one JSON line out, the result back, a refusal rejects (9.966718ms)
ℹ tests 56
ℹ suites 0
ℹ pass 56
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 10570.889232
@@ -0,0 +1,64 @@
✔ sessionModel: agent vars win, then the system's execution settings (16.419969ms)
✔ a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory (7.907433ms)
✔ a claude-code bundle adds the wrapped gate hook and the MCP config (4.748261ms)
✔ a bundle is written once: an existing file refuses (3.551392ms)
✔ a path with a single quote can't go into the hook command (3.6116ms)
✔ allow exits 0, a deny exits 2 with the reason on stderr (154.868776ms)
✔ a missing or wrong policy, or a bad event, exits 2 (84.333949ms)
✔ the bundle's wrapped command: a missing gate or node still blocks (1086.679703ms)
✔ claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist (767.100937ms)
✔ claude: the hook alone blocks a path outside the workspace (436.596675ms)
✔ claude: a second turn resumes the first turn's session (729.009786ms)
✔ claude adapter: --restricted is always passed (5.051284ms)
✔ claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do (752.813721ms)
✔ claude: a missing hook or MCP file refuses before claude starts (9.431351ms)
✔ pi: policy tools and typed tools pass, anything else is blocked (4.314906ms)
✔ claude: builtins map from pi names, typed tools need the mcp prefix (1.05245ms)
✔ file tool paths must resolve inside the workspace (2.215003ms)
✔ pi's own path normalisation can't be used to step out (1.563926ms)
✔ a symlink inside the workspace that points out is outside (1.278773ms)
✔ a dangling symlink is refused at any depth, in both harnesses (4.68278ms)
✔ read is checked under every spelling pi's read would open, in both harnesses (26.116575ms)
✔ other spellings cover directories, dangling links and pi's cwd (2.149847ms)
✔ a relative path climbs from the workspace's real path, in both harnesses (5.004587ms)
✔ claude path fields per tool (0.791412ms)
✔ glob patterns stay inside the workspace (0.664173ms)
✔ a path that can't be checked is blocked (0.371877ms)
✔ initialize, ping and tools/list (57.874523ms)
✔ tools/call goes through the tool socket; a refusal is an isError result (44.19034ms)
✔ unknown tools and methods are JSON-RPC errors and never reach the socket (40.393765ms)
✔ a missing argument is a usage error (29.144246ms)
✔ pi: typed tools reach the socket, the gate blocks, agent_end writes the marker (389.968217ms)
✔ pi: a write through a dangling symlink is blocked, and nothing appears outside (315.424366ms)
✔ pi: a read is refused when pi would open another spelling outside (313.603289ms)
✔ pi: a relative path climbs from the real path of a workspace behind a symlink (309.690487ms)
✔ pi: a relative path climbs from the real path of a dataRoot behind a symlink (329.985874ms)
✔ pi: a missing extension refuses before any model call (7.347651ms)
✔ pi: an extension without its configuration fails pi's start (252.241598ms)
✔ founderCheck: founder variables, then a needed service without a usable token (1.886259ms)
✔ turnRequest names the sender, class, reply and decision (0.25284ms)
✔ a message becomes a turn, the answer goes back as a RESULT, SIGTERM releases and exits 0 (268.26198ms)
✔ a SIGTERM before the claim stops the runner with exit 0 and no claim (106.707276ms)
✔ typed tools carry the runner's capability; launch goes to the host's launch socket (369.174258ms)
✔ a RESULT gets no automatic reply; failed turns reply with the reason (1292.260566ms)
✔ SIGTERM during a turn kills the turn's process group and still exits 0 (164.241797ms)
✔ founder credentials stop before the claim (20) (170.743016ms)
✔ a refused claim exits 21; an ended run's capability exits 22 (208.541368ms)
✔ the launch ending under a running session exits 22 (147.66599ms)
✔ a broker that stays unreachable exits 23 after brokerRetries polls (243.905838ms)
✔ a broker that is down at the claim exits 23, not 21 (92.452572ms)
✔ no capability, or a malformed one, on stdin exits 2 (184.969794ms)
✔ a missing or malformed policy exits 2 before the claim (130.261538ms)
✔ the PM gets launch, its task verbs and the reads (11.660568ms)
✔ a coder gets no launch, no resolve_decision, and no task tools without a tracker (4.20283ms)
✔ launch only when the business's launch block names the instance as launcher (2.073248ms)
✔ an action outside the instance's authority has no tool (1.784904ms)
✔ callTool: one JSON line out, the result back, a refusal rejects (11.548405ms)
ℹ tests 56
ℹ suites 0
ℹ pass 56
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 10368.226809
@@ -0,0 +1,64 @@
✔ sessionModel: agent vars win, then the system's execution settings (13.841783ms)
✔ a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory (6.287276ms)
✔ a claude-code bundle adds the wrapped gate hook and the MCP config (4.087337ms)
✔ a bundle is written once: an existing file refuses (3.239927ms)
✔ a path with a single quote can't go into the hook command (2.598993ms)
✔ allow exits 0, a deny exits 2 with the reason on stderr (123.207972ms)
✔ a missing or wrong policy, or a bad event, exits 2 (98.727577ms)
✔ the bundle's wrapped command: a missing gate or node still blocks (1087.836441ms)
✔ claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist (782.019432ms)
✔ claude: the hook alone blocks a path outside the workspace (491.276084ms)
✔ claude: a second turn resumes the first turn's session (767.524694ms)
✔ claude adapter: --restricted is always passed (4.996153ms)
✔ claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do (746.117429ms)
✔ claude: a missing hook or MCP file refuses before claude starts (7.61189ms)
✔ pi: policy tools and typed tools pass, anything else is blocked (4.492952ms)
✔ claude: builtins map from pi names, typed tools need the mcp prefix (0.865491ms)
✔ file tool paths must resolve inside the workspace (1.684216ms)
✔ pi's own path normalisation can't be used to step out (1.876241ms)
✔ a symlink inside the workspace that points out is outside (1.085719ms)
✔ a dangling symlink is refused at any depth, in both harnesses (3.340783ms)
✔ read is checked under every spelling pi's read would open, in both harnesses (20.843709ms)
✔ other spellings cover directories, dangling links and pi's cwd (2.281127ms)
✔ a relative path climbs from the workspace's real path, in both harnesses (4.161104ms)
✔ claude path fields per tool (0.717995ms)
✔ glob patterns stay inside the workspace (0.604427ms)
✔ a path that can't be checked is blocked (0.402529ms)
✔ initialize, ping and tools/list (48.632538ms)
✔ tools/call goes through the tool socket; a refusal is an isError result (35.737239ms)
✔ unknown tools and methods are JSON-RPC errors and never reach the socket (32.617341ms)
✔ a missing argument is a usage error (39.132107ms)
✔ pi: typed tools reach the socket, the gate blocks, agent_end writes the marker (372.477226ms)
✔ pi: a write through a dangling symlink is blocked, and nothing appears outside (328.647932ms)
✔ pi: a read is refused when pi would open another spelling outside (353.861388ms)
✔ pi: a relative path climbs from the real path of a workspace behind a symlink (315.988888ms)
✔ pi: a relative path climbs from the real path of a dataRoot behind a symlink (315.788017ms)
✔ pi: a missing extension refuses before any model call (6.271687ms)
✔ pi: an extension without its configuration fails pi's start (255.601498ms)
✔ founderCheck: founder variables, then a needed service without a usable token (1.197926ms)
✔ turnRequest names the sender, class, reply and decision (0.2689ms)
✔ a message becomes a turn, the answer goes back as a RESULT, SIGTERM releases and exits 0 (268.276295ms)
✔ a SIGTERM before the claim stops the runner with exit 0 and no claim (142.384566ms)
✔ typed tools carry the runner's capability; launch goes to the host's launch socket (520.416544ms)
✔ a RESULT gets no automatic reply; failed turns reply with the reason (1401.520636ms)
✔ SIGTERM during a turn kills the turn's process group and still exits 0 (204.356734ms)
✔ founder credentials stop before the claim (20) (278.91878ms)
✔ a refused claim exits 21; an ended run's capability exits 22 (334.728922ms)
✔ the launch ending under a running session exits 22 (210.128341ms)
✔ a broker that stays unreachable exits 23 after brokerRetries polls (304.579393ms)
✔ a broker that is down at the claim exits 23, not 21 (174.27166ms)
✔ no capability, or a malformed one, on stdin exits 2 (248.731471ms)
✔ a missing or malformed policy exits 2 before the claim (178.749977ms)
✔ the PM gets launch, its task verbs and the reads (7.78365ms)
✔ a coder gets no launch, no resolve_decision, and no task tools without a tracker (1.962966ms)
✔ launch only when the business's launch block names the instance as launcher (2.344147ms)
✔ an action outside the instance's authority has no tool (2.275202ms)
✔ callTool: one JSON line out, the result back, a refusal rejects (10.731608ms)
ℹ tests 56
ℹ suites 0
ℹ pass 56
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 10659.905003
@@ -0,0 +1,64 @@
✔ sessionModel: agent vars win, then the system's execution settings (13.676129ms)
✔ a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory (5.412586ms)
✔ a claude-code bundle adds the wrapped gate hook and the MCP config (3.383246ms)
✔ a bundle is written once: an existing file refuses (2.825298ms)
✔ a path with a single quote can't go into the hook command (2.090268ms)
✔ allow exits 0, a deny exits 2 with the reason on stderr (117.297968ms)
✔ a missing or wrong policy, or a bad event, exits 2 (88.00604ms)
✔ the bundle's wrapped command: a missing gate or node still blocks (1086.177758ms)
✔ claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist (742.293868ms)
✔ claude: the hook alone blocks a path outside the workspace (513.929576ms)
✔ claude: a second turn resumes the first turn's session (745.910637ms)
✔ claude adapter: --restricted is always passed (5.032461ms)
✔ claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do (707.280629ms)
✔ claude: a missing hook or MCP file refuses before claude starts (8.387445ms)
✔ pi: policy tools and typed tools pass, anything else is blocked (3.200965ms)
✔ claude: builtins map from pi names, typed tools need the mcp prefix (0.63065ms)
✔ file tool paths must resolve inside the workspace (1.045475ms)
✔ pi's own path normalisation can't be used to step out (0.876209ms)
✔ a symlink inside the workspace that points out is outside (0.808567ms)
✔ a dangling symlink is refused at any depth, in both harnesses (2.586055ms)
✔ read is checked under every spelling pi's read would open, in both harnesses (17.413263ms)
✔ other spellings cover directories, dangling links and pi's cwd (1.415811ms)
✔ a relative path climbs from the workspace's real path, in both harnesses (4.146039ms)
✔ claude path fields per tool (0.708978ms)
✔ glob patterns stay inside the workspace (0.61424ms)
✔ a path that can't be checked is blocked (0.368175ms)
✔ initialize, ping and tools/list (44.145606ms)
✔ tools/call goes through the tool socket; a refusal is an isError result (31.410294ms)
✔ unknown tools and methods are JSON-RPC errors and never reach the socket (28.633656ms)
✔ a missing argument is a usage error (27.843755ms)
✔ pi: typed tools reach the socket, the gate blocks, agent_end writes the marker (355.378997ms)
✔ pi: a write through a dangling symlink is blocked, and nothing appears outside (319.122119ms)
✔ pi: a read is refused when pi would open another spelling outside (331.533845ms)
✔ pi: a relative path climbs from the real path of a workspace behind a symlink (338.818078ms)
✔ pi: a relative path climbs from the real path of a dataRoot behind a symlink (314.704983ms)
✔ pi: a missing extension refuses before any model call (5.987169ms)
✔ pi: an extension without its configuration fails pi's start (268.431565ms)
✔ founderCheck: founder variables, then a needed service without a usable token (1.073037ms)
✔ turnRequest names the sender, class, reply and decision (0.167589ms)
✔ a message becomes a turn, the answer goes back as a RESULT, SIGTERM releases and exits 0 (269.596609ms)
✔ a SIGTERM before the claim stops the runner with exit 0 and no claim (123.863191ms)
✔ typed tools carry the runner's capability; launch goes to the host's launch socket (421.121943ms)
✔ a RESULT gets no automatic reply; failed turns reply with the reason (1369.369925ms)
✔ SIGTERM during a turn kills the turn's process group and still exits 0 (178.960623ms)
✔ founder credentials stop before the claim (20) (210.084389ms)
✔ a refused claim exits 21; an ended run's capability exits 22 (230.426167ms)
✔ the launch ending under a running session exits 22 (185.471755ms)
✔ a broker that stays unreachable exits 23 after brokerRetries polls (279.435871ms)
✔ a broker that is down at the claim exits 23, not 21 (126.104629ms)
✔ no capability, or a malformed one, on stdin exits 2 (189.947621ms)
✔ a missing or malformed policy exits 2 before the claim (128.156013ms)
✔ the PM gets launch, its task verbs and the reads (6.680204ms)
✔ a coder gets no launch, no resolve_decision, and no task tools without a tracker (1.717751ms)
✔ launch only when the business's launch block names the instance as launcher (1.52598ms)
✔ an action outside the instance's authority has no tool (2.066738ms)
✔ callTool: one JSON line out, the result back, a refusal rejects (8.417549ms)
ℹ tests 56
ℹ suites 0
ℹ pass 56
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 10477.502097
+9
View File
@@ -0,0 +1,9 @@
#!/bin/bash
# Round 4: the gate mutants on the harness suite. The other round 3 mutants
# change source and tests that round 4 doesn't touch, so they weren't rerun.
R=~/darkwing-scratch/r41d/mut
rm -f $R/summary.txt $R/M*-*.txt
for m in Ml-gate-pattern Ms-follow-walk Mw-ampm-case Mx-curly-once My-lstat-error-skips Mz-spell-no-normalise MA-pi-only MB-no-real-base MC-no-nfd-curly MD-given-only ME-real-only MF-either MG-inside-no-normalise; do
$R/run.sh $m harness
done
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,78 @@
# 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)]"),
# Round 4: the R5 relative-path check.
"MD-given-only": ("packages/harness/src/gate.mjs", ' return within(workspace, resolve(realpathSync(workspace), s)) && within(workspace, resolve(workspace, s));', " return within(workspace, resolve(workspace, s));"),
"ME-real-only": ("packages/harness/src/gate.mjs", ' return within(workspace, resolve(realpathSync(workspace), s)) && within(workspace, resolve(workspace, s));', " return within(workspace, resolve(realpathSync(workspace), s));"),
"MF-either": ("packages/harness/src/gate.mjs", ' return within(workspace, resolve(realpathSync(workspace), s)) && within(workspace, resolve(workspace, s));', ' return within(workspace, resolve(realpathSync(workspace), s)) || within(workspace, resolve(workspace, s));'),
"MG-inside-no-normalise": ("packages/harness/src/gate.mjs", "const s = normalise(p);\n if (isAbsolute(s)) return within", "const s = p;\n if (isAbsolute(s)) return within"),
}
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)
+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/r41d/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,14 @@
Ml-gate-pattern (packages/harness/src/gate.mjs): harness rc 1 pass 55 fail 1;
Ms-follow-walk (packages/harness/src/gate.mjs): harness rc 1 pass 53 fail 3;
Mw-ampm-case (packages/harness/src/gate.mjs): harness rc 0 pass 56 fail 0;
Mx-curly-once (packages/harness/src/gate.mjs): harness rc 0 pass 56 fail 0;
My-lstat-error-skips (packages/harness/src/gate.mjs): harness rc 0 pass 56 fail 0;
Mz-spell-no-normalise (packages/harness/src/gate.mjs): harness rc 0 pass 56 fail 0;
MA-pi-only (packages/harness/src/gate.mjs): harness rc 1 pass 55 fail 1;
MB-no-real-base (packages/harness/src/gate.mjs): harness rc 1 pass 55 fail 1;
MC-no-nfd-curly (packages/harness/src/gate.mjs): harness rc 1 pass 54 fail 2;
MD-given-only (packages/harness/src/gate.mjs): harness rc 1 pass 53 fail 3;
ME-real-only (packages/harness/src/gate.mjs): harness rc 1 pass 55 fail 1;
MF-either (packages/harness/src/gate.mjs): harness rc 1 pass 53 fail 3;
MG-inside-no-normalise (packages/harness/src/gate.mjs): harness rc 1 pass 55 fail 1;
DONE
@@ -0,0 +1,82 @@
✔ launch identity is stamped, payload identity is refused and stale holder cannot send (141.265652ms)
✔ decision classes route from policy; gated resolution is human-only, choice and target must match (256.147039ms)
✔ claim exclusion, holder release, gated revoke and rerouting to a new holder are atomic (246.534686ms)
✔ launch events require a human CLI capability; generic emit cannot forge authority events (145.552624ms)
✔ within-role decisions close atomically and invalid options or blocking omissions refuse (142.905147ms)
✔ observer capabilities read human inbox but cannot mutate or forge launch identity (126.014714ms)
✔ task action subjects and linked decision trail are complete and ordered (124.901599ms)
✔ launch binding is durable and reconnecting requires the identical trusted record (82.577206ms)
✔ business isolation includes inherited object names and cross-business message references (158.98533ms)
✔ authority never transfers between action, run, target, unresolved or replaced role holder (214.172783ms)
✔ task projection uses schema current view, skipping earlier and equal-start polls (115.636835ms)
✔ revocation permanently bars the old run from reclaiming first, including after broker restart (155.986702ms)
✔ empty message references refuse before storage; refusal-evidence failure stays a typed error (100.211715ms)
✔ both arbiters require human resolution when their cross-role route is themselves (170.699063ms)
✔ S1 adapter takes resolved limits and refs, rejects mismatched instance, never mutates input (2.448913ms)
✔ only validated broker references load; returned data and exceptions cannot expose a known token (5.338933ms)
✔ bad file modes, symlinks, repository/data paths, malformed tokens and missing dates refuse (3.616005ms)
✔ expiry refuses use and env references never become client data (0.835552ms)
✔ S1 parsed service refs work, service mismatch refuses, Gitea rotation due is a warning state (1.509074ms)
✔ opaque tokens shorter than 16 characters refuse before use (0.363752ms)
✔ endLaunch writes session.ended, releases the run claim and kills its capabilities (112.749681ms)
✔ endLaunch refuses an unknown run, a second end and a rebind of the ended run (132.696513ms)
✔ a restarted broker refuses to rebind an ended run; a refused rebind leaves the run unbound (148.142442ms)
✔ endLaunch leaves a claim another run took alone (158.162757ms)
✔ refuse records action.refused against the caller with the code only (124.764072ms)
✔ launches off refuses role.launch with launch-revoked until launches on (163.377777ms)
✔ broker process: launch ops authorize role.launch, record refusals and end runs (222.275652ms)
✔ human proof binds CLI entry, process start and nonce; agents and incomplete ancestry refuse (2.812658ms)
✔ process reader gets own kernel identity without exposing environment values (2.022907ms)
✔ EACCES ancestor environments skip only markers; commands and registered launches still refuse (1.21422ms)
✔ real pid 1 remains inspectable when its environment is protected (0.432171ms)
✔ within-role sends cite an open gated launch decision without spending it or naming it in grants (175.291927ms)
✔ missing and foreign-business citations refuse and roll back message and grant (174.715701ms)
✔ cross-role sends still need a matching resolved decision and consume it once (235.165957ms)
✔ broker process binds trusted launches, offers reader capabilities, refuses human mutation, closes cleanly (180.878894ms)
✔ startup token refusal returns safe code without value or partial listening broker (39.45217ms)
✔ loaded fixture token is absent from socket replies and SQLite, including refusal evidence (171.063865ms)
✔ killed broker leaves an explicit stale lock; another process cannot silently reclaim it (164.432592ms)
✔ trusted host registers later launches; socket clients never have a registration verb (159.992085ms)
✔ runtime excludes declared project roots even when host supplies no repoRoots (46.259516ms)
✔ a refused launch binding leaves the broker and existing capabilities alive; bad protocol stops it (143.94251ms)
✔ v3b prototype refusals, views and append-only mutations (942.561092ms)
✔ gated approval authorizes once, survives store reopen, and fresh approval works (234.75139ms)
✔ another run cannot consume an approval; a failed check leaves it usable (205.88139ms)
✔ two scheduled callers have exactly one grant and one consumed refusal (157.200154ms)
✔ failed commit rolls consumption back; cross-role consumes and within-role stays reusable (270.865391ms)
✔ class drift gated to cross-role refuses before consumption (176.065011ms)
✔ class drift cross-role to gated refuses before consumption (169.649872ms)
✔ class drift gated to within-role refuses before consumption (169.582233ms)
✔ class drift cross-role to within-role refuses before consumption (164.357356ms)
✔ class drift within-role to gated refuses before consumption (143.054783ms)
✔ class drift within-role to cross-role refuses before consumption (136.84595ms)
✔ message.send consumes approval and prevents a later send or authorize (163.811431ms)
✔ role.revoke consumes approval and prevents a later revoke or authorize (184.763789ms)
✔ creates private WAL store and excludes a second writer until explicit close (92.384573ms)
✔ rollback is atomic and schema metadata is checked against trusted DDL, not just itself (170.88004ms)
✔ existing empty database and symlink runtime directory refuse, never initialize over damage (167.724067ms)
✔ crash during a transaction recovers no partial event after explicit fixture-only lock removal (156.6242ms)
✔ writer refuses mixed at/read_at forms atomically, even through trusted SQL helpers (100.089282ms)
✔ async transactions refuse before invoking their function (84.719282ms)
✔ recordTask keeps sync reads and a role write apart (164.288089ms)
✔ read_at must be one canonical UTC format, so the projection compares strings safely (102.184227ms)
✔ a bad entry refuses the whole record (99.082849ms)
✔ taskView reads the projection for one business (114.742986ms)
✔ requestTask hands only a holder and a task verb to the handler, and records refusals (230.908121ms)
✔ the server sends task verbs to the adapter with its own timeout; other verbs stay synchronous (381.414015ms)
✔ without an adapter the server refuses every task verb (165.28934ms)
✔ the runtime refuses an invalid adapter and closes a valid one (178.819415ms)
✔ the process loads the S3 adapter from plain-data trackers (213.587599ms)
✔ socket capability stamps launch identity; shared views use wire, no SQL client (132.432256ms)
✔ two wire claims serialize; a lost reply never automatically retries (171.083375ms)
✔ malformed, oversized and identity-forging envelopes refuse without echoing input (104.965622ms)
✔ client preserves UTF-8 when a response divides a multibyte character (11.948306ms)
✔ committed mutation followed by dropped reply reports unknown and is never retried (136.629406ms)
ℹ tests 74
ℹ suites 0
ℹ pass 74
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 2281.398677
@@ -0,0 +1,68 @@
✔ config directory and file path follow MOSAIC_CONFIG (1.553587ms)
✔ the fixture business validates and comes back frozen (6.315213ms)
✔ two instances may share a definition (2.077018ms)
✔ top-level refusals (6.265561ms)
✔ arbiters and projects (7.887733ms)
✔ role instances (3.085341ms)
✔ Vikunja bots (8.808245ms)
✔ a role without Vikunja takes no tracker block (2.60627ms)
✔ credential references match the definition's services (3.647953ms)
✔ launch (11.703973ms)
✔ loadBusiness: file checks (2.04872ms)
✔ loadBusiness: not a regular file (49.944343ms)
✔ loading writes nothing (1.251757ms)
✔ names that are Object.prototype properties don't count as declared (5.435482ms)
✔ the shipped example refuses as written and validates once filled in (0.601228ms)
✔ usage errors exit 4 (315.229357ms)
✔ validate: a good business exits 0 and prints instance digests (73.51181ms)
✔ validate: project files (351.75333ms)
✔ validate: missing files and a broken system config (253.645234ms)
✔ validate: credential reference problems exit 2 and name each one (64.627904ms)
✔ validate: a token file inside the repository is refused (67.852495ms)
✔ validate: role definitions come from MOSAIC_ROLES_DIR (207.234442ms)
✔ resolve: prints one instance's record (222.046246ms)
✔ resolve: refusals (430.638885ms)
✔ parse: exactly one of file or env, plus the service's date (3.669091ms)
✔ check: a good file has no problems (1.129649ms)
✔ check never opens the file: a write-only token passes (0.483008ms)
✔ check: file problems (1.23747ms)
✔ check: token files can't live in the repository or dataRoot, even through a linked directory (1.274092ms)
✔ check: dates and environment references (0.577429ms)
✔ path and load (3.182412ms)
✔ refusals (1.88194ms)
✔ systemVars flattens the validated config (2.833551ms)
✔ precedence: system, business, project, project role, agent (7.176254ms)
✔ limits narrow the definition and never widen it (3.531924ms)
✔ role.launch stays within-role only for the instance the launch block names (6.071093ms)
✔ limits.authority without role.launch leaves the launcher with no launch block (1.975976ms)
✔ limits.authority narrows cross-role actions too (1.058494ms)
✔ classify (1.218652ms)
✔ the record carries what the broker and launcher need (0.928912ms)
✔ digest: key order doesn't matter, any value change does (6.44603ms)
✔ refusals (2.922372ms)
✔ the four shipped version 2 roles load (4.591684ms)
✔ shipped role scopes match addendum B section 2 and the SR runbook (1.653387ms)
✔ shipped authority follows the note's table (0.810481ms)
✔ version 1 files keep loading with no authority (1.311308ms)
✔ the conductor policy isn't a role (0.295062ms)
✔ a missing role file is exit 4, a symbolic link too (0.539465ms)
✔ version 2 refusals (1.857376ms)
✔ authority: closed vocabulary, no gated-only action, no overlap (3.343617ms)
✔ credentials: Gitea scopes (1.283982ms)
✔ credentials: Vikunja scopes are a group-to-verbs map from the grantable list (1.767491ms)
✔ credentials: services (0.909478ms)
✔ contract: a non-empty regular Markdown file beside the role file (1.29829ms)
✔ every key names known layers and a merge rule (1.03559ms)
✔ unknown keys and wrong layers refuse (0.899343ms)
✔ types (2.135088ms)
✔ merge: defaults, then the most specific layer wins (0.34324ms)
✔ merge: limits only narrow, and provenance lists each source (0.43299ms)
✔ merge doesn't change its inputs (0.16448ms)
ℹ tests 60
ℹ suites 0
ℹ pass 60
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 2061.893204
@@ -0,0 +1,93 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (108.493399ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (124.592277ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (108.130943ms)
✔ decide prints a declining choice as declining (103.347326ms)
✔ an unknown outcome is reported once and never resent (77.235557ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (72.170582ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (86.223951ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (54.324436ms)
✔ every human command refuses inside an agent run before it touches the bus (68.666738ms)
✔ usage errors exit 4; no business and no host is a usage error (58.485534ms)
✔ agents and tasks print through the broker (82.026326ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (1.255183ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (40.98501ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (31.673433ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (31.44595ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (29.305545ms)
✔ a business without tracker.baseUrl gets no trackers entry (28.189137ms)
✔ an unknown business and a broken system config refuse with exit 3 (63.026964ms)
✔ empty views say so (0.654093ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (0.815179ms)
✔ tasks print the tracker fields the snapshot carries (0.133138ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (879.576381ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (199.796083ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (103.058991ms)
✔ a broker reply that misses the wait breaks the channel: the late reply answers nothing, later requests refuse, the host exits 1 (1115.845944ms)
✔ a broker reply with another request's id, or none, breaks the channel and the host exits 1 (263.139734ms)
✔ a broker reply with no request waiting breaks the channel and the host exits 1 (139.535284ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (160.307462ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (177.42429ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (102.02552ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (142.149473ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (114.204878ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (158.924375ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (23.419602ms)
✔ bus stop refuses to signal a live pid that is not a bus host (202.500881ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (214.80008ms)
✔ bus start refuses with exit 3 without a notifier config (93.202427ms)
✔ bus start runs until bus stop; status reports it while it runs (658.007157ms)
✔ bus-service.sh renders the unit and installs it into a given directory (26.468007ms)
✔ bus start --pm: the PM runs under its own PID namespace, registered, with a manifest (1251.061453ms)
✔ the PM launches a coder through its launch tool; the coder answers; refusals name their code (593.182486ms)
✔ a runner that stops at once ends its launch with the runner's reason (193.38297ms)
✔ 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 (701.514915ms)
✔ 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.193237ms)
✔ a malformed sessions.json refuses the host start with exit 3 and stays as it was (103.004381ms)
✔ an over-long launch request is refused at once, not at the 10 s idle timeout (114.206499ms)
✔ a launch client that never closes its side doesn't hold the host's close (115.389874ms)
✔ a runner that ignores SIGTERM is killed when the host closes (1228.912194ms)
✔ zoned uses the IANA zone across DST (23.609043ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (106.289446ms)
✔ two blocking decisions get two DMs with different nonces (116.570341ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.220875ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (102.121619ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (94.352037ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (94.506708ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (92.56958ms)
✔ polled every POLL_MS against a permanent 403, a DM is sent at 0, 30, 60, 90 and 120 min and gives up only then (141.592641ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (110.682111ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (95.64294ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (58.221098ms)
✔ an inbox read failure is logged and the next poll retries (0.602797ms)
✔ no Discord id reaches the journal or the log (56.407946ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (10.479439ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (26.194974ms)
✔ the journal: a whole file that is one torn line truncates to empty (10.314475ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (0.535884ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.564367ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (1.75454ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.306269ms)
✔ 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.457156ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.377302ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.426368ms)
✔ digest content stays within Discord's 2000 characters (0.267179ms)
✔ runLoop never overlaps ticks and stops after the one in flight (110.853715ms)
task.close {} answered: invalid-request; fake saw 18 requests, first GET /info 200, GET /projects/1 200, GET /projects/1/views 200
task.close on a missing task answered: task-not-found; it made GET /tasks/999 404
✔ bootConfig trackers reach the S3 adapter in the real broker child, which goes ready against a fake Vikunja (380.731428ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (37.40083ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2140.922764ms)
✔ busExit and refuseInsideAgent (0.406241ms)
✔ talk sends a REQUEST, prints and reads everything that arrives, and stops at the reply (194.330924ms)
✔ talk --wait 0 only sends; no reply within --wait exits 1 and says where it will show (1126.296746ms)
✔ talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors (58.771617ms)
✔ launches off and on go to the broker and change the business's launch state (72.968752ms)
✔ launches list reads sessions.json and marks a stale entry; stop reports it and refuses a non-runner (69.017531ms)
ℹ tests 83
ℹ suites 0
ℹ pass 83
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 7952.992063
@@ -0,0 +1,64 @@
✔ sessionModel: agent vars win, then the system's execution settings (12.174642ms)
✔ a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory (5.164613ms)
✔ a claude-code bundle adds the wrapped gate hook and the MCP config (2.418064ms)
✔ a bundle is written once: an existing file refuses (1.722343ms)
✔ a path with a single quote can't go into the hook command (1.658098ms)
✔ allow exits 0, a deny exits 2 with the reason on stderr (119.743025ms)
✔ a missing or wrong policy, or a bad event, exits 2 (77.766471ms)
✔ the bundle's wrapped command: a missing gate or node still blocks (1086.900813ms)
✔ claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist (733.236148ms)
✔ claude: the hook alone blocks a path outside the workspace (545.313347ms)
✔ claude: a second turn resumes the first turn's session (734.516306ms)
✔ claude adapter: --restricted is always passed (5.339234ms)
✔ claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do (736.377038ms)
✔ claude: a missing hook or MCP file refuses before claude starts (7.585714ms)
✔ pi: policy tools and typed tools pass, anything else is blocked (3.375545ms)
✔ claude: builtins map from pi names, typed tools need the mcp prefix (0.881108ms)
✔ file tool paths must resolve inside the workspace (1.118282ms)
✔ pi's own path normalisation can't be used to step out (0.80142ms)
✔ a symlink inside the workspace that points out is outside (0.842261ms)
✔ a dangling symlink is refused at any depth, in both harnesses (2.728819ms)
✔ read is checked under every spelling pi's read would open, in both harnesses (13.971212ms)
✔ other spellings cover directories, dangling links and pi's cwd (1.336928ms)
✔ a relative path climbs from the workspace's real path, in both harnesses (4.249754ms)
✔ claude path fields per tool (0.778025ms)
✔ glob patterns stay inside the workspace (0.582081ms)
✔ a path that can't be checked is blocked (0.389253ms)
✔ initialize, ping and tools/list (41.590382ms)
✔ tools/call goes through the tool socket; a refusal is an isError result (29.687455ms)
✔ unknown tools and methods are JSON-RPC errors and never reach the socket (27.839299ms)
✔ a missing argument is a usage error (29.511723ms)
✔ pi: typed tools reach the socket, the gate blocks, agent_end writes the marker (342.258848ms)
✔ pi: a write through a dangling symlink is blocked, and nothing appears outside (329.954183ms)
✔ pi: a read is refused when pi would open another spelling outside (391.03747ms)
✔ pi: a relative path climbs from the real path of a workspace behind a symlink (337.070562ms)
✔ pi: a relative path climbs from the real path of a dataRoot behind a symlink (310.591766ms)
✔ pi: a missing extension refuses before any model call (6.795663ms)
✔ pi: an extension without its configuration fails pi's start (273.888723ms)
✔ founderCheck: founder variables, then a needed service without a usable token (1.401858ms)
✔ turnRequest names the sender, class, reply and decision (0.245937ms)
✔ a message becomes a turn, the answer goes back as a RESULT, SIGTERM releases and exits 0 (242.13304ms)
✔ a SIGTERM before the claim stops the runner with exit 0 and no claim (96.795615ms)
✔ typed tools carry the runner's capability; launch goes to the host's launch socket (399.58747ms)
✔ a RESULT gets no automatic reply; failed turns reply with the reason (1379.730931ms)
✔ SIGTERM during a turn kills the turn's process group and still exits 0 (168.713214ms)
✔ founder credentials stop before the claim (20) (165.371255ms)
✔ a refused claim exits 21; an ended run's capability exits 22 (189.675151ms)
✔ the launch ending under a running session exits 22 (142.691479ms)
✔ a broker that stays unreachable exits 23 after brokerRetries polls (253.317151ms)
✔ a broker that is down at the claim exits 23, not 21 (95.220818ms)
✔ no capability, or a malformed one, on stdin exits 2 (197.689545ms)
✔ a missing or malformed policy exits 2 before the claim (121.8547ms)
✔ the PM gets launch, its task verbs and the reads (7.064528ms)
✔ a coder gets no launch, no resolve_decision, and no task tools without a tracker (1.962884ms)
✔ launch only when the business's launch block names the instance as launcher (2.011183ms)
✔ an action outside the instance's authority has no tool (2.153315ms)
✔ callTool: one JSON line out, the result back, a refusal rejects (8.350813ms)
ℹ tests 56
ℹ suites 0
ℹ pass 56
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 10413.058302
@@ -0,0 +1,35 @@
✔ resolveSeat: by name under --repo resolves the repo layout (1.224063ms)
✔ resolveSeat: by path resolves the fleet layout (0.339743ms)
✔ resolveSeat: refusals for missing dir, missing launch.sh, non-executable launch.sh, invalid name, and unknown layout (0.674543ms)
✔ tmuxContext: outside tmux, default socket, custom socket, and exec failure (0.647433ms)
✔ makeRegistration produces a record that validates; each shape violation throws SeatError (0.780124ms)
✔ writeRegistration/readRegistration: round trip, permissions, absence, and malformed records (1.232878ms)
✔ updateTask: changes task, taskSetBy and updatedAt only, and refuses appropriately (1.122885ms)
✔ CLI launch: registers, execs the fake launch script, and passes args through (32.595514ms)
✔ CLI launch: --harness lands in the record (28.937557ms)
✔ CLI launch: the launch script's own exit code passes through (29.237677ms)
✔ CLI launch: relaunching a seat rewrites the one registration record (58.156763ms)
✔ CLI launch: omitting --task records an empty string, not null (29.421817ms)
✔ CLI seat task: updates only the task after a launch, and refuses on an unlaunched seat (81.737995ms)
✔ CLI refusals: no args, unknown flag, missing config, already-registered env, and exec failure (128.631197ms)
✔ samePath: equal paths, symlinked dirs, distinct dirs, and non-strings (0.465785ms)
✔ resolveSetBy: explicit --by wins over the environment; absent or empty environment gives unknown; invalid explicit or environment values refuse with exit 4 (0.653742ms)
✔ 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.7817ms)
✔ 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 (7.655627ms)
✔ 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.310362ms)
✔ family: exactly one launch.max key in the model name, else null (0.609701ms)
✔ sessionEnv passes only the allowlist, the repo's bin on PATH, and the run id (0.657394ms)
✔ newRun: short ids, 0700 directories, and a refusal when the socket path won't fit (1.481122ms)
✔ session file and launch log: 0600, the session file written once (0.945966ms)
✔ endReason maps the runner's exit codes; a signal is killed (0.10512ms)
✔ Registry mirrors to sessions.json; readSessions marks live entries; bad files refuse (1.293113ms)
✔ stopSession refuses an unknown run, reports a stale one, and won't signal a pid that isn't a runner (1.029557ms)
✔ a session runs under unshare as pid 1 of its namespace, claims, answers, and stops on mosaic stop (248.082544ms)
ℹ tests 27
ℹ suites 0
ℹ pass 27
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 729.094244
@@ -0,0 +1,12 @@
== MODE=absread 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":"File does not exist. Note: your current working directory is <R>/deep/store/ws."}]
outside after the run: secret.txt
workspace after the run:
== MODE=abswrite 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":false,"content":"File created successfully at: <R>/data/ws/../../data/ws/planted.txt (file state is current in your context — no need to Read it back)"}]
outside after the run: secret.txt
workspace after the run: planted.txt
@@ -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/r41d/wt/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/r41d/wt/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,12 @@
== MODE=absread given workspace <R>/data/ws, real <R>/deep/store/ws
gate decide: {"allow":true}
exit 0; stderr:
tool_result sent back: [{"is_error":true,"content":"ENOENT: no such file or directory, access '<R>/data/ws/secret.txt'"}]
outside after the run: secret.txt
workspace after the run:
== MODE=abswrite 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 <R>/data/ws/../../data/ws/planted.txt"}]
outside after the run: secret.txt
workspace after the run: planted.txt
@@ -0,0 +1,12 @@
== MODE=read given workspace <R>/data/ws, real <R>/deep/store/ws
gate decide: {"allow":false,"reason":"mosaic gate: read path is outside the workspace: ../../data/ws/secret.txt"}
exit 0; stderr:
tool_result sent back: [{"is_error":true,"content":"mosaic gate: read path is outside the workspace: ../../data/ws/secret.txt"}]
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":false,"reason":"mosaic gate: write path is outside the workspace: ../../data/ws/planted.txt"}
exit 0; stderr:
tool_result sent back: [{"is_error":true,"content":"mosaic gate: write path is outside the workspace: ../../data/ws/planted.txt"}]
outside after the run: secret.txt
workspace after the run:
@@ -0,0 +1,16 @@
== MODE=plain asked p l a i n . t x t
gate decide: {"allow":false,"reason":"mosaic gate: read path is outside the workspace: plain.txt"}
exit 0; stderr:
tool_result sent back: [{"is_error":true,"content":"mosaic gate: read path is outside the workspace: plain.txt"}]
== MODE=quote asked n o t e s ' s . t x t
gate decide: {"allow":false,"reason":"mosaic gate: read path is outside the workspace under another spelling: notes's.txt -> \"/home/jwoltje/darkwing-scratch/tmp/r41-pvariant-NS5I/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-NS5I/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-cSjW/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-cSjW/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-ffre/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-ffre/ws/résumé.txt\""}]
@@ -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 56 ℹ 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 '0a4c8f13b09b8882ea55f6061d26949330385ee8'.
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 '0a4c8f13b09b8882ea55f6061d26949330385ee8'.
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: 2927964 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/r41d/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,51 @@
#!/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.
# Round 4 addition: absread/abswrite give the absolute path
# "<R>/data/ws/../../data/ws/X". Lexically that is the workspace; walked
# through the link it is <R>/deep/data/ws/X, outside.
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 ;;
absread) TOOL="{\"name\":\"Read\",\"input\":{\"file_path\":\"$WS/../../data/ws/secret.txt\"}}"; TOOLS=Read ;;
abswrite) TOOL="{\"name\":\"Write\",\"input\":{\"file_path\":\"$WS/../../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,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));
+61
View File
@@ -0,0 +1,61 @@
#!/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.
# Round 4 addition: absread/abswrite give the absolute path
# "<R>/data/ws/../../data/ws/X". Lexically that is the workspace; walked
# through the link it is <R>/deep/data/ws/X, outside.
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"}}' ;;
absread) TOOL="{\"name\":\"read\",\"input\":{\"path\":\"$WS/../../data/ws/secret.txt\"}}" ;;
abswrite) TOOL="{\"name\":\"write\",\"input\":{\"path\":\"$WS/../../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"
+56
View File
@@ -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);
+175
View File
@@ -0,0 +1,175 @@
# Row 41, slice 1 S6 (meta-harness and launching), round 4 review (Darkwing)
Issue #1523, request comment 27033, queue revs 274 and 275 (`6f102777`).
Packet: `agents/filbert/work/s6/` at `55bff3b2`, base `915e00e5`, gate at
`0a4c8f13`, 42 files. Candidate manifest sha256
`08a78972e316cb3b9cba2050489bd65b2c0b1ec95eb7de9b91c266e89a0d0d66`.
Four files changed since round 3: `gate.mjs`, the harness README,
`gate.test.mjs` and `pi-session.test.mjs`. Round 3: `review-r3.md`,
comment 27032. Sage ruled that round 4 fixes R5 only and that R1 to R4
stay closed.
Verdict: **approve**, comment 27034. R5 is fixed. A relative path now has to resolve
inside from the workspace's real path, which is where Pi resolves it, and
from the path as given. My round 3 probes refuse in both modes and both
harnesses, the new tests cover both symlink layouts with a real Pi
session, and every mutant of the new line is killed. Nothing outside
`insideWorkspace` and its tests changed. The four spelling survivors from
round 3 still survive, as expected, since Sage kept them out of this
round. They stay follow-ups.
## Method
- A detached worktree at `0a4c8f13`, then `git apply --index build.patch`
and `sha256sum -c candidate-manifest.sha256`: 42 OK. After the probes and
mutants I checked again: 42 OK (`r4/mut/manifest-after.txt`), and no test
or probe process was left running.
- I rebuilt round 3's candidate beside it and diffed the two trees
(`r4/interdiff.patch`, 4 files, +126/−6). I read `gate.mjs` around the
change and both new tests in full.
- I reread Pi 0.85.1's `resolvePath` (`dist/utils/paths.js:82-86`). A
relative path is `path.resolve(cwd, p)`, and an absolute one is
`path.resolve(p)`. Both are lexical.
- Probes are in `r4/probe/`. They import from my scratch worktree by
absolute path (`WT`). Model calls go to `r4/probe/mock-api.mjs` on
127.0.0.1 with a dummy key, so nothing was spent. Claude Code is
2.1.296, and Pi is 0.85.1 from the repository's `node_modules`.
- 13 mutants on `gate.mjs` (`r4/mut/mutate.py`, `run.sh`, `all.sh`), each
run on the harness suite with the file restored from a backup copy. Nine
are round 3's gate mutants and four are new on the R5 line. The other 20
round 3 mutants change source and tests that round 4 doesn't touch, so I
didn't rerun them.
Node is v26.8.1, `TMPDIR=~/darkwing-scratch/tmp`. `gate.sh` set
`DOCKER_HOST=unix:///nonexistent.sock`.
## Suites
| Suite | Result |
|---|---|
| harness | 56/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-release | 4/0 without Docker, 14/0 with it (`test-release-docker.txt`) |
| test-discord | 66/0 |
| test-task | 26/2 |
Harness gains the three R5 tests over round 3's 53. The two `test-task`
failures are "user recall run succeeds" and "recalled user name", the live
worker check. It needs Docker and a paid model call, which I didn't make.
Filbert's base run fails the same two, and the follow-up for it is already
in BUILD.md.
## R5: fixed
`insideWorkspace` (`gate.mjs:76-80`) keeps the absolute branch and changes
the relative one:
```js
return within(workspace, resolve(realpathSync(workspace), s)) && within(workspace, resolve(workspace, s));
```
`within` walks each candidate with `real()`, so a dangling link on either
path still refuses (R3). `spellings()` already built from both bases in
round 3, so `read`'s other-spelling check needed nothing new.
Checking both bases is stricter than Sage's ruling, which asked for the
real cwd. Real-only matches Pi exactly. Both-bases refuses everything
real-only refuses, plus paths that climb out of the given path and come
back in through the real one, such as `../../store/ws/x`. BUILD.md and the
README name that as a choice. I'd recommended both bases in round 3, and I
think the extra refusal costs nothing: the plain relative path or the
absolute one reaches the same file.
My round 3 probes, unchanged except for `WT`:
| Probe | Mode | Round 3 | Round 4 |
|---|---|---|---|
| Pi, `../../data/ws/X` | read | allowed, secret in the tool result | gate refuses, nothing read |
| Pi | write | allowed, file created outside | gate refuses, nothing written anywhere |
| Claude Code | read | hook refuses `<R>/deep/data/ws/secret.txt` | same |
| Claude Code | write | hook refuses `<R>/deep/data/ws/planted.txt` | same |
Output: `r4/out/probe-pi-cwd-dotdot.txt` and `probe-claude-cwd-dotdot.txt`.
I also checked the absolute form of the same trick, which round 3 didn't
cover: `<R>/data/ws/../../data/ws/X`. Lexically that's the workspace, and
walked through the link it's `<R>/deep/data/ws/X`, outside. If either
harness passed it to the kernel unnormalised, the gate's lexical
`resolve(s)` would allow a path that opens outside. Neither does. Pi's
`resolvePath` normalises it, and Claude Code opens the normalised path too.
In a real session the write landed in the workspace as `planted.txt` and
the read found nothing (`r4/probe/pi-abs-dotdot.sh`,
`claude-abs-dotdot.sh`; output in `r4/out/probe-*-abs-dotdot.txt`). No
finding.
The tests:
- `gate.test.mjs`, "a relative path climbs from the workspace's real path,
in both harnesses". It runs two layouts, a symlinked workspace and a
symlinked dataRoot in the launcher's `workspaces/<b>/<i>` shape, each in
Pi and Claude Code. It asserts that the layout really splits the two
resolutions before it checks the gate, so a fixture mistake can't make
the test pass for nothing. The escape is refused for read and write, the
climb back in is allowed, and the stricter case is refused.
- `pi-session.test.mjs` runs a real Pi session per layout through the
adapter. The escape read and write come back as gate errors, the secret
isn't in stdout, and the outside directory holds only the planted file.
The inside read returns its content and the inside write lands.
## R4 and earlier: unchanged
`r4/probe/spell-edges.mjs` gives output identical to round 3, path
prefixes aside: 28 cases as expected. `pi-variant.sh` in a real Pi session
refuses all four modes (plain, quote, ampm, nfd), as in round 3.
## Mutants
| Mutant | Change | Harness | Result |
|---|---|---|---|
| MD | given path only (round 3's code) | 53/3 | killed: the gate test and both Pi sessions |
| ME | real path only | 55/1 | killed: the gate test's stricter case |
| MF | `\|\|` instead of `&&` | 53/3 | killed, same three as MD |
| MG | no `normalise` in `insideWorkspace` | 55/1 | killed: "pi's own path normalisation can't be used to step out" |
| Ml | the glob pattern `..` check off | 55/1 | killed |
| Ms | `real()` walks with `realpathSync`, not `lstat` | 53/3 | killed |
| MA | other-spelling check on Pi's `read` only | 55/1 | killed |
| MB | spellings from the given base only | 55/1 | killed |
| MC | no NFD-with-U+2019 spelling | 54/2 | killed |
| Mw | AM/PM regex without `i` | 56/0 | survives (round 3 follow-up) |
| Mx | `'` replaced once | 56/0 | survives (round 3 follow-up) |
| My | any `lstat` error skipped | 56/0 | survives (round 3 follow-up) |
| Mz | no `normalise` in `spellings()` | 56/0 | survives (round 3 follow-up) |
MD and ME match Filbert's table (16/3 and 18/1 on the two files).
Per-mutant output: `r4/mut/<id>-harness.txt`, summary in
`r4/mut/summary.txt`.
## Notes (not blocking)
1. The README says Claude Code makes "a path" absolute before the hook. I
saw it for `file_path` on `Read` and `Write`. I didn't check `Grep` and
`Glob`'s `path`. It doesn't matter for safety, because the gate now
checks a relative path from both bases whichever harness sends it.
2. The follow-ups in BUILD.md match what I asked for: Mw to Mz, a
`realpathSync` in the seat's `workspaceDir`, and the Ma leftover. With
R5 fixed in the gate, the `workspaceDir` change is hygiene, not a fix.
## Files
- `r4/candidate-manifest.sha256`, `r4/files.txt`: copies of Filbert's.
- `r4/interdiff.patch`: round 3 candidate to round 4 candidate.
- `r4/gate.sh`, `r4/out/`: suite runs and probe outputs.
- `r4/probe/`: round 3's probes, plus `pi-abs-dotdot.sh` and
`claude-abs-dotdot.sh` (round 3's with the `absread` and `abswrite`
modes).
- `r4/mut/`: mutant definitions, driver, outputs and the manifest check
after the runs.