docs(review): row 41 slice 1 S6 round 1 review, changes (darkwing)

R1 and R3 block: a late broker reply shifts later replies on the host IPC
channel, and a dangling workspace symlink passes the gate. R2 (close waits
on an open launch.sock client) should be fixed in the same round. Probes,
mutants and suite outputs alongside. Comment 26995 on #1523, queue rev 247.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-09 20:21:28 -05:00
co-authored by Claude Opus 5.5
parent e44d96e29e
commit f19787ee60
57 changed files with 3384 additions and 0 deletions
@@ -0,0 +1,117 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (102.91821ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (121.245477ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (104.328016ms)
✔ decide prints a declining choice as declining (98.264991ms)
✔ an unknown outcome is reported once and never resent (87.04748ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (81.339494ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (92.410729ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (85.608463ms)
✔ every human command refuses inside an agent run before it touches the bus (69.207672ms)
✔ usage errors exit 4; no business and no host is a usage error (83.827733ms)
✔ agents and tasks print through the broker (66.23017ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (1.136984ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (53.128351ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (33.918861ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (32.322342ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (29.633405ms)
✔ a business without tracker.baseUrl gets no trackers entry (28.530151ms)
✔ an unknown business and a broken system config refuse with exit 3 (49.933711ms)
✔ empty views say so (0.95895ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (1.176214ms)
✔ tasks print the tracker fields the snapshot carries (0.206492ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (897.060907ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (218.96706ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (166.998187ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (240.380587ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (176.050616ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (99.085127ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (157.614738ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (106.266039ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (172.254634ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (24.320455ms)
✔ bus stop refuses to signal a live pid that is not a bus host (203.668627ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (209.251282ms)
✔ bus start refuses with exit 3 without a notifier config (90.631351ms)
✔ bus start runs until bus stop; status reports it while it runs (651.145497ms)
✔ bus-service.sh renders the unit and installs it into a given directory (26.252918ms)
✖ bus start --pm: the PM runs under its own PID namespace, registered, with a manifest (30261.274586ms)
✖ the PM launches a coder through its launch tool; the coder answers; refusals name their code (18484.12709ms)
✔ a runner that stops at once ends its launch with the runner's reason (192.822298ms)
✔ 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 (30672.073519ms)
✔ 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 (33689.424955ms)
✔ a malformed sessions.json refuses the host start with exit 3 and stays as it was (170.672928ms)
✔ zoned uses the IANA zone across DST (24.830415ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (116.216116ms)
✔ two blocking decisions get two DMs with different nonces (115.607596ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.182767ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (108.508712ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (94.134315ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (100.255285ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (94.210215ms)
✔ 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 (164.847367ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (129.956437ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (114.751541ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (46.672456ms)
✔ an inbox read failure is logged and the next poll retries (0.651746ms)
✔ no Discord id reaches the journal or the log (74.269566ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (13.045248ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (31.448374ms)
✔ the journal: a whole file that is one torn line truncates to empty (25.364473ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (1.986317ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.773563ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (5.098288ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.437801ms)
✔ 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.763012ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.638287ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.733887ms)
✔ digest content stays within Discord's 2000 characters (0.483725ms)
✔ runLoop never overlaps ticks and stops after the one in flight (111.972535ms)
task.close {} answered: invalid-request; fake saw 18 requests, first GET /info 200, GET /projects/1 200, GET /projects/1/views 200
task.close on a missing task answered: task-not-found; it made GET /tasks/999 404
✔ bootConfig trackers reach the S3 adapter in the real broker child, which goes ready against a fake Vikunja (365.138347ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (41.26363ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2139.875576ms)
✔ busExit and refuseInsideAgent (0.410677ms)
✔ talk sends a REQUEST, prints and reads everything that arrives, and stops at the reply (201.635337ms)
✔ talk --wait 0 only sends; no reply within --wait exits 1 and says where it will show (1135.61941ms)
✔ talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors (70.768797ms)
✔ launches off and on go to the broker and change the business's launch state (75.752156ms)
✔ launches list reads sessions.json and marks a stale entry; stop reports it and refuses a non-runner (56.90734ms)
ℹ tests 77
ℹ suites 0
ℹ pass 75
ℹ fail 2
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 113545.957339
✖ failing tests:
test at packages/cli/tests/launcher.test.mjs:105:1
✖ bus start --pm: the PM runs under its own PID namespace, registered, with a manifest (30261.274586ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
+ actual - expected
+ 'killed'
- 'stopped'
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41/wt/packages/cli/tests/launcher.test.mjs:171:10)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Test.run (node:internal/test_runner/test:1409:7)
at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: 'killed',
expected: 'stopped',
operator: 'strictEqual',
diff: 'simple'
}
test at packages/cli/tests/launcher.test.mjs:175:1
✖ the PM launches a coder through its launch tool; the coder answers; refusals name their code (18484.12709ms)
Error: timed out waiting
at until (file:///home/jwoltje/darkwing-scratch/r41/wt/packages/cli/tests/launcher.test.mjs:43:9)
at async TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41/wt/packages/cli/tests/launcher.test.mjs:215:3)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7)
@@ -0,0 +1,116 @@
✔ sessionModel: agent vars win, then the system's execution settings (14.060701ms)
✔ a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory (6.264076ms)
✔ a claude-code bundle adds the wrapped gate hook and the MCP config (2.839664ms)
✔ a bundle is written once: an existing file refuses (3.015861ms)
✔ a path with a single quote can't go into the hook command (2.544498ms)
✔ allow exits 0, a deny exits 2 with the reason on stderr (132.672334ms)
✔ a missing or wrong policy, or a bad event, exits 2 (87.579364ms)
✔ the bundle's wrapped command: a missing gate or node still blocks (1096.85555ms)
✔ claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist (822.993933ms)
✔ claude: the hook alone blocks a path outside the workspace (474.272951ms)
✔ claude: a second turn resumes the first turn's session (795.497289ms)
✔ claude: a missing hook or MCP file refuses before claude starts (7.958534ms)
✔ pi: policy tools and typed tools pass, anything else is blocked (2.905317ms)
✔ claude: builtins map from pi names, typed tools need the mcp prefix (0.824151ms)
✔ file tool paths must resolve inside the workspace (0.924751ms)
✔ pi's own path normalisation can't be used to step out (0.989216ms)
✔ a symlink inside the workspace that points out is outside (0.928767ms)
✔ claude path fields per tool (1.135022ms)
✔ glob patterns stay inside the workspace (0.905393ms)
✔ a path that can't be checked is blocked (0.641327ms)
✔ initialize, ping and tools/list (52.803314ms)
✔ tools/call goes through the tool socket; a refusal is an isError result (36.588106ms)
✔ unknown tools and methods are JSON-RPC errors and never reach the socket (33.504222ms)
✔ a missing argument is a usage error (31.403397ms)
✔ pi: typed tools reach the socket, the gate blocks, agent_end writes the marker (415.995746ms)
✔ pi: a missing extension refuses before any model call (8.458682ms)
✔ pi: an extension without its configuration fails pi's start (279.315528ms)
✔ founderCheck: founder variables, then a needed service without a usable token (1.858056ms)
✔ turnRequest names the sender, class, reply and decision (0.265717ms)
✖ a message becomes a turn, the answer goes back as a RESULT, SIGTERM releases and exits 0 (193.748977ms)
✖ a SIGTERM before the claim stops the runner with exit 0 and no claim (92.328678ms)
✔ typed tools carry the runner's capability; launch goes to the host's launch socket (443.407365ms)
✔ a RESULT gets no automatic reply; failed turns reply with the reason (1296.372554ms)
✖ SIGTERM during a turn kills the turn's process group and still exits 0 (144.59476ms)
✔ founder credentials stop before the claim (20) (171.480323ms)
✔ a refused claim exits 21; an ended run's capability exits 22 (197.025228ms)
✔ the launch ending under a running session exits 22 (142.65891ms)
✔ a broker that stays unreachable exits 23 after brokerRetries polls (248.859517ms)
✔ a broker that is down at the claim exits 23, not 21 (106.087394ms)
✔ no capability, or a malformed one, on stdin exits 2 (164.168036ms)
✔ the PM gets launch, its task verbs and the reads (7.809666ms)
✔ a coder gets no launch, no resolve_decision, and no task tools without a tracker (2.544128ms)
✔ launch only when the business's launch block names the instance as launcher (2.508715ms)
✔ an action outside the instance's authority has no tool (3.166666ms)
✔ callTool: one JSON line out, the result back, a refusal rejects (11.382644ms)
ℹ tests 45
ℹ suites 0
ℹ pass 42
ℹ fail 3
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 10318.14779
✖ failing tests:
test at packages/harness/tests/runner.test.mjs:141:1
✖ a message becomes a turn, the answer goes back as a RESULT, SIGTERM releases and exits 0 (193.748977ms)
AssertionError [ERR_ASSERTION]: runner: demo/coder claimed by run coder-run
null !== 0
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41/wt/packages/harness/tests/runner.test.mjs:160:10)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: null,
expected: 0,
operator: 'strictEqual',
diff: 'simple'
}
test at packages/harness/tests/runner.test.mjs:166:1
✖ a SIGTERM before the claim stops the runner with exit 0 and no claim (92.328678ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal:
+ actual - expected
[
- 0,
null,
+ 'SIGTERM'
]
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41/wt/packages/harness/tests/runner.test.mjs:185:10)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: [ null, 'SIGTERM' ],
expected: [ 0, null ],
operator: 'deepStrictEqual',
diff: 'simple'
}
test at packages/harness/tests/runner.test.mjs:232:1
✖ SIGTERM during a turn kills the turn's process group and still exits 0 (144.59476ms)
AssertionError [ERR_ASSERTION]: runner: demo/coder claimed by run coder-run
null !== 0
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41/wt/packages/harness/tests/runner.test.mjs:242:10)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: null,
expected: 0,
operator: 'strictEqual',
diff: 'simple'
}
@@ -0,0 +1,102 @@
✔ launch identity is stamped, payload identity is refused and stale holder cannot send (184.047781ms)
✔ decision classes route from policy; gated resolution is human-only, choice and target must match (257.734218ms)
✔ claim exclusion, holder release, gated revoke and rerouting to a new holder are atomic (278.500051ms)
✔ launch events require a human CLI capability; generic emit cannot forge authority events (165.068907ms)
✔ within-role decisions close atomically and invalid options or blocking omissions refuse (161.335881ms)
✔ observer capabilities read human inbox but cannot mutate or forge launch identity (159.243496ms)
✔ task action subjects and linked decision trail are complete and ordered (176.904294ms)
✔ launch binding is durable and reconnecting requires the identical trusted record (109.155948ms)
✔ business isolation includes inherited object names and cross-business message references (174.305239ms)
✔ authority never transfers between action, run, target, unresolved or replaced role holder (270.690431ms)
✔ task projection uses schema current view, skipping earlier and equal-start polls (127.365414ms)
✔ revocation permanently bars the old run from reclaiming first, including after broker restart (183.630887ms)
✔ empty message references refuse before storage; refusal-evidence failure stays a typed error (137.706215ms)
✔ both arbiters require human resolution when their cross-role route is themselves (271.610917ms)
✔ S1 adapter takes resolved limits and refs, rejects mismatched instance, never mutates input (1.748448ms)
✔ only validated broker references load; returned data and exceptions cannot expose a known token (4.508833ms)
✔ bad file modes, symlinks, repository/data paths, malformed tokens and missing dates refuse (2.886979ms)
✔ expiry refuses use and env references never become client data (0.832119ms)
✔ S1 parsed service refs work, service mismatch refuses, Gitea rotation due is a warning state (1.473483ms)
✔ opaque tokens shorter than 16 characters refuse before use (0.359737ms)
✔ endLaunch writes session.ended, releases the run claim and kills its capabilities (133.186192ms)
✔ endLaunch refuses an unknown run, a second end and a rebind of the ended run (129.549343ms)
✖ a restarted broker refuses to rebind an ended run; a refused rebind leaves the run unbound (152.425639ms)
✔ endLaunch leaves a claim another run took alone (181.719438ms)
✔ refuse records action.refused against the caller with the code only (145.867162ms)
✔ launches off refuses role.launch with launch-revoked until launches on (176.571624ms)
✔ broker process: launch ops authorize role.launch, record refusals and end runs (214.227705ms)
✔ human proof binds CLI entry, process start and nonce; agents and incomplete ancestry refuse (2.122501ms)
✔ process reader gets own kernel identity without exposing environment values (0.56463ms)
✔ EACCES ancestor environments skip only markers; commands and registered launches still refuse (0.712195ms)
✔ real pid 1 remains inspectable when its environment is protected (0.361585ms)
✔ within-role sends cite an open gated launch decision without spending it or naming it in grants (199.323709ms)
✔ missing and foreign-business citations refuse and roll back message and grant (191.07183ms)
✔ cross-role sends still need a matching resolved decision and consume it once (246.333185ms)
✔ broker process binds trusted launches, offers reader capabilities, refuses human mutation, closes cleanly (188.169376ms)
✔ startup token refusal returns safe code without value or partial listening broker (41.838933ms)
✔ loaded fixture token is absent from socket replies and SQLite, including refusal evidence (166.55587ms)
✔ killed broker leaves an explicit stale lock; another process cannot silently reclaim it (176.255856ms)
✔ trusted host registers later launches; socket clients never have a registration verb (178.685487ms)
✔ runtime excludes declared project roots even when host supplies no repoRoots (36.737248ms)
✔ a refused launch binding leaves the broker and existing capabilities alive; bad protocol stops it (165.960804ms)
✔ v3b prototype refusals, views and append-only mutations (1032.22821ms)
✔ gated approval authorizes once, survives store reopen, and fresh approval works (249.596775ms)
✔ another run cannot consume an approval; a failed check leaves it usable (224.168828ms)
✔ two scheduled callers have exactly one grant and one consumed refusal (170.880883ms)
✔ failed commit rolls consumption back; cross-role consumes and within-role stays reusable (308.703039ms)
✔ class drift gated to cross-role refuses before consumption (198.719962ms)
✔ class drift cross-role to gated refuses before consumption (226.403993ms)
✔ class drift gated to within-role refuses before consumption (207.238596ms)
✔ class drift cross-role to within-role refuses before consumption (227.695738ms)
✔ class drift within-role to gated refuses before consumption (194.611101ms)
✔ class drift within-role to cross-role refuses before consumption (170.531727ms)
✔ message.send consumes approval and prevents a later send or authorize (209.047544ms)
✔ role.revoke consumes approval and prevents a later revoke or authorize (321.964213ms)
✔ creates private WAL store and excludes a second writer until explicit close (137.579044ms)
✔ rollback is atomic and schema metadata is checked against trusted DDL, not just itself (172.969185ms)
✔ existing empty database and symlink runtime directory refuse, never initialize over damage (191.788232ms)
✔ crash during a transaction recovers no partial event after explicit fixture-only lock removal (171.439266ms)
✔ writer refuses mixed at/read_at forms atomically, even through trusted SQL helpers (105.58886ms)
✔ async transactions refuse before invoking their function (85.925133ms)
✔ recordTask keeps sync reads and a role write apart (184.805478ms)
✔ read_at must be one canonical UTC format, so the projection compares strings safely (108.899917ms)
✔ a bad entry refuses the whole record (103.269817ms)
✔ taskView reads the projection for one business (126.877852ms)
✔ requestTask hands only a holder and a task verb to the handler, and records refusals (239.547908ms)
✔ the server sends task verbs to the adapter with its own timeout; other verbs stay synchronous (395.224422ms)
✔ without an adapter the server refuses every task verb (198.533632ms)
✔ the runtime refuses an invalid adapter and closes a valid one (212.245707ms)
✔ the process loads the S3 adapter from plain-data trackers (258.328732ms)
✔ socket capability stamps launch identity; shared views use wire, no SQL client (160.333179ms)
✔ two wire claims serialize; a lost reply never automatically retries (180.845064ms)
✔ malformed, oversized and identity-forging envelopes refuse without echoing input (99.411062ms)
✔ client preserves UTF-8 when a response divides a multibyte character (11.674905ms)
✔ committed mutation followed by dropped reply reports unknown and is never retried (135.16461ms)
ℹ tests 74
ℹ suites 0
ℹ pass 73
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 2789.931252
✖ failing tests:
test at packages/bus/tests/end-launch.test.mjs:73:1
✖ a restarted broker refuses to rebind an ended run; a refused rebind leaves the run unbound (152.425639ms)
AssertionError [ERR_ASSERTION]: Missing expected exception.
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41/wt/packages/bus/tests/end-launch.test.mjs:80:10)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: undefined,
expected: /unknown-run/,
operator: 'throws',
diff: 'simple'
}
@@ -0,0 +1,87 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (175.975906ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (184.05482ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (135.975643ms)
✔ decide prints a declining choice as declining (115.597908ms)
✔ an unknown outcome is reported once and never resent (107.994328ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (114.399879ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (118.753309ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (98.356969ms)
✔ every human command refuses inside an agent run before it touches the bus (96.122047ms)
✔ usage errors exit 4; no business and no host is a usage error (104.825903ms)
✔ agents and tasks print through the broker (109.999782ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (1.3094ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (38.79278ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (37.77004ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (32.111855ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (32.831006ms)
✔ a business without tracker.baseUrl gets no trackers entry (28.202059ms)
✔ an unknown business and a broken system config refuse with exit 3 (56.936951ms)
✔ empty views say so (0.745144ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (1.076708ms)
✔ tasks print the tracker fields the snapshot carries (0.205381ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (950.611218ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (227.116354ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (176.539657ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (211.488732ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (225.739981ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (176.746236ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (269.362482ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (165.128453ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (259.705176ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (24.783932ms)
✔ bus stop refuses to signal a live pid that is not a bus host (205.447122ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (219.571849ms)
✔ bus start refuses with exit 3 without a notifier config (107.523707ms)
✔ bus start runs until bus stop; status reports it while it runs (731.219938ms)
✔ bus-service.sh renders the unit and installs it into a given directory (38.872725ms)
✔ bus start --pm: the PM runs under its own PID namespace, registered, with a manifest (331.308668ms)
✔ the PM launches a coder through its launch tool; the coder answers; refusals name their code (667.342883ms)
✔ a runner that stops at once ends its launch with the runner's reason (255.258443ms)
✔ 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 (843.785719ms)
✔ 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 (3808.232229ms)
✔ a malformed sessions.json refuses the host start with exit 3 and stays as it was (167.609852ms)
✔ zoned uses the IANA zone across DST (13.817706ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (148.840172ms)
✔ two blocking decisions get two DMs with different nonces (156.482282ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.184902ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (155.373324ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (119.234353ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (114.466929ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (120.568701ms)
✔ 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 (173.689096ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (139.718846ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (137.675725ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (114.287003ms)
✔ an inbox read failure is logged and the next poll retries (0.671341ms)
✔ no Discord id reaches the journal or the log (113.963173ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (22.026662ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (42.879376ms)
✔ the journal: a whole file that is one torn line truncates to empty (19.168903ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (3.911022ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.753617ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (4.426854ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.578878ms)
✔ 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.669505ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.410289ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.471842ms)
✔ digest content stays within Discord's 2000 characters (0.330777ms)
✔ runLoop never overlaps ticks and stops after the one in flight (111.304879ms)
task.close {} answered: invalid-request; fake saw 18 requests, first GET /info 200, GET /projects/1 200, GET /projects/1/views 200
task.close on a missing task answered: task-not-found; it made GET /tasks/999 404
✔ bootConfig trackers reach the S3 adapter in the real broker child, which goes ready against a fake Vikunja (411.263109ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (32.559089ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2156.033595ms)
✔ busExit and refuseInsideAgent (0.434997ms)
✔ talk sends a REQUEST, prints and reads everything that arrives, and stops at the reply (295.088871ms)
✔ talk --wait 0 only sends; no reply within --wait exits 1 and says where it will show (1177.149209ms)
✔ talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors (89.322782ms)
✔ launches off and on go to the broker and change the business's launch state (112.778973ms)
✔ launches list reads sessions.json and marks a stale entry; stop reports it and refuses a non-runner (164.101361ms)
ℹ tests 77
ℹ suites 0
ℹ pass 77
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 6161.026277
@@ -0,0 +1,102 @@
✔ launch identity is stamped, payload identity is refused and stale holder cannot send (292.561005ms)
✔ decision classes route from policy; gated resolution is human-only, choice and target must match (277.551361ms)
✔ claim exclusion, holder release, gated revoke and rerouting to a new holder are atomic (497.078545ms)
✔ launch events require a human CLI capability; generic emit cannot forge authority events (226.410623ms)
✔ within-role decisions close atomically and invalid options or blocking omissions refuse (200.406094ms)
✔ observer capabilities read human inbox but cannot mutate or forge launch identity (167.322227ms)
✔ task action subjects and linked decision trail are complete and ordered (167.929413ms)
✔ launch binding is durable and reconnecting requires the identical trusted record (115.499176ms)
✔ business isolation includes inherited object names and cross-business message references (191.409782ms)
✔ authority never transfers between action, run, target, unresolved or replaced role holder (274.594256ms)
✔ task projection uses schema current view, skipping earlier and equal-start polls (108.292348ms)
✔ revocation permanently bars the old run from reclaiming first, including after broker restart (189.599503ms)
✔ empty message references refuse before storage; refusal-evidence failure stays a typed error (122.287696ms)
✔ both arbiters require human resolution when their cross-role route is themselves (209.820218ms)
✔ S1 adapter takes resolved limits and refs, rejects mismatched instance, never mutates input (2.954793ms)
✔ only validated broker references load; returned data and exceptions cannot expose a known token (6.947483ms)
✔ bad file modes, symlinks, repository/data paths, malformed tokens and missing dates refuse (3.716844ms)
✔ expiry refuses use and env references never become client data (1.082525ms)
✔ S1 parsed service refs work, service mismatch refuses, Gitea rotation due is a warning state (1.931891ms)
✔ opaque tokens shorter than 16 characters refuse before use (0.446136ms)
✔ endLaunch writes session.ended, releases the run claim and kills its capabilities (247.796173ms)
✔ endLaunch refuses an unknown run, a second end and a rebind of the ended run (130.726344ms)
✖ a restarted broker refuses to rebind an ended run; a refused rebind leaves the run unbound (157.253366ms)
✔ endLaunch leaves a claim another run took alone (289.241075ms)
✔ refuse records action.refused against the caller with the code only (251.654803ms)
✔ launches off refuses role.launch with launch-revoked until launches on (250.720391ms)
✔ broker process: launch ops authorize role.launch, record refusals and end runs (262.370088ms)
✔ human proof binds CLI entry, process start and nonce; agents and incomplete ancestry refuse (3.987867ms)
✔ process reader gets own kernel identity without exposing environment values (0.776973ms)
✔ EACCES ancestor environments skip only markers; commands and registered launches still refuse (1.162267ms)
✔ real pid 1 remains inspectable when its environment is protected (0.450215ms)
✔ within-role sends cite an open gated launch decision without spending it or naming it in grants (318.86415ms)
✔ missing and foreign-business citations refuse and roll back message and grant (196.815379ms)
✔ cross-role sends still need a matching resolved decision and consume it once (373.987519ms)
✔ broker process binds trusted launches, offers reader capabilities, refuses human mutation, closes cleanly (309.978292ms)
✔ startup token refusal returns safe code without value or partial listening broker (40.260658ms)
✔ loaded fixture token is absent from socket replies and SQLite, including refusal evidence (212.318126ms)
✔ killed broker leaves an explicit stale lock; another process cannot silently reclaim it (284.283419ms)
✔ trusted host registers later launches; socket clients never have a registration verb (331.796722ms)
✔ runtime excludes declared project roots even when host supplies no repoRoots (50.863608ms)
✔ a refused launch binding leaves the broker and existing capabilities alive; bad protocol stops it (199.936442ms)
✔ v3b prototype refusals, views and append-only mutations (1474.221154ms)
✔ gated approval authorizes once, survives store reopen, and fresh approval works (370.765558ms)
✔ another run cannot consume an approval; a failed check leaves it usable (254.968901ms)
✔ two scheduled callers have exactly one grant and one consumed refusal (269.788235ms)
✔ failed commit rolls consumption back; cross-role consumes and within-role stays reusable (497.173983ms)
✔ class drift gated to cross-role refuses before consumption (223.510713ms)
✔ class drift cross-role to gated refuses before consumption (231.466212ms)
✔ class drift gated to within-role refuses before consumption (212.371643ms)
✔ class drift cross-role to within-role refuses before consumption (210.13879ms)
✔ class drift within-role to gated refuses before consumption (186.617229ms)
✔ class drift within-role to cross-role refuses before consumption (166.777678ms)
✔ message.send consumes approval and prevents a later send or authorize (201.323203ms)
✔ role.revoke consumes approval and prevents a later revoke or authorize (232.747756ms)
✔ creates private WAL store and excludes a second writer until explicit close (245.691122ms)
✔ rollback is atomic and schema metadata is checked against trusted DDL, not just itself (172.009427ms)
✔ existing empty database and symlink runtime directory refuse, never initialize over damage (256.716305ms)
✔ crash during a transaction recovers no partial event after explicit fixture-only lock removal (351.248691ms)
✔ writer refuses mixed at/read_at forms atomically, even through trusted SQL helpers (149.511218ms)
✔ async transactions refuse before invoking their function (119.740418ms)
✔ recordTask keeps sync reads and a role write apart (277.097949ms)
✔ read_at must be one canonical UTC format, so the projection compares strings safely (114.028215ms)
✔ a bad entry refuses the whole record (116.960536ms)
✔ taskView reads the projection for one business (210.868484ms)
✔ requestTask hands only a holder and a task verb to the handler, and records refusals (438.207826ms)
✔ the server sends task verbs to the adapter with its own timeout; other verbs stay synchronous (438.027227ms)
✔ without an adapter the server refuses every task verb (198.146102ms)
✔ the runtime refuses an invalid adapter and closes a valid one (216.186052ms)
✔ the process loads the S3 adapter from plain-data trackers (273.619163ms)
✔ socket capability stamps launch identity; shared views use wire, no SQL client (260.103868ms)
✔ two wire claims serialize; a lost reply never automatically retries (186.295991ms)
✔ malformed, oversized and identity-forging envelopes refuse without echoing input (122.398394ms)
✔ client preserves UTF-8 when a response divides a multibyte character (12.28368ms)
✔ committed mutation followed by dropped reply reports unknown and is never retried (244.901528ms)
ℹ tests 74
ℹ suites 0
ℹ pass 73
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 3166.363958
✖ failing tests:
test at packages/bus/tests/end-launch.test.mjs:73:1
✖ a restarted broker refuses to rebind an ended run; a refused rebind leaves the run unbound (157.253366ms)
AssertionError [ERR_ASSERTION]: Missing expected exception.
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41/wt/packages/bus/tests/end-launch.test.mjs:79:10)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: undefined,
expected: /run-ended/,
operator: 'throws',
diff: 'simple'
}
@@ -0,0 +1,119 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (126.592557ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (130.171901ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (125.162483ms)
✔ decide prints a declining choice as declining (104.33608ms)
✔ an unknown outcome is reported once and never resent (89.318318ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (105.532354ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (105.868605ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (65.576879ms)
✔ every human command refuses inside an agent run before it touches the bus (111.297337ms)
✔ usage errors exit 4; no business and no host is a usage error (98.771393ms)
✔ agents and tasks print through the broker (255.048787ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (1.546141ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (45.672646ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (32.240318ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (29.603951ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (33.350544ms)
✔ a business without tracker.baseUrl gets no trackers entry (26.705721ms)
✔ an unknown business and a broken system config refuse with exit 3 (54.028045ms)
✔ empty views say so (0.997992ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (1.124414ms)
✔ tasks print the tracker fields the snapshot carries (0.15378ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (895.526588ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (212.725435ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (273.362879ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (245.235107ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (198.483689ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (167.789165ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (197.473695ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (124.052455ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (201.202702ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (23.250612ms)
✔ bus stop refuses to signal a live pid that is not a bus host (202.761707ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (213.465669ms)
✔ bus start refuses with exit 3 without a notifier config (86.816406ms)
✔ bus start runs until bus stop; status reports it while it runs (658.567869ms)
✔ bus-service.sh renders the unit and installs it into a given directory (28.128558ms)
✔ bus start --pm: the PM runs under its own PID namespace, registered, with a manifest (1360.589465ms)
✔ the PM launches a coder through its launch tool; the coder answers; refusals name their code (776.641584ms)
✔ a runner that stops at once ends its launch with the runner's reason (199.870832ms)
✖ 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 (711.911393ms)
✖ 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 (3581.287492ms)
✔ a malformed sessions.json refuses the host start with exit 3 and stays as it was (123.48589ms)
✔ zoned uses the IANA zone across DST (17.451622ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (132.26037ms)
✔ two blocking decisions get two DMs with different nonces (118.678545ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.218622ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (129.429198ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (109.642392ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (90.790543ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (101.894504ms)
✔ polled every POLL_MS against a permanent 403, a DM is sent at 0, 30, 60, 90 and 120 min and gives up only then (159.050941ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (147.116141ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (142.57825ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (249.598656ms)
✔ an inbox read failure is logged and the next poll retries (0.635093ms)
✔ no Discord id reaches the journal or the log (112.597633ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (24.68035ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (66.147162ms)
✔ the journal: a whole file that is one torn line truncates to empty (50.965235ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (2.213804ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.663316ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (1.799538ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.308098ms)
✔ 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.665486ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.365494ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.426602ms)
✔ digest content stays within Discord's 2000 characters (0.260771ms)
✔ runLoop never overlaps ticks and stops after the one in flight (110.010563ms)
task.close {} answered: invalid-request; fake saw 18 requests, first GET /info 200, GET /projects/1 200, GET /projects/1/views 200
task.close on a missing task answered: task-not-found; it made GET /tasks/999 404
✔ bootConfig trackers reach the S3 adapter in the real broker child, which goes ready against a fake Vikunja (365.325466ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (42.334679ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2132.640014ms)
✔ busExit and refuseInsideAgent (0.431379ms)
✔ talk sends a REQUEST, prints and reads everything that arrives, and stops at the reply (226.693198ms)
✔ talk --wait 0 only sends; no reply within --wait exits 1 and says where it will show (1151.320994ms)
✔ talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors (103.217521ms)
✔ launches off and on go to the broker and change the business's launch state (173.616665ms)
✔ launches list reads sessions.json and marks a stale entry; stop reports it and refuses a non-runner (134.088458ms)
ℹ tests 77
ℹ suites 0
ℹ pass 75
ℹ fail 2
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 6835.150141
✖ failing tests:
test at packages/cli/tests/launcher.test.mjs:252:3
✖ a host that died hard leaves its sessions to the next host, which kills them and ends their runs so the role can be launched again (711.911393ms)
AssertionError [ERR_ASSERTION]: run r3e4b2104c041 (pm) from an earlier host ended: host-lost
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41/wt/packages/cli/tests/launcher.test.mjs:297:12)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: false,
expected: true,
operator: '==',
diff: 'simple'
}
test at packages/cli/tests/launcher.test.mjs:252:3
✖ a host that died hard leaves its sessions to the next host, which finds one already exited (23) and ends their runs so the role can be launched again (3581.287492ms)
AssertionError [ERR_ASSERTION]: run r86d98d19d98a (pm) from an earlier host ended: host-lost
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41/wt/packages/cli/tests/launcher.test.mjs:297:12)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: false,
expected: true,
operator: '==',
diff: 'simple'
}
@@ -0,0 +1,132 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (134.382091ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (127.538676ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (116.860604ms)
✔ decide prints a declining choice as declining (125.22075ms)
✔ an unknown outcome is reported once and never resent (103.496002ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (125.776748ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (117.149997ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (115.624806ms)
✔ every human command refuses inside an agent run before it touches the bus (223.103098ms)
✔ usage errors exit 4; no business and no host is a usage error (150.021496ms)
✔ agents and tasks print through the broker (124.340719ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (1.57056ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (41.155739ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (35.962477ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (34.977988ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (45.484448ms)
✔ a business without tracker.baseUrl gets no trackers entry (35.743739ms)
✔ an unknown business and a broken system config refuse with exit 3 (70.823037ms)
✔ empty views say so (1.141349ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (1.432144ms)
✔ tasks print the tracker fields the snapshot carries (0.228737ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (911.580879ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (276.115157ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (200.288603ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (202.357798ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (207.000651ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (112.164695ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (185.066354ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (134.881776ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (189.591959ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (22.719956ms)
✔ bus stop refuses to signal a live pid that is not a bus host (203.112654ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (213.10587ms)
✔ bus start refuses with exit 3 without a notifier config (86.928226ms)
✔ bus start runs until bus stop; status reports it while it runs (656.036208ms)
✔ bus-service.sh renders the unit and installs it into a given directory (26.867061ms)
✖ bus start --pm: the PM runs under its own PID namespace, registered, with a manifest (282.417559ms)
✖ the PM launches a coder through its launch tool; the coder answers; refusals name their code (575.753358ms)
✔ a runner that stops at once ends its launch with the runner's reason (272.021572ms)
✔ 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 (770.912983ms)
✔ 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 (3659.382952ms)
✔ a malformed sessions.json refuses the host start with exit 3 and stays as it was (135.231912ms)
✔ zoned uses the IANA zone across DST (17.476571ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (132.004501ms)
✔ two blocking decisions get two DMs with different nonces (122.871191ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.292362ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (105.352783ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (109.51429ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (121.525089ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (118.943528ms)
✔ 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 (173.203968ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (138.607623ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (259.309314ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (157.557174ms)
✔ an inbox read failure is logged and the next poll retries (0.717398ms)
✔ no Discord id reaches the journal or the log (113.42138ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (20.122071ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (38.603439ms)
✔ the journal: a whole file that is one torn line truncates to empty (25.084268ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (2.048053ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.595689ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (1.751411ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.383996ms)
✔ 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.480832ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.369812ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.429485ms)
✔ digest content stays within Discord's 2000 characters (0.264703ms)
✔ runLoop never overlaps ticks and stops after the one in flight (110.764422ms)
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 (394.904188ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (41.671676ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2160.080078ms)
✔ busExit and refuseInsideAgent (0.4448ms)
✔ talk sends a REQUEST, prints and reads everything that arrives, and stops at the reply (219.683221ms)
✔ talk --wait 0 only sends; no reply within --wait exits 1 and says where it will show (1121.161112ms)
✔ talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors (108.204627ms)
✔ launches off and on go to the broker and change the business's launch state (117.554155ms)
✔ launches list reads sessions.json and marks a stale entry; stop reports it and refuses a non-runner (112.64814ms)
ℹ tests 77
ℹ suites 0
ℹ pass 75
ℹ fail 2
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 5778.01493
✖ failing tests:
test at packages/cli/tests/launcher.test.mjs:105:1
✖ bus start --pm: the PM runs under its own PID namespace, registered, with a manifest (282.417559ms)
AssertionError [ERR_ASSERTION]: The input did not match the regular expression /PM launch refused: instance-running/. Input:
'CliError: PM launch refused: capacity-full (opus sessions: 1 of 1)'
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41/wt/packages/cli/tests/launcher.test.mjs:137:3)
at async Test.run (node:internal/test_runner/test:1409:7)
at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: CliError: PM launch refused: capacity-full (opus sessions: 1 of 1)
at file:///home/jwoltje/darkwing-scratch/r41/wt/packages/cli/src/launcher.mjs:295:15
at process.processTicksAndRejections (node:internal/process/task_queues:104:5),
expected: /PM launch refused: instance-running/,
operator: 'rejects',
diff: 'simple'
}
test at packages/cli/tests/launcher.test.mjs:175:1
✖ the PM launches a coder through its launch tool; the coder answers; refusals name their code (575.753358ms)
AssertionError [ERR_ASSERTION]: coder
+ actual - expected
{
+ error: 'capacity-full',
- error: 'instance-running',
ok: false
}
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41/wt/packages/cli/tests/launcher.test.mjs:209:12)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: { ok: false, error: 'capacity-full' },
expected: { ok: false, error: 'instance-running' },
operator: 'deepStrictEqual',
diff: 'simple'
}
@@ -0,0 +1,107 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (138.757292ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (181.365968ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (135.995008ms)
✔ decide prints a declining choice as declining (131.321963ms)
✔ an unknown outcome is reported once and never resent (121.033541ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (143.568257ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (133.382442ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (103.046809ms)
✔ every human command refuses inside an agent run before it touches the bus (101.665231ms)
✔ usage errors exit 4; no business and no host is a usage error (137.587049ms)
✔ agents and tasks print through the broker (166.273722ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (36.002104ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (46.154581ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (45.097471ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (45.020635ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (41.702747ms)
✔ a business without tracker.baseUrl gets no trackers entry (33.239399ms)
✔ an unknown business and a broken system config refuse with exit 3 (72.015913ms)
✔ empty views say so (1.166082ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (1.362349ms)
✔ tasks print the tracker fields the snapshot carries (0.2314ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (952.770524ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (235.027778ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (216.642435ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (263.246996ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (215.728182ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (226.22554ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (166.772705ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (166.509966ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (257.036469ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (22.899723ms)
✔ bus stop refuses to signal a live pid that is not a bus host (203.205762ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (215.565389ms)
✔ bus start refuses with exit 3 without a notifier config (87.058049ms)
✔ bus start runs until bus stop; status reports it while it runs (657.293355ms)
✔ bus-service.sh renders the unit and installs it into a given directory (25.693957ms)
✔ bus start --pm: the PM runs under its own PID namespace, registered, with a manifest (1333.711945ms)
✖ the PM launches a coder through its launch tool; the coder answers; refusals name their code (861.939206ms)
✔ a runner that stops at once ends its launch with the runner's reason (231.210908ms)
✔ 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 (770.734104ms)
✔ 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 (3630.367619ms)
✔ a malformed sessions.json refuses the host start with exit 3 and stays as it was (122.084335ms)
✔ zoned uses the IANA zone across DST (18.085725ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (143.101836ms)
✔ two blocking decisions get two DMs with different nonces (178.859924ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.256723ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (138.729402ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (128.495778ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (123.094867ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (138.652432ms)
✔ polled every POLL_MS against a permanent 403, a DM is sent at 0, 30, 60, 90 and 120 min and gives up only then (183.760709ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (148.067127ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (156.1858ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (164.926157ms)
✔ an inbox read failure is logged and the next poll retries (0.636575ms)
✔ no Discord id reaches the journal or the log (194.997457ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (25.301543ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (84.875465ms)
✔ the journal: a whole file that is one torn line truncates to empty (36.937581ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (3.6726ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.589677ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (1.820138ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.315697ms)
✔ 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.459806ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.363338ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.442958ms)
✔ digest content stays within Discord's 2000 characters (0.260548ms)
✔ runLoop never overlaps ticks and stops after the one in flight (110.734543ms)
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 (442.592712ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (36.742582ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2185.380382ms)
✔ busExit and refuseInsideAgent (0.412571ms)
✔ talk sends a REQUEST, prints and reads everything that arrives, and stops at the reply (260.22186ms)
✔ talk --wait 0 only sends; no reply within --wait exits 1 and says where it will show (1168.161409ms)
✔ talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors (158.427277ms)
✔ launches off and on go to the broker and change the business's launch state (175.506781ms)
✔ launches list reads sessions.json and marks a stale entry; stop reports it and refuses a non-runner (137.45557ms)
ℹ tests 77
ℹ suites 0
ℹ pass 76
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 7039.056371
✖ failing tests:
test at packages/cli/tests/launcher.test.mjs:175:1
✖ the PM launches a coder through its launch tool; the coder answers; refusals name their code (861.939206ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
true !== false
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41/wt/packages/cli/tests/launcher.test.mjs:217:10)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: true,
expected: false,
operator: 'strictEqual',
diff: 'simple'
}
@@ -0,0 +1,78 @@
✔ sessionModel: agent vars win, then the system's execution settings (11.121928ms)
✔ a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory (4.728183ms)
✖ a claude-code bundle adds the wrapped gate hook and the MCP config (4.014424ms)
✔ a bundle is written once: an existing file refuses (1.996239ms)
✔ a path with a single quote can't go into the hook command (1.855058ms)
✔ allow exits 0, a deny exits 2 with the reason on stderr (130.812334ms)
✔ a missing or wrong policy, or a bad event, exits 2 (79.031735ms)
✔ the bundle's wrapped command: a missing gate or node still blocks (1094.514702ms)
✔ claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist (764.062618ms)
✔ claude: the hook alone blocks a path outside the workspace (589.82994ms)
✔ claude: a second turn resumes the first turn's session (736.915159ms)
✔ claude: a missing hook or MCP file refuses before claude starts (8.629216ms)
✔ pi: policy tools and typed tools pass, anything else is blocked (2.876463ms)
✔ claude: builtins map from pi names, typed tools need the mcp prefix (0.752765ms)
✔ file tool paths must resolve inside the workspace (0.962332ms)
✔ pi's own path normalisation can't be used to step out (0.907476ms)
✔ a symlink inside the workspace that points out is outside (0.808361ms)
✔ claude path fields per tool (0.860736ms)
✔ glob patterns stay inside the workspace (0.904636ms)
✔ a path that can't be checked is blocked (0.617929ms)
✔ initialize, ping and tools/list (42.391174ms)
✔ tools/call goes through the tool socket; a refusal is an isError result (37.12388ms)
✔ unknown tools and methods are JSON-RPC errors and never reach the socket (31.935515ms)
✔ a missing argument is a usage error (33.805981ms)
✔ pi: typed tools reach the socket, the gate blocks, agent_end writes the marker (359.224518ms)
✔ pi: a missing extension refuses before any model call (8.186443ms)
✔ pi: an extension without its configuration fails pi's start (270.982753ms)
✔ founderCheck: founder variables, then a needed service without a usable token (1.711642ms)
✔ turnRequest names the sender, class, reply and decision (0.259584ms)
✔ a message becomes a turn, the answer goes back as a RESULT, SIGTERM releases and exits 0 (269.324752ms)
✔ a SIGTERM before the claim stops the runner with exit 0 and no claim (131.430937ms)
✔ typed tools carry the runner's capability; launch goes to the host's launch socket (464.429598ms)
✔ a RESULT gets no automatic reply; failed turns reply with the reason (1520.134523ms)
✔ SIGTERM during a turn kills the turn's process group and still exits 0 (187.836217ms)
✔ founder credentials stop before the claim (20) (208.81483ms)
✔ a refused claim exits 21; an ended run's capability exits 22 (244.227937ms)
✔ the launch ending under a running session exits 22 (172.416748ms)
✔ a broker that stays unreachable exits 23 after brokerRetries polls (253.75994ms)
✔ a broker that is down at the claim exits 23, not 21 (92.919627ms)
✔ no capability, or a malformed one, on stdin exits 2 (191.524811ms)
✔ the PM gets launch, its task verbs and the reads (7.204459ms)
✔ a coder gets no launch, no resolve_decision, and no task tools without a tracker (2.916872ms)
✔ launch only when the business's launch block names the instance as launcher (2.364338ms)
✔ an action outside the instance's authority has no tool (2.35004ms)
✔ callTool: one JSON line out, the result back, a refusal rejects (8.053898ms)
ℹ tests 45
ℹ suites 0
ℹ pass 44
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 10694.510151
✖ failing tests:
test at packages/harness/tests/bundle.test.mjs:70:1
✖ a claude-code bundle adds the wrapped gate hook and the MCP config (4.014424ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
+ actual - expected
+ "timeout -k 2 10 '/usr/bin/node' '/home/jwoltje/darkwing-scratch/r41/wt/packages/harness/src/claude-gate.mjs' '/home/jwoltje/darkwing-scratch/tmp/mosaic-harness-F6vGQM/bundle/policy.json'"
- "timeout -k 2 10 '/usr/bin/node' '/home/jwoltje/darkwing-scratch/r41/wt/packages/harness/src/claude-gate.mjs' '/home/jwoltje/darkwing-scratch/tmp/mosaic-harness-F6vGQM/bundle/policy.json' || exit 2"
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41/wt/packages/harness/tests/bundle.test.mjs:78:10)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: "timeout -k 2 10 '/usr/bin/node' '/home/jwoltje/darkwing-scratch/r41/wt/packages/harness/src/claude-gate.mjs' '/home/jwoltje/darkwing-scratch/tmp/mosaic-harness-F6vGQM/bundle/policy.json'",
expected: "timeout -k 2 10 '/usr/bin/node' '/home/jwoltje/darkwing-scratch/r41/wt/packages/harness/src/claude-gate.mjs' '/home/jwoltje/darkwing-scratch/tmp/mosaic-harness-F6vGQM/bundle/policy.json' || exit 2",
operator: 'strictEqual',
diff: 'simple'
}
@@ -0,0 +1,87 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (130.720681ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (144.44443ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (123.061895ms)
✔ decide prints a declining choice as declining (244.593115ms)
✔ an unknown outcome is reported once and never resent (208.332046ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (147.08676ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (117.042615ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (119.363758ms)
✔ every human command refuses inside an agent run before it touches the bus (292.507856ms)
✔ usage errors exit 4; no business and no host is a usage error (138.115749ms)
✔ agents and tasks print through the broker (140.189507ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (1.09492ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (49.445915ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (39.563603ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (33.21625ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (30.793644ms)
✔ a business without tracker.baseUrl gets no trackers entry (36.275401ms)
✔ an unknown business and a broken system config refuse with exit 3 (56.275821ms)
✔ empty views say so (1.306748ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (1.509261ms)
✔ tasks print the tracker fields the snapshot carries (0.246762ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (911.121735ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (235.366643ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (369.841177ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (262.305547ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (236.663948ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (143.842312ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (190.154398ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (108.391088ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (206.604045ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (22.186881ms)
✔ bus stop refuses to signal a live pid that is not a bus host (202.80689ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (229.010884ms)
✔ bus start refuses with exit 3 without a notifier config (93.774875ms)
✔ bus start runs until bus stop; status reports it while it runs (668.258443ms)
✔ bus-service.sh renders the unit and installs it into a given directory (26.758168ms)
✔ bus start --pm: the PM runs under its own PID namespace, registered, with a manifest (1323.665538ms)
✔ the PM launches a coder through its launch tool; the coder answers; refusals name their code (848.147276ms)
✔ a runner that stops at once ends its launch with the runner's reason (245.519218ms)
✔ 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 (647.385463ms)
✔ 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 (3619.234805ms)
✔ a malformed sessions.json refuses the host start with exit 3 and stays as it was (104.246508ms)
✔ zoned uses the IANA zone across DST (24.971532ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (124.019745ms)
✔ two blocking decisions get two DMs with different nonces (127.853015ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.200872ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (116.476651ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (183.577337ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (261.864425ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (161.040869ms)
✔ 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 (179.477962ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (244.681013ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (245.72771ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (140.118308ms)
✔ an inbox read failure is logged and the next poll retries (0.686691ms)
✔ no Discord id reaches the journal or the log (138.080337ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (32.66773ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (49.31037ms)
✔ the journal: a whole file that is one torn line truncates to empty (32.685563ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (2.076912ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.597882ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (2.010512ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.388819ms)
✔ 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.471387ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.381919ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.426331ms)
✔ digest content stays within Discord's 2000 characters (0.247464ms)
✔ runLoop never overlaps ticks and stops after the one in flight (111.169144ms)
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 (395.541301ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (38.675564ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2159.439268ms)
✔ busExit and refuseInsideAgent (0.44803ms)
✔ talk sends a REQUEST, prints and reads everything that arrives, and stops at the reply (219.100073ms)
✔ talk --wait 0 only sends; no reply within --wait exits 1 and says where it will show (1125.936788ms)
✔ talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors (247.706439ms)
✔ launches off and on go to the broker and change the business's launch state (174.665863ms)
✔ launches list reads sessions.json and marks a stale entry; stop reports it and refuses a non-runner (131.345212ms)
ℹ tests 77
ℹ suites 0
ℹ pass 77
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 6883.778277
@@ -0,0 +1,65 @@
✔ sessionModel: agent vars win, then the system's execution settings (14.067777ms)
✔ a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory (4.83597ms)
✔ a claude-code bundle adds the wrapped gate hook and the MCP config (3.203511ms)
✔ a bundle is written once: an existing file refuses (2.324752ms)
✔ a path with a single quote can't go into the hook command (2.432801ms)
✔ allow exits 0, a deny exits 2 with the reason on stderr (125.69164ms)
✔ a missing or wrong policy, or a bad event, exits 2 (87.248095ms)
✔ the bundle's wrapped command: a missing gate or node still blocks (1096.130839ms)
✔ claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist (822.887941ms)
✔ claude: the hook alone blocks a path outside the workspace (619.788359ms)
✔ claude: a second turn resumes the first turn's session (720.544276ms)
✔ claude: a missing hook or MCP file refuses before claude starts (7.807494ms)
✔ pi: policy tools and typed tools pass, anything else is blocked (2.760889ms)
✔ claude: builtins map from pi names, typed tools need the mcp prefix (0.858294ms)
✔ file tool paths must resolve inside the workspace (1.329669ms)
✔ pi's own path normalisation can't be used to step out (1.208492ms)
✔ a symlink inside the workspace that points out is outside (1.109705ms)
✔ claude path fields per tool (0.783786ms)
✔ glob patterns stay inside the workspace (1.009446ms)
✔ a path that can't be checked is blocked (0.632633ms)
✔ initialize, ping and tools/list (44.810011ms)
✔ tools/call goes through the tool socket; a refusal is an isError result (34.700842ms)
✔ unknown tools and methods are JSON-RPC errors and never reach the socket (35.585978ms)
✔ a missing argument is a usage error (39.424495ms)
✔ pi: typed tools reach the socket, the gate blocks, agent_end writes the marker (377.597538ms)
✔ pi: a missing extension refuses before any model call (8.384513ms)
✔ pi: an extension without its configuration fails pi's start (294.902223ms)
✖ founderCheck: founder variables, then a needed service without a usable token (2.011371ms)
✔ turnRequest names the sender, class, reply and decision (0.351869ms)
✔ a message becomes a turn, the answer goes back as a RESULT, SIGTERM releases and exits 0 (239.104388ms)
✔ a SIGTERM before the claim stops the runner with exit 0 and no claim (134.155238ms)
✔ typed tools carry the runner's capability; launch goes to the host's launch socket (534.9943ms)
✔ a RESULT gets no automatic reply; failed turns reply with the reason (1584.330846ms)
✔ SIGTERM during a turn kills the turn's process group and still exits 0 (150.826229ms)
✔ the PM gets launch, its task verbs and the reads (7.17446ms)
✔ a coder gets no launch, no resolve_decision, and no task tools without a tracker (2.29576ms)
✔ launch only when the business's launch block names the instance as launcher (1.646169ms)
✔ an action outside the instance's authority has no tool (1.594448ms)
✔ callTool: one JSON line out, the result back, a refusal rejects (8.633147ms)
ℹ tests 39
ℹ suites 0
ℹ pass 38
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 263663.299994
✖ failing tests:
test at packages/harness/tests/runner.test.mjs:34:1
✖ founderCheck: founder variables, then a needed service without a usable token (2.011371ms)
AssertionError [ERR_ASSERTION]: The "string" argument must be of type string. Received type object (null)
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41/wt/packages/harness/tests/runner.test.mjs:38:10)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.start (node:internal/test_runner/test:1262:17)
at startSubtestAfterBootstrap (node:internal/test_runner/harness:387:17) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: null,
expected: /GITEA_TOKEN, SSH_AUTH_SOCK/,
operator: 'match',
diff: 'simple'
}
@@ -0,0 +1,87 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (125.138875ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (132.971762ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (117.374088ms)
✔ decide prints a declining choice as declining (108.459149ms)
✔ an unknown outcome is reported once and never resent (70.01985ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (94.875686ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (94.31331ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (50.391423ms)
✔ every human command refuses inside an agent run before it touches the bus (72.351222ms)
✔ usage errors exit 4; no business and no host is a usage error (83.26574ms)
✔ agents and tasks print through the broker (91.938302ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (1.1808ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (45.455893ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (34.445063ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (33.484263ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (31.217508ms)
✔ a business without tracker.baseUrl gets no trackers entry (32.020055ms)
✔ an unknown business and a broken system config refuse with exit 3 (59.190349ms)
✔ empty views say so (1.181996ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (1.453134ms)
✔ tasks print the tracker fields the snapshot carries (0.228609ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (911.109415ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (229.643058ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (307.116557ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (203.545959ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (165.693521ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (138.295568ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (180.904134ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (136.928153ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (167.809839ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (26.616224ms)
✔ bus stop refuses to signal a live pid that is not a bus host (202.697476ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (216.070056ms)
✔ bus start refuses with exit 3 without a notifier config (91.812758ms)
✔ bus start runs until bus stop; status reports it while it runs (661.600894ms)
✔ bus-service.sh renders the unit and installs it into a given directory (25.834051ms)
✔ bus start --pm: the PM runs under its own PID namespace, registered, with a manifest (1429.993014ms)
✔ the PM launches a coder through its launch tool; the coder answers; refusals name their code (639.118083ms)
✔ a runner that stops at once ends its launch with the runner's reason (266.413468ms)
✔ 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 (635.92366ms)
✔ 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 (3671.785556ms)
✔ a malformed sessions.json refuses the host start with exit 3 and stays as it was (133.951668ms)
✔ zoned uses the IANA zone across DST (21.770408ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (124.613973ms)
✔ two blocking decisions get two DMs with different nonces (127.527978ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.311431ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (112.494043ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (111.51636ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (80.954353ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (81.322222ms)
✔ 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 (153.159571ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (110.188277ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (115.163887ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (75.162747ms)
✔ an inbox read failure is logged and the next poll retries (0.6174ms)
✔ no Discord id reaches the journal or the log (68.352032ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (16.452539ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (16.506609ms)
✔ the journal: a whole file that is one torn line truncates to empty (10.938296ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (0.635215ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.620336ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (1.874754ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.330028ms)
✔ 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.49249ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.414306ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.448777ms)
✔ digest content stays within Discord's 2000 characters (0.259028ms)
✔ runLoop never overlaps ticks and stops after the one in flight (110.716598ms)
task.close {} answered: invalid-request; fake saw 18 requests, first GET /info 200, GET /projects/1 200, GET /projects/1/views 200
task.close on a missing task answered: task-not-found; it made GET /tasks/999 404
✔ bootConfig trackers reach the S3 adapter in the real broker child, which goes ready against a fake Vikunja (378.128826ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (41.478941ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2151.535751ms)
✔ busExit and refuseInsideAgent (0.435856ms)
✔ talk sends a REQUEST, prints and reads everything that arrives, and stops at the reply (215.850821ms)
✔ talk --wait 0 only sends; no reply within --wait exits 1 and says where it will show (1133.958331ms)
✔ talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors (108.172473ms)
✔ launches off and on go to the broker and change the business's launch state (120.665848ms)
✔ launches list reads sessions.json and marks a stale entry; stop reports it and refuses a non-runner (88.354039ms)
ℹ tests 77
ℹ suites 0
ℹ pass 77
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 6865.453741
@@ -0,0 +1,87 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (137.376158ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (121.751693ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (116.798959ms)
✔ decide prints a declining choice as declining (146.977151ms)
✔ an unknown outcome is reported once and never resent (119.334436ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (115.298648ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (125.351114ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (94.326095ms)
✔ every human command refuses inside an agent run before it touches the bus (119.08327ms)
✔ usage errors exit 4; no business and no host is a usage error (101.827088ms)
✔ agents and tasks print through the broker (95.910812ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (1.360098ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (48.404329ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (33.696218ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (33.178759ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (33.332481ms)
✔ a business without tracker.baseUrl gets no trackers entry (29.07756ms)
✔ an unknown business and a broken system config refuse with exit 3 (59.228455ms)
✔ empty views say so (0.801616ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (0.937632ms)
✔ tasks print the tracker fields the snapshot carries (0.142201ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (908.509723ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (259.291592ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (155.72522ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (216.453668ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (215.732438ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (133.198746ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (165.465891ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (98.613063ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (240.0965ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (24.289345ms)
✔ bus stop refuses to signal a live pid that is not a bus host (202.292309ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (225.9222ms)
✔ bus start refuses with exit 3 without a notifier config (88.973332ms)
✔ bus start runs until bus stop; status reports it while it runs (751.784886ms)
✔ bus-service.sh renders the unit and installs it into a given directory (49.720154ms)
✔ bus start --pm: the PM runs under its own PID namespace, registered, with a manifest (1296.224536ms)
✔ the PM launches a coder through its launch tool; the coder answers; refusals name their code (656.132557ms)
✔ a runner that stops at once ends its launch with the runner's reason (217.485134ms)
✔ 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 (728.931825ms)
✔ 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 (3650.060252ms)
✔ a malformed sessions.json refuses the host start with exit 3 and stays as it was (146.530135ms)
✔ zoned uses the IANA zone across DST (18.184875ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (145.69019ms)
✔ two blocking decisions get two DMs with different nonces (118.99032ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.176236ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (118.676676ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (133.002797ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (118.116242ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (110.099936ms)
✔ 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 (184.568054ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (144.754853ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (138.783123ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (89.036985ms)
✔ an inbox read failure is logged and the next poll retries (0.62497ms)
✔ no Discord id reaches the journal or the log (111.308913ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (31.479031ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (46.260958ms)
✔ the journal: a whole file that is one torn line truncates to empty (25.374439ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (1.988813ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.581731ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (1.786572ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.340661ms)
✔ 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.466709ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.374083ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.410585ms)
✔ digest content stays within Discord's 2000 characters (0.25301ms)
✔ runLoop never overlaps ticks and stops after the one in flight (125.526296ms)
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 (399.433333ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (46.256649ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2150.449936ms)
✔ busExit and refuseInsideAgent (0.391979ms)
✔ talk sends a REQUEST, prints and reads everything that arrives, and stops at the reply (236.876993ms)
✔ talk --wait 0 only sends; no reply within --wait exits 1 and says where it will show (1149.345411ms)
✔ talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors (98.170237ms)
✔ launches off and on go to the broker and change the business's launch state (98.79469ms)
✔ launches list reads sessions.json and marks a stale entry; stop reports it and refuses a non-runner (103.804047ms)
ℹ tests 77
ℹ suites 0
ℹ pass 77
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 6770.811975
@@ -0,0 +1,53 @@
✔ sessionModel: agent vars win, then the system's execution settings (14.050443ms)
✔ a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory (6.120541ms)
✔ a claude-code bundle adds the wrapped gate hook and the MCP config (4.081354ms)
✔ a bundle is written once: an existing file refuses (2.996025ms)
✔ a path with a single quote can't go into the hook command (2.951571ms)
✔ allow exits 0, a deny exits 2 with the reason on stderr (150.595498ms)
✔ a missing or wrong policy, or a bad event, exits 2 (124.742454ms)
✔ the bundle's wrapped command: a missing gate or node still blocks (1126.482389ms)
✔ claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist (909.741969ms)
✔ claude: the hook alone blocks a path outside the workspace (516.900249ms)
✔ claude: a second turn resumes the first turn's session (763.872943ms)
✔ claude: a missing hook or MCP file refuses before claude starts (8.211736ms)
✔ pi: policy tools and typed tools pass, anything else is blocked (3.953963ms)
✔ claude: builtins map from pi names, typed tools need the mcp prefix (0.997202ms)
✔ file tool paths must resolve inside the workspace (1.423588ms)
✔ pi's own path normalisation can't be used to step out (1.401389ms)
✔ a symlink inside the workspace that points out is outside (1.139946ms)
✔ claude path fields per tool (0.791831ms)
✔ glob patterns stay inside the workspace (0.945422ms)
✔ a path that can't be checked is blocked (0.646247ms)
✔ initialize, ping and tools/list (51.210531ms)
✔ tools/call goes through the tool socket; a refusal is an isError result (41.206446ms)
✔ unknown tools and methods are JSON-RPC errors and never reach the socket (38.168479ms)
✔ a missing argument is a usage error (39.579956ms)
✔ pi: typed tools reach the socket, the gate blocks, agent_end writes the marker (496.281275ms)
✔ pi: a missing extension refuses before any model call (9.103805ms)
✔ pi: an extension without its configuration fails pi's start (301.173776ms)
✔ founderCheck: founder variables, then a needed service without a usable token (1.727546ms)
✔ turnRequest names the sender, class, reply and decision (0.275096ms)
✔ a message becomes a turn, the answer goes back as a RESULT, SIGTERM releases and exits 0 (264.146564ms)
✔ a SIGTERM before the claim stops the runner with exit 0 and no claim (143.576835ms)
✔ typed tools carry the runner's capability; launch goes to the host's launch socket (536.40783ms)
✔ a RESULT gets no automatic reply; failed turns reply with the reason (1382.766716ms)
✔ SIGTERM during a turn kills the turn's process group and still exits 0 (214.519665ms)
✔ founder credentials stop before the claim (20) (304.717878ms)
✔ a refused claim exits 21; an ended run's capability exits 22 (320.859843ms)
✔ the launch ending under a running session exits 22 (207.759761ms)
✔ a broker that stays unreachable exits 23 after brokerRetries polls (302.795855ms)
✔ a broker that is down at the claim exits 23, not 21 (148.704368ms)
✔ no capability, or a malformed one, on stdin exits 2 (210.336697ms)
✔ the PM gets launch, its task verbs and the reads (9.712494ms)
✔ a coder gets no launch, no resolve_decision, and no task tools without a tracker (3.23337ms)
✔ launch only when the business's launch block names the instance as launcher (2.566048ms)
✔ an action outside the instance's authority has no tool (3.156249ms)
✔ callTool: one JSON line out, the result back, a refusal rejects (9.65517ms)
ℹ tests 45
ℹ suites 0
ℹ pass 45
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 10678.399832
@@ -0,0 +1,87 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (132.317557ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (134.090645ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (132.53407ms)
✔ decide prints a declining choice as declining (127.229793ms)
✔ an unknown outcome is reported once and never resent (97.531419ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (98.822586ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (113.290637ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (87.563392ms)
✔ every human command refuses inside an agent run before it touches the bus (89.267983ms)
✔ usage errors exit 4; no business and no host is a usage error (90.872874ms)
✔ agents and tasks print through the broker (99.389871ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (1.229007ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (65.988943ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (40.683718ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (45.804031ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (35.221951ms)
✔ a business without tracker.baseUrl gets no trackers entry (38.611374ms)
✔ an unknown business and a broken system config refuse with exit 3 (64.054106ms)
✔ empty views say so (1.179352ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (1.468164ms)
✔ tasks print the tracker fields the snapshot carries (0.245647ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (956.305599ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (235.464033ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (175.440963ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (221.774919ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (183.250261ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (146.195016ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (195.557326ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (228.386699ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (193.898946ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (28.035345ms)
✔ bus stop refuses to signal a live pid that is not a bus host (202.365135ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (215.714196ms)
✔ bus start refuses with exit 3 without a notifier config (97.320284ms)
✔ bus start runs until bus stop; status reports it while it runs (733.744653ms)
✔ bus-service.sh renders the unit and installs it into a given directory (30.592503ms)
✔ bus start --pm: the PM runs under its own PID namespace, registered, with a manifest (1316.305561ms)
✔ the PM launches a coder through its launch tool; the coder answers; refusals name their code (735.280709ms)
✔ a runner that stops at once ends its launch with the runner's reason (282.231928ms)
✔ 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 (707.643793ms)
✔ 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 (3693.537166ms)
✔ a malformed sessions.json refuses the host start with exit 3 and stays as it was (139.911386ms)
✔ zoned uses the IANA zone across DST (29.966188ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (134.629552ms)
✔ two blocking decisions get two DMs with different nonces (130.426366ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.212076ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (143.19933ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (118.110025ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (95.961197ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (107.204382ms)
✔ 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 (178.413565ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (126.22777ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (124.406448ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (92.254829ms)
✔ an inbox read failure is logged and the next poll retries (0.653785ms)
✔ no Discord id reaches the journal or the log (111.290157ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (27.715695ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (93.436044ms)
✔ the journal: a whole file that is one torn line truncates to empty (35.121818ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (2.199765ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.630155ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (1.853005ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.494157ms)
✔ 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.50078ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.37415ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.425391ms)
✔ digest content stays within Discord's 2000 characters (0.250758ms)
✔ runLoop never overlaps ticks and stops after the one in flight (110.832081ms)
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 (419.495349ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (50.573663ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2173.090633ms)
✔ busExit and refuseInsideAgent (0.401847ms)
✔ talk sends a REQUEST, prints and reads everything that arrives, and stops at the reply (224.32061ms)
✔ talk --wait 0 only sends; no reply within --wait exits 1 and says where it will show (1170.213512ms)
✔ talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors (116.532473ms)
✔ launches off and on go to the broker and change the business's launch state (97.462875ms)
✔ launches list reads sessions.json and marks a stale entry; stop reports it and refuses a non-runner (83.439542ms)
ℹ tests 77
ℹ suites 0
ℹ pass 77
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 6998.23078
@@ -0,0 +1,77 @@
✔ sessionModel: agent vars win, then the system's execution settings (11.615827ms)
✔ a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory (4.608262ms)
✔ a claude-code bundle adds the wrapped gate hook and the MCP config (3.093679ms)
✔ a bundle is written once: an existing file refuses (2.743247ms)
✔ a path with a single quote can't go into the hook command (1.960427ms)
✔ allow exits 0, a deny exits 2 with the reason on stderr (129.070397ms)
✔ a missing or wrong policy, or a bad event, exits 2 (95.751067ms)
✔ the bundle's wrapped command: a missing gate or node still blocks (1098.628901ms)
✔ claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist (833.557723ms)
✔ claude: the hook alone blocks a path outside the workspace (501.161697ms)
✔ claude: a second turn resumes the first turn's session (799.047884ms)
✔ claude: a missing hook or MCP file refuses before claude starts (7.620084ms)
✔ pi: policy tools and typed tools pass, anything else is blocked (3.560331ms)
✔ claude: builtins map from pi names, typed tools need the mcp prefix (1.195475ms)
✔ file tool paths must resolve inside the workspace (1.373421ms)
✔ pi's own path normalisation can't be used to step out (1.395868ms)
✔ a symlink inside the workspace that points out is outside (1.15816ms)
✔ claude path fields per tool (1.025541ms)
✖ glob patterns stay inside the workspace (2.186725ms)
✔ a path that can't be checked is blocked (0.852835ms)
✔ initialize, ping and tools/list (48.515972ms)
✔ tools/call goes through the tool socket; a refusal is an isError result (31.715873ms)
✔ unknown tools and methods are JSON-RPC errors and never reach the socket (30.592543ms)
✔ a missing argument is a usage error (32.791709ms)
✔ pi: typed tools reach the socket, the gate blocks, agent_end writes the marker (403.4094ms)
✔ pi: a missing extension refuses before any model call (8.601035ms)
✔ pi: an extension without its configuration fails pi's start (303.980444ms)
✔ founderCheck: founder variables, then a needed service without a usable token (1.638328ms)
✔ turnRequest names the sender, class, reply and decision (0.265375ms)
✔ a message becomes a turn, the answer goes back as a RESULT, SIGTERM releases and exits 0 (353.187333ms)
✔ a SIGTERM before the claim stops the runner with exit 0 and no claim (165.33647ms)
✔ typed tools carry the runner's capability; launch goes to the host's launch socket (717.750899ms)
✔ a RESULT gets no automatic reply; failed turns reply with the reason (1432.11811ms)
✔ SIGTERM during a turn kills the turn's process group and still exits 0 (276.246822ms)
✔ founder credentials stop before the claim (20) (215.437887ms)
✔ a refused claim exits 21; an ended run's capability exits 22 (382.043681ms)
✔ the launch ending under a running session exits 22 (173.218595ms)
✔ a broker that stays unreachable exits 23 after brokerRetries polls (348.026841ms)
✔ a broker that is down at the claim exits 23, not 21 (243.327611ms)
✔ no capability, or a malformed one, on stdin exits 2 (273.537344ms)
✔ the PM gets launch, its task verbs and the reads (9.380584ms)
✔ a coder gets no launch, no resolve_decision, and no task tools without a tracker (2.545292ms)
✔ launch only when the business's launch block names the instance as launcher (1.844344ms)
✔ an action outside the instance's authority has no tool (2.256037ms)
✔ callTool: one JSON line out, the result back, a refusal rejects (7.218466ms)
ℹ tests 45
ℹ suites 0
ℹ pass 44
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 11069.564681
✖ failing tests:
test at packages/harness/tests/gate.test.mjs:92:1
✖ glob patterns stay inside the workspace (2.186725ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
true !== false
at blocked (file:///home/jwoltje/darkwing-scratch/r41/wt/packages/harness/tests/gate.test.mjs:19:10)
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41/wt/packages/harness/tests/gate.test.mjs:98:5)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: true,
expected: false,
operator: 'strictEqual',
diff: 'simple'
}
@@ -0,0 +1,102 @@
✔ launch identity is stamped, payload identity is refused and stale holder cannot send (163.89862ms)
✔ decision classes route from policy; gated resolution is human-only, choice and target must match (275.331186ms)
✔ claim exclusion, holder release, gated revoke and rerouting to a new holder are atomic (282.996644ms)
✔ launch events require a human CLI capability; generic emit cannot forge authority events (195.063187ms)
✔ within-role decisions close atomically and invalid options or blocking omissions refuse (208.388066ms)
✔ observer capabilities read human inbox but cannot mutate or forge launch identity (157.831971ms)
✔ task action subjects and linked decision trail are complete and ordered (156.524887ms)
✔ launch binding is durable and reconnecting requires the identical trusted record (97.415614ms)
✔ business isolation includes inherited object names and cross-business message references (159.041055ms)
✔ authority never transfers between action, run, target, unresolved or replaced role holder (227.530616ms)
✔ task projection uses schema current view, skipping earlier and equal-start polls (94.890129ms)
✔ revocation permanently bars the old run from reclaiming first, including after broker restart (180.592353ms)
✔ empty message references refuse before storage; refusal-evidence failure stays a typed error (106.579275ms)
✔ both arbiters require human resolution when their cross-role route is themselves (189.732423ms)
✔ S1 adapter takes resolved limits and refs, rejects mismatched instance, never mutates input (1.899132ms)
✔ only validated broker references load; returned data and exceptions cannot expose a known token (5.641078ms)
✔ bad file modes, symlinks, repository/data paths, malformed tokens and missing dates refuse (3.241221ms)
✔ expiry refuses use and env references never become client data (0.737712ms)
✔ S1 parsed service refs work, service mismatch refuses, Gitea rotation due is a warning state (1.517077ms)
✔ opaque tokens shorter than 16 characters refuse before use (0.297143ms)
✔ endLaunch writes session.ended, releases the run claim and kills its capabilities (122.779602ms)
✔ endLaunch refuses an unknown run, a second end and a rebind of the ended run (127.653631ms)
✔ a restarted broker refuses to rebind an ended run; a refused rebind leaves the run unbound (155.030205ms)
✔ endLaunch leaves a claim another run took alone (193.274277ms)
✔ refuse records action.refused against the caller with the code only (145.282316ms)
✖ launches off refuses role.launch with launch-revoked until launches on (183.043385ms)
✔ broker process: launch ops authorize role.launch, record refusals and end runs (266.177254ms)
✔ human proof binds CLI entry, process start and nonce; agents and incomplete ancestry refuse (3.887495ms)
✔ process reader gets own kernel identity without exposing environment values (0.762853ms)
✔ EACCES ancestor environments skip only markers; commands and registered launches still refuse (1.082879ms)
✔ real pid 1 remains inspectable when its environment is protected (0.430481ms)
✔ within-role sends cite an open gated launch decision without spending it or naming it in grants (196.161745ms)
✔ missing and foreign-business citations refuse and roll back message and grant (183.836168ms)
✔ cross-role sends still need a matching resolved decision and consume it once (274.522528ms)
✔ broker process binds trusted launches, offers reader capabilities, refuses human mutation, closes cleanly (190.003233ms)
✔ startup token refusal returns safe code without value or partial listening broker (40.621827ms)
✔ loaded fixture token is absent from socket replies and SQLite, including refusal evidence (184.028601ms)
✔ killed broker leaves an explicit stale lock; another process cannot silently reclaim it (188.935171ms)
✔ trusted host registers later launches; socket clients never have a registration verb (175.835554ms)
✔ runtime excludes declared project roots even when host supplies no repoRoots (39.471345ms)
✔ a refused launch binding leaves the broker and existing capabilities alive; bad protocol stops it (178.989186ms)
✔ v3b prototype refusals, views and append-only mutations (1120.730725ms)
✔ gated approval authorizes once, survives store reopen, and fresh approval works (251.703532ms)
✔ another run cannot consume an approval; a failed check leaves it usable (238.891458ms)
✔ two scheduled callers have exactly one grant and one consumed refusal (180.906441ms)
✔ failed commit rolls consumption back; cross-role consumes and within-role stays reusable (351.290843ms)
✔ class drift gated to cross-role refuses before consumption (218.014846ms)
✔ class drift cross-role to gated refuses before consumption (203.570384ms)
✔ class drift gated to within-role refuses before consumption (183.6351ms)
✔ class drift cross-role to within-role refuses before consumption (196.755743ms)
✔ class drift within-role to gated refuses before consumption (134.864109ms)
✔ class drift within-role to cross-role refuses before consumption (160.50952ms)
✔ message.send consumes approval and prevents a later send or authorize (173.816965ms)
✔ role.revoke consumes approval and prevents a later revoke or authorize (195.598037ms)
✔ creates private WAL store and excludes a second writer until explicit close (123.44453ms)
✔ rollback is atomic and schema metadata is checked against trusted DDL, not just itself (181.784127ms)
✔ existing empty database and symlink runtime directory refuse, never initialize over damage (215.291682ms)
✔ crash during a transaction recovers no partial event after explicit fixture-only lock removal (169.453598ms)
✔ writer refuses mixed at/read_at forms atomically, even through trusted SQL helpers (119.878794ms)
✔ async transactions refuse before invoking their function (107.045531ms)
✔ recordTask keeps sync reads and a role write apart (179.866702ms)
✔ read_at must be one canonical UTC format, so the projection compares strings safely (109.539813ms)
✔ a bad entry refuses the whole record (118.580361ms)
✔ taskView reads the projection for one business (143.124952ms)
✔ requestTask hands only a holder and a task verb to the handler, and records refusals (262.005489ms)
✔ the server sends task verbs to the adapter with its own timeout; other verbs stay synchronous (413.469594ms)
✔ without an adapter the server refuses every task verb (173.691791ms)
✔ the runtime refuses an invalid adapter and closes a valid one (180.508213ms)
✔ the process loads the S3 adapter from plain-data trackers (232.75675ms)
✔ socket capability stamps launch identity; shared views use wire, no SQL client (146.93786ms)
✔ two wire claims serialize; a lost reply never automatically retries (182.474377ms)
✔ malformed, oversized and identity-forging envelopes refuse without echoing input (113.923911ms)
✔ client preserves UTF-8 when a response divides a multibyte character (11.855361ms)
✔ committed mutation followed by dropped reply reports unknown and is never retried (162.548925ms)
ℹ tests 74
ℹ suites 0
ℹ pass 73
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 2579.991609
✖ failing tests:
test at packages/bus/tests/end-launch.test.mjs:121:1
✖ launches off refuses role.launch with launch-revoked until launches on (183.043385ms)
AssertionError [ERR_ASSERTION]: Missing expected exception.
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41/wt/packages/bus/tests/end-launch.test.mjs:128:10)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: undefined,
expected: { code: 'launch-revoked' },
operator: 'throws',
diff: 'simple'
}
@@ -0,0 +1,87 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (109.862086ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (129.587767ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (92.553872ms)
✔ decide prints a declining choice as declining (105.185722ms)
✔ an unknown outcome is reported once and never resent (74.403125ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (75.320493ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (97.310508ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (80.781684ms)
✔ every human command refuses inside an agent run before it touches the bus (84.435133ms)
✔ usage errors exit 4; no business and no host is a usage error (80.337038ms)
✔ agents and tasks print through the broker (75.861924ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (1.835921ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (54.390762ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (37.014457ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (34.755279ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (35.304414ms)
✔ a business without tracker.baseUrl gets no trackers entry (27.453313ms)
✔ an unknown business and a broken system config refuse with exit 3 (73.51488ms)
✔ empty views say so (1.16644ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (1.434549ms)
✔ tasks print the tracker fields the snapshot carries (0.234705ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (886.74793ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (207.220461ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (125.163127ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (193.933367ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (204.121221ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (121.372003ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (175.827333ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (143.726724ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (167.137229ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (22.294447ms)
✔ bus stop refuses to signal a live pid that is not a bus host (202.775467ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (210.492927ms)
✔ bus start refuses with exit 3 without a notifier config (90.517024ms)
✔ bus start runs until bus stop; status reports it while it runs (670.225504ms)
✔ bus-service.sh renders the unit and installs it into a given directory (30.367746ms)
✔ bus start --pm: the PM runs under its own PID namespace, registered, with a manifest (1264.763565ms)
✔ the PM launches a coder through its launch tool; the coder answers; refusals name their code (659.045675ms)
✔ a runner that stops at once ends its launch with the runner's reason (207.163503ms)
✔ 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 (617.947072ms)
✔ 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 (3574.705922ms)
✔ a malformed sessions.json refuses the host start with exit 3 and stays as it was (110.311682ms)
✔ zoned uses the IANA zone across DST (16.358337ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (116.467328ms)
✔ two blocking decisions get two DMs with different nonces (124.106016ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.199104ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (95.468473ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (99.017605ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (75.330303ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (81.013867ms)
✔ polled every POLL_MS against a permanent 403, a DM is sent at 0, 30, 60, 90 and 120 min and gives up only then (159.689637ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (116.245282ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (96.001449ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (75.69486ms)
✔ an inbox read failure is logged and the next poll retries (0.661409ms)
✔ no Discord id reaches the journal or the log (59.545832ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (16.205132ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (20.246556ms)
✔ the journal: a whole file that is one torn line truncates to empty (21.610334ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (1.917923ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.705192ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (1.917939ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.529388ms)
✔ 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.533089ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.461178ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.564232ms)
✔ digest content stays within Discord's 2000 characters (0.288698ms)
✔ runLoop never overlaps ticks and stops after the one in flight (110.579344ms)
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 (358.584995ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (41.579301ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2145.123853ms)
✔ busExit and refuseInsideAgent (0.399961ms)
✔ talk sends a REQUEST, prints and reads everything that arrives, and stops at the reply (198.808191ms)
✔ talk --wait 0 only sends; no reply within --wait exits 1 and says where it will show (1102.511621ms)
✔ talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors (118.315699ms)
✔ launches off and on go to the broker and change the business's launch state (127.484544ms)
✔ launches list reads sessions.json and marks a stale entry; stop reports it and refuses a non-runner (83.416693ms)
ℹ tests 77
ℹ suites 0
ℹ pass 77
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 6523.228172
@@ -0,0 +1,125 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (122.875541ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (130.94748ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (111.565521ms)
✔ decide prints a declining choice as declining (100.598618ms)
✔ an unknown outcome is reported once and never resent (81.113719ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (83.224029ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (84.988204ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (70.293143ms)
✔ every human command refuses inside an agent run before it touches the bus (81.875595ms)
✔ usage errors exit 4; no business and no host is a usage error (95.688893ms)
✔ agents and tasks print through the broker (87.007356ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (2.678919ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (42.09848ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (31.644567ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (32.461392ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (39.422053ms)
✔ a business without tracker.baseUrl gets no trackers entry (31.112022ms)
✔ an unknown business and a broken system config refuse with exit 3 (56.891472ms)
✔ empty views say so (0.786498ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (0.964626ms)
✔ tasks print the tracker fields the snapshot carries (0.156842ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (924.290753ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (224.387012ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (131.886124ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (200.781729ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (202.118243ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (133.013545ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (183.058103ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (111.514305ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (204.993453ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (28.041817ms)
✔ bus stop refuses to signal a live pid that is not a bus host (203.159141ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (229.44384ms)
✔ bus start refuses with exit 3 without a notifier config (105.05832ms)
✔ bus start runs until bus stop; status reports it while it runs (658.38108ms)
✔ bus-service.sh renders the unit and installs it into a given directory (27.340512ms)
✔ bus start --pm: the PM runs under its own PID namespace, registered, with a manifest (1284.454067ms)
✔ the PM launches a coder through its launch tool; the coder answers; refusals name their code (660.213353ms)
✔ a runner that stops at once ends its launch with the runner's reason (228.790342ms)
✖ 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 (471.399509ms)
✖ 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 (3406.092202ms)
✔ a malformed sessions.json refuses the host start with exit 3 and stays as it was (137.539713ms)
✔ zoned uses the IANA zone across DST (17.45739ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (131.994345ms)
✔ two blocking decisions get two DMs with different nonces (124.836571ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.179365ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (97.474367ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (99.938485ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (91.272173ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (85.503667ms)
✔ 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 (145.863192ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (119.177833ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (124.013706ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (84.553923ms)
✔ an inbox read failure is logged and the next poll retries (0.616339ms)
✔ no Discord id reaches the journal or the log (72.213113ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (12.627127ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (15.576535ms)
✔ the journal: a whole file that is one torn line truncates to empty (17.63237ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (1.965226ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.641952ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (1.809363ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.310274ms)
✔ 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.45827ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.364092ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.445383ms)
✔ digest content stays within Discord's 2000 characters (0.25583ms)
✔ runLoop never overlaps ticks and stops after the one in flight (110.775491ms)
task.close {} answered: invalid-request; fake saw 18 requests, first GET /info 200, GET /projects/1 200, GET /projects/1/views 200
task.close on a missing task answered: task-not-found; it made GET /tasks/999 404
✔ bootConfig trackers reach the S3 adapter in the real broker child, which goes ready against a fake Vikunja (378.369694ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (35.260579ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2151.84806ms)
✔ busExit and refuseInsideAgent (0.392495ms)
✔ talk sends a REQUEST, prints and reads everything that arrives, and stops at the reply (229.052611ms)
✔ talk --wait 0 only sends; no reply within --wait exits 1 and says where it will show (1132.31844ms)
✔ talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors (120.754589ms)
✔ launches off and on go to the broker and change the business's launch state (115.404453ms)
✔ launches list reads sessions.json and marks a stale entry; stop reports it and refuses a non-runner (77.278529ms)
ℹ tests 77
ℹ suites 0
ℹ pass 75
ℹ fail 2
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 6277.504144
✖ failing tests:
test at packages/cli/tests/launcher.test.mjs:252:3
✖ a host that died hard leaves its sessions to the next host, which kills them and ends their runs so the role can be launched again (471.399509ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
0 !== 1
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41/wt/packages/cli/tests/launcher.test.mjs:281:12)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: 0,
expected: 1,
operator: 'strictEqual',
diff: 'simple'
}
test at packages/cli/tests/launcher.test.mjs:252:3
✖ a host that died hard leaves its sessions to the next host, which finds one already exited (23) and ends their runs so the role can be launched again (3406.092202ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
0 !== 1
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r41/wt/packages/cli/tests/launcher.test.mjs:281:12)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: 0,
expected: 1,
operator: 'strictEqual',
diff: 'simple'
}
+18
View File
@@ -0,0 +1,18 @@
#!/bin/bash
R=~/darkwing-scratch/r41/mut
rm -f $R/summary.txt
$R/run.sh Ma-late-signals harness cli
$R/run.sh Mb-runs-set-early bus cli
$R/run.sh Mc-no-run-ended bus cli
$R/run.sh Md-no-instance-running cli
$R/run.sh Me-no-authorize cli
$R/run.sh Mf-no-exit2-wrapper harness
$R/run.sh Mg-no-founder-check harness cli
$R/run.sh Mh-no-close-sigkill cli
$R/run.sh Mi-recover-no-kill cli
$R/run.sh Mj-no-stdin-cap harness
$R/run.sh Mk-launch-line-max cli
$R/run.sh Ml-gate-pattern harness
$R/run.sh Mm-no-revoke bus cli
$R/run.sh Mn-recover-no-end cli
echo DONE >> $R/summary.txt
@@ -0,0 +1,40 @@
# 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;',
' let session, cap;', ),
"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', ""),
}
f, old, new = M[sys.argv[1]]
s = open(f).read()
n = s.count(old)
if n != 1: sys.exit(f"{sys.argv[1]}: {n} matches in {f}")
open(f, "w").write(s.replace(old, new))
print(f)
+8
View File
@@ -0,0 +1,8 @@
#!/bin/bash
# parse.sh: pass/fail and failing test names per mutant output (spec reporter).
cd ~/darkwing-scratch/r41/mut
for f in M*-*.txt; do
p=$(grep -E '^ℹ pass' "$f" | awk '{print $3}'); x=$(grep -E '^ℹ fail' "$f" | awk '{print $3}')
echo "$f: pass $p fail $x"
[ "$x" != 0 ] && awk '/^✖ failing tests:/{on=1;next} on && /^✖ /{sub(/^✖ /," "); sub(/ \([0-9.]+ms\)$/,""); print}' "$f" | sort -u
done
+17
View File
@@ -0,0 +1,17 @@
#!/bin/bash
# run.sh <mutant> <pkg>...: mutate, run each package's node suite, restore.
set -u
cd ~/darkwing-scratch/r41/wt
export TMPDIR=~/darkwing-scratch/tmp DOCKER_HOST=unix:///nonexistent.sock
m=$1; shift
f=$(python3 ../mut/mutate.py "$m") || { echo "$m: no match" | tee -a ../mut/summary.txt; exit 1; }
line="$m ($f):"
for p in "$@"; do
out=../mut/$m-$p.txt
node --test "packages/$p/tests/*.test.mjs" > "$out" 2>&1
pass=$(grep -E '^# pass' "$out" | awk '{print $3}'); fail=$(grep -E '^# fail' "$out" | awk '{print $3}')
line="$line $p pass $pass fail $fail;"
[ "$fail" != 0 ] && line="$line [$(grep -E '^not ok' "$out" | sed 's/^not ok [0-9]* - //' | head -3 | paste -sd'|')]"
done
git checkout -- "$f"
echo "$line" | tee -a ../mut/summary.txt
@@ -0,0 +1,15 @@
Ma-late-signals (packages/harness/src/runner.mjs): harness pass fail ; [] cli pass fail ; []
Mb-runs-set-early (packages/bus/src/broker.mjs): bus pass fail ; [] cli pass fail ; []
Mc-no-run-ended (packages/bus/src/broker.mjs): bus pass fail ; [] cli pass fail ; []
Md-no-instance-running (packages/cli/src/launcher.mjs): cli pass fail ; []
Me-no-authorize (packages/cli/src/launcher.mjs): cli pass fail ; []
Mf-no-exit2-wrapper (packages/harness/src/bundle.mjs): harness pass fail ; []
Mg-no-founder-check (packages/harness/src/runner.mjs): harness pass fail ; [] cli pass fail ; []
Mh-no-close-sigkill (packages/cli/src/launcher.mjs): cli pass fail ; []
Mi-recover-no-kill (packages/cli/src/launcher.mjs): cli pass fail ; []
Mj-no-stdin-cap (packages/harness/src/runner.mjs): harness pass fail ; []
Mk-launch-line-max (packages/cli/src/launcher.mjs): cli pass fail ; []
Ml-gate-pattern (packages/harness/src/gate.mjs): harness pass fail ; []
Mm-no-revoke (packages/bus/src/broker.mjs): bus pass fail ; [] cli pass fail ; []
Mn-recover-no-end (packages/cli/src/launcher.mjs): cli pass fail ; []
DONE
@@ -0,0 +1,82 @@
✔ launch identity is stamped, payload identity is refused and stale holder cannot send (230.298902ms)
✔ decision classes route from policy; gated resolution is human-only, choice and target must match (495.596263ms)
✔ claim exclusion, holder release, gated revoke and rerouting to a new holder are atomic (295.350922ms)
✔ launch events require a human CLI capability; generic emit cannot forge authority events (209.247055ms)
✔ within-role decisions close atomically and invalid options or blocking omissions refuse (201.016991ms)
✔ observer capabilities read human inbox but cannot mutate or forge launch identity (146.820046ms)
✔ task action subjects and linked decision trail are complete and ordered (186.279171ms)
✔ launch binding is durable and reconnecting requires the identical trusted record (124.058295ms)
✔ business isolation includes inherited object names and cross-business message references (201.009327ms)
✔ authority never transfers between action, run, target, unresolved or replaced role holder (325.185099ms)
✔ task projection uses schema current view, skipping earlier and equal-start polls (123.998135ms)
✔ revocation permanently bars the old run from reclaiming first, including after broker restart (220.538954ms)
✔ empty message references refuse before storage; refusal-evidence failure stays a typed error (170.419881ms)
✔ both arbiters require human resolution when their cross-role route is themselves (217.901165ms)
✔ S1 adapter takes resolved limits and refs, rejects mismatched instance, never mutates input (2.544455ms)
✔ only validated broker references load; returned data and exceptions cannot expose a known token (4.455749ms)
✔ bad file modes, symlinks, repository/data paths, malformed tokens and missing dates refuse (6.670204ms)
✔ expiry refuses use and env references never become client data (0.783979ms)
✔ S1 parsed service refs work, service mismatch refuses, Gitea rotation due is a warning state (1.944049ms)
✔ opaque tokens shorter than 16 characters refuse before use (0.278578ms)
✔ endLaunch writes session.ended, releases the run claim and kills its capabilities (212.9763ms)
✔ endLaunch refuses an unknown run, a second end and a rebind of the ended run (283.135446ms)
✔ a restarted broker refuses to rebind an ended run; a refused rebind leaves the run unbound (239.880561ms)
✔ endLaunch leaves a claim another run took alone (189.377591ms)
✔ refuse records action.refused against the caller with the code only (148.68694ms)
✔ launches off refuses role.launch with launch-revoked until launches on (221.873558ms)
✔ broker process: launch ops authorize role.launch, record refusals and end runs (234.098929ms)
✔ human proof binds CLI entry, process start and nonce; agents and incomplete ancestry refuse (1.844295ms)
✔ process reader gets own kernel identity without exposing environment values (1.229094ms)
✔ EACCES ancestor environments skip only markers; commands and registered launches still refuse (0.718827ms)
✔ real pid 1 remains inspectable when its environment is protected (0.280884ms)
✔ within-role sends cite an open gated launch decision without spending it or naming it in grants (336.734778ms)
✔ missing and foreign-business citations refuse and roll back message and grant (356.763341ms)
✔ cross-role sends still need a matching resolved decision and consume it once (272.056987ms)
✔ broker process binds trusted launches, offers reader capabilities, refuses human mutation, closes cleanly (271.449396ms)
✔ startup token refusal returns safe code without value or partial listening broker (38.528737ms)
✔ loaded fixture token is absent from socket replies and SQLite, including refusal evidence (319.293579ms)
✔ killed broker leaves an explicit stale lock; another process cannot silently reclaim it (181.982551ms)
✔ trusted host registers later launches; socket clients never have a registration verb (178.757199ms)
✔ runtime excludes declared project roots even when host supplies no repoRoots (35.654181ms)
✔ a refused launch binding leaves the broker and existing capabilities alive; bad protocol stops it (188.093502ms)
✔ v3b prototype refusals, views and append-only mutations (1445.847272ms)
✔ gated approval authorizes once, survives store reopen, and fresh approval works (478.15649ms)
✔ another run cannot consume an approval; a failed check leaves it usable (310.533789ms)
✔ two scheduled callers have exactly one grant and one consumed refusal (193.236882ms)
✔ failed commit rolls consumption back; cross-role consumes and within-role stays reusable (384.018937ms)
✔ class drift gated to cross-role refuses before consumption (200.035157ms)
✔ class drift cross-role to gated refuses before consumption (257.75596ms)
✔ class drift gated to within-role refuses before consumption (192.144254ms)
✔ class drift cross-role to within-role refuses before consumption (267.10218ms)
✔ class drift within-role to gated refuses before consumption (197.394414ms)
✔ class drift within-role to cross-role refuses before consumption (210.140443ms)
✔ message.send consumes approval and prevents a later send or authorize (243.201049ms)
✔ role.revoke consumes approval and prevents a later revoke or authorize (221.001388ms)
✔ creates private WAL store and excludes a second writer until explicit close (190.861398ms)
✔ rollback is atomic and schema metadata is checked against trusted DDL, not just itself (370.583805ms)
✔ existing empty database and symlink runtime directory refuse, never initialize over damage (240.634985ms)
✔ crash during a transaction recovers no partial event after explicit fixture-only lock removal (172.61397ms)
✔ writer refuses mixed at/read_at forms atomically, even through trusted SQL helpers (114.410444ms)
✔ async transactions refuse before invoking their function (124.463303ms)
✔ recordTask keeps sync reads and a role write apart (290.376296ms)
✔ read_at must be one canonical UTC format, so the projection compares strings safely (254.023515ms)
✔ a bad entry refuses the whole record (152.38741ms)
✔ taskView reads the projection for one business (132.084924ms)
✔ requestTask hands only a holder and a task verb to the handler, and records refusals (283.971235ms)
✔ the server sends task verbs to the adapter with its own timeout; other verbs stay synchronous (431.972902ms)
✔ without an adapter the server refuses every task verb (229.494873ms)
✔ the runtime refuses an invalid adapter and closes a valid one (221.815058ms)
✔ the process loads the S3 adapter from plain-data trackers (297.187861ms)
✔ socket capability stamps launch identity; shared views use wire, no SQL client (247.764449ms)
✔ two wire claims serialize; a lost reply never automatically retries (371.117662ms)
✔ malformed, oversized and identity-forging envelopes refuse without echoing input (125.675324ms)
✔ client preserves UTF-8 when a response divides a multibyte character (11.780579ms)
✔ committed mutation followed by dropped reply reports unknown and is never retried (155.539343ms)
ℹ tests 74
ℹ suites 0
ℹ pass 74
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 3244.829626
@@ -0,0 +1,68 @@
✔ config directory and file path follow MOSAIC_CONFIG (1.46993ms)
✔ the fixture business validates and comes back frozen (3.65864ms)
✔ two instances may share a definition (1.05404ms)
✔ top-level refusals (3.346619ms)
✔ arbiters and projects (5.222003ms)
✔ role instances (2.840985ms)
✔ Vikunja bots (5.529147ms)
✔ a role without Vikunja takes no tracker block (1.73776ms)
✔ credential references match the definition's services (2.08883ms)
✔ launch (6.709177ms)
✔ loadBusiness: file checks (1.526452ms)
✔ loadBusiness: not a regular file (37.652055ms)
✔ loading writes nothing (1.309657ms)
✔ names that are Object.prototype properties don't count as declared (2.476127ms)
✔ the shipped example refuses as written and validates once filled in (0.52605ms)
✔ usage errors exit 4 (283.72903ms)
✔ validate: a good business exits 0 and prints instance digests (67.029589ms)
✔ validate: project files (305.887244ms)
✔ validate: missing files and a broken system config (248.794647ms)
✔ validate: credential reference problems exit 2 and name each one (62.924086ms)
✔ validate: a token file inside the repository is refused (65.917526ms)
✔ validate: role definitions come from MOSAIC_ROLES_DIR (186.048198ms)
✔ resolve: prints one instance's record (193.663903ms)
✔ resolve: refusals (386.422626ms)
✔ parse: exactly one of file or env, plus the service's date (1.918165ms)
✔ check: a good file has no problems (0.606329ms)
✔ check never opens the file: a write-only token passes (0.253188ms)
✔ check: file problems (0.698464ms)
✔ check: token files can't live in the repository or dataRoot, even through a linked directory (0.647238ms)
✔ check: dates and environment references (0.304771ms)
✔ path and load (1.699579ms)
✔ refusals (0.864022ms)
✔ systemVars flattens the validated config (1.491298ms)
✔ precedence: system, business, project, project role, agent (3.709113ms)
✔ limits narrow the definition and never widen it (1.852486ms)
✔ role.launch stays within-role only for the instance the launch block names (3.761048ms)
✔ limits.authority without role.launch leaves the launcher with no launch block (1.43637ms)
✔ limits.authority narrows cross-role actions too (0.814065ms)
✔ classify (0.916153ms)
✔ the record carries what the broker and launcher need (0.797372ms)
✔ digest: key order doesn't matter, any value change does (4.852509ms)
✔ refusals (1.887646ms)
✔ the four shipped version 2 roles load (2.349244ms)
✔ shipped role scopes match addendum B section 2 and the SR runbook (0.971249ms)
✔ shipped authority follows the note's table (0.495674ms)
✔ version 1 files keep loading with no authority (0.755756ms)
✔ the conductor policy isn't a role (0.203739ms)
✔ a missing role file is exit 4, a symbolic link too (0.312205ms)
✔ version 2 refusals (0.981826ms)
✔ authority: closed vocabulary, no gated-only action, no overlap (2.054732ms)
✔ credentials: Gitea scopes (0.783621ms)
✔ credentials: Vikunja scopes are a group-to-verbs map from the grantable list (0.892118ms)
✔ credentials: services (0.458892ms)
✔ contract: a non-empty regular Markdown file beside the role file (0.624834ms)
✔ every key names known layers and a merge rule (0.76137ms)
✔ unknown keys and wrong layers refuse (0.780944ms)
✔ types (1.595196ms)
✔ merge: defaults, then the most specific layer wins (0.253108ms)
✔ merge: limits only narrow, and provenance lists each source (0.323743ms)
✔ merge doesn't change its inputs (0.108269ms)
ℹ tests 60
ℹ suites 0
ℹ pass 60
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 1859.606824
@@ -0,0 +1,87 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (127.407326ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (141.689489ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (142.605184ms)
✔ decide prints a declining choice as declining (121.229846ms)
✔ an unknown outcome is reported once and never resent (112.51702ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (98.802976ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (99.474469ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (96.405106ms)
✔ every human command refuses inside an agent run before it touches the bus (135.991574ms)
✔ usage errors exit 4; no business and no host is a usage error (113.200529ms)
✔ agents and tasks print through the broker (164.909648ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (2.055998ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (44.45945ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (33.551109ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (31.918045ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (28.986033ms)
✔ a business without tracker.baseUrl gets no trackers entry (27.034417ms)
✔ an unknown business and a broken system config refuse with exit 3 (51.63733ms)
✔ empty views say so (0.744068ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (0.991007ms)
✔ tasks print the tracker fields the snapshot carries (0.136071ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (896.785596ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (256.455544ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (239.608612ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (291.720324ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (265.305714ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (122.588192ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (178.527251ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (197.615861ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (180.946075ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (21.7181ms)
✔ bus stop refuses to signal a live pid that is not a bus host (203.058669ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (212.928287ms)
✔ bus start refuses with exit 3 without a notifier config (89.140811ms)
✔ bus start runs until bus stop; status reports it while it runs (656.935804ms)
✔ bus-service.sh renders the unit and installs it into a given directory (28.348121ms)
✔ bus start --pm: the PM runs under its own PID namespace, registered, with a manifest (270.794964ms)
✔ the PM launches a coder through its launch tool; the coder answers; refusals name their code (815.154892ms)
✔ a runner that stops at once ends its launch with the runner's reason (263.406741ms)
✔ 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 (815.211823ms)
✔ 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 (3657.484394ms)
✔ a malformed sessions.json refuses the host start with exit 3 and stays as it was (113.489374ms)
✔ zoned uses the IANA zone across DST (15.474129ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (129.254695ms)
✔ two blocking decisions get two DMs with different nonces (128.049389ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.188199ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (133.43126ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (124.968283ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (116.127597ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (93.4847ms)
✔ 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 (165.887479ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (141.037869ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (153.200525ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (137.038182ms)
✔ an inbox read failure is logged and the next poll retries (0.650764ms)
✔ no Discord id reaches the journal or the log (170.603977ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (49.136453ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (81.494654ms)
✔ the journal: a whole file that is one torn line truncates to empty (22.271123ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (2.728536ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.584967ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (1.950866ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.292651ms)
✔ 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.45137ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.395917ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.410196ms)
✔ digest content stays within Discord's 2000 characters (0.266032ms)
✔ runLoop never overlaps ticks and stops after the one in flight (110.668485ms)
task.close {} answered: invalid-request; fake saw 18 requests, first GET /info 200, GET /projects/1 200, GET /projects/1/views 200
task.close on a missing task answered: task-not-found; it made GET /tasks/999 404
✔ bootConfig trackers reach the S3 adapter in the real broker child, which goes ready against a fake Vikunja (375.657886ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (34.268882ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2137.373473ms)
✔ busExit and refuseInsideAgent (0.46046ms)
✔ talk sends a REQUEST, prints and reads everything that arrives, and stops at the reply (229.138287ms)
✔ talk --wait 0 only sends; no reply within --wait exits 1 and says where it will show (1162.869992ms)
✔ talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors (170.664262ms)
✔ launches off and on go to the broker and change the business's launch state (117.53746ms)
✔ launches list reads sessions.json and marks a stale entry; stop reports it and refuses a non-runner (82.164709ms)
ℹ tests 77
ℹ suites 0
ℹ pass 77
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 6022.109024
@@ -0,0 +1,53 @@
✔ sessionModel: agent vars win, then the system's execution settings (17.278444ms)
✔ a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory (6.894082ms)
✔ a claude-code bundle adds the wrapped gate hook and the MCP config (5.689389ms)
✔ a bundle is written once: an existing file refuses (2.33278ms)
✔ a path with a single quote can't go into the hook command (3.881591ms)
✔ allow exits 0, a deny exits 2 with the reason on stderr (130.760929ms)
✔ a missing or wrong policy, or a bad event, exits 2 (76.614744ms)
✔ the bundle's wrapped command: a missing gate or node still blocks (1087.842947ms)
✔ claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist (731.662814ms)
✔ claude: the hook alone blocks a path outside the workspace (467.79837ms)
✔ claude: a second turn resumes the first turn's session (708.774651ms)
✔ claude: a missing hook or MCP file refuses before claude starts (16.048526ms)
✔ pi: policy tools and typed tools pass, anything else is blocked (3.616285ms)
✔ claude: builtins map from pi names, typed tools need the mcp prefix (0.753166ms)
✔ file tool paths must resolve inside the workspace (1.154562ms)
✔ pi's own path normalisation can't be used to step out (1.161134ms)
✔ a symlink inside the workspace that points out is outside (1.095973ms)
✔ claude path fields per tool (0.776238ms)
✔ glob patterns stay inside the workspace (1.201128ms)
✔ a path that can't be checked is blocked (0.581822ms)
✔ initialize, ping and tools/list (44.402251ms)
✔ tools/call goes through the tool socket; a refusal is an isError result (31.674476ms)
✔ unknown tools and methods are JSON-RPC errors and never reach the socket (30.522533ms)
✔ a missing argument is a usage error (28.622595ms)
✔ pi: typed tools reach the socket, the gate blocks, agent_end writes the marker (350.095579ms)
✔ pi: a missing extension refuses before any model call (8.3472ms)
✔ pi: an extension without its configuration fails pi's start (275.717684ms)
✔ founderCheck: founder variables, then a needed service without a usable token (1.095529ms)
✔ turnRequest names the sender, class, reply and decision (0.177223ms)
✔ a message becomes a turn, the answer goes back as a RESULT, SIGTERM releases and exits 0 (238.692774ms)
✔ a SIGTERM before the claim stops the runner with exit 0 and no claim (99.758588ms)
✔ typed tools carry the runner's capability; launch goes to the host's launch socket (439.551997ms)
✔ a RESULT gets no automatic reply; failed turns reply with the reason (1312.043711ms)
✔ SIGTERM during a turn kills the turn's process group and still exits 0 (151.833299ms)
✔ founder credentials stop before the claim (20) (166.088906ms)
✔ a refused claim exits 21; an ended run's capability exits 22 (212.018616ms)
✔ the launch ending under a running session exits 22 (155.468877ms)
✔ a broker that stays unreachable exits 23 after brokerRetries polls (248.508739ms)
✔ a broker that is down at the claim exits 23, not 21 (105.883143ms)
✔ no capability, or a malformed one, on stdin exits 2 (153.856802ms)
✔ the PM gets launch, its task verbs and the reads (8.254629ms)
✔ a coder gets no launch, no resolve_decision, and no task tools without a tracker (4.257605ms)
✔ launch only when the business's launch block names the instance as launcher (1.639872ms)
✔ an action outside the instance's authority has no tool (1.401759ms)
✔ callTool: one JSON line out, the result back, a refusal rejects (8.517699ms)
ℹ tests 45
ℹ suites 0
ℹ pass 45
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 10363.182249
@@ -0,0 +1,35 @@
✔ resolveSeat: by name under --repo resolves the repo layout (1.254502ms)
✔ resolveSeat: by path resolves the fleet layout (0.516747ms)
✔ resolveSeat: refusals for missing dir, missing launch.sh, non-executable launch.sh, invalid name, and unknown layout (0.847962ms)
✔ tmuxContext: outside tmux, default socket, custom socket, and exec failure (0.635368ms)
✔ makeRegistration produces a record that validates; each shape violation throws SeatError (0.803431ms)
✔ writeRegistration/readRegistration: round trip, permissions, absence, and malformed records (1.24324ms)
✔ updateTask: changes task, taskSetBy and updatedAt only, and refuses appropriately (1.239321ms)
✔ CLI launch: registers, execs the fake launch script, and passes args through (30.059497ms)
✔ CLI launch: --harness lands in the record (31.801058ms)
✔ CLI launch: the launch script's own exit code passes through (28.546759ms)
✔ CLI launch: relaunching a seat rewrites the one registration record (58.693016ms)
✔ CLI launch: omitting --task records an empty string, not null (28.874274ms)
✔ CLI seat task: updates only the task after a launch, and refuses on an unlaunched seat (80.75812ms)
✔ CLI refusals: no args, unknown flag, missing config, already-registered env, and exec failure (135.711596ms)
✔ samePath: equal paths, symlinked dirs, distinct dirs, and non-strings (0.40395ms)
✔ resolveSetBy: explicit --by wins over the environment; absent or empty environment gives unknown; invalid explicit or environment values refuse with exit 4 (0.555598ms)
✔ 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.899447ms)
✔ updateTask: records setBy, preserves startedAt and every unrelated field, upgrades an old record in place only when the task is set, and replaces a previous attribution (0.861976ms)
✔ 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.018867ms)
✔ family: exactly one launch.max key in the model name, else null (0.617808ms)
✔ sessionEnv passes only the allowlist, the repo's bin on PATH, and the run id (0.665325ms)
✔ newRun: short ids, 0700 directories, and a refusal when the socket path won't fit (1.463053ms)
✔ session file and launch log: 0600, the session file written once (1.346008ms)
✔ endReason maps the runner's exit codes; a signal is killed (0.107394ms)
✔ Registry mirrors to sessions.json; readSessions marks live entries; bad files refuse (1.244129ms)
✔ stopSession refuses an unknown run, reports a stale one, and won't signal a pid that isn't a runner (1.325051ms)
✔ a session runs under unshare as pid 1 of its namespace, claims, answers, and stops on mosaic stop (260.809593ms)
ℹ tests 27
ℹ suites 0
ℹ pass 27
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 726.22422
@@ -0,0 +1,15 @@
node-harness exit=0 ℹ pass 45 ℹ fail 0
node-seat exit=0 ℹ pass 27 ℹ fail 0
node-cli exit=0 ℹ pass 77 ℹ fail 0
node-bus exit=0 ℹ pass 74 ℹ fail 0
node-business exit=0 ℹ pass 60 ℹ fail 0
test-auth exit=0 selftest: 15 passed, 0 failed
test-config exit=0 selftest: 24 passed, 0 failed
test-conductor exit=0 selftest: 17 passed, 0 failed
test-queue exit=0 queue suite: 27 passed, 0 failed
test-foundation exit=0 selftest: 44 passed, 0 failed
test-extension-package exit=0 extension package selftest: 18 passed, 0 failed
test-release exit=0 selftest: 4 passed, 0 failed
test-discord exit=0 discord suite: 66 passed, 0 failed
test-task exit=1 selftest: 26 passed, 2 failed
GATE-DONE
@@ -0,0 +1,17 @@
OK status with missing harness credential exits 3 and still lists accounts
OK status reports harness credential (read-only) + mosaic accounts
OK api key material never reaches output
OK oauth token material never reaches output
OK unparseable credential file exits 2
OK symlinked credential file exits 4
OK env-side credential names reported
OK env var values never reach output
OK accounts without an accounts dir reports none and creates nothing
OK accounts lists files and marks the active one
OK loose account perms flagged in listing
OK agent --auth with missing account file refuses (exit 4)
OK agent --auth with non-0600 account file refuses
OK agent --auth with invalid account name refuses
OK auth.sh without valid config refuses
selftest: 15 passed, 0 failed
@@ -0,0 +1,55 @@
Note: switching to '2855e628ee91d046f4ea365b45dba5b0ccf4fd2a'.
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 '2855e628ee91d046f4ea365b45dba5b0ccf4fd2a'.
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: 42746 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/r41/wt) is not the queue's canonical root (/mnt/storage/src/mosaic-stack)
queue suite: 27 passed, 0 failed
@@ -0,0 +1,7 @@
OK valid RELEASE resolves (exit 0)
OK invalid RELEASE exits 1 (exit 1)
OK missing RELEASE exits 1 (exit 1)
OK valid RELEASE leaves image tag consistent with version
skip state-machine cases (docker daemon unavailable)
selftest: 4 passed, 0 failed
@@ -0,0 +1,33 @@
OK valid task validates (exit 0)
OK unknown task key exits 2 (exit 2)
OK unsupported taskVersion exits 2 (exit 2)
OK invalid task id exits 2 (exit 2)
OK empty prompt exits 2 (exit 2)
OK NUL in expectExact exits 2 (exit 2)
OK out-of-range timeout exits 2 (exit 2)
OK missing mission file exits 4 (exit 4)
OK task with valid mission validates (exit 0)
OK invalid mission exits 2 (exit 2)
OK validate missing task exits 4 (exit 4)
OK validation does not modify the task file
OK prune dry-run exits 0 (exit 0)
OK dry-run deleted nothing
OK prune --keep=2 --yes removes oldest (exit 0)
OK kept exactly 2 newest runs
OK newest run kept, oldest pruned
OK append-only receipt written (3 entries)
OK sessions/workspaces untouched by prune
OK prune with invalid keep exits 4 (exit 4)
skip adapter seam cases (docker daemon unavailable)
skip workspace/capability cases (docker daemon unavailable)
skip live task cases (docker unavailable)
OK onboard without name exits 4 (non-interactive) (exit 4)
OK onboard --name renders profile (exit 0)
OK profile written
OK canon structure: required filled, optional placeholdered
OK canon sections present
FAIL user recall run succeeds (exit 1)
FAIL recalled user name (response: )
OK no agent identity on headless run
selftest: 26 passed, 2 failed
+37
View File
@@ -0,0 +1,37 @@
#!/bin/sh
# The dangling-symlink write through a real Claude Code session: the
# candidate adapter, a settings.json in the bundle's form running the real
# claude-gate.mjs, --tools Write, and a mock API whose first answer calls
# Write on the link. Dummy key, 127.0.0.1 only.
set -eu
WT=$1; OUT=$2
R=$(mktemp -d "$TMPDIR/r41-cdangle-XXXX")
mkdir -p "$R/home" "$R/ws" "$R/sess" "$R/bundle" "$R/outside"
ln -s "$R/outside/planted.txt" "$R/ws/notes.md"
echo "the generated prompt" > "$R/bundle/prompt.md"
printf '{"harness":"claude-code","workspace":"%s","tools":["write"],"typed":[]}\n' "$R/ws" > "$R/bundle/policy.json"
printf '{"hooks":{"PreToolUse":[{"matcher":"*","hooks":[{"type":"command","command":"timeout -k 2 10 %s %s %s || exit 2","timeout":20}]}]}}\n' \
"$(command -v node)" "$WT/packages/harness/src/claude-gate.mjs" "$R/bundle/policy.json" > "$R/bundle/settings.json"
echo '{"mcpServers":{}}' > "$R/bundle/mcp.json"
TOOL_USE=$(printf '{"name":"Write","input":{"file_path":"%s","content":"written through the gate\\n"}}' "$R/ws/notes.md") \
node "$(dirname "$0")/mock-api.mjs" "$R/api.log" > "$R/port" & MOCK=$!
while [ ! -s "$R/port" ]; do sleep 0.1; done
set +e
env -i PATH="$PATH" HOME="$R/home" USER="$USER" LANG=C.UTF-8 \
ANTHROPIC_BASE_URL="http://127.0.0.1:$(cat "$R/port")" ANTHROPIC_API_KEY=sk-ant-dummy-not-a-key \
DISABLE_TELEMETRY=1 CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 MOSAIC_TOOLS=Write \
MOSAIC_SYSTEM_PROMPT_FILE="$R/bundle/prompt.md" MOSAIC_REQUEST="Message m1 from jason: write notes" \
MOSAIC_WORKSPACE="$R/ws" MOSAIC_SESSION_DIR="$R/sess" MOSAIC_MODEL=claude-sonnet-5-5 \
MOSAIC_CLAUDE_SETTINGS="$R/bundle/settings.json" MOSAIC_CLAUDE_MCP_CONFIG="$R/bundle/mcp.json" \
timeout 90 /bin/sh "$WT/adapters/claude/adapter.sh" < /dev/null > "$R/stdout" 2> "$R/stderr"
echo "exit $?" > "$R/exit"
kill $MOCK
{
echo "$(cat "$R/exit"); stdout: $(head -c 200 "$R/stdout"); stderr: $(head -c 300 "$R/stderr")"
echo "POST /v1/messages: $(grep -c '"url":"/v1/messages' "$R/api.log")"
echo "tool_result sent back: $(node -e '
const ls=require("fs").readFileSync(process.argv[1],"utf8").trim().split("\n").map(l=>JSON.parse(l)).filter(l=>l.url.startsWith("/v1/messages"));
const b=JSON.parse(ls.at(-1).body); const r=b.messages.flatMap(m=>Array.isArray(m.content)?m.content:[]).filter(c=>c.type==="tool_result");
console.log(JSON.stringify(r.map(c=>({is_error:c.is_error??false,content:c.content}))));' "$R/api.log")"
echo "outside file: $( [ -e "$R/outside/planted.txt" ] && cat "$R/outside/planted.txt" || echo absent)"
} | tee -a "$OUT"
@@ -0,0 +1,4 @@
exit 0; stdout: mock answer; stderr:
POST /v1/messages: 2
tool_result sent back: [{"is_error":true,"content":"/home/jwoltje/darkwing-scratch/tmp/r41-cdangle-0sjd/ws/notes.md resolves through a symlink to /home/jwoltje/darkwing-scratch/tmp/r41-cdangle-0sjd/outside/planted.txt, which is outside /home/jwoltje/darkwing-scratch/tmp/r41-cdangle-0sjd/ws; --restricted confines the file tools to the working directory."}]
outside file: absent
+39
View File
@@ -0,0 +1,39 @@
#!/bin/sh
# Does a Claude Code session started by adapters/claude/adapter.sh load
# CLAUDE.md files (fake HOME, parent and workspace) and auto-memory?
# Runs the host's claude against the mock API with a dummy key.
set -eu
WT=$1; OUT=$2; VARIANT=$3 # VARIANT: candidate | no-restricted
R=$(mktemp -d "$TMPDIR/r41-claude-XXXX")
mkdir -p "$R/home/.claude" "$R/work/ws" "$R/sess" "$R/bundle"
echo "MARKER-USER-CLAUDE-MD" > "$R/home/.claude/CLAUDE.md"
echo "MARKER-PARENT-CLAUDE-MD" > "$R/work/CLAUDE.md"
echo "MARKER-WS-CLAUDE-MD" > "$R/work/ws/CLAUDE.md"
SLUG=$(printf '%s' "$R/work/ws" | sed 's|[/.]|-|g')
mkdir -p "$R/home/.claude/projects/$SLUG/memory"
echo "MARKER-AUTOMEMORY" > "$R/home/.claude/projects/$SLUG/memory/MEMORY.md"
echo "the generated prompt" > "$R/bundle/prompt.md"
echo '{}' > "$R/bundle/settings.json"
echo '{"mcpServers":{}}' > "$R/bundle/mcp.json"
cp "$WT/adapters/claude/adapter.sh" "$R/adapter.sh"
[ "$VARIANT" = candidate ] || sed -i '/--restricted \\/d' "$R/adapter.sh"
node "$(dirname "$0")/mock-api.mjs" "$R/api.log" > "$R/port" & MOCK=$!
while [ ! -s "$R/port" ]; do sleep 0.1; done
set +e
env -i PATH="$PATH" HOME="$R/home" USER="$USER" LANG=C.UTF-8 \
ANTHROPIC_BASE_URL="http://127.0.0.1:$(cat "$R/port")" ANTHROPIC_API_KEY=sk-ant-dummy-not-a-key \
DISABLE_TELEMETRY=1 CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 \
MOSAIC_SYSTEM_PROMPT_FILE="$R/bundle/prompt.md" MOSAIC_REQUEST="Message m1 from jason: hi" \
MOSAIC_WORKSPACE="$R/work/ws" MOSAIC_SESSION_DIR="$R/sess" MOSAIC_MODEL=claude-sonnet-5-5 \
MOSAIC_CLAUDE_SETTINGS="$R/bundle/settings.json" MOSAIC_CLAUDE_MCP_CONFIG="$R/bundle/mcp.json" \
timeout 60 /bin/sh "$R/adapter.sh" > "$R/stdout" 2> "$R/stderr"
echo "exit $?" > "$R/exit"
kill $MOCK
{
echo "variant $VARIANT: $(cat "$R/exit"); stdout: $(head -c 200 "$R/stdout"); stderr: $(head -c 300 "$R/stderr")"
echo "requests: $(wc -l < "$R/api.log"); POST /v1/messages: $(grep -c '"url":"/v1/messages' "$R/api.log")"
for m in MARKER-USER-CLAUDE-MD MARKER-PARENT-CLAUDE-MD MARKER-WS-CLAUDE-MD MARKER-AUTOMEMORY "the generated prompt"; do
printf ' %-26s %s\n' "$m" "$(grep -c "$m" "$R/api.log" || true) request(s)"
done
} | tee -a "$OUT"
echo "$R" >> "$OUT.dirs"
@@ -0,0 +1,14 @@
variant candidate: exit 0; stdout: mock answer; stderr: Warning: no stdin data received in 3s, proceeding without it. If piping from a slow command, redirect stdin explicitly: < /dev/null to skip, or wait longer.
requests: 1; POST /v1/messages: 1
MARKER-USER-CLAUDE-MD 0 request(s)
MARKER-PARENT-CLAUDE-MD 0 request(s)
MARKER-WS-CLAUDE-MD 0 request(s)
MARKER-AUTOMEMORY 0 request(s)
the generated prompt 1 request(s)
variant no-restricted: exit 0; stdout: mock answer; stderr: Warning: no stdin data received in 3s, proceeding without it. If piping from a slow command, redirect stdin explicitly: < /dev/null to skip, or wait longer.
requests: 1; POST /v1/messages: 1
MARKER-USER-CLAUDE-MD 1 request(s)
MARKER-PARENT-CLAUDE-MD 1 request(s)
MARKER-WS-CLAUDE-MD 1 request(s)
MARKER-AUTOMEMORY 1 request(s)
the generated prompt 1 request(s)
@@ -0,0 +1,37 @@
// Does launcher.close() (a beforeClose hook) wait on a launch.sock client
// that never closes its side? MODE=silent sends nothing; MODE=line sends a
// request line and keeps the socket open after the reply.
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { connect } from "node:net";
import { join } from "node:path";
import { tmpdir } from "node:os";
const WT = process.env.WT, MODE = process.env.MODE ?? "silent";
const { bootConfig, loadSystem } = await import(`${WT}/packages/cli/src/config.mjs`);
const { startHost } = await import(`${WT}/packages/cli/src/host.mjs`);
const { createLauncher } = await import(`${WT}/packages/cli/src/launcher.mjs`);
const { launchSocketPath } = await import(`${WT}/packages/seat/src/session.mjs`);
const { fixture } = await import(`${WT}/packages/cli/tests/helpers.mjs`);
const root = mkdtempSync(join(tmpdir(), "r41-close-"));
const f = fixture(root, "acme", (doc) => ((doc.launch = { by: "pm", instances: ["coder"], max: { opus: 1, sonnet: 1 } }), doc));
mkdirSync(f.dataRoot, { mode: 0o700 });
const adapter = join(root, "adapter.sh");
writeFileSync(adapter, `exec '${process.execPath}' '${WT}/packages/harness/tests/fixtures/fake-adapter.mjs'\n`);
const system = loadSystem({ env: f.env });
const host = await startHost({ boot: bootConfig({ system, businessId: "acme", env: f.env }), business: "acme", log: (l) => console.log("host:", l) });
const launcher = createLauncher({ host, system, env: f.env, adapters: { pi: adapter, "claude-code": adapter }, sessionDefaults: { pollInterval: 100 }, versions: { pi: "0.85.1", claude: null }, log: (l) => console.log("launcher:", l) });
await launcher.listen();
const s = connect(launchSocketPath(f.dataRoot));
s.allowHalfOpen = true;
await new Promise((r) => s.on("connect", r));
s.on("data", (b) => console.log(`client got ${b.toString().trim()} at ${Date.now() - t0} ms`));
s.on("end", () => console.log(`client got FIN at ${Date.now() - t0} ms (not ending its side)`));
const t0 = Date.now();
if (MODE === "line") s.write(`{"cap":"${"0".repeat(64)}","instance":"coder"}\n`);
await new Promise((r) => setTimeout(r, 200));
console.log(`host.close(0) at ${Date.now() - t0} ms`);
const closing = host.close(0).then((c) => `closed ${c}`);
const limit = new Promise((r) => setTimeout(() => r("still not closed"), Number(process.env.LIMIT ?? 25000)));
console.log(`${await Promise.race([closing, limit])} at ${Date.now() - t0} ms`);
s.destroy();
console.log(`client destroyed; ${await closing} at ${Date.now() - t0} ms`);
rmSync(root, { recursive: true, force: true });
@@ -0,0 +1,12 @@
== MODE=silent
host.close(0) at 201 ms
client got {"ok":false,"error":"invalid-request"} at 10050 ms
client got FIN at 10050 ms (not ending its side)
still not closed at 25227 ms
client destroyed; closed 0 at 25240 ms
== MODE=line
client got {"ok":false,"error":"unauthenticated"} at 2 ms
client got FIN at 2 ms (not ending its side)
host.close(0) at 201 ms
still not closed at 25227 ms
client destroyed; closed 0 at 25246 ms
@@ -0,0 +1,43 @@
// Does launcher.close() SIGKILL a session whose runner never answers its
// SIGTERM? Launch the PM, SIGSTOP its runner from outside the namespace (a
// stopped process handles no signal but SIGKILL), close the host, and time
// it. Imports from the review worktree by absolute path.
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
const WT = process.env.WT;
const { bootConfig, loadSystem } = await import(`${WT}/packages/cli/src/config.mjs`);
const { startHost, startTimeOf } = await import(`${WT}/packages/cli/src/host.mjs`);
const { createLauncher } = await import(`${WT}/packages/cli/src/launcher.mjs`);
const { readSessions, launchLogFile } = await import(`${WT}/packages/seat/src/session.mjs`);
const { fixture } = await import(`${WT}/packages/cli/tests/helpers.mjs`);
const root = mkdtempSync(join(tmpdir(), "r41-kill-"));
const f = fixture(root, "acme", (doc) => {
doc.roles.coder.vars.model = "claude-sonnet-5-5";
doc.launch = { by: "pm", instances: ["coder"], max: { opus: 1, sonnet: 1 } };
return doc;
});
mkdirSync(f.dataRoot, { mode: 0o700 });
const adapter = join(root, "adapter.sh");
writeFileSync(adapter, `exec '${process.execPath}' '${WT}/packages/harness/tests/fixtures/fake-adapter.mjs'\n`);
const system = loadSystem({ env: f.env });
const logs = [];
const host = await startHost({ boot: bootConfig({ system, businessId: "acme", env: f.env }), business: "acme", log: (l) => logs.push(`host: ${l}`) });
const launcher = createLauncher({ host, system, env: f.env, adapters: { pi: adapter, "claude-code": adapter }, sessionDefaults: { pollInterval: 100 }, versions: { pi: "0.85.1", claude: null }, log: (l) => logs.push(`launcher: ${l}`) });
await launcher.listen();
const pm = await launcher.launchPm();
const [s] = readSessions(f.dataRoot);
const runnerLog = join(f.dataRoot, "launches", "acme", pm.run, "runner.log");
for (let i = 0; i < 300 && !readFileSync(runnerLog, "utf8").includes("claimed by run"); i++) await new Promise((r) => setTimeout(r, 50));
console.log(`pm run ${pm.run}: session pid ${s.pid}, runner pid ${s.runnerPid}, claimed`);
process.kill(s.runnerPid, "SIGSTOP");
console.log("runner SIGSTOPped");
const t0 = Date.now();
const code = await host.close(0);
console.log(`host.close -> ${code} after ${Date.now() - t0} ms`);
console.log(`session pid alive: ${startTimeOf(s.pid) === s.startTime}; runner pid alive: ${startTimeOf(s.runnerPid) === s.runnerStartTime}`);
const ends = readFileSync(launchLogFile(f.dataRoot, "acme"), "utf8").trim().split("\n").map((l) => JSON.parse(l)).filter((e) => e.event === "end");
console.log("launch-log end lines:", JSON.stringify(ends.map((e) => ({ run: e.run, reason: e.reason, exitCode: e.exitCode, signal: e.signal }))));
console.log("sessions.json after:", JSON.stringify(readSessions(f.dataRoot)));
console.log(logs.filter((l) => /ended|run /.test(l)).join("\n"));
rmSync(root, { recursive: true, force: true });
@@ -0,0 +1,7 @@
pm run r497e2e1c6477: session pid 316699, runner pid 316700, claimed
runner SIGSTOPped
host.close -> 0 after 30033 ms
session pid alive: false; runner pid alive: false
launch-log end lines: [{"run":"r497e2e1c6477","reason":"killed","exitCode":null,"signal":"SIGKILL"}]
sessions.json after: []
launcher: run r497e2e1c6477 (pm) ended: killed
@@ -0,0 +1,27 @@
// A dangling symlink in the workspace that points outside it. decide() walks
// up to the nearest existing ancestor with existsSync, which follows links,
// so the link itself counts as "missing" and the path passes. Then the same
// two calls pi's write tool makes (core/tools/write.js:45-48: mkdir of the
// dirname, recursive, then writeFile) create the file outside.
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync } from "node:fs";
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { tmpdir } from "node:os";
const { decide } = await import(`${process.env.WT}/packages/harness/src/gate.mjs`);
const root = mkdtempSync(join(tmpdir(), "r41-dangle-"));
const ws = join(root, "ws");
const outside = join(root, "outside", "planted.txt");
mkdirSync(dirname(outside), { recursive: true });
mkdirSync(ws);
symlinkSync(outside, join(ws, "notes.md")); // as a checked-out repo could carry it
for (const harness of ["pi", "claude-code"]) {
const policy = { harness, workspace: ws, tools: ["write", "edit"], typed: [] };
const [tool, field] = harness === "pi" ? ["write", "path"] : ["Write", "file_path"];
console.log(`${harness} ${tool} {${field}: "notes.md"}:`, JSON.stringify(decide(policy, tool, { [field]: harness === "pi" ? "notes.md" : join(ws, "notes.md") })));
}
console.log("outside file exists before:", existsSync(outside));
const target = join(ws, "notes.md");
await mkdir(dirname(target), { recursive: true });
await writeFile(target, "written through the gate\n", "utf-8");
console.log("outside file after pi's write calls:", existsSync(outside) ? JSON.stringify(readFileSync(outside, "utf8")) : "absent");
rmSync(root, { recursive: true, force: true });
@@ -0,0 +1,4 @@
pi write {path: "notes.md"}: {"allow":true}
claude-code Write {file_path: "notes.md"}: {"allow":true}
outside file exists before: false
outside file after pi's write calls: "written through the gate\n"
@@ -0,0 +1,69 @@
// R1: host->broker IPC replies carry no id. Stall the broker past the 10 s
// firstReply wait with two requests queued, resume it, and see which reply
// each request gets. Imports from the review worktree by absolute path.
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
const WT = process.env.WT;
const { Client } = await import(`${WT}/packages/bus/src/client.mjs`);
const { bootConfig, loadSystem } = await import(`${WT}/packages/cli/src/config.mjs`);
const { startHost, startTimeOf } = await import(`${WT}/packages/cli/src/host.mjs`);
const { fixture } = await import(`${WT}/packages/cli/tests/helpers.mjs`);
const root = mkdtempSync(join(tmpdir(), "r41-desync-"));
const f = fixture(root, "acme", (doc) => {
doc.launch = { by: "pm", instances: ["coder", "reviewer", "cto"], max: { opus: 1, sonnet: 1 } };
return doc;
});
mkdirSync(f.dataRoot, { mode: 0o700 });
const system = loadSystem({ env: f.env });
const boot = bootConfig({ system, businessId: "acme", env: f.env });
const host = await startHost({ boot, business: "acme", log: (l) => console.log("host:", l) });
const bind = async (role, run) => {
const b = await host.bindLaunch({ business: "acme", role, run, harness: "pi", pid: process.pid, startTime: startTimeOf(process.pid) });
await new Client({ path: host.path, cap: b.cap }).call("role.claim");
return b.cap;
};
const pm = await bind("pm", "pm-run");
const cto = await bind("cto", "cto-run");
const show = (p) => p.then((v) => ({ ok: v }), (e) => ({ err: e.code ?? e.message }));
console.log("baseline, no stall:");
console.log(" authorizeLaunch(pm, coder):", JSON.stringify(await show(host.op({ op: "authorizeLaunch", cap: pm, instance: "coder" }))));
console.log(" authorizeLaunch(cto, coder):", JSON.stringify(await show(host.op({ op: "authorizeLaunch", cap: cto, instance: "coder" }))));
console.log(" identity(bogus):", JSON.stringify(await show(host.op({ op: "identity", cap: "bogus" }))));
console.log(`SIGSTOP broker ${host.pids.broker}`);
process.kill(host.pids.broker, "SIGSTOP");
const t0 = Date.now();
const p1 = show(host.op({ op: "authorizeLaunch", cap: pm, instance: "coder" }));
const p2 = show(host.op({ op: "authorizeLaunch", cap: cto, instance: "coder" }));
const p3 = show(host.op({ op: "identity", cap: "bogus" }));
const r1 = await p1;
console.log(`p1 authorizeLaunch(pm) after ${Date.now() - t0} ms:`, JSON.stringify(r1));
process.kill(host.pids.broker, "SIGCONT");
console.log("SIGCONT");
console.log("p2 authorizeLaunch(cto), should refuse:", JSON.stringify(await p2));
console.log("p3 identity(bogus), should refuse:", JSON.stringify(await p3));
const p4 = await show(host.op({ op: "identity", cap: cto }));
console.log("p4 identity(cto), should be cto:", JSON.stringify(p4));
await new Promise((r) => setTimeout(r, 500));
console.log("--- bind case: two binds queued during a stall");
process.kill(host.pids.broker, "SIGSTOP");
const rec = (run) => ({ business: "acme", role: "coder", run, harness: "pi", pid: process.pid, startTime: startTimeOf(process.pid) });
const b1 = show(host.bindLaunch(rec("run-x")));
const b2 = show(host.bindLaunch({ ...rec("run-y"), role: "reviewer" }));
console.log("b1 bind run-x:", JSON.stringify(await b1));
process.kill(host.pids.broker, "SIGCONT");
const r2 = await b2;
console.log("b2 bind run-y (reviewer) got:", r2.ok ? JSON.stringify({ run: r2.ok.run, role: r2.ok.role }) : JSON.stringify(r2));
if (r2.ok?.cap) {
const who = await show(new Client({ path: host.path, cap: r2.ok.cap }).call("role.claim"));
console.log("claim with b2's cap:", JSON.stringify(who));
}
await new Promise((r) => setTimeout(r, 3000));
console.log("after 3 s idle, identity(pm), should be pm:", JSON.stringify(await show(host.op({ op: "identity", cap: pm }))));
console.log("then identity(pm) again:", JSON.stringify(await show(host.op({ op: "identity", cap: pm }))));
await host.close(0);
rmSync(root, { recursive: true, force: true });
@@ -0,0 +1,16 @@
baseline, no stall:
authorizeLaunch(pm, coder): {"ok":{"class":"within-role"}}
authorizeLaunch(cto, coder): {"err":"decision-required"}
identity(bogus): {"err":"unauthenticated"}
SIGSTOP broker 86383
p1 authorizeLaunch(pm) after 10024 ms: {"err":"broker did not reply within 10 s"}
SIGCONT
p2 authorizeLaunch(cto), should refuse: {"ok":{"class":"within-role"}}
p3 identity(bogus), should refuse: {"err":"decision-required"}
p4 identity(cto), should be cto: {"err":"unauthenticated"}
--- bind case: two binds queued during a stall
b1 bind run-x: {}
b2 bind run-y (reviewer) got: {"run":"run-x"}
claim with b2's cap: {"ok":{"role":"coder","run":"run-x"}}
after 3 s idle, identity(pm), should be pm: {}
then identity(pm) again: {"ok":{"business":"acme","role":"pm","run":"pm-run","human":null}}
@@ -0,0 +1,28 @@
// decide() on path spellings that might leave the workspace.
import { mkdirSync, mkdtempSync, symlinkSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
const { decide } = await import(`${process.env.WT}/packages/harness/src/gate.mjs`);
const root = mkdtempSync(join(tmpdir(), "r41-gate-"));
const ws = join(root, "ws");
mkdirSync(join(ws, "sub"), { recursive: true });
symlinkSync("/etc", join(ws, "etc-link"));
symlinkSync(join(root, "not-yet"), join(ws, "dangling"));
const pi = { harness: "pi", workspace: ws, tools: ["read", "write", "grep", "find", "ls"], typed: ["message.send"] };
const cc = { ...pi, harness: "claude-code" };
const cases = [
[pi, "read", { path: "sub/../../x" }], [pi, "read", { path: "@/etc/passwd" }], [pi, "read", { path: "~/x" }],
[pi, "read", { path: "file:///etc/passwd" }], [pi, "read", { path: " /etc/passwd" }], [pi, "read", { path: "etc-link/passwd" }],
[pi, "write", { path: "dangling/x" }], [pi, "read", { path: "sub/./x" }], [pi, "grep", { path: "..", pattern: "x" }],
[pi, "find", { pattern: "{..,x}/*" }], [pi, "find", { pattern: "*", path: "/" }], [pi, "ls", {}],
[pi, "bash", { command: "cat /etc/passwd" }], [pi, "message.send", {}],
[cc, "Read", { file_path: "/etc/passwd" }], [cc, "Read", { file_path: `${ws}/sub/x` }], [cc, "Glob", { pattern: "/etc/*" }],
[cc, "Glob", { pattern: "**/x", path: "/" }], [cc, "Grep", { pattern: "x", path: "/etc" }], [cc, "Grep", { pattern: "x", glob: "../*" }],
[cc, "Bash", { command: "id" }], [cc, "WebFetch", { url: "http://x" }], [cc, "mcp__mosaic__message.send", {}],
[cc, "mcp__other__message.send", {}], [cc, "message.send", {}], [cc, "Read", { file_path: 7 }],
];
for (const [p, tool, input] of cases) {
const d = decide(p, tool, input);
console.log(`${p.harness.padEnd(11)} ${tool.padEnd(28)} ${JSON.stringify(input).padEnd(40)} ${d.allow ? "ALLOW" : "deny: " + d.reason.replace("mosaic gate: ", "")}`);
}
rmSync(root, { recursive: true, force: true });
@@ -0,0 +1,26 @@
pi read {"path":"sub/../../x"} deny: read path is outside the workspace: sub/../../x
pi read {"path":"@/etc/passwd"} deny: read path is outside the workspace: @/etc/passwd
pi read {"path":"~/x"} deny: read path is outside the workspace: ~/x
pi read {"path":"file:///etc/passwd"} deny: read path is outside the workspace: file:///etc/passwd
pi read {"path":" /etc/passwd"} ALLOW
pi read {"path":"etc-link/passwd"} deny: read path is outside the workspace: etc-link/passwd
pi write {"path":"dangling/x"} ALLOW
pi read {"path":"sub/./x"} ALLOW
pi grep {"path":"..","pattern":"x"} deny: grep path is outside the workspace: ..
pi find {"pattern":"{..,x}/*"} ALLOW
pi find {"pattern":"*","path":"/"} deny: find path is outside the workspace: /
pi ls {} ALLOW
pi bash {"command":"cat /etc/passwd"} deny: bash isn't in this session's policy
pi message.send {} ALLOW
claude-code Read {"file_path":"/etc/passwd"} deny: Read path is outside the workspace: /etc/passwd
claude-code Read {"file_path":"/home/jwoltje/darkwing-scratch/tmp/r41-gate-1f41z7/ws/sub/x"} ALLOW
claude-code Glob {"pattern":"/etc/*"} deny: Glob pattern must stay inside the workspace: /etc/*
claude-code Glob {"pattern":"**/x","path":"/"} deny: Glob path is outside the workspace: /
claude-code Grep {"pattern":"x","path":"/etc"} deny: Grep path is outside the workspace: /etc
claude-code Grep {"pattern":"x","glob":"../*"} ALLOW
claude-code Bash {"command":"id"} deny: Bash isn't in this session's policy
claude-code WebFetch {"url":"http://x"} deny: WebFetch isn't in this session's policy
claude-code mcp__mosaic__message.send {} ALLOW
claude-code mcp__other__message.send {} deny: mcp__other__message.send isn't in this session's policy
claude-code message.send {} deny: message.send isn't in this session's policy
claude-code Read {"file_path":7} deny: Read.file_path isn't a string
@@ -0,0 +1,45 @@
// A mock Messages API on 127.0.0.1: logs each request body to LOG and
// streams back one short text answer. No real model is reached.
import { createServer } from "node:http";
import { appendFileSync } from "node:fs";
const LOG = process.argv[2];
// TOOL_USE: optional JSON {name, input}; the first streamed answer calls it.
let toolUse = process.env.TOOL_USE ? JSON.parse(process.env.TOOL_USE) : null;
const sse = (res, ev, data) => res.write(`event: ${ev}\ndata: ${JSON.stringify(data)}\n\n`);
const server = createServer((req, res) => {
let body = "";
req.on("data", (b) => (body += b));
req.on("end", () => {
appendFileSync(LOG, `${JSON.stringify({ method: req.method, url: req.url, body })}\n`);
if (req.method !== "POST" || !req.url.startsWith("/v1/messages") || req.url.includes("count_tokens")) {
res.writeHead(200, { "content-type": "application/json" });
return res.end(req.url.includes("count_tokens") ? '{"input_tokens":1}' : "{}");
}
let stream = false;
try { stream = JSON.parse(body).stream === true; } catch {}
const msg = { id: "msg_mock", type: "message", role: "assistant", model: "claude-sonnet-5-5", content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 1, output_tokens: 1 } };
if (!stream) {
res.writeHead(200, { "content-type": "application/json" });
return res.end(JSON.stringify({ ...msg, content: [{ type: "text", text: "mock answer" }], stop_reason: "end_turn" }));
}
res.writeHead(200, { "content-type": "text/event-stream" });
sse(res, "message_start", { type: "message_start", message: msg });
if (toolUse) {
const t = toolUse;
toolUse = null;
sse(res, "content_block_start", { type: "content_block_start", index: 0, content_block: { type: "tool_use", id: "toolu_mock1", name: t.name, input: {} } });
sse(res, "content_block_delta", { type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: JSON.stringify(t.input) } });
sse(res, "content_block_stop", { type: "content_block_stop", index: 0 });
sse(res, "message_delta", { type: "message_delta", delta: { stop_reason: "tool_use", stop_sequence: null }, usage: { output_tokens: 2 } });
sse(res, "message_stop", { type: "message_stop" });
return res.end();
}
sse(res, "content_block_start", { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } });
sse(res, "content_block_delta", { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "mock answer" } });
sse(res, "content_block_stop", { type: "content_block_stop", index: 0 });
sse(res, "message_delta", { type: "message_delta", delta: { stop_reason: "end_turn", stop_sequence: null }, usage: { output_tokens: 2 } });
sse(res, "message_stop", { type: "message_stop" });
res.end();
});
});
server.listen(0, "127.0.0.1", () => console.log(server.address().port));
+41
View File
@@ -0,0 +1,41 @@
#!/bin/sh
# R3 through a real Pi 0.85.1 session: the candidate adapters/pi/adapter.sh,
# the candidate pi-extension.mjs (the gate) loaded with -e, --tools write,
# and a mock Messages API on 127.0.0.1 whose first answer calls write on a
# dangling symlink in the workspace. Dummy key; no real model.
set -eu
WT=$1; OUT=$2
R=$(mktemp -d "$TMPDIR/r41-pdangle-XXXX")
mkdir -p "$R/home" "$R/agent" "$R/ws" "$R/bundle" "$R/outside"
ln -s "$R/outside/planted.txt" "$R/ws/notes.md"
# MODE=existing: the control, the link target exists, so the link isn't dangling.
if [ "${MODE:-dangling}" = existing ]; then echo "was here before" > "$R/outside/planted.txt"; fi
echo "== MODE=${MODE:-dangling}" | tee -a "$OUT"
echo "the generated prompt" > "$R/bundle/prompt.md"
printf '{"harness":"pi","workspace":"%s","tools":["write"],"typed":[]}\n' "$R/ws" > "$R/bundle/policy.json"
echo '[]' > "$R/bundle/tools.json"
TOOL_USE='{"name":"write","input":{"path":"notes.md","content":"written through the gate\n"}}' \
node "$(dirname "$0")/mock-api.mjs" "$R/api.log" > "$R/port" & MOCK=$!
while [ ! -s "$R/port" ]; do sleep 0.1; done
printf '{"providers":{"mock":{"baseUrl":"http://127.0.0.1:%s","api":"anthropic-messages","apiKey":"dummy-not-a-key","models":[{"id":"claude-sonnet-5-5"}]}}}\n' "$(cat "$R/port")" > "$R/agent/models.json"
set +e
env -i PATH="$WT/node_modules/.bin:$PATH" HOME="$R/home" USER="$USER" LANG=C.UTF-8 \
PI_CODING_AGENT_DIR="$R/agent" PI_PROVIDER=mock PI_MODEL=claude-sonnet-5-5 \
MOSAIC_SYSTEM_PROMPT_FILE="$R/bundle/prompt.md" MOSAIC_REQUEST="Message m1 from jason: write notes" \
MOSAIC_WORKSPACE="$R/ws" MOSAIC_TOOLS=write \
MOSAIC_EXTENSIONS="$WT/packages/harness/src/pi-extension.mjs" \
MOSAIC_POLICY_FILE="$R/bundle/policy.json" MOSAIC_TOOLS_FILE="$R/bundle/tools.json" \
MOSAIC_TOOL_SOCKET="$R/no-socket" MOSAIC_TURN_MARKER="$R/marker" \
timeout 90 /bin/sh "$WT/adapters/pi/adapter.sh" < /dev/null > "$R/stdout" 2> "$R/stderr"
echo "exit $?" > "$R/exit"
kill $MOCK
{
echo "$(cat "$R/exit"); stdout: $(head -c 200 "$R/stdout"); stderr: $(head -c 300 "$R/stderr")"
echo "POST /v1/messages: $(grep -c '"url":"/v1/messages' "$R/api.log")"
echo "tool_result sent back: $(node -e '
const ls=require("fs").readFileSync(process.argv[1],"utf8").trim().split("\n").map(l=>JSON.parse(l)).filter(l=>l.url.startsWith("/v1/messages"));
const b=JSON.parse(ls.at(-1).body); const r=b.messages.flatMap(m=>Array.isArray(m.content)?m.content:[]).filter(c=>c.type==="tool_result");
console.log(JSON.stringify(r.map(c=>({is_error:c.is_error??false,content:c.content}))));' "$R/api.log")"
echo "turn marker: $( [ -e "$R/marker" ] && echo written || echo absent)"
echo "outside file: $( [ -e "$R/outside/planted.txt" ] && cat "$R/outside/planted.txt" || echo absent)"
} | tee -a "$OUT"
@@ -0,0 +1,12 @@
== MODE=dangling
exit 0; stdout: mock answer; stderr:
POST /v1/messages: 2
tool_result sent back: [{"is_error":false,"content":"Successfully wrote to notes.md"}]
turn marker: written
outside file: written through the gate
== MODE=existing
exit 0; stdout: mock answer; stderr:
POST /v1/messages: 2
tool_result sent back: [{"is_error":true,"content":"mosaic gate: write path is outside the workspace: notes.md"}]
turn marker: written
outside file: was here before
+351
View File
@@ -0,0 +1,351 @@
# Row 41, slice 1 S6 (meta-harness and launching), round 1 review (Darkwing)
Issue #1523, request comment 26991, queue revs 244 and 245 (`c7a2c703`).
Packet: `agents/filbert/work/s6/` at `9b670e27`, base `915e00e5`, gate at
`2855e628`, 41 files, +4302/−55. Candidate manifest sha256
`5b3a934d01eb518e23b842623aeb3cbfc287f1b88110edc1e044d4131b2927f3`,
`build.patch` sha256
`dc346027db9b650c70b57ff390b57a8d76d3e2ccd434538cb083fe9a97ea4454`.
Rulings: lead decisions 77 and 78.
Verdict: **changes**, comment 26995. Two findings block. R1: a broker
reply that misses the host's 10 s wait shifts every later reply by one, so a
refused `authorizeLaunch` comes back allowed and one run's capability goes
to another. R3: a dangling symlink in the workspace passes the gate, and a
real Pi session writes through it to a file outside. R2 should be fixed in
the same round: `launcher.close()` waits on any `launch.sock` client that
keeps its side open, so the runners never get their SIGTERM. The rest of
the brief holds under my probes and mutants.
## Method
- A detached worktree at `2855e628`, then `git apply --index build.patch`
and `sha256sum -c candidate-manifest.sha256`: 41 OK. After the mutants
and probes I checked again: 41 OK, and `git diff --name-only` empty.
- I read the diff for the five places Filbert named, plus `gate.mjs`,
`claude-gate.mjs`, `pi-extension.mjs`, `session.mjs` and the READMEs,
against the S6 brief, the S0 lines and decisions 77 and 78.
- Probes in `probe/`. They import from my scratch worktree by absolute
path (`WT`), so they won't run as committed without setting it. The
model calls go to `probe/mock-api.mjs`, a mock Messages API on
127.0.0.1 with a dummy key; no real model was reached and nothing was
spent. Claude Code is 2.1.296, Pi is the pinned 0.85.1.
- Fourteen mutants of my own (`mut/mutate.py`, `mut/run.sh`), each run
against the node suites of the packages it touches, then restored.
Node is v26.8.1, `TMPDIR=~/darkwing-scratch/tmp`,
`DOCKER_HOST=unix:///nonexistent.sock`.
## Suites
Run sequentially in the worktree, each output in `out/`.
| Suite | Result | File |
|---|---|---|
| `node --test 'packages/harness/tests/*.test.mjs'` | 45/45 | `out/node-harness.txt` |
| `node --test 'packages/seat/tests/*.test.mjs'` | 27/27 | `out/node-seat.txt` |
| `node --test 'packages/cli/tests/*.test.mjs'` | 77/77 | `out/node-cli.txt` |
| `node --test 'packages/bus/tests/*.test.mjs'` | 74/74 | `out/node-bus.txt` |
| `node --test 'packages/business/tests/*.test.mjs'` | 60/60 | `out/node-business.txt` |
| `test-auth`, `test-config`, `test-conductor` | 15, 24, 17 passed, 0 failed | `out/test-*.txt` |
| `test-queue`, `test-foundation`, `test-extension-package` | 27, 44, 18 passed, 0 failed | |
| `test-release`, `test-discord` | 4, 66 passed, 0 failed | |
| `test-task` | 26 passed, 2 failed | `out/test-task.txt` |
The two `test-task` failures are the live user-recall pair, which needs
Docker. They match Filbert's base run. Filbert's gate covers the other
node suites.
## R1: broker replies carry no id, and a late one shifts every later reply (blocking)
`host.mjs:157-171`. `request()` sends one message to the broker child and
takes the first IPC message that arrives within 10 s (`firstReply`) as the
answer. The comment at `:155` says the replies carry no request id, so
requests go one at a time. That holds only while every reply arrives
inside the wait. When one doesn't, `firstReply` rejects and the queue
moves on, but the broker still answers. That late answer is the first
message the next request sees. From then on every request gets the reply
to the one before it.
`probe/desync.mjs` starts a real host, binds a `pm` and a `cto` run, and
stalls the broker with SIGSTOP while three requests wait
(`probe/desync.txt`):
```
baseline, no stall:
authorizeLaunch(pm, coder): {"ok":{"class":"within-role"}}
authorizeLaunch(cto, coder): {"err":"decision-required"}
identity(bogus): {"err":"unauthenticated"}
p1 authorizeLaunch(pm) after 10024 ms: {"err":"broker did not reply within 10 s"}
p2 authorizeLaunch(cto), should refuse: {"ok":{"class":"within-role"}}
p3 identity(bogus), should refuse: {"err":"decision-required"}
p4 identity(cto), should be cto: {"err":"unauthenticated"}
--- bind case: two binds queued during a stall
b1 bind run-x: {}
b2 bind run-y (reviewer) got: {"run":"run-x"}
claim with b2's cap: {"ok":{"role":"coder","run":"run-x"}}
after 3 s idle, identity(pm), should be pm: {}
then identity(pm) again: {"ok":{"business":"acme","role":"pm","run":"pm-run","human":null}}
```
The `cto` instance needs a decision to launch a coder, and after the stall
it is allowed. In the bind case the reviewer launch `run-y` receives
`run-x`'s capability, and a `role.claim` with it acts as `coder`, run
`run-x`. In the launcher that capability goes on the new runner's stdin
(`launcher.mjs:217`), so the reviewer process would run as the coder. The
shift doesn't heal with idle time: 3 s later `identity(pm)` still gets the
bind reply (`{}`, no `result`). The last line looks right only because it
asks the same question as the one before it.
This fails open at the boundary decision 77 names. The trigger in my probe
is SIGSTOP. Without it, anything that holds the broker past 10 s does the
same: a slow disk under a checkpoint, swap, or a same-UID process that asks
the systemd user manager to stop it (the README's own limit). I didn't find
a path inside the broker alone that blocks for 10 s; sqlite's busy timeout
is 5 s per statement. The pattern itself came with `bindLaunch` in S4
(`2f5303c1`), but nothing called it until this row, which now routes all
six launch ops through it.
Either fix works:
1. Put an id on each request, echo it in `process.mjs`, and have
`firstReply` ignore (and log) a reply whose id doesn't match. That
needs a test that stalls the broker past the wait and checks the next
request's answer.
2. Treat a `firstReply` timeout on the broker as fatal to the channel:
close the host with exit 1, and the unit restarts it. Simpler, and the
launches in flight fail closed.
## R2: `launcher.close()` waits on any `launch.sock` client (should fix)
`launcher.mjs:377-381`. `close()` awaits `server.close(r)`, which waits
until every connection on `launch.sock` is gone. The reply path
(`:343`) calls `socket.end()`, which sends FIN but doesn't destroy the
socket; a client that keeps its write side open keeps the connection, and
`close()` waits. The SIGTERM to the runners comes after that line, so they
get none.
`probe/close-hang.mjs` connects with `allowHalfOpen` and never ends its
side (`probe/close-hang.txt`):
```
== MODE=silent
host.close(0) at 201 ms
client got {"ok":false,"error":"invalid-request"} at 10050 ms
client got FIN at 10050 ms (not ending its side)
still not closed at 25227 ms
client destroyed; closed 0 at 25240 ms
== MODE=line
client got {"ok":false,"error":"unauthenticated"} at 2 ms
client got FIN at 2 ms (not ending its side)
host.close(0) at 201 ms
still not closed at 25227 ms
client destroyed; closed 0 at 25246 ms
```
The host closes only when the client goes away. A default Node client
would end its side on FIN, so this takes a client that doesn't: a stopped
or hung process, or a session that does it on purpose. The socket is 0600
and same UID, and a session with `bash` can reach it. Then `bus stop`
waits until systemd's stop timeout kills the host, and the runners die by
SIGKILL with no `role.release` and no end record from this host. Filbert
asked about the 30 s close. The 30 s SIGKILL timer starts only after this
wait, so it doesn't bound it.
Fix: keep the accepted sockets in a set and destroy them in `close()`
before awaiting `server.close`, or `destroySoon()` after the reply. A test
with a half-open client would cover it.
## R3: a dangling symlink in the workspace passes the gate (blocking for Pi)
`gate.mjs:45-55`. `real()` walks up from the target until `existsSync`
returns true, then realpaths that ancestor and keeps the missing tail.
`existsSync` follows links, so a symlink whose target doesn't exist counts
as missing. Its name stays in the tail, under the workspace root, and
`insideWorkspace` says yes. The harness README says every path "must
resolve inside the workspace after symlinks"; for a dangling link it
doesn't.
The gate on its own (`probe/dangling-write.mjs`, `dangling-write.txt`),
with `ws/notes.md -> ../outside/planted.txt` and the target absent:
```
pi write {path: "notes.md"}: {"allow":true}
claude-code Write {file_path: "notes.md"}: {"allow":true}
outside file exists before: false
outside file after pi's write calls: "written through the gate\n"
```
The last line repeats the two calls Pi's write tool makes
(`core/tools/write.js:45-48`: a recursive `mkdir` of the dirname, then
`writeFile`).
Then a real Pi 0.85.1 session (`probe/pi-dangling.sh`,
`pi-dangling.txt`): the candidate `adapters/pi/adapter.sh` with the
candidate `pi-extension.mjs`, `--tools write`, and a mock API whose first
answer calls `write` on `notes.md`. The control run gives the link a target
that exists.
```
== MODE=dangling
exit 0; stdout: mock answer; stderr:
POST /v1/messages: 2
tool_result sent back: [{"is_error":false,"content":"Successfully wrote to notes.md"}]
turn marker: written
outside file: written through the gate
== MODE=existing
exit 0; stdout: mock answer; stderr:
POST /v1/messages: 2
tool_result sent back: [{"is_error":true,"content":"mosaic gate: write path is outside the workspace: notes.md"}]
turn marker: written
outside file: was here before
```
The gate refuses the link when its target exists. Remove the target and
the same write lands outside the workspace.
Claude Code through the candidate adapter and the real `claude-gate.mjs`
(`probe/claude-dangling.sh`, `claude-dangling.txt`): the gate allowed the
`Write`, and Claude Code refused it itself.
```
tool_result sent back: [{"is_error":true,"content":".../ws/notes.md resolves through a symlink to .../outside/planted.txt, which is outside .../ws; --restricted confines the file tools to the working directory."}]
outside file: absent
```
So Claude Code is held by `--restricted`, which the README calls an extra
layer the S0 lines don't depend on. Pi has no second layer.
The link doesn't have to come from the session. Workspaces persist per
instance (`workspaceDir(dataRoot, business, instance)`), so a checked-out
repository can carry one, and so can an earlier session of the same
instance whose role had `bash`. A link under a missing directory
(`dangling/x` in `probe/gate-edges.txt`) passes the same way.
Fix: in `real()`, `lstat` each component that doesn't exist. If it is a
symlink, either refuse the path or `readlink` it, resolve the target
against its directory and keep walking. Add a gate test for a dangling
link and for a link under a missing directory, for both harnesses.
## What holds
- **Early signals.** `runner.mjs:256-257` installs SIGTERM and SIGINT
before anything reads stdin or the session file. Mutant Ma moves them
after setup and fails three harness tests and two cli tests.
- **Stdin cap.** `runner.mjs:78` refuses more than 4096 bytes. Mutant Mj (no cap) survives; see note 5.
- **Exit codes 20-23.** The runner tests cover each. Mutant Mg (no
founder variable check) fails the `founderCheck` unit test, and the runner test that expects
exit 20 hangs instead; I killed it after four minutes.
- **Launcher check order.** identity, not-a-role, instance-not-listed,
instance-running, harness and role contract, model family, capacity,
then the broker's `authorizeLaunch`, all before `start`. Mutant Md
(no instance-running check) fails two cli tests, Me (no
`authorizeLaunch`) fails one.
- **`recover()`.** It SIGKILLs a live leftover whose start time matches and
whose cmdline is `unshare`, rebinds the run with its recorded launch and
ends it `host-lost`; a rebind refused with `run-ended` is logged and
skipped. Mutant Mn (no `endRun`) fails both host-died-hard tests, and so
does Mc. Mutant Mi (no SIGKILL) survives; see note 5.
- **The 30 s close.** `probe/close-kill.mjs` SIGSTOPs a claimed PM's
runner from outside the namespace and closes the host
(`probe/close-kill.txt`): `host.close -> 0 after 30033 ms`, the session
and runner are both gone, the launch log has one end line
(`killed`, `SIGKILL`) and `sessions.json` is empty. Mutant Mh (no
SIGKILL) survives, so only the probe shows it.
- **Broker.** `bindLaunch` records the run only after its checks, and a
rebind of an ended run refuses with `run-ended`
(`broker.mjs:160-193`, `:203`). Mutant Mb (record first) and Mc (no
`run-ended`) each fail "a restarted broker refuses to rebind an ended
run; a refused rebind leaves the run unbound". Mutant Mm (no `launch-revoked`) fails "launches off refuses
role.launch with launch-revoked until launches on". No new socket verb,
event kind or schema change: the new ops are in `process.mjs`'s
`LAUNCH_OPS` only.
- **Claude Code hook.** The bundle writes
`timeout -k 2 10 <gate> <policy> || exit 2` with hook timeout 20, and
the adapter never passes `--bare`. That matches S0 lines 1-4. Mutant Mf
(no `|| exit 2`) fails the bundle test.
- **Gate spellings.** Every escape I tried is denied except the dangling
link (`probe/gate-edges.txt`): `sub/../../x`, `@/etc/passwd`, `~/x`,
`file:///etc/passwd`, a symlink to `/etc`, absolute paths, `Glob /etc/*`,
`Glob` with path `/`, `Grep /etc`, `Bash`, `WebFetch`, a foreign `mcp__`
prefix, the bare typed name for Claude Code, and a non-string path. Pi
`read " /etc/passwd"` is allowed, and Pi resolves it the same way, as a
relative path under the workspace. `find "{..,x}/*"` and `Grep` glob
`../*` are allowed, and they're harmless: fd and rg filter files under
the search root and can't leave it. Mutant Ml (no `..` check on glob patterns) fails "glob
patterns stay inside the workspace".
- **`--no-approve`.** Both `adapters/pi/adapter.sh` and
`scripts/agent-host-dev.sh` pass it, which matches Filbert's table.
- **Adapter stdin** is `/dev/null` (the runner spawns it with
`["ignore", "pipe", errFd]`), and the session environment is the
`ENV_ALLOW` list.
## Mutants
`mut/summary.txt` has the raw lines; `mut/parse.sh` reads the outputs.
| Mutant | Change | Result |
|---|---|---|
| Ma | signal handlers installed after setup | harness 42/3, cli 75/2 |
| Mb | broker records the run before its checks | bus 73/1, cli 77/0 |
| Mc | no `run-ended` refusal on rebind | bus 73/1, cli 75/2 |
| Md | no instance-running check | cli 75/2 |
| Me | no `authorizeLaunch` before start | cli 76/1 |
| Mf | hook command without `\|\| exit 2` | harness 44/1 |
| Mg | `founderCheck` finds no founder variables | harness: unit test fails, runner test hangs (killed); cli 77/0 |
| Mh | no SIGKILL after 30 s in `close()` | cli 77/0, **survives** |
| Mi | no SIGKILL in `recover()` | cli 77/0, **survives** |
| Mj | no 4096-byte stdin cap in the runner | harness 45/0, **survives** |
| Mk | no `LINE_MAX` on `launch.sock` | cli 77/0, **survives** |
| Ml | no `..` check on glob patterns | harness 44/1 |
| Mm | no `launch-revoked` refusal | bus 73/1, cli 77/0 |
| Mn | `recover()` doesn't end the run | cli 75/2 |
## Notes (not blocking)
1. **`--restricted` does more than the README says.** `probe/claude-memory.sh`
plants marker lines in `~/.claude/CLAUDE.md`, a parent `CLAUDE.md`, the
workspace `CLAUDE.md` and the auto-memory file, and runs the candidate
adapter against the mock API (`probe/claude-memory.txt`). With
`--restricted`, none of the four markers reaches the request and the
generated prompt does. Without it, all four reach it. So `--restricted`
is what keeps founder and repository memory out of the session's
prompt, and with R3 it is the layer that holds for Claude Code. The
README ("no test treats it as the layer that holds") and the adapter
comment should say so, and a test should fail if the adapter drops the
flag.
2. **`launchPm` skips `authorizeLaunch`.** `mosaic launches off` doesn't
stop `bus start --pm`. That looks intended (the human launches the PM),
but one README sentence would say so.
3. **A bad policy file is an uncaught rejection.** `runner.mjs:268` reads
and parses the policy outside the `try` at `:260`, so a missing or
malformed one exits 1 with a stack trace instead of the usage exit.
4. **The system prompt is in argv** for both adapters
(`--system-prompt "$PROMPT_CONTENT"`), readable in `/proc/<pid>/cmdline`
by the same UID. The PID namespace hides it from other sessions. It
holds no secret today; noting it for when it might.
5. **Test gaps.** Four mutants survive the suites: Mh, Mi, Mj and Mk
(table above). The code does the right thing in each as far as I
checked (`close-kill.txt` for Mh), but no test would notice it going.
For Mi, the host-died-hard test checks that the old session is gone once
the next host is up, and it is gone even without the kill; I didn't
trace why. A leftover kept alive (a SIGSTOPped runner, as in
`close-kill.mjs`) would separate the two. Mg is caught by the unit test,
but its runner-level test hangs rather than fails; a timeout on it would
turn that into a failure. With R1, R2 and R3 fixed, a test for each
belongs in the same round.
## Files
- `review-r1.md`, this file.
- `out/`: the suite outputs and `summary.txt`.
- `probe/desync.mjs`, `desync.txt`: R1.
- `probe/close-hang.mjs`, `close-hang.txt`: R2.
- `probe/close-kill.mjs`, `close-kill.txt`: the 30 s SIGKILL in `close()`.
- `probe/dangling-write.mjs`, `dangling-write.txt`, `pi-dangling.sh`,
`pi-dangling.txt`, `claude-dangling.sh`, `claude-dangling.txt`: R3.
- `probe/gate-edges.mjs`, `gate-edges.txt`: gate spellings.
- `probe/claude-memory.sh`, `claude-memory.txt`: note 1.
- `probe/mock-api.mjs`: the mock Messages API.
- `mut/`: `mutate.py`, `run.sh`, `all.sh`, `parse.sh`, each mutant's
output and `summary.txt`.