Compare commits

..
105 changed files with 10425 additions and 786 deletions
+10
View File
@@ -5174,3 +5174,13 @@ Before: managed project/thread verbs had no Console conversation area. After: fi
2026-10-20 04:44:54 UTC | Row 178 round 2 preparation | Before: Dewey C1 five render guards lacked killing tests. Added independent refusal fixtures, a temporary uid+1 id stub with cleanup, and empty loopback proof case; activation ordering recorded in r2 BUILD. Product unchanged; syntax/text preflight only, no test or mutant execution. Pending row 181 rebase and Sage slot.
2026-10-20 05:10:34 UTC | Row 178 #1676 round 2 verified | Before: C1 render refusals lacked killing tests and row 181 base was pending. Rebased onto 931651f2d, kept all COUNTED/count code and tests, appended T178 tail; added independent owner/collision/absolute/charset/header and empty-loopback proof tests. Four files singly 156/156, all 24 mutants killed, metadata and sandbox fixture probes pass; scratch sweep empty. Packet agents/rocko/work/queue-178/r2/, candidate 9300a670, inventory d0dcde7b (59/59), scan 58 files no findings; in-review revs 2166-2167, expected no-token request failure. No live actions or commits.
2026-10-20 19:09:44 UTC | Gander row 196 #1694 design phase | Before: tester TCP reaches every local uid and human proofs do not bind the socket peer. After: design draft at agents/gander/work/queue-196/DESIGN.md inventories listeners, proposes private tester HTTP and SO_PEERCRED binding, aligns the per-request principal contract with Morgana and Gizmoduck, and lists migration, tests, mutants and same-uid limits. Python versus other credential options and Console provenance require Duckworth ruling. Documentation only. No product code, suite, live probe, secret access or deployment.
2026-10-20 19:16:20 UTC | Gander row 196 #1694 design r2 | Before: Duckworth r1 requested concurrent helper-fd evidence (B1) and selected O1 with interpreter, proof-only and image conditions. After: design adds eight concurrent genuine proof processes plus a racing copier, M20 swapped socket and post-helper socket survival, root-owned system ELF pinning and activation evidence, safe gid validation, cap traffic without helpers, and unchanged P19 R2 text. Mos accepted socket-only tester access (mos-1015 via sage-1363); any authenticated browser relay is a separate row. Documentation coverage checks passed. No product code, suites or live action.
2026-10-20 19:21:19 UTC | Gander row 196 #1694 design approval and N5 | Before: Duckworth approved r2 (duckworth-0009, cb6d840df) with a nonblocking interpreter-upgrade note. After: r3 chooses startup safety revalidation, appends actual interpreter identity to bus/peercred-starts.jsonl and retains old activation evidence. Operator re-pin uses the next reviewed copy, respecting already-active refusal. Added task-by-task PLAN.md for inline implementation and independent manifest review. Documentation checks passed. No product code, suites or live action.
2026-10-20 19:34:28 UTC | Gander row 196 build paused at design mismatch | Before: approved design promised Unix tester cleanup only for the owned inode. After: scratch test shows Node/libuv close unlinks a replacement inode before JS close handlers. Evidence agents/gander/work/queue-196/evidence/tester-cleanup-blocker.txt. Sent Sage/Duckworth gander-0016/0017, recommending explicit same-uid residual with private 0700 parent retained. Interpreter, concurrent peer, principal, human, peer-transport, transport, tester and process single files passed. Partial code uncommitted, tester transport not wired. No suites, mutant batches or live actions.
2026-10-21 18:45 UTC | Gander row 196 preservation before rebase | Sage-2051, Mos order mos-1193, explicitly authorizes commit and fast-forward push of the current build before deferred full verification. Product and evidence preserved together; prior focused checks and stopped gates are recorded in the row196 packet, with no claim of full acceptance. Next: rebase onto a36523713 on new gander/row196-r2, report range-diff/patch-id/conflicts, then await Sage's clear for M1-M29 and all requested suites. No live action, force push, branch deletion or tests during hold.
-2
View File
@@ -20,8 +20,6 @@ not fleet seats. Keep changes in `/mnt/storage/src/mosaic-stack`; do not modify
| Morgana | Builder, MVP T1 row 193 (human users and RBAC) | T3 session (Claude, Opus 5.5), started by Sage under mos-0974/0977 | none, T3 only |
| Honker | Builder, MVP T1 row 194 (agent lifecycle) | T3 session (Claude, Opus 5.5), started by Sage under mos-0974/0977 | none, T3 only |
| Gander | Builder, the human boundary across uids (P19 333, #1694) | T3 session (Codex, gpt-6.1-sol), started by Sage under mos-0977 | none, T3 only |
| Herb | Builder, a second gate lane (#1695) | T3 session (Codex, gpt-6.1-sol), started by Sage under mos-0977 | none, T3 only |
| Bushroot | Builder, retire the control board writer on 7331 (#1696) | T3 session (Claude, claude-haiku-5-5), started by Sage under mos-1008 | none, T3 only |
| Webby | Reviewer: Case 2 designs, rows 192 and 194 | T3 session (Claude, Opus 5.5, thread 03086006), started by Mos (mos-0981) | none, T3 only |
| Duckworth | Reviewer: federation designs, row 193 | T3 session (Claude, Opus 5.5, thread ffceafec), started by Mos (mos-0977) | none, T3 only |
| Sage | Project lead: coordination, review, integration; earlier DYOR strategy records retained | T3 session (Claude Code); Pi launcher `zai/glm-5.3:high` retained | `agents/sage/launch.sh` |
-8
View File
@@ -1,8 +0,0 @@
# Bushroot
Builder seat, Claude Haiku 5.5, started by Sage under mos-1008 item 2 (a low-level model under Q177) for the row
that retires the control board writer on 7331 (#1696).
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
credentials of its own; until the credentials row settles per-agent identity, it pushes only its own `bushroot/*`
branches through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
-292
View File
@@ -1,292 +0,0 @@
# Row 198 design: retire the control board writer on 7331
Gitea #1696. Brief `docs/plans/2026-10-20_retire-control-board.md` (blob 11977a5cb, commit a31f1a0f8, Mos's
call mos-1019). Owner Bushroot, reviewer Webby, gate owner Sage. Inventory: `agents/bushroot/work/queue-198/INVENTORY.md`
(004f133bd, sha256 76b630ce...877).
Base: brief commit a31f1a0f8 is on origin `refactor` (head aa1bd209e). This branch rebases on `refactor` after
row 192 lands (Sage's sequencing, ruling 4).
## Revision 5 (Webby REVIEW-r4.md, origin/webby/reviews 5a02ee395, on rev 4 9af75dc72; Mos mos-1045)
- **B1 (r4, the `--tester-port` swap)**: reverted. `parses_flag` is for `--board` only. The `--tester-port` grep at
`stack-release.sh:670` stays. The `--tester-port` test row I added in rev 4 is removed.
- **N1 (r4, stale mark)**: fixed. A write counter guards `index()` coalescing. `markSeen()` and `reply()` start a fresh
scan and never join one. The new `board.test.mjs` case is in the new-tests table, and mutant M15 is in the table.
- **Sequencing (sage-1386)**: row 197 lands before 198. The rebase is noted under Open points.
## Revision 4 (Webby REVIEW-r3.md, origin/webby/reviews c9d65664a, on rev 3 7e2ead7f3)
- **B1** (blocking child calls): fixed. The board's child calls become async. `createBoard` takes an async `exec`,
and `index()`, `markSeen()` and `reply()` return promises. The sync `scan` and `replyToRow` stay for the CLI and
their existing tests. See decision 10, the new B1 test and mutant M14.
- **N1** (fallback copy): fixed. Decision 8 now says which copy gets the 7331 fallback.
- **N2** (flag check matched a refusal): fixed. The parse check matches the parser branch, not the flag name. See
decision 8. M12's target now names `--board` only in a refusal.
## Revision 3 (Sage rulings sage-1372, on rev 2 80fd45521)
- Ruling 1: `/api/seen` goes under `--send`, the same as `/api/reply`. Mos agrees (mos-1023 item 5). Both POSTs sit
above `serve.mjs:558` (decision 5).
- Ruling 2: the board context keeps the writer's read-only fleet default (decision 9).
- Ruling 3: a rollback to a copy with no recorded `--board` falls back to 7331, which is dark, as it is today (decision 8).
- Ruling 5: loopback stays read-only. No new gate in this row. The cross-uid loopback gate is row 196's. Sage took
this to Mos (sage-1370). If Mos rules otherwise, Sage says so and this design changes.
- Ruling 6, correction: neither the live pointer nor the live unit carries `--board`. Sage checked the live
`ExecStart`: `cli.mjs serve --checkout /mnt/storage/src/mosaic-stack --port 7332 --public-host mosaic.woltje.com
--auth-header X-authentik-username --allow-user jason.woltje --send --tester-port 7333`. Rev 2 said otherwise, and
that was wrong. Decisions 6 and 8 are rewritten. The B2 guard stays. `webui-service.sh` lines 44, 71, 110 and 163
still take and emit `--board`, so they are in the retirement.
- Ruling 7: base. Brief commit a31f1a0f8 is on origin `refactor` at aa1bd209e.
## Revision 2 (Webby REVIEW.md, origin/webby/reviews 9afb947c7)
Rev 1 was c8fe23a37. Rev 2 answered B1-B3 and N1-N5 (80fd45521). Changes are marked where they land.
- **B1** (pinned copy): fixed. `packages/control-board/src` and `tools/tmux/agent-send.sh` join `COPY_PATHS`. The
copy test starts the Console from a fresh copy and expects 200 on `/api/board`. See decision 7.
- **B2** (re-pin carries `--board`): fixed. `stack-release.sh` drops `--board` when the target's `cli.mjs` lacks it,
as `console_for_target` already does for `--tester-port`. The plan lists the drop. See decision 8.
- **B3** (POST placement): fixed. The board POST branches sit above the refusal at `serve.mjs:558`, after the
header and origin checks, next to bus message and chat. Loopback cannot reply or mark seen. See decision 5 and mutant M10.
- **N1**: the board context uses the writer's default fleet source. See decision 9.
- **N2**: the M2 check covers all of `packages/control-board/src`, not one file.
- **N3**: the lines that change are listed by file in "Test line changes" below.
- **N4**: the README records that loopback board reads stay open to any local process until Mos rules.
- **N5**: row 198 lands after row 192. The board route tests rerun on the rebased tree.
## Decisions
1. **The Console calls the board's read code in-process.** A new module `packages/control-board/src/board.mjs`
holds the route logic that `createServer` runs today: rescan, mark seen, reply and conversation read. It
imports no `node:http` and opens no socket. Its methods are async (decision 10).
2. **No listener anywhere in the board package.** `createServer` and `startServer` are deleted from
`serve.mjs`. `serve.mjs` keeps its pure exports (`replyToRow`, `isLoopbackHost`, `foreignRequest`,
`conversationResponse`, `REFUSAL_STATUS`, the reply constants). That keeps `packages/conversation/tests/flows.test.mjs`
unchanged. The file name `serve.mjs` now misleads. Renaming it is a separate follow-up and is not in this row.
3. **The writer's CLI refuses `serve`.** `packages/control-board/src/cli.mjs` keeps `scan`. `serve` exits 2 with a
message that points to the Console. The default port 7331 goes away.
4. **The Console gets the board by context, not by URL.** `startServer` in `packages/webui/src/serve.mjs` takes
`board` as a context object (or `null` when no board is configured), not a URL. `DEFAULT_BOARD`, `boardURL`
and the proxy `fetch` are removed.
5. **`/api/reply` and `/api/seen` are gated by `--send`, and sit above the `:558` refusal.** Today the loopback Console
forwards both POSTs with no `--send` check. That is a real gap, and this row closes it. Placement matters.
In preview mode, `serve.mjs:558` refuses every non-GET before the board block, so a board branch below it never
runs. In loopback mode, `--send` cannot be set at all (`:479`). So both POST branches go after the header and
origin checks (`:493-516`), beside `/api/bus/message` (`:550`) and chat (`:554`), and require
`send !== null && named`. The result: `/api/reply` is the first board write the live Console has ever allowed.
Mos ruled it in (mos-1019), and mos-1023 item 5 confirms `/api/seen` too. In loopback mode, reply and mark-seen
refuse with 403 `read-only`. Board reads still work there. `/api/seen` writes marks under the data root, and without
the gate it would be the only preview-mode POST that passes without `--send`.
6. **The retirement removes `--board` from the Console scripts.** No installed unit or live pointer carries `--board`
(Sage checked, rev 3). So no live unit needs a flag change to start. The flag goes from `packages/webui/src/cli.mjs`
(`:14`, `:18`, `:32`) and `scripts/webui-service.sh` (lines 44, 71, 110, 161-163). An old `--board` argument refuses
with the usage error (exit 2). A re-pin with the flag removed is an ordinary re-pin. Cutover needs no unit edit.
7. **The pinned copy carries the board code (B1).** The live Console runs from `copies/<sha>`. `COPY_PATHS` in
`scripts/stack-copy.sh` (lines 23-33) lacks `packages/control-board/src` and `tools/tmux`. Without them a
Console from a copy fails at import, and `reply` cannot find `agent-send.sh`. Both paths join `COPY_PATHS`.
`tools/tmux` goes in whole, the same way `packages/plugins` does, because `agent-send.sh` may load its own
siblings. The `control-board/src` closure is `conversation/src`, `discord/src` and `seat/src`, and those are already in
the list.
8. **Re-pin and rollback drop `--board` when the target cannot parse it (B2 guard).** The pointer's console flags
(`P_CONSOLE`, `stack-release.sh:128`) pass through `:676` into `RUN_CONSOLE`, and `:459` passes them to
`webui-service.sh install`. The live pointer has no `--board` (rev 3), so this is a guard for a pointer that
carries one, such as an older pointer or a rollback target. Rule: keep `--board` only when the target's
`packages/webui/src/cli.mjs` parses it. Otherwise drop it and report the drop in the plan. That is the same shape
as `console_for_target` and `--tester-port` (`:667`, `:687-690`). Rollback to a copy that parses `--board` keeps
the recorded flag. A copy that does not parse it (a new copy) gets the flag dropped and starts with no board URL,
so its board is in-process or null, never 7331. The 7331 fallback applies only to an older copy that parses
`--board` and has no recorded flag. That copy starts with its own default, 7331, which is dark, the same as today
(Sage ruling 3).
**Parse check (N2, rev 5).** "Parses it" means the target's `cli.mjs` holds the parser branch, `flag === '--board'`,
not the flag name. The name also appears in the usage text and in refusal messages, so a plain `grep` would count
a copy that refuses the flag as one that accepts it. One helper, `parses_flag FILE FLAG`, greps for
`flag === 'FLAG'`. It is used for `--board` only, in `console_for_target` and the rollback path. The existing
`--tester-port` grep at `stack-release.sh:670` stays as it is (Webby REVIEW-r4). Changing it would break the
`stack-release.test.mjs:265` fixtures, which write a comment, not a parser line. No copy names `--tester-port` in a
refusal today, so the gap exists only for `--board`.
9. **The board context uses the writer's default fleet source (N1).** `--fleet` defaults to `~/.mosaic/fleet/agents`,
as the writer did (`cli.mjs:14`). The Console reads it and never writes it. Whether the fleet is retired
and the default becomes `none` is a later decision. This row keeps the board's contents unchanged.
10. **The board's child calls are async (B1).** The Console runs the board in its own process. A blocking child call
would stall every route, the event stream, `/healthz` and the tester listener, which shares the process
(webui `cli.mjs:91`). Rule: no `spawnSync` on any path that `createBoard` reaches.
- `scan.mjs` gains `tmuxInspectAsync` (same args, same 5 s timeout) and `scanAsync`. The sync `tmuxInspect` and
`scan` stay for the CLI. The parse of the tmux output is one pure function that both paths call, so they cannot
drift.
- `serve.mjs` gains `replyToRowAsync` (same 15 s timeout, same env, same result shapes). The sync `replyToRow`
stays for its existing tests. The reply trailer and status mapping are shared pure code.
- The default async `exec` is `spawn` with a timeout that kills the child. A timeout or a spawn failure maps to
`{ error }`, the same shape the sync path returns. Tests inject `exec`.
- `index()` coalesces concurrent calls, but only onto a scan that started after the last write (rev 5, Webby N1).
The board keeps a write counter. `markSeen()` and `reply()` bump it, then start a fresh scan and never join one.
`index()` joins an in-flight scan only when that scan started at the current counter value. So an answer never
omits a mark that was set before it was asked for. This coalescing is my choice to limit tmux calls under
parallel reads; the design has no other constraint on it.
- `conversation(url)` spawns nothing. It reads files on the event loop, as the writer did. Not changed in this row.
## Open points for Sage and Mos
- **Auth on loopback (Sage ruling 5, taken to Mos as sage-1370).** The Console has two modes. In preview mode, a named
request needs the proxy header and proof, and the board routes inherit that. In loopback mode (no `--public-host`),
there is no sign-in. Loopback stays read-only with no new gate in this row. Board reads stay open to any local
process, as they are today. Reply and mark-seen cannot run there, because `--send` needs preview mode. The
cross-uid loopback gate is row 196's. If Mos rules otherwise, Sage says so and this design changes.
- **Row 192 overlap (Launchpad, SSO on `webui/src/serve.mjs`).** My edits touch these regions of
`packages/webui/src/serve.mjs`:
- lines 12-27 (`DEFAULT_BOARD`, `boardURL`)
- line 466 (`startServer` signature, `board` option)
- lines 473 (`upstream`)
- lines 556-580 (board proxy block)
- lines 598-610 (`/api/config` `board` field)
I do not edit the preview and proof gate (lines ~486-505) or the auth-header checks. The board branches go
after those checks, in the same order as the bus and chat branches. Sage should sequence 192 and 198 so the
second one rebases on the first. I will not touch the SSO code.
- **Cutover.** The new Console code reaches the live Console through a copy and re-pin. The restart is a human
step (`console-holds-human-authority`). No unit flag changes (decision 6). The live unit stays up until then.
- **Sequencing (ruling 4, sage-1386).** Sage sequences 198 after 192 and after row 197 (Herb). I rebase once, onto the
tree with 192 and 197 landed, and rerun the board route tests there. 197 replaces closed-endpoint ports with dynamic
allocations. Its overlap with this row is `packages/webui/tests/serve.test.mjs:100` and
`packages/webui/tests/preview.test.mjs:208`. My rewrites of those lines (the dead-board 503 change at serve.test.mjs:79
and :105, and preview.test.mjs:219 in the N3 list) are written against 197's dynamic ports, not the old fixed ones.
I do not edit either overlap line before 197 lands. The manifest goes to review only after the rebase.
## File-by-file
### Board package
| File | Change |
|---|---|
| `packages/control-board/src/board.mjs` | **New.** `createBoard({ specs, boardDir, seatsDir, discordDataRoot, isAlive, now, agentSend, exec, conversationReader })`. Returns `{ index(), markSeen(body), reply(body), conversation(url) }`. `index`, `markSeen` and `reply` return promises and use the async paths (decision 10). Each method is the body of a route that `createServer` runs now. No `node:http`, no listener. |
| `packages/control-board/src/scan.mjs` | Adds `tmuxInspectAsync` and `scanAsync`, and one shared pure parse. Sync `tmuxInspect` and `scan` stay for the CLI. |
| `packages/control-board/src/serve.mjs` | Delete `createServer` (lines 229-310), `startServer` (312-322), `page` loading (`loadPage`, 224-227), and the `node:http` import. Keep the pure exports and the sync `replyToRow`. Add `replyToRowAsync` (decision 10). Remove the `7331` default. |
| `packages/control-board/src/cli.mjs` | `serve` exits 2 with `refused: the control board no longer serves HTTP. Use the Console: node packages/webui/src/cli.mjs serve`. The `--port` and `--host` flags are removed. `scan` is unchanged. Usage text is updated. |
| `packages/control-board/src/page.html` | Delete. The Console serves its own `index.html`. |
| `packages/control-board/README.md` | Remove the `serve` section (lines ~107, 127), the port default and the start instructions. Keep the reply-convention text (lines 144-151). It names no start command. |
| `packages/control-board/package.json` | The `mosaic-board` bin keeps `scan` only. Description text updated. |
### Console
| File | Change |
|---|---|
| `packages/webui/src/serve.mjs` | Remove `DEFAULT_BOARD`, `boardURL` and the proxy `fetch`. `startServer({ board })` takes a context object or `null`. Board routes call `board.index()`, `board.markSeen()`, `board.reply()` or `board.conversation()` directly. If `board` is `null`, the board routes answer 503 with `{ error: 'no board configured' }`. `/api/reply` and `/api/seen` need `send !== null && named`, the same gate as the other writes. Without it they answer 403 `read-only`. `/api/config` returns `board: null` and no URL. The app reads `config.board` only for the footer text, so a `null` value needs a small change in `app.js:468`. |
| `packages/webui/src/cli.mjs` | Remove `--board` and its usage text. Build the board context from the config, `--checkout` (or cwd, as the writer did), and the seats and board dirs. Failure to read the system config gives `board: null`, not a refusal. The Console still starts, as it does for the bus today. |
| `packages/webui/src/public/app.js` | Line 468: keep the footer, show `Board: in-process` (or hide it). Lines 365, 468 and the 502 message text (`Board unreachable ... Check the board server`) are reworded. The text is not a contract. |
| `packages/webui/src/public/index.html` | No change. |
| `packages/webui/README.md` | Remove the start line (10), the `--board` line (15), the test command that lists `control-board/tests/` (766, keep only the in-process tests), and the rollback line (881). |
| `packages/webui/systemd/mosaic-webui.service.in` | No change in the template. The `@FLAGS@` set loses `--board`. |
| `scripts/webui-service.sh` | Remove `--board` from the usage (44, 71), the parser (110), the validation (161-162) and the flag (163). An old `--board` argument refuses with the usage error. |
| `scripts/stack-copy.sh` | **B1.** `COPY_PATHS` (23-33) adds `packages/control-board/src` and `tools/tmux`. |
| `scripts/stack-release.sh` | **B2.** `console_for_target` (around 664-692) drops `--board` when the target's `cli.mjs` does not parse it. The plan line names the drop. Rollback keeps the recorded flag when the target parses it. |
| `packages/webui/README.md` | Also records (N4) that loopback board reads stay open to any local process until Mos rules. |
| `packages/webui/src/public/bus.js` | No change. The `#/` board link is the Console's own page, not 7331. |
### Tests (webui)
Fixture plan. "Dead board" means a context whose `index()` throws, which replaces `board: 'http://127.0.0.1:9'`.
| File | Change |
|---|---|
| `packages/webui/tests/fixture.mjs` | Call `createBoard` directly. Pass the context to `startServer`. No port 0 board server. |
| `packages/webui/tests/history-fixture.mjs` | Same as fixture.mjs. |
| `packages/webui/tests/discord.test.mjs` | Same. Board server removed. |
| `packages/webui/tests/relaunch.test.mjs` | Same. |
| `packages/webui/tests/return-flow.test.mjs` | Same. |
| `packages/webui/tests/tester-fixture.mjs` | `board: 'http://127.0.0.1:9'` becomes the dead board. |
| 16 files with `board: 'http://127.0.0.1:9'` (bus, chat, instances-browser, limits-browser, messages, pinned-copy, preview, proof-cli, proof, routes-browser, send, settings-form, tester-cli, tester-listener, usage-browser, views) | Mechanical: dead board. Assertions that expect a 502 `Board unreachable` string are updated to the new 503 `no board configured` or the dead-board error text. Sage needs to confirm this count before the edit. |
| `packages/webui/tests/serve.test.mjs` | Lines 17-19 (`boardURL` checks) are removed. Add the new board route tests (below). |
| `packages/webui/tests/reads.test.mjs` | Line 188: the `board` setting becomes `null`. |
| `packages/webui/tests/service.test.mjs` | Lines 225-394: unit `ExecStart` text loses `--board`. The `--board` refusal cases (250, 260, 394) become refusal of the old flag. |
| Browser tests (`browser-edge`, `bus-browser`, `chat-browser`, `docs-browser`, `editor-browser`) | Fixture context only. No change to the browser assertions. |
### Tests (control board)
| File | Change |
|---|---|
| `packages/control-board/tests/serve.test.mjs` | 32 tests use HTTP. Move the route assertions to `board.test.mjs`, which calls `createBoard` directly. Keep the `replyToRow` tests here. |
| `packages/control-board/tests/discord.test.mjs` | Two HTTP uses (lines 123 and the `startServer` call). Call `createBoard`. |
| `packages/control-board/tests/scan.test.mjs`, `attention*.test.mjs`, `relaunch.test.mjs` | No change. They test `scan`, not HTTP. |
| `packages/control-board/tests/retired.test.mjs` | **New.** Three checks, all in-process, no socket. (1) `cli.mjs serve` exits 2 and prints the Console pointer. (2) The module graph has no `node:http` import and no `listen(` call in `packages/control-board/src`. (3) A grep over the start paths (`scripts/`, `*.service*`, `packages/*/README.md`, `package.json` scripts) finds no `control-board.*serve` or `7331` starter. |
### New tests for the acceptance items
| Test | Where | Assertion |
|---|---|---|
| Board view answers with nothing on 7331 | `packages/webui/tests/board-inprocess.test.mjs` (new) | Console with a context returns 200 on `/api/board` with a stubbed `globalThis.fetch` that throws on any call. Zero fetch calls. |
| Every board route refuses without the Console's gate | same file | In preview mode, `/api/board`, `/api/seen`, `/api/reply`, `/api/conversations` each return 401 without the header and 403 with a foreign Host. |
| `/api/reply` refuses without `--send` | same file | Preview mode, no `send`. POST `/api/reply` returns 403 `read-only`. `replyToRow` is never called (an injected `exec` counts calls). |
| `/api/seen` refuses without `--send` | same file | Same shape. No mark file written. |
| `/api/reply` runs with `--send` | same file | Positive control: with `send` and the sign-in header, the injected `exec` is called once with the expected argv. |
| No Console proxy left | `packages/webui/tests/board-inprocess.test.mjs` | `DEFAULT_BOARD` is not exported. `boardURL` is not exported. Grep check: no `7331` in `packages/webui/src`. |
| markSeen answer includes its own mark (N1, rev 5) | `packages/control-board/tests/board.test.mjs` (new) | Hold a scan in flight with an injected exec that resolves late. Call `index()`, then `markSeen()` before the scan resolves. The `markSeen()` answer shows the mark. A mutant that joins the pre-write scan must fail it. The same check covers `reply()`. |
| Board does not block the Console (B1) | same file | Injected async `exec` for the tmux check resolves after 2 s. While `/api/board` is pending, `/healthz` answers within 200 ms. Mutant M14 uses an `exec` that blocks the thread for 2 s with `Atomics.wait`, and this test must kill it. |
## Mutants
Each mutant runs on a scratch copy of the tree (not the live checkout), with the new tests. The test that
must kill it is listed. A mutant that survives means the test is weak.
| # | Mutant | Must be killed by |
|---|---|---|
| M1 | `cli.mjs serve` calls `startServer` again on 7331 | `retired.test.mjs` (1) |
| M2 | `serve.mjs` restores `createServer` or `startServer` and exports them | `retired.test.mjs` (2) |
| M3 | Console `DEFAULT_BOARD` is restored and the proxy `fetch` returns | `board-inprocess.test.mjs` zero-fetch case |
| M4 | `/api/reply` skips the `send` check | `/api/reply refuses without --send` |
| M5 | `/api/seen` skips the `send` check | `/api/seen refuses without --send` |
| M6 | The board block runs before the preview and header gate | "every board route refuses" (401 case) |
| M7 | A board route accepts a foreign Host | "every board route refuses" (403 case) |
| M8 | `scripts/webui-service.sh` re-accepts `--board` | `service.test.mjs` refusal case |
| M9 | A README or script again tells people to run `control-board serve` | `retired.test.mjs` (3) |
| M10 | The `/api/reply` and `/api/seen` branches move below the `:558` refusal | The positive control "`/api/reply` runs with `--send`" (403 from `:558`) |
| M11 | `COPY_PATHS` drops `packages/control-board/src` or `tools/tmux` | Fresh-copy test in `pinned-copy.test.mjs`: `/api/board` must answer 200 |
| M12 | `console_for_target` keeps `--board` for a target that does not parse it | `stack-release.test.mjs`: re-pin plan lists the drop; install argv has no `--board`. The target `cli.mjs` names `--board` only in a refusal message and has no parser branch (N2). A grep-based check keeps the flag and this test kills it. |
| M15 | `index()` joins a scan that started before the last write (N1) | the `markSeen` answer case in `board.test.mjs` |
| M13 | Rollback drops a recorded `--board` from a target that parses it | `stack-release.test.mjs`: rollback keeps the flag |
| M14 | The board's tmux check or reply runs through a blocking call (`spawnSync`, or an `exec` that blocks the thread) | `board-inprocess.test.mjs` B1 case: `/healthz` must answer within 200 ms while `/api/board` is pending |
Mutants M11 to M13 run in the cli suite, which needs Sage's slot. M10 and M14 run in one webui test file each.
## Test additions (B1, B2)
| Test | File | Assertion |
|---|---|---|
| Fresh-copy Console answers the board | `packages/webui/tests/pinned-copy.test.mjs` (extend) | `stack-copy.sh` builds a copy at a fixed sha in a temp dir. Starts the Console from it on port 0. `GET /api/board` returns 200 JSON. `agent-send.sh` resolves inside the copy. |
| Re-pin drops `--board` | `packages/cli/tests/stack-release.test.mjs` (extend) | Pointer records `--board`. Target `cli.mjs` lacks the parser branch, and names `--board` only in a refusal (the N2 fixture). The plan names the drop. The install argv has no `--board`. |
| Rollback keeps `--board` | same | Rollback to a copy with `flag === '--board'` in its `cli.mjs` keeps the recorded flag. |
## Test line changes (N3)
Board-related lines that change, by file:
- `packages/webui/tests/serve.test.mjs:79`: `/api/board` on a dead board, 502 becomes 503.
- `packages/webui/tests/serve.test.mjs:105`: 502 with the board URL in the body. Becomes 503 with no URL.
- `packages/webui/tests/preview.test.mjs:219`: dead board, 502 becomes 503. `board.body.board` is no longer a URL.
- `packages/webui/tests/send-browser.test.mjs:89`: a `502 board unreachable` string. Verify at edit time. Likely a bus status, which stays.
- `packages/webui/tests/serve.test.mjs:17-19`: `boardURL` checks, removed.
Unchanged, not board: `bus.test.mjs:226-236`, `chat.test.mjs:171-179`, `instances.test.mjs:163-168`,
`limits-route.test.mjs:35`, `project-file.test.mjs:128`, `routes.test.mjs:170`, `send.test.mjs:230`,
`tester-cli.test.mjs:57`, `tester-listener.test.mjs:113`, `usage.test.mjs:24`, `board-held.test.mjs:204` (page text).
Dead-board fixture files (16 webui plus `tester-fixture.mjs`) match Webby's count.
## Gate
Mutant runs use single test files, which run freely. Any suite (control-board, webui, cli, every
`scripts/test-*.sh`) needs Sage's slot.
Webby approves INVENTORY.md (already sent), then this DESIGN.md, then the manifest. Sage's gate runs the
control-board and webui suites, the cli suite and every `scripts/test-*.sh`. All must be green, and mutants
M1-M15 killed.
## Stop points
- If Mos rules that loopback board reads need a gate, this design changes. Sage tells me, and I stop and report.
- If the Console's auth model changes under row 192, the board block in section "Console" changes with it.
- The Console restart and unit re-render are Jason's or Mos's steps. I do not run them.
## Boundaries kept
No change to the live units, `~/.mosaic-dev`, `~/.config/mosaic-dev`, slot files, `~/.mosaic`, or any live port.
No request to 7331 or 7332 at any point.
-125
View File
@@ -1,125 +0,0 @@
# Row 198 inventory: control board writer and port 7331
Gitea #1696. Brief: `docs/plans/2026-10-20_retire-control-board.md`. Owner Bushroot, reviewer Webby, gate owner Sage.
Scope: tracked files at `bushroot/row198` (base `refactor`). Excludes `v1/` and other seats' `agents/*/work/` packets.
Method: `git grep -I` for `control-board`, `control_board`, `controlboard` and `7331`, then a read of each code hit.
## Classes
- **starts**: runs `packages/control-board` `serve`, or a unit, script or default that does.
- **imports**: loads code from `packages/control-board` at run time or test time.
- **mentions**: text only (docs, comments, fixture strings, history).
## Result
**Live dependency found. Stop before any removal.** The Console (`packages/webui`) proxies its board reads and
writes to the writer on 7331. The Console's board view, `/api/board`, `/api/seen`, `/api/reply` and the
conversation routes all forward to `DEFAULT_BOARD = http://127.0.0.1:7331`. The Console has no board without
a writer on that port. Mos decides before anything is removed.
Nothing in the repository runs `control-board serve` from a unit. The only starter is the manual command in
`packages/control-board/README.md` and `packages/webui/README.md`. `ss -ltnp` showed nothing on 7331 at
2026-10-20T19:05:09Z (brief).
## Hits
### Starts the writer, or defaults to its port
| File | Line | Class | What it does |
|---|---|---|---|
| `packages/control-board/src/cli.mjs` | 4, 14 | starts | `serve` subcommand; default `port: 7331` |
| `packages/control-board/src/serve.mjs` | 312 | starts | `startServer` default `port = 7331` (the writer itself) |
| `packages/control-board/README.md` | 107, 127 | starts | Tells people to run `serve` and open 7331; documents the default |
| `packages/webui/README.md` | 10 | starts | Tells people to run `node packages/control-board/src/cli.mjs serve` |
| `packages/webui/README.md` | 15, 881 | depends | Console reads the board at 7331; rollback uses "the unchanged board at 7331" |
| `packages/webui/src/cli.mjs` | 14, 18 | depends | `--board` default is `DEFAULT_BOARD` (7331) |
| `packages/webui/src/serve.mjs` | 12, 473, 561, 578, 610 | **depends (live)** | `DEFAULT_BOARD`; proxies `GET /api/board`, `GET /api/conversation(s)`, `POST /api/seen`, `POST /api/reply` upstream; returns 502 if the writer is absent |
| `packages/webui/src/public/app.js` | 468, 365 | depends | Page reads `/api/config` board, then `/api/board` through the Console proxy |
| `packages/webui/src/public/bus.js` | 1037, 1088, 1100 | depends | Routes `#/` to the board page; bus pages link to it |
| `scripts/webui-service.sh` | 89-163 | depends | Renders the Console unit with `--board URL`; default comes from `serve` |
| `packages/webui/systemd/mosaic-webui.service.in` | 29 | depends | Unit runs the Console (`webui/src/cli.mjs serve`), not the writer; flags carry `--board` |
| `scripts/stack-copy.sh` | 88 | mentions | Checks the Console `ExecStart` string; no writer reference |
| `scripts/stack-release.sh` | 1372 | mentions | Checks the Console command line; no writer reference |
### Imports code from `packages/control-board`
| File | Line | Class | What it imports |
|---|---|---|---|
| `packages/webui/tests/fixture.mjs` | 6 | imports | `startServer` from `control-board/src/serve.mjs` (test fixture for the Console) |
| `packages/webui/tests/history-fixture.mjs` | 8 | imports | same |
| `packages/webui/tests/discord.test.mjs` | 6 | imports | same |
| `packages/webui/tests/relaunch.test.mjs` | 6 | imports | same |
| `packages/webui/tests/return-flow.test.mjs` | 13 | imports | same |
| `packages/conversation/tests/flows.test.mjs` | 15 | imports | `replyToRow` from `control-board/src/serve.mjs` |
The webui tests run the writer's server in-process. They do not start the writer on 7331 (the tests bind
their own port), but they need `serve.mjs`. Any removal or refusal in `serve.mjs` breaks these tests unless
the writer's read-only code moves first.
`packages/webui/src/` (non-test code) does not import `control-board`. Only the tests do.
### Tests that name the writer or its files
| File | Line | Class | Note |
|---|---|---|---|
| `packages/control-board/tests/serve.test.mjs` | 37, 744-767 | imports | Tests the writer: reply runner, `REPLY_TRAILER`, `-S <host>:control-board` |
| `packages/control-board/tests/scan.test.mjs` | 47, 306, 353 | mentions | Tmp dir names and comments |
| `packages/webui/tests/serve.test.mjs` | 18-19, 35 | mentions | `boardURL` checks on port 7331; reply trailer string |
| `packages/webui/tests/reads.test.mjs` | 188 | mentions | Expected `board` setting value 7331 |
| `packages/webui/tests/service.test.mjs` | 225-394 | mentions | Unit `ExecStart` text includes `--board http://127.0.0.1:7331` |
| `packages/plugins/tests/harness.test.mjs` | 173 | mentions | Path string `packages/control-board/src/scan.mjs` |
| `packages/ledger/tests/ledger.test.mjs` | 80, 233-327 | mentions | Sender name `control-board` as a test fixture |
| `packages/queue/tests/fixtures/genesis-render.md` | 6-11 | mentions | Fixture text |
| `packages/conversation/tests/reader.test.mjs` | 2 | mentions | Comment |
| `packages/conversation/README.md` | 207 | mentions | Says the control-board suite serves the routes |
### Code that reads the sender name, not the writer
| File | Line | Class | Note |
|---|---|---|---|
| `packages/ledger/src/ledger.mjs` | 120 | mentions | Classifies `control-board` as sender kind `board`. No import. |
### Package and script metadata
| File | Line | Class | Note |
|---|---|---|---|
| `packages/control-board/package.json` | 2, 5, 9 | starts | Declares `bin: mosaic-board -> src/cli.mjs` (a `mosaic-board serve` entry point) |
| `package.json` (root) | - | none | No script names the board or 7331 |
| `scripts/mosaic` | 3 | mentions | Comment only |
| `packages/seat/package.json` | 5 | mentions | Description: "the control board reads" registration |
| `packages/seat/README.md` | 139 | mentions | Says the board reads `registration.json` |
| `packages/plugins/README.md` | 181 | mentions | Names `scan.mjs` in an exception note |
### Docs (mention only)
- `AGENTS.md` lines 144-150: reply convention for control-board attention. Names the package README. Does
not tell anyone to start 7331. The brief says to change it only if it does.
- `docs/SESSIONS.md` (32), `BUILD-LOG.md` (76), `docs/plans/CURRENT.md` (8), `docs/plans/QUEUE.md` (12),
`docs/plans/queue.json` (45), `docs/plans/2026-09-12_control-board-mvp.md` (21),
`docs/plans/2026-10-*` and other plan pages, `docs/plans/reviews/*`, `docs/design/*`.
- `docs/plans/CURRENT.md:171` says "Start it with `node packages/control-board/src/cli.mjs serve`". That is
an instruction to start the writer. Update it when the writer is retired.
- `docs/plans/2026-10-18_containerized-install-design.md:927` and `-rows.md:213` cite `serve.mjs` line numbers.
- `docs/plans/2026-10-19_seen-click-intent.md:29` cites `serve.mjs:257` (`/api/seen`).
- `BUILD-LOG.md` is append-only. Leave it alone.
- `agents/bushroot/README.md`, `agents/README.md`, `docs/design/README.md`, `docs/design/IMPLEMENTING.md`:
mention only.
- `packages/webui/third_party/editor/PROVENANCE.json`: mention only (the vendored editor's clipboard, not the board).
- `packages/webui/src/public/*` clipboard, board-view and CSS names: unrelated to the writer.
### Excluded
- `v1/*`: archived.
- `agents/*/work/*`: other seats' packets (darkwing, filbert, rocko and others). Not read for this inventory.
## Open items for Mos
1. The Console's board view and proxy routes need a writer on 7331 or an equivalent read path. Retiring the
writer means choosing a replacement first, or accepting that the board view goes dark.
2. Four plan pages and `CURRENT.md:171` still tell people to start the writer.
3. The webui tests and `conversation/tests/flows.test.mjs` import `serve.mjs`. Any change to it needs those
tests moved first.
## Sha256
Recorded in the send message after commit. This file is the inventory; the commit carries it.
+100
View File
@@ -0,0 +1,100 @@
# Row 196 build evidence
Base 4d26003f7. Build authorized sage-1371, design approved duckworth-0009/0015.
No live units, live ports, credentials, real second uid or live state touched.
Single test files only so far. Suites and mutant batches require Sage's slot.
## Cleanup ruling, sage-1382 (2026-10-20T19:39:30Z)
The first scratch test proved Node/libuv unlinks a final-path replacement before
JavaScript close callbacks. Evidence: evidence/tester-cleanup-blocker.txt.
Duckworth's option 1 (523a6bddf) was then verified in a standalone scratch test:
bind temporary.sock, record dev/ino, rename to tester.sock, connect successfully,
replace final inode, close. Temporary name absent and replacement survived.
Sage chose option 1. The code binds a unique temporary name in the checked 0700
parent, chmods/verifies the socket, records its dev/ino and renames to tester.sock.
The temporary basename has the same length as tester.sock. libuv remembers the
removed temporary name. The close callback lstat/checks the final dev/ino before
unlink. The final-path replacement test was RED with direct bind and GREEN after
rename. It also asserts no temporary entry after bind or close. M24 will bind the
final path directly and must fail this test.
Residual: a hostile same-uid process can replace a path between lstat and unlink
(or between the final-path check and rename), or recreate the temporary name
before libuv closes it. The checked private parent excludes other uids, not the
instance's own uid. These same-uid races are outside this row's guarantees.
## Development checks
Evidence files retain the red/green and diagnostic output. The initial concurrent
peer test exposed that Node pauses a Socket passed through spawn stdio. The caller
now resumes it after helper exit, with bidirectional post-helper traffic asserted.
No product commits until applicable suites pass. Final candidate and counts follow
after implementation and authorized verification.
## Gate stop, 2026-10-20T19:57:01Z
Gate run 1 stopped at bus 463 pass/1 failure. The row164 reviewed byte pins needed
the intentional row196 human-walk/nonce and Console initial-spawn nonce changes.
Only those two pins were updated, retaining human-cli and CLI transport pins,
literal path recovery and channel plug-in import bans. Focused test passed.
Run 2: bus 464 pass/0 fail. WebUI stopped at two failures in tester-browser.test.mjs:
it still formed TCP URLs from the now-Unix listener address. Script counted 269
passing top-level cases before interruption and 2 failures. No final WebUI summary.
CLI and mutants did not run. Node24 peer 3/3 and principal 4/4 passed separately.
Sage received clear (gander-0023). The failures are fixture migration omissions,
not timing failures from Launchpad's overlapping single T56 run. No product commit.
Focused repairs: the browser fixture fulfills synthetic-origin requests through
CDP from the scratch Unix listener, with no TCP listener, proxy or product relay.
The two tester browser tests pass. Self-review also found a FIFO receipt could
block open before fstat; O_NONBLOCK now makes it refuse promptly (RED timeout,
then interpreter 5/5 GREEN). Missing/non-regular helper now refuses construction
before opening broker.sock (RED missing refusal, then peer reader 4/4 GREEN).
Renewed slot requested in gander-0024; no suite or mutant rerun yet.
## Manifest preparation while the slot is occupied
sage-1409 accepts the CDP fixture repair and startup refusals. MANIFEST.md gives
each its own item, including the old/new human.mjs and WebUI bus.mjs sha256 values
and rationale for the row164 re-pin. PRODUCT-FILES.json records current product,
test and operator-document hashes, explicitly as an uncommitted draft.
Self-review also found helper shutdown returned before child close. The focused
test was RED (evidence/peer-shutdown-red.txt), then GREEN after reader.close awaits
all killed helper close events and server.close awaits reader.close. M27 removes
the wait. M25 removes FIFO nonblocking open and M26 removes helper preflight.
All three are prepared but unexecuted, reported to Sage in gander-0025.
After this change, individual Node 24 files pass: peercred 5/5 and principal 4/4.
Node 26 peer-transport is 3/3. Their logs retain the full results. No suite or
mutant batch ran during the hold. sage-1416 puts Gander after Launchpad, Darkwing,
Rocko and row 192; Sage must send go before the renewed batch starts.
The consumer audit found one additional conversion defect in the files already
changed: proof insertion stopped at --tester-port but not --tester-socket.
Pointer argv therefore differed from rendered unit argv when conversion also
supplied a proof pair. The extended row196 release case reproduced the exact
order mismatch, then passed after adding the socket flag to that insertion
boundary. The proof-file no-read check still passes. M28 restores the defect.
No batch ran. Shell syntax and diff whitespace checks pass. Final prepared
mutant count is 28, pending Sage's go.
## Static review completion, sage-1425
The changed verifier path now has a real Client refusal test. A completed proof
refusal reaches the client as human-required; helper failure is peercred-required.
No broker request dispatches. peer-transport is 4/4. Client code is unchanged:
an abrupt close, timeout or transport error without a response is outcome-unknown.
The scratch base/candidate comparison returns human-required for both. It does
not reproduce Quackerjack's exact failing instance; the manifest names this limit.
Review also found release did not check tester path length until after service
stop. The focused long-path activation fixture reproduced it, then passed with
the new preflight. Existing unsafe parent type/uid/mode checks also precede stop;
the fixture verifies no start/stop calls for unsafe mode. M29 removes the check.
Legacy tester-port-invalid wording is restored to avoid an unrelated contract
change. Review covered all changed trust paths and consumers; no further code
changes are planned before the batch. M1-M29 remain prepared and unexecuted.
+332
View File
@@ -0,0 +1,332 @@
# Row 196: the human boundary across uids and humans
Gander, 2026-10-20. Revision 3, recording approved r2 and N5. Gitea #1694, P19 333.
Base: `96f243faf`, branch `gander/row196`. Design approval latest-by 2026-10-22, landing
latest-by 2026-10-24 and before federation R3. This document authorizes no live action.
## 1. Outcome and authority
Other Linux users must not reach the tester's data or writes. A bus human proof must describe
the process that actually connected. Console user calls must also pass the human walk and
come directly from this bus tree's Console. Row 193 and federation R3 use that one verifier.
This is not isolation from a hostile process on the instance's own uid. Keeping the human
terminal CLI means that an unmarked process on that uid still has the owner's authority.
Process ancestry cannot prove that a process was never an agent. Sections 4 and 8 state this
limit without treating it as an enforcement guarantee.
Inputs: COMMON at `53bb59462`, the row brief at `96f243faf`, Q175 sections 1, 6 and 8,
federation r2 at `60b9b112a` section 3.6, Duckworth's B1-B5 at `b402197ff`, row 190's
DESIGN.md and the row 190b brief. Q175-Q178 and the MVP plan set the order. Row 190b is on
hold pending the host decision. This row does not assume that a separate uid is already live.
Sage's `sage-1343` accepts the design direction and requires Duckworth to rule on the peer
credential implementation. `sage-1345` keeps the control board inventory-only. Morgana's
`morgana-0003` supplies the principal contract in section 4. Sage registers this session.
Mos accepted removal of direct tester browser access in mos-1015, relayed by sage-1363.
Agents test through the private socket. Any later browser view is an authenticated relay
behind SSO, designed and reviewed in its own row. This row adds no relay.
## 2. Inventory from code, not a live probe
"Same uid" includes untrusted local processes. "Other uid" excludes root, which is outside
this boundary. SSO below means Q175 is active on that instance, not merely installed.
| Entry | Same uid today | Other uid today | Effect of SSO and this row |
|---|---|---|---|
| Main Console, default 7332, bare serve default 7330 | Loopback reads. With preview/send, matching Host, header and origin can write as owner | Same TCP reach and header forgery | Q175 SSO verifies login and refuses non-public Host except GET /healthz. This row does not change public ingress |
| Tester TCP, default installation 7333, optional per instance | Reads and the tester instance, route, name and chat writes | Identical reach, no uid check | Q175 deliberately leaves tester separate. This row replaces TCP with a private filesystem Unix socket |
| `<dataRoot>/bus/broker.sock` | Connects, then needs cap or proof. Claimed human PID is not the socket peer | Normally refused by bus/ 0700 and socket 0600 | SSO changes nothing. Add peer uid and PID binding, retain file checks |
| Human wrapper to tree's human-cli.mjs | Owner if CLI path, nonce and ancestry pass. Agent ancestors refuse | Cannot connect to protected socket, and proof uid refuses | Preserve terminal owner path, add peer binding. SSO does not authenticate terminal users |
| Tester CLI to broker.sock | Tester if its proof passes and bus.tester is exactly true. T3 agents are allowed, managed runs are not | Filesystem modes normally refuse | Same tester policy, plus peer binding. No owner/user upgrade |
| `<dataRoot>/bus-host/launch.sock` | Requires run cap and launcher checks | bus-host/ 0700, socket 0600 | No human proof endpoint. Unchanged |
| `<dataRoot>/bus-host/git.sock` | Git capability protocol and broker authorization | bus-host/ 0700, socket 0600 | No human proof endpoint. Unchanged |
| Discord connector | Outbound Discord gateway/REST, no inbound local listener. Local process could tamper with same-uid files/process | No local port to call. File separation depends on uid and modes | Existing guild, user, channel, bot and webhook checks remain. Discord is not a new bus human transport |
| Row 190 additions | Separate configs, roots, unit names, wrappers and Console ports, on one uid | Console/tester TCP still reachable | Adds no new class of listener. Row 196 derives its socket per dataRoot. Row 190 alone supplies no hostile-process isolation |
| Control board, default 7331 | Unauthenticated transcript/board reads, POST /api/reply and /api/seen after request checks | Same loopback reach. A local client can supply matching request headers | Independent development service, outside instance SSO. Inventory only per sage-1345. Its fix needs another row |
Evidence: `packages/webui/src/{cli,serve,bus}.mjs`, `packages/bus/src/{server,runtime,human,
tester,store}.mjs`, `packages/cli/src/{launcher,git-socket,transport}.mjs`,
`packages/discord/src/{gateway,authorize}.mjs`, `packages/control-board/src/serve.mjs`.
Store checks bus/ with `safe(directory,true)`, including owner, symlink and mode checks.
The dataRoot check itself does not require 0700 today. Do not describe that as a checked mode.
## 3. D1: private tester HTTP socket
Add `serve --tester-socket`, a boolean selecting `<dataRoot>/console/tester.sock`. No arbitrary
path or new system config field. Create console/ as 0700. Refuse a symlink, wrong owner,
non-directory or any group/other bits on an existing console/. Refuse a pre-existing socket
or other entry rather than deleting it blindly. A stale socket needs an operator's diagnosis.
Refuse a path longer than Linux's 107-byte pathname capacity before binding.
Bind HTTP on the Unix path, set socket mode 0600, then verify type, owner and mode before
declaring ready. The private parent covers the bind-to-chmod interval. Cleanup only the owned
device/inode, as broker.sock does. Never open a TCP compatibility bridge or an abstract socket.
Readiness failure closes the main listener too, rather than leaving a partially started Console.
The tester handler keeps its fixed principal, header/cookie refusals and exact write list.
Use a fixed logical origin `http://mosaic-tester.invalid` for URL and same-origin checks. It
does not resolve that name. Clients send that Host and Origin plus the existing Fetch Metadata
when required. A Host string is not authentication. Unix socket access supplies that boundary.
The main listener still refuses x-mosaic-tester. No tester /auth route exists.
`bus.tester` remains Jason's switch. Socket reach never enables tester authority by itself.
Turning it off must refuse every tester bus call. This row adds no user or owner capability
to the tester. Tester reads retain today's scope, including any local Console reads that are
not bus calls. This row does not turn bus.tester into a separate HTTP read switch.
### Consumers and migration paths
| Consumer of 7333 | New path |
|---|---|
| Agent tester HTTP tools and curl probes in authorized window packets | `curl --unix-socket <dataRoot>/console/tester.sock http://mosaic-tester.invalid/...`, with the fixed Origin for writes. Add one repository helper for bounded tester HTTP requests so callers do not copy path/origin logic |
| `scripts/mosaic ... --tester` | Already uses TESTER_CLI and broker.sock, not TCP. Keep its verbs, output and switch |
| Browser tester page in row 176 and later window checks | Ordinary browsers cannot navigate a Unix HTTP socket. Replace automated tester HTTP checks with the socket helper. Browser visual QA uses scratch fixtures only. No local TCP relay is added |
| `webui-service.sh` render/install and installed unit | Carry --tester-socket for new copies. Refuse a target without support. Units running old copies retain their old flags until a window |
| `stack-release.sh` activate, plan, rehearsal and rollback | Add --tester-socket. Replace inherited --tester-port N only when explicitly requested. Refuse incompatible inherited flags before stopping units. Plan names the transport and every dropped pair. Rehearsal uses a scratch socket |
| Gate/window Console checks, including agents' console-check scripts | Extend checks to use scratch Unix HTTP and expect no tester TCP bind. Sage owns live packet updates, including Q175 phase 2's old --tester-port 7333 instructions |
| `docs/TOOLS.md`, webui/cli/bus READMEs and examples | Document socket invocation, fixed origin, loss of direct browser navigation and per-instance path. Historical window receipts stay unchanged |
Repository search found no non-test hardcoded 7333 caller in tools/ or the harness code. The
active windows and external seat commands still need Sage's consumer audit before re-pin.
The build manifest will record the full search, rather than treating this draft as that audit.
## 4. D2: one verified principal per bus request
Internal interface: `verifyPrincipal(request, connection) -> {kind,business,id}` or refusal.
Kinds are owner, user and tester. The connection metadata is supplied by the trusted server,
never deserialized from JSON. No role or authority is returned. The broker looks up live policy.
1. Read and validate the bounded envelope first. For human, tester and user proofs, read Linux
SO_PEERCRED on that accepted Unix connection before dispatch. Require a valid peer PID
and the bus uid. Refuse unavailable credentials, unsupported platform and errors. Cap
traffic keeps its capability protocol behind the validated 0700 directory and 0600 socket.
2. Require exactly one cap, human, tester or future user envelope. Human/tester/user proof.pid
must equal the kernel peer PID. Verify startTime, nonce and CLI path against that process.
Keep the second process read after the walk. Missing, changed or exited processes refuse.
3. Owner terminal: run the full existing human walk. Map the business to businesses[b].human.
No supplied human id, role or uid selects the principal. Keep today's terminal invocation.
4. Tester: run verifyTester, retaining its deliberate difference from the human walk. An agent
seat may be tester. A managed-run marker or registered managed ancestor refuses. Then check
bus.tester. The full human walk must never be weakened to admit a session user.
5. Session user: use the full human walk with the path-bound user-cli entry and its own nonce
variable, MOSAIC_BUS_USER_NONCE. Parameterize the nonce reader without admitting a human
or tester nonce as a user proof. Require its immediate parent to be the same tree's resolved webui/src/cli.mjs,
argv[2] `serve`. Check parent uid, startTime and ppid again before binding. Both child and
parent remain alive for the call. A different checkout or wrapper parent refuses.
6. For user calls, the Console puts its mapped access-file user id in the verified proof's
user field, as federation 3.6 specifies. The id comes only from claims Q175 verified for
this request's live session, never from an identity cached past that session. It never
takes that id from a form field or verb
args. Return kind user, proof.business and proof.user as id only after the provenance
checks. R3 then validates that mapping and the live grant. Bad access.json denies users,
not the terminal owner. The verifier does not implement access-file policy.
7. Owner Console calls keep the verified Q175 owner subject path. Under SSO only a subject
in console.sso.subjects reaches owner bus calls. They use human-cli and the same peer/walk
checks. A terminal owner still needs no SSO token. No anonymous main-listener fallback.
The bus verifies which process supplied the session identity. It relies on that Console's
Q175 implementation to have verified OIDC. It does not independently verify an ID token in
this option. A provider/sub field alone, from another process, is not enough.
The Console must spawn user-cli directly with its nonce already in the launch environment.
It must not use human-cli's existing self-respawn pattern, which makes the CLI's parent a CLI.
Apply the same initial-nonce spawn to Console human calls for consistent tests. Terminal
human-cli can retain its self-respawn since no Console-parent condition applies there.
Never strip agent markers from inherited environments to make a walk pass.
The server binds an internal cap for this request, dispatches, and disconnects in every
success/error/timeout path, including asynchronous task and web calls. It never returns the
cap or a reusable principal handle. A proof copied by another process has a different kernel
peer PID and fails. One verified CLI may make only its one request on its one connection.
The session may hold s.user for that request. No PID or principal cache survives the connection.
### Fit with rows 193 and R3
Row 196 owns peer metadata, proof/walk verification and the internal principal interface.
It supplies the Console provenance verifier and tests before R3 adds user-cli and user sessions.
R3 owns access.json mapping and the bindUser integration, using this interface. It must not
add a second ancestry implementation. Row 193 adds viewer and share policy to the same live
grant check. An owner id is the business human. A user id is the access-file id, with stored
owner string `user:<id>`. Neither row takes role from the proof.
No live grant, unknown subject, duplicate owner/user mapping and invalid access file all
deny user binding through R3's policy. Per-request authorization, access.reload and the
30-second recheck remain R3's responsibility. Share visibility uses that check in row 193.
Binding provenance is not a grant and never widens one. R3's user dispatch must run before
names falls through to #agent. R3 must not land before this contract and its tests are green.
Gizmoduck's gizmoduck-0015 and Gander's gander-0007 agree on this split. R3 keeps the reserved
user-name mutant. Row 196 tests kind separation even if a user id resembles the owner name.
### OIDC alternative
The bus could verify the signed ID token itself and map its issuer/sub. That reduces reliance
on the Console's subject assertion, but requires JWKS/provider handling, nonce or challenge
binding, replay handling, login and logout/session lifetimes in the bus. Reusing an ID token
on every request either creates a replay credential or expires a Console session with the
token. A bus-issued session needs a separate lifecycle design. It also leaves the terminal
owner and hostile same-uid limit intact. Recommend the Console provenance contract for this
slice. Duckworth approved this option in duckworth-0006, with the live-session condition above
and the requirement to carry section 8 R2 into P19 333 word for word.
## 5. D3: obtaining kernel credentials, O1 approved with conditions
All options must bind the accepted connection, not a PID a caller picked. None gets a silent
fallback to the current proof. Sage required this comparison. Duckworth selected O1 in
duckworth-0006, with the interpreter, proof-only and image conditions below. O4 remains the
fallback if Python is unavailable, and needs a revised build manifest before use.
| Option | What it proves and costs | Recommendation |
|---|---|---|
| O1 Python stdlib helper on inherited accepted socket | Node passes the accepted net.Socket as fd 3 using documented spawn stdio stream passing. Python socket(fileno=3) reads SO_PEERCRED and returns only pid,uid,gid on stdout. No socket reads, no shell, no credential values. Adds Python to bus hosts and any future bus image, with a process per admitted proof connection. Copies carry the helper and depend on the system interpreter | Selected by Duckworth, with conditions below |
| O2 /proc/net/unix plus inode and /proc/PID/fd scans | The table identifies Unix socket entries, not an authenticated peer PID for each accepted connection. Finding a process holding an inode is racy, may have several holders and can be unreadable. The server endpoint's inode is not the client's inode | Reject as a replacement for SO_PEERCRED. No claimed proof PID may choose the scan target |
| O3 plain Node fd passing or abstract sockets | A private inherited IPC channel can authenticate a child that this parent spawned. The bus did not spawn the independently started Console or terminal CLI. Moving the bus into the Console's child tree changes lifecycle and ownership. An abstract name supplies no filesystem mode boundary and no peer uid check by itself. Node's public net API supplies no SO_PEERCRED accessor | Reject as a drop-in. A larger bus-spawned transport design could use fd capabilities, but is outside this slice |
| O4 small compiled helper or Node-API addon | Calls getsockopt directly, with no Python runtime. A helper can use the same inherited fd. Adds compiler/build closure and platform artifacts to stack-copy and containers. An addon also needs a supported way to obtain the accepted socket fd and Node version coverage | Viable alternative if Python is refused. Prefer a small helper over undocumented Node handles |
For O1, pin the system interpreter `/usr/bin/python3`, resolving its system symlink to an
absolute ELF binary. Require root ownership and no group/other write on that executable and
its path components. Refuse scripts, PATH shims and any resolved path under an OS account's
home, including /home and /root. A pyenv shim can choose an interpreter before -I -S applies.
No PATH lookup, caller-selected interpreter or helper. Invoke the resolved binary with
`-I -S`, a minimal environment and the absolute helper path in the same pinned copy.
Activation preflight records the interpreter's resolved path, version and file identity/hash
in its gate evidence. The bus validates and pins the resolved executable at each startup
before opening broker.sock. The activation gate must report the same interpreter used by
that bus. Missing, unsafe or changed resolution during that activation refuses it, never
falling back to PATH. No secret values are recorded. Test system-owned interpreter checks
through injected metadata.
N5 decision: a later bus restart repeats the safety/version/support checks and accepts a
safe system interpreter installed by an OS upgrade. It does not compare against an old
activation hash. Before opening the listener, append a startup receipt with timestamp,
copy path, resolved interpreter path, version and file identity/hash to
`<dataRoot>/bus/peercred-starts.jsonl` (0600 in the checked 0700 bus directory). Refuse an
unsafe receipt file or failed append. Never edit the old activation evidence. The startup
receipt identifies the binary now in use, so the old receipt remains a historical fact.
If an upgraded interpreter fails the checks, startup fails closed until a safe supported
system interpreter is restored by the operator. There is no shim or O4 auto-fallback.
Operator re-pin after an interpreter change: in the next authorized window, prepare the
next reviewed copy with `scripts/stack-copy.sh copy NEW`, then run
`scripts/stack-release.sh activate NEW BUSINESS --tester-socket` with that instance's existing
flags and selector, first with --plan. NEW must differ from the active copy because activate
refuses already-active. Record the interpreter in the new activation evidence and compare it
with the bus startup receipt. A routine OS update alone does not authorize a re-pin or unit
restart. A bus restart does not rewrite config, pointer or activation history.
Preflight helper and SO_PEERCRED support too. The helper lives under packages/bus/src so
stack-copy's closure includes it. Copies remain immutable. Document Python >=3.10 on bus
hosts. Today's Containerfile builds the worker image and runs run-agent.sh, not the bus.
It needs no Python change for this row. A future bus-host image must explicitly include
the pinned system Python or use reviewed O4. No install through the node_modules symlink.
Pause each proof connection while reading credentials. Limit outstanding helpers
to 16, timeout to one second, output to one strict JSON record of at most 256 bytes. Refuse
nonzero exit, extra output, malformed integers, missing fd and timeout. Validate pid, uid and
gid as safe integers, pid >=2 and uid/gid >=0. Only pid and uid select the principal. gid is
validated but grants no authority. Kill and reap helpers on shutdown. Cap envelopes never
spawn this helper. Reading the proof envelope first is sound because SO_PEERCRED describes
the peer at connect time. Keep the accepted Socket object owned by its connection closure,
not a shared current-fd variable. Only the helper's duplicate fd is closed when it exits.
References: [Node stdio stream passing](https://nodejs.org/api/child_process.html#optionsstdio),
[Node Unix socket support](https://nodejs.org/api/net.html#ipc-support),
[Python socket and getsockopt](https://docs.python.org/3/library/socket.html),
[Linux Unix credentials and permissions](https://man7.org/linux/man-pages/man7/unix.7.html),
[Linux proc network table fields](https://man7.org/linux/man-pages/man5/proc_pid_net.5.html).
These support the APIs. The connection-binding behavior still needs T2 on supported Node versions.
## 6. Migration and rollback
No config schema or store schema changes. Old copies and units keep working until each
instance re-pins. No new verifier is loaded into a running old broker. Do not pair a new
Console with an old bus and call it protected. Same-tree paths intentionally refuse mixtures.
The authorized window must pin bus, Console and human wrapper to one candidate. New tools
offer `activate ID BUSINESS --tester-socket`. Plan must show the old tester-port pair removed
and the new socket flag added, and tell Jason that direct tester browser navigation is lost.
Mos accepted that change in mos-1015 (sage-1363). Agents test through the socket. A later
authenticated browser relay behind SSO belongs to its own row. Without the explicit
conversion, a candidate that no longer
serves tester TCP refuses inherited --tester-port before stopping anything. New serve refuses
--tester-port with a migration error. It never silently changes that flag's meaning.
Rollback to a copy lacking --tester-socket drops tester enablement and reports it, as today's
tools drop unsupported flags. It does not turn the socket back into TCP. The old main Console
and owner CLI can run with their known old limits. A failed activation's recovery may restore
the old saved unit, including its TCP listener. Report that recovery as restoration of the old
security state, never as a hardened deployment. After rollback, R3 and second-human access are
not accepted until prerequisites are restored. Older operator tools cannot manage the new
flag safely. The window must use the reviewed candidate tool blob for rollback as well.
The Q173 window supplies real uid-denial evidence and the final consumer audit. No second uid,
live socket, unit, config or secret is touched in this row's tests. Row 190b or a separate host
still owns cross-instance file isolation. It is not replaced by SSO or this socket change.
## 7. Acceptance tests and mutants
All fixtures use scratch roots and ephemeral sockets, no live 7332/7333, public origin or
provider. No real second uid. Foreign uid checks use injected metadata and name that limit.
Use short TMPDIR paths so the longest Unix socket path stays under 108 bytes.
| Test | Required evidence | Mutant killed |
|---|---|---|
| T1 private tester | HTTP over scratch socket reads and writes as tester only. parent 0700, socket 0600. No tester TCP listen. Wrong Host/origin and auth cookies refuse | M1 bind loopback TCP as well, M2 remove socket mode check, M3 accept owner cookie |
| T2 real peer binding | At least 8 concurrent proof connections from distinct scratch client processes, each with its own live proof, plus a copier racing one live CLI. Barrier the helpers so connections overlap. Each genuine call must bind its own peer PID and pass, the copier must refuse. Repeat on Node 24 and 26. A lower-level helper fixture exchanges bytes both ways on the accepted socket after the helper exits, and each real bus call receives its response after helper exit. Helper failure cases all refuse | M4 use proof.pid as peer PID, M5 continue after helper error, M20 give the helper another pending connection's socket |
| T3 uid/path refusal | Inject foreign peer uid, wrong file owner/modes, symlink parent, existing socket and long path. Inject interpreter script, user-owned ELF, home path, writable ancestor and missing executable. Refuse before dispatch, no repaired paths. Unsafe pid/uid/gid and extra helper output refuse | M6 ignore uid, M7 accept unsafe parent |
| T4 ancestry | Agent env, binary and registered launch above owner/user refuse. Missing/reused PID, changed parent and cycles refuse. Clean terminal owner works | M8 omit walk, M9 omit identity re-read |
| T5 Console parent | Direct CLI child from exact tree Console fixture works. Same CLI under shell, other copy, wrapper, agent ancestor or CLI respawn refuses | M10 remove direct-parent check, M11 accept any Console basename |
| T6 principal shape | No human id or role from request body. Exactly one proof. No returned cap. Disconnect on synchronous and async failure, timeout and close. Tester agent allowed, managed tester refused | M12 accept extra identity, M13 omit disconnect, M14 use tester walk for user |
| T7 consumer migration | Render/plan/activate/rehearse fixture carries socket flag in unit and pointer. Explicit conversion removes port. Unsupported/inherited mismatch refuses before unit calls. Rollback disables unsupported tester, never opens TCP | M15 keep inherited tester port, M16 restore TCP on downgrade |
| T8 gates and identity | bus.tester false refuses tester calls. SSO owner/user wiring uses only claims verified for that request's live session. Expired/logged-out or replaced session cannot reuse an earlier mapped id. User kind never becomes owner from its id. R3 integration maps subject, denies missing/invalid grants and owner overlap, broken access file leaves terminal owner working | M17 bypass tester switch, M18 turn user kind into owner by id |
| T9 isolation limits | Two scratch instances have different sockets and matching tree paths. Foreign-uid stand-in refuses. Record that no real uid boundary was exercised | M19 choose default instance socket |
| T10 interpreter and cap path | Activation evidence records the same pinned root-owned system ELF that the bus uses. Scripts, home paths and unsafe owners/modes refuse. Simulated OS replacement followed by startup accepts a safe replacement and appends its actual identity without changing the old receipt, refuses an unsafe replacement or unsafe/unwritable receipt. Cap calls complete without invoking the helper, proven with a helper spy that fails if called | M21 bypass interpreter safety, M22 invoke helper for cap traffic, M23 retain stale interpreter identity after restart |
Tests for new policy stay in the bus, WebUI and CLI packages. R3 provides the real user/grant
half of T8 in its candidate, with its own gate. This row tests the shared verifier's user
provenance without shipping placeholder user access. Mutants run on scratch copies,
restore the original bytes and verify hashes. Two failures in a gate run stop and are reported.
Single test files run freely. Ask Sage for the slot before any suite. Final acceptance is
Duckworth's approved manifest plus Sage's bus, WebUI, CLI and scripts/test-*.sh gate.
## 8. Closure, limits and review decisions
R1. Same uid remains trusted OS authority. A hostile process may detach, clear its markers,
modify argv, read same-uid pipes or capabilities, edit policy files, or interfere with the
Console where host policy permits. SO_PEERCRED proves the connecting process identity at
connection time. It does not prove human intent, protect memory, or track a socket passed to
another process. Ordinary agent children fail the walk. Hostile detached agents are not
claimed contained. Separate uids narrow cross-instance reach, not reach within one instance.
R2. The Console provenance option trusts verified code and process metadata, not an independent
bus-side OIDC exchange. That trade must be approved, not hidden behind the phrase zero trust.
R3. A Unix tester endpoint removes direct browser navigation. No bridge is in this design.
The 7331 development board remains a separately reported reachability and impersonation risk.
P19 333 can record the following after the implementation gate and authorized re-pin:
"Tester ingress is private to the instance uid. Bus human proofs are tied to the actual
Unix peer and retain the human walk. Session-user calls require the same-tree Console as
direct parent. Q175 phase 2 separately protects main Console ingress with verified OIDC.
Hostile processes on the instance uid, root, and the independent development control board
remain outside these guarantees. Cross-instance file isolation requires the uid/host gate."
Duckworth requires that P19 333 also carry R2 word for word:
"The Console provenance option trusts verified code and process metadata, not an independent
bus-side OIDC exchange. That trade must be approved, not hidden behind the phrase zero trust."
Do not mark the historical task fully closed on a design or commit. Closure needs live
Q175 phase 2, this row's re-pin and the recorded residual accepted through Mos. A promise of
hostile same-uid isolation would require separate OS authority for agents and a new design.
Review receipt: duckworth-0006, notes at `86be181a2:agents/duckworth/work/row196/REVIEW.md`.
Q1 selects O1 with the conditions incorporated in section 5. Q2 approves Console provenance
with the live-session and unchanged P19 R2 conditions. B1 adds concurrent T2, M20 and socket
survival checks. N2's detached-Console limit remains in R1. N3's gid validation and N4's
operator window warning are included. Mos's tester-access ruling is recorded above. No
product code begins before design approval.
Duckworth approved revision 2 in duckworth-0009, review at
`cb6d840df:agents/duckworth/work/row196/REVIEW-r2.md`. N5 permits either restart policy.
Revision 3 selects safety revalidation with a new append-only startup receipt, and gives the
operator a re-pin path that respects the existing already-active refusal.
+121
View File
@@ -0,0 +1,121 @@
# Row 196 review manifest
Status: draft, not an acceptance claim. Candidate product files are uncommitted.
Base: 4d26003f731a6eec439df6a48cacc3ef482a59c8. Design r3 approved by
duckworth-0009/0015 and sage-1371. No merge, deployment or live re-pin requested.
Current path hashes are in PRODUCT-FILES.json, marked as an uncommitted draft.
Final candidate commit, exact changed-file hashes and gate totals follow after
the renewed suite slot. Sage owns that slot and the final gate. Duckworth reviews
this manifest before Sage's gate.
## Review items
| Item | Change and acceptance evidence | Mutants |
|---|---|---|
| Kernel peer proof | `bus/src/peercred.py` obtains SO_PEERCRED from inherited fd 3. `peercred.mjs` owns the accepted Socket in each connection closure, bounds helpers and validates all three safe integers. `peercred.test.mjs` overlaps eight distinct callers plus a copier and checks traffic after helper exit. | M4, M5, M20 |
| Interpreter trust | `bus/src/interpreter.mjs` resolves fixed system Python, checks root ownership, safe ancestors, ELF and version, and refuses home paths or shims. Startup appends actual identity; activation compares the fresh receipt. `interpreter.test.mjs` covers safe replacement and unchanged older receipt. | M21, M23 |
| Startup FIFO refusal | Receipt opens include O_NONBLOCK before fstat, avoiding an indefinite FIFO open. `interpreter.test.mjs`, `non-regular startup receipt refuses promptly without waiting on a FIFO peer`, was RED by timeout, then GREEN. Evidence: `evidence/interpreter-fifo-red.txt`, `evidence/interpreter-green.txt`. No live receipt was used. | M25 removes O_NONBLOCK |
| Missing helper refusal | Reader construction refuses missing, symlink or non-regular peer helper before opening the broker socket. `peercred.test.mjs`, `missing or non-regular peer helper refuses at construction before opening the bus`, was RED, then GREEN. Evidence: `evidence/peer-helper-red.txt`, `evidence/peercred-green.txt`. | M26 removes helper preflight |
| Helper shutdown | Reader close kills and awaits helper close events; server close awaits it. `peercred.test.mjs`, `shutdown waits for killed helper close before returning`, was RED, then GREEN. Evidence: `evidence/peer-shutdown-red.txt`, `evidence/peercred-green.txt`. | M27 returns without waiting |
| Shared principal | `bus/src/principal.mjs` checks kernel uid/PID, retains the human walk, separates nonce kinds and requires the exact same-tree Console serve direct parent for users. `principal.test.mjs` supplies process-table refusals. User id is provenance only; R3 owns mapping, grants and user dispatch. | M6, M8-M12, M14, M18 |
| Request lifetime | `bus/src/server.mjs` runs the helper only for proof envelopes and releases transient caps for sync/async success, refusal, timeout and close. `peer-transport.test.mjs` tests cleanup and fails if a cap request invokes the helper. | M13, M22 |
| Private tester ingress | `webui/src/tester-socket.mjs` owns 0700 console directory and 0600 socket; `serve.mjs` preserves tester policy and refuses sign-in headers/cookies. `tester-socket.test.mjs` covers unsafe paths, origin/Host, instance separation and HTTP access. | M1-M3, M7, M19 |
| Owned inode cleanup | sage-1382 selects temporary bind, rename to final, conditional final dev/ino cleanup. `tester-socket.test.mjs` asserts temporary name absent after bind/close and replacement final inode survives close. Scratch proof and remaining same-uid race are in BUILD.md. | M24 binds final directly |
| Tester policy | `bus.tester` remains Jason's switch. Existing `tester.test.mjs` verifies refusals. No user widening, new browser relay or control-board change. | M17 |
| Consumer migration | WebUI CLI, service rendering, release plan/activation/rehearsal and CLI Unix HTTP helper carry explicit --tester-socket. Inherited TCP on a new target refuses before stop without explicit conversion. Unsupported rollback disables tester and reports it. Focused service/release/tester HTTP files cover these paths. | M15, M16 |
| Proof pair ordering | Conversion plus an explicit proof pair keeps pointer and unit argv in the same order: preview, proof pair, tester socket. The focused row196 release test was RED with the old insertion scan, then GREEN after including --tester-socket in its stop list. The proof-file no-read check also passes. Evidence: `evidence/release-proof-order-red.txt` and `evidence/release-proof-order-green.txt`. | M28 omits socket from the insertion boundary |
| Path preflight before stop | Activation checks the final Unix pathname byte length and any existing parent directory's type, owner and mode before stopping units. The row196 release case reproduces a too-long path reaching service stops, then verifies length and unsafe-parent refusals leave start/stop calls unchanged. Evidence: `evidence/release-path-red.txt` and `evidence/release-path-green.txt`. | M29 removes the preflight |
| Browser fixture migration | Test-only CDP interception fulfills synthetic-origin requests from the scratch Unix listener. No TCP listener or product relay. `tester-browser.test.mjs` is 2/2 after the fixture repair, accepted in sage-1409. Evidence: `evidence/tester-browser-focused.txt`. | Product transport removal is covered by M1; browser fixture itself is tested by its two cases. |
All package paths above are under `packages/`. Mutants are prepared in
`mutants.mjs` but none have run as of this draft. Their kill and restoration
receipts must be attached before acceptance. M25-M29 supplement design M1-M24.
## Separate row164 proof baseline update
Duckworth should review this hunk separately, as requested in sage-1409.
Only two baselines in `packages/bus/tests/channel-proof.test.mjs` change.
| File | Previous sha256 | Candidate sha256 | Why |
|---|---|---|---|
| `packages/bus/src/human.mjs` | `b995fc5cff3af8ade4c990d0e3bebf9d83829e00f034ebc9c3dcfd05f252b573` | `0c6d96abe4607cba0b02b0638a2fc217ff8135bac9a47761a0ef015fbe9d3079` | Read the distinct user nonce and parameterize the existing path-bound human walk for the shared verifier. |
| `packages/webui/src/bus.mjs` | `76a1b57858c4aa41071746713a196299264851c18322a6f9869d553f0362d421` | `934ab2f9b6bde72aafe7814758550c283eb25a4f3a23b284021b13718d67178f` | Supply the initial owner CLI nonce at spawn and map peercred-required to HTTP refusal. |
The human-cli and CLI transport hashes stay fixed. Literal shim path recovery,
forged-proof refusal and the channel plug-in proof-caller import ban remain tested.
Focused evidence: `evidence/channel-proof.txt`. These hashes must be checked again
against the final candidate before review.
## Consumer inventory and window
Tester tools use `packages/cli/src/tester-http.mjs` and the selected instance's
console/tester.sock. WebUI tester test fixtures and browser fixtures use scratch
Unix sockets. The stack-release gate and rehearsal carry --tester-socket and
compare the bus startup interpreter receipt. Package READMEs and docs/TOOLS.md
show the Unix path and explicit migration. Direct browser access is lost by
Mos's accepted ruling mos-1015; a future SSO relay needs its own row. Name that
loss to Jason in the authorized window. No candidate is live from this work.
The 7331 development control board is inventory only. Its reads, /api/reply
writes and reachability remain the separate boundary described in DESIGN.md.
Row 190b remains on hold; this work does not isolate hostile same-uid processes.
## Gate history and remaining evidence
Run 1: bus 463 pass, one byte-baseline failure. Run 2: bus 464 pass; WebUI
stopped at its second fixture migration failure, with 269 observed top-level
passes and no final summary. CLI and mutants did not start. Sage received clear
in gander-0023. No timing failures were attributed to Launchpad's T56 overlap.
The two browser failures were repaired and verified in a single file.
sage-1416 delays the renewed slot until Launchpad, Darkwing, Rocko and row 192
finish. No suite or mutant batch starts before Sage's go. Remaining: final
bus/WebUI/CLI suites, M1-M29 receipts, final changed-file hashes,
verified candidate commit/push, Duckworth
manifest review and Sage's gate. No approval is implied by this draft.
Node 24 focused reruns after the shutdown change are complete: peercred 5/5 and
principal 4/4. Node 26 peer-transport now passes 4/4, including client-visible
refusal codes. Full logs are in evidence/.
## Refused caller behavior and static review
The verifier path is changed by this row. A completed proof refusal returns an
explicit protocol error before closing the connection: `human-required` for a
rejected human proof and `peercred-required` for helper failure. The real Client
receives those codes in `peer-transport.test.mjs`, without broker dispatch. No
client code is changed. Abrupt close, transport error or timeout without a valid
response still reports `outcome-unknown`; this row does not convert that case.
Quackerjack's reported failed-proof close was not reproduced against this branch's
base. A scratch comparison loads HEAD's server and the candidate with the same
refusing authenticator and unchanged Client. Both return `human-required`.
Evidence: `evidence/refusal-baseline-comparison.txt`. This comparison does not
claim to reproduce Quackerjack's exact process ancestry or instance. The manifest
therefore claims the explicit codes only for the paths verified here.
Static review covered peer reader and shutdown, runtime startup and failure
cleanup, principal/walk dispatch, tester bind/close, Console spawn, Unix client,
release conversion/recovery/preflight, unit rendering and test fixture changes.
It found the shutdown, proof ordering and preflight defects recorded above.
Each has a focused test and prepared mutant. An unrelated change to legacy
tester-port-invalid text was also removed to preserve the existing contract.
No further implementation changes are planned before the authorized batch.
## Limits carried to acceptance
No real second uid or live instance is exercised. Foreign uid metadata is a
stand-in. The authorized window still owns real uid-denial and consumer audit.
Python is a new bus-host dependency; the worker Containerfile is unchanged.
Restarts validate the current interpreter and append a receipt; old receipts
are preserved. Interpreter changes use the next reviewed copy and preserve
the already-active refusal.
Hostile same-uid processes can read or alter same-uid state, detach or interfere
with pathname checks and Console provenance. Separate uids narrow cross-instance
reach, not reach within one instance. R3 must source the proof id only from
claims verified for that request's live session and reread grants per request.
P19 333 must retain this text unchanged:
The Console provenance option trusts verified code and process metadata, not an independent
bus-side OIDC exchange. That trade must be approved, not hidden behind the phrase zero trust.
+121
View File
@@ -0,0 +1,121 @@
# Row 196 Human Boundary Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox syntax for tracking.
**Goal:** Bind human proofs to their actual Unix peers and make tester HTTP private to the instance uid.
**Architecture:** The bus reads kernel credentials through a pinned system Python helper only for proof envelopes. One principal verifier keeps the human walk and supplies Console provenance for federation R3. Tester HTTP moves to a private filesystem socket. Release tools migrate the flags explicitly.
**Tech Stack:** Linux, Node 24 and 26, Python >=3.10 standard library, existing Node test runner and shell release tools.
**Spec:** `agents/gander/work/queue-196/DESIGN.md`, approved r2 at 5fe259f16, with N5 restart policy added in r3.
## Global Constraints
- Work in the existing gander/row196 worktree. Plain node_modules symlink. No new package dependency.
- No live files, credentials, listeners, units, second uid or external authentication service.
- Helpers: proof envelopes only, at most 16 concurrent, one second timeout, 256-byte strict output.
- Root-owned system Python ELF at resolved /usr/bin/python3, never PATH or a home/shim path.
- console/ 0700, tester.sock 0600, Unix pathname at most 107 bytes.
- One principal and transient cap per request. R3 owns user access policy and user-cli integration.
- Mos accepted socket-only tester access. No TCP relay or control-board change.
- Single test files run freely. Ask Sage before any suite. Stop and report two failures in a gate run.
- Commit code only after applicable suites pass. Sage owns the final gate and manifest queue moves.
## Review Focus
- Connection overlap and disconnect: helpers must retain the correct Socket object and release capacity on every exit.
- OS interpreter replacement: restart records the actual safe binary, while old receipts remain unchanged.
- Long or unsafe instance paths: refuse before binding or stopping any service.
- Rollback/recovery: distinguish intentional tester disablement from restoration of an old saved TCP unit.
- CLI nonce respawn and session expiry: direct-parent provenance must survive correct Console spawning and refuse stale identity.
## Task 1: kernel credentials and interpreter evidence
Files: create `packages/bus/src/peercred.mjs`, `packages/bus/src/peercred.py`,
`packages/bus/src/interpreter.mjs`; tests in `packages/bus/tests/peercred.test.mjs` and
`packages/bus/tests/interpreter.test.mjs`, with subprocess fixtures under `packages/bus/fixtures/`.
Interfaces: `inspectInterpreter()` returns a validated immutable record of path, version,
device, inode and sha256. `recordInterpreterStart(directory, record)` appends the actual startup
identity safely. `createPeerReader({interpreter})` returns `{read(socket),close()}`. read resolves
to `{pid,uid,gid}` from that exact socket, or refuses. Test-only injected OS/process operations
do not become product flags or environment overrides.
- [ ] Write interpreter safety, receipt replacement and cap-independent helper tests (T3/T10).
- [ ] Run each new test file alone and record the expected missing-module failures.
- [ ] Implement system interpreter validation and startup receipt append with no-follow, owner/type/mode checks.
- [ ] Write T2's eight-process barrier plus racing copier, wrong-fd case and post-helper socket exchange.
- [ ] Implement fd-3 helper, strict result validation, bounded async reader and helper shutdown.
- [ ] Run `node --test packages/bus/tests/interpreter.test.mjs` and separately `node --test packages/bus/tests/peercred.test.mjs`.
- [ ] Confirm process/socket cleanup, eight distinct kernel PIDs, copier refusal and original receipt preservation.
## Task 2: principal provenance and bus dispatch
Files: modify `packages/bus/src/{human,tester,server,runtime}.mjs`, create
`packages/bus/src/principal.mjs`, add `packages/bus/tests/principal.test.mjs`, and extend
`packages/bus/tests/human.test.mjs`. Extend server/runtime test files after inspecting their fixtures.
Interfaces: `verifyPrincipal(request, {peer,...trustedPathsAndProcessReaders})` returns
`{kind,business,id}`. Human maps to the business human, tester to tester, user to its verified
Console proof id. No roles or grants. Server obtains peer metadata from Task 1 only after
envelope validation and only for proofs. Existing cap dispatch never invokes the helper.
Future user binding stays refused without R3's trusted integration.
- [ ] Write T4/T5/T6/T8 process-table tests, including changed ancestry, distinct nonce namespaces and kind confusion.
- [ ] Run the focused new file and confirm expected failure before implementing the verifier.
- [ ] Parameterize the nonce/path portion of the existing human walk and retain all marker/binary/launch checks.
- [ ] Wire peer metadata, interpreter startup receipt and one-request cleanup into runtime/server.
- [ ] Test cap requests with a failing helper spy (M22), and test async refusal/close cleanup.
- [ ] Run each affected bus test file alone. Record any base fixture changes needed for real peer identity.
## Task 3: tester Unix HTTP and Console nonce spawning
Files: modify `packages/webui/src/{cli,serve,bus}.mjs`, create
`packages/webui/src/tester-socket.mjs` and `packages/cli/src/tester-http.mjs`; add focused
`packages/webui/tests/tester-socket.test.mjs` and `packages/cli/tests/tester-http.test.mjs`.
Extend existing WebUI transport tests.
Interfaces: `startTesterSocket({dataRoot,...handlerOptions})` owns the private listener and
cleanup. `testerRequest({dataRoot,method,path,body})` uses the fixed logical origin and bounded
Unix HTTP. Console human child spawning supplies its initial nonce, preserving agent markers.
R3 reuses that spawn pattern for its user-cli.
- [ ] Write T1/T3/T9 socket permission, unsafe-path, wrong-origin and instance separation tests.
- [ ] Confirm the new focused files fail, then implement --tester-socket and refusal of --tester-port.
- [ ] Keep tester principal, write allowlist and cookie/header refusals intact. Add no browser relay.
- [ ] Write and implement the initial-nonce Console spawn test and the helper request timeout test.
- [ ] Run each new or changed test file alone, verifying cleanup and no tester TCP bind.
## Task 4: release migration and operator evidence
Files: modify `scripts/{webui-service,stack-release}.sh`, inspect
`scripts/stack-copy.sh` closure, extend `packages/cli/tests/stack-release.test.mjs` and its
render fixtures. Update `docs/TOOLS.md` and package READMEs with the implementation.
Interfaces: `activate ID BUSINESS --tester-socket` explicitly replaces inherited tester-port
flags. Pointer and unit argv agree. Candidate support and interpreter preflight run before
any unit stop. Activation gate evidence records the same interpreter as the bus startup receipt.
- [ ] Write T7 explicit conversion, inherited mismatch and unsupported-target refusal fixtures.
- [ ] Run only the affected test file, then implement flag rendering, plan output and rollback disabling unsupported tester.
- [ ] Preserve old-copy recovery behavior and report restored old security state accurately.
- [ ] Add rehearsal scratch socket and T10 interpreter gate identity comparison.
- [ ] Verify same-copy activation refusal is preserved. Document N5's next-reviewed-copy re-pin path.
- [ ] Run the affected file alone, shell syntax checks, and inspect remaining tester-port references by purpose.
## Task 5: mutants, approved suites and manifest
Files: add the row's mutation runner and receipts under `agents/gander/work/queue-196/`.
Append BUILD-LOG phase receipts. No SESSIONS or queue file edits.
- [ ] Exercise M1-M23 against isolated scratch copies. Record killer test, restored hash and cleanup for each.
- [ ] Run focused tests on Node 24 and 26, including the concurrent T2 case and socket lifetime checks.
- [ ] Send Sage the scope and estimated duration, obtain the suite slot, then run approved bus/WebUI/CLI suites.
- [ ] Let Sage own the scripts/test-*.sh gate. Stop on the repository's two-failure rule.
- [ ] Build the exact changed-path manifest with acceptance evidence, search results, limits and consumer audit.
- [ ] Commit/push the verified candidate only after applicable suites pass. Send Duckworth and Sage path, commit and hashes for manifest review.
Execution stays with Gander in this worktree. No new agent delegation. Duckworth reviews
the manifest independently. Push is only to gander/row196. No merge, re-pin or deployment
is authorized by this plan.
@@ -0,0 +1,186 @@
{
"status": "uncommitted draft; regenerate after final tests",
"base": "4d26003f731a6eec439df6a48cacc3ef482a59c8",
"files": [
{
"path": "docs/TOOLS.md",
"baselineSha256": "5bd1bc697cd56e57b00e60c0e80be72cd1043a55a6d2fc0b9e2ccdd0c5cf9802",
"candidateSha256": "7eba3f1ac2e5a159c9c764f1c3e4f6fdfaf06d2904cdc36338723d5ddd9044c8"
},
{
"path": "packages/bus/README.md",
"baselineSha256": "4377cc32aed37fa1bb645c0bbc3340eed4ec01740e67327449949474e35d3fdb",
"candidateSha256": "becff52b647f28079d2756b26a7e46e28f14b695f57b2dede779e7cb43af3d96"
},
{
"path": "packages/bus/fixtures/peer-client.mjs",
"baselineSha256": null,
"candidateSha256": "60720461a7994011a9a30594715eb2c992be699cd61c656e77cbd609ad05328e"
},
{
"path": "packages/bus/src/human.mjs",
"baselineSha256": "b995fc5cff3af8ade4c990d0e3bebf9d83829e00f034ebc9c3dcfd05f252b573",
"candidateSha256": "0c6d96abe4607cba0b02b0638a2fc217ff8135bac9a47761a0ef015fbe9d3079"
},
{
"path": "packages/bus/src/interpreter.mjs",
"baselineSha256": null,
"candidateSha256": "d5d98a9133a1b449acc48143fd559d890a470c6ea210b30b9b70e491e6cd855a"
},
{
"path": "packages/bus/src/peercred.mjs",
"baselineSha256": null,
"candidateSha256": "29c88655e8b9aed8db604573c9213fd9f3c40ef35ff04f4eda8fbe8f1b8a32d7"
},
{
"path": "packages/bus/src/peercred.py",
"baselineSha256": null,
"candidateSha256": "d1536a8f472fa5b3773adb10555529ed049ad63b647c0b44c00ce5f25ce09f85"
},
{
"path": "packages/bus/src/principal.mjs",
"baselineSha256": null,
"candidateSha256": "32ef39d80b523bdd586c29620d7a752c309c8e1aa70befc1414125468f3411dd"
},
{
"path": "packages/bus/src/runtime.mjs",
"baselineSha256": "fe70f4b6568fbfec85e58d34f3cb8c3624daba65487d73ad24e981f2121cb27a",
"candidateSha256": "e001bdba127fc6e7a6e95ef1864e661b2842f3c797169ab0a6ccaa5e2cd7da00"
},
{
"path": "packages/bus/src/server.mjs",
"baselineSha256": "8a1b8eac60a5885bbfd36f939b63443de303ed298bf9e1ff653a5a7bcbae76d2",
"candidateSha256": "a5788c0a0b2b78de5eb2111a47c475e100cbb0e2eca4b601e7f027cac2c56d7e"
},
{
"path": "packages/bus/tests/channel-proof.test.mjs",
"baselineSha256": "98f12f509f0e292fa0b0e3924834f9d0f285c08ea5c4d22057566c49e2bd6c68",
"candidateSha256": "e99133c3c957a1e856e404343721add2430e4a0d8c75452517aef04942af56ab"
},
{
"path": "packages/bus/tests/interpreter.test.mjs",
"baselineSha256": null,
"candidateSha256": "d19779b97e80ed3d62f58d0e798df46c23f45fa02b14fb4ac7a2ff4690a734e2"
},
{
"path": "packages/bus/tests/peer-transport.test.mjs",
"baselineSha256": null,
"candidateSha256": "a39af877a2d9143cc8d0584af0d603ca7ab651db8787e5816579762f17295887"
},
{
"path": "packages/bus/tests/peercred.test.mjs",
"baselineSha256": null,
"candidateSha256": "2c4c0bed41e546ce94535d8c4547b1cf5552a181183525dd1d52e2af1d64ccb6"
},
{
"path": "packages/bus/tests/principal.test.mjs",
"baselineSha256": null,
"candidateSha256": "cefbc0688d823c69196d94bb71467884acd639070721286d7e8e4e986c8240c1"
},
{
"path": "packages/cli/README.md",
"baselineSha256": "2c9f8fe7143aa80e59826ab53bd046de2b5e0a8f3e81fe4e3df77726bb54d50b",
"candidateSha256": "23f4c767a67b358025f0d035d2a676e14e335478c480bc44a5c303066c40709c"
},
{
"path": "packages/cli/src/tester-http.mjs",
"baselineSha256": null,
"candidateSha256": "4bd7bdf0898844684ebf9444b110d2c5b6fa874b2681183e54f1f62f97823192"
},
{
"path": "packages/cli/tests/stack-release.test.mjs",
"baselineSha256": "b01ff656962c6fdb5bc3554cfbb13645b7f25adae5f6cd9608af8de6d6091c6c",
"candidateSha256": "4c92b7cc7c4b942a1bbb9a9e24c7c1ef10e23d3f9d166bee98ed1b7f6b8de4b0"
},
{
"path": "packages/cli/tests/tester-http.test.mjs",
"baselineSha256": null,
"candidateSha256": "fbaa36e64129aee324341364bde831c379c098b97932cceadb7fcaa7909d4b20"
},
{
"path": "packages/webui/README.md",
"baselineSha256": "78c7b60d23cf20863a1cb66e16af2f9363d08acc79c5b4a344cd8af4fbe5bd44",
"candidateSha256": "8bf41140a0c013a3d77757b66e442d46c278cf8eb0f54ac26b4e477e8265279b"
},
{
"path": "packages/webui/src/bus.mjs",
"baselineSha256": "76a1b57858c4aa41071746713a196299264851c18322a6f9869d553f0362d421",
"candidateSha256": "934ab2f9b6bde72aafe7814758550c283eb25a4f3a23b284021b13718d67178f"
},
{
"path": "packages/webui/src/cli.mjs",
"baselineSha256": "4f988333ec6af225a023323dda85a159b81a77f408a8dd7bff798fe9a1f4b24b",
"candidateSha256": "fbe9db9352300fb969aa889e40323c61b1a51d01f63434964be0a8c530240e50"
},
{
"path": "packages/webui/src/serve.mjs",
"baselineSha256": "ba7d3e35b225cb4e17a4d2530a0f34d0a3734641e3982229a38207a36b3cd433",
"candidateSha256": "894bdb260af418bef7ca9dac5dd19541ca898d6f946ed12fa832bc3475234709"
},
{
"path": "packages/webui/src/tester-socket.mjs",
"baselineSha256": null,
"candidateSha256": "1c1fe0c795adac97f28502564a75f26d5666d9be5ae51169f7dd5bb7741fad16"
},
{
"path": "packages/webui/tests/browser.mjs",
"baselineSha256": "1e70a0175f1d660f6b71449a1423ece83fbe8332108960fa9aff6f7a5e48022a",
"candidateSha256": "2f3ea92cbe1ca115301ee1e3b8fc55a206842606237446b700881ee57a1b18cb"
},
{
"path": "packages/webui/tests/proof-cli.test.mjs",
"baselineSha256": "c23975b140ed977f3b98dbbec5de289e9f930cc42089193c4aa7c106338a68ba",
"candidateSha256": "105cf4936db0498d0c0025698c7ee33ddce9530b43f6c829b77afdbcb3075207"
},
{
"path": "packages/webui/tests/proof.test.mjs",
"baselineSha256": "c06a1e673ef6f3df8f0473692c828aeba853369d2b668e821f1d41e6340b6503",
"candidateSha256": "5f34d699a1bce6f46f286def1a93fdd4d863d06bec6839677fae1041be7153dd"
},
{
"path": "packages/webui/tests/service.test.mjs",
"baselineSha256": "424be2837a18a41eab56b0deb576e35b460ac16b8c371762ad44896ffc3aad7d",
"candidateSha256": "2304ced2473c25b0369b4bb9b1433c1be3ee9ab8a6f75c971487abcd48cb1d1b"
},
{
"path": "packages/webui/tests/tester-browser-fixture.mjs",
"baselineSha256": null,
"candidateSha256": "9215ff92df774ba1e83819b87f758b53c8f3d19c2824c916490165914f1cb9e8"
},
{
"path": "packages/webui/tests/tester-browser.test.mjs",
"baselineSha256": "3cc6def0c8cfb6cf7bb91a52df6e57f18071549588b35394e0083f7dc938dacf",
"candidateSha256": "89ffc5cbed1630a523080e8561d3678c1f781fb657199a5cd6cdb8189f082c73"
},
{
"path": "packages/webui/tests/tester-cli.test.mjs",
"baselineSha256": "dd74517b336e1670a5910481920ba875ffb004d52d8c6bee7cc5efc81e43215d",
"candidateSha256": "a5c6c95dbda75dce37d38d3ce4761d3ad576babd9304a046e9a62b1b5dc2fdfd"
},
{
"path": "packages/webui/tests/tester-fixture.mjs",
"baselineSha256": "2169803c50815d214cab120eaa9d1542aa3b96bfe4738d68307eb0b92c1213f3",
"candidateSha256": "7d4f3a59d21675807b1bfb92d40b7378134df450590bc6d42fa4103ab1343348"
},
{
"path": "packages/webui/tests/tester-listener.test.mjs",
"baselineSha256": "aed4cec37401bc7953b1a872349ed77c70210fcfb75ebf6d4e881a393f92c7e9",
"candidateSha256": "7f0fc2daead11048cb86e0a74b6a223314785b1740a2927461d012b876a69f18"
},
{
"path": "packages/webui/tests/tester-socket.test.mjs",
"baselineSha256": null,
"candidateSha256": "c01a942da1ad66a08a4e7c92d3c7e64f517530acfe5517f1e9895767d5ea3c0b"
},
{
"path": "scripts/stack-release.sh",
"baselineSha256": "5bb3ac40102ce515f1acdcbc5670feb161ce40a193e94e2f8b104ebf98eddc38",
"candidateSha256": "59b39d63feb77f22abb4b7152c18feff53715cf3a10dde6374c69615329868c7"
},
{
"path": "scripts/webui-service.sh",
"baselineSha256": "d514caf757c03e5d02ef39b19359669bd2476dca1877f5d237c65bd62c33d2bf",
"candidateSha256": "67cc347231d4410acf0fd845bde58aeabf099d1d6cf3e36efda4db66c5438c10"
}
]
}
+20
View File
@@ -0,0 +1,20 @@
# Row 196 execution ledger
Plan: agents/gander/work/queue-196/PLAN.md
Base: 4d26003f7. Build authorized by sage-1371, mos-1021 item 2 and mos-1023 item 3.
Pre-flight: Task 1 supplies peer reader and interpreter evidence to Task 2 and release gate in Task 4. Task 2 supplies principal provenance; R3 supplies user policy. Task 3 supplies tester transport to Task 4. Interfaces match approved design.
Ruling: retain this repository packet as the ledger instead of adding a root .superpowers directory, honoring bootstrap-only root. No code commits before applicable suites pass; Duckworth is the independent manifest reviewer. Suites and mutant batches require Sage slot.
Task 1: in progress.
Task 1: interpreter tests RED missing module -> GREEN 3/3; peer reader RED missing module -> timeout diagnosed: Node spawn stdio pauses Socket -> explicit caller resume -> GREEN 3/3. Concurrency test observed nine distinct peers, eight accepted, copier refused, post-helper bidirectional traffic. No suite/commit yet.
Task 2: principal RED missing module -> GREEN 3/3; existing human 4/4, peer-transport RED -> GREEN 2/2, transport 5/5, tester 14/14, process 8/8, all individual files. Runtime records interpreter and supplies kernel peer to shared verifier; cap traffic does not spawn helper.
Task 3: BLOCKED by design contradiction, reported gander-0016/0017 at 2026-10-20T19:34:28Z. Node/libuv closes Unix listener by unlinking its pathname even after replacement inode. Scratch cleanup assertion reproduces; conditional JavaScript cleanup is too late. Tester module not wired into serve. Recommend explicitly accepting same-uid pathname replacement as residual under private-parent boundary. No product commit, suite or mutant batch.
Task 3 resumed: sage-1382 selected Duckworth option 1. Temporary bind -> rename -> conditional final inode cleanup implemented. Replacement survival and absence of temporary name RED -> GREEN. M24 is bind-final-directly. Remaining same-uid lstat/unlink race recorded in BUILD.md. No design weakening.
Gate run 1 bus: 463 pass, 1 fail, no other packages started. Failure was row164 byte baseline, which already records intentional re-pins in later rows. Ruling: update only human.mjs and webui bus.mjs pins because their approved row196 changes are intentional, preserving the other two pins, literal entry-path tests and plug-in import ban. This does not bypass a policy refusal or weaken the proof walk.
Gate run 2: bus 464/464 pass. WebUI stopped on second failure: tester-browser.test.mjs still constructed TCP URLs using Unix address().port. 269 top-level passing cases observed, 2 failed, final suite summary unavailable due interruption. CLI and M1-M24 not started. Not timing failures. Sage notified clear in gander-0023. Node24 peer 3/3 and principal 4/4 passed. No suite/mutant rerun without renewed slot. Next: migrate scratch browser fixture without adding a product relay.
Focused repair after gate stop: tester-browser 2/2 GREEN using CDP request interception and real scratch Unix HTTP, no TCP listener or product relay. Self-review added FIFO startup-receipt refusal (RED timeout -> GREEN) and helper file preflight (RED no refusal -> GREEN). interpreter 5/5 and peer 4/4. Renewed slot requested gander-0024; suites and mutant batch still stopped.
sage-1409 accepts the fixture repair and requires separate manifest items for the row164 hashes and startup refusals, each with tests and mutants. sage-1416 delays the slot about an hour; no suite or mutant batch before go. Draft MANIFEST.md and PRODUCT-FILES.json prepared. M25 FIFO, M26 helper preflight and M27 helper shutdown are prepared, not executed. Gander-0025 reports the planned M1-M27 batch.
Focused shutdown check: peer reader now awaits killed helper close events and server close awaits the reader. peer-shutdown-red.txt records the failing assertion, peercred-green.txt the 5/5 result. Final focused Node24 reruns: peercred 5/5, principal 4/4. Node26 peer-transport 3/3 after async reader close. git diff --check clean. These are single-file checks, not renewed suite acceptance.
Consumer audit found proof insertion missed the new socket flag, making pointer and rendered unit argument order disagree. Focused row196 release fixture extended with preview + explicit proof: RED exact-order mismatch -> GREEN 1/1 after adding --tester-socket to the insertion boundary. Existing proof-file no-read check passes. Evidence release-proof-order-{red,green}.txt. M28 prepared for that boundary. Final planned mutant batch M1-M28; none executed. Shell syntax and diff whitespace checks pass.
sage-1425: complete static self-review before slot, document refused caller outcome. Real Client test now verifies human-required for proof refusal and peercred-required for helper failure, without dispatch; peer-transport 4/4. Scratch HEAD-vs-candidate comparison returns human-required in both. Quackerjack's exact failing instance is not reproduced; client.mjs unchanged and abrupt close still outcome-unknown. Manifest states the limit.
Static review complete: peer/helper lifecycle, runtime, verifier walk, tester socket, Console spawn, Unix client, release/recovery/preflight and fixtures inspected. Found release path preflight missing before stop: long-path fixture RED -> GREEN with preflight; unsafe parent also refuses before any stop. release-path-{red,green}.txt. M29 prepared. Restored unchanged legacy tester-port-invalid wording. Prepared batch now M1-M29, still unexecuted. No further implementation changes planned before Sage's go.
sage-1464 (2026-10-20T20:40Z): renewed bus/WebUI/CLI and M1-M29 slot is fifth, after rows 192, 201, 204 and 200. Continue holding until Sage's go. Fleet notice #426(a), mos-1086, live 20:32:55Z: no branch deletion; any rebase must use a new branch name; leave pr-merge.sh -d off. No rebase or deletion is planned for this row.
sage-2051 / mos-1193 (2026-10-21T18:45Z) explicitly orders committing and fast-forward pushing the current row196 build before its deferred full verification, then rebasing onto a36523713 on new gander/row196-r2. This supersedes the earlier no-product-commit-before-suite gate for this preservation commit only. Full acceptance remains pending. No tests until Sage says clear. Subsequent batch order: M1-M29, bus, WebUI, CLI, every whole scripts/test-*.sh; TMPDIR must be created with mktemp under /mnt/storage/scratch/tmp, never /tmp. Preserve the old branch. Row252 design may proceed while waiting; no row252 code before Webby approves.
@@ -0,0 +1,10 @@
✔ channel conversion preserves the four proof/transport byte baselines and modules cannot import a proof caller (0.85226ms)
✔ channel recovery still requires the same shim path and rejects an absent or forged proof (308.49278ms)
ℹ tests 2
ℹ suites 0
ℹ pass 2
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 389.108211
@@ -0,0 +1,12 @@
✔ human proof binds CLI entry, process start and nonce; agents and incomplete ancestry refuse (2.430093ms)
✔ process reader gets own kernel identity without exposing environment values (0.499236ms)
✔ EACCES ancestor environments skip only markers; commands and registered launches still refuse (0.651712ms)
✔ real pid 1 remains inspectable when its environment is protected (0.268904ms)
ℹ tests 4
ℹ suites 0
ℹ pass 4
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 60.373844
@@ -0,0 +1,28 @@
✖ non-regular startup receipt refuses promptly without waiting on a FIFO peer (1008.229518ms)
ℹ tests 1
ℹ suites 0
ℹ pass 0
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 1059.700302
✖ failing tests:
test at packages/bus/tests/interpreter.test.mjs:69:1
✖ non-regular startup receipt refuses promptly without waiting on a FIFO peer (1008.229518ms)
AssertionError [ERR_ASSERTION]: unsafe FIFO must refuse without blocking startup
null !== 0
at TestContext.<anonymous> (file:///home/jwoltje/.t3/worktrees/mosaic-stack/gander-row196/packages/bus/tests/interpreter.test.mjs:75:9)
at async Test.run (node:internal/test_runner/test:1409:7)
at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: null,
expected: 0,
operator: 'strictEqual',
diff: 'simple'
}
@@ -0,0 +1,21 @@
✔ system Python is a resolved root-owned ELF, never a shim or home interpreter (1.874163ms)
✔ real system interpreter is available and safely inspected (16.54588ms)
✔ restart receipt appends actual replacement identity and refuses unsafe files (3.340596ms)
✖ activation gate compares fresh startup receipt to the current interpreter identity (1.116553ms)
ℹ tests 4
ℹ suites 0
ℹ pass 3
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 70.983973
✖ failing tests:
test at packages/bus/tests/interpreter.test.mjs:59:1
✖ activation gate compares fresh startup receipt to the current interpreter identity (1.116553ms)
TypeError: verifyInterpreterReceipt is not a function
at TestContext.<anonymous> (file:///home/jwoltje/.t3/worktrees/mosaic-stack/gander-row196/packages/bus/tests/interpreter.test.mjs:64:15)
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,13 @@
✔ system Python is a resolved root-owned ELF, never a shim or home interpreter (1.992922ms)
✔ real system interpreter is available and safely inspected (18.869225ms)
✔ restart receipt appends actual replacement identity and refuses unsafe files (3.111417ms)
✔ activation gate compares fresh startup receipt to the current interpreter identity (1.357978ms)
✔ non-regular startup receipt refuses promptly without waiting on a FIFO peer (32.332597ms)
ℹ tests 5
ℹ suites 0
ℹ pass 5
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 105.511835
@@ -0,0 +1,35 @@
node:internal/modules/esm/resolve:272
throw new ERR_MODULE_NOT_FOUND(
^
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/home/jwoltje/.t3/worktrees/mosaic-stack/gander-row196/packages/bus/src/interpreter.mjs' imported from /home/jwoltje/.t3/worktrees/mosaic-stack/gander-row196/packages/bus/tests/interpreter.test.mjs
at finalizeResolution (node:internal/modules/esm/resolve:272:11)
at moduleResolve (node:internal/modules/esm/resolve:879:10)
at defaultResolve (node:internal/modules/esm/resolve:1006:11)
at #cachedDefaultResolve (node:internal/modules/esm/loader:708:20)
at #resolveAndMaybeBlockOnLoaderThread (node:internal/modules/esm/loader:728:38)
at ModuleLoader.resolveSync (node:internal/modules/esm/loader:766:56)
at #resolve (node:internal/modules/esm/loader:690:17)
at ModuleLoader.getOrCreateModuleJob (node:internal/modules/esm/loader:610:35)
at ModuleJob.syncLink (node:internal/modules/esm/module_job:277:33)
at ModuleJob.link (node:internal/modules/esm/module_job:389:17) {
code: 'ERR_MODULE_NOT_FOUND',
url: 'file:///home/jwoltje/.t3/worktrees/mosaic-stack/gander-row196/packages/bus/src/interpreter.mjs'
}
Node.js v26.8.1
✖ packages/bus/tests/interpreter.test.mjs (29.295027ms)
ℹ tests 1
ℹ suites 0
ℹ pass 0
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 34.780279
✖ failing tests:
test at packages/bus/tests/interpreter.test.mjs:1:1
✖ packages/bus/tests/interpreter.test.mjs (29.295027ms)
'test failed'
@@ -0,0 +1,13 @@
✔ T2 eight proof callers and a racing copier bind the exact accepted socket, which survives helper exit (244.921933ms)
✔ helper output and failures fail closed including unsafe gid (2.053177ms)
✔ helper timeout, capacity and shutdown refuse without leaving children (26.359085ms)
✔ missing or non-regular peer helper refuses at construction before opening the bus (0.377385ms)
✔ shutdown waits for killed helper close before returning (5.980511ms)
ℹ tests 5
ℹ suites 0
ℹ pass 5
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 352.965488
@@ -0,0 +1,12 @@
✔ principal binds kernel peer PID and uid, maps owner, and never takes identity or role from body (2.18658ms)
✔ user requires its own nonce and full walk plus immediate same-tree Console parent (0.652569ms)
✔ tester deliberately admits agent parents but rejects managed runs and another nonce (0.382801ms)
✔ human leaf identity is reread after walking the parents (0.23585ms)
ℹ tests 4
ℹ suites 0
ℹ pass 4
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 69.614066
@@ -0,0 +1,6 @@
{
"version": "v24.22.0",
"path": "/mnt/storage/scratch/tmp/g196-node24-gzFqsW/bin/node",
"sha256": "cc2f601cee82d9a5a233302fe307abc600727035fd7d2c3a3571755989e95008",
"source": "https://nodejs.org/dist/v24.22.0/node-v24.22.0-linux-x64.tar.xz"
}
@@ -0,0 +1,27 @@
✖ missing or non-regular peer helper refuses at construction before opening the bus (0.992412ms)
ℹ tests 1
ℹ suites 0
ℹ pass 0
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 73.967525
✖ failing tests:
test at packages/bus/tests/peercred.test.mjs:93:1
✖ missing or non-regular peer helper refuses at construction before opening the bus (0.992412ms)
AssertionError [ERR_ASSERTION]: Missing expected exception.
at TestContext.<anonymous> (file:///home/jwoltje/.t3/worktrees/mosaic-stack/gander-row196/packages/bus/tests/peercred.test.mjs:94:9)
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: false,
code: 'ERR_ASSERTION',
actual: undefined,
expected: /peercred-required/,
operator: 'throws',
diff: 'simple'
}
@@ -0,0 +1,28 @@
✖ shutdown waits for killed helper close before returning (1.761026ms)
ℹ tests 1
ℹ suites 0
ℹ pass 0
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 78.151245
✖ failing tests:
test at packages/bus/tests/peercred.test.mjs:98:1
✖ shutdown waits for killed helper close before returning (1.761026ms)
AssertionError [ERR_ASSERTION]: shutdown must await the helper close event
0 !== 1
at TestContext.<anonymous> (file:///home/jwoltje/.t3/worktrees/mosaic-stack/gander-row196/packages/bus/tests/peercred.test.mjs:106:9)
at async Test.run (node:internal/test_runner/test:1409:7)
at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: 0,
expected: 1,
operator: 'strictEqual',
diff: 'simple'
}
@@ -0,0 +1,12 @@
✔ proof receives actual peer, cap traffic never invokes helper and transient cap disconnects (7.225507ms)
✔ proof helper error and disconnect during verification never dispatch or bind (1.683542ms)
✔ Client receives explicit proof refusal codes without dispatch, not outcome-unknown (50.186199ms)
✔ asynchronous proof calls release their transient session on success, failure and timeout (28.028729ms)
ℹ tests 4
ℹ suites 0
ℹ pass 4
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 148.350042
@@ -0,0 +1,50 @@
✖ proof receives actual peer, cap traffic never invokes helper and transient cap disconnects (5.927716ms)
✖ proof helper error and disconnect during verification never dispatch or bind (1.959972ms)
ℹ tests 2
ℹ suites 0
ℹ pass 0
ℹ fail 2
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 57.64878
✖ failing tests:
test at packages/bus/tests/peer-transport.test.mjs:9:1
✖ proof receives actual peer, cap traffic never invokes helper and transient cap disconnects (5.927716ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
false !== true
at TestContext.<anonymous> (file:///home/jwoltje/.t3/worktrees/mosaic-stack/gander-row196/packages/bus/tests/peer-transport.test.mjs:15:9)
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: false,
expected: true,
operator: 'strictEqual',
diff: 'simple'
}
test at packages/bus/tests/peer-transport.test.mjs:20:1
✖ proof helper error and disconnect during verification never dispatch or bind (1.959972ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
+ actual - expected
+ undefined
- 'peercred-required'
at TestContext.<anonymous> (file:///home/jwoltje/.t3/worktrees/mosaic-stack/gander-row196/packages/bus/tests/peer-transport.test.mjs:25:9)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: undefined,
expected: 'peercred-required',
operator: 'strictEqual',
diff: 'simple'
}
@@ -0,0 +1,21 @@
✖ T2 eight proof callers and a racing copier bind the exact accepted socket, which survives helper exit (10044.708285ms)
ℹ started 9 clients
ℹ received 9 envelopes
ℹ read 9 peers
ℹ received 9 replies; after 0
✔ helper output and failures fail closed including unsafe gid (2.230205ms)
✔ helper timeout, capacity and shutdown refuse without leaving children (25.395463ms)
ℹ tests 3
ℹ suites 0
ℹ pass 2
ℹ fail 0
ℹ cancelled 1
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 10137.502565
✖ failing tests:
test at packages/bus/tests/peercred.test.mjs:15:1
✖ T2 eight proof callers and a racing copier bind the exact accepted socket, which survives helper exit (10044.708285ms)
'test timed out after 10000ms'
@@ -0,0 +1,13 @@
✔ T2 eight proof callers and a racing copier bind the exact accepted socket, which survives helper exit (274.013965ms)
✔ helper output and failures fail closed including unsafe gid (1.948532ms)
✔ helper timeout, capacity and shutdown refuse without leaving children (26.176111ms)
✔ missing or non-regular peer helper refuses at construction before opening the bus (0.331892ms)
✔ shutdown waits for killed helper close before returning (5.788665ms)
ℹ tests 5
ℹ suites 0
ℹ pass 5
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 396.110405
@@ -0,0 +1,35 @@
node:internal/modules/esm/resolve:272
throw new ERR_MODULE_NOT_FOUND(
^
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/home/jwoltje/.t3/worktrees/mosaic-stack/gander-row196/packages/bus/src/peercred.mjs' imported from /home/jwoltje/.t3/worktrees/mosaic-stack/gander-row196/packages/bus/tests/peercred.test.mjs
at finalizeResolution (node:internal/modules/esm/resolve:272:11)
at moduleResolve (node:internal/modules/esm/resolve:879:10)
at defaultResolve (node:internal/modules/esm/resolve:1006:11)
at #cachedDefaultResolve (node:internal/modules/esm/loader:708:20)
at #resolveAndMaybeBlockOnLoaderThread (node:internal/modules/esm/loader:728:38)
at ModuleLoader.resolveSync (node:internal/modules/esm/loader:766:56)
at #resolve (node:internal/modules/esm/loader:690:17)
at ModuleLoader.getOrCreateModuleJob (node:internal/modules/esm/loader:610:35)
at ModuleJob.syncLink (node:internal/modules/esm/module_job:277:33)
at ModuleJob.link (node:internal/modules/esm/module_job:389:17) {
code: 'ERR_MODULE_NOT_FOUND',
url: 'file:///home/jwoltje/.t3/worktrees/mosaic-stack/gander-row196/packages/bus/src/peercred.mjs'
}
Node.js v26.8.1
✖ packages/bus/tests/peercred.test.mjs (31.978232ms)
ℹ tests 1
ℹ suites 0
ℹ pass 0
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 37.606365
✖ failing tests:
test at packages/bus/tests/peercred.test.mjs:1:1
✖ packages/bus/tests/peercred.test.mjs (31.978232ms)
'test failed'
@@ -0,0 +1,15 @@
✔ T2 eight proof callers and a racing copier bind the exact accepted socket, which survives helper exit (259.164726ms)
ℹ started 9 clients
ℹ received 9 envelopes
ℹ read 9 peers; states false/true/false,false/true/false,false/true/false,false/true/false,false/true/false,false/true/false,false/true/false,false/true/false,false/true/false
ℹ received 9 replies; after 9
✔ helper output and failures fail closed including unsafe gid (1.460068ms)
✔ helper timeout, capacity and shutdown refuse without leaving children (26.09001ms)
ℹ tests 3
ℹ suites 0
ℹ pass 3
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 355.364452
@@ -0,0 +1,12 @@
✔ principal binds kernel peer PID and uid, maps owner, and never takes identity or role from body (1.70707ms)
✔ user requires its own nonce and full walk plus immediate same-tree Console parent (0.585886ms)
✔ tester deliberately admits agent parents but rejects managed runs and another nonce (0.289109ms)
✔ human leaf identity is reread after walking the parents (0.170627ms)
ℹ tests 4
ℹ suites 0
ℹ pass 4
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 53.605151
@@ -0,0 +1,35 @@
node:internal/modules/esm/resolve:272
throw new ERR_MODULE_NOT_FOUND(
^
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/home/jwoltje/.t3/worktrees/mosaic-stack/gander-row196/packages/bus/src/principal.mjs' imported from /home/jwoltje/.t3/worktrees/mosaic-stack/gander-row196/packages/bus/tests/principal.test.mjs
at finalizeResolution (node:internal/modules/esm/resolve:272:11)
at moduleResolve (node:internal/modules/esm/resolve:879:10)
at defaultResolve (node:internal/modules/esm/resolve:1006:11)
at #cachedDefaultResolve (node:internal/modules/esm/loader:708:20)
at #resolveAndMaybeBlockOnLoaderThread (node:internal/modules/esm/loader:728:38)
at ModuleLoader.resolveSync (node:internal/modules/esm/loader:766:56)
at #resolve (node:internal/modules/esm/loader:690:17)
at ModuleLoader.getOrCreateModuleJob (node:internal/modules/esm/loader:610:35)
at ModuleJob.syncLink (node:internal/modules/esm/module_job:277:33)
at ModuleJob.link (node:internal/modules/esm/module_job:389:17) {
code: 'ERR_MODULE_NOT_FOUND',
url: 'file:///home/jwoltje/.t3/worktrees/mosaic-stack/gander-row196/packages/bus/src/principal.mjs'
}
Node.js v26.8.1
✖ packages/bus/tests/principal.test.mjs (28.063196ms)
ℹ tests 1
ℹ suites 0
ℹ pass 0
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 33.574982
✖ failing tests:
test at packages/bus/tests/principal.test.mjs:1:1
✖ packages/bus/tests/principal.test.mjs (28.063196ms)
'test failed'
@@ -0,0 +1,16 @@
✔ broker process binds trusted launches, offers reader capabilities, refuses human mutation, closes cleanly (135.66144ms)
✔ startup token refusal returns safe code without value or partial listening broker (72.874581ms)
✔ loaded fixture token is absent from socket replies and SQLite, including refusal evidence (105.550828ms)
✔ killed broker leaves an explicit stale lock; another process cannot silently reclaim it (164.1127ms)
✔ trusted host registers later launches; socket clients never have a registration verb (109.156675ms)
✔ REQ-LIMIT-4 the broker process answers the launcher's mayLaunch op with the limits' awaited decision (100.062279ms)
✔ runtime excludes declared project roots even when host supplies no repoRoots (70.91343ms)
✔ a refused launch binding leaves the broker and existing capabilities alive; bad protocol stops it (105.059528ms)
ℹ tests 8
ℹ suites 0
ℹ pass 8
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 913.679719
@@ -0,0 +1,11 @@
✔ proof startup refuses partial flags non-preview and invalid fixture file metadata or bytes (1039.716731ms)
✔ serve argv carries only fixture proof path and header; startup explicitly reports proof on or off (241.707876ms)
✔ CLI applies proof on the main listener and rejects that header on its tester listener (232.216606ms)
ℹ tests 3
ℹ suites 0
ℹ pass 3
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 1631.223267
@@ -0,0 +1,12 @@
✔ proxy proof admits named reads and sends, reads its fixture once, and never exposes the value (33.64167ms)
✔ proxy proof refuses missing wrong short or repeated proof before auth and before any read (9.334211ms)
✔ proxy proof header is refused on other Hosts and on the tester listener (4.637787ms)
✔ proof options reject unsafe or colliding header names without disclosing file contents (0.742812ms)
ℹ tests 4
ℹ suites 0
ℹ pass 4
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 170.24723
@@ -0,0 +1,2 @@
baseline human-required
candidate human-required
@@ -0,0 +1,9 @@
✔ row196 explicit socket conversion refuses inherited TCP before stop and rollback disables socket (5883.382797ms)
ℹ tests 1
ℹ suites 0
ℹ pass 1
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 6639.610757
@@ -0,0 +1,36 @@
✖ row196 explicit socket conversion refuses inherited TCP before stop and rollback disables socket (6495.905564ms)
ℹ tests 1
ℹ suites 0
ℹ pass 0
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 7217.097801
✖ failing tests:
test at packages/cli/tests/stack-release.test.mjs:2088:1
✖ row196 explicit socket conversion refuses inherited TCP before stop and rollback disables socket (6495.905564ms)
AssertionError [ERR_ASSERTION]: stack-release: interpreter: {"path":"/usr/bin/python3","sha256":"fixture"}
stack-release: captured the rollback set: /mnt/storage/scratch/tmp/sr-eLxVuy/data-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/state/stack-rollback/20261020T201023Z-bb03a6447dc9
stack-release: stopped both units
stack-release: stopped store schema: 3g
stack-release: backup: /mnt/storage/scratch/tmp/sr-eLxVuy/data-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/backups/stack/20261020T201023Z-bb03a6447dc9
stack-release: installed bb03a6447dc98f172cbd28c2fbab126b37ff69c3
stack-release: gate passed on bb03a6447dc98f172cbd28c2fbab126b37ff69c3
stack-release: activate ok: bb03a6447dc98f172cbd28c2fbab126b37ff69c3 is live for acme
0 !== 3
at TestContext.<anonymous> (file:///home/jwoltje/.t3/worktrees/mosaic-stack/gander-row196/packages/cli/tests/stack-release.test.mjs:2108:9)
at async Test.run (node:internal/test_runner/test:1409:7)
at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: 0,
expected: 3,
operator: 'strictEqual',
diff: 'simple'
}
@@ -0,0 +1,9 @@
✔ row196 explicit socket conversion refuses inherited TCP before stop and rollback disables socket (5160.650984ms)
ℹ tests 1
ℹ suites 0
ℹ pass 1
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 6043.67883
@@ -0,0 +1,43 @@
✖ row196 explicit socket conversion refuses inherited TCP before stop and rollback disables socket (3046.881963ms)
ℹ tests 1
ℹ suites 0
ℹ pass 0
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 3691.652388
✖ failing tests:
test at packages/cli/tests/stack-release.test.mjs:2088:1
✖ row196 explicit socket conversion refuses inherited TCP before stop and rollback disables socket (3046.881963ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal:
+ actual - expected
... Skipped lines
[
'--port',
'43495',
'--business',
'acme',
...
'jason',
+ '--tester-socket',
'--proof-header',
'x-console-proof',
'--proof-file',
'/mnt/storage/scratch/tmp/sr-fLVpjD/proof.187',
- '--tester-socket'
]
at TestContext.<anonymous> (file:///home/jwoltje/.t3/worktrees/mosaic-stack/gander-row196/packages/cli/tests/stack-release.test.mjs:2102:9)
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: [ '--port', '43495', '--business', 'acme', '--public-host', 'console.example', '--auth-header', 'x-user', '--allow-user', 'jason', '--tester-socket', '--proof-header', 'x-console-proof', '--proof-file', '/mnt/storage/scratch/tmp/sr-fLVpjD/proof.187' ],
expected: [ '--port', '43495', '--business', 'acme', '--public-host', 'console.example', '--auth-header', 'x-user', '--allow-user', 'jason', '--proof-header', 'x-console-proof', '--proof-file', '/mnt/storage/scratch/tmp/sr-fLVpjD/proof.187', '--tester-socket' ],
operator: 'deepStrictEqual',
diff: 'simple'
}
@@ -0,0 +1,9 @@
✔ row196 explicit socket conversion refuses inherited TCP before stop and rollback disables socket (4452.603661ms)
ℹ tests 1
ℹ suites 0
ℹ pass 1
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 5183.817431
@@ -0,0 +1,36 @@
✖ row196 explicit socket conversion refuses inherited TCP before stop and rollback disables socket (2938.271119ms)
ℹ tests 1
ℹ suites 0
ℹ pass 0
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 3279.087225
✖ failing tests:
test at packages/cli/tests/stack-release.test.mjs:2088:1
✖ row196 explicit socket conversion refuses inherited TCP before stop and rollback disables socket (2938.271119ms)
AssertionError [ERR_ASSERTION]: stack-release: captured the rollback set: /mnt/storage/scratch/tmp/sr-QgMOBP/data/state/stack-rollback/20261020T194438Z-6e9ce64cf63b
stack-release: drop --tester-port 7333; the target Console does not support it
stack-release: stopped both units
stack-release: stopped store schema: 3g
stack-release: backup: /mnt/storage/scratch/tmp/sr-QgMOBP/data/backups/stack/20261020T194438Z-6e9ce64cf63b
stack-release: installed 6e9ce64cf63b125d007476844c4d695a308514d8
stack-release: gate passed on 6e9ce64cf63b125d007476844c4d695a308514d8
stack-release: activate ok: 6e9ce64cf63b125d007476844c4d695a308514d8 is live for acme
0 !== 3
at TestContext.<anonymous> (file:///home/jwoltje/.t3/worktrees/mosaic-stack/gander-row196/packages/cli/tests/stack-release.test.mjs:2095:45)
at async Test.run (node:internal/test_runner/test:1409:7)
at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: 0,
expected: 3,
operator: 'strictEqual',
diff: 'simple'
}
@@ -0,0 +1,9 @@
✔ row196 socket flag renders only for supporting targets and refuses mixed transports (16.910871ms)
ℹ tests 1
ℹ suites 0
ℹ pass 1
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 77.189844
@@ -0,0 +1,32 @@
✖ row196 socket flag renders only for supporting targets and refuses mixed transports (7.489228ms)
ℹ tests 1
ℹ suites 0
ℹ pass 0
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 75.646616
✖ failing tests:
test at packages/webui/tests/service.test.mjs:812:1
✖ row196 socket flag renders only for supporting targets and refuses mixed transports (7.489228ms)
AssertionError [ERR_ASSERTION]: usage: scripts/webui-service.sh copy COMMIT [--copies DIR] [--allow-worktree] | render [--copy ID [--copies DIR]] [FLAGS] | install --copy ID [--copies DIR] [--dir DIR] [--no-reload] [--allow-worktree] [FLAGS] | uninstall [--dir DIR] [--no-reload] | status [--dir DIR]
FLAGS: [--port N] [--business ID] [--board URL] [--public-host NAME --auth-header HEADER --allow-user ID [--send]] [--tester-port N] [--proof-header NAME --proof-file PATH]
--tester-port: 1024..65535, different from --port and never 7332; loopback tester listener.
--allow-worktree is for the tests only: copy and install otherwise refuse outside the canonical checkout.
4 !== 0
at TestContext.<anonymous> (file:///home/jwoltje/.t3/worktrees/mosaic-stack/gander-row196/packages/webui/tests/service.test.mjs:815:9)
at async Test.run (node:internal/test_runner/test:1409:7)
at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: 4,
expected: 0,
operator: 'strictEqual',
diff: 'simple'
}
@@ -0,0 +1,40 @@
✔ tester service render carries the tester port alongside existing flags (24.94035ms)
✔ tester service refuses forwarded 7332 even with another main port (9.82912ms)
✔ tester service invalid ports refuse render and install with exit 4 and leave the unit alone (104.005996ms)
✔ tester service counts the digits as written before it strips leading zeros (10.30812ms)
✔ tester service install and status show the tester port without changing send reporting (205.553924ms)
✔ render prints the unit without the preview flags (7.561049ms)
✔ render carries every serve flag, the preview set included (6.973808ms)
✔ a partial preview set is refused (16.637515ms)
✔ a value the unit cannot carry is refused, for every flag (256.274405ms)
✔ the rendered unit names none of the human proof agent markers and sets no agent variable (16.730737ms)
✔ install into --dir with --no-reload writes the file whole, then reports unchanged (48.962613ms)
✔ install without --no-reload runs daemon-reload and nothing that starts the unit (20.812535ms)
✔ uninstall refuses while the unit is running, with or without --no-reload, and removes it once it is not (109.285453ms)
✔ status prints the unit state and GET /healthz on the installed port (162.506277ms)
✔ usage: an unknown verb or flag, and start or enable, exit 4 (59.94317ms)
✔ without --send, render is the template with its placeholders filled, byte for byte (32.400878ms)
✔ render --send with the preview set ends ExecStart with --send and changes nothing else (52.094309ms)
✔ --send without the whole preview set is refused and writes nothing (131.187356ms)
✔ install --send into --dir with --no-reload writes the sending unit, and status says whether it sends (215.263849ms)
✔ uninstall and status take no --send (29.745082ms)
✔ install refuses while the unit is running, in any state but inactive or failed, and changes nothing (248.230583ms)
✔ when the state read fails, install and uninstall refuse with one line and change nothing (71.067475ms)
✔ install needs --copy naming a copy that exists, and refuses outside the canonical checkout without --allow-worktree (63.261377ms)
✔ install refuses unless the bus unit runs from the same copy, naming both paths, and changes nothing (235.658605ms)
✔ render --copy runs the copy and reads the queue in the checkout; without --copy the unit is unchanged (19.314052ms)
✔ the uid header takes only an Authentik uid; the username header still takes a username (300.432846ms)
✔ a preview value cannot start with - or . (28.388974ms)
✔ status names the copy and whether refactor has moved past it, and says when the unit runs from no copy (388.565391ms)
✔ proxy proof render carries both flags but never opens the fixture file (10.105723ms)
✔ proxy proof render refuses incomplete non-preview missing unsafe-mode and repo/copy paths (80.016026ms)
✔ proxy proof status retains send and tester reporting with both proof flags (97.518111ms)
✔ row196 socket flag renders only for supporting targets and refuses mixed transports (18.052409ms)
ℹ tests 32
ℹ suites 0
ℹ pass 32
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 3155.23567
@@ -0,0 +1,18 @@
{
"bus": {
"top_level_passes_observed": 464,
"top_level_failures": 0,
"final_pass_count": [
"464"
],
"interrupted": false
},
"webui": {
"top_level_passes_observed": 269,
"top_level_failures": 2,
"final_pass_count": [],
"interrupted": true
},
"mutants_executed": 0,
"cli_started": false
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,83 @@
[
{
"package": "bus",
"args": [
"--test",
"--test-reporter=tap",
"--test-concurrency=2",
"packages/bus/tests/account-migration.test.mjs",
"packages/bus/tests/agents.test.mjs",
"packages/bus/tests/approval-binding.test.mjs",
"packages/bus/tests/broker.test.mjs",
"packages/bus/tests/business.test.mjs",
"packages/bus/tests/channel-proof.test.mjs",
"packages/bus/tests/chief-of-staff.test.mjs",
"packages/bus/tests/comment-cursors.test.mjs",
"packages/bus/tests/comment-migration.test.mjs",
"packages/bus/tests/credential-status.test.mjs",
"packages/bus/tests/credentials.test.mjs",
"packages/bus/tests/csuite-messaging.test.mjs",
"packages/bus/tests/daily-quota-runtime.test.mjs",
"packages/bus/tests/decision-close.test.mjs",
"packages/bus/tests/decision-route.test.mjs",
"packages/bus/tests/decisions.test.mjs",
"packages/bus/tests/end-launch.test.mjs",
"packages/bus/tests/hard-waits.test.mjs",
"packages/bus/tests/human.test.mjs",
"packages/bus/tests/instances-verb.test.mjs",
"packages/bus/tests/interpreter.test.mjs",
"packages/bus/tests/land.test.mjs",
"packages/bus/tests/launch-notice.test.mjs",
"packages/bus/tests/limit-checks.test.mjs",
"packages/bus/tests/limit-stop.test.mjs",
"packages/bus/tests/limits-runtime.test.mjs",
"packages/bus/tests/limits.test.mjs",
"packages/bus/tests/message-citation.test.mjs",
"packages/bus/tests/messages.test.mjs",
"packages/bus/tests/messaging.test.mjs",
"packages/bus/tests/name-migration.test.mjs",
"packages/bus/tests/names.test.mjs",
"packages/bus/tests/peer-transport.test.mjs",
"packages/bus/tests/peercred.test.mjs",
"packages/bus/tests/plugins-verb.test.mjs",
"packages/bus/tests/principal.test.mjs",
"packages/bus/tests/process.test.mjs",
"packages/bus/tests/project-await.test.mjs",
"packages/bus/tests/project-files.test.mjs",
"packages/bus/tests/project-runtime.test.mjs",
"packages/bus/tests/replies.test.mjs",
"packages/bus/tests/requirements.test.mjs",
"packages/bus/tests/review.test.mjs",
"packages/bus/tests/routes-verb.test.mjs",
"packages/bus/tests/schema.test.mjs",
"packages/bus/tests/single-use.test.mjs",
"packages/bus/tests/spec-migration.test.mjs",
"packages/bus/tests/spec-review.test.mjs",
"packages/bus/tests/spec-socket.test.mjs",
"packages/bus/tests/spec.test.mjs",
"packages/bus/tests/staff-request.test.mjs",
"packages/bus/tests/state.test.mjs",
"packages/bus/tests/store.test.mjs",
"packages/bus/tests/task-page.test.mjs",
"packages/bus/tests/task-read.test.mjs",
"packages/bus/tests/tasks.test.mjs",
"packages/bus/tests/tester-threads.test.mjs",
"packages/bus/tests/tester-writes.test.mjs",
"packages/bus/tests/tester.test.mjs",
"packages/bus/tests/thread-migration.test.mjs",
"packages/bus/tests/threads.test.mjs",
"packages/bus/tests/transport.test.mjs",
"packages/bus/tests/usage-broker.test.mjs",
"packages/bus/tests/usage-migration.test.mjs",
"packages/bus/tests/usage-runtime.test.mjs",
"packages/bus/tests/usage.test.mjs",
"packages/bus/tests/web.test.mjs",
"packages/bus/tests/workflow-roles.test.mjs"
],
"log": "/home/jwoltje/.t3/worktrees/mosaic-stack/gander-row196/agents/gander/work/queue-196/evidence/suite-bus.tap",
"failures": 1,
"stopped": false,
"code": 1,
"signal": null
}
]
@@ -0,0 +1,143 @@
[
{
"package": "bus",
"args": [
"--test",
"--test-reporter=tap",
"--test-concurrency=2",
"packages/bus/tests/account-migration.test.mjs",
"packages/bus/tests/agents.test.mjs",
"packages/bus/tests/approval-binding.test.mjs",
"packages/bus/tests/broker.test.mjs",
"packages/bus/tests/business.test.mjs",
"packages/bus/tests/channel-proof.test.mjs",
"packages/bus/tests/chief-of-staff.test.mjs",
"packages/bus/tests/comment-cursors.test.mjs",
"packages/bus/tests/comment-migration.test.mjs",
"packages/bus/tests/credential-status.test.mjs",
"packages/bus/tests/credentials.test.mjs",
"packages/bus/tests/csuite-messaging.test.mjs",
"packages/bus/tests/daily-quota-runtime.test.mjs",
"packages/bus/tests/decision-close.test.mjs",
"packages/bus/tests/decision-route.test.mjs",
"packages/bus/tests/decisions.test.mjs",
"packages/bus/tests/end-launch.test.mjs",
"packages/bus/tests/hard-waits.test.mjs",
"packages/bus/tests/human.test.mjs",
"packages/bus/tests/instances-verb.test.mjs",
"packages/bus/tests/interpreter.test.mjs",
"packages/bus/tests/land.test.mjs",
"packages/bus/tests/launch-notice.test.mjs",
"packages/bus/tests/limit-checks.test.mjs",
"packages/bus/tests/limit-stop.test.mjs",
"packages/bus/tests/limits-runtime.test.mjs",
"packages/bus/tests/limits.test.mjs",
"packages/bus/tests/message-citation.test.mjs",
"packages/bus/tests/messages.test.mjs",
"packages/bus/tests/messaging.test.mjs",
"packages/bus/tests/name-migration.test.mjs",
"packages/bus/tests/names.test.mjs",
"packages/bus/tests/peer-transport.test.mjs",
"packages/bus/tests/peercred.test.mjs",
"packages/bus/tests/plugins-verb.test.mjs",
"packages/bus/tests/principal.test.mjs",
"packages/bus/tests/process.test.mjs",
"packages/bus/tests/project-await.test.mjs",
"packages/bus/tests/project-files.test.mjs",
"packages/bus/tests/project-runtime.test.mjs",
"packages/bus/tests/replies.test.mjs",
"packages/bus/tests/requirements.test.mjs",
"packages/bus/tests/review.test.mjs",
"packages/bus/tests/routes-verb.test.mjs",
"packages/bus/tests/schema.test.mjs",
"packages/bus/tests/single-use.test.mjs",
"packages/bus/tests/spec-migration.test.mjs",
"packages/bus/tests/spec-review.test.mjs",
"packages/bus/tests/spec-socket.test.mjs",
"packages/bus/tests/spec.test.mjs",
"packages/bus/tests/staff-request.test.mjs",
"packages/bus/tests/state.test.mjs",
"packages/bus/tests/store.test.mjs",
"packages/bus/tests/task-page.test.mjs",
"packages/bus/tests/task-read.test.mjs",
"packages/bus/tests/tasks.test.mjs",
"packages/bus/tests/tester-threads.test.mjs",
"packages/bus/tests/tester-writes.test.mjs",
"packages/bus/tests/tester.test.mjs",
"packages/bus/tests/thread-migration.test.mjs",
"packages/bus/tests/threads.test.mjs",
"packages/bus/tests/transport.test.mjs",
"packages/bus/tests/usage-broker.test.mjs",
"packages/bus/tests/usage-migration.test.mjs",
"packages/bus/tests/usage-runtime.test.mjs",
"packages/bus/tests/usage.test.mjs",
"packages/bus/tests/web.test.mjs",
"packages/bus/tests/workflow-roles.test.mjs"
],
"log": "/home/jwoltje/.t3/worktrees/mosaic-stack/gander-row196/agents/gander/work/queue-196/evidence/suite-bus.tap",
"failures": 0,
"stopped": false,
"code": 0,
"signal": null
},
{
"package": "webui",
"args": [
"--test",
"--test-reporter=tap",
"--test-concurrency=2",
"packages/webui/tests/assistant.test.mjs",
"packages/webui/tests/board-held.test.mjs",
"packages/webui/tests/browser-edge.test.mjs",
"packages/webui/tests/browser.test.mjs",
"packages/webui/tests/bus-browser.test.mjs",
"packages/webui/tests/bus.test.mjs",
"packages/webui/tests/channel-chat.test.mjs",
"packages/webui/tests/chat-browser.test.mjs",
"packages/webui/tests/chat.test.mjs",
"packages/webui/tests/conversation.test.mjs",
"packages/webui/tests/discord.test.mjs",
"packages/webui/tests/docs-browser.test.mjs",
"packages/webui/tests/editor-browser.test.mjs",
"packages/webui/tests/editor-provenance.test.mjs",
"packages/webui/tests/editor.test.mjs",
"packages/webui/tests/history-return-flow.test.mjs",
"packages/webui/tests/instances-browser.test.mjs",
"packages/webui/tests/instances.test.mjs",
"packages/webui/tests/limits-browser.test.mjs",
"packages/webui/tests/limits-route.test.mjs",
"packages/webui/tests/markdown-preview.test.mjs",
"packages/webui/tests/messages.test.mjs",
"packages/webui/tests/pinned-copy.test.mjs",
"packages/webui/tests/preview.test.mjs",
"packages/webui/tests/project-file.test.mjs",
"packages/webui/tests/project-name-repo.test.mjs",
"packages/webui/tests/proof-cli.test.mjs",
"packages/webui/tests/proof.test.mjs",
"packages/webui/tests/reads.test.mjs",
"packages/webui/tests/relaunch.test.mjs",
"packages/webui/tests/return-flow.test.mjs",
"packages/webui/tests/routes-browser.test.mjs",
"packages/webui/tests/routes.test.mjs",
"packages/webui/tests/send-browser.test.mjs",
"packages/webui/tests/send.test.mjs",
"packages/webui/tests/serve.test.mjs",
"packages/webui/tests/service.test.mjs",
"packages/webui/tests/settings-form.test.mjs",
"packages/webui/tests/settings-write.test.mjs",
"packages/webui/tests/shell.test.mjs",
"packages/webui/tests/tester-browser.test.mjs",
"packages/webui/tests/tester-cli.test.mjs",
"packages/webui/tests/tester-listener.test.mjs",
"packages/webui/tests/tester-socket.test.mjs",
"packages/webui/tests/usage-browser.test.mjs",
"packages/webui/tests/usage.test.mjs",
"packages/webui/tests/views.test.mjs"
],
"log": "/home/jwoltje/.t3/worktrees/mosaic-stack/gander-row196/agents/gander/work/queue-196/evidence/suite-webui.tap",
"failures": 2,
"stopped": true,
"code": 1,
"signal": null
}
]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,10 @@
✔ tester Console banner exposes only instance route and name editors and saves a route as tester (903.47494ms)
✔ row 177: the tester Console chat composer is enabled and a send shows in its own thread (779.82198ms)
ℹ tests 2
ℹ suites 0
ℹ pass 2
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 1835.567916
@@ -0,0 +1,29 @@
✖ tester cleanup must leave a replacement inode untouched (4.588498ms)
ℹ tests 1
ℹ suites 0
ℹ pass 0
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 77.335255
✖ failing tests:
test at packages/webui/tests/tester-socket.test.mjs:31:1
✖ tester cleanup must leave a replacement inode untouched (4.588498ms)
AssertionError [ERR_ASSERTION]: closing the listener must not unlink a replacement inode
false !== true
at TestContext.<anonymous> (file:///home/jwoltje/.t3/worktrees/mosaic-stack/gander-row196/packages/webui/tests/tester-socket.test.mjs:40:9)
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: false,
code: 'ERR_ASSERTION',
actual: false,
expected: true,
operator: 'strictEqual',
diff: 'simple'
}
@@ -0,0 +1,9 @@
✔ tester cleanup must leave a replacement inode untouched (4.721014ms)
ℹ tests 1
ℹ suites 0
ℹ pass 1
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 69.761924
@@ -0,0 +1,29 @@
✖ tester cleanup must leave a replacement inode untouched (5.181827ms)
ℹ tests 1
ℹ suites 0
ℹ pass 0
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 70.292577
✖ failing tests:
test at packages/webui/tests/tester-socket.test.mjs:31:1
✖ tester cleanup must leave a replacement inode untouched (5.181827ms)
AssertionError [ERR_ASSERTION]: closing the listener must not unlink a replacement inode
false !== true
at TestContext.<anonymous> (file:///home/jwoltje/.t3/worktrees/mosaic-stack/gander-row196/packages/webui/tests/tester-socket.test.mjs:41:9)
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: false,
code: 'ERR_ASSERTION',
actual: false,
expected: true,
operator: 'strictEqual',
diff: 'simple'
}
@@ -0,0 +1,10 @@
✔ tester-port validates range tunnel main port and requires configured bus before listening (842.750345ms)
✔ CLI tester listener binds a private Unix socket and spawns TESTER_CLI for every Console bus read (2447.891251ms)
ℹ tests 2
ℹ suites 0
ℹ pass 2
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 3426.404641
@@ -0,0 +1,10 @@
✔ tester HTTP helper uses instance socket, logical origin and JSON without retrying (16.169258ms)
✔ tester HTTP timeout closes the socket and does not retry (24.057725ms)
ℹ tests 2
ℹ suites 0
ℹ pass 2
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 109.82809
@@ -0,0 +1,18 @@
✔ main listener refuses x-mosaic-tester before any board or bus read for every Host (17.594021ms)
✔ tester listener rejects preview binding and requires its fixed logical Host and no proxy auth header (97.026908ms)
✔ tester listener writes only instances routes and names with tester receipts and actor (102.526377ms)
✔ tester listener refuses every human-only write with no bus call (74.180878ms)
✔ tester-off and tester-required reads return HTTP 403 (72.172231ms)
✔ tester credentials routes and committed project reads keep secrets and paths bounded (139.034531ms)
✔ row 177 W1: the tester chat makes, sends to, opens and reads its own thread as tester (19.236645ms)
✔ row 177 W2: tester chat writes refuse a missing or foreign Origin, cross-site, text/plain and the proxy header with no bus call (9.497407ms)
✔ row 177 W3: the human thread through the tester chat is thread-not-owned, and bus.tester off answers tester-off (22.342419ms)
✔ row 177 W4: the main listener refuses x-mosaic-tester on chat reads and writes before the chat (1.907ms)
ℹ tests 10
ℹ suites 0
ℹ pass 10
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 677.96388
@@ -0,0 +1,11 @@
✔ private tester HTTP serves on 0600 socket in 0700 parent and fixed origin (56.084397ms)
✔ private tester refuses symlink, unsafe parent and long path without repairs (0.822118ms)
✔ tester cleanup must leave a replacement inode untouched (1.433318ms)
ℹ tests 3
ℹ suites 0
ℹ pass 3
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 119.311098
@@ -0,0 +1,35 @@
node:internal/modules/esm/resolve:272
throw new ERR_MODULE_NOT_FOUND(
^
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/home/jwoltje/.t3/worktrees/mosaic-stack/gander-row196/packages/webui/src/tester-socket.mjs' imported from /home/jwoltje/.t3/worktrees/mosaic-stack/gander-row196/packages/webui/tests/tester-socket.test.mjs
at finalizeResolution (node:internal/modules/esm/resolve:272:11)
at moduleResolve (node:internal/modules/esm/resolve:879:10)
at defaultResolve (node:internal/modules/esm/resolve:1006:11)
at #cachedDefaultResolve (node:internal/modules/esm/loader:708:20)
at #resolveAndMaybeBlockOnLoaderThread (node:internal/modules/esm/loader:728:38)
at ModuleLoader.resolveSync (node:internal/modules/esm/loader:766:56)
at #resolve (node:internal/modules/esm/loader:690:17)
at ModuleLoader.getOrCreateModuleJob (node:internal/modules/esm/loader:610:35)
at ModuleJob.syncLink (node:internal/modules/esm/module_job:277:33)
at ModuleJob.link (node:internal/modules/esm/module_job:389:17) {
code: 'ERR_MODULE_NOT_FOUND',
url: 'file:///home/jwoltje/.t3/worktrees/mosaic-stack/gander-row196/packages/webui/src/tester-socket.mjs'
}
Node.js v26.8.1
✖ packages/webui/tests/tester-socket.test.mjs (33.788429ms)
ℹ tests 1
ℹ suites 0
ℹ pass 0
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 42.005573
✖ failing tests:
test at packages/webui/tests/tester-socket.test.mjs:1:1
✖ packages/webui/tests/tester-socket.test.mjs (33.788429ms)
'test failed'
@@ -0,0 +1,22 @@
✔ the tester binds only when the business sets bus.tester to exactly true (61.599223ms)
✔ a business may not name a role tester, so the tester address is never a role (17.534761ms)
✔ every verb outside the tester list refuses tester-refused and is recorded against the tester (37.114694ms)
✔ a tester message is from tester, needs no prefix, and writes no human.input or authority (14.658214ms)
✔ a tester message to a role no run holds refuses tester-target-not-running and stores nothing (10.010587ms)
✔ a tester message is refused while the run is only launched, and routes once it claims (11.801045ms)
✔ a human message to a role no run holds still waits and tells launch.by (9.843013ms)
✔ a role answers a tester only in reply to a tester message, and the answer never reaches the human (21.065845ms)
✔ the tester reads the human views a reader reads (23.085923ms)
✔ tester proof binds its own CLI, start time and nonce, refuses a managed run and allows an agent (1.132907ms)
✔ neither CLI's nonce proves the other: each proof is bound to its own CLI and nonce variable (0.338746ms)
✔ testerNonce reads only the tester's variable and gives null on any read error (0.167036ms)
✔ an envelope carries exactly one of cap, human and tester; no tester authenticator refuses (82.495712ms)
✔ tester-cli.mjs binds through the runtime when the switch is on, and refuses off or under a run marker (949.011498ms)
ℹ tests 14
ℹ suites 0
ℹ pass 14
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 1344.0805
@@ -0,0 +1,13 @@
✔ socket capability stamps launch identity; shared views use wire, no SQL client (55.397456ms)
✔ two wire claims serialize; a lost reply never automatically retries (48.315452ms)
✔ malformed, oversized and identity-forging envelopes refuse without echoing input (34.817302ms)
✔ client preserves UTF-8 when a response divides a multibyte character (11.903844ms)
✔ committed mutation followed by dropped reply reports unknown and is never retried (29.646174ms)
ℹ tests 5
ℹ suites 0
ℹ pass 5
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 288.566303
@@ -0,0 +1,18 @@
✔ bus routes serve the four reads from the reader view, unescaped JSON for the page to escape (67.351635ms)
✔ decisions route serves the roles and recent lists from the reader view (32.001579ms)
✔ credentials route serves the broker credential status from the reader view (11.335934ms)
✔ replies route serves the replies to one message from the reader view (33.381558ms)
✔ bus routes are GET-only, have no write verb, and keep the same-origin checks (56.18236ms)
✔ bus refusals map to statuses and never carry a path or a stack (26.580535ms)
✔ humanCall speaks the human transport protocol without blocking the server (1664.225925ms)
✔ busReader takes --business, else the live bus host, else refuses (179.394177ms)
✔ humanCall kills a transport that runs past its timeout (527.236263ms)
✔ serve refuses a --business value that is not a business id (267.848133ms)
ℹ tests 10
ℹ suites 0
ℹ pass 10
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 3002.288149
+65
View File
@@ -0,0 +1,65 @@
// Authorized batch only. Mutate isolated scratch copies, never the candidate or installed tree.
import {mkdtempSync,cpSync,readFileSync,writeFileSync,rmSync,symlinkSync,existsSync,mkdirSync} from 'node:fs';
import {tmpdir} from 'node:os';import {join,resolve} from 'node:path';
import {spawnSync,execFileSync} from 'node:child_process';import {createHash} from 'node:crypto';
import {fileURLToPath} from 'node:url';
const root=fileURLToPath(new URL('../../../../',import.meta.url));
const out=join(root,'agents/gander/work/queue-196/evidence/mutants');mkdirSync(out,{recursive:true});
const scratch=mkdtempSync(join(tmpdir(),'g196-mut-'));
const p='packages/bus/src/principal.mjs',h='packages/bus/src/human.mjs',w='packages/webui/src/tester-socket.mjs',s='packages/bus/src/server.mjs',peer='packages/bus/src/peercred.mjs';
const principal='packages/bus/tests/principal.test.mjs',socket='packages/webui/tests/tester-socket.test.mjs',transport='packages/bus/tests/peer-transport.test.mjs',interpreter='packages/bus/tests/interpreter.test.mjs';
const release=['--test-name-pattern=row196 explicit socket','packages/cli/tests/stack-release.test.mjs'];
const specs=[
['M1', 'packages/webui/src/serve.mjs','tester uses TCP',socket, text=>text.replace('if (tester) return bindTesterSocket(server, testerSocket);',"if (tester) return new Promise(r=>server.listen(0,'127.0.0.1',()=>r(server)));" )],
['M2',w,'socket mode widened',socket,t=>t.replace('chmodSync(temporary, 0o600)','chmodSync(temporary, 0o666)')],
['M3','packages/webui/src/serve.mjs','sign-in accepted on tester',socket,t=>t.replace("if (tester && (Object.hasOwn(req.headers, 'authorization')", "if (false && (Object.hasOwn(req.headers, 'authorization')")],
['M4',p,'copied PID accepted',principal,t=>t.replace('peer.pid !== proof.pid','false')],
['M5',peer,'helper parse error ignored','packages/bus/tests/peercred.test.mjs',t=>t.replace('} catch { finish(true); }','} catch { finish(false, {pid:2,uid:0,gid:0}); }')],
['M6',p,'foreign uid accepted',principal,t=>t.replace('peer.uid !== process.getuid()','false')],
['M7',w,'unsafe parent accepted',socket,t=>t.replace('(dir.mode & 0o077)','false')],
['M8',h,'agent environment walk skipped',principal,t=>t.replace('current.env !== null && MARKERS.some','false && MARKERS.some')],
['M9',h,'leaf identity not reread',principal,t=>t.replace('if (again.startTime !== proof.startTime || again.ppid !== p.ppid)','if (false)')],
['M10',p,'Console direct-parent check removed',principal,t=>t.replace("if (parent.uid !== peer.uid || parent.argv[1] !== resolve(paths.console) || parent.argv[2] !== 'serve')",'if (false)')],
['M11',p,'Console basename accepted',principal,t=>t.replace("import { resolve }", "import { resolve, basename }").replace('parent.argv[1] !== resolve(paths.console)','basename(parent.argv[1] ?? "") !== basename(paths.console)')],
['M12',h,'unknown proof identity accepted',principal,t=>t.replace("Object.keys(proof).some((k) => !['pid', 'startTime', 'nonce', 'business'].includes(k))",'false')],
['M13',s,'transient cap leaked',transport,t=>t.replace('broker.disconnect(boundCap)','void(boundCap)')],
['M14',p,'tester walk used for user',principal,t=>t.replace("verifyHuman(checked, { cliPath: paths[kind], launches, readProcess,\n nonceKey: kind === 'user' ? 'MOSAIC_BUS_USER_NONCE' : 'MOSAIC_BUS_CLI_NONCE' })",'verifyTester(checked, {cliPath:paths[kind],launches,readProcess,readNonce:()=>checked.nonce})')],
['M15','scripts/stack-release.sh','TCP retained during conversion',release,t=>t.replace('RUN_CONSOLE=("${without_tester[@]}" --tester-socket)','RUN_CONSOLE=("${P_CONSOLE[@]}" --tester-socket)')],
['M16','scripts/stack-release.sh','TCP reopened on downgrade',release,t=>t.replace('RUN_CONSOLE=("${without_tester[@]}") DROP_TESTER="--tester-socket','RUN_CONSOLE=("${without_tester[@]}" --tester-port 7333) DROP_TESTER="--tester-socket')],
['M17','packages/bus/src/broker.mjs','tester switch ignored','packages/bus/tests/tester.test.mjs',t=>t.replace("if (this.#business(record.business).vars?.['bus.tester'] !== true)",'if (false)')],
['M18',p,'user kind becomes owner',principal,t=>t.replace("return { kind: 'user', business, id: proof.user }","return { kind: 'owner', business, id: proof.user }")],
['M19','packages/cli/src/tester-http.mjs','selected instance root ignored',socket,t=>t.replace('socketPath: testerSocketPath(dataRoot)','socketPath: testerSocketPath(dataRoot + "/wrong-instance")')],
['M20',peer,'helper gets another pending socket','packages/bus/tests/peercred.test.mjs',t=>t.replace('export function createPeerReader','function originalPeerReader')+`\nexport function createPeerReader(options){const base=originalPeerReader(options),queue=[];let scheduled=false;return{read(socket){return new Promise((resolve,reject)=>{queue.push({socket,resolve,reject});if(!scheduled){scheduled=true;queueMicrotask(()=>{scheduled=false;const jobs=queue.splice(0);jobs.forEach((job,i)=>base.read(jobs[(i+1)%jobs.length].socket).then(job.resolve,job.reject));});}});},close(){base.close();}};}\n`],
['M21','packages/bus/src/interpreter.mjs','user-owned interpreter accepted',interpreter,t=>t.replace('s.uid !== 0 || (s.mode & 0o022)','false || (s.mode & 0o022)')],
['M22',s,'cap requests invoke helper',transport,t=>t.replace(' if (kind) {',' if (r.cap) await peers.read(socket);\n if (kind) {')],
['M23','packages/bus/src/interpreter.mjs','restart retains stale identity',interpreter,t=>t.replace('export function recordInterpreterStart(directory, record) {','export function recordInterpreterStart(directory, record) {\n try { readFileSync(join(directory,"peercred-starts.jsonl")); return; } catch {}')],
['M24',w,'listener binds final path directly',socket,t=>t.replace("const temporary = join(parent, '.' + randomBytes(4).toString('hex') + '.s');",'const temporary = path;')],
['M25','packages/bus/src/interpreter.mjs','FIFO receipt blocks before type refusal',['--test-name-pattern=non-regular startup receipt',interpreter],t=>t.replaceAll(' | constants.O_NONBLOCK','')],
['M26',peer,'missing helper accepted at startup',['--test-name-pattern=missing or non-regular peer helper','packages/bus/tests/peercred.test.mjs'],t=>t.replace('if (!s.isFile() || s.isSymbolicLink()) throw Error();','if (false) throw Error();').replace('catch { throw refused(); }','catch {}')],
['M27',peer,'shutdown returns before helpers are reaped',['--test-name-pattern=shutdown waits','packages/bus/tests/peercred.test.mjs'],t=>t.replace(" await Promise.all([...children].map(child => new Promise(resolve => {\n child.once('close', resolve);\n child.kill('SIGKILL');\n })));"," for (const child of children) child.kill('SIGKILL');")],
['M28','scripts/stack-release.sh','proof pair placed after tester socket flag',release,t=>t.replace('--send | --tester-port | --tester-socket) break','--send | --tester-port) break')],
['M29','scripts/stack-release.sh','unsafe tester path reaches service stop',release,t=>t.split('\n').filter(line=>!line.includes('|| check_refuse tester-socket-path')).join('\n')],
];
const hash=x=>createHash('sha256').update(x).digest('hex');
const results=[];
try {
for(const name of ['packages','scripts','adapters','roles','contracts','RELEASE','package.json','package-lock.json','.git'])if(existsSync(join(root,name)))cpSync(join(root,name),join(scratch,name),{recursive:true,filter:src=>!src.includes('/node_modules/')&&!src.endsWith('/node_modules')});
symlinkSync(join(root,'node_modules'),join(scratch,'node_modules'));
for(const [id,file,description,test,mutate] of specs) {
const at=join(scratch,file),original=readFileSync(at,'utf8'),before=hash(original),changed=mutate(original);
if(changed===original)throw Error(id+' did not change bytes');
writeFileSync(at,changed);
const syntax=spawnSync(file.endsWith('.sh')?'bash':process.execPath,[file.endsWith('.sh')?'-n':'--check',at],{encoding:'utf8'});
if(syntax.status!==0)throw Error(id+' invalid mutation: '+syntax.stderr);
const args=['--test',...(Array.isArray(test)?test:[test])];
const run=spawnSync(process.execPath,args,{cwd:scratch,encoding:'utf8',timeout:30000,maxBuffer:2*1024*1024});
const log=(run.stdout??'')+(run.stderr??'');writeFileSync(join(out,id+'.txt'),log);
writeFileSync(at,original);if(hash(readFileSync(at))!==before)throw Error(id+' restore hash failed');
const failed=/[ℹ#]\s*(?:fail|tests failed)\s+[1-9]/.test(log) || /✖/.test(log);
const killed=run.status!==0&&failed&&!/SyntaxError|ERR_MODULE_NOT_FOUND/.test(log);
const result={id,description,test,args,status:run.status,signal:run.signal,killed,restoredSha256:before};results.push(result);
writeFileSync(join(out,'results.json'),JSON.stringify(results,null,2)+'\n');
console.log(id+' '+(killed?'KILLED':'SURVIVED/INVALID'));
if(!killed) {process.exitCode=1;break;}
}
} finally {rmSync(scratch,{recursive:true,force:true});}
@@ -0,0 +1,20 @@
import {spawn} from 'node:child_process';
import {createWriteStream,writeFileSync,readdirSync} from 'node:fs';
import {fileURLToPath} from 'node:url';import {join} from 'node:path';
const root=fileURLToPath(new URL('../../../../',import.meta.url));
const results=[];
for(const pkg of ['bus','webui','cli']) {
const files=readdirSync(join(root,'packages',pkg,'tests')).filter(n=>n.endsWith('.test.mjs')).map(n=>`packages/${pkg}/tests/${n}`);
const log=join(root,`agents/gander/work/queue-196/evidence/suite-${pkg}.tap`),output=createWriteStream(log);
let failures=0,buffer='',stopped=false;
const args=['--test','--test-reporter=tap','--test-concurrency=2',...files];
const child=spawn(process.execPath,args,{cwd:root,stdio:['ignore','pipe','pipe'],detached:true});
const read=data=>{output.write(data);buffer+=data;const lines=buffer.split('\n');buffer=lines.pop();for(const line of lines)if(/^not ok /.test(line)){failures++;if(failures>=2&&!stopped){stopped=true;process.kill(-child.pid,'SIGTERM');}}};
child.stdout.on('data',read);child.stderr.on('data',data=>output.write(data));
const status=await new Promise(resolve=>child.on('close',(code,signal)=>resolve({code,signal})));
await new Promise(resolve=>output.end(resolve));
results.push({package:pkg,args,log,failures,stopped,...status});
writeFileSync(join(root,'agents/gander/work/queue-196/evidence/suite-results.json'),JSON.stringify(results,null,2)+'\n');
console.log(pkg+' '+JSON.stringify(status)+' failures='+failures);
if(status.code!==0||failures){process.exitCode=1;break;}
}
-8
View File
@@ -1,8 +0,0 @@
# Herb
Builder seat, Codex gpt-6.1-sol, started by Sage under mos-0977 item 5 (more Codex Sol rows) for a second gate lane
design row (#1695, mos-0977 item 6).
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
credentials of its own; until the credentials row settles per-agent identity, it pushes only its own `herb/*`
branches through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
-8
View File
@@ -1,8 +0,0 @@
#!/bin/bash
n=0
for p in /proc/[0-9]*; do
c=$(tr '\0' ' ' < $p/cmdline 2>/dev/null) || continue
case "$c" in *shell-snapshots*|*chrome-devtools-mcp*|*sage-busy-now*) continue;; esac
if echo "$c" | grep -qE 'node --test|gate\.sh|mutant|scripts/test-[a-z-]+\.sh|docker compose'; then echo "BUSY ${p#/proc/}: ${c:0:160}"; n=$((n+1)); fi
done
[ $n -eq 0 ] && echo CLEAR
@@ -1,24 +0,0 @@
#!/bin/bash
set -o pipefail
# Landing gate template (2026-10-14): sed the G= and OUT= lines and this comment.
# Sequential suites, each teed to its own log; stops at the first failure.
G=/mnt/storage/scratch/SET-ME-wt
OUT=/mnt/storage/scratch/SET-ME-out
cd "$G" || exit 9
mkdir -p "$OUT" || exit 9 # a missing OUT failed the row 142 gate at step 1 (2026-10-15)
# test-task.sh live cases run `docker compose` from $G, so without a name the project is "wt"
# and leaves wt_default behind (row 142 gate, 2026-10-15). Name it per gate dir and remove it on exit.
export COMPOSE_PROJECT_NAME="$(basename "$(dirname "$G")")"
trap 'docker network rm "${COMPOSE_PROJECT_NAME}_default" >/dev/null 2>&1 && echo "removed network ${COMPOSE_PROJECT_NAME}_default"' EXIT
# A gate tree without the node_modules link fails cli tests with ENOENT (2026-10-13).
[ -d "$G/node_modules" ] || { echo "STOP no node_modules in $G"; exit 8; }
echo "base $(git rev-parse --short HEAD) dirty $(git status --short | grep -vc node_modules) DOCKER_HOST=${DOCKER_HOST:-unset}"
run() { local name="$1" dir="$2" cmd="$3"
(cd "$G/$dir" && bash -c "$cmd") > "$OUT/$name.log" 2>&1; local rc=$?
echo "$name rc=$rc $(grep -E '^ℹ (pass|fail) [0-9]+' "$OUT/$name.log" | tr '\n' ' ') | $(tail -n1 "$OUT/$name.log")"
[ $rc -eq 0 ] || { echo "STOP $name"; exit $rc; }; }
# No pkg-queue step: scripts/test-queue.sh runs packages/queue/tests/ itself (same 156 tests; CI debate item 2a, Vikunja #274).
for d in packages/*/; do p=$(basename "$d"); [ -d "$d/tests" ] || continue; [ "$p" = queue ] && continue
run "pkg-$p" "packages/$p" 'node --test tests/*.test.mjs'; done
for s in scripts/test-*.sh; do n=$(basename "$s" .sh); run "script-${n#test-}" . "bash $s"; done
echo GATE_PASSED
+40 -29
View File
@@ -367,7 +367,7 @@ reports checkout or copy identity and refactor drift, and writes nothing.
```
scripts/stack-release.sh adopt BUSINESS
scripts/stack-release.sh activate ID BUSINESS [--pm | --pm-after-harness] [--migrate] [--tester-port N]
scripts/stack-release.sh activate ID BUSINESS [--pm | --pm-after-harness] [--migrate] [--tester-socket | --tester-port N (legacy copies)]
[--proof-header NAME --proof-file PATH | --no-proof] [--timeout S] [--plan]
scripts/stack-release.sh pm-on BUSINESS [--timeout S]
scripts/stack-release.sh rollback BUSINESS [--restore ACTIVATION [--events N]] [--timeout S] [--plan]
@@ -461,7 +461,8 @@ refuses `capture-failed` before it stops anything. Adopt uses the same rule.
The Console's row 178 proof pair gets into the pointer only through `activate`
(row 187). `--proof-header NAME --proof-file PATH` puts the pair in the
pointer's Console flags and the unit after the preview flags and before `--send`
and `--tester-port`, where `webui-service.sh` renders it, replacing any pair
and the tester flag (`--tester-socket`, or `--tester-port` for legacy copies),
where `webui-service.sh` renders it, replacing any pair
already there. `--no-proof` drops it. Either may name the live copy, which a
plain activate refuses with `already-active`, so the proof goes on or off
without changing copies; `--no-proof` with no pair in the pointer still refuses
@@ -921,36 +922,46 @@ not claim an assistant answer; an uncertain receipt is never retried. Read the
current list/history before trying again. Activation still needs Mos's separate
window; changing these files does not authorize a live Console action.
### Console tester listener (`serve --tester-port N`, row 176)
### Console tester listener (`serve --tester-socket`, row 196)
`node packages/webui/src/cli.mjs serve --tester-port N` adds a second listener
bound only to `127.0.0.1:N`. N must be an integer in 1024..65535, different
from `--port` and never 7332 (the tunnel's forwarded port). It requires the
bus configuration. It calls only the tester shim; the main listener keeps its
existing human transport and rejects every `x-mosaic-tester` request.
The tester listener rejects the main preview auth header and only accepts its
own loopback Host/port. Its Settings banner identifies the tester. Its instance,
route and name editors use the tester's three writes; settings, document saves,
assistant stop, role messages and chat writes stay refused. Writes require the
exact `http://127.0.0.1:N` Origin, same-origin Fetch Metadata, JSON and the existing
bounded strict bodies. `tester-off` and `tester-required` answer HTTP 403.
`node packages/webui/src/cli.mjs serve --tester-socket` adds private HTTP at
`<dataRoot>/console/tester.sock` (0600) under console/ (0700). The path must fit
107 bytes. Unsafe ownership, modes, symlinks and pre-existing entries refuse.
The main Console remains on its configured TCP port. New copies refuse --tester-port.
`scripts/webui-service.sh render|install --tester-port N` carries the flag into
ExecStart, and status names its URL. Bad tester ports exit 4. Install remains a
unit write for the authorized operator, with no start/restart verb.
The tester's fixed Host is mosaic-tester.invalid and its Origin is
http://mosaic-tester.invalid. No DNS is used. POST also needs same-origin Fetch
Metadata and bounded JSON. The endpoint uses only TESTER_CLI. It admits the
existing instance, route, name and tester-chat writes, never owner settings,
document saves, assistant stop or role messages. bus.tester stays Jason's switch.
Authorization, login/session cookies and configured proxy headers refuse.
`stack-release.sh activate ID BUSINESS --tester-port N` replaces or adds this
Console flag in both the installed unit and the pointer. It checks the target
copy's own Console before stopping anything; an explicit unsupported request
refuses `tester-port-target`. A port outside 1024 to 65535, 7332 or the
Console's `--port` is a usage error (exit 4). Under `--plan` both are listed
with the plan's other refusals instead, the bad port as `tester-port-invalid`,
and the plan exits 3. Without an explicit flag, activate and rollback
keep it between supporting copies, or drop it when the target predates support.
The plan prints the final Console flags and any dropped `--tester-port N`;
the steps log records a drop too. `pm-on` keeps the pointer's Console flags.
These operations retain their existing Mos/Jason window authority. Nothing in
this row forwards the tester port through the tunnel or changes live units.
```sh
node packages/cli/src/tester-http.mjs GET /api/bus/agents
printf '%s' '{"name":"fixture"}' | node packages/cli/src/tester-http.mjs POST /api/chat/projects
```
The helper loads the selected instance config and reports HTTP status/text/body
as JSON, exiting 2 for refusal. It never retries writes. Direct browser access
is removed with Mos's acceptance (mos-1015). A future authenticated browser relay
behind SSO is a separate row. No TCP compatibility bridge is provided.
`webui-service.sh render|install --tester-socket` checks target support and carries
the flag into ExecStart. `stack-release.sh activate ID BUSINESS --tester-socket`
explicitly replaces an inherited --tester-port pair in both unit and pointer.
Without explicit conversion a new target refuses tester-transport-migration before
stopping anything. Unsupported targets refuse tester-socket-target. Rollback drops
unsupported socket enablement, visibly in plan/steps, without enabling TCP. Legacy
flags remain supported by the release tool for legacy target copies only. Failed
activation recovery can restore the previous TCP unit and its old limits.
Activation of a new copy preflights the pinned root-owned system Python ELF,
records its resolved path/version/identity/hash and checks the bus's fresh startup
receipt. Restarts revalidate and append actual interpreter identity to
`<dataRoot>/bus/peercred-starts.jsonl`, without comparing an old activation hash.
After an OS interpreter update, the next authorized re-pin uses a different reviewed
copy and refreshes activation evidence. Do not bypass already-active or use a PATH shim.
All live operations keep their existing window authority.
Console proxy proof (row 178, #1676): `serve`, `webui-service.sh render` and
`install` accept `--proof-header NAME --proof-file PATH` together, under preview
+32 -63
View File
@@ -1,77 +1,46 @@
# Brief: Melanie's hub instance on its own host (row 195, was 190b)
# Brief: Melanie's hub instance under its own Linux user (row 190b)
Sage, 2026-10-20. Rewritten for Mos's rulings mos-0998 and mos-1005 (Vikunja #418, #419). The first version
put the hub under its own Linux user on dragon-lin. That form can't pass Duckworth's B7 there, because the
factory uid's `sudo -n` succeeds on dragon-lin (Mos's recon, brain-redux
`projects/mosaic-mvp/MELANIE-HOST-RECON.md`). Melanie's own server is ruled out too: it has 1.2 GiB RAM free
with her two Hermes bots running, and co-tenancy would share her user and her passwordless sudo. The hub runs
on a separate host. Which host is Jason's decision (#419): hardware outside the cluster, a cluster VM with
factory PVE root removed, or a cluster VM with that residual accepted. This row is written so the host choice
doesn't change it. Gitea #1693. On the 10-27 MVP path. Federation R8 (Gizmoduck's design r4, 04a6112be) needs
this row or nothing.
Sage, 2026-10-20, from Darkwing's draft (agents/darkwing/work/queue-190b/BRIEF-DRAFT.md) for Mos's ruling
mos-0986 (2026-10-20T18:53:48Z) on row 190 R1. Mos ruled a separate uid per instance a hard precondition of
federation R8 (mos-0984, mos-0985) and of Case 2 row C2-13. The finding was federation review B5
(duckworth-0001): on one uid any factory agent can read, write and control Melanie's hub directly, and SSO and
RBAC don't stop a local process with filesystem access. On the 10-27 MVP path. Gitea #1693.
Owner Darkwing (Opus 5.5), reviewer Duckworth (author of B5 and B7), gate owner Sage. Chained on row 190
(Q177): the hub runs under row 190's config canon. Design first. The first deliverable is
`agents/darkwing/work/queue-190b/DESIGN.md`.
Owner Darkwing (Opus 5.5), reviewer Duckworth (author of B5; mos-0981 keeps new work off Filbert and Dewey),
gate owner Sage. Merge-chained on row 190 (Q177): it builds on row 190's candidate and keeps its own review and
gate. Design first. The first deliverable is `agents/darkwing/work/queue-190b/DESIGN.md`.
## Goal
Melanie's hub instance (Case 2, C2-13) runs on its own host. No factory agent can read or write the hub's
config, data root or secrets, reach its bus socket, or log in to the hub host. Her writing reaches the hub only
through the existing git.mosaicstack.dev remotes, books repo only.
## Provision (blank until Jason answers #419)
Who provisions the host and how is left open. Sizing from Mos (mos-0998): 4 vCPU, 8 GB RAM, 64 GB disk,
internal network only. Each agent process measures 300 to 390 MB RSS on dragon-lin. The design takes the host
as given: a fresh Linux host with a named admin path, reachable by the deploying seat while the row runs.
Melanie's hub instance runs under its own Linux user. No process of the factory instance can read or write the
hub's config, data root or secrets, or control the hub's units. That covers the bus, role seats and their
sessions, task runs, the Discord connector and the Console.
## The design must state
1. The deploy. Exactly what Darkwing installs on the hub host and how: the stack copy, node, the units, the
config dir and data root under row 190's canon (O1: `~/.config/mosaic-NAME`, and "`~` is the home of the OS
user that runs the instance"). Which commands run as which user on the hub host, and which need its root.
2. Re-pin, rollback and backup on the hub host, and who runs them after the deploy key is gone.
3. How the hub gets model access without a copy of jwoltje's `claude-session.env` or any factory credential.
If that needs the credentials row (T1 item 6), say so and name the interim. If it needs a new subscription
or a new provider account, flag it for Jason and don't pick one (mos-1008).
4. The books repo path. Which git.mosaicstack.dev remote, which credential the hub holds for it, and that it
can't read or push any other repository.
5. The admin path and the end state. The host ends with no key the factory uid can read. The design names the
admin path that remains (for example the hypervisor console, or a key kept off the factory uid) and who
holds it.
6. The acceptance script, as code Duckworth can read. It takes Gizmoduck's list of infrastructure gate items
as its input (mos-1011), not a fixed set of checks. Each item names a path, its closed form and its
accepted form, and the script reports which form holds for each, or fails. The list covers at least:
- federation R8's separate-host form: the hub's bus socket, data root and config dir don't exist on the
factory host, the factory host mounts nothing of the hub's, and no factory agent holds a login or key
for the hub host
- B7: from the factory uid, `sudo -n`, `doas -n`, `su`, polkit, and `ssh -o BatchMode=yes` to the hub
host all fail
- the hypervisor and backup paths ops-01 measured (mos-1011: one PVE node the factory key has root on,
PBS on that node with unencrypted backups, a flat /16 LAN), each closed or accepted by Jason
7. The identity provider and TLS front end (B8, mos-1008, mos-1011). Either the factory holds no credential
that can create users, change groups, issue tokens or impersonate in the issuer the hub trusts, or the hub
uses an issuer the factory can't administer. A brand, tenant or app inside the same Authentik does not
count as separate. The same holds for the TLS front end that serves the hub. The proof is a failed admin
call from every token the factory's root can read. No credential value is printed in that proof.
1. How operators (Mos, Sage, Jason) deploy, re-pin and roll back the hub. Which commands they run, as which
user, and what each one needs.
2. Each root step Jason runs: useradd, the home and its modes, `loginctl enable-linger`, and any sudoers rule,
with the exact command lines.
3. How the hub reaches the Claude proxy and gets its session env without sharing jwoltje's
`claude-session.env`.
4. Whether real isolation needs the factory instance's seats to run apart from the operators' uid. If yes, say
so plainly with its cost. Don't design around it.
It must also cover the copies (shared read-only trees under jwoltje's home, or the hub's own), the human CLI
wrapper and human proof for the hub's Console, and how the stack-release pointer, log and backups work under
the second user.
## Boundaries
- Provisioning, hypervisor access and any infra or k3s change are Jason's (Q173 exclusions). Mos takes the
hypervisor residual (factory root on the PVE nodes, Q172) to Jason. It doesn't block this row.
- Melanie's server (`[email protected]`) is reference only, read only, under Jason's authorization in
mos-0995. Nothing on it changes, and her Hermes bots keep running.
- No tunnel, Traefik or other exposure outside the internal network. That waits for Jason, after Q175 SSO.
- No credential value is printed, logged or committed. Keys made for the deploy are removed at the end, and
the acceptance script proves it.
- The admin-call proof in point 7 and any call to the issuer or the TLS front end run only in a window Mos
approves, never as a test or an anonymous probe (Q175).
- Tests in the repository use scratch roots and stand-ins. The live deploy and the acceptance run on the hub
host are a window with a backup first (where there is state) and a STATE report after.
- Row 190 lands first, as designed: one uid, scratch two-instance tests without root.
- No agent runs a root step. Every one goes to Jason as an exact command list.
- No real hub user, unit or data root is created in this row's tests. Tests use scratch roots and stand-ins as
row 190 does. Anything that needs a second real uid is a Q173 window with a backup first and a STATE report
after.
- No tunnel or Traefik exposure. That waits for Jason, after Q175 SSO.
## Gate
Duckworth approves DESIGN.md, then the manifest. Any repository change is green on Sage's gate (cli suite and
every `scripts/test-*.sh`), with the design's mutants killed. The row is done when the acceptance script passes
on the hub host from the factory uid, Duckworth reads its output, and Mos confirms.
Duckworth approves DESIGN.md, then the manifest. The cli suite and every `scripts/test-*.sh` are green on
Sage's gate. The design's mutants are killed.
@@ -1,42 +0,0 @@
# Brief: retire the control board writer on 7331
Sage, 2026-10-20, for Mos's ruling mos-1008 item 2. Gitea #1696. MVP 10-27 work. Owner Bushroot (Haiku 5.5, a
low-level model under Q177), reviewer Webby, gate owner Sage. Base: refactor at the brief commit.
## Why
`packages/control-board/src/serve.mjs` is a loopback HTTP service on 7331 with no authentication. It reads
transcripts and writes through `/api/reply`. Any host process that reaches the port can do both. That fails
Q175 ("zero trust with verification"). Gander found it in the row 196 inventory (gander-0004). Nothing listens
on 7331 now (`ss -ltnp`, 2026-10-20T19:05:09Z), so retiring it costs the least.
## Steps, in order
1. Inventory first. List every reference to the control board and to 7331: scripts, units, `scripts/mosaic`
and other CLI dispatch, docs (AGENTS.md, READMEs, TOOLS.md), package.json scripts, tests, and the Console
(`packages/webui`). For each, say whether it starts the writer, reads its code, or only mentions it.
Commit the list as `agents/bushroot/work/queue-ROW/INVENTORY.md` and send it to Sage and Webby.
2. Stop if something depends on it. If a reference shows a live dependency (the Console or another package
imports code that only the control board has, or a unit or script still starts it), stop. Sage takes it to
Mos before anything is removed.
3. Otherwise retire the writer. Either remove the `serve` entry point, or make it refuse to start with a
message that points to the Console. Keep any read-only code another package imports, and say which.
Update the docs that tell people to start it. The AGENTS.md text about the control-board reply convention
is a separate matter. Change it only if it tells someone to start 7331.
## Acceptance
Nothing in the stack can start a writer on 7331. A test proves it: the entry point refuses (or is gone), no
script or unit starts it, and a grep check finds no starter. Each change gets a mutant that brings the writer
back and the test kills it.
## Boundaries
- No change to the live units, `~/.mosaic-dev`, `~/.config/mosaic-dev`, slot files or `~/.mosaic`.
- No request to any live port.
- Single test files run freely. Suites need Sage's slot.
## Gate
Webby approves INVENTORY.md, then the manifest. The control-board and webui package suites, the cli suite and
every `scripts/test-*.sh` are green on Sage's gate, and the mutants are killed.
-42
View File
@@ -1,42 +0,0 @@
# Brief: a second gate lane, two full gates at once on dragon-lin
Sage, 2026-10-20, for Mos's order mos-0977 item 6 ("put the design for a second gate lane on the list now")
and the MVP push (mos-0974). Gitea #1695. Owner Herb (Codex gpt-6.1-sol, a new builder seat), reviewer
Webby, gate owner Sage. Base: refactor at the brief commit. Design first. The first deliverable is
`agents/herb/work/queue-ROW/DESIGN.md`.
## Why
Every full run shares one slot today. A full gate (cli suite plus every `scripts/test-*.sh`) takes about 13
minutes or more, reviewers' runs and mutants take the same slot, and with eight or more seats building for
10-27 the slot is the bottleneck. The slot exists because concurrent runs broke each other. The busy check is
`agents/sage/work/gate-lane/busy-now.sh` (a copy of Sage's), and the gate is
`agents/sage/work/gate-lane/gate-template.sh` (a copy of Sage's template). The host has 16 cores and 62 GB.
## What the design must settle
1. Every shared resource two concurrent runs can collide on, found from the code and tests, not guessed:
Docker compose project names and networks (`scripts/test-task.sh` and others), fixed TCP ports in tests,
unix socket paths and their 107-byte limit under TMPDIR, the sandbox's private `/tmp`, shared caches,
scratch and worktree paths, the queue lock, and any test that reads host-wide state (process lists,
`pgrep`, `/proc`). For each: how a lane gets its own copy, or why it is safe to share.
2. Timing. Which tests have time limits that a doubled CPU load could break, measured, and what changes (a
bound, or a lane rule that keeps those suites apart).
3. The lane mechanism: lane ids, a lock per lane that a run takes and releases even on a crash, the busy
check that reports per lane, and how a seat asks for a lane. Sage still grants it.
4. The gate template takes a lane id and isolates everything from point 1 by it.
5. Proof: two full gates on different candidates at once, three times, with zero cross-failures, plus the
wall time of each against a single-lane run. Any flake found is named with its cause.
## Boundaries
- No change to the live units, `~/.mosaic-dev`, `~/.config/mosaic-dev` or slot files. The proof runs use
scratch roots only, and it needs Sage's grant of the whole slot while it runs.
- Test code changes that only isolate a resource are in scope. A change to what a test checks is not, and
goes to its owner.
- Docker networks and containers a run makes are removed by the run (trap), and nothing else is touched.
## Gate
Webby approves DESIGN.md, then the manifest. The cli suite and every `scripts/test-*.sh` are green on Sage's
gate, both singly and in the two-lane proof.
+35 -2
View File
@@ -9,12 +9,14 @@ returned to a client or written to the database.
Slice 1 shares an OS user. Files, capabilities, process ancestry checks and
hooks are **not a wall against a hostile same-UID process**. In particular,
Linux `/proc` checks here are not SO_PEERCRED authentication. The trusted host
The ancestry walk remains cooperative. Human/tester/user proof envelopes are now
bound to the connecting PID and uid using Linux SO_PEERCRED before dispatch. The trusted host
and well-behaved clients must follow this protocol. Containers remain later work.
## Start and stop
Requires Linux and Node 24 or 26, local disk, no dependencies. The data root must
Requires Linux, Node 24 or 26, local disk and system Python >=3.10. The bus pins
the root-owned ELF resolved from /usr/bin/python3, never a PATH shim or home binary. The data root must
already exist and belong to the user. A broker creates `bus/` mode 0700 and the
DB/lock/socket mode 0600. It refuses symlinks or unsafe existing permissions.
@@ -882,3 +884,34 @@ on the session and written on each usage record; a pi run's is null. The
account stays out of `session.launched`'s body. `mayLaunch` and `mustStop`
pass it to the limit checks in the subject. A session can edit its own transcript: this is a best reading, not proof.
No enforcement or resets are added. Settings reads through the same bus view.
## Human boundary (row 196)
A bounded Python stdlib helper receives only the accepted Unix socket fd and
returns pid, uid and gid. All three are validated as safe integers. Only pid and
uid select a principal. It runs for proof envelopes only, never cap traffic, with
a limit of 16 helpers, a one-second timeout and 256-byte result. Proof PID must
equal kernel peer PID. Human/user proofs retain the full agent ancestry walk.
Tester proofs deliberately admit agent seats and still refuse managed runs.
The trusted verifyPrincipal interface returns {kind,business,id} for one request.
User provenance requires the same-tree Console as direct parent and a distinct
user nonce. R3 supplies user mapping and checks the live grant on every request.
No role is accepted from a proof or cached. Caps are not returned to proof clients.
Every restart repeats interpreter safety checks and appends a 0600 startup receipt
to bus/peercred-starts.jsonl before listening. The receipt names the actual current
interpreter and copy. A safe OS replacement is accepted without comparing an old
activation hash. Unsafe interpreter or failed receipt append refuses startup.
Original activation evidence is never rewritten. The next authorized re-pin to a
different reviewed copy refreshes activation evidence. The worker Containerfile
does not run the bus and needs no Python change. Any future bus image must provide
this dependency or use a separately reviewed compiled helper.
Hostile processes on the instance uid remain outside this guarantee. In particular,
a detached agent can erase visible ancestry, and a same-uid process can tamper with
files, pipes or process memory where OS policy permits. Separate instance uids
narrow cross-instance reach, not reach within one instance.
The Console provenance option trusts verified code and process metadata, not an independent
bus-side OIDC exchange. That trade must be approved, not hidden behind the phrase zero trust.
+17
View File
@@ -0,0 +1,17 @@
import { connect } from 'node:net';
process.send({pid:process.pid});
process.once('message', ({path,claimed}) => {
const s = connect(path);
s.on('error', () => process.exit(2));
let text = '';
s.on('connect', () => s.write(JSON.stringify({human:{pid:claimed ?? process.pid,startTime:'test',nonce:'a'.repeat(64),business:'demo'},verb:'agents'})+'\n'));
s.on('data', data => {
text += data;
if (!text.includes('\n')) return;
const reply = JSON.parse(text);
s.write('after-helper\n');
s.end();
process.send({reply});
process.disconnect();
});
});
+3 -3
View File
@@ -31,7 +31,7 @@ export function readProcess(pid, { readFile = readFileSync, stat = lstatSync } =
for (const entry of environment.split('\0')) {
const i = entry.indexOf('=');
const key = entry.slice(0, i);
if (key === 'MOSAIC_BUS_CLI_NONCE' || MARKERS.includes(key)) env[key] = entry.slice(i + 1);
if (['MOSAIC_BUS_CLI_NONCE', 'MOSAIC_BUS_USER_NONCE'].includes(key) || MARKERS.includes(key)) env[key] = entry.slice(i + 1);
}
return {
pid,
@@ -49,7 +49,7 @@ export function readProcess(pid, { readFile = readFileSync, stat = lstatSync } =
}
// Cooperative same-UID process checks, not peer credentials or a hostile-process wall.
// A nonce in the CLI's launch environment binds the submitted PID to a live CLI.
export function verifyHuman(proof, { cliPath, launches = [], readProcess: read = readProcess }) {
export function verifyHuman(proof, { cliPath, launches = [], readProcess: read = readProcess, nonceKey = 'MOSAIC_BUS_CLI_NONCE' }) {
if (
!proof ||
typeof proof !== 'object' ||
@@ -66,7 +66,7 @@ export function verifyHuman(proof, { cliPath, launches = [], readProcess: read =
if (
p.uid !== process.getuid() ||
p.startTime !== proof.startTime ||
p.env?.MOSAIC_BUS_CLI_NONCE !== proof.nonce ||
p.env?.[nonceKey] !== proof.nonce ||
p.argv[1] !== resolve(cliPath)
)
refuse();
+67
View File
@@ -0,0 +1,67 @@
import { realpathSync, statSync, readFileSync, openSync, fstatSync, writeSync, fsyncSync, closeSync, constants } from 'node:fs';
import { dirname, join, sep } from 'node:path';
import { createHash } from 'node:crypto';
import { execFileSync } from 'node:child_process';
const systemPython = '/usr/bin/python3';
const defaultIO = { realpath: realpathSync, stat: statSync, readFile: readFileSync, exec: execFileSync };
const below = (path, parent) => path === parent || path.startsWith(parent + sep);
// Trusted dependency injection is for offline tests, never a CLI/env interpreter override.
export function inspectInterpreter({ io = defaultIO } = {}) {
try {
const path = io.realpath(systemPython);
const homes = ['/home', '/root', ...String(io.readFile('/etc/passwd', 'utf8')).split('\n')
.map(line => line.split(':')[5]).filter(p => p?.startsWith('/') && p !== '/')];
if (!path.startsWith('/') || homes.some(home => below(path, home))) throw Error();
// Check both the system entry's parent and every resolved component. /usr/bin/python3
// may be a system symlink; its containing directory and resolved target must be safe.
for (const start of [dirname(systemPython), path]) {
for (let at = start;; at = dirname(at)) {
const s = io.stat(at);
if (s.uid !== 0 || (s.mode & 0o022) || (at === path ? !s.isFile() : !s.isDirectory())) throw Error();
if (at === '/') break;
}
}
const data = io.readFile(path);
if (!Buffer.isBuffer(data) || !data.subarray(0, 4).equals(Buffer.from([127, 69, 76, 70]))) throw Error();
const version = JSON.parse(String(io.exec(path, ['-I', '-S', '-c', 'import json,sys,socket;assert hasattr(socket,"SO_PEERCRED");print(json.dumps(list(sys.version_info[:3])))'],
{ env: {}, timeout: 1000, maxBuffer: 256, encoding: 'utf8' })));
if (!Array.isArray(version) || version.length !== 3 || !version.every(Number.isSafeInteger) ||
version[0] < 3 || (version[0] === 3 && version[1] < 10)) throw Error();
const s = io.stat(path);
return Object.freeze({ path, version: Object.freeze(version), dev: s.dev, ino: s.ino,
sha256: createHash('sha256').update(data).digest('hex') });
} catch { throw Error('interpreter-unsafe'); }
}
export function verifyInterpreterReceipt(directory, expected, since) {
let fd;
try {
fd = openSync(join(directory, 'peercred-starts.jsonl'), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
const s = fstatSync(fd);
if (!s.isFile() || s.uid !== process.getuid() || (s.mode & 0o077) || s.nlink !== 1) throw Error();
const text = readFileSync(fd, 'utf8');
if (!text.endsWith('\n')) throw Error();
const actual = JSON.parse(text.trimEnd().split('\n').at(-1));
if (actual.copy !== new URL('../../../', import.meta.url).pathname || !(Date.parse(actual.at) >= Number(since)) ||
Date.parse(actual.at) > Date.now() + 1000 || ['path', 'sha256', 'dev', 'ino', 'version'].some(k => JSON.stringify(actual[k]) !== JSON.stringify(expected[k]))) throw Error();
return true;
} catch { throw Error('interpreter-receipt-mismatch'); }
finally { if (fd !== undefined) closeSync(fd); }
}
export function recordInterpreterStart(directory, record) {
let fd;
try {
const dir = statSync(directory);
if (!dir.isDirectory() || dir.uid !== process.getuid() || (dir.mode & 0o077)) throw Error();
fd = openSync(join(directory, 'peercred-starts.jsonl'),
constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o600);
const s = fstatSync(fd);
if (!s.isFile() || s.uid !== process.getuid() || (s.mode & 0o077) || s.nlink !== 1) throw Error();
const line = Buffer.from(JSON.stringify({ at: new Date().toISOString(), copy: new URL('../../../', import.meta.url).pathname, ...record }) + '\n');
if (writeSync(fd, line) !== line.length) throw Error();
fsyncSync(fd);
} catch { throw Error('interpreter-receipt-unsafe'); }
finally { if (fd !== undefined) closeSync(fd); }
}
+58
View File
@@ -0,0 +1,58 @@
import { spawn as nodeSpawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { lstatSync } from 'node:fs';
import { BusError } from './broker.mjs';
const helper = fileURLToPath(new URL('./peercred.py', import.meta.url));
const refused = () => new BusError('peercred-required');
export function createPeerReader({ interpreter, spawn = nodeSpawn, timeout = 1000, stat = lstatSync } = {}) {
try { const s = stat(helper); if (!s.isFile() || s.isSymbolicLink()) throw Error(); }
catch { throw refused(); }
const children = new Set();
let closed = false;
return {
read(socket) {
if (closed || children.size >= 16) return Promise.reject(refused());
return new Promise((resolve, reject) => {
let child, timer, output = '', settled = false, invalid = false;
const finish = (error, value) => {
if (settled) return;
settled = true;
clearTimeout(timer);
error ? reject(refused()) : resolve(value);
};
try {
// Keep this connection's Socket in this closure. Never borrow another pending fd.
child = spawn(interpreter.path, ['-I', '-S', helper],
{ env: {}, stdio: ['ignore', 'pipe', 'pipe', socket] });
children.add(child);
timer = setTimeout(() => { child.kill('SIGKILL'); finish(true); }, timeout);
child.stdout.on('data', data => {
output += data.toString('utf8');
if (Buffer.byteLength(output) > 256) { invalid = true; child.kill('SIGKILL'); finish(true); }
});
child.stderr.on('data', () => { invalid = true; });
child.on('error', () => { children.delete(child); finish(true); });
child.on('close', (code, signal) => {
children.delete(child);
if (settled) return;
try {
if (closed || invalid || code !== 0 || signal) throw Error();
const p = JSON.parse(output);
if (!p || Array.isArray(p) || Object.keys(p).sort().join(',') !== 'gid,pid,uid' ||
!['pid', 'uid', 'gid'].every(k => Number.isSafeInteger(p[k])) || p.pid < 2 || p.uid < 0 || p.gid < 0) throw Error();
finish(false, Object.freeze(p));
} catch { finish(true); }
});
} catch { finish(true); }
});
},
async close() {
closed = true;
await Promise.all([...children].map(child => new Promise(resolve => {
child.once('close', resolve);
child.kill('SIGKILL');
})));
},
};
}
+14
View File
@@ -0,0 +1,14 @@
"""Read only kernel credentials from an inherited accepted AF_UNIX stream."""
import json
import socket
import struct
import sys
try:
with socket.socket(fileno=3) as peer:
if peer.family != socket.AF_UNIX or peer.type != socket.SOCK_STREAM:
raise ValueError()
pid, uid, gid = struct.unpack("3i", peer.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12))
print(json.dumps({"pid": pid, "uid": uid, "gid": gid}, separators=(",", ":")))
except Exception:
sys.exit(2)
+44
View File
@@ -0,0 +1,44 @@
import { resolve } from 'node:path';
import { BusError } from './broker.mjs';
import { verifyHuman, readProcess as defaultRead } from './human.mjs';
import { verifyTester, testerNonce } from './tester.mjs';
// Trusted transport API. Identity is held only by this connection's transient session.
// User mapping and live grant authorization belong to R3, not to this verifier.
export function verifyPrincipal(request, { peer, paths, businesses, launches = [], readProcess = defaultRead, readTesterNonce = testerNonce }) {
const kinds = ['human', 'tester', 'user'].filter(k => request?.[k]);
if (kinds.length !== 1 || request.cap) throw new BusError('invalid-envelope');
const kind = kinds[0], proof = request[kind];
const error = kind === 'tester' ? 'tester-required' : 'human-required';
const refuse = () => { throw new BusError(error); };
if (!peer || peer.uid !== process.getuid() || peer.pid !== proof.pid) refuse();
if (kind === 'tester') {
const { business } = verifyTester(proof, { cliPath: paths.tester, launches, readProcess, readNonce: readTesterNonce });
return { kind: 'tester', business, id: 'tester' };
}
let checked = proof, parent;
if (kind === 'user') {
if (!proof || typeof proof.user !== 'string' || !proof.user ||
Object.keys(proof).some(k => !['pid', 'startTime', 'nonce', 'business', 'user'].includes(k))) refuse();
const { user, ...rest } = proof;
checked = rest;
try {
const child = readProcess(proof.pid);
parent = readProcess(child.ppid);
if (parent.uid !== peer.uid || parent.argv[1] !== resolve(paths.console) || parent.argv[2] !== 'serve') refuse();
} catch { refuse(); }
}
const { business } = verifyHuman(checked, { cliPath: paths[kind], launches, readProcess,
nonceKey: kind === 'user' ? 'MOSAIC_BUS_USER_NONCE' : 'MOSAIC_BUS_CLI_NONCE' });
if (kind === 'user') {
try {
const child = readProcess(proof.pid), again = readProcess(parent.pid);
if (child.ppid !== parent.pid || again.startTime !== parent.startTime || again.ppid !== parent.ppid ||
again.uid !== parent.uid || again.argv[1] !== parent.argv[1] || again.argv[2] !== 'serve') refuse();
} catch { refuse(); }
return { kind: 'user', business, id: proof.user };
}
const id = businesses[business]?.human;
if (!id) throw new BusError('unknown-business');
return { kind: 'owner', business, id };
}
+19 -18
View File
@@ -12,10 +12,11 @@ import { createLoader, hasModule } from '../../plugins/src/loader.mjs';
import { pluginSettings } from '../../business/src/plugin-settings.mjs';
import { instanceSettings } from '../../business/src/instance-settings.mjs';
import { webService } from './web.mjs';
import { verifyHuman } from './human.mjs';
import { verifyPrincipal } from './principal.mjs';
import { inspectInterpreter, recordInterpreterStart } from './interpreter.mjs';
import { createPeerReader } from './peercred.mjs';
import { createUsage } from './usage.mjs';
import { createLimits } from './limits.mjs';
import { verifyTester } from './tester.mjs';
// Trusted host API. S1 supplies resolved definitions, S6 supplies launch records and ends them.
// Human limits writes use the shared organization writer behind the verified transport.
// `tasks` is S3's adapter factory: ({broker, credentials, businesses}) => {handle, timeout, close}.
@@ -166,29 +167,29 @@ export async function startBroker({
throw new BusError('invalid-adapter');
}
const web = await webServices(businesses);
const interpreter = inspectInterpreter();
recordInterpreterStart(store.directory, interpreter);
const peerReader = createPeerReader({ interpreter });
const principalPaths = {
human: fileURLToPath(new URL('./human-cli.mjs', import.meta.url)),
tester: fileURLToPath(new URL('./tester-cli.mjs', import.meta.url)),
user: fileURLToPath(new URL('./user-cli.mjs', import.meta.url)),
console: fileURLToPath(new URL('../../webui/src/cli.mjs', import.meta.url)),
};
const path = join(store.directory, 'broker.sock');
server = await serve({
web: (business) => web.get(business) ?? null,
tasks: adapter ? { handle: adapter.handle, timeout: adapter.timeout } : null,
broker,
path,
authenticateHuman: (proof) => {
const { business } = verifyHuman(proof, {
cliPath: fileURLToPath(new URL('./human-cli.mjs', import.meta.url)),
launches: launchRecords,
});
const human = businesses[business]?.human;
if (!human) throw new BusError('unknown-business');
return broker.bindHuman({ business, human, via: 'cli', outsideAgent: true });
peerReader,
authenticateHuman: (proof, peer) => {
const p = verifyPrincipal({ human: proof }, { peer, paths: principalPaths, businesses, launches: launchRecords });
return broker.bindHuman({ business: p.business, human: p.id, via: 'cli', outsideAgent: true });
},
// Row 110 (#1604): a tester proves its CLI process and that no managed run is above it.
// The broker then binds only if the business sets `bus.tester` to true.
authenticateTester: (proof) => {
const { business } = verifyTester(proof, {
cliPath: fileURLToPath(new URL('./tester-cli.mjs', import.meta.url)),
launches: launchRecords,
});
return broker.bindTester({ business });
authenticateTester: (proof, peer) => {
const p = verifyPrincipal({ tester: proof }, { peer, paths: principalPaths, businesses, launches: launchRecords });
return broker.bindTester({ business: p.business });
},
});
// S6 launcher operations. The host decides capacity and the instance list; the broker
+33 -18
View File
@@ -1,6 +1,8 @@
import { createServer } from 'node:net';
import { lstatSync, chmodSync, unlinkSync } from 'node:fs';
import { BusError, TASK_VERBS, WEB_VERBS, PROJECT_FILE_VERBS, PROJECT_SAVE_VERB, PROJECT_SAVE_REQUEST_MAX } from './broker.mjs';
import { inspectInterpreter } from './interpreter.mjs';
import { createPeerReader } from './peercred.mjs';
const LIMIT = 65536;
// Row 148: a document save may carry up to 1 MiB of text. Only a request that names that verb near
// its start (the proof object comes first) may pass LIMIT; the parsed verb is checked again below.
@@ -10,31 +12,37 @@ const SAVE_VERB_AT_START = new RegExp(`"verb":"${PROJECT_SAVE_VERB.replaceAll('.
// `web` is the trusted web service (web.mjs), or a function from a business id to one (or null); only
// the web verbs reach it, and without one they refuse web-not-configured (or web-not-granted).
// A request carries exactly one of cap, human (row S4) and tester (row 110) proof.
export async function serve({ broker, path, authenticateHuman = null, authenticateTester = null, timeout = 5000, tasks = null, web = null }) {
export async function serve({ broker, path, authenticateHuman = null, authenticateTester = null, authenticateUser = null, peerReader = null, timeout = 5000, tasks = null, web = null }) {
try {
lstatSync(path);
throw new BusError('socket-exists');
} catch (e) {
if (e.code !== 'ENOENT') throw e;
}
const peers = peerReader ?? createPeerReader({ interpreter: inspectInterpreter() });
const sockets = new Set();
const server = createServer({ allowHalfOpen: true }, (socket) => {
sockets.add(socket);
socket.on('close', () => sockets.delete(socket));
let boundCap;
const release = () => { if (boundCap) { broker.disconnect(boundCap); boundCap = null; } };
socket.on('close', () => { sockets.delete(socket); release(); });
socket.on('error', () => {});
let input = Buffer.alloc(0),
done = false;
done = false, processing = false;
const finish = (result) => {
if (done) return;
done = true;
release();
socket.resume();
socket.end(JSON.stringify(result) + '\n');
};
socket.setTimeout(timeout, () => {
finish({ ok: false, error: 'request-timeout' });
socket.destroySoon();
});
socket.on('data', (chunk) => {
socket.on('data', async (chunk) => {
if (done) return;
if (processing) return finish({ ok: false, error: 'invalid-envelope' });
const total = input.length + chunk.length;
if (total > LIMIT && !(total <= PROJECT_SAVE_REQUEST_MAX &&
SAVE_VERB_AT_START.test(Buffer.concat([input, chunk]).subarray(0, 1024).toString('latin1')))) {
@@ -44,6 +52,7 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
input = Buffer.concat([input, chunk]);
const end = input.indexOf(10);
if (end < 0) return;
processing = true;
let transient;
try {
if (end !== input.length - 1) throw new BusError('invalid-envelope');
@@ -53,19 +62,24 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
!r ||
Array.isArray(r) ||
typeof r !== 'object' ||
Object.keys(r).some((k) => !['cap', 'human', 'tester', 'verb', 'args'].includes(k)) ||
Object.keys(r).some((k) => !['cap', 'human', 'tester', 'user', 'verb', 'args'].includes(k)) ||
typeof r.verb !== 'string' ||
[r.cap, r.human, r.tester].filter(Boolean).length !== 1
[r.cap, r.human, r.tester, r.user].filter(Boolean).length !== 1
)
throw new BusError('invalid-envelope');
let cap = r.cap;
if (r.human) {
if (!authenticateHuman) throw new BusError('human-required');
transient = cap = authenticateHuman(r.human);
}
if (r.tester) {
if (!authenticateTester) throw new BusError('tester-required');
transient = cap = authenticateTester(r.tester);
const kind = r.human ? 'human' : r.tester ? 'tester' : r.user ? 'user' : null;
if (kind) {
const authenticate = kind === 'human' ? authenticateHuman : kind === 'tester' ? authenticateTester : authenticateUser;
if (!authenticate) throw new BusError(kind === 'tester' ? 'tester-required' : 'human-required');
socket.pause();
let peer;
try { peer = await peers.read(socket); }
catch { throw new BusError('peercred-required'); }
if (done || socket.destroyed) return;
transient = cap = authenticate(r[kind], peer);
boundCap = transient;
socket.resume();
}
if (typeof cap !== 'string') throw new BusError('unauthenticated');
if (tasks && TASK_VERBS.includes(r.verb)) {
@@ -80,7 +94,7 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
(e) => finish({ ok: false, error: e instanceof BusError ? e.code : 'adapter-failed' }),
)
.finally(() => {
if (own) broker.disconnect(own);
if (own) release();
});
return;
}
@@ -98,7 +112,7 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
(e) => finish({ ok: false, error: e instanceof BusError ? e.code : 'web-failed' }),
)
.finally(() => {
if (own) broker.disconnect(own);
if (own) release();
});
return;
}
@@ -113,7 +127,7 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
Promise.resolve().then(() => broker.request(cap, { verb: r.verb, args: r.args ?? {} })).then(
result => { if (!socket.destroyed) finish({ ok: true, result }); },
e => { if (!socket.destroyed) finish({ ok: false, error: e instanceof BusError ? e.code : 'invalid-envelope' }); },
).finally(() => { if (own) broker.disconnect(own); });
).finally(() => { if (own) release(); });
return;
}
const result = broker.request(cap, { verb: r.verb, args: r.args ?? {} });
@@ -121,11 +135,11 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
} catch (e) {
finish({ ok: false, error: e instanceof BusError ? e.code : 'invalid-envelope' });
} finally {
if (transient) broker.disconnect(transient);
if (transient) release();
}
});
socket.on('end', () => {
if (!done) finish({ ok: false, error: 'incomplete-request' });
if (!done && !processing) finish({ ok: false, error: 'incomplete-request' });
});
});
await new Promise((resolve, reject) => {
@@ -139,6 +153,7 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
const owned = lstatSync(path);
return {
async close() {
await peers.close();
for (const s of sockets) s.destroy();
await new Promise((resolve) => server.close(resolve));
try {
+6 -3
View File
@@ -1,4 +1,7 @@
// Row 164 explicitly freezes proof bytes and its literal entry-path rule.
// Row 164 freezes reviewed proof bytes and the literal entry-path rule.
// Row 196 intentionally re-pins human.mjs for the distinct user nonce and shared human
// walk, and webui bus.mjs for initial-spawn nonce plus peer refusal status. Entry paths
// and the ban on channel plug-ins importing a proof caller are retained.
import test from "node:test";
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
@@ -8,7 +11,7 @@ import { provedRuntime } from "../../cli/tests/channel-fixture.mjs";
import { Client } from "../src/client.mjs";
const baselines = {
"packages/bus/src/human.mjs": "b995fc5cff3af8ade4c990d0e3bebf9d83829e00f034ebc9c3dcfd05f252b573",
"packages/bus/src/human.mjs": "0c6d96abe4607cba0b02b0638a2fc217ff8135bac9a47761a0ef015fbe9d3079",
"packages/bus/src/human-cli.mjs": "269c3a66008c0d57a54b14b73cf988d5f61f5e815455486296716e98ce5e8073",
"packages/cli/src/transport.mjs": "5129c44b4d9f642e825344df65c9bfb4bac4fc8d6b25343b42d39853dbd40774",
// Row 165 (#1662): seven STATUS entries for the role instance routes (organization-instance-exists,
@@ -16,7 +19,7 @@ const baselines = {
// organization-no-change, organization-project-mismatch, organization-unresolvable-instance), no proof or
// transport change. Row 155 (#1649) added eight for the assistant routes.
// Row 171 (#1668): project-name-is-repo maps to 409; no proof or transport change.
"packages/webui/src/bus.mjs": "76a1b57858c4aa41071746713a196299264851c18322a6f9869d553f0362d421",
"packages/webui/src/bus.mjs": "934ab2f9b6bde72aafe7814758550c283eb25a4f3a23b284021b13718d67178f",
};
const repoFile = path => new URL("../../../" + path, import.meta.url);
+76
View File
@@ -0,0 +1,76 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, writeFileSync, chmodSync, symlinkSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { inspectInterpreter, recordInterpreterStart } from '../src/interpreter.mjs';
function fixture(change = {}) {
const binary = Buffer.from('\x7fELFbody');
return { realpath: () => '/usr/bin/python3.14',
stat: path => ({ uid: 0, mode: 0o100755, dev: 1, ino: 10, isFile: () => path.endsWith('3.14'), isDirectory: () => !path.endsWith('3.14') }),
readFile: path => path === '/etc/passwd' ? 'root:x:0:0::/root:/bin/sh\nuser:x:1000:1000::/home/user:/bin/sh\n' : binary,
exec: () => '[3,14,0]', ...change };
}
test('system Python is a resolved root-owned ELF, never a shim or home interpreter', () => {
const record = inspectInterpreter({ io: fixture() });
assert.equal(record.path, '/usr/bin/python3.14');
assert.deepEqual(record.version, [3,14,0]);
assert.match(record.sha256, /^[a-f0-9]{64}$/);
const bad = [
{ realpath: () => '/home/user/python3' },
{ realpath: () => '/root/python3' },
{ realpath: () => '/srv/user/python3', readFile: p => p === '/etc/passwd' ? 'u:x:1000:1000::/srv/user:/bin/sh' : Buffer.from('\x7fELF') },
{ readFile: p => p === '/etc/passwd' ? '' : Buffer.from('#!/bin/sh\n') },
{ stat: () => ({ uid: 1000, mode: 0o100755, isFile: () => true, isDirectory: () => true }) },
{ stat: () => ({ uid: 0, mode: 0o100775, isFile: () => true, isDirectory: () => true }) },
{ stat: () => { throw Error('missing'); } },
{ exec: () => '[3,9,9]' },
{ exec: () => 'unexpected' },
];
for (const f of bad) assert.throws(() => inspectInterpreter({ io: fixture(f) }), /interpreter-unsafe/);
});
test('real system interpreter is available and safely inspected', () => {
const actual = inspectInterpreter();
assert.ok(actual.path.startsWith('/usr/'));
assert.ok(actual.version[0] >= 3);
});
test('restart receipt appends actual replacement identity and refuses unsafe files', t => {
const root = mkdtempSync(join(tmpdir(), 'g196-int-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const first = inspectInterpreter({ io: fixture() });
recordInterpreterStart(root, first);
const file = join(root, 'peercred-starts.jsonl');
const old = readFileSync(file, 'utf8');
const second = inspectInterpreter({ io: fixture({ readFile: p => p === '/etc/passwd' ? '' : Buffer.from('\x7fELFnew') }) });
recordInterpreterStart(root, second);
const now = readFileSync(file, 'utf8');
assert.ok(now.startsWith(old));
assert.notEqual(JSON.parse(old).sha256, JSON.parse(now.trim().split('\n')[1]).sha256);
chmodSync(file, 0o644);
assert.throws(() => recordInterpreterStart(root, second), /interpreter-receipt-unsafe/);
rmSync(file);
writeFileSync(join(root,'target'), 'untouched');
symlinkSync(join(root,'target'),file);
assert.throws(() => recordInterpreterStart(root, second), /interpreter-receipt-unsafe/);
assert.equal(readFileSync(join(root,'target'),'utf8'),'untouched');
});
test('activation gate compares fresh startup receipt to the current interpreter identity',async t=>{
const { verifyInterpreterReceipt }=await import('../src/interpreter.mjs');
const root=mkdtempSync(join(tmpdir(),'g196-int-'));t.after(()=>rmSync(root,{recursive:true,force:true}));
const first=inspectInterpreter({io:fixture()}),since=Date.now()-1;
recordInterpreterStart(root,first);
assert.equal(verifyInterpreterReceipt(root,first,since),true);
assert.throws(()=>verifyInterpreterReceipt(root,{...first,sha256:'b'.repeat(64)},since),/interpreter-receipt-mismatch/);
assert.throws(()=>verifyInterpreterReceipt(root,first,Date.now()+1000),/interpreter-receipt-mismatch/);
});
test('non-regular startup receipt refuses promptly without waiting on a FIFO peer',async t=>{
const {execFileSync,spawnSync}=await import('node:child_process');
const root=mkdtempSync(join(tmpdir(),'g196-fifo-'));t.after(()=>rmSync(root,{recursive:true,force:true}));
execFileSync('mkfifo',[join(root,'peercred-starts.jsonl')]);
const url=new URL('../src/interpreter.mjs',import.meta.url).href;
const child=spawnSync(process.execPath,['--input-type=module','-e',`import {recordInterpreterStart} from ${JSON.stringify(url)};try{recordInterpreterStart(${JSON.stringify(root)},{});process.exit(2)}catch(e){process.exit(e.message==='interpreter-receipt-unsafe'?0:3)}`],{timeout:1000});
assert.equal(child.status,0,'unsafe FIFO must refuse without blocking startup');
});
@@ -0,0 +1,59 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { connect } from 'node:net';
import { mkdtempSync,rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { serve } from '../src/server.mjs';
import { Client } from '../src/client.mjs';
import { BusError } from '../src/broker.mjs';
const request=(path,body)=>new Promise((resolve,reject)=>{const s=connect(path);let out='';s.on('error',reject);s.on('data',b=>out+=b);s.on('end',()=>resolve(JSON.parse(out)));s.end(JSON.stringify(body)+'\n');});
test('proof receives actual peer, cap traffic never invokes helper and transient cap disconnects',async t=>{
const root=mkdtempSync(join(tmpdir(),'g196-s-')),path=join(root,'s');let peers=0,auth=0;const disconnected=[];
const broker={request:()=>({ok:'value'}),disconnect:c=>disconnected.push(c)};
const reader={read:async socket=>{peers++;return {pid:process.pid,uid:process.getuid(),gid:process.getgid()};},close(){}};
const server=await serve({path,broker,peerReader:reader,authenticateHuman:(proof,peer)=>{auth++;assert.equal(peer.pid,process.pid);return 'transient';}});
t.after(async()=>{await server.close();rmSync(root,{recursive:true,force:true});});
assert.equal((await request(path,{human:{pid:process.pid},verb:'agents'})).ok,true);
assert.equal(peers,1);assert.equal(auth,1);assert.ok(disconnected.includes('transient'));
assert.equal((await request(path,{cap:'existing',verb:'agents'})).ok,true);
assert.equal(peers,1);
});
test('proof helper error and disconnect during verification never dispatch or bind',async t=>{
const root=mkdtempSync(join(tmpdir(),'g196-s-'));let auth=0,calls=0;
const broker={request:()=>calls++,disconnect(){}};
const server=await serve({path:join(root,'s'),broker,peerReader:{read:async()=>{throw Error('helper');},close(){}},authenticateHuman:()=>{auth++;return 'x';}});
t.after(async()=>{await server.close();rmSync(root,{recursive:true,force:true});});
assert.equal((await request(join(root,'s'),{human:{pid:process.pid},verb:'agents'})).error,'peercred-required');
assert.equal(auth,0);assert.equal(calls,0);
});
test('Client receives explicit proof refusal codes without dispatch, not outcome-unknown',async t=>{
for(const failure of ['human-required','peercred-required']) {
const root=mkdtempSync(join(tmpdir(),'g196-refuse-')),path=join(root,'s');let calls=0;
const server=await serve({path,broker:{request(){calls++;},disconnect(){}},
...(failure==='peercred-required'?{}:{authenticateHuman(){throw new BusError('human-required');}}),
peerReader:failure==='peercred-required'?{read:async()=>{throw Error('helper');},close(){}}:undefined,
...(failure==='peercred-required'?{authenticateHuman(){throw Error('must not authenticate');}}:{})});
try {
const client=new Client({path,human:{pid:process.pid,business:'fixture',startTime:'0',nonce:'a'.repeat(64)}});
await assert.rejects(client.call('agents'),e=>e.code===failure);
assert.equal(calls,0);
}finally{await server.close();rmSync(root,{recursive:true,force:true});}
}
});
test('asynchronous proof calls release their transient session on success, failure and timeout',async t=>{
const {TASK_VERBS,BusError}=await import('../src/broker.mjs');
for(const mode of ['success','failure','timeout']) {
const root=mkdtempSync(join(tmpdir(),'g196-async-')),path=join(root,'s'),disconnected=[];
const broker={disconnect:cap=>disconnected.push(cap),requestTask:()=>mode==='success'?Promise.resolve('done'):mode==='failure'?Promise.reject(new BusError('fixture-failed')):new Promise(()=>{})};
const server=await serve({path,broker,tasks:{handle(){},timeout:25},peerReader:{read:async()=>({pid:process.pid,uid:process.getuid(),gid:process.getgid()}),close(){}},authenticateHuman:()=>mode});
try {
const response=await request(path,{human:{pid:process.pid},verb:TASK_VERBS[0]});
assert.equal(response.ok,mode==='success');
if(mode==='timeout')assert.equal(response.error,'request-timeout');
assert.deepEqual(disconnected,[mode]);
}finally{await server.close();rmSync(root,{recursive:true,force:true});}
}
});
+108
View File
@@ -0,0 +1,108 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createServer } from 'node:net';
import { fork } from 'node:child_process';
import { once } from 'node:events';
import { mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { EventEmitter } from 'node:events';
import { PassThrough } from 'node:stream';
import { createPeerReader } from '../src/peercred.mjs';
import { inspectInterpreter } from '../src/interpreter.mjs';
const interpreter = inspectInterpreter();
test('T2 eight proof callers and a racing copier bind the exact accepted socket, which survives helper exit', {timeout:10000}, async t => {
const root = mkdtempSync(join(tmpdir(),'g196-p-'));
const path = join(root,'s');
const reader = createPeerReader({interpreter});
const sockets = [], pending = [], after = [];
let resolveReady;
const ready = new Promise(r => resolveReady = r);
const server = createServer({allowHalfOpen:true}, socket => {
sockets.push(socket);
let text = '';
socket.on('data', chunk => {
text += chunk;
if (!text.includes('\n')) return;
if (!socket.proof) {
socket.proof = JSON.parse(text.slice(0,text.indexOf('\n')));
text = text.slice(text.indexOf('\n')+1);
pending.push(socket);
if (pending.length === 9) resolveReady();
}
if (text.includes('after-helper\n')) after.push(socket);
});
socket.on('error',()=>{});
});
const children = [];
t.after(async () => { reader.close(); for(const c of children)c.kill(); for(const s of sockets)s.destroy(); await new Promise(r=>server.close(r)); rmSync(root,{recursive:true,force:true}); });
await new Promise(r=>server.listen(path,r));
const pids=[];
for(let i=0;i<9;i++) {
const c=fork(new URL('../fixtures/peer-client.mjs',import.meta.url),[],{stdio:['ignore','ignore','inherit','ipc']});
children.push(c); pids.push((await once(c,'message'))[0].pid);
}
const replies=children.map(c=>once(c,'message'));
children.forEach((c,i)=>c.send({path,...(i===8?{claimed:pids[0]}:{})}));
await ready;
const peers = await Promise.all(pending.map(socket => reader.read(socket)));
pending.forEach(s=>s.resume()); // spawn stdio pauses inherited streams.
assert.equal(new Set(peers.map(p=>p.pid)).size,9);
pending.forEach((socket,i)=> {
const peer=peers[i];
assert.equal(peer.uid,process.getuid());
const accepted=peer.pid===socket.proof.human.pid;
assert.equal(accepted, peer.pid!==pids[8]);
socket.write(JSON.stringify({accepted,pid:peer.pid})+'\n');
});
const results=await Promise.all(replies);
assert.equal(results.filter(([r])=>r.reply.accepted).length,8);
assert.equal(results[8][0].reply.accepted,false);
await Promise.all(sockets.map(s=>s.readableEnded?Promise.resolve():once(s,'end')));
assert.equal(after.length,9, 'all sockets read after the helper closed its duplicate');
});
function fakeSpawn(output, status=0) {
return () => {
const child=new EventEmitter(); child.stdout=new PassThrough(); child.stderr=new PassThrough();
child.kill=()=>{queueMicrotask(()=>child.emit('close',null,'SIGKILL'));return true;};
queueMicrotask(()=>{child.stdout.end(output);child.emit('close',status,null);});
return child;
};
}
test('helper output and failures fail closed including unsafe gid', async () => {
for(const [out,code] of [['{}',0],['{"pid":2,"uid":0,"gid":9007199254740992}',0],['{"pid":2,"uid":0,"gid":0}\nextra',0],['x'.repeat(257),0],['{"pid":2,"uid":0,"gid":0}',1]]) {
const reader=createPeerReader({interpreter,spawn:fakeSpawn(out,code)});
await assert.rejects(reader.read({}),/peercred-required/);reader.close();
}
const reader=createPeerReader({interpreter,spawn:()=>{throw Error('spawn');}});
await assert.rejects(reader.read({}),/peercred-required/);reader.close();
});
test('helper timeout, capacity and shutdown refuse without leaving children', async () => {
const reader=createPeerReader({interpreter,timeout:25,spawn:()=>{
const c=new EventEmitter();c.stdout=new PassThrough();c.stderr=new PassThrough();
c.kill=()=>{queueMicrotask(()=>c.emit('close',null,'SIGKILL'));return true;};return c;
}});
const jobs=Array.from({length:16},()=>assert.rejects(reader.read({}),/peercred-required/));
await assert.rejects(reader.read({}),/peercred-required/);
await Promise.all(jobs);reader.close();
await assert.rejects(reader.read({}),/peercred-required/);
});
test('missing or non-regular peer helper refuses at construction before opening the bus',()=>{
assert.throws(()=>createPeerReader({interpreter,stat:()=>{throw Error('missing');}}),/peercred-required/);
assert.throws(()=>createPeerReader({interpreter,stat:()=>({isFile:()=>false,isSymbolicLink:()=>true})}),/peercred-required/);
});
test('shutdown waits for killed helper close before returning',async()=>{
let reaped=0;
const reader=createPeerReader({interpreter,spawn:()=>{
const c=new EventEmitter();c.stdout=new PassThrough();c.stderr=new PassThrough();
c.kill=()=>{setTimeout(()=>{reaped++;c.emit('close',null,'SIGKILL');},5);return true;};return c;
}});
const pending=assert.rejects(reader.read({}),/peercred-required/);
await reader.close();
assert.equal(reaped,1,'shutdown must await the helper close event');
await pending;
});
+48
View File
@@ -0,0 +1,48 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { verifyPrincipal } from '../src/principal.mjs';
const nonce='a'.repeat(64), uid=process.getuid();
const paths={human:'/tree/human-cli.mjs',tester:'/tree/tester-cli.mjs',user:'/tree/user-cli.mjs',console:'/tree/console.mjs'};
function context(kind='human') {
const variable=kind==='human'?'MOSAIC_BUS_CLI_NONCE':kind==='tester'?'MOSAIC_BUS_TESTER_NONCE':'MOSAIC_BUS_USER_NONCE';
const table=new Map([[50,{pid:50,ppid:40,startTime:'100',uid,argv:['node',paths[kind]],env:{[variable]:nonce}}],
[40,{pid:40,ppid:1,startTime:'90',uid,argv:['node',paths.console,'serve'],env:{}}]]);
return {peer:{pid:50,uid,gid:uid},paths,businesses:{demo:{human:'owner'}},launches:[],
readProcess:pid=>{if(!table.has(pid))throw Error('gone');return {...table.get(pid)};},
readTesterNonce:()=>table.get(50).env.MOSAIC_BUS_TESTER_NONCE,table};
}
const proof={pid:50,startTime:'100',nonce,business:'demo'};
test('principal binds kernel peer PID and uid, maps owner, and never takes identity or role from body',()=>{
const c=context();
assert.deepEqual(verifyPrincipal({human:proof},c),{kind:'owner',business:'demo',id:'owner'});
for(const peer of [{pid:51,uid,gid:uid},{pid:50,uid:uid+1,gid:uid}])assert.throws(()=>verifyPrincipal({human:proof},{...c,peer}),/human-required/);
assert.throws(()=>verifyPrincipal({human:{...proof,human:'other'}},c),/human-required/);
assert.throws(()=>verifyPrincipal({human:proof,tester:proof},c),/invalid-envelope/);
});
test('user requires its own nonce and full walk plus immediate same-tree Console parent',()=>{
const request={user:{...proof,user:'owner'}};
assert.deepEqual(verifyPrincipal(request,context('user')),{kind:'user',business:'demo',id:'owner'});
for(const change of [{argv:['bash']},{argv:['node','/other/console.mjs','serve']},{argv:['node',paths.console,'other']},{env:{CODEX_THREAD_ID:'agent'}}]) {
const c=context('user');Object.assign(c.table.get(40),change);
assert.throws(()=>verifyPrincipal(request,c),/human-required/);
}
const wrong=context('user');wrong.table.get(50).env={MOSAIC_BUS_CLI_NONCE:nonce};
assert.throws(()=>verifyPrincipal(request,wrong),/human-required/);
const launched=context('user');launched.launches=[{pid:40,startTime:'90'}];
assert.throws(()=>verifyPrincipal(request,launched),/human-required/);
const changed=context('user'), read=changed.readProcess;let n=0;
changed.readProcess=pid=>{const p=read(pid);if(pid===40&&n++>0)p.startTime='91';return p;};
assert.throws(()=>verifyPrincipal(request,changed),/human-required/);
});
test('tester deliberately admits agent parents but rejects managed runs and another nonce',()=>{
const c=context('tester');c.table.get(40).env={CODEX_THREAD_ID:'seat'};
assert.deepEqual(verifyPrincipal({tester:proof},c),{kind:'tester',business:'demo',id:'tester'});
c.table.get(40).env={MOSAIC_RUN_ID:'managed'};
assert.throws(()=>verifyPrincipal({tester:proof},c),/tester-required/);
});
test('human leaf identity is reread after walking the parents',()=>{
const c=context(),read=c.readProcess;let n=0;
c.readProcess=pid=>{const p=read(pid);if(pid===50&&n++>0)p.startTime='101';return p;};
assert.throws(()=>verifyPrincipal({human:proof},c),/human-required/);
});
+9
View File
@@ -1017,3 +1017,12 @@ provider/model, provider and account groups, with input/output/cacheWrite/
cacheRead and unknown turns beside every total. JSON retains the full view.
Account is unassigned today. Unknown turns can have used tokens: known sums
are a best reading of session-editable transcripts, not proof or a limit check.
### Private tester HTTP (row 196)
`node packages/cli/src/tester-http.mjs GET /api/config` selects the instance's
console/tester.sock from its dataRoot. POST reads one bounded JSON body from stdin.
The helper supplies the fixed logical origin, reports status/text/body as JSON,
and never retries an uncertain write. It cannot navigate a browser or upgrade the
tester to an owner. `mosaic ... --tester` continues using broker.sock directly.
See docs/TOOLS.md for explicit activation conversion and rollback behavior.
+39
View File
@@ -0,0 +1,39 @@
import { request } from 'node:http';
import { fileURLToPath } from 'node:url';
import { readFileSync } from 'node:fs';
import { testerSocketPath, TESTER_ORIGIN } from '../../webui/src/tester-socket.mjs';
export function testerRequest({ dataRoot, path = '/healthz', method = 'GET', body, headers = {}, timeout = 30000 }) {
if (typeof path !== 'string' || !/^\/(?!\/)/.test(path) || /[\r\n\0]/.test(path)) return Promise.reject(Error('tester-http-path'));
const data = body === undefined ? undefined : JSON.stringify(body);
if (data && Buffer.byteLength(data) > 16384) return Promise.reject(Error('tester-http-size'));
return new Promise((resolve, reject) => {
const req = request({ socketPath: testerSocketPath(dataRoot), path, method,
headers: { host: new URL(TESTER_ORIGIN).host, origin: TESTER_ORIGIN, 'sec-fetch-site': 'same-origin',
...(data ? { 'content-type': 'application/json' } : {}), ...headers } }, res => {
const chunks = []; let size = 0;
res.on('data', b => { size += b.length; if (size > 8 * 1024 * 1024) req.destroy(Error('tester-http-size')); else chunks.push(b); });
res.on('error', reject);
res.on('end', () => {
const text = Buffer.concat(chunks).toString('utf8');
try { resolve({ status: res.statusCode, text, body: res.headers['content-type']?.includes('json') ? JSON.parse(text) : null }); }
catch { reject(Error('tester-http-response')); }
});
});
req.setTimeout(timeout, () => req.destroy(Error('tester-http-timeout')));
req.on('error', reject);
req.end(data);
});
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
try {
const { loadSystem } = await import('./config.mjs');
const [method = 'GET', path = '/healthz'] = process.argv.slice(2);
if (!['GET', 'HEAD', 'POST'].includes(method) || process.argv.length > 4) throw Error('tester-http-usage');
const body = method === 'POST' ? JSON.parse(readFileSync(0, 'utf8')) : undefined;
const result = await testerRequest({ dataRoot: loadSystem().dataRoot, path, method, body });
process.stdout.write(JSON.stringify(result) + '\n');
if (result.status >= 400) process.exitCode = 2;
} catch { process.stderr.write('tester-http-refused\n'); process.exitCode = 2; }
}
+29 -2
View File
@@ -314,9 +314,9 @@ function biz(e,plugins){
writeFileSync(f,JSON.stringify(b,null,2)+'\n',{mode:0o600});chmodSync(f,0o600);
}
async function mk(t){
async function mk(t,dataName='data'){
const root=realpathSync(mkdtempSync(join(tmpdir(),'sr-')));
const e={root,data:join(root,'data'),bin:join(root,'bin'),sd:join(root,'sd'),units:join(root,'xcfg','systemd','user'),cfg:join(root,'cfg','config.json'),claude:join(root,'claude-bin','claude')};
const e={root,data:join(root,dataName),bin:join(root,'bin'),sd:join(root,'sd'),units:join(root,'xcfg','systemd','user'),cfg:join(root,'cfg','config.json'),claude:join(root,'claude-bin','claude')};
Object.assign(e,{state:join(e.data,'state'),pointer:join(e.data,'state','stack-active.json'),log:join(e.data,'state','stack-activation-log.jsonl'),store:join(e.data,'bus','bus.sqlite'),lock:join(e.data,'state','stack-release.lock')});
for(const d of [join(root,'home'),dirname(e.cfg),join(e.data,'bus'),e.bin,dirname(e.claude),join(e.sd,'faults'),e.units,join(root,'xdata')])mkdirSync(d,{recursive:true});
chmodSync(join(e.data,'bus'),0o700);
@@ -2084,3 +2084,30 @@ test('R15 a rehearsal stopped by TERM, INT or HUP fails signal and still stops t
assert.equal(existsSync(`/proc/${pid}`),false,`${sig}: host pid ${pid} is still there`);
}
});
test('row196 explicit socket conversion refuses inherited TCP before stop and rollback disables socket',async t=>{
const e=await mk(t),base=await seedPreview(e,{copy:A}),proof=await proofFile(e);
let r=await run(e,'activate',B,'acme','--tester-port','7333');assert.equal(r.code,0,r.out+r.err);
const file=join(COPIES,C1,'packages/webui/src/cli.mjs'),original=readFileSync(file,'utf8');
chmodSync(file,0o644);writeFileSync(file,'// MOSAIC_TESTER_SOCKET_V1\n// --tester-socket\n// --proof-header\n');chmodSync(file,0o444);
t.after(()=>{chmodSync(file,0o644);writeFileSync(file,original);chmodSync(file,0o444);});
const module=join(COPIES,C1,'packages/bus/src/interpreter.mjs');
chmodSync(dirname(module),0o755);
writeFileSync(module,'export const inspectInterpreter=()=>({path:"/usr/bin/python3",sha256:"fixture"});export const verifyInterpreterReceipt=()=>true;\n');chmodSync(module,0o444);chmodSync(dirname(module),0o555);
t.after(()=>{chmodSync(dirname(module),0o755);rmSync(module);chmodSync(dirname(module),0o555);});
const n=calls(e).length;
r=await run(e,'activate',C1,'acme');assert.equal(r.code,3,r.out+r.err);assert.match(r.err,/tester-transport-migration/);assert.deepEqual(startsStops(e,n),[]);
r=await run(e,'activate',C1,'acme','--tester-socket','--plan');assert.equal(r.code,0,r.out+r.err);assert.match(r.out,/--tester-socket/);assert.match(r.out,/drop.*--tester-port 7333/);
r=await run(e,'activate',C1,'acme','--tester-socket',...pair(proof));assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(e).console,[...base,...pair(proof),'--tester-socket']);assert.match(consoleText(e),/--tester-socket/);assert.doesNotMatch(consoleText(e),/--tester-port/);
r=await run(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);assert.doesNotMatch(consoleText(e),/--tester-(socket|port)/);assert.match(r.out,/drop.*--tester-socket/);
await noLeak(e);
mkdirSync(join(e.data,'console'),{mode:0o755});
const unsafeBefore=calls(e).length;
r=await run(e,'activate',C1,'acme','--tester-socket');
assert.equal(r.code,3,r.out+r.err);assert.match(r.err,/tester-socket-path/);assert.deepEqual(startsStops(e,unsafeBefore),[]);
const long=await mk(t,'data-'+ 'a'.repeat(100));await seed(long,{copy:B});
const before=calls(long).length;
r=await run(long,'activate',C1,'acme','--tester-socket');
assert.equal(r.code,3,r.out+r.err);assert.match(r.err,/tester-socket-path/);assert.deepEqual(startsStops(long,before),[]);
});
+24
View File
@@ -0,0 +1,24 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { mkdtempSync,rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { bindTesterSocket, testerSocketPath, TESTER_ORIGIN } from '../../webui/src/tester-socket.mjs';
import { testerRequest } from '../src/tester-http.mjs';
test('tester HTTP helper uses instance socket, logical origin and JSON without retrying',async t=>{
const root=mkdtempSync(join(tmpdir(),'g196-h-'));let hits=0;
const server=await bindTesterSocket(createServer((q,r)=>{hits++;assert.equal(q.headers.host,new URL(TESTER_ORIGIN).host);assert.equal(q.headers.origin,TESTER_ORIGIN);r.setHeader('content-type','application/json');r.end('{"ok":true}');}),testerSocketPath(root));
t.after(async()=>{await new Promise(r=>server.close(r));rmSync(root,{recursive:true,force:true});});
assert.equal((await testerRequest({dataRoot:root,path:'/fixture',method:'POST',body:{x:1}})).body.ok,true);
assert.equal(hits,1);
for(const path of ['//remote','bad','/a\n'])await assert.rejects(testerRequest({dataRoot:root,path}),/tester-http-path/);
await assert.rejects(testerRequest({dataRoot:root,body:{x:'x'.repeat(16385)}}),/tester-http-size/);
});
test('tester HTTP timeout closes the socket and does not retry',async t=>{
const root=mkdtempSync(join(tmpdir(),'g196-h-'));let hits=0;
const server=await bindTesterSocket(createServer(()=>{hits++;}),testerSocketPath(root));
t.after(async()=>{server.closeAllConnections();await new Promise(r=>server.close(r));rmSync(root,{recursive:true,force:true});});
await assert.rejects(testerRequest({dataRoot:root,timeout:20}),/tester-http-timeout/);
assert.equal(hits,1);
});
+31 -26
View File
@@ -942,35 +942,40 @@ thread-scoped; sign-out clears private chat state. Bodies are inert text.
### Tester listener
`serve --tester-port 7333` starts a second listener on `127.0.0.1` only and
requires the bus config. The main listener keeps the human transport and rejects
`x-mosaic-tester` for every Host. The second listener rejects the main preview
auth header and uses `TESTER_CLI` for bus reads, the instance, route and name
writes, and chat. Its banner says tester; it offers only those editors and the
chat composer. It never enables settings saves, document saves, assistant stop or
messages to roles.
Both credential and committed-project reads keep their existing secret/path checks.
`serve --tester-socket` serves tester HTTP at `<dataRoot>/console/tester.sock`.
It creates or checks console/ as 0700 and the socket as 0600, owned by the instance
uid. It refuses symlinks, unsafe modes, existing entries and overlong Unix paths.
It binds a unique temporary name in that parent and renames onto the final path.
Cleanup removes the final path only when its dev/ino still matches. Hostile
same-uid replacement races remain outside the boundary.
Its chat (row 177, #1675) is the tester's own. The chat calls the bus through
`TESTER_CLI`, so it lists, opens and sends only in threads owned by `tester`, and
a human's thread answers 403 `thread-not-owned`. The main listener's chat stays
the human's and never lists tester threads. A tester send starts an assistant
turn, so a loopback caller on this port can start assistant turns, which row 176's
listener could not. The business var `bus.tester` stays the switch: while it is
off, chat answers 403 `tester-off` like every other tester call.
There is no tester TCP listener. `--tester-port` is retired for this copy.
Mos accepted loss of direct browser navigation (mos-1015). Agents use the socket:
The tester port must be 1024..65535, different from `--port`, and never the
forwarded port 7332. Writes need the listener's own loopback Origin and
same-origin Fetch Metadata, plus the existing bounded strict JSON bodies.
With the tester disabled by the business file, reads refuse HTTP 403 and the
page says the tester is off. The listener's address and identity are printed at start.
```sh
node packages/cli/src/tester-http.mjs GET /api/config
curl --unix-socket "$INSTANCE_DATA/console/tester.sock" http://mosaic-tester.invalid/healthz
```
The service script accepts the same `--tester-port` flag. The authorized window
uses `stack-release.sh activate ID BUSINESS --tester-port 7333` so both the unit
and pointer carry it. A target that lacks support refuses an explicit flag before
stopping anything. Re-pins/rollbacks preserve it for supporting targets and drop
it, visibly in the plan and steps log, for older ones. See docs/TOOLS.md. No live
listener, unit or tunnel changes are part of a build or test.
The helper selects dataRoot from the instance config, uses fixed Host and Origin
`http://mosaic-tester.invalid`, and adds same-origin Fetch Metadata and JSON for
POST bodies read from stdin. It never retries an uncertain write. Ordinary
browsers cannot navigate this socket. A later authenticated browser relay behind
SSO requires its own row.
The tester calls TESTER_CLI for reads, instance/route/name writes and its own chat.
It rejects sign-in cookies, Authorization, proxy auth/proof headers and owner-only
writes. Its bus calls still require `bus.tester` exactly true. A tester cannot
become an owner or user. The main listener rejects x-mosaic-tester for every Host.
Credential and committed-project read restrictions remain in force.
In an authorized window, `stack-release.sh activate ID BUSINESS --tester-socket`
explicitly converts inherited tester TCP flags in both unit and pointer. Without
that flag a new target refuses inherited TCP before stopping any unit. Rollback
to an older target drops tester enablement and reports it, without reopening TCP.
The reviewed window tool must also be used for rollback. Failed activation may
restore the old saved TCP unit, which restores the old security state and must
be reported as such. Builds and tests never change live units, listeners or tunnels.
### Proxy proof (row 178)
+3 -2
View File
@@ -9,6 +9,7 @@
// an agent run gets `human-required` from the broker.
import { spawn } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import { HUMAN_CLI } from '../../cli/src/transport.mjs';
import { readHostState } from '../../cli/src/host.mjs';
import { views } from '../../bus/src/views.mjs';
@@ -28,7 +29,7 @@ export function humanCall({ socket, business, env = process.env, cli = HUMAN_CLI
return (verb, args = {}) => new Promise((resolve, reject) => {
let child;
try {
child = spawn(process.execPath, [cli, socket], { env, stdio: ['pipe', 'pipe', 'pipe'] });
child = spawn(process.execPath, [cli, socket], { env: cli === HUMAN_CLI ? { ...env, MOSAIC_BUS_CLI_NONCE: randomBytes(32).toString('hex') } : env, stdio: ['pipe', 'pipe', 'pipe'] });
} catch { return reject(new BusReadError('outcome-unknown', 'the bus transport did not start')); }
const out = [], err = [];
let size = 0, settled = false;
@@ -83,7 +84,7 @@ export function sendOutcome(err) {
// HTTP status by bus error code. 403: the bus refused this caller; 503: no
// bus to ask, or no answer; 502: an answer that can't be used.
const STATUS = {
'human-required': 403, unauthenticated: 403, 'unknown-business': 403, 'read-only': 403,
'human-required': 403, 'peercred-required': 403, unauthenticated: 403, 'unknown-business': 403, 'read-only': 403,
'outcome-unknown': 503, 'no-bus-host': 503, 'not-configured': 503,
'revision-conflict':409,'organization-busy':409,'organization-uncertain':503,
'invalid-request': 400, 'unknown-role': 400, 'message-not-found': 400, 'request-too-large': 400,
+10 -10
View File
@@ -1,7 +1,9 @@
#!/usr/bin/env node
// MOSAIC_TESTER_SOCKET_V1: private tester ingress, no tester TCP.
import { createChat } from './chat.mjs';
import { startServer, previewOptions, proofOptions, DEFAULT_BOARD } from './serve.mjs';
import { busCaller } from './bus.mjs';
import { startTesterSocket } from './tester-socket.mjs';
import { TESTER_CLI } from '../../cli/src/transport.mjs';
import { views } from '../../bus/src/views.mjs';
import { loadSystem, socketPath, REPO, rolesDir } from '../../cli/src/config.mjs';
@@ -11,24 +13,23 @@ import { statSync } from 'node:fs';
import { isAbsolute } from 'node:path';
import { ID_PATTERN } from '../../business/src/vocabulary.mjs';
const args = process.argv.slice(2);
const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--tester-port N] [--checkout PATH] [--board http://127.0.0.1:7331] [--business ID] [--public-host NAME --auth-header HEADER --allow-user ID [--send] [--proof-header NAME --proof-file PATH]]';
const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--tester-socket] [--checkout PATH] [--board http://127.0.0.1:7331] [--business ID] [--public-host NAME --auth-header HEADER --allow-user ID [--send] [--proof-header NAME --proof-file PATH]]';
try {
if (args.length === 1 && ['--help', '-h'].includes(args[0])) { console.log(usage); process.exit(0); }
if (args.shift() !== 'serve') throw new Error(usage);
const options = { board: DEFAULT_BOARD, port: 7330 }, preview = {}, proofFlags = {};
let sendOn = false, testerPort = null;
let sendOn = false, testerSocket = false;
while (args.length) {
const flag = args.shift();
// Row 76 (#1570): the one flag without a value.
if (flag === '--send') { sendOn = true; continue; }
if (flag === '--tester-socket') { testerSocket = true; continue; }
if (flag === '--tester-port') throw new Error('--tester-port retired; activate explicitly with --tester-socket');
const value = args.shift();
if (!value) throw new Error(`missing value for ${flag}`);
if (flag === '--port') {
if (!/^\d+$/.test(value) || Number(value) > 65535) throw new Error('port must be 0..65535');
options.port = Number(value);
} else if (flag === '--tester-port') {
if (!/^\d{1,5}$/.test(value) || !Number.isSafeInteger(Number(value)) || Number(value) < 1024 || Number(value) > 65535) throw new Error('--tester-port must be a whole number in 1024..65535');
testerPort = Number(value);
} else if (flag === '--board') options.board = value;
// Row 77 (#1571): the canonical checkout the queue read runs from, when
// serve runs from a pinned copy; everything else loads from the copy.
@@ -50,7 +51,6 @@ try {
else if (flag === '--proof-file') proofFlags.proofFile = value;
else throw new Error(`unknown option: ${flag}`);
}
if (testerPort !== null && (testerPort === options.port || testerPort === 7332)) throw new Error('--tester-port must differ from --port and cannot be the forwarded port 7332');
options.preview = previewOptions(preview);
const proof = proofOptions(proofFlags, options.preview);
if (sendOn && !options.preview) throw new Error('--send needs the preview mode (--public-host, --auth-header and --allow-user)');
@@ -78,17 +78,17 @@ try {
} catch (err) {
const why = redactor({ configDir: configDir() })(err.message);
if (sendOn) throw new Error(`--send needs the bus configured (${why})`);
if (testerPort !== null) throw new Error(`--tester-port needs the bus configured (${why})`);
if (testerSocket) throw new Error(`--tester-socket needs the bus configured (${why})`);
busLine = `Bus: not configured (${why})`;
}
const reads = consoleReads({ root: REPO, queueRoot: options.checkout ?? REPO, system, configDir: configDir(), rolesDir: rolesDir(), business: options.business ?? null });
const server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, proof, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite });
let testerServer = null;
if (testerPort !== null) {
if (testerSocket) {
const call = busCaller({ dataRoot: system.dataRoot, socket: socketPath(system.dataRoot), business: options.business ?? null, cli: TESTER_CLI });
const testerBus = { ...views({ call }), names: () => call('names'), instances: () => call('instances'), routes: () => call('routes') };
try {
testerServer = await startServer({ host: '127.0.0.1', port: testerPort, board: options.board,
testerServer = await startTesterSocket({ dataRoot: system.dataRoot, board: options.board,
tester: true, testerProofHeader: proof?.header ?? null, testerAuthHeader: options.preview?.header ?? null, bus: testerBus, reads,
projectFiles: args => call('project.files', args), projectFile: args => call('project.file', args),
instancesWrite: args => call('instances.set', args), routesWrite: args => call('routes.set', args), nameWrite: args => call('name.set', args),
@@ -100,7 +100,7 @@ try {
const previewLine = options.preview ? `\nPreview: https://${options.preview.host}/ through the proxy, signed-in user from ${options.preview.header}, ${send ? 'reads and human messages to roles (--send); one line per send below' : 'read-only'}` : '';
const proofLine = `\nProxy: proxy proof ${proof ? 'on' : 'off'}`;
const queueLine = options.checkout ? '\nQueue: read from the --checkout directory' : '';
console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${options.board}\n${busLine}${queueLine}${previewLine}${proofLine}${testerServer ? `\nTester Console: http://127.0.0.1:${testerServer.address().port}/ (binds the tester)` : ''}\nCtrl-C stops this server.`);
console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${options.board}\n${busLine}${queueLine}${previewLine}${proofLine}${testerServer ? `\nTester Console: unix:${testerServer.address()} (binds the tester)` : ''}\nCtrl-C stops this server.`);
for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => { server.close(); testerServer?.close(); });
} catch (err) {
console.error(`refused: ${err.message}`);
+10 -3
View File
@@ -1,4 +1,5 @@
import { createServer } from 'node:http';
import { bindTesterSocket, TESTER_ORIGIN } from './tester-socket.mjs';
import { readFileSync, openSync, fstatSync, closeSync, constants } from 'node:fs';
import { timingSafeEqual } from 'node:crypto';
import { resolve } from 'node:path';
@@ -463,13 +464,15 @@ async function projectFileRead(res, read, path, search) {
// `send` is the bus write (row 76): `args => Promise<{id}>`, only with the preview mode. `log`
// takes one line per send attempt that got past sign-in; never the body. `fieldsFile` is for
// tests; the field table is otherwise the committed packages/tasks/fields.json.
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, tester = false, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, log = line => console.log(line), fieldsFile = undefined } = {}) {
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, tester = false, testerSocket = null, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, log = line => console.log(line), fieldsFile = undefined } = {}) {
if (!isLoopback(host)) throw new Error('refusing to bind to non-loopback host');
if (host === 'localhost') host = '127.0.0.1';
if (tester && preview !== null) throw new Error('tester listener cannot use preview mode');
if (tester && host !== '127.0.0.1') throw new Error('tester listener must bind 127.0.0.1');
// Row 177 (#1675): chat is allowed. The tester listener's chat binds the tester, so the bus keeps it on tester threads.
if (tester && [send, settingsWrite, projectSave, assistantStop].some(value => value !== null)) throw new Error('tester listener permits only instance, route, name and chat writes');
if (tester && !testerSocket) throw new Error('tester listener requires private socket');
if (!tester && testerSocket) throw new Error('tester socket requires tester listener');
const upstream = boardURL(board);
// An object with none of the three is refused too: asking for the preview never serves without it.
if (preview !== null && !(preview = previewOptions({ publicHost: preview.host, authHeader: preview.header, allowUser: preview.user }))) {
@@ -485,6 +488,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
if (!tester && Object.hasOwn(req.headers, 'x-mosaic-tester')) return json(res, 403, { error: 'tester listener required' });
if (tester && testerAuthHeader && Object.hasOwn(req.headers, testerAuthHeader.toLowerCase())) return json(res, 403, { error: 'proxy header refused' });
if (tester && testerProofHeader && Object.hasOwn(req.headers, testerProofHeader.toLowerCase())) return json(res, 403, { error: 'proxy proof header refused' });
if (tester && (Object.hasOwn(req.headers, 'authorization') || /(?:^|;\s*)__Host-mosaic_(?:session|login)=/.test(req.headers.cookie ?? ''))) return json(res, 403, { error: 'tester listener takes no sign-in' });
let path, search;
// Node keeps the first of repeated Host headers; a second one (a loopback Host, then NAME) is
// never legitimate, so it is refused before any check reads the Host.
@@ -506,7 +510,9 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
// otherwise land on the loopback path unchecked. The operator's browser never sends it.
if (preview !== null && !named && req.headersDistinct[preview.header]) return json(res, 403, { error: 'proxy Host refused', message: 'the proxy must forward Host unchanged' });
try {
if (!named) {
if (tester) {
if (req.headers.host !== new URL(TESTER_ORIGIN).host) return json(res, 403, { error: 'non-local Host refused' });
} else if (!named) {
const authority = new URL(`http://${req.headers.host}`);
if (!isLoopback(authority.hostname.replace(/^\[|\]$/g, '')) || Number(authority.port || 80) !== server.address().port) return json(res, 403, { error: 'non-local Host refused' });
}
@@ -519,7 +525,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
const values = req.headersDistinct[preview.header];
if (!values || values.length !== 1 || values[0] !== preview.user) return json(res, 401, { error: 'sign-in required' });
}
const writer = tester ? { tester: true, user: 'tester', origin: `http://127.0.0.1:${server.address().port}` } : preview;
const writer = tester ? { tester: true, user: 'tester', origin: TESTER_ORIGIN } : preview;
if (tester && !['GET', 'HEAD'].includes(req.method) && !['/api/instances', '/api/routes', '/api/name'].includes(path) && !TESTER_CHAT_WRITE.test(path))
return json(res, 403, { error: 'read-only', sent: false });
if(path==='/api/settings'&&req.method==='POST'){
@@ -618,6 +624,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
res.end(req.method === 'HEAD' ? undefined : content);
} catch { json(res, 500, { error: 'WebUI asset unavailable' }); }
});
if (tester) return bindTesterSocket(server, testerSocket);
return new Promise((resolvePromise, reject) => {
server.once('error', reject);
server.listen(port, host, () => { server.off('error', reject); resolvePromise(server); });
+46
View File
@@ -0,0 +1,46 @@
import { lstatSync, mkdirSync, chmodSync, unlinkSync, renameSync } from 'node:fs';
import { isAbsolute, join, dirname } from 'node:path';
import { randomBytes } from 'node:crypto';
export const TESTER_ORIGIN = 'http://mosaic-tester.invalid';
export function testerSocketPath(dataRoot) {
if (typeof dataRoot !== 'string' || !isAbsolute(dataRoot)) throw Error('tester-socket-path');
const path = join(dataRoot, 'console', 'tester.sock');
if (Buffer.byteLength(path) > 107) throw Error('tester-socket-path');
return path;
}
export async function bindTesterSocket(server, path) {
if (typeof path !== 'string' || !isAbsolute(path) || Buffer.byteLength(path) > 107) throw Error('tester-socket-path');
const parent = dirname(path);
try { mkdirSync(parent, { mode: 0o700 }); } catch (e) { if (e.code !== 'EEXIST') throw Error('tester-socket-unsafe'); }
const dir = lstatSync(parent);
if (!dir.isDirectory() || dir.isSymbolicLink() || dir.uid !== process.getuid() || (dir.mode & 0o077)) throw Error('tester-socket-unsafe');
try { lstatSync(path); throw Error('tester-socket-exists'); } catch (e) { if (e.code !== 'ENOENT') throw e; }
// Same length as tester.sock, so the final-path capacity check covers the temporary name.
const temporary = join(parent, '.' + randomBytes(4).toString('hex') + '.s');
try { lstatSync(temporary); throw Error('tester-socket-exists'); } catch (e) { if (e.code !== 'ENOENT') throw e; }
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(temporary, () => { server.off('error', reject); resolve(); });
});
try {
chmodSync(temporary, 0o600);
const owned = lstatSync(temporary);
if (!owned.isSocket() || owned.uid !== process.getuid() || (owned.mode & 0o777) !== 0o600) throw Error('tester-socket-unsafe');
// Recheck before publishing. Same-uid swaps between lstat and rename/unlink remain
// outside the boundary, as Sage's option-1 ruling and the build receipt state.
try { lstatSync(path); throw Error('tester-socket-exists'); } catch (e) { if (e.code !== 'ENOENT') throw e; }
renameSync(temporary, path);
// libuv remembers only the now-absent temporary name. Publish the actual client path.
server.address = () => server.listening ? path : null;
server.once('close', () => {
try { const s = lstatSync(path); if (s.dev === owned.dev && s.ino === owned.ino) unlinkSync(path); }
catch (e) { if (e.code !== 'ENOENT') server.emit('error', e); }
});
return server;
} catch (error) { await new Promise(r => server.close(r)); throw error; }
}
export async function startTesterSocket({ dataRoot, ...options }) {
const { startServer } = await import('./serve.mjs');
return startServer({ ...options, tester: true, testerSocket: testerSocketPath(dataRoot) });
}
+3 -1
View File
@@ -14,13 +14,14 @@ export async function browser({ machine = true } = {}) {
'--disable-dev-shm-usage', '--remote-debugging-pipe', `--user-data-dir=${profile}`,
], { stdio: ['ignore', 'ignore', 'ignore', 'pipe', 'pipe'] });
let id = 0, buffer = '';
const pending = new Map();
const pending = new Map(), events = new Map();
child.stdio[4].on('data', chunk => {
buffer += chunk.toString();
let end;
while ((end = buffer.indexOf('\0')) !== -1) {
const message = JSON.parse(buffer.slice(0, end));
buffer = buffer.slice(end + 1);
if (message.method) for (const fn of events.get(message.method) ?? []) fn(message.params, message.sessionId);
if (pending.has(message.id)) {
const { resolve, reject, timeout } = pending.get(message.id);
clearTimeout(timeout);
@@ -50,6 +51,7 @@ export async function browser({ machine = true } = {}) {
};
return {
call, evaluate,
on(method, fn) { if (!events.has(method)) events.set(method, new Set()); events.get(method).add(fn); return () => events.get(method).delete(fn); },
async viewport(width, height = 1000) {
await call('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: false });
},
+4 -4
View File
@@ -1,3 +1,4 @@
import { testerRequest } from '../../cli/src/tester-http.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawn, spawnSync } from 'node:child_process';
@@ -62,9 +63,8 @@ test('serve argv carries only fixture proof path and header; startup explicitly
test('CLI applies proof on the main listener and rejects that header on its tester listener', async t => {
const f = await testerFixture(t), file = join(f.root, 'proof.fixture');
writeFileSync(file, VALUE, { mode: 0o600 });
const free = createServer(); free.listen(0, '127.0.0.1'); await once(free, 'listening');
const testerPort = free.address().port; await new Promise(r => free.close(r));
const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', '--tester-port', String(testerPort), '--business', 'acme', ...PREVIEW, '--proof-header', 'x-console-proof', '--proof-file', file], { env: f.f.env, stdio: ['ignore', 'pipe', 'pipe'] });
await new Promise(r=>f.web.close(r));
const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', '--tester-socket', '--business', 'acme', ...PREVIEW, '--proof-header', 'x-console-proof', '--proof-file', file], { env: f.f.env, stdio: ['ignore', 'pipe', 'pipe'] });
let output = ''; child.stdout.on('data', c => output += c); child.stderr.resume();
t.after(async () => { if (child.exitCode === null && child.signalCode === null) { child.kill('SIGTERM'); await once(child, 'close'); } });
await new Promise((resolve, reject) => {
@@ -81,5 +81,5 @@ test('CLI applies proof on the main listener and rejects that header on its test
assert.equal((await get(port, { host: 'console.example', 'x-user': 'jason' })).status, 401);
const good = await get(port, { host: 'console.example', 'x-user': 'jason', 'x-console-proof': VALUE });
assert.equal(good.status, 200); assert.ok(!good.text.includes(VALUE));
assert.equal((await get(testerPort, { host: `127.0.0.1:${testerPort}`, 'x-console-proof': VALUE })).status, 403);
assert.equal((await testerRequest({dataRoot:f.f.dataRoot,path:'/api/config',headers:{'x-console-proof':VALUE}})).status, 403);
});
+6 -2
View File
@@ -7,6 +7,8 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import * as serve from '../src/serve.mjs';
import { close } from './fixture.mjs';
import { startTesterSocket } from '../src/tester-socket.mjs';
import { testerRequest } from '../../cli/src/tester-http.mjs';
const HOST = 'console.example', HEADER = 'x-console-proof', VALUE = 'fixture_proxy_proof_0123456789abcdef_0123456789';
const preview = { host: HOST, header: 'x-user', user: 'jason' };
function ask(port, { host = HOST, headers = {}, path = '/api/bus/tasks', method = 'GET', body } = {}) {
@@ -66,9 +68,11 @@ test('proxy proof header is refused on other Hosts and on the tester listener',
assert.equal((await ask(f.port, { host, headers: { 'x-user': undefined, [HEADER]: VALUE } })).status, 403);
}
assert.equal((await ask(f.port, { host: `127.0.0.1:${f.port}`, headers: { 'x-user': undefined, [HEADER]: '' } })).status, 403);
const server = await serve.startServer({ port: 0, tester: true, testerProofHeader: HEADER, board: 'http://127.0.0.1:9' });
const testerRoot = mkdtempSync(join(tmpdir(),'tester-proof-'));
t.after(()=>rmSync(testerRoot,{recursive:true,force:true}));
const server = await startTesterSocket({ dataRoot: testerRoot, testerProofHeader: HEADER, board: 'http://127.0.0.1:9' });
t.after(() => close(server));
const result = await ask(server.address().port, { host: `127.0.0.1:${server.address().port}`, headers: { 'x-user': undefined, [HEADER]: VALUE }, path: '/api/config' });
const result = await testerRequest({dataRoot:testerRoot,headers:{[HEADER]:VALUE},path:'/api/config'});
assert.equal(result.status, 403); assert.deepEqual(f.calls, []);
});
test('proof options reject unsafe or colliding header names without disclosing file contents', t => {
+10
View File
@@ -808,3 +808,13 @@ test('proxy proof status retains send and tester reporting with both proof flags
assert.match(status.stdout, /send: on \(--send; human messages to roles\)/);
assert.match(status.stdout, /tester: http:\/\/127\.0\.0\.1:1024/);
});
test('row196 socket flag renders only for supporting targets and refuses mixed transports',async t=>{
const dir=tmp(t);
const good=await run(dir,['render','--tester-socket']);
assert.equal(good.status,0,good.stderr);assert.match(execStart(good.stdout),/ --tester-socket$/);
const old=await run(dir,['render','--copy',COPY_ID,'--tester-socket']);
assert.equal(old.status,1);assert.match(old.stderr,/tester.socket.*support/i);
const both=await run(dir,['render','--tester-socket','--tester-port','7333']);
assert.equal(both.status,4);
});
@@ -0,0 +1,26 @@
// UI QA only: CDP fulfills this browser's synthetic-origin requests from a scratch Unix
// listener. No TCP listener, proxy, installed endpoint or reusable relay is created.
import { request } from 'node:http';
import { browser } from './browser.mjs';
import { TESTER_ORIGIN } from '../src/tester-socket.mjs';
export async function testerBrowser(server) {
const b = await browser();
b.on('Fetch.requestPaused', ({requestId,request:incoming}) => {
const url = new URL(incoming.url);
const q = request({socketPath:server.address(),path:url.pathname+url.search,method:incoming.method,
headers:{...incoming.headers,host:new URL(TESTER_ORIGIN).host,origin:TESTER_ORIGIN,'sec-fetch-site':'same-origin'}}, res => {
const parts=[];
res.on('data',part=>parts.push(part));
res.on('end',()=>{
const responseHeaders=Object.entries(res.headers).filter(([name])=>!['connection','keep-alive','transfer-encoding'].includes(name))
.flatMap(([name,value])=>(Array.isArray(value)?value:[value]).map(value=>({name,value:String(value)})));
b.call('Fetch.fulfillRequest',{requestId,responseCode:res.statusCode,responseHeaders,body:Buffer.concat(parts).toString('base64')}).catch(()=>{});
});
});
q.on('error',()=>b.call('Fetch.failRequest',{requestId,errorReason:'Failed'}).catch(()=>{}));
q.end(incoming.postData);
});
await b.call('Fetch.enable',{patterns:[{urlPattern:TESTER_ORIGIN+'/*',requestStage:'Request'}]});
return b;
}
+5 -5
View File
@@ -1,14 +1,14 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { browser } from './browser.mjs';
import { testerBrowser } from './tester-browser-fixture.mjs';
import { testerFixture, testerChat } from './tester-fixture.mjs';
test('tester Console banner exposes only instance route and name editors and saves a route as tester', { timeout: 60000 }, async t => {
const s = await testerFixture(t), b = await browser();
const s = await testerFixture(t), b = await testerBrowser(s.web);
try {
const wait = expr => b.evaluate(`(async()=>{for(let n=0;n<150;n++){if(${expr})return true;await new Promise(r=>setTimeout(r,30));}throw Error('wait failed: '+${JSON.stringify(expr)});})()`);
await b.navigate(`http://127.0.0.1:${s.web.address().port}/#/settings`);
await b.navigate(`http://mosaic-tester.invalid/#/settings`);
await wait('!!document.querySelector("#tester-banner") && document.querySelector("#routes-form button[type=submit]")?.disabled === false');
assert.match(await b.evaluate('document.querySelector("#tester-banner")?.textContent ?? ""'), /tester/i);
assert.equal(await b.evaluate('document.querySelector("#routes-form button[type=submit]").disabled'), false);
@@ -25,13 +25,13 @@ test('tester Console banner exposes only instance route and name editors and sav
});
test('row 177: the tester Console chat composer is enabled and a send shows in its own thread', { timeout: 60000 }, async t => {
const s = await testerChat(t), b = await browser();
const s = await testerChat(t), b = await testerBrowser(s.web);
try {
const wait = expr => b.evaluate(`(async()=>{for(let n=0;n<150;n++){if(${expr})return true;await new Promise(r=>setTimeout(r,30));}throw Error('wait failed: '+${JSON.stringify(expr)});})()`);
const tester = s.broker.bindTester({ business: 'demo' });
const project = s.broker.request(tester, { verb: 'project.create', args: { name: 'probe work' } }).id;
const thread = s.broker.request(tester, { verb: 'thread.create', args: { project, name: 'probe' } }).id;
await b.navigate(`http://127.0.0.1:${s.web.address().port}/#/chat/threads/${thread}`);
await b.navigate(`http://mosaic-tester.invalid/#/chat/threads/${thread}`);
await wait('!!document.querySelector("#tester-banner") && document.querySelector("#chat-body")?.disabled === false');
await b.evaluate(`(()=>{const e=document.querySelector('#chat-body');e.value='from the tester console';e.dispatchEvent(new Event('input',{bubbles:true}));document.querySelector('#chat-send-form').requestSubmit();return true;})()`);
await wait('document.querySelector("#chat-receipt")?.textContent.includes("Accepted")');
+11 -9
View File
@@ -9,9 +9,10 @@ import { tmp, fixture } from '../../cli/tests/helpers.mjs';
import { TESTER_CLI, HUMAN_CLI } from '../../cli/src/transport.mjs';
import { testerFixture } from './tester-fixture.mjs';
import { request } from 'node:http';
import { testerSocketPath, TESTER_ORIGIN } from '../src/tester-socket.mjs';
const CLI = new URL('../src/cli.mjs', import.meta.url).pathname;
const get = (port, path) => new Promise((resolve, reject) => {
const q = request({ host: '127.0.0.1', port, path, headers: { host: `127.0.0.1:${port}` } }, r => {
const q = request({ socketPath: port, path, headers: { host: new URL(TESTER_ORIGIN).host } }, r => {
let text = ''; r.on('data', c => text += c); r.on('end', () => resolve({ status: r.statusCode, body: text }));
}); q.on('error', reject); q.end();
});
@@ -24,24 +25,25 @@ test('tester-port validates range tunnel main port and requires configured bus b
const root = tmp(t, 'tester-cli-invalid-'), f = fixture(root);
for (const flags of [['--tester-port', '7332'], ['--port', '7444', '--tester-port', '7444'], ['--tester-port', '7444', '--port', '7444'], ...['1023', '65536', '1.5', '-1', 'nope'].map(p => ['--tester-port', p])]) {
const r = spawnSync(process.execPath, [CLI, 'serve', ...flags], { encoding: 'utf8', env: { ...f.env, MOSAIC_CONFIG: join(root, 'missing.json') }, timeout: 5000 });
assert.equal(r.status, 2); assert.match(r.stderr, /tester.port.*(1024|7332|differ|whole)/i);
assert.equal(r.status, 2); assert.match(r.stderr, /tester.port.*retired/i);
}
const r = spawnSync(process.execPath, [CLI, 'serve', '--port', '0', '--tester-port', '7333'], { encoding: 'utf8', env: { ...f.env, MOSAIC_CONFIG: join(root, 'missing.json') }, timeout: 5000 });
assert.equal(r.status, 2); assert.match(r.stderr, /tester-port needs the bus configured/);
const r = spawnSync(process.execPath, [CLI, 'serve', '--port', '0', '--tester-socket'], { encoding: 'utf8', env: { ...f.env, MOSAIC_CONFIG: join(root, 'missing.json') }, timeout: 5000 });
assert.equal(r.status, 2); assert.match(r.stderr, /tester-socket needs the bus configured/);
});
test('CLI tester listener binds loopback and spawns TESTER_CLI for every Console bus read', { timeout: 30000 }, async t => {
const f = await testerFixture(t), port = await freePort(), log = join(f.root, 'spawn.jsonl');
test('CLI tester listener binds a private Unix socket and spawns TESTER_CLI for every Console bus read', { timeout: 30000 }, async t => {
const f = await testerFixture(t), port = testerSocketPath(f.f.dataRoot), log = join(f.root, 'spawn.jsonl');
await new Promise(r=>f.web.close(r));
const hook = join(f.root, 'spawn-hook.mjs');
writeFileSync(log, '');
writeFileSync(hook, `import cp from 'node:child_process';import {syncBuiltinESMExports} from 'node:module';import {appendFileSync} from 'node:fs';const original=cp.spawn;cp.spawn=function(cmd,args,options){if(Array.isArray(args)&&args[0]?.endsWith('-cli.mjs'))appendFileSync(${JSON.stringify(log)},JSON.stringify({cli:args[0]})+'\\n');return original.call(this,cmd,args,options);};syncBuiltinESMExports();`);
const child = spawn(process.execPath, [CLI, 'serve', '--board', 'http://127.0.0.1:9', '--port', '0', '--tester-port', String(port), '--business', 'acme', '--public-host', 'console.example', '--auth-header', 'x-user', '--allow-user', 'jason'], { env: { ...f.f.env, NODE_OPTIONS: `--import=${hook}` }, stdio: ['ignore', 'pipe', 'pipe'] });
const child = spawn(process.execPath, [CLI, 'serve', '--board', 'http://127.0.0.1:9', '--port', '0', '--tester-socket', '--business', 'acme', '--public-host', 'console.example', '--auth-header', 'x-user', '--allow-user', 'jason'], { env: { ...f.f.env, NODE_OPTIONS: `--import=${hook}` }, stdio: ['ignore', 'pipe', 'pipe'] });
let output = '', error = '';
child.stdout.on('data', c => output += c); child.stderr.on('data', c => error += c);
t.after(async () => { if (child.exitCode === null) { child.kill('SIGTERM'); await once(child, 'close'); } });
await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(Error('startup did not name tester: ' + output + error)), 5000);
const check = () => { if (output.includes(`Tester Console: http://127.0.0.1:${port}/`)) { clearTimeout(timer); resolve(); } };
const check = () => { if (output.includes(`Tester Console: unix:${port}`)) { clearTimeout(timer); resolve(); } };
child.stdout.on('data', check); child.once('exit', code => { clearTimeout(timer); reject(Error('CLI exit ' + code + ': ' + output + error)); }); check();
});
assert.match(output, /binds (the )?tester/);
@@ -60,7 +62,7 @@ test('CLI tester listener binds loopback and spawns TESTER_CLI for every Console
const { revision, plugins } = await f.call('plugins');
await f.call('plugins.set', { revision, plugins: { ...plugins, channel: ['cli', 'webui'] } });
const send = (path, value) => new Promise((resolve, reject) => {
const q = request({ host: '127.0.0.1', port, path, method: 'POST', headers: { host: `127.0.0.1:${port}`, origin: `http://127.0.0.1:${port}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' } }, r => {
const q = request({ socketPath: port, path, method: 'POST', headers: { host: new URL(TESTER_ORIGIN).host, origin: TESTER_ORIGIN, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' } }, r => {
let text = ''; r.on('data', c => text += c); r.on('end', () => resolve({ status: r.statusCode, body: text }));
}); q.on('error', reject); q.end(JSON.stringify(value));
});

Some files were not shown because too many files have changed in this diff Show More