Compare commits

..
150 changed files with 7648 additions and 4212 deletions

No files matched your search

+3 -3
View File
@@ -5412,8 +5412,8 @@ Row 299 (573, #1806, Gander, Filbert): before, the P10 band test timed a fault r
2026-10-23T17:32:37Z | Gyro sage-3544 fenton-0016 N1/N2 delta | Before: W7 Gate/final real-conversion list omit the W8 build-order cross-reference and W8 human-view filtering cites agent settings. After: both locations explicitly put W8 landing before human opening as mos-1470 build order, not acceptance; comment/tracked-deletion links are not current story links and never appear in the human Connections view regardless of agent settings. New commit atop approved 5af060fb5, no amend. Static diff and append-only checks pass. Docs only, no repo Node/tests/live action. Fenton checks only this delta.
2026-10-23 | Violet row297 #1803, Q245/mos-1498 | Before: no GitHub grant method or Q-B3 answer; row198 unverified. Recovery: read brief, AGENTS, roster, row198 BUILD, #436b r5 1be535aca and Gitea implementation. Merged 14fe7513c then 4d8123b0a without rewriting upstream commits, both Untested. Q-B3 proposal in agents/violet/work/queue-297/DESIGN.md agrees S1/S2/S3/S5/S6 and proposes per-method S4 with bounded token expiry; repository identity and renewal consumer scope go to Sage before implementation. No node/tests/build/live credential operation. Gate and landing remain Sage's, after row198.
2026-10-23 | Fethry | Row 225 C2-10b (#1726), handoff recovery. Before: Morgana's untested candidate 9a131e956 replaces renderItems with renderReply and adds Console citation/notice/status rendering and status/revert routes. Continuing that exact history on fethry/row225; preserving A1-A5 and the accepted file deviations. Remaining: confirm limits contract, review/test candidate under Sage CLEAR, obtain Mos OK through Sage for inherited old-test changes, manifest and Dewey review. No Node/tests run in this session and no green claim.
2026-10-23 | Violet row297 #1803, implementation candidate (UNTESTED) | After Q-B3 reads (dewey-0931, megavolt-0206) and scope sage-3620: protected GitHub App admin/HTTPS/JWT boundary, event-backed durable grants, guarded renewal, token generations, expiry/revoke/recovery, broker/runtime/CLI wiring, additive role/project binding parsing and documentation. All new tests use fake HTTPS/scratch CA/RSA keys; no node or tests run under Sage hold. Old-test delta only packages/business/tests/role.test.mjs:209 1/1 regex, Mos mos-1518; new closed-service-list test preserves its former claim. Consumer/publication gap is explicit in BUILD and README. Static git diff --check only; suites, mutants, manifest approval and gate remain outstanding. Row198 ab6417475 announced as next merge base, still untested.
2026-10-23 | Fethry | Row 225 T1 lane A (sage-3641/3647). Before: nested replyLimits fallback/problem omitted, wrong-project revert receipt accepted, invalid library mapping hidden. After RED: these three local behaviors corrected; three assertions in the new handoff test files corrected against existing route/mapping/Boolean behavior, each explained in BUILD.md. Whole-file GREEN: renderer 9/0, routes 13/0, browser 9/0; full library 81/0, channel-proof 2/0. Approved old-test numstats remain 80/66 and 3/1. Full webui/build and full integration gate have not run. Isolated mutants next within the authorized 19:38Z hard stop; no completion claim.
2026-10-23 | Violet row297 #1803 static review dewey-0944/0946 follow-up (UNTESTED) | Before: no per-path digest packet and lifecycle-delete test stopped after first revoke job. After: second-request completion asserted; expiry sweep removes expired generation files without removing durable grants; installation-token fallback removed; manual run validates before lookup; official API-version source and admin-remove/event-scan limits documented. Three added mutant killers cover DELETE 401, uncertain 0/5xx mint and shutdown signal veto. Digest file will pin the 24 reviewed git blobs and its own SHA will be reported separately. No Node/tests/mutants run; static diff only, Sage CLEAR still required. Landing must retain the seat's BUILD-LOG and SESSIONS appends alongside refactor's.
2026-10-23 | Fethry | Row 225 T1 expanded evidence complete (sage-3647). Full library81/0 and channel-proof2/0; final targeted renderer9/0, routes13/0, browser9/0. Isolated kit14/14 assertion kills on507901277, every target restored/hash-checked, candidate hashes unchanged. First attempt stopped at a wait-based failure, recorded as not killed; corrected the new test's repaint wait, baseline9/0, and reran the whole kit. No processes left; lane A returned around19:29Z. Source manifest for Dewey prepared; full webui/build and Sage's integration/scripts gate still pending. Raw logs preserved in scratch, normalized display copies and raw SHA256 provenance in agent evidence.
-509
View File
@@ -1,509 +0,0 @@
# Row 198 build: retire the board writer on 7331; the Console serves the board in-process
Branch `bushroot/row198-r3`, rebased onto `aca16404c` (queue revs 3195-3216 on refactor). Supersedes
`bushroot/row198-r2` (base b79ad5f2c), which is kept and not force-pushed.
Issue #1495 family, Gitea #1696. Ruling source: Sage sage-2821 answering Webby webby-0150
(CHANGES B1-B5, N1-N3); earlier Q220 ruling sage-2803 (Mos mos-1301), reviewer Webby.
## Status: UNVERIFIED
No test suite, mutant, or node process has run for this commit. Sage's gate (sage-2803, sage-2821):
"no node of any kind and no https git; no test runs until my CLEAR." This commit is local.
It must not be pushed until the gate runs green and Sage opens a gap for the push.
## Ruled: `--board` is dropped for every op (Sage sage-2834 ruling a, sage-2837)
Design item 8's "keep the flag where the target parses it" part is withdrawn. `webui-service.sh
install` has no `--board` branch (exit 4), and `c3b60e513` removed the flag from its parse_flags,
so a kept flag fails at install-console. `install_stack` (stack-release.sh:565) runs that install on
activate as well as rollback, so a re-pin to a pre-198 copy has the same failure. sage-2837 makes
the rule per target, not per op.
Code: `console_for_target` drops every `--board` pair from `BASE_CONSOLE`, for any op. The reason
follows the target's `cli.mjs`:
- `parses_flag` true (pre-198 target): `drop --board <url>; the 7331 board is retired, so this
Console runs without the board`.
- `parses_flag` false (post-198 target): `drop --board <url>; the target Console does not parse it,
so the board runs in the Console`.
`DROP_BOARD_WHY` carries the reason to both print sites (plan and steps).
Tests: M12 unchanged (post-198 target, activate --plan). M13 now covers a kept `--board` for a target
that parses it, on both `activate --plan` to a pre-198 copy and `rollback --plan`. Each asserts the
drop line with the retired-board reason and no `--board` on the `Console:` install line, and that the
plan leaves state unchanged. The README rollback note covers any move to a pre-198 copy.
## What changed in the source
- `scripts/stack-release.sh` (B2, design item 8): `console_for_target` drops a `--board` pair from
`BASE_CONSOLE` unless the target's `cli.mjs` parses `--board`. `parses_flag` matches
`flag === '--board'` and ignores lines that contain `throw` (N2: the target names `--board` only
in a refusal). A dropped pair sets `DROP_BOARD` and `DROP_BOARD_WHY` (reason by target, see the
Ruled section), printed in the plan and the steps. Init at the globals. `bash -n` passes; not run.
- `packages/control-board/src/board.mjs` (B4): `createBoard` takes `isPidAlive` and passes it to
`scanAsync`, so the stale-pid refusal can be tested with an injected liveness check.
- `packages/control-board/src/cli.mjs`: `serve` throws RETIRED, exit 2 (`ConfigError` path).
- `packages/webui/src/serve.mjs`: `/api/seen` and `/api/reply` answer 405 for a non-POST, 403
`{error:'read-only', sent:false}` without `--send` on the public name, 503 with no board, and
otherwise run the in-process board. `boardSend` answers 400 on a body that is not JSON, before
any board call.
- `packages/webui/src/cli.mjs`: `--board` throws `--board retired: the board runs in the Console
(row 198)`. The board is built over `system.dataRoot` (`board/`, `seats/`).
- `scripts/webui-service.sh`: the unit no longer carries `--board`; render and install refuse it.
- `scripts/stack-copy.sh`: COPY_PATHS adds `packages/control-board/src` and `tools/tmux`.
## What changed in the tests
- `packages/cli/tests/stack-release.test.mjs` (B2): new block before the Row 181 section.
`setCli` rewrites a copy's `cli.mjs` and restores it with mode 0444 in `t.after`; `withBoard`
appends the flags to the pointer console.
- M12: `seed`, `setCli` to a refusal-only `cli.mjs`, `withBoard`, `activate --plan`. Expects the
drop line, no `--board` in the Console line, and no state change.
- M13: `setCli` to a parser, real `activate` without the flag, then the unit gets `--board` and
`rollback --plan`. Expects the rollback drop line with the retired-board reason and no `--board`
in the `Console:` install line. Activation runs without the flag because `webui-service.sh
install` refuses it.
- `packages/control-board/tests/serve.test.mjs` (B4, B5): `replyBoard(f, agentSend, isPidAlive)`
passes `isPidAlive` through. New refusal row: a stale registration (pid 4242, `isPidAlive` false)
answers 409 with `/stale/`. B5: the non-numeric `--port` test is deleted, since `serve` no
longer takes a port.
- `packages/webui/tests/send.test.mjs` (B4): `setup` builds an in-process board object
(`index`, `markSeen`, `reply`, each recording into `boardHits`); the `upstream` server is gone.
New case: a board write with a body that is not JSON answers 400 under `--send`, and the board
runs nothing.
- `packages/control-board/tests/retired.test.mjs` (new, B3): M1 `cli.mjs` starts no server;
M2 no board source imports `node:http`, calls `createServer`/`.listen(`, or exports a server;
M9 live READMEs, AGENTS.md and `scripts/*.sh` do not tell anyone to run `control-board serve`
or open 127.0.0.1:7331, except a line that says it is retired. Append-only logs and plans are
not scanned. Grep check on the current tree: no hits.
- `packages/control-board/tests/board.test.mjs` (B3): new test, M15. After a write, `index()` must
start a fresh scan. The event log must read `write, bump, scan, scan`, and the answer must show
the mark.
- The reply join killer (B3, M16) lives in `packages/control-board/tests/serve.test.mjs`, not
`board.test.mjs`. `reply()` refuses an unregistered row before any send, so the killer needs a
registered fixture (`replyFixture`), which `serve.test.mjs` already builds.
- `packages/webui/tests/board-inprocess.test.mjs` (new, B3): M6 and M7 (preview: each board route
answers 401 without the proxy header and 403 with a foreign Host, and the board never runs);
M5 (`/api/seen` without `--send` writes no `seen.json`); M10 (positive control: with `--send` on
the public name, `/api/seen` writes the mark and `/api/reply` runs exec once); M3 (a board read
makes no request to a URL other than the Console's own); M14 (a real board over a 1 s tmux shim:
the loop never stalls and `/healthz` answers while `/api/board` is pending).
- `packages/webui/tests/pinned-copy.test.mjs` (B3, M11): the existing "serve runs from a copy"
test also asserts `/api/board` answers 200 with a `sessions` array from the copy.
## Dropped coverage (B4), with reasons
- Stale-pid reply case. Not dropped: moved. It runs against `createBoard` with the new
`isPidAlive` passthrough (`serve.test.mjs`, the 409 `/stale/` row).
- Bad-JSON request case. Moved to `packages/webui/tests/send.test.mjs`. The HTTP writer is gone;
body parsing now happens in the Console's `boardSend`.
- `page.html` tests. Dropped: the page is deleted, and the Console serves the board page.
- Tester board-proxy tests. Dropped: there is no proxy. The tester socket is built with the
in-process board (N1 below).
## Corrections to r2 (N1)
The r2 note said the tester socket gets `board: null`. That was wrong. Only
`packages/webui/tests/proof.test.mjs` passes `board: null`. `cli.mjs` passes the in-process board
to `startTesterSocket`.
## Mutant map (M1-M15, design rev 5)
Each mutant runs on a scratch copy with the new tests. Not run yet.
| # | Mutant | Killer |
|---|---|---|
| M1 | `cli.mjs serve` starts a server | `retired.test.mjs` M1; also `board.test.mjs` "cli serve refuses" |
| M2 | serve source restores or exports `createServer`/`startServer` | `retired.test.mjs` M2 |
| M3 | Console proxy fetch to a board URL returns | `board-inprocess.test.mjs` "makes no request to a board URL" |
| M4 | `/api/reply` skips the `send` check | `webui serve.test.mjs` "writes refused without --send" (existing; zero captures) |
| M5 | `/api/seen` skips the `send` check | `board-inprocess.test.mjs` "writes no mark"; `serve.test.mjs` as M4 |
| M6 | board block runs before the preview and header gate | `board-inprocess.test.mjs` preview 401 |
| M7 | board route accepts a foreign Host | `board-inprocess.test.mjs` preview 403 |
| M8 | `webui-service.sh` re-accepts `--board` | `service.test.mjs` render refusal list (status 1) |
| M9 | live doc or script tells people to run `control-board serve` | `retired.test.mjs` M9 |
| M10 | `/api/reply` and `/api/seen` branches move below the read-only refusal | `board-inprocess.test.mjs` positive control (200, exec once) |
| M11 | `COPY_PATHS` drops `packages/control-board/src` or `tools/tmux` | `pinned-copy.test.mjs` "serve runs from a copy": `/api/board` 200 |
| M12 | `console_for_target` keeps `--board` for a target that does not parse it | `stack-release.test.mjs` M12 |
| M13 | a recorded `--board` is kept for a target that parses it (activate or rollback) | `stack-release.test.mjs` M13 (activate and rollback): plan drop line, then a real run: drop line in the output (rollback: steps.log), no `--board` in the unit or pointer |
| M14 | a board read blocks the loop: `tmuxInspectAsync` in `scan.mjs` swapped for `tmuxInspect` (spawnSync) | `board-inprocess.test.mjs` M14: real `createBoard`, tmux shim on PATH sleeps 1 s; the timer gap while `/api/board` is pending and `/healthz` elapsed time |
| M15 | `index()` joins a scan that started before the last write | `board.test.mjs` M15 (event log) |
| M16 | `reply()` drops the counter bump: `writes++` at `board.mjs:61` (the event at line 62 is a second mutant, caught by the event log) | `serve.test.mjs` M16: event log `["scan","scan","send","bump","scan"]`, then the next `index()` must log `scan` |
| M17 | `index()` never clears a settled scan: the `promise.then(clear, clear)` line in `board.mjs` `index()` is dropped (Webby B4, r3) | `board.test.mjs` B4: two `index()` calls with no write log `["scan","scan"]` and the second shows a session line appended between them; `board.test.mjs` B4 rejected scan: the next `index()` runs fresh. M15 and M16 still pass after the clear, so they do not kill M17 |
### M14: closed by the real-board test (Sage sage-2839, Webby N1)
The earlier gap came from a stub board: a `spawnSync` in `scan.mjs` never ran under the test. The
M14 test now builds a real `createBoard` whose tmux check runs through a shim on PATH. The shim is
`#!/bin/sh`, `sleep 1`, `exit 1`. The test measures two things, both by elapsed time:
- a 10 ms timer's largest gap while `/api/board` is pending (a blocked loop stalls the timer);
- `/healthz` elapsed time, fetched after `/api/board` is sent.
The bound is 500 ms for each. The async path (`runAsync`) keeps both near 10 ms. The M14 mutant
(`tmuxInspectAsync` calls `spawnSync`) stalls the loop for the shim's full second, so both checks
fail. The real-board test is not run yet (gate). Known soft spots, to check on the first run: the
shim must win the PATH lookup, and the `specs` entry is minimal (no registration), which may not
match a real spec.
## Checks done without running anything
- Read whole: `packages/webui/tests/fixture.mjs`, `packages/webui/tests/send.test.mjs` (first
70 lines), `packages/webui/tests/serve.test.mjs` (first 80 lines), `packages/control-board/
tests/board.test.mjs`, `packages/control-board/src/board.mjs`, `packages/webui/tests/pinned-copy.test.mjs`
(the serve-from-copy test).
- Grep, current tree: `--board` and `boardURL` in `packages/*/tests` and `scripts/test-*.sh`
show only intended refusals and the unrelated case2 suite (`case2-unit.test.mjs`, `case2-unit.sh`).
- The M9 and M2 rules run as greps over the live docs and `packages/control-board/src`: no hits.
- sha256 pins: no changed file pins a file sha256. The 64-hex literal at `service.test.mjs:23` is
a fixture uid, not a hash.
- `packages/webui/tests/library-cli.test.mjs:16`: a fake serve module exports `DEFAULT_BOARD`.
Harmless; may be dropped later.
## Docs (N2)
- `packages/webui/README.md`: the start block is one command (`node packages/webui/src/cli.mjs
serve`); `--board` is refused; the proxy-origin sentence is gone; the Host and write paragraph
says seen and reply reach the in-process board under `--send` on the public name; rollback has
no 7331 step.
- `packages/control-board/README.md`: `serve` is retired and exits 2; the board runs in the
Console; `scan` is kept; the serve-only flags are removed; the route list is marked as served by
the Console; exit 0 is scan only.
- `docs/plans/CURRENT.md`: one dated line appended at the end (append-only).
Open, ruled sage-2868: boardSend's `/api/seen` catch returns 400 for a scan failure after writeMark. Left as built: a scan failure is not a client error, so this is a real gap. Named follow-up in Sage's post-253 DEFERRED sweep (N2).
Still in the README, on purpose: `packages/webui/README.md` line 826 runs the control-board suite
(`node --test packages/control-board/tests/`). The suite still exists, so the line stays.
## Gate, not run
- control-board, webui and cli suites; `scripts/test-*.sh`; the mutants M1-M15 on a scratch copy.
All wait for Sage's CLEAR.
- The per-target drop (sage-2834 ruling a, sage-2837) is implemented and documented; M13 has its
expectation for activate and rollback. Pushed at 2a758a5ba (sage-2852 gap, 17:20Z).
- Webby webby-0152/0153 and Sage sage-2839, after 2a758a5ba: B1 and N3 met at 2a758a5ba. B2 (real
activate and rollback runs, unit and pointer checked), B3 and M16 (reply() join), N1 and M14 (real
board over a tmux shim) are written and uncommitted; the test runs wait for the CLEAR.
- N2 is open, not changed: `boardSend`'s `/api/seen` catch answers 400 with `err.message` for a scan
that fails after the mark is written. Webby marked it non-blocking; Sage has not ruled on it. A
500 for that case would need `ConfigError` to tell refusals from scan failures.
- M14: Sage accepted the gap as recorded (sage-2834). The mutant run is the check.
- Push: not done. Sage's gaps govern it.
## r4: rebase onto 45dca69f6 (sage-3575 step 1, sage-3586 ruling a)
Branch `bushroot/row198-r4-45dca69f6`: the eight r3 commits replayed on `origin/refactor` 45dca69f6.
`bushroot/row198-r3-253` stays at 1fb66792e and is not touched. Seven commits replayed clean. Only the
retire commit (0bde5c8d7, now 4288b7b16) conflicted, in three files and seven hunks.
Ruling applied: every upstream feature stays (federation view, decisionWrite, userOptions, the
`/api/config` fields, granted-user gating). The board runs in-process. The URL proxy to a board origin is
removed, because the proxy is the route to the 7331 writer that row 198 retires. This is the one place an
upstream behaviour changes. Board routes now answer from the in-process board with the same response
shapes. A granted user still gets `owner-only` 403 on board routes, because the granted-user gate runs before
them.
### Conflict hunks
Each hunk: upstream side, our side, resolution, reason.
1. `packages/webui/src/serve.mjs`, `startServer` signature. Upstream: `board = DEFAULT_BOARD`, with
`decisionWrite`, `userOptions`, `federation`. Ours: `board = null`, none of the three. Resolved: upstream
signature with `board = null`, and our doc comment above it. Reason: keeps upstream's parameters; no board
is now the default, not port 7331.
2. `packages/webui/src/serve.mjs`, body after the tester checks. Upstream: decision check, `confirmations`,
`const upstream = boardURL(board)`, `userOptions` check, `owner` with `upstream`, `hub`. Ours: nothing.
Resolved: upstream minus `const upstream = boardURL(board);`; `owner` drops `upstream`. Reason:
`boardURL` is the proxy's loopback validator; there is no proxy. The `let {...}` destructure and the
granted-user `upstream = null` line lose the same name.
3. `packages/webui/src/serve.mjs`, the board proxy block (`allowed`, `fetch(upstream + path ...)`, 502 on
failure). Upstream: the proxy, after the read-only gate. Ours: nothing here; in-process routes sit at two
points. Writes (`/api/seen`, `/api/reply`) answer 405 for a non-POST, 403 `{error:'read-only', sent:false}`
without `--send` and a named human, 503 with no board, then `boardSend`. Reads (`/api/board`,
`/api/conversations`, `/api/conversation`) answer 405 for a non-GET, 503 with no board, then
`consoleRead(board.index())` or `board.conversation`. Resolved: ours, upstream block removed. Reason: this
block is the 7331 route. Auth check order: the sign-in gate (401 for an unsigned request under `--sso`)
and the granted-user owner-only gate both run before the write routes and the read routes.
4. `packages/webui/src/serve.mjs`, `/api/config`. Upstream: `board: upstream`. Ours: `board: boardLabel`.
Resolved: `board: granted ? null : boardLabel`, with every upstream field after it unchanged. Reason:
upstream sent `null` to a granted user (its `upstream` was null); `boardLabel` is `'in the Console'` for the
owner and `null` with no system config.
5. `packages/webui/src/cli.mjs`, imports. Upstream: `DEFAULT_BOARD` in the `./serve.mjs` import, plus the bus
and console imports. Ours: `createBoard`, the two scan discovery imports, and `busCaller` again. Resolved:
upstream imports minus `DEFAULT_BOARD`, plus `createBoard` and `discoverRepoAgents`/`discoverFleetAgents`.
Reason: `DEFAULT_BOARD` no longer exists; `busCaller` was already imported by upstream.
6. `packages/webui/src/cli.mjs`, the `startServer` call. Upstream: `board: options.board`, with
`federation: system?.federation ?? null`. Ours: `board`, without `federation`, `decisionWrite`,
`userOptions`. Resolved: upstream call with `board`. Reason: `--board` is refused at `cli.mjs:39`, so
`options.board` no longer exists; the new `board` is the in-process object.
7. `packages/webui/src/public/app.js`, `/api/config` handler. Upstream: `board = config.board`, the federation
render, the sso line. Ours: `board = config.board || 'the Console board'`. Resolved: upstream handler with
our fallback label. Reason: `config.board` is `null` for a granted user and with no system config, which
would print `Board: null`.
### Upstream tests changed to match (same commit, 4288b7b16)
Upstream added tests that pass a URL board string or `f.boardURL`. Each now means "no board" (`board: null`,
which answers the board routes 503) or the in-process fixture (`f.board`). `board: 'http://127.0.0.1:9'` and
`board:'http://127.0.0.1:9'` became `board: null`; `f.boardURL` became `f.board` in decide-browser,
thread-claims-browser, processing-browser and granted-user. No assertion compared the URL string. The fake
`DEFAULT_BOARD` export strings in library-cli and thread-claims are left: they are fake module text, not board
config.
### Not ported: fake HTTP board servers (known to fail; port before the gate)
These still pass a fake HTTP origin as `board`. The in-process board never calls it, so each test's
board-route assumptions are false now. Each needs a read and a port to an in-process `board` object:
- `packages/webui/tests/browser-edge.test.mjs:19` (waits for an HTTP `/api/board` request, lines 15 and 31)
- `packages/webui/tests/preview.test.mjs:62` (fake board answers `/api/board`; line 160 asserts 200)
- `packages/webui/tests/tester-listener.test.mjs:29` (`hits` counter on the fake board)
- `packages/webui/tests/shell.test.mjs:111` and `:427` (fake board with unavailable, empty, one, fail, refuse modes)
Also a risk, not checked: any test that reaches a board route with no `board` argument. The default moved
from 7331 (a 502 on a dead origin) to `null` (503). A test asserting 502 there would now fail.
### Range-diff
`git range-diff 48e450ce8..bushroot/row198-r3-253 45dca69f6..HEAD`, markers only:
1: 0bde5c8d7 ! 1: 4288b7b16 retire the board writer on 7331 (resolved above)
2: 69ac924bc = 2: aad3000fe build design item 8; killers for M1-M15; B4 drops; docs; BUILD.md
3: 716b46adf = 3: 77c12b554 rollback always drops --board (sage-2834 ruling a)
4: 0bb5f230f = 4: c9c8badc3 drop --board for every op, reason by target (sage-2837)
5: d388f47ec = 5: f2fd6145a real-run M13 pairs, reply join killer M16, real-board M14 (sage-2839)
6: 02d5bd9b4 = 6: f913d0e76 index() clears a settled scan; B4 tests and mutant M17 (webby-0157)
7: 25565fa97 = 7: 41b5aae3c BUILD.md places the reply join killer (B3, M16)
8: 1fb66792e = 8: 3c57ebb22 BUILD.md records N2 as a named follow-up (sage-2868)
Seven commits are unchanged by patch-id. Only the retire commit is `!`.
### Files to rerun (auto-merged or changed on rebase)
The drift check found nine paths touched by both r3 and upstream. Six auto-merged and need a test rerun:
- `packages/cli/tests/stack-release.test.mjs`
- `packages/webui/README.md`
- `packages/webui/tests/browser.test.mjs`
- `packages/webui/tests/chat-browser.test.mjs`
- `packages/webui/tests/chat.test.mjs`
- `scripts/stack-release.sh`
The three conflicted src files are in the hunk list above. The 21 upstream-stub test files listed above also
need a rerun, because the drift check did not cover them: they are upstream-only changes.
### Gate status
UNVERIFIED. No node, no test run, and no mutant has run on r4. The gate waits for Sage's CLEAR, after 226,
220/216 and 285 in gate order. The four fake-board tests above are known red. The rest are unknown until the
run.
## r4: the four fake-board tests ported to the in-process board (sage-3597, sage-3603)
Scope: the four tests that read the retired fake HTTP board server, plus one new granted-user test. Numstat is
against 14fe7513c, the r4 tip before this entry. Nothing in this section has been run. The gate is still
UNVERIFIED, and the tests here were not executed (slot rule). Sage sends the list below to Mos (mos-1272).
| File | +/- | What changed |
|---|---|---|
| `packages/webui/tests/browser-edge.test.mjs` | 14/13 | The fake server is gone. `index()` holds while `holdBoard` is set and resolves to the page's board on release. `reply()` holds in place of the `held` response and returns `{status, body}`. The 5 s "made no /api/board request" check still runs: it now watches `index()` being called. Every assertion is kept. |
| `packages/webui/tests/preview.test.mjs` | 12/16 | `setup()` builds an in-process board that records `GET /api/board`, `POST /api/seen` and `POST /api/reply` in `boardHits`. The 401 and read-only tests keep their `boardHits` checks. The dead-board case is a board whose `index()` throws, so the answer is 502 `invalid-response`. The upstream origin is no longer in the body (see the deleted assertion below). |
| `packages/webui/tests/tester-listener.test.mjs` | 4/4 | The first test's `hits` counter moves into an in-process `index()`. Every bus and board read still counts, and both 403 checks are unchanged. The `createServer` import is removed. |
| `packages/webui/tests/shell.test.mjs` | 44/63 | `board()` is an in-process board. Modes: `empty`, `one`, `registered`, `fail` (throws, so 502 `invalid-response`), `unavailable` (throws `ReadError('read-failed')`, so 503). `hold()` gates `index()`. The conversation catalogue and page are sync. Changed expectations (listed below). Refusal-only steps removed (listed below). |
| `packages/webui/tests/granted-user.test.mjs` | 32/2 | `setup()` takes `board` and `send` options, defaulting to the old `null` and `false`. New test `R6 G4b`: a granted session gets 403 `owner-only` on all five board paths with no board read, and the owner session gets 200 on each, with the read order checked. |
### Changed expectations (not loosened; each is a product change, for Mos)
These expectations now name the in-process contract. The message text came from the fake server before. Each one is
an old assertion with a new value.
- `shell.test.mjs` stale-data check: `/board scan failed.*Showing last known data/` is now `/invalid-response.*Showing last known data/`. A scan throw is mapped by `consoleRead` to `invalid-response` (502). Before, the fake returned 500 with the text.
- `shell.test.mjs` unavailable check: `/board unavailable.*No board data loaded/` is now `/read-failed.*No board data loaded/`. `consoleRead` sends the code as `error`, and the page shows `err.message`, which is that code. The page shows `read-failed` now, where it showed `board unavailable` before.
- `preview.test.mjs` dead board: the status stays 502, and `body.error` is now `invalid-response`.
### Deleted assertions (proxy-only, no in-process counterpart)
- `preview.test.mjs` `assert.equal(board.body.board, dead)`. The in-process board has no origin, so the body
can't name one. The status and the bus checks around it are kept.
- `shell.test.mjs` the refusal block: the fake's HTTP 403 `board-refused`. `consoleRead` has no 403 code: `STATUS`
in `reads.mjs` maps only `not-configured`, `no-bus-host`, `queue-refused`, `read-failed`, `not-found` and
`invalid-request`, and any other code is 502. So the in-process board cannot refuse, and `app.js`'s
`err.status === 403` branch ("The board refused the read", `#board-refused`, the fail-closed drop of kept
rows, the inspector and the counts) has no trigger in these tests. The block that was deleted asserted every
one of these. The conversation refusal is deleted too: a 403 from `/api/conversation` closed the open
conversation, and the new step closes it with `#conv-back`.
This is a coverage loss, and it needs a decision. Either `reads.mjs` maps a board refusal to 403 (a product
change, which Mos rules on), or the refusal branch is retired. The granted-user 403 `owner-only` answer goes
through the same `err.status === 403` branch, so the page path is still reached by granted users. It is not
covered by a shell check.
### Status change, dead board (Webby's question, webby-0229)
A board failure used to answer by the origin's state: a dead origin was 502 with the origin named, a board 500
was 500, and a 503 passed through. Now:
- no board configured (`board` null): 503 `not-configured`;
- a `ReadError` with a code in `STATUS`: that code's status;
- any other throw (a scan failure): 502 `invalid-response` on `/api/board`. The 500 applies only on the
conversation routes (`conversation read failed`).
No test under `packages/webui/tests` asserts 502 on a board path other than the preview dead-board case above.
The grep: `usage`, `limits-route`, `routes`, `chat`, `library-status`, `thread-claims`, `send`, `bus`,
`project-file`, `tester-listener`, `send-browser`, `instances`, `tester-cli` and `agents`. Those 502s belong to bus,
chat, or project-file routes, which this change does not touch.
### Granted-user gate (sage-3603): the test and its mutant
The board is a closure that the granted rebind doesn't touch, so `userRoute` is the only gate in front of it
for a granted session. Upstream's null-origin backstop is gone. Test `R6 G4b` pins that: the granted session gets
403 `owner-only` on `GET /api/board`, `GET /api/conversations`, `GET /api/conversation`, `POST /api/seen` and
`POST /api/reply`, and `reads` is still empty after those five requests. The owner session gets 200 on each, and
`reads` shows the five calls in order.
Mutant M18 (not run): in `packages/webui/src/serve.mjs`, change the granted gate
`if (!userRoute(path, req.method)) return json(res, 403, ...)` so it passes when `path` is one of the five board
paths. Expected: `R6 G4b` fails at the granted 403 assertion, because the granted request reaches the board and
`reads` is not empty. Run it with the rest of the granted-user suite once Sage gives CLEAR. The kill is not yet shown.
### Gate status
UNVERIFIED. These four files and the granted-user test are edited but not run. The rule "commit only after suites
are green" is not yet met, and this commit says so. The coverage loss above needs Sage's ruling before the gate.
## r4 round 2: mos-1513 (B1 fix), N1 test, route helper (19:23Z 10-23)
Not run. Node and browser suites wait for Sage's CLEAR.
### B1: CDP interception instead of a named listener on the old files
Per mos-1513, the browser tests keep the real in-process board. Each one calls `routeWrites(b, base, board)` from
`packages/webui/tests/board-route.mjs` before it navigates. Fetch intercepts the page's `/api/config` (answered
with the real body and `send: true`), `/api/reply` and `/api/seen`. Each write is forwarded over node http to a
named send listener on the same board, with the preview Host, the `https://` Origin and the signed user header.
So the server's own gate, parse and status mapping answer, and the page gets that answer back. Nothing re-implements
`boardSend`.
Shared helper, named for webby-0243: `board-route.mjs` exports `ROUTE`, `namedListener(board)`, `forward(port, path,
body)` and `routeWrites(b, base, board)`. `namedListener` and `forward` are the same code `routeWrites` uses.
### Numstat (git diff --numstat, packages/webui/tests, against 4d8123b0a)
3 1 board-held.test.mjs
7 2 browser-edge.test.mjs
6 2 browser.test.mjs
6 3 discord.test.mjs
4 2 history-return-flow.test.mjs
4 2 return-flow.test.mjs
New files: `board-route.mjs` (helper), `board-route-map.test.mjs` (node, see below), `board-refused.test.mjs` (N1).
### Line map: every removed line in the six old tests
No assertion line is changed. Every removed line is one of these, and each is listed by old line:
- board-held.test.mjs: `done` teardown (adds `stopRoute()` before `b.close()`); the `routeWrites` line is added in
`setup`. No assertion.
- browser-edge.test.mjs: the `await b.viewport(320); await b.navigate(...)` line is rewritten as the same two
statements with the `routeWrites` line added before it; the `finally` adds `stopRoute?.()`. The `board` object
is unchanged. `held` and `releaseBoard` are the same. No assertion.
- browser.test.mjs: the `finally` adds `stopRoute?.()`; `routeWrites` is added before the `viewport`/`navigate`
line, which is byte-identical. No assertion.
- discord.test.mjs: the `fetch(base + '/api/reply', ...)` request line becomes `namedListener` + `forward`, the
request setup, not the assertion; `assert.equal(reply.status, 409)` is byte-identical. The `finally` closes the
named listener.
- history-return-flow.test.mjs: `let b` gains `stopRoute`; `finally` adds `stopRoute()`. No assertion.
- return-flow.test.mjs: `let web, b` gains `stopRoute`; `routeWrites` added after `browser()`; `finally` adds
`stopRoute()`. No assertion.
### N1 (sage-3618, mos-1506 b): the refusal block, old to new
Old line numbers are in `shell.test.mjs` at 14fe7513c, the block 178-245. New line numbers are in
`board-refused.test.mjs`. Every old assertion is restored with its text unchanged:
181 -> 74 183 -> 76 184 -> 77 185 -> 78 187 -> 80
188 -> 81 189 -> 82 190 -> 83 191 -> 84 192 -> 85
193 -> 86 194 -> 87 195 -> 88 196 -> 89 197 -> 90
203 -> 97 204 -> 98 205 -> 99 206 -> 100 207 -> 101
208 -> 102 209 -> 103 210 -> 104 211 -> 105 212 -> 106
215 -> 109 216 -> 110 217 -> 111 218 -> 112 220 -> 114
221 -> 115 227 -> 122 229 -> 124 232 -> 127 236 -> 131
237 -> 132 238 -> 133 239 -> 134 240 -> 135 241 -> 136
243 -> 138
Changes in the port: the page-level 403 stub (`Fetch` on `*/api/board*` and `*/api/conversation*`) is the one
place a page-level stub is used, per N1. The board is the in-process one with `refuse()` set. The fake-board
data in `board()` is the old fake board's data, moved.
Pinned N1 mutant (not run): in `packages/webui/public/app.js` (the `error(err)` branch that sets
`refused = err.status === 403`), drop the `#board-refused` show and the `refused` banner. Expected:
`board-refused.test.mjs` fails at line 80 (`banner ref`) and line 83 (`#board-refused` hidden). Run after CLEAR.
### Node coverage for the real route
`board-route-map.test.mjs` (new) starts the named listener on a fixture board and pins the mapping the browser
tests now rely on: seen is 200; a delivered reply is 200 with `delivered: true`; an exit-4 reply is 200 with
`delivered: false` and `exitCode: 4` (this is `replyWith` in control-board/src/serve.mjs, which maps a nonzero exit
to 200, not a status code). Existing coverage cited, not duplicated: `board-inprocess.test.mjs:84-97` (seen 200 and
reply delivered through a named listener); `send.test.mjs:114-119` (loopback Host writes 403 read-only, nothing
reaches the board); `preview.test.mjs:185-199` (every write 403 read-only from the public name and loopback; 401
unsigned on `/api/reply`).
The discord 409 is now reached through the named listener, so `discord.test.mjs` pins the connector refusal on the
real route.
### Not done in this round
- The plain-listener browser check for the reply form and Seen line with send off (the `canSend` gate in app.js).
The node and browser coverage for that gate is not written yet.
- `send-browser.test.mjs:67,243` is unchanged. Webby to confirm next round.
- Mutant M18 and the N1 mutant are not run.
### Gate status
UNVERIFIED. Nothing in this round is run. Waiting for Sage's CLEAR before any node or browser run.
### Correction to the r4 round 2 entry above (19:24Z 10-23)
- The N1 mutant's file is `packages/webui/src/public/app.js`, not `packages/webui/public/app.js`.
- `board-refused.test.mjs` does not use the in-process board. Its `board()` is an inline stub object with a `refuse()`
flag; the 403 comes from a page-level Fetch stub on `*/api/board*` and `*/api/conversation*`, as the N1 ruling
allows. The "in-process board with refuse() set" sentence above is wrong.
## r4 round 2 fixes: mos-1522 (19:40Z 10-23)
Not run. Commit held locally until the suites pass (mos-1522).
- B1 (discord.test.mjs:43, :50): app.js now checks `r.connector` first, then `!canSend`, then the registered-seat text, in both the inspector reply line (app.js:100) and `convForm` (app.js:200, conv-no-reply). Discord still asserts "Board replies disabled for Discord connectors" with no routeWrites added. Assert lines are unchanged.
- B2: `conv-no-reply` with send off reads "Replies are off: this Console is read-only.", asserted in send-off-browser.test.mjs.
- routeWrites flips only `send` to true, over the real `/api/config` answer. Nothing else in the config body is changed.
- Mutants (not run): (a) drop `canSend &&` from `canReply` (app.js:51); (b) drop the `seenButton` guard (app.js:71). Expected: send-off-browser fails on `.reply-form` absent (a) and on `[data-seen]` absent (b).
## r4 round 2 follow-up: Webby CHANGES on ab6417475 (N1, N2, B1 board-refused) (10-23 19:46Z)
Not run. Gate waits for Sage's CLEAR. Commit A of the follow-up; the old-test setup is commit B, below.
- N1: `routeWrites` builds the config URL with `new URL('/api/config', base)`, so a trailing-slash base no longer requests `//api/config` (which parses as host `api`). The config body now comes from one new export, `sendConfig(base)` (board-route.mjs), which `routeWrites` and `sendOnConfig` both use.
- New export `sendOnConfig(b, base)` (board-route.mjs): Fetch answers the page's `/api/config` with send on and leaves every write alone. It is config only (mos-1524); its callers are in commit B.
- board-refused.test.mjs: line 13 imports `sendConfig`; the Fetch pattern list adds `*/api/config` (line 59 onward); the handler answers `/api/config` with `sendConfig(base)` and returns before the refusal branch. The 403 refusal stub is unchanged. The assert that was line 122 (`#conv-form` hidden is false) is now line 128, unchanged; it sees send on.
- N2, the "Not done" list above is out of date. Its line "the canSend check is not written" is wrong: the check is in app.js (ab6417475, mos-1522 fixes in b0c79751c). Written as of this follow-up:
- app.js:24 `canSend` (set from `/api/config` at app.js:482); app.js:51 `canReply` gated on `canSend`; app.js:71 `seenButton` guard.
- app.js:100 inspector reply line and app.js:200 `convForm` (conv-no-reply): connector first, then the read-only line, then the registered-seat line.
- New user-visible strings: "Replies are off: this Console is read-only." and "Seen is off: this Console is read-only.".
- New test files: board-route.mjs (helper), board-route-map.test.mjs (node), board-refused.test.mjs, send-off-browser.test.mjs.
- Mutants planned, not run: (a) drop `canSend &&` from `canReply` (app.js:51); (b) drop the `seenButton` guard (app.js:71). Both must fail in send-off-browser.test.mjs.
## r4 round 2 follow-up, commit B: old-test setup for the canSend gate (Webby B1, mos-1524) (10-23 19:46Z)
Not run. Setup only: no assert line changed. Approved on the mos-1522 terms (mos-1524: config-only stub, not routeWrites). Gate waits for Sage's CLEAR.
Numstat for this commit (`git diff --numstat`):
| file | added | removed | change |
| --- | --- | --- | --- |
| packages/webui/tests/relaunch.test.mjs | 2 | 1 | line 10 imports `sendOnConfig`; line 32 `await sendOnConfig(b, webURL)` after `browser()` |
| packages/webui/tests/shell.test.mjs | 2 | 0 | line 17 imports `sendOnConfig`; line 124 `await sendOnConfig(b, base)`, first test only |
| packages/webui/tests/sso-browser.test.mjs | 1 | 1 | line 33 page stub: `/api/config` gets `send: true` on every page; `sso: true` still only with `sso` in the URL |
- relaunch.test.mjs:49 (`#reply` present, "reply eligibility unchanged") is unchanged and now sees send on. No teardown is needed: `sendOnConfig` starts no server; `b.close()` in the finally ends the Fetch domain.
- shell.test.mjs:217 (`#conv-form` hidden is false) is unchanged and now sees send on. The five-states test (from about line 410) has no reply, Seen or conv-form assertion and is not changed.
- sso-browser.test.mjs:91-92 (`[data-seen]` wait and click) is unchanged. Its `/api/seen` write is still answered 401 `sign-in required` by the page stub before it reaches the server, so the sign-in path is what runs. The sign-in lines and the `__signIn` list are unchanged.
- board-refused.test.mjs:128 (`#conv-form` hidden is false) is in commit A, with its config stub; it needs no change here.
- Totals for this commit: 5 added, 2 removed, 3 files. Assert lines changed: 0.
+376
View File
@@ -0,0 +1,376 @@
# Row 225 (C2-10b) Console chat: replies, citations, write notices, status line
Owner: Morgana, from sage-3582 (18:53Z 10-23). Base: origin/refactor 45dca69f6. Branch: morgana/row225.
Brief: docs/plans/2026-10-20_case2-rows.md, C2-10b. Design: agents/gosalyn/work/case2/DESIGN.md 7.4,
5.2 (the reply schema and the check result) and the write notice and `library.revert` (about line 286).
## Ruling on the dependency gap (sage-3591)
225 builds against the design contracts. `renderReply` stays pure, and the caller supplies the answers
it can't know. The revert route proxies to the bus verb and shows the refusal code until the verb
exists. Landing order: 225 lands after 217 (C2-06), not after 288 (C2-02). Sage adds `after 217` to
row 225. Before 288 lands, the Revert button gets the bus's refusal and shows its code, as the brief
allows.
## A1. The check result I assume (to 217, Launchpad)
The broker stores one check result with each agent-to-human message the guard passes. The Console reads
it on the message row that `/api/chat/threads/<id>/messages` returns, under the key `check`:
```json
{
"check": {
"citations": [
{ "scope": "canon", "agent": null },
{ "scope": "supporting", "agent": "acme/scribe" },
{ "scope": "web", "agent": null }
]
}
}
```
- `check` is `null` or absent on a message the guard didn't check: a human's message, a free-text reply,
a notice, or a message from before 217.
- `check.citations` has one entry per entry of the body's `citations`, in the same order.
- `scope` is one of `canon`, `playground`, `supporting` and `web`. A url cite is `web`.
- `agent` is the `Mosaic-Agent` trailer value, `<business>/<instance>`, from
`git log -1 --format=%(trailers:key=Mosaic-Agent,valueonly) <commit> -- <path>`, or `null` when the
file's last commit at `commit` carries none. A url cite has `agent: null`. The Console shows the
agent-written label when `agent` is a nonempty string, and names the agent.
The Console trusts nothing in the body for scope or label. A cite with no matching check entry renders
with no badge and no label. It doesn't fall back to a scope it worked out itself.
If 217 uses other names, I change mine to theirs before either of us pushes (sage-3591 item 1).
## A2. The revert verb I assume (to 288, Honker)
- Verb: `library.revert`. A human verb (design M4): from an agent session it refuses `human-required`,
like `library.status` (`broker.mjs:5133`).
- Args: `{ "project": <business project name>, "commit": <40 lowercase hex> }`. The design writes
`library.revert {commit}`. `project` is added to match the other library verbs, and like theirs it is the
key of `business.projects` (`mirror.mjs:31`), which is the chat project's `repo` link, not its UUID. Both are required, and no other key
is allowed (`invalid-request`), as the other library verbs check with `keys(args, ...)`.
- Result: `{ "project", "path", "reverted": <the commit given>, "commit": <the new commit> }`.
- Refusal codes the Console shows by name:
- `library-revert-not-agent`: `commit` has no `Mosaic-Agent` trailer for this business, or isn't
reachable from the served commit.
- `stale-base`: a later commit changed that path.
- `human-required`, `invalid-request`, `project-not-configured`.
- Before 288 lands, the bus refuses the verb (expected `unknown-verb`, `broker.mjs:6578`), and the Console
shows that code.
- HTTP status in the Console (`packages/webui/src/bus.mjs` `busStatus`): `stale-base` and
`library-revert-not-agent` map to 409. Every other code keeps the existing mapping.
## A3. The status line
`GET /api/library/status?project=<id>` proxies `library.status {project}` through the existing
`libraryStatus` option. `cli.mjs:110` already passes that option, but `startServer` doesn't read it.
The status line shows:
- the served commit (7 characters) and its age;
- `library-diverged` when the sync state names it;
- the fetch error code;
- the reply limits in force, and the keys that fell back;
- the count of R-problems, linking to the list.
It reads the fields `readSyncState` returns at the base (`packages/library/src/mirror.mjs:232`). It shows
any field 217 adds (the limits) only when present.
## A4. "Changed since" (ruled sage-3596)
Sage ruled that 217 supplies `last`. On a `thread.messages` row that carries `check`, each path cite's
entry gets `last`, the file's last commit at the served tip, read when the message is read. When `last`
is absent the Console marks nothing changed, and a test pins that.
One more field is needed, raised in morgana-0200. The cited `commit` is a library commit, not the
file's own last commit, so a cite made at a commit that didn't touch the file would differ from `last`
while the file is unchanged. I asked 217 to also store `file`, the file's last commit at the cited
commit. That's `%H` from the same `git log -1 <commit> -- <path>` call that reads the trailer, so it
costs no extra git call. The Console marks a cite changed when both `file` and `last` are 40-hex and
differ. The cited commit is reachable from the served one (5.2), so `file` is an ancestor of `last`,
and differing means newer. A missing or malformed `file` or `last` marks nothing.
The entry the Console reads, in full: `{ scope, agent, file?, last? }`.
## A5. `no-cited-answer` retires
`renderItems` and its `NOTICES` (`no-cited-answer`, `no-answer`, `turn-failed`) go. 217 sends the
runner's fixed texts as plain bodies (design 5.2, "Runner texts"), and renderReply shows those as free
text. Design 7.4 names no other notice than `library-write`, so nothing is lost (sage-3596 item 3).
## Files
Both deviations below were accepted in sage-3596 and are named in the manifest for Dewey. The brief owns these:
- `packages/library/src/render.mjs`: `renderReply` replaces `renderItems`.
- `packages/library/src/index.mjs`: the export.
- `packages/library/README.md`: the section.
- `packages/library/tests/render.test.mjs`.
It also owns `bus.js`, its stylesheet, `serve.mjs` (only the two routes) and new webui tests. Two
deviations, both recorded here:
- The chat view's message list is `packages/webui/src/public/chat.js:85` (`window.MosaicChat`), not
`bus.js`. The reply, notice and Revert rendering goes in `chat.js` and `chat.css`. The status line
goes in the chat thread view, so it is also in `chat.js`.
- `packages/webui/src/console-bus.mjs` and `cli.mjs` each get one `libraryRevert` line, beside the
existing `libraryStatus` wiring.
## Overlap with row 220 (C2-10a, Researcher, 3ef6da90d)
220 changes one line each in the webui tests discord.test.mjs, fixture.mjs, history-fixture.mjs,
relaunch.test.mjs and return-flow.test.mjs. Its other paths are in control-board, conversation, discord
and business. 225 doesn't touch those five files or those packages. Its webui tests are new files.
## Handoff to Fethry (mos-1501, Q245)
The row moved to Fethry (Codex, fethry/row225). Everything below is **untested**: no suite or single file
has run on it, since a run needs Sage's CLEAR. Each changed file passes `node --check`.
Done in the handoff commit:
- `render.mjs` `renderReply(body, {check, changed, name})`; index export; README section;
`render.test.mjs` rewritten. `renderItems` is gone from every package.
- `serve.mjs`: `/render.mjs` static entry (owner and user file maps), `GET /api/library/status`
(one `project` query, answer must name that project), `POST /api/library/revert` (save's boundary,
strict `{project, commit}`, refusal shows the code, lost or unusable answer is `outcome-unknown`,
never retried). Both are nulled for a granted user, and `userRoute` keeps them owner-only. The tester
listener refuses `libraryRevert`.
- `bus.mjs`: `library-revert-not-agent: 409`. That re-pins `packages/webui/src/bus.mjs` in
`packages/bus/tests/channel-proof.test.mjs`, with a comment line, as row 254 did. It's a third deviation,
in another package's test.
- `console-bus.mjs` and `cli.mjs`: `libraryRevert`, beside `libraryStatus`.
- `chat.js`: dynamic `import('/render.mjs')` (CSP is `script-src 'self'`); body text until it loads.
Cites with a scope badge, the agent-written label, "changed since" from `file`/`last`, links only for
https with `rel="noopener noreferrer"`. A notice gets a Revert button behind `confirm()`. The library
status line goes under the thread heading.
- `chat.css`: cite, badge, notice and status styles.
- New tests: `packages/webui/tests/library-routes.test.mjs` and
`packages/webui/tests/chat-replies-browser.test.mjs`. 220's five files are untouched.
Decisions made in the build:
- The library name is the chat project's `repo`. A project with no repo reads no status and its
Revert does nothing.
- These codes hide the status line: `project-not-declared`, `library-mapping-invalid`,
`project-not-configured`, `not-configured` and `owner-only`.
- Otherwise every code-repo thread, and every granted user, would show "unavailable".
- Cost: a broken library mapping also hides the line. `library.status` from the CLI still shows it.
- "Age" is the time since `lastFetch` ("fetched 5 min ago"), not the served commit's age, which the
status doesn't carry.
- The limits fields are assumed to be `replyLimits {requireCitation, citationsPerReply}` and
`replyLimitsFallback [keys]`, and are shown only when present. **Not agreed with 217.** Confirm with
Beakley and rename.
- A notice body's shape is all the renderer checks. An agent in a business without a library could
send a body that looks like a notice. The page would show it, but `library.revert` still refuses it.
Left:
1. Get CLEAR and run the webui and library suites, `channel-proof.test.mjs`, and the two new files whole.
The browser test's selectors and waits have never run.
2. Agree the limits field names with 217 (Beakley).
3. Granted-user R6 G5 (`granted-user.test.mjs:252`) lists owner verbs by hand. Add `library.revert`
once 288 lands.
4. BUILD.md: the mutant list and the mos-1275 timeout plan.
5. The manifest names the three deviations: chat.js and chat.css in place of bus.js, the console-bus and
cli lines, and the channel-proof re-pin.
## Fethry continuation (2026-10-23, Q245)
Owner: Fethry, thread 14f80e3c-ace7-4793-a577-e1275ca09b0b. Branch fethry/row225.
This file carries Morgana's complete BUILD.md above unchanged, recovered from 9a131e956
(base 45dca69f6). Morgana stopped editing 225 at that handoff. Issue #1726; Dewey reviews.
Sage owns queue moves, test slots, gate and landing. No Node/tests until Sage CLEAR
(sage-3611); commits/pushes before the gate are explicitly Untested. No credential files read.
### Confirmed interfaces and rulings
- Beakley beakley-0004 confirms ordered check.citations {scope, agent, file, last}; a missing
or malformed file/last means no changed marker. Fetched age accurately describes lastFetch.
- Beakley's concrete library.status shape is replyLimits:
{requireCitation, citationsPerReply, fallback: [keys], problem: 'reply-limits-unreadable' | null}.
The inherited top-level replyLimitsFallback assumption is superseded. Show nested fallback and
problem, with a browser regression test for each. No broker or policy change in this row.
- Bubba bubba-0002 confirms A2 and the exact broker notice shape. fethry-0005 forwards the
project-key correction: project is chat project.repo, not the chat UUID.
- Keep the inherited ordinary-code-repo/granted-user status hiding behavior for now, but a
library-mapping-invalid error should stay visible: hiding an invalid mapping hides diagnosis.
Confirm this local UI choice through regression coverage and tell Sage/Dewey.
### Continuation plan and acceptance evidence
Spec: docs/plans/2026-10-20_case2-rows.md C2-10b; design 5.2 and 7.4;
standing rulings sage-3591, sage-3596 and sage-3611. Implement inline in this existing isolated
T3 worktree; use the existing candidate and tests, never restart the implementation.
1. Static audit the handoff against the contracts and inherited tests. Add failing coverage for
nested limits fallback/problem, wrong-project revert receipts, missing file/last and invalid
mapping visibility. Existing tests require Mos approval through Sage before further edits.
2. On Sage CLEAR, run the new files whole and library renderer tests with per-test deadlines;
record RED before changing uncovered behavior. Fix only demonstrated local defects. Run
targeted GREEN, then the full relevant suites only within the authorized slot.
3. Record exact commands, timings and output paths. Prepare mutant kill list below; run only on
CLEAR, using isolated copies so the candidate is never temporarily changed for mutation.
4. Freeze a manifest containing all source/test/doc changes and all three handoff deviations.
Push only fethry/*, as additional commits. Send exact head/results to Dewey and Sage; cc Mos
on pushes/blockers. Sage performs queue moves, gate and landing. No merge/release/live changes.
### Existing test changes requiring Mos OK through Sage (mos-1272)
Diff against 45dca69f6, inherited from Morgana 9a131e956:
| File | Added | Removed | Reason |
| --- | ---: | ---: | --- |
| packages/library/tests/render.test.mjs | 80 | 66 | Brief requires retiring renderItems and replacing its tests with renderReply coverage |
| packages/bus/tests/channel-proof.test.mjs | 3 | 1 | bus.mjs gains library-revert-not-agent:409; refresh that source hash and document why |
No further edits to these files pending approval. No edits to row220's five tests.
The new webui test files are additions against the row base.
### Timeout plan (mos-1275)
Every Node test run uses --test-concurrency=1 and --test-timeout=30000, under an outer timeout.
New library/route files: outer 120s each; new browser file: outer 240s. Full library and channel-proof:
outer 180s; full webui: outer 900s, MOSAIC_REQUIRE_EDITOR_BUILD=1. Full webui requires its own
packages/webui npm ci per agents/README.md and Sage CLEAR. Output redirects to an evidence log;
inspect tail, failure names and summary, never claim green from an exit alone. A timeout is a
failed run: stop, report the exact command/phase/last output, and diagnose before retrying.
Sage's gate owns every scripts/test-*.sh; no implicit slot or test authority from this plan.
### Mutant plan (pending CLEAR)
- M1: drop a path badge or the supporting label; cite browser assertions must fail.
- M2: allow http/javascript links; renderer and browser safe-link assertions must fail.
- M3: compare cited library commit to last instead of file to last, or treat a missing file/last as
changed; changed-since assertions must fail, including same-file/different-library-commit.
- M4: ignore nested fallback/problem; status browser tests must fail.
- M5: remove a revert fence (send, identity, exact Origin or Fetch Metadata), strict key/commit
validation or wrong-project receipt validation; route tests must fail with zero unexpected calls.
- M6: retry a refused/unknown revert; single-call route/browser assertions must fail.
- M7: remove granted-user library-route fence; owner-only route coverage must fail.
Progress: handoff and interfaces recovered; candidate untested. No completion claim.
Static evidence correction: the first pushed patch artifact used ordinary context lines, whose
single-space blank context lines make git diff --check report whitespace in the artifact. Regenerated
inherited-old-tests.patch with --unified=0; it is the same exact two-file change, applicable with
git apply --unidiff-zero. No source or existing test changed for this correction.
Mutation kit authored at mutants.py (11 concrete mutations across M1-M7), Python AST parse only.
It archives the committed packages plus their tracked fixture dependencies into disposable snapshots,
links the existing dependency install read-only in practice (tests do not install), enforces exactly
one mutation anchor and requires an assertion failure for a kill. It records source hashes before
and after. It has not run; anchors for the planned fixes intentionally require those fixes first.
No Node/tests/build/browser work has run in Fethry's session pending Sage CLEAR.
### Old-test approval and preservation condition (mos-1510, sage-3623)
Mos approves render.test.mjs +80/-66 and channel-proof.test.mjs +3/-1 exactly. No additional edits
are permitted to either file without a new Mos OK through Sage. The condition on the renderer:
- The old malformed-body fixed-line behavior no longer applies. Design 5.2 now says any body
outside the cited-object schema is free text whose text is the whole body. Rejecting it into a
fixed line would hide human/free-text/older messages and the four fixed runner texts. The new
render.test.mjs tests “free text renders as itself with no citations” and “a body without the
reply or notice shape is free text: the whole body, no citations” explicitly cover this new rule.
- The old no-options path-badge behavior still applies. The new render.test.mjs test “without a
check result a path cite has no badge, no label and is never changed” covers it, and the browser
test checks an unchecked path cite has no badge/label. No path inference fallback was added.
Dewey checks both against the frozen old-test delta. The two numstats remain 80/66 and 3/1.
### Test slot and shared protocol (sage-3623, beakley-0006)
Targeted lane A slot is planned around 19:47Z, after Gosalyn's 226 run and Sage's queue gap.
Wait for explicit CLEAR; the scheduled time alone grants nothing. Hard stop 20:00Z for Sage's
226 gate. Boyd's lane B bus/cli/business work is disjoint and does not count as a busy lane A.
The three requested files are the library renderer test and the two new webui files, whole/serial.
Beakley confirms reply-protocol.mjs exports only GUARD_CODES (the seven guard refusal codes) and
FIXED_TEXTS {empty, failed, uncited, tooLong}. It has no scope/notice constants or validator.
renderReply defines none of those codes or runner texts: it already renders the fixed bodies as free
text. It therefore needs no protocol import and no extra browser module route (beakley-0006).
Do not add unused imports or redefine those shared codes/texts later.
2026-10-23T19:21:07Z dependency update (bubba-0006): Bubba's first static row288 push is
origin/bubba/row288 c867cada9, explicitly Untested with no Node run. A2 library.revert uses the
business.projects key; broker notice retains the exact agreed body. Bubba adds librarySave only
in console-bus.mjs/cli.mjs and leaves libraryRevert wiring owned by 225. Preserve both callback
changes when Sage integrates. No dependency merge or test run performed here; 225 still lands
after 217 and may show unknown-verb until 288 lands.
## T1 targeted RED to GREEN (sage-3641, 2026-10-23T19:23Z)
All three files ran whole and serial, with TMPDIR=/mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2,
--test-concurrency=1 --test-timeout=30000 and outer timeout -k 10 (120s renderer, 120s routes,
240s browser). Initial renderer 9/0 (0.105s), routes 9/3 (0.226s), browser 5/3 (5.630s).
Final renderer 9/0 (0.105s), routes 12/0 (0.212s), browser 8/0 (5.284s). Logs in evidence/.
Production fixes after RED:
- Reject a library.revert receipt with the wrong project as outcome-unknown; no retry.
- Read replyLimits.fallback and display replyLimits.problem per beakley-0004/0006.
- Show library-mapping-invalid in the status line; its hidden-status branch is covered by the
browser refusal matrix once the nested-limits assertion passes. This diagnosis was made statically;
the first RED exits that matrix at the preceding fallback assertion, so visibility itself has only
a GREEN run, unlike the fallback/problem and wrong-project fixes.
Three assertion corrections in NEW handoff files (not pre-existing tests, per sage-3647):
1. library-routes.test.mjs: POST /api/library/status in preview mode expects 403, because the existing
global preview write fence runs before the GET-only route and returns read-only. The route remains
GET-only and no callback fires on that POST. Do not weaken or bypass the fence to get 405.
2. library-routes.test.mjs: project-not-declared expects 400, which busStatus already maps at the
row base. The inherited 502 expectation contradicted that existing map. No production map change.
3. chat-replies-browser.test.mjs: coerce the whole “img element OR window.injected” expression to a
Boolean. With no injected node/code the old expression returns undefined, not false. Assertions
still require neither DOM injection nor executed handler. All preceding cite/link assertions pass.
Frozen pre-existing test deltas still render.test.mjs 80/66 and channel-proof.test.mjs 3/1.
No Node/Chromium process with this slot TMPDIR remained after targeted tests (checked /proc).
Expanded CLEAR sage-3647: full library, then channel-proof whole, then isolated mutants that fit;
still hard stop 19:38Z. No full webui suite, npm ci or editor build authorized in this window.
Expanded-slot evidence: full library 81/0 (9.179s), channel-proof 2/0 (0.573s), whole/serial,
--test-timeout=30000, outer timeout -k 10 180, same TMPDIR. Logs copied to evidence/.
To exercise invalid-mapping visibility independently, added its own browser case and temporarily
restored only the hidden-mapping branch. Whole browser RED: 7/2 (8.810s); the direct assertion sees
null instead of the diagnostic, and the refusal-matrix wait also fails. Finally restored the exact
corrected source before the next run. This supersedes the earlier visibility “GREEN only” note.
Final targeted coverage before mutation: browser 9/0 (6014.970ms runner duration), routes 13/0.
Added an independent send-off test with a configured libraryRevert callback: absence of --send
still refuses with zero callback calls. This makes the send fence independently mutation-testable.
The kit now has 14 concrete mutations, including agent-label, unreadable-limits problem and send.
Each isolated target is restored to its candidate SHA256, and worktree hashes are checked before
continuing to the next mutant (sage-3647). Runner uses TAP so assertion failures can be distinguished
from timeouts/import/process failures. Full webui/build and the integration gate remain pending.
Dependency update beakley-0008: row217 first push origin/beakley/row217 c25f5b8da is UNTESTED,
continued from Launchpad e25ef0373. No dependency merge here; its protocol/module mount belongs to
217 and renderReply needs none of those codes/texts. Integration acceptance remains Sage's gate.
First isolated-mutant attempt stopped honestly at M3-wrong-commit: M1 badge, M1 agent label and M2
https-only link mutants were killed by assertions; M3 failed via the browser's expected-state wait,
so the strict runner did NOT count it as a kill. Every target restored/hash-checked and candidate
hashes unchanged. Logs retained in evidence/mutants-attempt1/. Corrected the new browser test to wait
for a completed repaint (independent of desired output), then assert changed-marker count. Whole
browser baseline remains 9/0. No production change. Re-run the whole isolated kit against this test.
## T1 complete: isolated mutants and source-review handoff
sage-3647 final kit on source candidate 507901277: 14/14 assertion kills, no timeout/import/process
failure counted. Each mutant matched one anchor, restored the target SHA256 and checked the three
candidate source hashes before the next. evidence/mutants-final/summary.json records every result;
final worktreeUnchanged=true. Logs name the failing tests, including M3's direct assertion after the
refresh wait correction. /proc inspection found no remaining Node/Chromium process with the slot
TMPDIR. Returned lane A to Sage as fethry-0016 at about19:29Z, before the19:38Z hard stop.
Checked-in TAP display copies remove trailing whitespace so the diff is clean. Their raw SHA256
values are recorded in evidence/raw-log-provenance.json; original byte-for-byte logs remain under
/mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2. This changes evidence presentation only, never tests.
MANIFEST.md and manifest.r1.sha256 freeze the13 changed package files for Dewey. All three handoff
deviations and Mos1510's two preservation conditions are explicit. Old-test numstats remain80/66 and3/1.
No full webui/npm ci/build was run; Sage explicitly reserved it for a later window. Full package/script
and dependency integration gate remains required before landing. No queue move, merge or live action.
+67
View File
@@ -0,0 +1,67 @@
# Row 225 review packet — C2-10b (#1726)
Owner Fethry (14f80e3c), reviewer Dewey (84fb346c). Base 45dca69f6.
Source candidate 507901277 continues Morgana 66f7becf2 and 9a131e956; no rewritten history.
Status: targeted tests and full library green; full webui/build and Sage's full gate still pending.
This packet requests source/manifest review, not landing or a full-green claim.
## Behavior and contracts
The Console shows reply text and citations with path/line/quote, scope and agent-written labels,
marks a changed file only with valid differing check.file/check.last, links only https URLs, shows
write/delete notices and explicit Revert results, and displays library status, fetch age, divergence,
fetch errors, nested replyLimits metadata and R-problems. Invalid library mappings remain visible.
Revert validates {project,commit}, preserves refusal codes and reports uncertain/wrong-project
receipts as outcome-unknown without retry. The project is chat project.repo (business.projects key).
A1-A5 are in BUILD.md. Dependency contracts: row217 ordered check.citations and nested replyLimits;
row288 A2 revert result/notice. No dependency merge in this candidate. Landing order is 217 then225;
unknown-verb is expected until288 lands. Beakley's GUARD_CODES/FIXED_TEXTS need no renderer import:
renderReply defines neither and renders those fixed bodies as free text.
## Frozen implementation and tests
manifest.r1.sha256 freezes the 13 changed package paths, including the two approved existing-test
changes. Check from repository root with `sha256sum -c agents/fethry/work/queue-225/manifest.r1.sha256`.
All other changed files are BUILD/session records, handoff/approval evidence and this review packet.
No role, policy, schema, secret, live instance, deployment or queue change is included.
Three departures from the brief, carried from Morgana's handoff:
1. Chat rendering/status live in public/chat.js and chat.css, where the message list is, instead of
bus.js and its stylesheet (sage-3596). serve.mjs serves the same pure render.mjs module to owner
and granted-user pages; no duplicated browser renderer and no generated asset.
2. console-bus.mjs and cli.mjs wire libraryRevert beside libraryStatus (sage-3596). --send still
independently gates Revert. Preserve row288's separate librarySave wiring at integration.
3. webui/src/bus.mjs maps library-revert-not-agent to409; bus/tests/channel-proof.test.mjs re-pins
only that source's hash and documents why. Mos read and approved this exact +3/-1 (mos-1510).
Existing-test authority: Mos1510 via Sage3623 approves render.test.mjs +80/-66 and channel-proof +3/-1.
Both exact numstats are unchanged. The malformed-body fixed-line rule is superseded by design5.2's
whole-body free text, covered by two replacement renderer tests. The old no-options path-badge rule
is retained and covered by the replacement test plus unchecked-cite browser coverage. BUILD.md
explains these two preservation conditions and the three new-file assertion corrections in detail.
## Verification and remaining gate
Evidence logs are under evidence/; commands, deadlines, failures and corrections are in BUILD.md.
Targeted whole-file tests: renderer9/0, routes13/0, browser9/0. Full library81/0, channel-proof2/0.
No skips/cancellations. Invalid-mapping visibility also has an independent RED then GREEN.
All 14 isolated mutants were killed by assertions on source candidate507901277; each mutation
matched one anchor, restored its source hash, and verified unchanged candidate hashes before the next
mutation. Summary: evidence/mutants-final/summary.json. The first attempt stopped at an expected-state
wait (not counted as a kill), then the new browser test was improved and the whole kit rerun.
Raw logs remain under the scratch root; checked-in display copies trim trailing whitespace and record
raw digests in evidence/raw-log-provenance.json.
Before landing, Sage still runs full webui with MOSAIC_REQUIRE_EDITOR_BUILD=1 after the worktree-local
webui npm ci, every applicable package/script suite and integration against217. No full webui/build
in T1 (sage-3647). Dewey approval and Sage's gate/landing remain required; queue moves belong to Sage.
Static audit: the channel-proof source pin matches webui bus.mjs; no hash pin of bus.js, serve.mjs
or library render.mjs was found. packages' only remaining renderItems match is an explanatory comment
in the frozen replacement test; no runtime export, import or call remains. No no-cited-answer match.
Review focus: stored check metadata versus untrusted body, missing/malformed file/last, https-only
links and inert hostile text, Revert authority/receipt uncertainty, async route/sign-out isolation,
and status diagnostics without owner transport leakage to a granted user.
@@ -0,0 +1,64 @@
TAP version 13
# Subtest: chat replies: free text, cites with badges, the agent label, changed since and safe links
ok 1 - chat replies: free text, cites with badges, the agent label, changed since and safe links
---
duration_ms: 704.129155
type: 'test'
...
# Subtest: chat replies: a write notice reads as its line with a Revert; a refused revert shows its code
ok 2 - chat replies: a write notice reads as its line with a Revert; a refused revert shows its code
---
duration_ms: 690.249337
type: 'test'
...
# Subtest: chat replies: a read-only Console shows the notice with Revert disabled
ok 3 - chat replies: a read-only Console shows the notice with Revert disabled
---
duration_ms: 595.036289
type: 'test'
...
# Subtest: chat status line: served commit and age, divergence, fetch error and the problems one click away
ok 4 - chat status line: served commit and age, divergence, fetch error and the problems one click away
---
duration_ms: 627.082447
type: 'test'
...
# Subtest: chat status line: limits when the status carries them, no problems, and refusals
ok 5 - chat status line: limits when the status carries them, no problems, and refusals
---
duration_ms: 787.799722
type: 'test'
...
# Subtest: chat status line: a project with no repository link reads no library status
ok 6 - chat status line: a project with no repository link reads no library status
---
duration_ms: 587.311743
type: 'test'
...
# Subtest: chat status line: unreadable reply limits show their problem and both fallback keys
ok 7 - chat status line: unreadable reply limits show their problem and both fallback keys
---
duration_ms: 618.209773
type: 'test'
...
# Subtest: chat replies: unchanged file at a different library commit and incomplete checks stay unmarked
ok 8 - chat replies: unchanged file at a different library commit and incomplete checks stay unmarked
---
duration_ms: 633.838649
type: 'test'
...
# Subtest: chat status line: an invalid library mapping remains visible for diagnosis
ok 9 - chat status line: an invalid library mapping remains visible for diagnosis
---
duration_ms: 619.313248
type: 'test'
...
1..9
# tests 9
# suites 0
# pass 9
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 6014.970062
@@ -0,0 +1,64 @@
TAP version 13
# Subtest: chat replies: free text, cites with badges, the agent label, changed since and safe links
ok 1 - chat replies: free text, cites with badges, the agent label, changed since and safe links
---
duration_ms: 636.096036
type: 'test'
...
# Subtest: chat replies: a write notice reads as its line with a Revert; a refused revert shows its code
ok 2 - chat replies: a write notice reads as its line with a Revert; a refused revert shows its code
---
duration_ms: 741.239029
type: 'test'
...
# Subtest: chat replies: a read-only Console shows the notice with Revert disabled
ok 3 - chat replies: a read-only Console shows the notice with Revert disabled
---
duration_ms: 568.879998
type: 'test'
...
# Subtest: chat status line: served commit and age, divergence, fetch error and the problems one click away
ok 4 - chat status line: served commit and age, divergence, fetch error and the problems one click away
---
duration_ms: 641.929005
type: 'test'
...
# Subtest: chat status line: limits when the status carries them, no problems, and refusals
ok 5 - chat status line: limits when the status carries them, no problems, and refusals
---
duration_ms: 870.041985
type: 'test'
...
# Subtest: chat status line: a project with no repository link reads no library status
ok 6 - chat status line: a project with no repository link reads no library status
---
duration_ms: 648.87451
type: 'test'
...
# Subtest: chat status line: unreadable reply limits show their problem and both fallback keys
ok 7 - chat status line: unreadable reply limits show their problem and both fallback keys
---
duration_ms: 566.725897
type: 'test'
...
# Subtest: chat replies: unchanged file at a different library commit and incomplete checks stay unmarked
ok 8 - chat replies: unchanged file at a different library commit and incomplete checks stay unmarked
---
duration_ms: 611.150846
type: 'test'
...
# Subtest: chat status line: an invalid library mapping remains visible for diagnosis
ok 9 - chat status line: an invalid library mapping remains visible for diagnosis
---
duration_ms: 618.488891
type: 'test'
...
1..9
# tests 9
# suites 0
# pass 9
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 6014.364529
@@ -0,0 +1,16 @@
✔ chat replies: free text, cites with badges, the agent label, changed since and safe links (583.234335ms)
✔ chat replies: a write notice reads as its line with a Revert; a refused revert shows its code (634.71741ms)
✔ chat replies: a read-only Console shows the notice with Revert disabled (574.836149ms)
✔ chat status line: served commit and age, divergence, fetch error and the problems one click away (607.74749ms)
✔ chat status line: limits when the status carries them, no problems, and refusals (861.034669ms)
✔ chat status line: a project with no repository link reads no library status (611.433469ms)
✔ chat status line: unreadable reply limits show their problem and both fallback keys (606.380675ms)
✔ chat replies: unchanged file at a different library commit and incomplete checks stay unmarked (650.947719ms)
ℹ tests 8
ℹ suites 0
ℹ pass 8
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 5251.735356
@@ -0,0 +1,78 @@
✖ chat replies: free text, cites with badges, the agent label, changed since and safe links (980.914799ms)
✔ chat replies: a write notice reads as its line with a Revert; a refused revert shows its code (753.988771ms)
✔ chat replies: a read-only Console shows the notice with Revert disabled (672.814814ms)
✔ chat status line: served commit and age, divergence, fetch error and the problems one click away (622.859977ms)
✖ chat status line: limits when the status carries them, no problems, and refusals (583.956229ms)
✔ chat status line: a project with no repository link reads no library status (635.758222ms)
✖ chat status line: unreadable reply limits show their problem and both fallback keys (581.636121ms)
✔ chat replies: unchanged file at a different library commit and incomplete checks stay unmarked (653.788881ms)
ℹ tests 8
ℹ suites 0
ℹ pass 5
ℹ fail 3
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 5597.371605
✖ failing tests:
test at packages/webui/tests/chat-replies-browser.test.mjs:81:1
✖ chat replies: free text, cites with badges, the agent label, changed since and safe links (980.914799ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
+ actual - expected
+ undefined
- false
at TestContext.<anonymous> (file:///home/jwoltje/.t3/worktrees/mosaic-stack/fethry-row225/packages/webui/tests/chat-replies-browser.test.mjs:102:10)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Test.run (node:internal/test_runner/test:1409:7)
at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: undefined,
expected: false,
operator: 'strictEqual',
diff: 'simple'
}
test at packages/webui/tests/chat-replies-browser.test.mjs:152:1
✖ chat status line: limits when the status carries them, no problems, and refusals (583.956229ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
+ actual - expected
+ 'Library at ccccccc · fetched 5 min ago · citations required, up to 4 per reply · no problems'
- 'Library at ccccccc · fetched 5 min ago · citations required, up to 4 per reply · limits fell back: citationsPerReply · no problems'
at TestContext.<anonymous> (file:///home/jwoltje/.t3/worktrees/mosaic-stack/fethry-row225/packages/webui/tests/chat-replies-browser.test.mjs:156:10)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: 'Library at ccccccc · fetched 5 min ago · citations required, up to 4 per reply · no problems',
expected: 'Library at ccccccc · fetched 5 min ago · citations required, up to 4 per reply · limits fell back: citationsPerReply · no problems',
operator: 'strictEqual',
diff: 'simple'
}
test at packages/webui/tests/chat-replies-browser.test.mjs:173:1
✖ chat status line: unreadable reply limits show their problem and both fallback keys (581.636121ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
+ actual - expected
+ 'Library at ccccccc · fetched 5 min ago · citations optional, up to 8 per reply · no problems'
- 'Library at ccccccc · fetched 5 min ago · citations optional, up to 8 per reply · limits fell back: requireCitation, citationsPerReply · reply-limits-unreadable · no problems'
at TestContext.<anonymous> (file:///home/jwoltje/.t3/worktrees/mosaic-stack/fethry-row225/packages/webui/tests/chat-replies-browser.test.mjs:179:10)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: 'Library at ccccccc · fetched 5 min ago · citations optional, up to 8 per reply · no problems',
expected: 'Library at ccccccc · fetched 5 min ago · citations optional, up to 8 per reply · limits fell back: requireCitation, citationsPerReply · reply-limits-unreadable · no problems',
operator: 'strictEqual',
diff: 'simple'
}
@@ -0,0 +1,22 @@
TAP version 13
# Subtest: channel conversion preserves the four proof/transport byte baselines and modules cannot import a proof caller
ok 1 - channel conversion preserves the four proof/transport byte baselines and modules cannot import a proof caller
---
duration_ms: 4.280459
type: 'test'
...
# Subtest: channel recovery still requires the same shim path and rejects an absent or forged proof
ok 2 - channel recovery still requires the same shim path and rejects an absent or forged proof
---
duration_ms: 413.032186
type: 'test'
...
1..2
# tests 2
# suites 0
# pass 2
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 539.602759
@@ -0,0 +1,496 @@
TAP version 13
# Subtest: resolver step 1: a path from the repo root, with or without .md
ok 1 - resolver step 1: a path from the repo root, with or without .md
---
duration_ms: 1.051208
type: 'test'
...
# Subtest: resolver step 2: the same path with vaultPrefix/ stripped
ok 2 - resolver step 2: the same path with vaultPrefix/ stripped
---
duration_ms: 0.164795
type: 'test'
...
# Subtest: resolver step 3: a path relative to the linking file, never above the repo root
ok 3 - resolver step 3: a path relative to the linking file, never above the repo root
---
duration_ms: 0.192765
type: 'test'
...
# Subtest: resolver step 4: a base name that matches exactly one indexed file
ok 4 - resolver step 4: a base name that matches exactly one indexed file
---
duration_ms: 0.201611
type: 'test'
...
# Subtest: a link into another of her repos resolves to nothing
ok 5 - a link into another of her repos resolves to nothing
---
duration_ms: 0.130282
type: 'test'
...
# Subtest: wikilink forms, embeds, and links skipped inside code
ok 6 - wikilink forms, embeds, and links skipped inside code
---
duration_ms: 1.445011
type: 'test'
...
# Subtest: only a body embed whose target has an extension other than .md, in any case, is an attachment
ok 7 - only a body embed whose target has an extension other than .md, in any case, is an attachment
---
duration_ms: 0.25003
type: 'test'
...
# Subtest: the fixture mapping checks and comes back frozen
ok 8 - the fixture mapping checks and comes back frozen
---
duration_ms: 1.772928
type: 'test'
...
# Subtest: C2-01c: a prefix inside or holding another entry's book refuses library-playground-other-universe
ok 9 - C2-01c: a prefix inside or holding another entry's book refuses library-playground-other-universe
---
duration_ms: 0.735312
type: 'test'
...
# Subtest: C2-01c: a prefix equal to a book folder, or holding its own, refuses library-mapping-invalid
ok 10 - C2-01c: a prefix equal to a book folder, or holding its own, refuses library-mapping-invalid
---
duration_ms: 0.532498
type: 'test'
...
# Subtest: C2-01c: universe-level prefixes lie outside every book, in a plain universe and in a hybrid with no books
ok 11 - C2-01c: universe-level prefixes lie outside every book, in a plain universe and in a hybrid with no books
---
duration_ms: 0.668173
type: 'test'
...
# Subtest: C2-01c: a draft takes kind, name and playground only, and comes back frozen with books []
ok 12 - C2-01c: a draft takes kind, name and playground only, and comes back frozen with books []
---
duration_ms: 1.032507
type: 'test'
...
# Subtest: no playground prefix holds another, and no book folder holds another
ok 13 - no playground prefix holds another, and no book folder holds another
---
duration_ms: 0.507943
type: 'test'
...
# Subtest: shape refusals: kind, remote, branch, unknown keys, types, manuscript, folder names
ok 14 - shape refusals: kind, remote, branch, unknown keys, types, manuscript, folder names
---
duration_ms: 0.740368
type: 'test'
...
# Subtest: C2-03a sage-2021: user is optional, kept when given, and must be a plain account name
ok 15 - C2-03a sage-2021: user is optional, kept when given, and must be a plain account name
---
duration_ms: 0.659277
type: 'test'
...
# Subtest: a hybrid universe is a shape the query code can take (1.4), with sources that must exist
ok 16 - a hybrid universe is a shape the query code can take (1.4), with sources that must exist
---
duration_ms: 0.695117
type: 'test'
...
# Subtest: a caller-supplied fail receives the code, as business file loading will pass it (C2-04)
ok 17 - a caller-supplied fail receives the code, as business file loading will pass it (C2-04)
---
duration_ms: 0.392362
type: 'test'
...
# Subtest: scope comes from the path alone (design 1.2)
ok 18 - scope comes from the path alone (design 1.2)
---
duration_ms: 0.466406
type: 'test'
...
# Subtest: C2-04 mirror: exact default path, bare without checkout or remote, and idempotent initialization
ok 19 - C2-04 mirror: exact default path, bare without checkout or remote, and idempotent initialization
---
duration_ms: 46.645994
type: 'test'
...
# Subtest: C2-04 mirror: simultaneous creators publish one intact mirror and leave no staging paths
ok 20 - C2-04 mirror: simultaneous creators publish one intact mirror and leave no staging paths
---
duration_ms: 32.763959
type: 'test'
...
# Subtest: C2-04 mirror: skipped publication accepts both coreutils statuses without removing the winner
ok 21 - C2-04 mirror: skipped publication accepts both coreutils statuses without removing the winner
---
duration_ms: 109.751094
type: 'test'
...
# Subtest: C2-04 mirror: preserve occupied directories and reject symlink ancestors and code overlaps
ok 22 - C2-04 mirror: preserve occupied directories and reject symlink ancestors and code overlaps
---
duration_ms: 20.129293
type: 'test'
...
# Subtest: C2-04 mirror: existing metadata accepts only bare Git plus state and the precreated lock
ok 23 - C2-04 mirror: existing metadata accepts only bare Git plus state and the precreated lock
---
duration_ms: 24.021555
type: 'test'
...
# Subtest: C2-04 state: missing state is empty only while the served ref is absent
ok 24 - C2-04 state: missing state is empty only while the served ref is absent
---
duration_ms: 37.436349
type: 'test'
...
# Subtest: C2-04 state: valid problems and sync metadata return no content and change no state bytes
ok 25 - C2-04 state: valid problems and sync metadata return no content and change no state bytes
---
duration_ms: 37.681315
type: 'test'
...
# Subtest: C2-04 state: strict keys, types, times, branch, commit and fixed R messages fail closed
ok 26 - C2-04 state: strict keys, types, times, branch, commit and fixed R messages fail closed
---
duration_ms: 105.483341
type: 'test'
...
# Subtest: C2-04 state: symlink, hardlink, non-private state and missing lock refuse without replacement
ok 27 - C2-04 state: symlink, hardlink, non-private state and missing lock refuse without replacement
---
duration_ms: 29.562786
type: 'test'
...
# Subtest: C2-04 file: served commit, not HEAD or a caller commit, owns the exact returned text
ok 28 - C2-04 file: served commit, not HEAD or a caller commit, owns the exact returned text
---
duration_ms: 74.057232
type: 'test'
...
# Subtest: C2-04 file: R1, R2 and R7 exclusions and never-synced mirrors cannot return file bodies
ok 29 - C2-04 file: R1, R2 and R7 exclusions and never-synced mirrors cannot return file bodies
---
duration_ms: 80.573844
type: 'test'
...
# Subtest: C2-04 lock: a first-sync and fast-forward reader wait for a consistent pair across processes
ok 30 - C2-04 lock: a first-sync and fast-forward reader wait for a consistent pair across processes
---
duration_ms: 253.247948
type: 'test'
...
# Subtest: C2-04 lock: same-process waits, timeout code, callback expiry and throw release
ok 31 - C2-04 lock: same-process waits, timeout code, callback expiry and throw release
---
duration_ms: 5031.462795
type: 'test'
...
# Subtest: C2-04 lock: owner death releases the kernel lock without replacing its file
ok 32 - C2-04 lock: owner death releases the kernel lock without replacing its file
---
duration_ms: 61.533295
type: 'test'
...
# Subtest: C2-04 lock: a missing flock binary becomes a handled refusal
ok 33 - C2-04 lock: a missing flock binary becomes a handled refusal
---
duration_ms: 57.176644
type: 'test'
...
# Subtest: C2-04 lock: an unawaited scoped read drains before the lock is released
ok 34 - C2-04 lock: an unawaited scoped read drains before the lock is released
---
duration_ms: 22.766874
type: 'test'
...
# Subtest: the fixture reads clean: every resolver step, appears_in and relationships wikilinks indexed
ok 35 - the fixture reads clean: every resolver step, appears_in and relationships wikilinks indexed
---
duration_ms: 33.888061
type: 'test'
...
# Subtest: R1: executables, symlinks, gitlinks, blobs over 1 MiB and non-UTF-8 text are not indexed
ok 36 - R1: executables, symlinks, gitlinks, blobs over 1 MiB and non-UTF-8 text are not indexed
---
duration_ms: 68.951498
type: 'test'
...
# Subtest: R2: unmapped paths are reported and not indexed
ok 37 - R2: unmapped paths are reported and not indexed
---
duration_ms: 25.70771
type: 'test'
...
# Subtest: R3: front matter not fully parsed stays raw, with the line where parsing stopped
ok 38 - R3: front matter not fully parsed stays raw, with the line where parsing stopped
---
duration_ms: 26.059427
type: 'test'
...
# Subtest: front matter forms the reader accepts and refuses
ok 39 - front matter forms the reader accepts and refuses
---
duration_ms: 0.448293
type: 'test'
...
# Subtest: R4: a link to no file, to more than one, or into another repo is reported and not followed
ok 40 - R4: a link to no file, to more than one, or into another repo is reported and not followed
---
duration_ms: 26.225692
type: 'test'
...
# Subtest: a block list link carries its item line, a scalar or flow link its key line, and problems follow
ok 41 - a block list link carries its item line, a scalar or flow link its key line, and problems follow
---
duration_ms: 22.565103
type: 'test'
...
# Subtest: an unresolved attachment embed gives no problem; a link or front matter value to one is R4; a note embed resolves
ok 42 - an unresolved attachment embed gives no problem; a link or front matter value to one is R4; a note embed resolves
---
duration_ms: 20.903777
type: 'test'
...
# Subtest: an indexed Dr.Quinn.md resolves as a body link, a block list link and an embed
ok 43 - an indexed Dr.Quinn.md resolves as a body link, a block list link and an embed
---
duration_ms: 25.42747
type: 'test'
...
# Subtest: R5: a canon file that refers into the playground is reported, and the link still goes by path
ok 44 - R5: a canon file that refers into the playground is reported, and the link still goes by path
---
duration_ms: 27.37119
type: 'test'
...
# Subtest: R6: a canon file that refers into another universe is reported; a hybrid may refer into its sources
ok 45 - R6: a canon file that refers into another universe is reported; a hybrid may refer into its sources
---
duration_ms: 30.585458
type: 'test'
...
# Subtest: R7: two paths that differ only in case are both left out
ok 46 - R7: two paths that differ only in case are both left out
---
duration_ms: 40.709509
type: 'test'
...
# Subtest: the reader reads the commit, never the working tree, and refuses a bad commit id
ok 47 - the reader reads the commit, never the working tree, and refuses a bad commit id
---
duration_ms: 33.579454
type: 'test'
...
# Subtest: a bare mirror reads the same as the clone
ok 48 - a bare mirror reads the same as the clone
---
duration_ms: 38.368686
type: 'test'
...
# Subtest: free text renders as itself with no citations
ok 49 - free text renders as itself with no citations
---
duration_ms: 1.571777
type: 'test'
...
# Subtest: a cited reply renders its text, then each cite with the badge from the check result
ok 50 - a cited reply renders its text, then each cite with the badge from the check result
---
duration_ms: 0.537429
type: 'test'
...
# Subtest: a parsed body renders as its string does
ok 51 - a parsed body renders as its string does
---
duration_ms: 0.304068
type: 'test'
...
# Subtest: without a check result a path cite has no badge, no label and is never changed
ok 52 - without a check result a path cite has no badge, no label and is never changed
---
duration_ms: 0.184363
type: 'test'
...
# Subtest: the label and the badge come from the check result only, never from a bad entry
ok 53 - the label and the badge come from the check result only, never from a bad entry
---
duration_ms: 0.180581
type: 'test'
...
# Subtest: a web cite is a link only for https
ok 54 - a web cite is a link only for https
---
duration_ms: 0.293669
type: 'test'
...
# Subtest: a write notice renders its fixed line
ok 55 - a write notice renders its fixed line
---
duration_ms: 0.277197
type: 'test'
...
# Subtest: a body without the reply or notice shape is free text: the whole body, no citations
ok 56 - a body without the reply or notice shape is free text: the whole body, no citations
---
duration_ms: 0.345807
type: 'test'
...
# Subtest: an empty citations list is a reply with no cites
ok 57 - an empty citations list is a reply with no cites
---
duration_ms: 0.187354
type: 'test'
...
# Subtest: C2-01c: each level gives its universe, and its book or null
ok 58 - C2-01c: each level gives its universe, and its book or null
---
duration_ms: 3.363132
type: 'test'
...
# Subtest: C2-01c: prefixes come first: in a book that holds a prefix, a file outside it is canon and one under it playground
ok 59 - C2-01c: prefixes come first: in a book that holds a prefix, a file outside it is canon and one under it playground
---
duration_ms: 0.480483
type: 'test'
...
# Subtest: C2-01c: a canon file linking into a draft gives R6, and into its own universe-level prefix gives R5
ok 60 - C2-01c: a canon file linking into a draft gives R6, and into its own universe-level prefix gives R5
---
duration_ms: 44.606565
type: 'test'
...
# Subtest: the survey prints folder names, counts, key sets and link-form counts, and never text
ok 61 - the survey prints folder names, counts, key sets and link-form counts, and never text
---
duration_ms: 37.28555
type: 'test'
...
# Subtest: folder names print DEL, C1 and bidi controls, U+061C among them, as escapes
ok 62 - folder names print DEL, C1 and bidi controls, U+061C among them, as escapes
---
duration_ms: 26.222951
type: 'test'
...
# Subtest: an embed of an attachment counts as an embed and as an attachment; a plain link to one does not
ok 63 - an embed of an attachment counts as an embed and as an attachment; a plain link to one does not
---
duration_ms: 26.23793
type: 'test'
...
# Subtest: with --mapping the survey adds scope, resolver step and problem counts
ok 64 - with --mapping the survey adds scope, resolver step and problem counts
---
duration_ms: 41.437066
type: 'test'
...
# Subtest: survey exit codes: usage 4, bad mapping 3, unreadable repo 2
ok 65 - survey exit codes: usage 4, bad mapping 3, unreadable repo 2
---
duration_ms: 17.463059
type: 'test'
...
# Subtest: scripts/mosaic library survey reaches the survey
ok 66 - scripts/mosaic library survey reaches the survey
---
duration_ms: 77.634947
type: 'test'
...
# Subtest: C2-03a argv: the fixed sequence, one https allow, no other protocol key
ok 67 - C2-03a argv: the fixed sequence, one https allow, no other protocol key
---
duration_ms: 1.605597
type: 'test'
...
# Subtest: C2-03a argv: only an https URL with a plain host, no userinfo, and a plain user
ok 68 - C2-03a argv: only an https URL with a plain host, no userinfo, and a plain user
---
duration_ms: 0.713147
type: 'test'
...
# Subtest: C2-03a helper: answers only the exact prompts for the configured host and user (dewey-0765)
ok 69 - C2-03a helper: answers only the exact prompts for the configured host and user (dewey-0765)
---
duration_ms: 31.590474
type: 'test'
...
# Subtest: C2-03a first sync: served is the fetched tip, HEAD is served, the survey reads it, and the token stays in the slot
ok 70 - C2-03a first sync: served is the fetched tip, HEAD is served, the survey reads it, and the token stays in the slot
---
duration_ms: 135.075159
type: 'test'
...
# Subtest: C2-03a fast-forward: served moves, problems are rebuilt, and a repeat changes only lastFetch
ok 71 - C2-03a fast-forward: served moves, problems are rebuilt, and a repeat changes only lastFetch
---
duration_ms: 225.707972
type: 'test'
...
# Subtest: C2-03a Filbert N2: a read that took served before a sync still finds its objects after served moves
ok 72 - C2-03a Filbert N2: a read that took served before a sync still finds its objects after served moves
---
duration_ms: 205.10431
type: 'test'
...
# Subtest: C2-03a overlap: a reader during a sync sees the old pair or the new one, never a mix
ok 73 - C2-03a overlap: a reader during a sync sees the old pair or the new one, never a mix
---
duration_ms: 175.907929
type: 'test'
...
# Subtest: C2-03a diverged: a non-fast-forward keeps serving and holds until a human accepts the tip
ok 74 - C2-03a diverged: a non-fast-forward keeps serving and holds until a human accepts the tip
---
duration_ms: 431.553812
type: 'test'
...
# Subtest: C2-03a accept starts the record over, so a changed branch gets a fresh state
ok 75 - C2-03a accept starts the record over, so a changed branch gets a fresh state
---
duration_ms: 195.011432
type: 'test'
...
# Subtest: C2-03a fetch failure: a wrong token keeps serving and records the error, lastFetch unchanged
ok 76 - C2-03a fetch failure: a wrong token keeps serving and records the error, lastFetch unchanged
---
duration_ms: 192.898644
type: 'test'
...
# Subtest: C2-03a a failed first fetch records the error with nothing served
ok 77 - C2-03a a failed first fetch records the error with nothing served
---
duration_ms: 61.594695
type: 'test'
...
# Subtest: C2-03a a 302 to another host fails the fetch and the other host sees nothing
ok 78 - C2-03a a 302 to another host fails the fetch and the other host sees nothing
---
duration_ms: 64.82991
type: 'test'
...
# Subtest: C2-03a the slot: missing or unsafe refuses before git, and sync never opens it
ok 79 - C2-03a the slot: missing or unsafe refuses before git, and sync never opens it
---
duration_ms: 59.167725
type: 'test'
...
# Subtest: C2-03a sage-2021: an entry with no user refuses library-remote-invalid and runs no git
ok 80 - C2-03a sage-2021: an entry with no user refuses library-remote-invalid and runs no git
---
duration_ms: 35.37205
type: 'test'
...
# Subtest: C2-03a one sync per mirror: an overlapping call refuses library-sync-busy
ok 81 - C2-03a one sync per mirror: an overlapping call refuses library-sync-busy
---
duration_ms: 118.260687
type: 'test'
...
1..81
# tests 81
# suites 0
# pass 81
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 9149.325891
@@ -0,0 +1,48 @@
✔ chat replies: free text, cites with badges, the agent label, changed since and safe links (633.807074ms)
✔ chat replies: a write notice reads as its line with a Revert; a refused revert shows its code (621.95095ms)
✔ chat replies: a read-only Console shows the notice with Revert disabled (577.638161ms)
✔ chat status line: served commit and age, divergence, fetch error and the problems one click away (555.847899ms)
✖ chat status line: limits when the status carries them, no problems, and refusals (3725.696272ms)
✔ chat status line: a project with no repository link reads no library status (657.572856ms)
✔ chat status line: unreadable reply limits show their problem and both fallback keys (663.163445ms)
✔ chat replies: unchanged file at a different library commit and incomplete checks stay unmarked (616.184063ms)
✖ chat status line: an invalid library mapping remains visible for diagnosis (597.976623ms)
ℹ tests 9
ℹ suites 0
ℹ pass 7
ℹ fail 2
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 8782.679569
✖ failing tests:
test at packages/webui/tests/chat-replies-browser.test.mjs:152:1
✖ chat status line: limits when the status carries them, no problems, and refusals (3725.696272ms)
Error: Uncaught (in promise) Error: window.__statusReads.length && document.querySelector(".chat-conversation") && document.querySelector(".chat-library")?.textContent==="Library status unavailable (library-mapping-invalid).": undefined
at Object.evaluate (file:///home/jwoltje/.t3/worktrees/mosaic-stack/fethry-row225/packages/webui/tests/browser.mjs:49:35)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async TestContext.<anonymous> (file:///home/jwoltje/.t3/worktrees/mosaic-stack/fethry-row225/packages/webui/tests/chat-replies-browser.test.mjs:162:5)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7)
test at packages/webui/tests/chat-replies-browser.test.mjs:200:1
✖ chat status line: an invalid library mapping remains visible for diagnosis (597.976623ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
+ actual - expected
+ null
- 'Library status unavailable (library-mapping-invalid).'
at TestContext.<anonymous> (file:///home/jwoltje/.t3/worktrees/mosaic-stack/fethry-row225/packages/webui/tests/chat-replies-browser.test.mjs:203:10)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: null,
expected: 'Library status unavailable (library-mapping-invalid).',
operator: 'strictEqual',
diff: 'simple'
}
@@ -0,0 +1,4 @@
{"mutant": "M1-badges", "anchorHits": 1, "restored": true, "worktreeUnchanged": true, "exit": 1, "killed": true, "test": "packages/webui/tests/chat-replies-browser.test.mjs", "log": "/mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mutants/M1-badges.tap"}
{"mutant": "M1-agent-label", "anchorHits": 1, "restored": true, "worktreeUnchanged": true, "exit": 1, "killed": true, "test": "packages/webui/tests/chat-replies-browser.test.mjs", "log": "/mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mutants/M1-agent-label.tap"}
{"mutant": "M2-http-links", "anchorHits": 1, "restored": true, "worktreeUnchanged": true, "exit": 1, "killed": true, "test": "packages/webui/tests/chat-replies-browser.test.mjs", "log": "/mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mutants/M2-http-links.tap"}
{"mutant": "M3-wrong-commit", "anchorHits": 1, "restored": true, "worktreeUnchanged": true, "exit": 1, "killed": false, "test": "packages/webui/tests/chat-replies-browser.test.mjs", "log": "/mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mutants/M3-wrong-commit.tap"}
@@ -0,0 +1,192 @@
TAP version 13
# Subtest: chat replies: free text, cites with badges, the agent label, changed since and safe links
not ok 1 - chat replies: free text, cites with badges, the agent label, changed since and safe links
---
duration_ms: 627.180675
type: 'test'
location: '/mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mosaic-row225-mutants-9ui_vkk0/M1-agent-label/packages/webui/tests/chat-replies-browser.test.mjs:81:1'
failureType: 'testCodeFailure'
error: |-
Expected values to be strictly deep-equal:
+ actual - expected
... Skipped lines
[
{
agent: null,
badge: 'canon',
changed: false,
...
{
+ agent: null,
- agent: 'agent-written: acme/scribe',
badge: 'supporting',
changed: false,
href: null,
quote: 'green eyes',
ref: 'Notes/Eyes.md:9-10 @aaaaaaa',
code: 'ERR_ASSERTION'
name: 'AssertionError'
expected:
0:
badge: 'canon'
ref: 'Book One/Chapters/Chapter 03.md:41 @aaaaaaa'
href: ~
rel: ~
quote: 'her grey eyes'
agent: ~
changed: false
1:
badge: 'playground'
ref: 'Book One/Playground/What If.md:5-6 @aaaaaaa'
href: ~
rel: ~
quote: 'a storm'
agent: ~
changed: true
2:
badge: 'supporting'
ref: 'Notes/Eyes.md:9-10 @aaaaaaa'
href: ~
rel: ~
quote: 'green eyes'
agent: 'agent-written: acme/scribe'
changed: false
3:
badge: 'web'
ref: 'https://example.invalid/tides'
href: 'https://example.invalid/tides'
rel: 'noopener noreferrer'
quote: 'spring tides'
agent: ~
changed: false
4:
badge: 'web'
ref: 'http://example.invalid/plain'
href: ~
rel: ~
quote: 'plain http'
agent: ~
changed: false
5:
badge: 'web'
ref: 'javascript:window.injected=2'
href: ~
rel: ~
quote: 'script'
agent: ~
changed: false
actual:
0:
badge: 'canon'
ref: 'Book One/Chapters/Chapter 03.md:41 @aaaaaaa'
href: ~
rel: ~
quote: 'her grey eyes'
agent: ~
changed: false
1:
badge: 'playground'
ref: 'Book One/Playground/What If.md:5-6 @aaaaaaa'
href: ~
rel: ~
quote: 'a storm'
agent: ~
changed: true
2:
badge: 'supporting'
ref: 'Notes/Eyes.md:9-10 @aaaaaaa'
href: ~
rel: ~
quote: 'green eyes'
agent: ~
changed: false
3:
badge: 'web'
ref: 'https://example.invalid/tides'
href: 'https://example.invalid/tides'
rel: 'noopener noreferrer'
quote: 'spring tides'
agent: ~
changed: false
4:
badge: 'web'
ref: 'http://example.invalid/plain'
href: ~
rel: ~
quote: 'plain http'
agent: ~
changed: false
5:
badge: 'web'
ref: 'javascript:window.injected=2'
href: ~
rel: ~
quote: 'script'
agent: ~
changed: false
operator: 'deepStrictEqual'
stack: |-
TestContext.<anonymous> (file:///mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mosaic-row225-mutants-9ui_vkk0/M1-agent-label/packages/webui/tests/chat-replies-browser.test.mjs:90:10)
process.processTicksAndRejections (node:internal/process/task_queues:104:5)
async Test.run (node:internal/test_runner/test:1409:7)
async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3)
...
# Subtest: chat replies: a write notice reads as its line with a Revert; a refused revert shows its code
ok 2 - chat replies: a write notice reads as its line with a Revert; a refused revert shows its code
---
duration_ms: 778.572201
type: 'test'
...
# Subtest: chat replies: a read-only Console shows the notice with Revert disabled
ok 3 - chat replies: a read-only Console shows the notice with Revert disabled
---
duration_ms: 641.728604
type: 'test'
...
# Subtest: chat status line: served commit and age, divergence, fetch error and the problems one click away
ok 4 - chat status line: served commit and age, divergence, fetch error and the problems one click away
---
duration_ms: 629.327348
type: 'test'
...
# Subtest: chat status line: limits when the status carries them, no problems, and refusals
ok 5 - chat status line: limits when the status carries them, no problems, and refusals
---
duration_ms: 837.886073
type: 'test'
...
# Subtest: chat status line: a project with no repository link reads no library status
ok 6 - chat status line: a project with no repository link reads no library status
---
duration_ms: 613.037186
type: 'test'
...
# Subtest: chat status line: unreadable reply limits show their problem and both fallback keys
ok 7 - chat status line: unreadable reply limits show their problem and both fallback keys
---
duration_ms: 651.026507
type: 'test'
...
# Subtest: chat replies: unchanged file at a different library commit and incomplete checks stay unmarked
ok 8 - chat replies: unchanged file at a different library commit and incomplete checks stay unmarked
---
duration_ms: 735.546636
type: 'test'
...
# Subtest: chat status line: an invalid library mapping remains visible for diagnosis
ok 9 - chat status line: an invalid library mapping remains visible for diagnosis
---
duration_ms: 712.174542
type: 'test'
...
1..9
# tests 9
# suites 0
# pass 8
# fail 1
# cancelled 0
# skipped 0
# todo 0
# duration_ms 6353.090574
@@ -0,0 +1,206 @@
TAP version 13
# Subtest: chat replies: free text, cites with badges, the agent label, changed since and safe links
not ok 1 - chat replies: free text, cites with badges, the agent label, changed since and safe links
---
duration_ms: 638.589537
type: 'test'
location: '/mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mosaic-row225-mutants-9ui_vkk0/M1-badges/packages/webui/tests/chat-replies-browser.test.mjs:81:1'
failureType: 'testCodeFailure'
error: |-
Expected values to be strictly deep-equal:
+ actual - expected
... Skipped lines
[
{
agent: null,
+ badge: null,
- badge: 'canon',
changed: false,
href: null,
quote: 'her grey eyes',
ref: 'Book One/Chapters/Chapter 03.md:41 @aaaaaaa',
rel: null
...
agent: null,
+ badge: null,
- badge: 'playground',
changed: true,
href: null,
quote: 'a storm',
ref: 'Book One/Playground/What If.md:5-6 @aaaaaaa',
rel: null
...
agent: 'agent-written: acme/scribe',
+ badge: null,
- badge: 'supporting',
changed: false,
href: null,
quote: 'green eyes',
ref: 'Notes/Eyes.md:9-10 @aaaaaaa',
rel: null
code: 'ERR_ASSERTION'
name: 'AssertionError'
expected:
0:
badge: 'canon'
ref: 'Book One/Chapters/Chapter 03.md:41 @aaaaaaa'
href: ~
rel: ~
quote: 'her grey eyes'
agent: ~
changed: false
1:
badge: 'playground'
ref: 'Book One/Playground/What If.md:5-6 @aaaaaaa'
href: ~
rel: ~
quote: 'a storm'
agent: ~
changed: true
2:
badge: 'supporting'
ref: 'Notes/Eyes.md:9-10 @aaaaaaa'
href: ~
rel: ~
quote: 'green eyes'
agent: 'agent-written: acme/scribe'
changed: false
3:
badge: 'web'
ref: 'https://example.invalid/tides'
href: 'https://example.invalid/tides'
rel: 'noopener noreferrer'
quote: 'spring tides'
agent: ~
changed: false
4:
badge: 'web'
ref: 'http://example.invalid/plain'
href: ~
rel: ~
quote: 'plain http'
agent: ~
changed: false
5:
badge: 'web'
ref: 'javascript:window.injected=2'
href: ~
rel: ~
quote: 'script'
agent: ~
changed: false
actual:
0:
badge: ~
ref: 'Book One/Chapters/Chapter 03.md:41 @aaaaaaa'
href: ~
rel: ~
quote: 'her grey eyes'
agent: ~
changed: false
1:
badge: ~
ref: 'Book One/Playground/What If.md:5-6 @aaaaaaa'
href: ~
rel: ~
quote: 'a storm'
agent: ~
changed: true
2:
badge: ~
ref: 'Notes/Eyes.md:9-10 @aaaaaaa'
href: ~
rel: ~
quote: 'green eyes'
agent: 'agent-written: acme/scribe'
changed: false
3:
badge: 'web'
ref: 'https://example.invalid/tides'
href: 'https://example.invalid/tides'
rel: 'noopener noreferrer'
quote: 'spring tides'
agent: ~
changed: false
4:
badge: 'web'
ref: 'http://example.invalid/plain'
href: ~
rel: ~
quote: 'plain http'
agent: ~
changed: false
5:
badge: 'web'
ref: 'javascript:window.injected=2'
href: ~
rel: ~
quote: 'script'
agent: ~
changed: false
operator: 'deepStrictEqual'
stack: |-
TestContext.<anonymous> (file:///mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mosaic-row225-mutants-9ui_vkk0/M1-badges/packages/webui/tests/chat-replies-browser.test.mjs:90:10)
process.processTicksAndRejections (node:internal/process/task_queues:104:5)
async Test.run (node:internal/test_runner/test:1409:7)
async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3)
...
# Subtest: chat replies: a write notice reads as its line with a Revert; a refused revert shows its code
ok 2 - chat replies: a write notice reads as its line with a Revert; a refused revert shows its code
---
duration_ms: 770.070531
type: 'test'
...
# Subtest: chat replies: a read-only Console shows the notice with Revert disabled
ok 3 - chat replies: a read-only Console shows the notice with Revert disabled
---
duration_ms: 519.328288
type: 'test'
...
# Subtest: chat status line: served commit and age, divergence, fetch error and the problems one click away
ok 4 - chat status line: served commit and age, divergence, fetch error and the problems one click away
---
duration_ms: 647.667301
type: 'test'
...
# Subtest: chat status line: limits when the status carries them, no problems, and refusals
ok 5 - chat status line: limits when the status carries them, no problems, and refusals
---
duration_ms: 999.237447
type: 'test'
...
# Subtest: chat status line: a project with no repository link reads no library status
ok 6 - chat status line: a project with no repository link reads no library status
---
duration_ms: 676.088845
type: 'test'
...
# Subtest: chat status line: unreadable reply limits show their problem and both fallback keys
ok 7 - chat status line: unreadable reply limits show their problem and both fallback keys
---
duration_ms: 634.472637
type: 'test'
...
# Subtest: chat replies: unchanged file at a different library commit and incomplete checks stay unmarked
ok 8 - chat replies: unchanged file at a different library commit and incomplete checks stay unmarked
---
duration_ms: 655.963182
type: 'test'
...
# Subtest: chat status line: an invalid library mapping remains visible for diagnosis
ok 9 - chat status line: an invalid library mapping remains visible for diagnosis
---
duration_ms: 578.819646
type: 'test'
...
1..9
# tests 9
# suites 0
# pass 8
# fail 1
# cancelled 0
# skipped 0
# todo 0
# duration_ms 6239.022812
@@ -0,0 +1,204 @@
TAP version 13
# Subtest: chat replies: free text, cites with badges, the agent label, changed since and safe links
not ok 1 - chat replies: free text, cites with badges, the agent label, changed since and safe links
---
duration_ms: 655.901619
type: 'test'
location: '/mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mosaic-row225-mutants-9ui_vkk0/M2-http-links/packages/webui/tests/chat-replies-browser.test.mjs:81:1'
failureType: 'testCodeFailure'
error: |-
Expected values to be strictly deep-equal:
+ actual - expected
... Skipped lines
[
{
agent: null,
badge: 'canon',
changed: false,
...
changed: false,
+ href: 'http://example.invalid/plain',
- href: null,
quote: 'plain http',
ref: 'http://example.invalid/plain',
+ rel: 'noopener noreferrer'
- rel: null
},
{
agent: null,
badge: 'web',
changed: false,
+ href: 'javascript:window.injected=2',
- href: null,
quote: 'script',
ref: 'javascript:window.injected=2',
+ rel: 'noopener noreferrer'
- rel: null
}
]
code: 'ERR_ASSERTION'
name: 'AssertionError'
expected:
0:
badge: 'canon'
ref: 'Book One/Chapters/Chapter 03.md:41 @aaaaaaa'
href: ~
rel: ~
quote: 'her grey eyes'
agent: ~
changed: false
1:
badge: 'playground'
ref: 'Book One/Playground/What If.md:5-6 @aaaaaaa'
href: ~
rel: ~
quote: 'a storm'
agent: ~
changed: true
2:
badge: 'supporting'
ref: 'Notes/Eyes.md:9-10 @aaaaaaa'
href: ~
rel: ~
quote: 'green eyes'
agent: 'agent-written: acme/scribe'
changed: false
3:
badge: 'web'
ref: 'https://example.invalid/tides'
href: 'https://example.invalid/tides'
rel: 'noopener noreferrer'
quote: 'spring tides'
agent: ~
changed: false
4:
badge: 'web'
ref: 'http://example.invalid/plain'
href: ~
rel: ~
quote: 'plain http'
agent: ~
changed: false
5:
badge: 'web'
ref: 'javascript:window.injected=2'
href: ~
rel: ~
quote: 'script'
agent: ~
changed: false
actual:
0:
badge: 'canon'
ref: 'Book One/Chapters/Chapter 03.md:41 @aaaaaaa'
href: ~
rel: ~
quote: 'her grey eyes'
agent: ~
changed: false
1:
badge: 'playground'
ref: 'Book One/Playground/What If.md:5-6 @aaaaaaa'
href: ~
rel: ~
quote: 'a storm'
agent: ~
changed: true
2:
badge: 'supporting'
ref: 'Notes/Eyes.md:9-10 @aaaaaaa'
href: ~
rel: ~
quote: 'green eyes'
agent: 'agent-written: acme/scribe'
changed: false
3:
badge: 'web'
ref: 'https://example.invalid/tides'
href: 'https://example.invalid/tides'
rel: 'noopener noreferrer'
quote: 'spring tides'
agent: ~
changed: false
4:
badge: 'web'
ref: 'http://example.invalid/plain'
href: 'http://example.invalid/plain'
rel: 'noopener noreferrer'
quote: 'plain http'
agent: ~
changed: false
5:
badge: 'web'
ref: 'javascript:window.injected=2'
href: 'javascript:window.injected=2'
rel: 'noopener noreferrer'
quote: 'script'
agent: ~
changed: false
operator: 'deepStrictEqual'
stack: |-
TestContext.<anonymous> (file:///mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mosaic-row225-mutants-9ui_vkk0/M2-http-links/packages/webui/tests/chat-replies-browser.test.mjs:90:10)
process.processTicksAndRejections (node:internal/process/task_queues:104:5)
async Test.run (node:internal/test_runner/test:1409:7)
async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3)
...
# Subtest: chat replies: a write notice reads as its line with a Revert; a refused revert shows its code
ok 2 - chat replies: a write notice reads as its line with a Revert; a refused revert shows its code
---
duration_ms: 741.122544
type: 'test'
...
# Subtest: chat replies: a read-only Console shows the notice with Revert disabled
ok 3 - chat replies: a read-only Console shows the notice with Revert disabled
---
duration_ms: 616.728572
type: 'test'
...
# Subtest: chat status line: served commit and age, divergence, fetch error and the problems one click away
ok 4 - chat status line: served commit and age, divergence, fetch error and the problems one click away
---
duration_ms: 625.818493
type: 'test'
...
# Subtest: chat status line: limits when the status carries them, no problems, and refusals
ok 5 - chat status line: limits when the status carries them, no problems, and refusals
---
duration_ms: 867.033773
type: 'test'
...
# Subtest: chat status line: a project with no repository link reads no library status
ok 6 - chat status line: a project with no repository link reads no library status
---
duration_ms: 655.587703
type: 'test'
...
# Subtest: chat status line: unreadable reply limits show their problem and both fallback keys
ok 7 - chat status line: unreadable reply limits show their problem and both fallback keys
---
duration_ms: 615.997516
type: 'test'
...
# Subtest: chat replies: unchanged file at a different library commit and incomplete checks stay unmarked
ok 8 - chat replies: unchanged file at a different library commit and incomplete checks stay unmarked
---
duration_ms: 648.263681
type: 'test'
...
# Subtest: chat status line: an invalid library mapping remains visible for diagnosis
ok 9 - chat status line: an invalid library mapping remains visible for diagnosis
---
duration_ms: 624.323397
type: 'test'
...
1..9
# tests 9
# suites 0
# pass 8
# fail 1
# cancelled 0
# skipped 0
# todo 0
# duration_ms 6168.633364
@@ -0,0 +1,74 @@
TAP version 13
# Subtest: chat replies: free text, cites with badges, the agent label, changed since and safe links
ok 1 - chat replies: free text, cites with badges, the agent label, changed since and safe links
---
duration_ms: 640.32042
type: 'test'
...
# Subtest: chat replies: a write notice reads as its line with a Revert; a refused revert shows its code
ok 2 - chat replies: a write notice reads as its line with a Revert; a refused revert shows its code
---
duration_ms: 694.233373
type: 'test'
...
# Subtest: chat replies: a read-only Console shows the notice with Revert disabled
ok 3 - chat replies: a read-only Console shows the notice with Revert disabled
---
duration_ms: 611.500508
type: 'test'
...
# Subtest: chat status line: served commit and age, divergence, fetch error and the problems one click away
ok 4 - chat status line: served commit and age, divergence, fetch error and the problems one click away
---
duration_ms: 606.581119
type: 'test'
...
# Subtest: chat status line: limits when the status carries them, no problems, and refusals
ok 5 - chat status line: limits when the status carries them, no problems, and refusals
---
duration_ms: 896.462474
type: 'test'
...
# Subtest: chat status line: a project with no repository link reads no library status
ok 6 - chat status line: a project with no repository link reads no library status
---
duration_ms: 666.798177
type: 'test'
...
# Subtest: chat status line: unreadable reply limits show their problem and both fallback keys
ok 7 - chat status line: unreadable reply limits show their problem and both fallback keys
---
duration_ms: 629.105559
type: 'test'
...
# Subtest: chat replies: unchanged file at a different library commit and incomplete checks stay unmarked
not ok 8 - chat replies: unchanged file at a different library commit and incomplete checks stay unmarked
---
duration_ms: 3654.057789
type: 'test'
location: '/mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mosaic-row225-mutants-9ui_vkk0/M3-wrong-commit/packages/webui/tests/chat-replies-browser.test.mjs:184:1'
failureType: 'testCodeFailure'
error: 'Uncaught (in promise) Error: document.querySelectorAll(".chat-changed").length===0: undefined'
code: 'ERR_TEST_FAILURE'
stack: |-
Object.evaluate (file:///mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mosaic-row225-mutants-9ui_vkk0/M3-wrong-commit/packages/webui/tests/browser.mjs:49:35)
process.processTicksAndRejections (node:internal/process/task_queues:104:5)
async TestContext.<anonymous> (file:///mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mosaic-row225-mutants-9ui_vkk0/M3-wrong-commit/packages/webui/tests/chat-replies-browser.test.mjs:195:3)
async Test.run (node:internal/test_runner/test:1409:7)
async Test.processPendingSubtests (node:internal/test_runner/test:974:7)
...
# Subtest: chat status line: an invalid library mapping remains visible for diagnosis
ok 9 - chat status line: an invalid library mapping remains visible for diagnosis
---
duration_ms: 656.44095
type: 'test'
...
1..9
# tests 9
# suites 0
# pass 8
# fail 1
# cancelled 0
# skipped 0
# todo 0
# duration_ms 9179.328916
@@ -0,0 +1,47 @@
{
"head": "77172ce3b46eb289409068518b7022fc7be47ebe",
"clear": "sage-3647",
"worktreeUnchanged": true,
"results": [
{
"mutant": "M1-badges",
"anchorHits": 1,
"restored": true,
"worktreeUnchanged": true,
"exit": 1,
"killed": true,
"test": "packages/webui/tests/chat-replies-browser.test.mjs",
"log": "/mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mutants/M1-badges.tap"
},
{
"mutant": "M1-agent-label",
"anchorHits": 1,
"restored": true,
"worktreeUnchanged": true,
"exit": 1,
"killed": true,
"test": "packages/webui/tests/chat-replies-browser.test.mjs",
"log": "/mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mutants/M1-agent-label.tap"
},
{
"mutant": "M2-http-links",
"anchorHits": 1,
"restored": true,
"worktreeUnchanged": true,
"exit": 1,
"killed": true,
"test": "packages/webui/tests/chat-replies-browser.test.mjs",
"log": "/mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mutants/M2-http-links.tap"
},
{
"mutant": "M3-wrong-commit",
"anchorHits": 1,
"restored": true,
"worktreeUnchanged": true,
"exit": 1,
"killed": false,
"test": "packages/webui/tests/chat-replies-browser.test.mjs",
"log": "/mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mutants/M3-wrong-commit.tap"
}
]
}
@@ -0,0 +1,14 @@
{"mutant": "M1-badges", "anchorHits": 1, "restored": true, "worktreeUnchanged": true, "exit": 1, "killed": true, "test": "packages/webui/tests/chat-replies-browser.test.mjs", "log": "/mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mutants-final/M1-badges.tap"}
{"mutant": "M1-agent-label", "anchorHits": 1, "restored": true, "worktreeUnchanged": true, "exit": 1, "killed": true, "test": "packages/webui/tests/chat-replies-browser.test.mjs", "log": "/mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mutants-final/M1-agent-label.tap"}
{"mutant": "M2-http-links", "anchorHits": 1, "restored": true, "worktreeUnchanged": true, "exit": 1, "killed": true, "test": "packages/webui/tests/chat-replies-browser.test.mjs", "log": "/mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mutants-final/M2-http-links.tap"}
{"mutant": "M3-wrong-commit", "anchorHits": 1, "restored": true, "worktreeUnchanged": true, "exit": 1, "killed": true, "test": "packages/webui/tests/chat-replies-browser.test.mjs", "log": "/mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mutants-final/M3-wrong-commit.tap"}
{"mutant": "M3-missing-file", "anchorHits": 1, "restored": true, "worktreeUnchanged": true, "exit": 1, "killed": true, "test": "packages/webui/tests/chat-replies-browser.test.mjs", "log": "/mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mutants-final/M3-missing-file.tap"}
{"mutant": "M4-fallback", "anchorHits": 1, "restored": true, "worktreeUnchanged": true, "exit": 1, "killed": true, "test": "packages/webui/tests/chat-replies-browser.test.mjs", "log": "/mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mutants-final/M4-fallback.tap"}
{"mutant": "M4-problem", "anchorHits": 1, "restored": true, "worktreeUnchanged": true, "exit": 1, "killed": true, "test": "packages/webui/tests/chat-replies-browser.test.mjs", "log": "/mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mutants-final/M4-problem.tap"}
{"mutant": "M5-send", "anchorHits": 1, "restored": true, "worktreeUnchanged": true, "exit": 1, "killed": true, "test": "packages/webui/tests/library-routes.test.mjs", "log": "/mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mutants-final/M5-send.tap"}
{"mutant": "M5-project-receipt", "anchorHits": 1, "restored": true, "worktreeUnchanged": true, "exit": 1, "killed": true, "test": "packages/webui/tests/library-routes.test.mjs", "log": "/mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mutants-final/M5-project-receipt.tap"}
{"mutant": "M5-commit-input", "anchorHits": 1, "restored": true, "worktreeUnchanged": true, "exit": 1, "killed": true, "test": "packages/webui/tests/library-routes.test.mjs", "log": "/mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mutants-final/M5-commit-input.tap"}
{"mutant": "M5-origin", "anchorHits": 1, "restored": true, "worktreeUnchanged": true, "exit": 1, "killed": true, "test": "packages/webui/tests/library-routes.test.mjs", "log": "/mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mutants-final/M5-origin.tap"}
{"mutant": "M5-fetch-metadata", "anchorHits": 1, "restored": true, "worktreeUnchanged": true, "exit": 1, "killed": true, "test": "packages/webui/tests/library-routes.test.mjs", "log": "/mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mutants-final/M5-fetch-metadata.tap"}
{"mutant": "M6-duplicate-call", "anchorHits": 1, "restored": true, "worktreeUnchanged": true, "exit": 1, "killed": true, "test": "packages/webui/tests/library-routes.test.mjs", "log": "/mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mutants-final/M6-duplicate-call.tap"}
{"mutant": "M7-user-fence", "anchorHits": 1, "restored": true, "worktreeUnchanged": true, "exit": 1, "killed": true, "test": "packages/webui/tests/library-routes.test.mjs", "log": "/mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mutants-final/M7-user-fence.tap"}
@@ -0,0 +1,192 @@
TAP version 13
# Subtest: chat replies: free text, cites with badges, the agent label, changed since and safe links
not ok 1 - chat replies: free text, cites with badges, the agent label, changed since and safe links
---
duration_ms: 646.851404
type: 'test'
location: '/mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mosaic-row225-mutants-e6t705gk/M1-agent-label/packages/webui/tests/chat-replies-browser.test.mjs:81:1'
failureType: 'testCodeFailure'
error: |-
Expected values to be strictly deep-equal:
+ actual - expected
... Skipped lines
[
{
agent: null,
badge: 'canon',
changed: false,
...
{
+ agent: null,
- agent: 'agent-written: acme/scribe',
badge: 'supporting',
changed: false,
href: null,
quote: 'green eyes',
ref: 'Notes/Eyes.md:9-10 @aaaaaaa',
code: 'ERR_ASSERTION'
name: 'AssertionError'
expected:
0:
badge: 'canon'
ref: 'Book One/Chapters/Chapter 03.md:41 @aaaaaaa'
href: ~
rel: ~
quote: 'her grey eyes'
agent: ~
changed: false
1:
badge: 'playground'
ref: 'Book One/Playground/What If.md:5-6 @aaaaaaa'
href: ~
rel: ~
quote: 'a storm'
agent: ~
changed: true
2:
badge: 'supporting'
ref: 'Notes/Eyes.md:9-10 @aaaaaaa'
href: ~
rel: ~
quote: 'green eyes'
agent: 'agent-written: acme/scribe'
changed: false
3:
badge: 'web'
ref: 'https://example.invalid/tides'
href: 'https://example.invalid/tides'
rel: 'noopener noreferrer'
quote: 'spring tides'
agent: ~
changed: false
4:
badge: 'web'
ref: 'http://example.invalid/plain'
href: ~
rel: ~
quote: 'plain http'
agent: ~
changed: false
5:
badge: 'web'
ref: 'javascript:window.injected=2'
href: ~
rel: ~
quote: 'script'
agent: ~
changed: false
actual:
0:
badge: 'canon'
ref: 'Book One/Chapters/Chapter 03.md:41 @aaaaaaa'
href: ~
rel: ~
quote: 'her grey eyes'
agent: ~
changed: false
1:
badge: 'playground'
ref: 'Book One/Playground/What If.md:5-6 @aaaaaaa'
href: ~
rel: ~
quote: 'a storm'
agent: ~
changed: true
2:
badge: 'supporting'
ref: 'Notes/Eyes.md:9-10 @aaaaaaa'
href: ~
rel: ~
quote: 'green eyes'
agent: ~
changed: false
3:
badge: 'web'
ref: 'https://example.invalid/tides'
href: 'https://example.invalid/tides'
rel: 'noopener noreferrer'
quote: 'spring tides'
agent: ~
changed: false
4:
badge: 'web'
ref: 'http://example.invalid/plain'
href: ~
rel: ~
quote: 'plain http'
agent: ~
changed: false
5:
badge: 'web'
ref: 'javascript:window.injected=2'
href: ~
rel: ~
quote: 'script'
agent: ~
changed: false
operator: 'deepStrictEqual'
stack: |-
TestContext.<anonymous> (file:///mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mosaic-row225-mutants-e6t705gk/M1-agent-label/packages/webui/tests/chat-replies-browser.test.mjs:90:10)
process.processTicksAndRejections (node:internal/process/task_queues:104:5)
async Test.run (node:internal/test_runner/test:1409:7)
async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3)
...
# Subtest: chat replies: a write notice reads as its line with a Revert; a refused revert shows its code
ok 2 - chat replies: a write notice reads as its line with a Revert; a refused revert shows its code
---
duration_ms: 667.90945
type: 'test'
...
# Subtest: chat replies: a read-only Console shows the notice with Revert disabled
ok 3 - chat replies: a read-only Console shows the notice with Revert disabled
---
duration_ms: 588.346681
type: 'test'
...
# Subtest: chat status line: served commit and age, divergence, fetch error and the problems one click away
ok 4 - chat status line: served commit and age, divergence, fetch error and the problems one click away
---
duration_ms: 485.882289
type: 'test'
...
# Subtest: chat status line: limits when the status carries them, no problems, and refusals
ok 5 - chat status line: limits when the status carries them, no problems, and refusals
---
duration_ms: 709.847041
type: 'test'
...
# Subtest: chat status line: a project with no repository link reads no library status
ok 6 - chat status line: a project with no repository link reads no library status
---
duration_ms: 544.885551
type: 'test'
...
# Subtest: chat status line: unreadable reply limits show their problem and both fallback keys
ok 7 - chat status line: unreadable reply limits show their problem and both fallback keys
---
duration_ms: 514.410016
type: 'test'
...
# Subtest: chat replies: unchanged file at a different library commit and incomplete checks stay unmarked
ok 8 - chat replies: unchanged file at a different library commit and incomplete checks stay unmarked
---
duration_ms: 542.746154
type: 'test'
...
# Subtest: chat status line: an invalid library mapping remains visible for diagnosis
ok 9 - chat status line: an invalid library mapping remains visible for diagnosis
---
duration_ms: 582.608663
type: 'test'
...
1..9
# tests 9
# suites 0
# pass 8
# fail 1
# cancelled 0
# skipped 0
# todo 0
# duration_ms 5409.980177
@@ -0,0 +1,206 @@
TAP version 13
# Subtest: chat replies: free text, cites with badges, the agent label, changed since and safe links
not ok 1 - chat replies: free text, cites with badges, the agent label, changed since and safe links
---
duration_ms: 621.562133
type: 'test'
location: '/mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mosaic-row225-mutants-e6t705gk/M1-badges/packages/webui/tests/chat-replies-browser.test.mjs:81:1'
failureType: 'testCodeFailure'
error: |-
Expected values to be strictly deep-equal:
+ actual - expected
... Skipped lines
[
{
agent: null,
+ badge: null,
- badge: 'canon',
changed: false,
href: null,
quote: 'her grey eyes',
ref: 'Book One/Chapters/Chapter 03.md:41 @aaaaaaa',
rel: null
...
agent: null,
+ badge: null,
- badge: 'playground',
changed: true,
href: null,
quote: 'a storm',
ref: 'Book One/Playground/What If.md:5-6 @aaaaaaa',
rel: null
...
agent: 'agent-written: acme/scribe',
+ badge: null,
- badge: 'supporting',
changed: false,
href: null,
quote: 'green eyes',
ref: 'Notes/Eyes.md:9-10 @aaaaaaa',
rel: null
code: 'ERR_ASSERTION'
name: 'AssertionError'
expected:
0:
badge: 'canon'
ref: 'Book One/Chapters/Chapter 03.md:41 @aaaaaaa'
href: ~
rel: ~
quote: 'her grey eyes'
agent: ~
changed: false
1:
badge: 'playground'
ref: 'Book One/Playground/What If.md:5-6 @aaaaaaa'
href: ~
rel: ~
quote: 'a storm'
agent: ~
changed: true
2:
badge: 'supporting'
ref: 'Notes/Eyes.md:9-10 @aaaaaaa'
href: ~
rel: ~
quote: 'green eyes'
agent: 'agent-written: acme/scribe'
changed: false
3:
badge: 'web'
ref: 'https://example.invalid/tides'
href: 'https://example.invalid/tides'
rel: 'noopener noreferrer'
quote: 'spring tides'
agent: ~
changed: false
4:
badge: 'web'
ref: 'http://example.invalid/plain'
href: ~
rel: ~
quote: 'plain http'
agent: ~
changed: false
5:
badge: 'web'
ref: 'javascript:window.injected=2'
href: ~
rel: ~
quote: 'script'
agent: ~
changed: false
actual:
0:
badge: ~
ref: 'Book One/Chapters/Chapter 03.md:41 @aaaaaaa'
href: ~
rel: ~
quote: 'her grey eyes'
agent: ~
changed: false
1:
badge: ~
ref: 'Book One/Playground/What If.md:5-6 @aaaaaaa'
href: ~
rel: ~
quote: 'a storm'
agent: ~
changed: true
2:
badge: ~
ref: 'Notes/Eyes.md:9-10 @aaaaaaa'
href: ~
rel: ~
quote: 'green eyes'
agent: 'agent-written: acme/scribe'
changed: false
3:
badge: 'web'
ref: 'https://example.invalid/tides'
href: 'https://example.invalid/tides'
rel: 'noopener noreferrer'
quote: 'spring tides'
agent: ~
changed: false
4:
badge: 'web'
ref: 'http://example.invalid/plain'
href: ~
rel: ~
quote: 'plain http'
agent: ~
changed: false
5:
badge: 'web'
ref: 'javascript:window.injected=2'
href: ~
rel: ~
quote: 'script'
agent: ~
changed: false
operator: 'deepStrictEqual'
stack: |-
TestContext.<anonymous> (file:///mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mosaic-row225-mutants-e6t705gk/M1-badges/packages/webui/tests/chat-replies-browser.test.mjs:90:10)
process.processTicksAndRejections (node:internal/process/task_queues:104:5)
async Test.run (node:internal/test_runner/test:1409:7)
async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3)
...
# Subtest: chat replies: a write notice reads as its line with a Revert; a refused revert shows its code
ok 2 - chat replies: a write notice reads as its line with a Revert; a refused revert shows its code
---
duration_ms: 731.101767
type: 'test'
...
# Subtest: chat replies: a read-only Console shows the notice with Revert disabled
ok 3 - chat replies: a read-only Console shows the notice with Revert disabled
---
duration_ms: 633.163696
type: 'test'
...
# Subtest: chat status line: served commit and age, divergence, fetch error and the problems one click away
ok 4 - chat status line: served commit and age, divergence, fetch error and the problems one click away
---
duration_ms: 637.761161
type: 'test'
...
# Subtest: chat status line: limits when the status carries them, no problems, and refusals
ok 5 - chat status line: limits when the status carries them, no problems, and refusals
---
duration_ms: 847.929653
type: 'test'
...
# Subtest: chat status line: a project with no repository link reads no library status
ok 6 - chat status line: a project with no repository link reads no library status
---
duration_ms: 635.512979
type: 'test'
...
# Subtest: chat status line: unreadable reply limits show their problem and both fallback keys
ok 7 - chat status line: unreadable reply limits show their problem and both fallback keys
---
duration_ms: 645.427688
type: 'test'
...
# Subtest: chat replies: unchanged file at a different library commit and incomplete checks stay unmarked
ok 8 - chat replies: unchanged file at a different library commit and incomplete checks stay unmarked
---
duration_ms: 672.272209
type: 'test'
...
# Subtest: chat status line: an invalid library mapping remains visible for diagnosis
ok 9 - chat status line: an invalid library mapping remains visible for diagnosis
---
duration_ms: 629.043251
type: 'test'
...
1..9
# tests 9
# suites 0
# pass 8
# fail 1
# cancelled 0
# skipped 0
# todo 0
# duration_ms 6169.954716
@@ -0,0 +1,204 @@
TAP version 13
# Subtest: chat replies: free text, cites with badges, the agent label, changed since and safe links
not ok 1 - chat replies: free text, cites with badges, the agent label, changed since and safe links
---
duration_ms: 582.248796
type: 'test'
location: '/mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mosaic-row225-mutants-e6t705gk/M2-http-links/packages/webui/tests/chat-replies-browser.test.mjs:81:1'
failureType: 'testCodeFailure'
error: |-
Expected values to be strictly deep-equal:
+ actual - expected
... Skipped lines
[
{
agent: null,
badge: 'canon',
changed: false,
...
changed: false,
+ href: 'http://example.invalid/plain',
- href: null,
quote: 'plain http',
ref: 'http://example.invalid/plain',
+ rel: 'noopener noreferrer'
- rel: null
},
{
agent: null,
badge: 'web',
changed: false,
+ href: 'javascript:window.injected=2',
- href: null,
quote: 'script',
ref: 'javascript:window.injected=2',
+ rel: 'noopener noreferrer'
- rel: null
}
]
code: 'ERR_ASSERTION'
name: 'AssertionError'
expected:
0:
badge: 'canon'
ref: 'Book One/Chapters/Chapter 03.md:41 @aaaaaaa'
href: ~
rel: ~
quote: 'her grey eyes'
agent: ~
changed: false
1:
badge: 'playground'
ref: 'Book One/Playground/What If.md:5-6 @aaaaaaa'
href: ~
rel: ~
quote: 'a storm'
agent: ~
changed: true
2:
badge: 'supporting'
ref: 'Notes/Eyes.md:9-10 @aaaaaaa'
href: ~
rel: ~
quote: 'green eyes'
agent: 'agent-written: acme/scribe'
changed: false
3:
badge: 'web'
ref: 'https://example.invalid/tides'
href: 'https://example.invalid/tides'
rel: 'noopener noreferrer'
quote: 'spring tides'
agent: ~
changed: false
4:
badge: 'web'
ref: 'http://example.invalid/plain'
href: ~
rel: ~
quote: 'plain http'
agent: ~
changed: false
5:
badge: 'web'
ref: 'javascript:window.injected=2'
href: ~
rel: ~
quote: 'script'
agent: ~
changed: false
actual:
0:
badge: 'canon'
ref: 'Book One/Chapters/Chapter 03.md:41 @aaaaaaa'
href: ~
rel: ~
quote: 'her grey eyes'
agent: ~
changed: false
1:
badge: 'playground'
ref: 'Book One/Playground/What If.md:5-6 @aaaaaaa'
href: ~
rel: ~
quote: 'a storm'
agent: ~
changed: true
2:
badge: 'supporting'
ref: 'Notes/Eyes.md:9-10 @aaaaaaa'
href: ~
rel: ~
quote: 'green eyes'
agent: 'agent-written: acme/scribe'
changed: false
3:
badge: 'web'
ref: 'https://example.invalid/tides'
href: 'https://example.invalid/tides'
rel: 'noopener noreferrer'
quote: 'spring tides'
agent: ~
changed: false
4:
badge: 'web'
ref: 'http://example.invalid/plain'
href: 'http://example.invalid/plain'
rel: 'noopener noreferrer'
quote: 'plain http'
agent: ~
changed: false
5:
badge: 'web'
ref: 'javascript:window.injected=2'
href: 'javascript:window.injected=2'
rel: 'noopener noreferrer'
quote: 'script'
agent: ~
changed: false
operator: 'deepStrictEqual'
stack: |-
TestContext.<anonymous> (file:///mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mosaic-row225-mutants-e6t705gk/M2-http-links/packages/webui/tests/chat-replies-browser.test.mjs:90:10)
process.processTicksAndRejections (node:internal/process/task_queues:104:5)
async Test.run (node:internal/test_runner/test:1409:7)
async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3)
...
# Subtest: chat replies: a write notice reads as its line with a Revert; a refused revert shows its code
ok 2 - chat replies: a write notice reads as its line with a Revert; a refused revert shows its code
---
duration_ms: 640.769563
type: 'test'
...
# Subtest: chat replies: a read-only Console shows the notice with Revert disabled
ok 3 - chat replies: a read-only Console shows the notice with Revert disabled
---
duration_ms: 572.506418
type: 'test'
...
# Subtest: chat status line: served commit and age, divergence, fetch error and the problems one click away
ok 4 - chat status line: served commit and age, divergence, fetch error and the problems one click away
---
duration_ms: 519.031091
type: 'test'
...
# Subtest: chat status line: limits when the status carries them, no problems, and refusals
ok 5 - chat status line: limits when the status carries them, no problems, and refusals
---
duration_ms: 743.513043
type: 'test'
...
# Subtest: chat status line: a project with no repository link reads no library status
ok 6 - chat status line: a project with no repository link reads no library status
---
duration_ms: 676.282756
type: 'test'
...
# Subtest: chat status line: unreadable reply limits show their problem and both fallback keys
ok 7 - chat status line: unreadable reply limits show their problem and both fallback keys
---
duration_ms: 595.166769
type: 'test'
...
# Subtest: chat replies: unchanged file at a different library commit and incomplete checks stay unmarked
ok 8 - chat replies: unchanged file at a different library commit and incomplete checks stay unmarked
---
duration_ms: 626.475981
type: 'test'
...
# Subtest: chat status line: an invalid library mapping remains visible for diagnosis
ok 9 - chat status line: an invalid library mapping remains visible for diagnosis
---
duration_ms: 596.828198
type: 'test'
...
1..9
# tests 9
# suites 0
# pass 8
# fail 1
# cancelled 0
# skipped 0
# todo 0
# duration_ms 5658.889854
@@ -0,0 +1,81 @@
TAP version 13
# Subtest: chat replies: free text, cites with badges, the agent label, changed since and safe links
ok 1 - chat replies: free text, cites with badges, the agent label, changed since and safe links
---
duration_ms: 639.027823
type: 'test'
...
# Subtest: chat replies: a write notice reads as its line with a Revert; a refused revert shows its code
ok 2 - chat replies: a write notice reads as its line with a Revert; a refused revert shows its code
---
duration_ms: 693.772757
type: 'test'
...
# Subtest: chat replies: a read-only Console shows the notice with Revert disabled
ok 3 - chat replies: a read-only Console shows the notice with Revert disabled
---
duration_ms: 683.094789
type: 'test'
...
# Subtest: chat status line: served commit and age, divergence, fetch error and the problems one click away
ok 4 - chat status line: served commit and age, divergence, fetch error and the problems one click away
---
duration_ms: 637.029355
type: 'test'
...
# Subtest: chat status line: limits when the status carries them, no problems, and refusals
ok 5 - chat status line: limits when the status carries them, no problems, and refusals
---
duration_ms: 772.100011
type: 'test'
...
# Subtest: chat status line: a project with no repository link reads no library status
ok 6 - chat status line: a project with no repository link reads no library status
---
duration_ms: 599.342035
type: 'test'
...
# Subtest: chat status line: unreadable reply limits show their problem and both fallback keys
ok 7 - chat status line: unreadable reply limits show their problem and both fallback keys
---
duration_ms: 574.320042
type: 'test'
...
# Subtest: chat replies: unchanged file at a different library commit and incomplete checks stay unmarked
not ok 8 - chat replies: unchanged file at a different library commit and incomplete checks stay unmarked
---
duration_ms: 621.466015
type: 'test'
location: '/mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mosaic-row225-mutants-e6t705gk/M3-missing-file/packages/webui/tests/chat-replies-browser.test.mjs:184:1'
failureType: 'testCodeFailure'
error: |-
Expected values to be strictly equal:
1 !== 0
code: 'ERR_ASSERTION'
name: 'AssertionError'
expected: 0
actual: 1
operator: 'strictEqual'
stack: |-
TestContext.<anonymous> (file:///mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mosaic-row225-mutants-e6t705gk/M3-missing-file/packages/webui/tests/chat-replies-browser.test.mjs:196:10)
process.processTicksAndRejections (node:internal/process/task_queues:104:5)
async Test.run (node:internal/test_runner/test:1409:7)
async Test.processPendingSubtests (node:internal/test_runner/test:974:7)
...
# Subtest: chat status line: an invalid library mapping remains visible for diagnosis
ok 9 - chat status line: an invalid library mapping remains visible for diagnosis
---
duration_ms: 590.165121
type: 'test'
...
1..9
# tests 9
# suites 0
# pass 8
# fail 1
# cancelled 0
# skipped 0
# todo 0
# duration_ms 5921.651346
@@ -0,0 +1,81 @@
TAP version 13
# Subtest: chat replies: free text, cites with badges, the agent label, changed since and safe links
ok 1 - chat replies: free text, cites with badges, the agent label, changed since and safe links
---
duration_ms: 704.141588
type: 'test'
...
# Subtest: chat replies: a write notice reads as its line with a Revert; a refused revert shows its code
ok 2 - chat replies: a write notice reads as its line with a Revert; a refused revert shows its code
---
duration_ms: 816.229452
type: 'test'
...
# Subtest: chat replies: a read-only Console shows the notice with Revert disabled
ok 3 - chat replies: a read-only Console shows the notice with Revert disabled
---
duration_ms: 697.826745
type: 'test'
...
# Subtest: chat status line: served commit and age, divergence, fetch error and the problems one click away
ok 4 - chat status line: served commit and age, divergence, fetch error and the problems one click away
---
duration_ms: 638.579753
type: 'test'
...
# Subtest: chat status line: limits when the status carries them, no problems, and refusals
ok 5 - chat status line: limits when the status carries them, no problems, and refusals
---
duration_ms: 850.914117
type: 'test'
...
# Subtest: chat status line: a project with no repository link reads no library status
ok 6 - chat status line: a project with no repository link reads no library status
---
duration_ms: 634.004327
type: 'test'
...
# Subtest: chat status line: unreadable reply limits show their problem and both fallback keys
ok 7 - chat status line: unreadable reply limits show their problem and both fallback keys
---
duration_ms: 585.469632
type: 'test'
...
# Subtest: chat replies: unchanged file at a different library commit and incomplete checks stay unmarked
not ok 8 - chat replies: unchanged file at a different library commit and incomplete checks stay unmarked
---
duration_ms: 673.386968
type: 'test'
location: '/mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mosaic-row225-mutants-e6t705gk/M3-wrong-commit/packages/webui/tests/chat-replies-browser.test.mjs:184:1'
failureType: 'testCodeFailure'
error: |-
Expected values to be strictly equal:
1 !== 0
code: 'ERR_ASSERTION'
name: 'AssertionError'
expected: 0
actual: 1
operator: 'strictEqual'
stack: |-
TestContext.<anonymous> (file:///mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mosaic-row225-mutants-e6t705gk/M3-wrong-commit/packages/webui/tests/chat-replies-browser.test.mjs:196:10)
process.processTicksAndRejections (node:internal/process/task_queues:104:5)
async Test.run (node:internal/test_runner/test:1409:7)
async Test.processPendingSubtests (node:internal/test_runner/test:974:7)
...
# Subtest: chat status line: an invalid library mapping remains visible for diagnosis
ok 9 - chat status line: an invalid library mapping remains visible for diagnosis
---
duration_ms: 579.53998
type: 'test'
...
1..9
# tests 9
# suites 0
# pass 8
# fail 1
# cancelled 0
# skipped 0
# todo 0
# duration_ms 6292.579267
@@ -0,0 +1,102 @@
TAP version 13
# Subtest: chat replies: free text, cites with badges, the agent label, changed since and safe links
ok 1 - chat replies: free text, cites with badges, the agent label, changed since and safe links
---
duration_ms: 683.334532
type: 'test'
...
# Subtest: chat replies: a write notice reads as its line with a Revert; a refused revert shows its code
ok 2 - chat replies: a write notice reads as its line with a Revert; a refused revert shows its code
---
duration_ms: 686.685624
type: 'test'
...
# Subtest: chat replies: a read-only Console shows the notice with Revert disabled
ok 3 - chat replies: a read-only Console shows the notice with Revert disabled
---
duration_ms: 612.757087
type: 'test'
...
# Subtest: chat status line: served commit and age, divergence, fetch error and the problems one click away
ok 4 - chat status line: served commit and age, divergence, fetch error and the problems one click away
---
duration_ms: 584.651483
type: 'test'
...
# Subtest: chat status line: limits when the status carries them, no problems, and refusals
not ok 5 - chat status line: limits when the status carries them, no problems, and refusals
---
duration_ms: 637.405759
type: 'test'
location: '/mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mosaic-row225-mutants-e6t705gk/M4-fallback/packages/webui/tests/chat-replies-browser.test.mjs:152:1'
failureType: 'testCodeFailure'
error: |-
Expected values to be strictly equal:
+ actual - expected
+ 'Library at ccccccc · fetched 5 min ago · citations required, up to 4 per reply · no problems'
- 'Library at ccccccc · fetched 5 min ago · citations required, up to 4 per reply · limits fell back: citationsPerReply · no problems'
code: 'ERR_ASSERTION'
name: 'AssertionError'
expected: 'Library at ccccccc · fetched 5 min ago · citations required, up to 4 per reply · limits fell back: citationsPerReply · no problems'
actual: 'Library at ccccccc · fetched 5 min ago · citations required, up to 4 per reply · no problems'
operator: 'strictEqual'
stack: |-
TestContext.<anonymous> (file:///mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mosaic-row225-mutants-e6t705gk/M4-fallback/packages/webui/tests/chat-replies-browser.test.mjs:156:10)
process.processTicksAndRejections (node:internal/process/task_queues:104:5)
async Test.run (node:internal/test_runner/test:1409:7)
async Test.processPendingSubtests (node:internal/test_runner/test:974:7)
...
# Subtest: chat status line: a project with no repository link reads no library status
ok 6 - chat status line: a project with no repository link reads no library status
---
duration_ms: 626.790802
type: 'test'
...
# Subtest: chat status line: unreadable reply limits show their problem and both fallback keys
not ok 7 - chat status line: unreadable reply limits show their problem and both fallback keys
---
duration_ms: 643.680732
type: 'test'
location: '/mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mosaic-row225-mutants-e6t705gk/M4-fallback/packages/webui/tests/chat-replies-browser.test.mjs:173:1'
failureType: 'testCodeFailure'
error: |-
Expected values to be strictly equal:
+ actual - expected
+ 'Library at ccccccc · fetched 5 min ago · citations optional, up to 8 per reply · reply-limits-unreadable · no problems'
- 'Library at ccccccc · fetched 5 min ago · citations optional, up to 8 per reply · limits fell back: requireCitation, citationsPerReply · reply-limits-unreadable · no problems'
code: 'ERR_ASSERTION'
name: 'AssertionError'
expected: 'Library at ccccccc · fetched 5 min ago · citations optional, up to 8 per reply · limits fell back: requireCitation, citationsPerReply · reply-limits-unreadable · no problems'
actual: 'Library at ccccccc · fetched 5 min ago · citations optional, up to 8 per reply · reply-limits-unreadable · no problems'
operator: 'strictEqual'
stack: |-
TestContext.<anonymous> (file:///mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mosaic-row225-mutants-e6t705gk/M4-fallback/packages/webui/tests/chat-replies-browser.test.mjs:179:10)
process.processTicksAndRejections (node:internal/process/task_queues:104:5)
async Test.run (node:internal/test_runner/test:1409:7)
async Test.processPendingSubtests (node:internal/test_runner/test:974:7)
...
# Subtest: chat replies: unchanged file at a different library commit and incomplete checks stay unmarked
ok 8 - chat replies: unchanged file at a different library commit and incomplete checks stay unmarked
---
duration_ms: 645.032205
type: 'test'
...
# Subtest: chat status line: an invalid library mapping remains visible for diagnosis
ok 9 - chat status line: an invalid library mapping remains visible for diagnosis
---
duration_ms: 616.242947
type: 'test'
...
1..9
# tests 9
# suites 0
# pass 7
# fail 2
# cancelled 0
# skipped 0
# todo 0
# duration_ms 5845.226677
@@ -0,0 +1,83 @@
TAP version 13
# Subtest: chat replies: free text, cites with badges, the agent label, changed since and safe links
ok 1 - chat replies: free text, cites with badges, the agent label, changed since and safe links
---
duration_ms: 663.005514
type: 'test'
...
# Subtest: chat replies: a write notice reads as its line with a Revert; a refused revert shows its code
ok 2 - chat replies: a write notice reads as its line with a Revert; a refused revert shows its code
---
duration_ms: 662.360161
type: 'test'
...
# Subtest: chat replies: a read-only Console shows the notice with Revert disabled
ok 3 - chat replies: a read-only Console shows the notice with Revert disabled
---
duration_ms: 620.68694
type: 'test'
...
# Subtest: chat status line: served commit and age, divergence, fetch error and the problems one click away
ok 4 - chat status line: served commit and age, divergence, fetch error and the problems one click away
---
duration_ms: 744.825035
type: 'test'
...
# Subtest: chat status line: limits when the status carries them, no problems, and refusals
ok 5 - chat status line: limits when the status carries them, no problems, and refusals
---
duration_ms: 826.418848
type: 'test'
...
# Subtest: chat status line: a project with no repository link reads no library status
ok 6 - chat status line: a project with no repository link reads no library status
---
duration_ms: 549.365993
type: 'test'
...
# Subtest: chat status line: unreadable reply limits show their problem and both fallback keys
not ok 7 - chat status line: unreadable reply limits show their problem and both fallback keys
---
duration_ms: 589.776762
type: 'test'
location: '/mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mosaic-row225-mutants-e6t705gk/M4-problem/packages/webui/tests/chat-replies-browser.test.mjs:173:1'
failureType: 'testCodeFailure'
error: |-
Expected values to be strictly equal:
+ actual - expected
+ 'Library at ccccccc · fetched 5 min ago · citations optional, up to 8 per reply · limits fell back: requireCitation, citationsPerReply · no problems'
- 'Library at ccccccc · fetched 5 min ago · citations optional, up to 8 per reply · limits fell back: requireCitation, citationsPerReply · reply-limits-unreadable · no problems'
code: 'ERR_ASSERTION'
name: 'AssertionError'
expected: 'Library at ccccccc · fetched 5 min ago · citations optional, up to 8 per reply · limits fell back: requireCitation, citationsPerReply · reply-limits-unreadable · no problems'
actual: 'Library at ccccccc · fetched 5 min ago · citations optional, up to 8 per reply · limits fell back: requireCitation, citationsPerReply · no problems'
operator: 'strictEqual'
stack: |-
TestContext.<anonymous> (file:///mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mosaic-row225-mutants-e6t705gk/M4-problem/packages/webui/tests/chat-replies-browser.test.mjs:179:10)
process.processTicksAndRejections (node:internal/process/task_queues:104:5)
async Test.run (node:internal/test_runner/test:1409:7)
async Test.processPendingSubtests (node:internal/test_runner/test:974:7)
...
# Subtest: chat replies: unchanged file at a different library commit and incomplete checks stay unmarked
ok 8 - chat replies: unchanged file at a different library commit and incomplete checks stay unmarked
---
duration_ms: 590.919434
type: 'test'
...
# Subtest: chat status line: an invalid library mapping remains visible for diagnosis
ok 9 - chat status line: an invalid library mapping remains visible for diagnosis
---
duration_ms: 545.326381
type: 'test'
...
1..9
# tests 9
# suites 0
# pass 8
# fail 1
# cancelled 0
# skipped 0
# todo 0
# duration_ms 5914.325706
@@ -0,0 +1,105 @@
TAP version 13
# Subtest: a signed-in same-origin revert reaches exactly one library.revert and returns the new commit
ok 1 - a signed-in same-origin revert reaches exactly one library.revert and returns the new commit
---
duration_ms: 16.128237
type: 'test'
...
# Subtest: a revert needs send, the named Host, the user, the exact Origin and same-origin Fetch Metadata
ok 2 - a revert needs send, the named Host, the user, the exact Origin and same-origin Fetch Metadata
---
duration_ms: 5.987607
type: 'test'
...
# Subtest: a revert takes a project and a 40-hex commit, nothing else, before the bus is asked
not ok 3 - a revert takes a project and a 40-hex commit, nothing else, before the bus is asked
---
duration_ms: 4.805637
type: 'test'
location: '/mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mosaic-row225-mutants-e6t705gk/M5-commit-input/packages/webui/tests/library-routes.test.mjs:64:1'
failureType: 'testCodeFailure'
error: |-
{"project":"books","commit":"abc"}
200 !== 400
code: 'ERR_ASSERTION'
name: 'AssertionError'
expected: 400
actual: 200
operator: 'strictEqual'
stack: |-
TestContext.<anonymous> (file:///mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mosaic-row225-mutants-e6t705gk/M5-commit-input/packages/webui/tests/library-routes.test.mjs:69:12)
process.processTicksAndRejections (node:internal/process/task_queues:104:5)
async Test.run (node:internal/test_runner/test:1409:7)
async Test.processPendingSubtests (node:internal/test_runner/test:974:7)
...
# Subtest: a bus refusal is reported as not reverted with its code; before C2-02 that is the verb refusal
ok 4 - a bus refusal is reported as not reverted with its code; before C2-02 that is the verb refusal
---
duration_ms: 6.383811
type: 'test'
...
# Subtest: a revert whose answer is lost or unusable is reported unknown and is never retried
ok 5 - a revert whose answer is lost or unusable is reported unknown and is never retried
---
duration_ms: 4.000988
type: 'test'
...
# Subtest: the status read takes one project query and answers the bus result for that project
ok 6 - the status read takes one project query and answers the bus result for that project
---
duration_ms: 2.487122
type: 'test'
...
# Subtest: a status answer for another project or of the wrong shape is refused; a refusal keeps its status
ok 7 - a status answer for another project or of the wrong shape is refused; a refusal keeps its status
---
duration_ms: 4.9292
type: 'test'
...
# Subtest: the status read needs no send; a read-only Console still has no revert
ok 8 - the status read needs no send; a read-only Console still has no revert
---
duration_ms: 1.266317
type: 'test'
...
# Subtest: library routes are the owner's: a granted user is refused them, and the tester never reverts
ok 9 - library routes are the owner's: a granted user is refused them, and the tester never reverts
---
duration_ms: 0.443059
type: 'test'
...
# Subtest: the page gets the one library renderer, byte for byte, as a JavaScript module
ok 10 - the page gets the one library renderer, byte for byte, as a JavaScript module
---
duration_ms: 1.048018
type: 'test'
...
# Subtest: a revert receipt for another project is unknown and is never retried
ok 11 - a revert receipt for another project is unknown and is never retried
---
duration_ms: 0.864934
type: 'test'
...
# Subtest: a granted-user HTTP session cannot reach owner library callbacks
ok 12 - a granted-user HTTP session cannot reach owner library callbacks
---
duration_ms: 1.064626
type: 'test'
...
# Subtest: a configured revert callback remains unreachable when send is off
ok 13 - a configured revert callback remains unreachable when send is off
---
duration_ms: 0.803048
type: 'test'
...
1..13
# tests 13
# suites 0
# pass 12
# fail 1
# cancelled 0
# skipped 0
# todo 0
# duration_ms 153.3549
@@ -0,0 +1,105 @@
TAP version 13
# Subtest: a signed-in same-origin revert reaches exactly one library.revert and returns the new commit
ok 1 - a signed-in same-origin revert reaches exactly one library.revert and returns the new commit
---
duration_ms: 20.271342
type: 'test'
...
# Subtest: a revert needs send, the named Host, the user, the exact Origin and same-origin Fetch Metadata
not ok 2 - a revert needs send, the named Host, the user, the exact Origin and same-origin Fetch Metadata
---
duration_ms: 5.700968
type: 'test'
location: '/mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mosaic-row225-mutants-e6t705gk/M5-fetch-metadata/packages/webui/tests/library-routes.test.mjs:48:1'
failureType: 'testCodeFailure'
error: |-
sec-fetch-site
200 !== 403
code: 'ERR_ASSERTION'
name: 'AssertionError'
expected: 403
actual: 200
operator: 'strictEqual'
stack: |-
TestContext.<anonymous> (file:///mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mosaic-row225-mutants-e6t705gk/M5-fetch-metadata/packages/webui/tests/library-routes.test.mjs:57:12)
process.processTicksAndRejections (node:internal/process/task_queues:104:5)
async Test.run (node:internal/test_runner/test:1409:7)
async Test.processPendingSubtests (node:internal/test_runner/test:974:7)
...
# Subtest: a revert takes a project and a 40-hex commit, nothing else, before the bus is asked
ok 3 - a revert takes a project and a 40-hex commit, nothing else, before the bus is asked
---
duration_ms: 6.427935
type: 'test'
...
# Subtest: a bus refusal is reported as not reverted with its code; before C2-02 that is the verb refusal
ok 4 - a bus refusal is reported as not reverted with its code; before C2-02 that is the verb refusal
---
duration_ms: 7.39929
type: 'test'
...
# Subtest: a revert whose answer is lost or unusable is reported unknown and is never retried
ok 5 - a revert whose answer is lost or unusable is reported unknown and is never retried
---
duration_ms: 5.425467
type: 'test'
...
# Subtest: the status read takes one project query and answers the bus result for that project
ok 6 - the status read takes one project query and answers the bus result for that project
---
duration_ms: 2.634037
type: 'test'
...
# Subtest: a status answer for another project or of the wrong shape is refused; a refusal keeps its status
ok 7 - a status answer for another project or of the wrong shape is refused; a refusal keeps its status
---
duration_ms: 5.727409
type: 'test'
...
# Subtest: the status read needs no send; a read-only Console still has no revert
ok 8 - the status read needs no send; a read-only Console still has no revert
---
duration_ms: 1.317954
type: 'test'
...
# Subtest: library routes are the owner's: a granted user is refused them, and the tester never reverts
ok 9 - library routes are the owner's: a granted user is refused them, and the tester never reverts
---
duration_ms: 0.583799
type: 'test'
...
# Subtest: the page gets the one library renderer, byte for byte, as a JavaScript module
ok 10 - the page gets the one library renderer, byte for byte, as a JavaScript module
---
duration_ms: 1.207375
type: 'test'
...
# Subtest: a revert receipt for another project is unknown and is never retried
ok 11 - a revert receipt for another project is unknown and is never retried
---
duration_ms: 0.956772
type: 'test'
...
# Subtest: a granted-user HTTP session cannot reach owner library callbacks
ok 12 - a granted-user HTTP session cannot reach owner library callbacks
---
duration_ms: 1.147745
type: 'test'
...
# Subtest: a configured revert callback remains unreachable when send is off
ok 13 - a configured revert callback remains unreachable when send is off
---
duration_ms: 0.819337
type: 'test'
...
1..13
# tests 13
# suites 0
# pass 12
# fail 1
# cancelled 0
# skipped 0
# todo 0
# duration_ms 166.134058
@@ -0,0 +1,105 @@
TAP version 13
# Subtest: a signed-in same-origin revert reaches exactly one library.revert and returns the new commit
ok 1 - a signed-in same-origin revert reaches exactly one library.revert and returns the new commit
---
duration_ms: 16.249491
type: 'test'
...
# Subtest: a revert needs send, the named Host, the user, the exact Origin and same-origin Fetch Metadata
not ok 2 - a revert needs send, the named Host, the user, the exact Origin and same-origin Fetch Metadata
---
duration_ms: 5.463985
type: 'test'
location: '/mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mosaic-row225-mutants-e6t705gk/M5-origin/packages/webui/tests/library-routes.test.mjs:48:1'
failureType: 'testCodeFailure'
error: |-
origin
200 !== 403
code: 'ERR_ASSERTION'
name: 'AssertionError'
expected: 403
actual: 200
operator: 'strictEqual'
stack: |-
TestContext.<anonymous> (file:///mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mosaic-row225-mutants-e6t705gk/M5-origin/packages/webui/tests/library-routes.test.mjs:57:12)
process.processTicksAndRejections (node:internal/process/task_queues:104:5)
async Test.run (node:internal/test_runner/test:1409:7)
async Test.processPendingSubtests (node:internal/test_runner/test:974:7)
...
# Subtest: a revert takes a project and a 40-hex commit, nothing else, before the bus is asked
ok 3 - a revert takes a project and a 40-hex commit, nothing else, before the bus is asked
---
duration_ms: 6.035196
type: 'test'
...
# Subtest: a bus refusal is reported as not reverted with its code; before C2-02 that is the verb refusal
ok 4 - a bus refusal is reported as not reverted with its code; before C2-02 that is the verb refusal
---
duration_ms: 5.621744
type: 'test'
...
# Subtest: a revert whose answer is lost or unusable is reported unknown and is never retried
ok 5 - a revert whose answer is lost or unusable is reported unknown and is never retried
---
duration_ms: 4.928333
type: 'test'
...
# Subtest: the status read takes one project query and answers the bus result for that project
ok 6 - the status read takes one project query and answers the bus result for that project
---
duration_ms: 2.455282
type: 'test'
...
# Subtest: a status answer for another project or of the wrong shape is refused; a refusal keeps its status
ok 7 - a status answer for another project or of the wrong shape is refused; a refusal keeps its status
---
duration_ms: 4.838747
type: 'test'
...
# Subtest: the status read needs no send; a read-only Console still has no revert
ok 8 - the status read needs no send; a read-only Console still has no revert
---
duration_ms: 1.235209
type: 'test'
...
# Subtest: library routes are the owner's: a granted user is refused them, and the tester never reverts
ok 9 - library routes are the owner's: a granted user is refused them, and the tester never reverts
---
duration_ms: 0.436399
type: 'test'
...
# Subtest: the page gets the one library renderer, byte for byte, as a JavaScript module
ok 10 - the page gets the one library renderer, byte for byte, as a JavaScript module
---
duration_ms: 1.100574
type: 'test'
...
# Subtest: a revert receipt for another project is unknown and is never retried
ok 11 - a revert receipt for another project is unknown and is never retried
---
duration_ms: 0.943474
type: 'test'
...
# Subtest: a granted-user HTTP session cannot reach owner library callbacks
ok 12 - a granted-user HTTP session cannot reach owner library callbacks
---
duration_ms: 1.176957
type: 'test'
...
# Subtest: a configured revert callback remains unreachable when send is off
ok 13 - a configured revert callback remains unreachable when send is off
---
duration_ms: 0.861834
type: 'test'
...
1..13
# tests 13
# suites 0
# pass 12
# fail 1
# cancelled 0
# skipped 0
# todo 0
# duration_ms 150.969641
@@ -0,0 +1,105 @@
TAP version 13
# Subtest: a signed-in same-origin revert reaches exactly one library.revert and returns the new commit
ok 1 - a signed-in same-origin revert reaches exactly one library.revert and returns the new commit
---
duration_ms: 18.918199
type: 'test'
...
# Subtest: a revert needs send, the named Host, the user, the exact Origin and same-origin Fetch Metadata
ok 2 - a revert needs send, the named Host, the user, the exact Origin and same-origin Fetch Metadata
---
duration_ms: 7.26916
type: 'test'
...
# Subtest: a revert takes a project and a 40-hex commit, nothing else, before the bus is asked
ok 3 - a revert takes a project and a 40-hex commit, nothing else, before the bus is asked
---
duration_ms: 5.352342
type: 'test'
...
# Subtest: a bus refusal is reported as not reverted with its code; before C2-02 that is the verb refusal
ok 4 - a bus refusal is reported as not reverted with its code; before C2-02 that is the verb refusal
---
duration_ms: 6.134713
type: 'test'
...
# Subtest: a revert whose answer is lost or unusable is reported unknown and is never retried
ok 5 - a revert whose answer is lost or unusable is reported unknown and is never retried
---
duration_ms: 4.288914
type: 'test'
...
# Subtest: the status read takes one project query and answers the bus result for that project
ok 6 - the status read takes one project query and answers the bus result for that project
---
duration_ms: 2.745385
type: 'test'
...
# Subtest: a status answer for another project or of the wrong shape is refused; a refusal keeps its status
ok 7 - a status answer for another project or of the wrong shape is refused; a refusal keeps its status
---
duration_ms: 5.739155
type: 'test'
...
# Subtest: the status read needs no send; a read-only Console still has no revert
ok 8 - the status read needs no send; a read-only Console still has no revert
---
duration_ms: 1.264793
type: 'test'
...
# Subtest: library routes are the owner's: a granted user is refused them, and the tester never reverts
ok 9 - library routes are the owner's: a granted user is refused them, and the tester never reverts
---
duration_ms: 0.471965
type: 'test'
...
# Subtest: the page gets the one library renderer, byte for byte, as a JavaScript module
ok 10 - the page gets the one library renderer, byte for byte, as a JavaScript module
---
duration_ms: 1.045204
type: 'test'
...
# Subtest: a revert receipt for another project is unknown and is never retried
not ok 11 - a revert receipt for another project is unknown and is never retried
---
duration_ms: 1.531252
type: 'test'
location: '/mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mosaic-row225-mutants-e6t705gk/M5-project-receipt/packages/webui/tests/library-routes.test.mjs:145:1'
failureType: 'testCodeFailure'
error: |-
Expected values to be strictly equal:
200 !== 503
code: 'ERR_ASSERTION'
name: 'AssertionError'
expected: 503
actual: 200
operator: 'strictEqual'
stack: |-
TestContext.<anonymous> (file:///mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mosaic-row225-mutants-e6t705gk/M5-project-receipt/packages/webui/tests/library-routes.test.mjs:148:10)
process.processTicksAndRejections (node:internal/process/task_queues:104:5)
async Test.run (node:internal/test_runner/test:1409:7)
async Test.processPendingSubtests (node:internal/test_runner/test:974:7)
...
# Subtest: a granted-user HTTP session cannot reach owner library callbacks
ok 12 - a granted-user HTTP session cannot reach owner library callbacks
---
duration_ms: 1.60161
type: 'test'
...
# Subtest: a configured revert callback remains unreachable when send is off
ok 13 - a configured revert callback remains unreachable when send is off
---
duration_ms: 0.816068
type: 'test'
...
1..13
# tests 13
# suites 0
# pass 12
# fail 1
# cancelled 0
# skipped 0
# todo 0
# duration_ms 171.289003
@@ -0,0 +1,106 @@
TAP version 13
# Subtest: a signed-in same-origin revert reaches exactly one library.revert and returns the new commit
ok 1 - a signed-in same-origin revert reaches exactly one library.revert and returns the new commit
---
duration_ms: 18.930067
type: 'test'
...
# Subtest: a revert needs send, the named Host, the user, the exact Origin and same-origin Fetch Metadata
ok 2 - a revert needs send, the named Host, the user, the exact Origin and same-origin Fetch Metadata
---
duration_ms: 7.470645
type: 'test'
...
# Subtest: a revert takes a project and a 40-hex commit, nothing else, before the bus is asked
ok 3 - a revert takes a project and a 40-hex commit, nothing else, before the bus is asked
---
duration_ms: 6.646929
type: 'test'
...
# Subtest: a bus refusal is reported as not reverted with its code; before C2-02 that is the verb refusal
ok 4 - a bus refusal is reported as not reverted with its code; before C2-02 that is the verb refusal
---
duration_ms: 6.637697
type: 'test'
...
# Subtest: a revert whose answer is lost or unusable is reported unknown and is never retried
ok 5 - a revert whose answer is lost or unusable is reported unknown and is never retried
---
duration_ms: 4.174441
type: 'test'
...
# Subtest: the status read takes one project query and answers the bus result for that project
ok 6 - the status read takes one project query and answers the bus result for that project
---
duration_ms: 2.513131
type: 'test'
...
# Subtest: a status answer for another project or of the wrong shape is refused; a refusal keeps its status
ok 7 - a status answer for another project or of the wrong shape is refused; a refusal keeps its status
---
duration_ms: 5.145692
type: 'test'
...
# Subtest: the status read needs no send; a read-only Console still has no revert
ok 8 - the status read needs no send; a read-only Console still has no revert
---
duration_ms: 1.182362
type: 'test'
...
# Subtest: library routes are the owner's: a granted user is refused them, and the tester never reverts
ok 9 - library routes are the owner's: a granted user is refused them, and the tester never reverts
---
duration_ms: 0.471806
type: 'test'
...
# Subtest: the page gets the one library renderer, byte for byte, as a JavaScript module
ok 10 - the page gets the one library renderer, byte for byte, as a JavaScript module
---
duration_ms: 1.064814
type: 'test'
...
# Subtest: a revert receipt for another project is unknown and is never retried
ok 11 - a revert receipt for another project is unknown and is never retried
---
duration_ms: 0.910004
type: 'test'
...
# Subtest: a granted-user HTTP session cannot reach owner library callbacks
ok 12 - a granted-user HTTP session cannot reach owner library callbacks
---
duration_ms: 1.16191
type: 'test'
...
# 2026-10-23T19:28:51.488Z library-revert user="jason" project="books" commit="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" commit=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
# Subtest: a configured revert callback remains unreachable when send is off
not ok 13 - a configured revert callback remains unreachable when send is off
---
duration_ms: 1.624909
type: 'test'
location: '/mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mosaic-row225-mutants-e6t705gk/M5-send/packages/webui/tests/library-routes.test.mjs:175:1'
failureType: 'testCodeFailure'
error: |-
Expected values to be strictly equal:
200 !== 403
code: 'ERR_ASSERTION'
name: 'AssertionError'
expected: 403
actual: 200
operator: 'strictEqual'
stack: |-
TestContext.<anonymous> (file:///mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mosaic-row225-mutants-e6t705gk/M5-send/packages/webui/tests/library-routes.test.mjs:181:10)
process.processTicksAndRejections (node:internal/process/task_queues:104:5)
async Test.run (node:internal/test_runner/test:1409:7)
async Test.processPendingSubtests (node:internal/test_runner/test:974:7)
...
1..13
# tests 13
# suites 0
# pass 12
# fail 1
# cancelled 0
# skipped 0
# todo 0
# duration_ms 165.919088
@@ -0,0 +1,177 @@
TAP version 13
# Subtest: a signed-in same-origin revert reaches exactly one library.revert and returns the new commit
not ok 1 - a signed-in same-origin revert reaches exactly one library.revert and returns the new commit
---
duration_ms: 17.922489
type: 'test'
location: '/mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mosaic-row225-mutants-e6t705gk/M6-duplicate-call/packages/webui/tests/library-routes.test.mjs:40:1'
failureType: 'testCodeFailure'
error: |-
Expected values to be strictly deep-equal:
+ actual - expected
[
{
commit: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
project: 'books'
},
+ {
+ commit: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
+ project: 'books'
+ }
]
code: 'ERR_ASSERTION'
name: 'AssertionError'
expected:
0:
project: 'books'
commit: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
actual:
0:
project: 'books'
commit: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
1:
project: 'books'
commit: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
operator: 'deepStrictEqual'
stack: |-
TestContext.<anonymous> (file:///mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mosaic-row225-mutants-e6t705gk/M6-duplicate-call/packages/webui/tests/library-routes.test.mjs:43:10)
process.processTicksAndRejections (node:internal/process/task_queues:104:5)
async Test.run (node:internal/test_runner/test:1409:7)
async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3)
...
# Subtest: a revert needs send, the named Host, the user, the exact Origin and same-origin Fetch Metadata
ok 2 - a revert needs send, the named Host, the user, the exact Origin and same-origin Fetch Metadata
---
duration_ms: 7.053366
type: 'test'
...
# Subtest: a revert takes a project and a 40-hex commit, nothing else, before the bus is asked
ok 3 - a revert takes a project and a 40-hex commit, nothing else, before the bus is asked
---
duration_ms: 5.869486
type: 'test'
...
# Subtest: a bus refusal is reported as not reverted with its code; before C2-02 that is the verb refusal
ok 4 - a bus refusal is reported as not reverted with its code; before C2-02 that is the verb refusal
---
duration_ms: 6.852952
type: 'test'
...
# Subtest: a revert whose answer is lost or unusable is reported unknown and is never retried
not ok 5 - a revert whose answer is lost or unusable is reported unknown and is never retried
---
duration_ms: 2.870452
type: 'test'
location: '/mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mosaic-row225-mutants-e6t705gk/M6-duplicate-call/packages/webui/tests/library-routes.test.mjs:88:1'
failureType: 'testCodeFailure'
error: |-
Expected values to be strictly equal:
2 !== 1
code: 'ERR_ASSERTION'
name: 'AssertionError'
expected: 1
actual: 2
operator: 'strictEqual'
stack: |-
TestContext.<anonymous> (file:///mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mosaic-row225-mutants-e6t705gk/M6-duplicate-call/packages/webui/tests/library-routes.test.mjs:96:12)
process.processTicksAndRejections (node:internal/process/task_queues:104:5)
async Test.run (node:internal/test_runner/test:1409:7)
async Test.processPendingSubtests (node:internal/test_runner/test:974:7)
...
# Subtest: the status read takes one project query and answers the bus result for that project
ok 6 - the status read takes one project query and answers the bus result for that project
---
duration_ms: 2.904694
type: 'test'
...
# Subtest: a status answer for another project or of the wrong shape is refused; a refusal keeps its status
ok 7 - a status answer for another project or of the wrong shape is refused; a refusal keeps its status
---
duration_ms: 5.7962
type: 'test'
...
# Subtest: the status read needs no send; a read-only Console still has no revert
ok 8 - the status read needs no send; a read-only Console still has no revert
---
duration_ms: 1.518332
type: 'test'
...
# Subtest: library routes are the owner's: a granted user is refused them, and the tester never reverts
ok 9 - library routes are the owner's: a granted user is refused them, and the tester never reverts
---
duration_ms: 0.563294
type: 'test'
...
# Subtest: the page gets the one library renderer, byte for byte, as a JavaScript module
ok 10 - the page gets the one library renderer, byte for byte, as a JavaScript module
---
duration_ms: 1.17446
type: 'test'
...
# Subtest: a revert receipt for another project is unknown and is never retried
not ok 11 - a revert receipt for another project is unknown and is never retried
---
duration_ms: 1.258633
type: 'test'
location: '/mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mosaic-row225-mutants-e6t705gk/M6-duplicate-call/packages/webui/tests/library-routes.test.mjs:145:1'
failureType: 'testCodeFailure'
error: |-
Expected values to be strictly deep-equal:
+ actual - expected
[
{
commit: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
project: 'books'
},
+ {
+ commit: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
+ project: 'books'
+ }
]
code: 'ERR_ASSERTION'
name: 'AssertionError'
expected:
0:
project: 'books'
commit: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
actual:
0:
project: 'books'
commit: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
1:
project: 'books'
commit: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
operator: 'deepStrictEqual'
stack: |-
TestContext.<anonymous> (file:///mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mosaic-row225-mutants-e6t705gk/M6-duplicate-call/packages/webui/tests/library-routes.test.mjs:151:10)
process.processTicksAndRejections (node:internal/process/task_queues:104:5)
async Test.run (node:internal/test_runner/test:1409:7)
async Test.processPendingSubtests (node:internal/test_runner/test:974:7)
...
# Subtest: a granted-user HTTP session cannot reach owner library callbacks
ok 12 - a granted-user HTTP session cannot reach owner library callbacks
---
duration_ms: 1.362877
type: 'test'
...
# Subtest: a configured revert callback remains unreachable when send is off
ok 13 - a configured revert callback remains unreachable when send is off
---
duration_ms: 0.848309
type: 'test'
...
1..13
# tests 13
# suites 0
# pass 10
# fail 3
# cancelled 0
# skipped 0
# todo 0
# duration_ms 169.162917
@@ -0,0 +1,126 @@
TAP version 13
# Subtest: a signed-in same-origin revert reaches exactly one library.revert and returns the new commit
ok 1 - a signed-in same-origin revert reaches exactly one library.revert and returns the new commit
---
duration_ms: 18.215173
type: 'test'
...
# Subtest: a revert needs send, the named Host, the user, the exact Origin and same-origin Fetch Metadata
ok 2 - a revert needs send, the named Host, the user, the exact Origin and same-origin Fetch Metadata
---
duration_ms: 6.416847
type: 'test'
...
# Subtest: a revert takes a project and a 40-hex commit, nothing else, before the bus is asked
ok 3 - a revert takes a project and a 40-hex commit, nothing else, before the bus is asked
---
duration_ms: 5.529629
type: 'test'
...
# Subtest: a bus refusal is reported as not reverted with its code; before C2-02 that is the verb refusal
ok 4 - a bus refusal is reported as not reverted with its code; before C2-02 that is the verb refusal
---
duration_ms: 6.92431
type: 'test'
...
# Subtest: a revert whose answer is lost or unusable is reported unknown and is never retried
ok 5 - a revert whose answer is lost or unusable is reported unknown and is never retried
---
duration_ms: 4.588509
type: 'test'
...
# Subtest: the status read takes one project query and answers the bus result for that project
ok 6 - the status read takes one project query and answers the bus result for that project
---
duration_ms: 2.817257
type: 'test'
...
# Subtest: a status answer for another project or of the wrong shape is refused; a refusal keeps its status
ok 7 - a status answer for another project or of the wrong shape is refused; a refusal keeps its status
---
duration_ms: 6.020444
type: 'test'
...
# Subtest: the status read needs no send; a read-only Console still has no revert
ok 8 - the status read needs no send; a read-only Console still has no revert
---
duration_ms: 1.377224
type: 'test'
...
# Subtest: library routes are the owner's: a granted user is refused them, and the tester never reverts
not ok 9 - library routes are the owner's: a granted user is refused them, and the tester never reverts
---
duration_ms: 1.015984
type: 'test'
location: '/mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mosaic-row225-mutants-e6t705gk/M7-user-fence/packages/webui/tests/library-routes.test.mjs:132:1'
failureType: 'testCodeFailure'
error: |-
/api/library/status
true !== false
code: 'ERR_ASSERTION'
name: 'AssertionError'
expected: false
actual: true
operator: 'strictEqual'
stack: |-
TestContext.<anonymous> (file:///mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mosaic-row225-mutants-e6t705gk/M7-user-fence/packages/webui/tests/library-routes.test.mjs:133:106)
Test.runInAsyncScope (node:async_hooks:226:14)
Test.run (node:internal/test_runner/test:1402:25)
Test.processPendingSubtests (node:internal/test_runner/test:974:18)
Test.postRun (node:internal/test_runner/test:1542:19)
Test.run (node:internal/test_runner/test:1467:12)
process.processTicksAndRejections (node:internal/process/task_queues:104:5)
async Test.processPendingSubtests (node:internal/test_runner/test:974:7)
...
# Subtest: the page gets the one library renderer, byte for byte, as a JavaScript module
ok 10 - the page gets the one library renderer, byte for byte, as a JavaScript module
---
duration_ms: 1.695555
type: 'test'
...
# Subtest: a revert receipt for another project is unknown and is never retried
ok 11 - a revert receipt for another project is unknown and is never retried
---
duration_ms: 1.378325
type: 'test'
...
# Subtest: a granted-user HTTP session cannot reach owner library callbacks
not ok 12 - a granted-user HTTP session cannot reach owner library callbacks
---
duration_ms: 1.178045
type: 'test'
location: '/mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mosaic-row225-mutants-e6t705gk/M7-user-fence/packages/webui/tests/library-routes.test.mjs:155:1'
failureType: 'testCodeFailure'
error: |-
Expected values to be strictly equal:
503 !== 403
code: 'ERR_ASSERTION'
name: 'AssertionError'
expected: 403
actual: 503
operator: 'strictEqual'
stack: |-
TestContext.<anonymous> (file:///mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mosaic-row225-mutants-e6t705gk/M7-user-fence/packages/webui/tests/library-routes.test.mjs:167:12)
process.processTicksAndRejections (node:internal/process/task_queues:104:5)
async Test.run (node:internal/test_runner/test:1409:7)
async Test.processPendingSubtests (node:internal/test_runner/test:974:7)
...
# Subtest: a configured revert callback remains unreachable when send is off
ok 13 - a configured revert callback remains unreachable when send is off
---
duration_ms: 1.057394
type: 'test'
...
1..13
# tests 13
# suites 0
# pass 11
# fail 2
# cancelled 0
# skipped 0
# todo 0
# duration_ms 176.864112
@@ -0,0 +1,147 @@
{
"head": "50790127716002f97154800ecbfb968690a9d644",
"clear": "sage-3647",
"worktreeUnchanged": true,
"results": [
{
"mutant": "M1-badges",
"anchorHits": 1,
"restored": true,
"worktreeUnchanged": true,
"exit": 1,
"killed": true,
"test": "packages/webui/tests/chat-replies-browser.test.mjs",
"log": "/mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mutants-final/M1-badges.tap"
},
{
"mutant": "M1-agent-label",
"anchorHits": 1,
"restored": true,
"worktreeUnchanged": true,
"exit": 1,
"killed": true,
"test": "packages/webui/tests/chat-replies-browser.test.mjs",
"log": "/mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mutants-final/M1-agent-label.tap"
},
{
"mutant": "M2-http-links",
"anchorHits": 1,
"restored": true,
"worktreeUnchanged": true,
"exit": 1,
"killed": true,
"test": "packages/webui/tests/chat-replies-browser.test.mjs",
"log": "/mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mutants-final/M2-http-links.tap"
},
{
"mutant": "M3-wrong-commit",
"anchorHits": 1,
"restored": true,
"worktreeUnchanged": true,
"exit": 1,
"killed": true,
"test": "packages/webui/tests/chat-replies-browser.test.mjs",
"log": "/mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mutants-final/M3-wrong-commit.tap"
},
{
"mutant": "M3-missing-file",
"anchorHits": 1,
"restored": true,
"worktreeUnchanged": true,
"exit": 1,
"killed": true,
"test": "packages/webui/tests/chat-replies-browser.test.mjs",
"log": "/mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mutants-final/M3-missing-file.tap"
},
{
"mutant": "M4-fallback",
"anchorHits": 1,
"restored": true,
"worktreeUnchanged": true,
"exit": 1,
"killed": true,
"test": "packages/webui/tests/chat-replies-browser.test.mjs",
"log": "/mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mutants-final/M4-fallback.tap"
},
{
"mutant": "M4-problem",
"anchorHits": 1,
"restored": true,
"worktreeUnchanged": true,
"exit": 1,
"killed": true,
"test": "packages/webui/tests/chat-replies-browser.test.mjs",
"log": "/mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mutants-final/M4-problem.tap"
},
{
"mutant": "M5-send",
"anchorHits": 1,
"restored": true,
"worktreeUnchanged": true,
"exit": 1,
"killed": true,
"test": "packages/webui/tests/library-routes.test.mjs",
"log": "/mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mutants-final/M5-send.tap"
},
{
"mutant": "M5-project-receipt",
"anchorHits": 1,
"restored": true,
"worktreeUnchanged": true,
"exit": 1,
"killed": true,
"test": "packages/webui/tests/library-routes.test.mjs",
"log": "/mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mutants-final/M5-project-receipt.tap"
},
{
"mutant": "M5-commit-input",
"anchorHits": 1,
"restored": true,
"worktreeUnchanged": true,
"exit": 1,
"killed": true,
"test": "packages/webui/tests/library-routes.test.mjs",
"log": "/mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mutants-final/M5-commit-input.tap"
},
{
"mutant": "M5-origin",
"anchorHits": 1,
"restored": true,
"worktreeUnchanged": true,
"exit": 1,
"killed": true,
"test": "packages/webui/tests/library-routes.test.mjs",
"log": "/mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mutants-final/M5-origin.tap"
},
{
"mutant": "M5-fetch-metadata",
"anchorHits": 1,
"restored": true,
"worktreeUnchanged": true,
"exit": 1,
"killed": true,
"test": "packages/webui/tests/library-routes.test.mjs",
"log": "/mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mutants-final/M5-fetch-metadata.tap"
},
{
"mutant": "M6-duplicate-call",
"anchorHits": 1,
"restored": true,
"worktreeUnchanged": true,
"exit": 1,
"killed": true,
"test": "packages/webui/tests/library-routes.test.mjs",
"log": "/mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mutants-final/M6-duplicate-call.tap"
},
{
"mutant": "M7-user-fence",
"anchorHits": 1,
"restored": true,
"worktreeUnchanged": true,
"exit": 1,
"killed": true,
"test": "packages/webui/tests/library-routes.test.mjs",
"log": "/mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2/mutants-final/M7-user-fence.tap"
}
]
}
@@ -0,0 +1,155 @@
{
"rawRoot": "/mnt/storage/scratch/tmp/fethry-row225-KEmw8wQ2",
"logs": [
{
"path": "evidence/browser-final.tap",
"rawSha256": "4e6d5874786aa5d407b59d0b701652d419d0fecda5402ede40f52b313def12b0",
"normalization": "trailing whitespace removed from display copy"
},
{
"path": "evidence/browser-final2.tap",
"rawSha256": "194b3150e59868aab023d9ccfc75c69b82e43afba38c1b12e87a1fb3d68fe821",
"normalization": "trailing whitespace removed from display copy"
},
{
"path": "evidence/browser-green.tap",
"rawSha256": "030d7a5c9af70d4a7ade177906fb971b29fa4cf1fc5efd39f35643ed1969cd01",
"normalization": "trailing whitespace removed from display copy"
},
{
"path": "evidence/browser-red.tap",
"rawSha256": "db00bee89b4353efe7d324b17417347b30964f79ad4469a5dc98c6d125691f55",
"normalization": "trailing whitespace removed from display copy"
},
{
"path": "evidence/channel-proof.tap",
"rawSha256": "cb7c0398af2a2a0e7cae24ee2c8167601cbf7e3f9cf3ed7479b2a6ef65f5da8a",
"normalization": "trailing whitespace removed from display copy"
},
{
"path": "evidence/library-full.tap",
"rawSha256": "5447e3fce497a0dfb03ddd3eb2795dc2a179554f794e4c37458219c70736a645",
"normalization": "trailing whitespace removed from display copy"
},
{
"path": "evidence/mapping-red.tap",
"rawSha256": "8ca7310e46df2d35fa96e3aba378721ad388dce41f003ee133b684eeb7eaf245",
"normalization": "trailing whitespace removed from display copy"
},
{
"path": "evidence/mutants-attempt1/M1-agent-label.tap",
"rawSha256": "db7ab1c64bc05b719e5164ed4ce1dd8b79ad53309d9b8e3e1ca0cc8867bca70f",
"normalization": "trailing whitespace removed from display copy"
},
{
"path": "evidence/mutants-attempt1/M1-badges.tap",
"rawSha256": "08351b9061351812008ebd7067a9aed24a9e5386d8f6284fb44ae0f59c8f2eca",
"normalization": "trailing whitespace removed from display copy"
},
{
"path": "evidence/mutants-attempt1/M2-http-links.tap",
"rawSha256": "69921707c219d19fb2c33a3995268b6442d783ec702050b32f78d8d2db6fb581",
"normalization": "trailing whitespace removed from display copy"
},
{
"path": "evidence/mutants-attempt1/M3-wrong-commit.tap",
"rawSha256": "fe9fb027efae26799e2dfac539e0986402cefe806ca31479f52420e67a8df423",
"normalization": "trailing whitespace removed from display copy"
},
{
"path": "evidence/mutants-final/M1-agent-label.tap",
"rawSha256": "afbab60cbc12348c4d0c4be1a8c4590dd157c71f9ffc7375bbde82facb9868bb",
"normalization": "trailing whitespace removed from display copy"
},
{
"path": "evidence/mutants-final/M1-badges.tap",
"rawSha256": "3bb9fb9dcf74a398934d6de48c2ca99ab2eab618fb944297b3d7db00eec0e69f",
"normalization": "trailing whitespace removed from display copy"
},
{
"path": "evidence/mutants-final/M2-http-links.tap",
"rawSha256": "8909921fcf602fcafa94edd19c1167a8f91ed970a3e649110e3503836690e567",
"normalization": "trailing whitespace removed from display copy"
},
{
"path": "evidence/mutants-final/M3-missing-file.tap",
"rawSha256": "3fd2749ea5bc4245f7d49f585b82eae60f61ed150ab4dd22e8148a1e9467c8c7",
"normalization": "trailing whitespace removed from display copy"
},
{
"path": "evidence/mutants-final/M3-wrong-commit.tap",
"rawSha256": "85696c155f0a2844ee903c8fb38cab0a0fb6b0610c8b9ee8aa77a08cb6012ca2",
"normalization": "trailing whitespace removed from display copy"
},
{
"path": "evidence/mutants-final/M4-fallback.tap",
"rawSha256": "6f59f2845e0130d4d2fe1cb4aa7a435502f6043e11ed4efd67339c12e986103a",
"normalization": "trailing whitespace removed from display copy"
},
{
"path": "evidence/mutants-final/M4-problem.tap",
"rawSha256": "61ab08b823eb08cbb9c9eeaac82b297ea5d1272bb5dc64e9598734688b55eb80",
"normalization": "trailing whitespace removed from display copy"
},
{
"path": "evidence/mutants-final/M5-commit-input.tap",
"rawSha256": "b703ee5ca47e5c64749aab3edfb5ae2e085367921ffa75337f4f0e5c7b3dc404",
"normalization": "trailing whitespace removed from display copy"
},
{
"path": "evidence/mutants-final/M5-fetch-metadata.tap",
"rawSha256": "6951725340e4d74764552e7e406e0fc4bafeec35172de56d13bca852b92daa1a",
"normalization": "trailing whitespace removed from display copy"
},
{
"path": "evidence/mutants-final/M5-origin.tap",
"rawSha256": "95fcb5a92e82fa7de2972fd8f78c4f0f6d1c5f49885123e01fb1b69c3b62ba21",
"normalization": "trailing whitespace removed from display copy"
},
{
"path": "evidence/mutants-final/M5-project-receipt.tap",
"rawSha256": "a86806a189ef73f61ddd88479336321964eadef0793c7b45da6573ccd3e38801",
"normalization": "trailing whitespace removed from display copy"
},
{
"path": "evidence/mutants-final/M5-send.tap",
"rawSha256": "4cce523356a3e2041197156412eb35088f8b6d38fcceca2d9b76b6098ad17627",
"normalization": "trailing whitespace removed from display copy"
},
{
"path": "evidence/mutants-final/M6-duplicate-call.tap",
"rawSha256": "6dda962389fbc7379ac7e7bc79cc24b68db00d4f9bb649ab73e75eaa87a5da00",
"normalization": "trailing whitespace removed from display copy"
},
{
"path": "evidence/mutants-final/M7-user-fence.tap",
"rawSha256": "abec36c65c3ece6e7d0c4dfd41eede62c9a585f07b3ed4cb3b8d05f0dbabaed3",
"normalization": "trailing whitespace removed from display copy"
},
{
"path": "evidence/render-green.tap",
"rawSha256": "98463ac3d1262f84758def8bbdc27c46fa7c674bb8e8c20b9c752083163d0864",
"normalization": "trailing whitespace removed from display copy"
},
{
"path": "evidence/render-red.tap",
"rawSha256": "03645c7a5ba0c3d285e58e1d6fd05dc58a1174b463a258b01bcf258b2141a85e",
"normalization": "trailing whitespace removed from display copy"
},
{
"path": "evidence/routes-final.tap",
"rawSha256": "f2b58a97c301dc9e57458d82785bd50debb2254df5d411d8e8c4e6b9c0aa00d2",
"normalization": "trailing whitespace removed from display copy"
},
{
"path": "evidence/routes-green.tap",
"rawSha256": "c34451a0eef5f82fe6ddcbbf370179d8e8a14e36168175bdeff5b32e2072e9d6",
"normalization": "trailing whitespace removed from display copy"
},
{
"path": "evidence/routes-red.tap",
"rawSha256": "5ca5bafe2465209128508e5459f4826c34d00de86d1e79671196dc4e5c57f874",
"normalization": "trailing whitespace removed from display copy"
}
]
}
@@ -0,0 +1,17 @@
✔ free text renders as itself with no citations (1.332106ms)
✔ a cited reply renders its text, then each cite with the badge from the check result (0.493062ms)
✔ a parsed body renders as its string does (0.2706ms)
✔ without a check result a path cite has no badge, no label and is never changed (0.175324ms)
✔ the label and the badge come from the check result only, never from a bad entry (0.171275ms)
✔ a web cite is a link only for https (0.254363ms)
✔ a write notice renders its fixed line (0.201038ms)
✔ a body without the reply or notice shape is free text: the whole body, no citations (0.315846ms)
✔ an empty citations list is a reply with no cites (0.167552ms)
ℹ tests 9
ℹ suites 0
ℹ pass 9
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 70.188818
@@ -0,0 +1,17 @@
✔ free text renders as itself with no citations (1.211164ms)
✔ a cited reply renders its text, then each cite with the badge from the check result (0.457937ms)
✔ a parsed body renders as its string does (0.255138ms)
✔ without a check result a path cite has no badge, no label and is never changed (0.189285ms)
✔ the label and the badge come from the check result only, never from a bad entry (0.162195ms)
✔ a web cite is a link only for https (0.231665ms)
✔ a write notice renders its fixed line (0.167596ms)
✔ a body without the reply or notice shape is free text: the whole body, no citations (0.283804ms)
✔ an empty citations list is a reply with no cites (0.145774ms)
ℹ tests 9
ℹ suites 0
ℹ pass 9
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 73.226696
@@ -0,0 +1,88 @@
TAP version 13
# Subtest: a signed-in same-origin revert reaches exactly one library.revert and returns the new commit
ok 1 - a signed-in same-origin revert reaches exactly one library.revert and returns the new commit
---
duration_ms: 18.873338
type: 'test'
...
# Subtest: a revert needs send, the named Host, the user, the exact Origin and same-origin Fetch Metadata
ok 2 - a revert needs send, the named Host, the user, the exact Origin and same-origin Fetch Metadata
---
duration_ms: 7.061714
type: 'test'
...
# Subtest: a revert takes a project and a 40-hex commit, nothing else, before the bus is asked
ok 3 - a revert takes a project and a 40-hex commit, nothing else, before the bus is asked
---
duration_ms: 6.45503
type: 'test'
...
# Subtest: a bus refusal is reported as not reverted with its code; before C2-02 that is the verb refusal
ok 4 - a bus refusal is reported as not reverted with its code; before C2-02 that is the verb refusal
---
duration_ms: 6.964122
type: 'test'
...
# Subtest: a revert whose answer is lost or unusable is reported unknown and is never retried
ok 5 - a revert whose answer is lost or unusable is reported unknown and is never retried
---
duration_ms: 5.745801
type: 'test'
...
# Subtest: the status read takes one project query and answers the bus result for that project
ok 6 - the status read takes one project query and answers the bus result for that project
---
duration_ms: 2.815134
type: 'test'
...
# Subtest: a status answer for another project or of the wrong shape is refused; a refusal keeps its status
ok 7 - a status answer for another project or of the wrong shape is refused; a refusal keeps its status
---
duration_ms: 7.105984
type: 'test'
...
# Subtest: the status read needs no send; a read-only Console still has no revert
ok 8 - the status read needs no send; a read-only Console still has no revert
---
duration_ms: 2.002948
type: 'test'
...
# Subtest: library routes are the owner's: a granted user is refused them, and the tester never reverts
ok 9 - library routes are the owner's: a granted user is refused them, and the tester never reverts
---
duration_ms: 0.708324
type: 'test'
...
# Subtest: the page gets the one library renderer, byte for byte, as a JavaScript module
ok 10 - the page gets the one library renderer, byte for byte, as a JavaScript module
---
duration_ms: 1.553573
type: 'test'
...
# Subtest: a revert receipt for another project is unknown and is never retried
ok 11 - a revert receipt for another project is unknown and is never retried
---
duration_ms: 1.371025
type: 'test'
...
# Subtest: a granted-user HTTP session cannot reach owner library callbacks
ok 12 - a granted-user HTTP session cannot reach owner library callbacks
---
duration_ms: 1.586864
type: 'test'
...
# Subtest: a configured revert callback remains unreachable when send is off
ok 13 - a configured revert callback remains unreachable when send is off
---
duration_ms: 1.226995
type: 'test'
...
1..13
# tests 13
# suites 0
# pass 13
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 177.384993
@@ -0,0 +1,20 @@
✔ a signed-in same-origin revert reaches exactly one library.revert and returns the new commit (19.266366ms)
✔ a revert needs send, the named Host, the user, the exact Origin and same-origin Fetch Metadata (7.415028ms)
✔ a revert takes a project and a 40-hex commit, nothing else, before the bus is asked (6.176121ms)
✔ a bus refusal is reported as not reverted with its code; before C2-02 that is the verb refusal (7.325625ms)
✔ a revert whose answer is lost or unusable is reported unknown and is never retried (5.0635ms)
✔ the status read takes one project query and answers the bus result for that project (3.008847ms)
✔ a status answer for another project or of the wrong shape is refused; a refusal keeps its status (6.314096ms)
✔ the status read needs no send; a read-only Console still has no revert (1.440122ms)
✔ library routes are the owner's: a granted user is refused them, and the tester never reverts (0.563979ms)
✔ the page gets the one library renderer, byte for byte, as a JavaScript module (1.180551ms)
✔ a revert receipt for another project is unknown and is never retried (1.12255ms)
✔ a granted-user HTTP session cannot reach owner library callbacks (1.351581ms)
ℹ tests 12
ℹ suites 0
ℹ pass 12
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 182.202771
@@ -0,0 +1,76 @@
✔ a signed-in same-origin revert reaches exactly one library.revert and returns the new commit (18.853567ms)
✔ a revert needs send, the named Host, the user, the exact Origin and same-origin Fetch Metadata (6.491576ms)
✔ a revert takes a project and a 40-hex commit, nothing else, before the bus is asked (5.647113ms)
✔ a bus refusal is reported as not reverted with its code; before C2-02 that is the verb refusal (6.08423ms)
✔ a revert whose answer is lost or unusable is reported unknown and is never retried (5.445934ms)
✖ the status read takes one project query and answers the bus result for that project (3.470803ms)
✖ a status answer for another project or of the wrong shape is refused; a refusal keeps its status (5.42219ms)
✔ the status read needs no send; a read-only Console still has no revert (1.428304ms)
✔ library routes are the owner's: a granted user is refused them, and the tester never reverts (0.509039ms)
✔ the page gets the one library renderer, byte for byte, as a JavaScript module (1.123365ms)
✖ a revert receipt for another project is unknown and is never retried (1.209291ms)
✔ a granted-user HTTP session cannot reach owner library callbacks (1.398731ms)
ℹ tests 12
ℹ suites 0
ℹ pass 9
ℹ fail 3
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 198.061254
✖ failing tests:
test at packages/webui/tests/library-routes.test.mjs:100:1
✖ the status read takes one project query and answers the bus result for that project (3.470803ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
403 !== 405
at TestContext.<anonymous> (file:///home/jwoltje/.t3/worktrees/mosaic-stack/fethry-row225/packages/webui/tests/library-routes.test.mjs:107:10)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: 403,
expected: 405,
operator: 'strictEqual',
diff: 'simple'
}
test at packages/webui/tests/library-routes.test.mjs:111:1
✖ a status answer for another project or of the wrong shape is refused; a refusal keeps its status (5.42219ms)
AssertionError [ERR_ASSERTION]: project-not-declared
400 !== 502
at TestContext.<anonymous> (file:///home/jwoltje/.t3/worktrees/mosaic-stack/fethry-row225/packages/webui/tests/library-routes.test.mjs:119:12)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: 400,
expected: 502,
operator: 'strictEqual',
diff: 'simple'
}
test at packages/webui/tests/library-routes.test.mjs:145:1
✖ a revert receipt for another project is unknown and is never retried (1.209291ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
200 !== 503
at TestContext.<anonymous> (file:///home/jwoltje/.t3/worktrees/mosaic-stack/fethry-row225/packages/webui/tests/library-routes.test.mjs:148:10)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: 200,
expected: 503,
operator: 'strictEqual',
diff: 'simple'
}
@@ -0,0 +1,170 @@
diff --git a/packages/bus/tests/channel-proof.test.mjs b/packages/bus/tests/channel-proof.test.mjs
index ba0179832..435d8fa2d 100644
--- a/packages/bus/tests/channel-proof.test.mjs
+++ b/packages/bus/tests/channel-proof.test.mjs
@@ -11,0 +12,2 @@
+// Row 225 (C2-10b) re-pins webui bus.mjs for one STATUS entry, library-revert-not-agent: 409; no proof
+// or transport change.
@@ -32 +34 @@ const baselines = {
- "packages/webui/src/bus.mjs": "604092cb706b152d458f299ff1071f6cd488774629012f96b672f7e9a16c0b2c",
+ "packages/webui/src/bus.mjs": "9f33e9ca4feb7265ae33fd3ddf0ead0b75375d07ecbe9d8e55c618d29edea658",
diff --git a/packages/library/tests/render.test.mjs b/packages/library/tests/render.test.mjs
index b07e26d25..9c861bfe9 100644
--- a/packages/library/tests/render.test.mjs
+++ b/packages/library/tests/render.test.mjs
@@ -3 +3 @@ import assert from 'node:assert/strict';
-import { renderItems, UNREADABLE } from '../src/index.mjs';
+import { renderReply } from '../src/index.mjs';
@@ -4,0 +5 @@ import { renderItems, UNREADABLE } from '../src/index.mjs';
+// Row 225 (C2-10b): renderReply replaces renderItems (design 7.4, the reply schema of 5.2).
@@ -6,6 +7,12 @@ const A = 'a'.repeat(40), B = 'b'.repeat(40);
-const cite = (path, lines, quote, commit = A) => ({ path, commit, lines, quote });
-const options = {
- scopeOf: path => path.includes('/Playground/') ? 'playground' : path.endsWith('.md') ? 'canon' : undefined,
- changed: ({ path, commit }) => path === 'Book One/Characters/Mara Vell.md' && commit === A,
-};
-const render = items => renderItems(JSON.stringify({ items }), options);
+const cite = (path, lines, quote, commit = A, extra = {}) => ({ path, commit, lines, quote, ...extra });
+const web = (url, quote = 'spring tides') => ({ url, quote });
+const reply = (text, citations) => JSON.stringify({ text, citations });
+const CITED = reply("Mara's eyes are grey in chapter 3.", [
+ cite('Book One/Chapters/Chapter 03.md', [41, 41], 'her grey eyes', A, { as: 'canon' }),
+ cite('Book One/Playground/What If.md', [5, 6], 'a storm', B),
+ cite('Notes/Eyes.md', [9, 10], 'green eyes'),
+ web('https://example.invalid/tides'),
+]);
+const CHECK = { citations: [{ scope: 'canon', agent: null }, { scope: 'playground', agent: null },
+ { scope: 'supporting', agent: 'acme/scribe' }, { scope: 'web', agent: null }] };
+const changed = ({ path, commit }) => path === 'Notes/Eyes.md' && commit === A;
@@ -13,21 +20,4 @@ const render = items => renderItems(JSON.stringify({ items }), options);
-// One snapshot per item kind: the plain text both channels show.
-const SNAPSHOTS = {
- fact: [{ kind: 'fact', text: 'Tomas is the ferryman.', cites: [cite('Book One/Characters/Tomas.md', [3, 3], 'the ferryman', B)] }],
- finding: [{ kind: 'finding', text: 'Eye colour differs.', cites: [cite('Book One/Chapters/Chapter 01.md', [41, 41], 'her grey eyes'),
- cite('Book One/Characters/Mara Vell.md', [9, 10], 'green eyes')] }],
- correction: [{ kind: 'correction', text: 'Chapter 1 says grey.', from: 'green eyes', to: 'grey eyes',
- cites: [cite('Book One/Characters/Mara Vell.md', [9, 9], 'green eyes', B)] }],
- question: [{ kind: 'question', text: 'Is the harbor north of the town?', cites: [cite('Book One/Places/Harbor Town.md', [2, 4], 'the harbor', B)] }],
- idea: [{ kind: 'idea', text: 'A storm could close the harbor.', cites: [cite('Book One/Playground/What If.md', [5, 5], 'a storm', B),
- { url: 'https://example.invalid/tides', quote: 'spring tides' }] }],
-};
-const EXPECTED = {
- fact: '1. Fact: Tomas is the ferryman.\n [canon] Book One/Characters/Tomas.md:3 @bbbbbbb "the ferryman"',
- finding: '1. Finding: Eye colour differs.\n [canon] Book One/Chapters/Chapter 01.md:41 @aaaaaaa "her grey eyes"\n'
- + ' [canon] Book One/Characters/Mara Vell.md:9-10 @aaaaaaa "green eyes" (changed since)',
- correction: '1. Correction: Chapter 1 says grey.\n From: "green eyes"\n To: "grey eyes"\n'
- + ' [canon] Book One/Characters/Mara Vell.md:9 @bbbbbbb "green eyes"',
- question: '1. Question: Is the harbor north of the town?\n [canon] Book One/Places/Harbor Town.md:2-4 @bbbbbbb "the harbor"',
- idea: '1. Idea: A storm could close the harbor.\n [playground] Book One/Playground/What If.md:5 @bbbbbbb "a storm"\n'
- + ' [web] https://example.invalid/tides "spring tides"',
-};
+test('free text renders as itself with no citations', () => {
+ for (const body of ['Grey, in chapter 3.', '', '"a JSON string"', '[1, 2]', '42', 'null'])
+ assert.deepEqual(renderReply(body, { check: CHECK }), { kind: 'text', text: body, plain: body });
+});
@@ -35,8 +25,15 @@ const EXPECTED = {
-for (const kind of Object.keys(SNAPSHOTS)) {
- test(`renderItems snapshot: ${kind}`, () => {
- const out = render(SNAPSHOTS[kind]);
- assert.equal(out.kind, 'items');
- assert.equal(out.text, EXPECTED[kind]);
- assert.equal(out.items[0].kind, kind);
- });
-}
+test('a cited reply renders its text, then each cite with the badge from the check result', () => {
+ const out = renderReply(CITED, { check: CHECK, changed });
+ assert.equal(out.kind, 'reply');
+ assert.equal(out.text, "Mara's eyes are grey in chapter 3.");
+ assert.deepEqual(out.citations.map(c => c.badge), ['canon', 'playground', 'supporting', 'web']);
+ assert.equal(out.plain, "Mara's eyes are grey in chapter 3.\n"
+ + ' [canon] Book One/Chapters/Chapter 03.md:41 @aaaaaaa "her grey eyes"\n'
+ + ' [playground] Book One/Playground/What If.md:5-6 @bbbbbbb "a storm"\n'
+ + ' [supporting] Notes/Eyes.md:9-10 @aaaaaaa "green eyes" (agent-written: acme/scribe) (changed since)\n'
+ + ' [web] https://example.invalid/tides "spring tides"');
+ assert.deepEqual(out.citations[0], { badge: 'canon', path: 'Book One/Chapters/Chapter 03.md', commit: A, lines: [41, 41],
+ ref: 'Book One/Chapters/Chapter 03.md:41 @aaaaaaa', quote: 'her grey eyes', canon: true, agent: null, changed: false });
+ assert.deepEqual(out.citations[3], { badge: 'web', url: 'https://example.invalid/tides', link: 'https://example.invalid/tides',
+ ref: 'https://example.invalid/tides', quote: 'spring tides', agent: null, changed: false });
+});
@@ -44,6 +41,2 @@ for (const kind of Object.keys(SNAPSHOTS)) {
-test('renderItems numbers several items and takes a parsed body as well as a string', () => {
- const items = [...SNAPSHOTS.fact, ...SNAPSHOTS.question];
- const out = renderItems({ items }, options);
- assert.equal(out.text, `${EXPECTED.fact}\n${EXPECTED.question.replace(/^1\./, '2.')}`);
- assert.deepEqual(out.items[1].cites[0], { badge: 'canon', path: 'Book One/Places/Harbor Town.md', commit: B, lines: [2, 4],
- ref: 'Book One/Places/Harbor Town.md:2-4 @bbbbbbb', quote: 'the harbor', changed: false });
+test('a parsed body renders as its string does', () => {
+ assert.deepEqual(renderReply(JSON.parse(CITED), { check: CHECK, changed }), renderReply(CITED, { check: CHECK, changed }));
@@ -52,4 +45,4 @@ test('renderItems numbers several items and takes a parsed body as well as a str
-test('without options a path cite has no badge and is never marked changed', () => {
- assert.equal(renderItems({ items: SNAPSHOTS.finding }).text,
- '1. Finding: Eye colour differs.\n Book One/Chapters/Chapter 01.md:41 @aaaaaaa "her grey eyes"\n'
- + ' Book One/Characters/Mara Vell.md:9-10 @aaaaaaa "green eyes"');
+test('without a check result a path cite has no badge, no label and is never changed', () => {
+ const out = renderReply(CITED);
+ assert.deepEqual(out.citations.map(c => [c.badge, c.agent, c.changed]),
+ [[null, null, false], [null, null, false], [null, null, false], ['web', null, false]]);
@@ -58,6 +51,6 @@ test('without options a path cite has no badge and is never marked changed', ()
-const NOTICE_SNAPSHOTS = { 'no-cited-answer': 'No cited answer', 'no-answer': 'No answer', 'turn-failed': 'The turn failed' };
-for (const [notice, text] of Object.entries(NOTICE_SNAPSHOTS)) {
- test(`renderItems snapshot: notice ${notice}`, () => {
- assert.deepEqual(renderItems(JSON.stringify({ items: [], notice }), options), { kind: 'notice', notice, text });
- });
-}
+test('the label and the badge come from the check result only, never from a bad entry', () => {
+ const bad = { citations: [{ scope: 'web', agent: '' }, { scope: 'story', agent: 7 }, null, { scope: 'canon', agent: 'x/y' }] };
+ const out = renderReply(CITED, { check: bad });
+ // A path cite can't be web; a url cite is web by its shape whatever the check says, and never agent-written.
+ assert.deepEqual(out.citations.map(c => [c.badge, c.agent]), [[null, null], [null, null], [null, null], ['web', null]]);
+});
@@ -65,14 +58,8 @@ for (const [notice, text] of Object.entries(NOTICE_SNAPSHOTS)) {
-test('a body without the reply shape shows a fixed line and none of its text', () => {
- const secret = 'free prose the guard would refuse';
- const bodies = [secret, JSON.stringify(secret), '{"items": [], "notice": "other"}', JSON.stringify({ items: [], notice: 'no-answer', extra: secret }),
- JSON.stringify({ items: [] }), JSON.stringify({ items: [{ kind: 'story', text: secret, cites: [] }] }),
- JSON.stringify({ items: [{ kind: 'fact', text: secret }] }), JSON.stringify({ items: SNAPSHOTS.fact, prose: secret }),
- JSON.stringify({ items: [{ kind: 'correction', text: secret, cites: [] }] }),
- JSON.stringify({ items: [{ kind: 'fact', text: secret, cites: [cite('a.md', [2, 1], 'q')] }] }),
- JSON.stringify({ items: [{ kind: 'fact', text: secret, cites: [{ path: 'a.md', commit: 'abc', lines: [1, 1], quote: 'q' }] }] }),
- JSON.stringify({ items: [{ kind: 'fact', text: secret, cites: [{ url: 'https://x.invalid', path: 'a.md', quote: 'q' }] }] }),
- null, 42, '[]'];
- for (const body of bodies) {
- const out = renderItems(body, options);
- assert.deepEqual(out, { kind: 'unreadable', text: UNREADABLE }, String(body));
- assert.ok(!JSON.stringify(out).includes(secret));
+test('a web cite is a link only for https', () => {
+ const urls = { 'https://example.invalid/a': 'https://example.invalid/a', 'http://example.invalid/a': null,
+ 'javascript:alert(1)': null, 'JAVASCRIPT:alert(1)': null, 'data:text/html,x': null, 'not a url': null, '//example.invalid': null };
+ for (const [url, link] of Object.entries(urls)) {
+ const out = renderReply(reply('t', [web(url)]));
+ assert.equal(out.kind, 'reply', url);
+ assert.equal(out.citations[0].link, link, url);
+ assert.equal(out.citations[0].ref, url);
@@ -80,0 +68,27 @@ test('a body without the reply shape shows a fixed line and none of its text', (
+
+test('a write notice renders its fixed line', () => {
+ const body = op => JSON.stringify({ notice: 'library-write', op, path: 'Notes/Eyes.md', commit: B });
+ assert.deepEqual(renderReply(body('write'), { name: 'Scribe' }),
+ { kind: 'notice', op: 'write', path: 'Notes/Eyes.md', commit: B, text: 'Scribe wrote Notes/Eyes.md @bbbbbbb', plain: 'Scribe wrote Notes/Eyes.md @bbbbbbb' });
+ assert.equal(renderReply(body('delete'), { name: 'Scribe' }).text, 'Scribe deleted Notes/Eyes.md @bbbbbbb');
+ assert.equal(renderReply(body('write')).text, 'An agent wrote Notes/Eyes.md @bbbbbbb');
+});
+
+test('a body without the reply or notice shape is free text: the whole body, no citations', () => {
+ const bodies = [
+ JSON.stringify({ text: 't' }), JSON.stringify({ citations: [] }), JSON.stringify({ text: 1, citations: [] }),
+ JSON.stringify({ text: 't', citations: [], extra: 1 }), JSON.stringify({ items: [] }),
+ reply('t', [cite('a.md', [2, 1], 'q')]), reply('t', [cite('a.md', [0, 1], 'q')]), reply('t', [cite('a.md', [1], 'q')]),
+ reply('t', [{ path: 'a.md', commit: 'abc', lines: [1, 1], quote: 'q' }]), reply('t', [cite('a.md', [1, 1], 'q', A, { as: 'playground' })]),
+ reply('t', [cite('a.md', [1, 1], 'q', A, { scope: 'canon' })]), reply('t', [{ url: 'https://x.invalid', path: 'a.md', quote: 'q' }]),
+ reply('t', [{ url: 'https://x.invalid' }]), reply('t', [{ url: 'https://x.invalid', quote: 'q', agent: 'x/y' }]),
+ JSON.stringify({ notice: 'library-write', op: 'move', path: 'a.md', commit: A }),
+ JSON.stringify({ notice: 'library-write', op: 'write', path: 'a.md', commit: 'abc' }),
+ JSON.stringify({ notice: 'library-write', op: 'write', path: 'a.md', commit: A, by: 'x' }),
+ ];
+ for (const body of bodies) assert.deepEqual(renderReply(body, { check: CHECK }), { kind: 'text', text: body, plain: body }, body);
+});
+
+test('an empty citations list is a reply with no cites', () => {
+ assert.deepEqual(renderReply(reply('Grey.', [])), { kind: 'reply', text: 'Grey.', citations: [], plain: 'Grey.' });
+});
@@ -0,0 +1,13 @@
6bdaf870be3d8d03a68eb03f5f6916150bb1b3db134ffbe84018e91d47e6434d packages/bus/tests/channel-proof.test.mjs
0385fc49b445ff730021eb11c20b29027631e0d27350e4591d5a0936ffbdccf6 packages/library/README.md
653ff444ba66cb73e1ca4df1f6037f15a2ec7d3d009cf614a0dc51deabda4b50 packages/library/src/index.mjs
dfcc9f271afc7afdab908395a7f3c402100fa89b4994b20d5bcae3140d5c335e packages/library/src/render.mjs
f3c213f3b0bd080d0bc5837f741a29e324acb91cf0c27144dee08a4113262210 packages/library/tests/render.test.mjs
9f33e9ca4feb7265ae33fd3ddf0ead0b75375d07ecbe9d8e55c618d29edea658 packages/webui/src/bus.mjs
f1a0c9a55b1ae933e205c7e2b9753afe0c2f1fc9b0faa2cf18765396f99ac8dd packages/webui/src/cli.mjs
bf49a22147be0535076640d20149116a744dad9b0b32dd667ee297a061d56134 packages/webui/src/console-bus.mjs
793e7dd9edbe5a55c2deab3c8aef4fbd5b4c838e132e2ac9294043c7176ec6f7 packages/webui/src/public/chat.css
af3a6f382e0d40cf2b28c31db7cb58ba775f1e242651ba42c7426bda99dc0d89 packages/webui/src/public/chat.js
6412fe34bc640433e9cf7610f65d3574a3415c934354d8cb52e056793a81948f packages/webui/src/serve.mjs
f191752f867a112c867ca24268f70aa99ca0903c9208f76bc4bb4c1587cb55df packages/webui/tests/chat-replies-browser.test.mjs
df4ff385ac4ca8244ffa00faf82fcca1f4fcdac8da02a4f4d71233d4df8e1157 packages/webui/tests/library-routes.test.mjs
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""Run row225 mutants in disposable snapshots, only on Sage CLEAR.
Commit the tested candidate first. Never change the worktree or shared dependencies.
A timeout, import failure or process failure is not a mutation kill.
"""
import argparse
import hashlib
import json
import os
from pathlib import Path
import shutil
import subprocess
import tarfile
import tempfile
BROWSER = 'packages/webui/tests/chat-replies-browser.test.mjs'
ROUTES = 'packages/webui/tests/library-routes.test.mjs'
CHAT = 'packages/webui/src/public/chat.js'
SERVE = 'packages/webui/src/serve.mjs'
RENDER = 'packages/library/src/render.mjs'
MUTANTS = [
('M1-badges', RENDER, 'badge: scope, path:', 'badge: null, path:', BROWSER),
('M1-agent-label', RENDER, 'canon: c.canon, agent,', 'canon: c.canon, agent: null,', BROWSER),
('M2-http-links', RENDER, "new URL(url).protocol === 'https:' ? url : null", 'url', BROWSER),
('M3-wrong-commit', CHAT, 'e.file !== e.last', 'cite.commit !== e.last', BROWSER),
('M3-missing-file', CHAT, "SHA.test(e?.file ?? '') && SHA.test(e?.last ?? '')", "SHA.test(e?.last ?? '')", BROWSER),
('M4-fallback', CHAT, 'Array.isArray(limits.fallback) ? limits.fallback.filter', 'Array.isArray(v.replyLimitsFallback) ? v.replyLimitsFallback.filter', BROWSER),
('M4-problem', CHAT, "if (CODE.test(limits.problem ?? '')) parts.push(`<code>${txt(limits.problem)}</code>`);", '', BROWSER),
('M5-send', SERVE, 'if (!send || !libraryRevert || !named)', 'if (!libraryRevert || !named)', ROUTES),
('M5-project-receipt', SERVE, 'result.project !== value.project || ', '', ROUTES),
('M5-commit-input', SERVE, '!COMMIT.test(value.commit)', 'false', ROUTES),
('M5-origin', SERVE, "if (one(req, 'origin') !== ownOrigin(preview)) return refuse(403, 'cross-origin request refused', 'a revert needs the Console\\'s own Origin');", '', ROUTES),
('M5-fetch-metadata', SERVE, "if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site request refused', 'a revert needs same-origin Fetch Metadata');", '', ROUTES),
('M6-duplicate-call', SERVE, 'result = await libraryRevert({ project: value.project, commit: value.commit });', 'result = await libraryRevert({ project: value.project, commit: value.commit }); await libraryRevert({ project: value.project, commit: value.commit });', ROUTES),
('M7-user-fence', SERVE, "if (path === '/api/chat' || path.startsWith('/api/chat/')) return true;", "if (path.startsWith('/api/library/')) return true; if (path === '/api/chat' || path.startsWith('/api/chat/')) return true;", ROUTES),
]
def digest(path):
return hashlib.sha256(path.read_bytes()).hexdigest()
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--clear', required=True, help='Sage CLEAR authorizing this run')
parser.add_argument('--output', required=True, type=Path)
args = parser.parse_args()
repo = Path(subprocess.check_output(['git', 'rev-parse', '--show-toplevel'], text=True).strip())
if subprocess.check_output(['git', 'status', '--porcelain', '--', 'packages'], text=True).strip():
raise SystemExit('Commit tested packages first: snapshot must match candidate.')
head = subprocess.check_output(['git', 'rev-parse', 'HEAD'], text=True).strip()
before = {p: digest(repo / p) for p in [CHAT, SERVE, RENDER]}
args.output.mkdir(parents=True, exist_ok=True)
results = []
with tempfile.TemporaryDirectory(prefix='mosaic-row225-mutants-') as directory:
scratch = Path(directory)
archive = scratch / 'candidate.tar'
with archive.open('wb') as stream:
subprocess.run(['git', 'archive', 'HEAD', 'packages', 'roles', 'contracts', 'tasks', 'missions', 'package.json', 'RELEASE'], stdout=stream, check=True)
for name, target, old, new, test in MUTANTS:
tree = scratch / name
tree.mkdir()
with tarfile.open(archive) as bundle:
bundle.extractall(tree, filter='data')
(tree / 'node_modules').symlink_to((repo / 'node_modules').resolve(), target_is_directory=True)
path = tree / target
source = path.read_text()
hits = source.count(old)
if hits != 1:
raise SystemExit(f'{name}: expected one anchor, found {hits}')
path.write_text(source.replace(old, new, 1))
command = ['timeout', '-k', '10', '240' if test == BROWSER else '120', 'node', '--test', '--test-reporter=tap', '--test-concurrency=1', '--test-timeout=30000', test]
run = subprocess.run(command, cwd=tree, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=os.environ)
log = args.output / f'{name}.tap'
log.write_text(run.stdout)
path.write_text(source)
restored = digest(path) == hashlib.sha256(source.encode()).hexdigest()
worktree_clean = before == {p: digest(repo / p) for p in before}
if not restored or not worktree_clean:
raise SystemExit(f'{name}: restore/hash check failed')
killed = run.returncode == 1 and 'ERR_ASSERTION' in run.stdout and 'not ok ' in run.stdout
results.append({'mutant': name, 'anchorHits': hits, 'restored': restored, 'worktreeUnchanged': worktree_clean, 'exit': run.returncode, 'killed': killed, 'test': test, 'log': str(log)})
print(json.dumps(results[-1]), flush=True)
shutil.rmtree(tree)
if not killed:
break
after = {p: digest(repo / p) for p in before}
report = {'head': head, 'clear': args.clear, 'worktreeUnchanged': before == after, 'results': results}
(args.output / 'summary.json').write_text(json.dumps(report, indent=2) + '\n')
if before != after or len(results) != len(MUTANTS) or not all(r['killed'] for r in results):
raise SystemExit(1)
if __name__ == '__main__':
main()
+177
View File
@@ -0,0 +1,177 @@
# Row 225 (C2-10b) Console chat: replies, citations, write notices, status line
Owner: Morgana, from sage-3582 (18:53Z 10-23). Base: origin/refactor 45dca69f6. Branch: morgana/row225.
Brief: docs/plans/2026-10-20_case2-rows.md, C2-10b. Design: agents/gosalyn/work/case2/DESIGN.md 7.4,
5.2 (the reply schema and the check result) and the write notice and `library.revert` (about line 286).
## Ruling on the dependency gap (sage-3591)
225 builds against the design contracts. `renderReply` stays pure, and the caller supplies the answers
it can't know. The revert route proxies to the bus verb and shows the refusal code until the verb
exists. Landing order: 225 lands after 217 (C2-06), not after 288 (C2-02). Sage adds `after 217` to
row 225. Before 288 lands, the Revert button gets the bus's refusal and shows its code, as the brief
allows.
## A1. The check result I assume (to 217, Launchpad)
The broker stores one check result with each agent-to-human message the guard passes. The Console reads
it on the message row that `/api/chat/threads/<id>/messages` returns, under the key `check`:
```json
{
"check": {
"citations": [
{ "scope": "canon", "agent": null },
{ "scope": "supporting", "agent": "acme/scribe" },
{ "scope": "web", "agent": null }
]
}
}
```
- `check` is `null` or absent on a message the guard didn't check: a human's message, a free-text reply,
a notice, or a message from before 217.
- `check.citations` has one entry per entry of the body's `citations`, in the same order.
- `scope` is one of `canon`, `playground`, `supporting` and `web`. A url cite is `web`.
- `agent` is the `Mosaic-Agent` trailer value, `<business>/<instance>`, from
`git log -1 --format=%(trailers:key=Mosaic-Agent,valueonly) <commit> -- <path>`, or `null` when the
file's last commit at `commit` carries none. A url cite has `agent: null`. The Console shows the
agent-written label when `agent` is a nonempty string, and names the agent.
The Console trusts nothing in the body for scope or label. A cite with no matching check entry renders
with no badge and no label. It doesn't fall back to a scope it worked out itself.
If 217 uses other names, I change mine to theirs before either of us pushes (sage-3591 item 1).
## A2. The revert verb I assume (to 288, Honker)
- Verb: `library.revert`. A human verb (design M4): from an agent session it refuses `human-required`,
like `library.status` (`broker.mjs:5133`).
- Args: `{ "project": <business project name>, "commit": <40 lowercase hex> }`. The design writes
`library.revert {commit}`. `project` is added to match the other library verbs, and like theirs it is the
key of `business.projects` (`mirror.mjs:31`), which is the chat project's `repo` link, not its UUID. Both are required, and no other key
is allowed (`invalid-request`), as the other library verbs check with `keys(args, ...)`.
- Result: `{ "project", "path", "reverted": <the commit given>, "commit": <the new commit> }`.
- Refusal codes the Console shows by name:
- `library-revert-not-agent`: `commit` has no `Mosaic-Agent` trailer for this business, or isn't
reachable from the served commit.
- `stale-base`: a later commit changed that path.
- `human-required`, `invalid-request`, `project-not-configured`.
- Before 288 lands, the bus refuses the verb (expected `unknown-verb`, `broker.mjs:6578`), and the Console
shows that code.
- HTTP status in the Console (`packages/webui/src/bus.mjs` `busStatus`): `stale-base` and
`library-revert-not-agent` map to 409. Every other code keeps the existing mapping.
## A3. The status line
`GET /api/library/status?project=<id>` proxies `library.status {project}` through the existing
`libraryStatus` option. `cli.mjs:110` already passes that option, but `startServer` doesn't read it.
The status line shows:
- the served commit (7 characters) and its age;
- `library-diverged` when the sync state names it;
- the fetch error code;
- the reply limits in force, and the keys that fell back;
- the count of R-problems, linking to the list.
It reads the fields `readSyncState` returns at the base (`packages/library/src/mirror.mjs:232`). It shows
any field 217 adds (the limits) only when present.
## A4. "Changed since" (ruled sage-3596)
Sage ruled that 217 supplies `last`. On a `thread.messages` row that carries `check`, each path cite's
entry gets `last`, the file's last commit at the served tip, read when the message is read. When `last`
is absent the Console marks nothing changed, and a test pins that.
One more field is needed, raised in morgana-0200. The cited `commit` is a library commit, not the
file's own last commit, so a cite made at a commit that didn't touch the file would differ from `last`
while the file is unchanged. I asked 217 to also store `file`, the file's last commit at the cited
commit. That's `%H` from the same `git log -1 <commit> -- <path>` call that reads the trailer, so it
costs no extra git call. The Console marks a cite changed when both `file` and `last` are 40-hex and
differ. The cited commit is reachable from the served one (5.2), so `file` is an ancestor of `last`,
and differing means newer. A missing or malformed `file` or `last` marks nothing.
The entry the Console reads, in full: `{ scope, agent, file?, last? }`.
## A5. `no-cited-answer` retires
`renderItems` and its `NOTICES` (`no-cited-answer`, `no-answer`, `turn-failed`) go. 217 sends the
runner's fixed texts as plain bodies (design 5.2, "Runner texts"), and renderReply shows those as free
text. Design 7.4 names no other notice than `library-write`, so nothing is lost (sage-3596 item 3).
## Files
Both deviations below were accepted in sage-3596 and are named in the manifest for Dewey. The brief owns these:
- `packages/library/src/render.mjs`: `renderReply` replaces `renderItems`.
- `packages/library/src/index.mjs`: the export.
- `packages/library/README.md`: the section.
- `packages/library/tests/render.test.mjs`.
It also owns `bus.js`, its stylesheet, `serve.mjs` (only the two routes) and new webui tests. Two
deviations, both recorded here:
- The chat view's message list is `packages/webui/src/public/chat.js:85` (`window.MosaicChat`), not
`bus.js`. The reply, notice and Revert rendering goes in `chat.js` and `chat.css`. The status line
goes in the chat thread view, so it is also in `chat.js`.
- `packages/webui/src/console-bus.mjs` and `cli.mjs` each get one `libraryRevert` line, beside the
existing `libraryStatus` wiring.
## Overlap with row 220 (C2-10a, Researcher, 3ef6da90d)
220 changes one line each in the webui tests discord.test.mjs, fixture.mjs, history-fixture.mjs,
relaunch.test.mjs and return-flow.test.mjs. Its other paths are in control-board, conversation, discord
and business. 225 doesn't touch those five files or those packages. Its webui tests are new files.
## Handoff to Fethry (mos-1501, Q245)
The row moved to Fethry (Codex, fethry/row225). Everything below is **untested**: no suite or single file
has run on it, since a run needs Sage's CLEAR. Each changed file passes `node --check`.
Done in the handoff commit:
- `render.mjs` `renderReply(body, {check, changed, name})`; index export; README section;
`render.test.mjs` rewritten. `renderItems` is gone from every package.
- `serve.mjs`: `/render.mjs` static entry (owner and user file maps), `GET /api/library/status`
(one `project` query, answer must name that project), `POST /api/library/revert` (save's boundary,
strict `{project, commit}`, refusal shows the code, lost or unusable answer is `outcome-unknown`,
never retried). Both are nulled for a granted user, and `userRoute` keeps them owner-only. The tester
listener refuses `libraryRevert`.
- `bus.mjs`: `library-revert-not-agent: 409`. That re-pins `packages/webui/src/bus.mjs` in
`packages/bus/tests/channel-proof.test.mjs`, with a comment line, as row 254 did. It's a third deviation,
in another package's test.
- `console-bus.mjs` and `cli.mjs`: `libraryRevert`, beside `libraryStatus`.
- `chat.js`: dynamic `import('/render.mjs')` (CSP is `script-src 'self'`); body text until it loads.
Cites with a scope badge, the agent-written label, "changed since" from `file`/`last`, links only for
https with `rel="noopener noreferrer"`. A notice gets a Revert button behind `confirm()`. The library
status line goes under the thread heading.
- `chat.css`: cite, badge, notice and status styles.
- New tests: `packages/webui/tests/library-routes.test.mjs` and
`packages/webui/tests/chat-replies-browser.test.mjs`. 220's five files are untouched.
Decisions made in the build:
- The library name is the chat project's `repo`. A project with no repo reads no status and its
Revert does nothing.
- These codes hide the status line: `project-not-declared`, `library-mapping-invalid`,
`project-not-configured`, `not-configured` and `owner-only`.
- Otherwise every code-repo thread, and every granted user, would show "unavailable".
- Cost: a broken library mapping also hides the line. `library.status` from the CLI still shows it.
- "Age" is the time since `lastFetch` ("fetched 5 min ago"), not the served commit's age, which the
status doesn't carry.
- The limits fields are assumed to be `replyLimits {requireCitation, citationsPerReply}` and
`replyLimitsFallback [keys]`, and are shown only when present. **Not agreed with 217.** Confirm with
Beakley and rename.
- A notice body's shape is all the renderer checks. An agent in a business without a library could
send a body that looks like a notice. The page would show it, but `library.revert` still refuses it.
Left:
1. Get CLEAR and run the webui and library suites, `channel-proof.test.mjs`, and the two new files whole.
The browser test's selectors and waits have never run.
2. Agree the limits field names with 217 (Beakley).
3. Granted-user R6 G5 (`granted-user.test.mjs:252`) lists owner verbs by hand. Add `library.revert`
once 288 lands.
4. BUILD.md: the mutant list and the mos-1275 timeout plan.
5. The manifest names the three deviations: chat.js and chat.css in place of bus.js, the console-bus and
cli lines, and the channel-proof re-pin.
-154
View File
@@ -1,154 +0,0 @@
# Row 297: GitHub grant method (#1803)
Owner Violet (thread `6229eb89-192c-4ca8-9a07-204a33458de3`), reviewer Dewey.
Assignment: Mos mos-1498 / Q245, 2026-10-23. Branch `violet/row297`.
Brief: `docs/plans/2026-10-23_mvp-readiness-rows.md`, GitHub grant method section.
## Status: Untested; Q-B3 contract review before implementation
First commit merges row 198 at `14fe7513c`; second merges its next head `4d8123b0a`.
Both are merge commits; Bushroot's commits remain byte-identical. New 198 heads will
be merged, never rebased/amended/force-pushed. Row 198 is unverified and its old-test
approval is pending. Row 297 cannot land before row 198 passes its own gate.
Read AGENTS.md, agents/README.md, Bushroot's queue-198 BUILD.md and the supplied brief.
Recovered #436b r5 at `1be535aca`, section 10 S1-S6, and inspected row 236's Gitea
implementation, current credential schema, CLI and broker integration. Bushroot
confirmed there are no prior Q-B3 answers or started row 297 work.
`DESIGN.md` answers Q-B3, proposes per-method S4, and names the repository identity
and renewal consumer boundaries requiring Sage's ruling. No implementation starts
before Dewey and Megavolt read the answer, as brief Step 1 requires. Shared Gitea
behavior remains unchanged. No credential file or live token was read; research
used public official GitHub documentation only.
## Verification and slot
- `scripts/mosaic queue next violet` in this worktree refused linked-worktree execution.
- Retried in `/mnt/storage/src/mosaic-stack`: reported an unknown rendered view and
`nothing` for Violet. No queue file changed. Sage owns registration and queue changes.
- No node, test, webui build, gate or mutant run. Sage sage-3614 requires CLEAR first.
- Merge commits are explicitly labeled Untested per Mos's assignment.
## Pre-existing tests changed by Violet
None. Inherited row 198 test changes belong to Bushroot and are recorded in that
row's BUILD.md; Violet has neither changed nor claimed verification of those files.
## Push and landing
Only `violet/*` may be pushed. No push, PR, landing or queue movement yet. First push
ETA communicated as 20:00 UTC, subject to Q-B3 review. Sage owns the gate and landing.
## First push: 6af63e1d3 (2026-10-23)
Pushed only `violet/row297`, normal new-branch push. Packet sent as violet-0004 to
Sage, Dewey, Megavolt and Mos with its commit link and the two explicit scope
rulings. `git diff --cached --check` passed before the packet commit. Verified
14fe7513c and 4d8123b0a are ancestors; only the packet, this BUILD and append-only
session/build-log entries differ from 4d8123b0a. No product or test delta.
Independent inspection after the packet confirms the boundaries: runtime.mjs
constructs only giteaGrant/giteaKeys; broker grants.run routes only to Gitea;
credential reference dates are calendar days, and publication identity is Gitea
fork-based. A GitHub renewal consumer cannot safely reuse those assumptions
without the method-specific contract. Implementation waits on the required
Q-B3 reading and Sage's scope ruling, not on a routine coding choice.
## Implementation in progress (sage-3620; dewey-0931; megavolt-0206)
Both Q-B3 reads completed with no design blocker. Sage selected the App bot on
configured repositories, no accounts/forks, and protected mint/renewal/evidence/
revoke/expiry in this row. Consumer creation is excluded. DESIGN.md now names
R1-R5 and exact clock constants, plus method-specific CLI shapes and workflow
exclusion. PLAN.md records inline implementation. Tests were authored before
their implementations, but no red or green execution is claimed: Sage's Node/test
hold still applies. No test, syntax check through Node, mutant or suite has run.
Code in progress: private file/HTTPS/admin boundary; event-backed durable grants
and generation cleanup; callback-only renewal; broker/CLI/runtime dispatch;
additive reviewed schema support without changing role policy files. New tests
use a fake HTTPS API and generated scratch CA/key. Their fake origin is injected
only through tests; metadata still rejects every HTTP origin, including loopback.
### Consumer gap for Sage's follow-up row
No GitHub consumer, git helper, mirror push or publication is implemented. The
protected `Broker.githubWithToken` callback is the stop point. Gitea's generic
Credentials path has calendar-day dates and fork-specific identity; GitHub tokens
remain protected generation files rather than loading into that unguarded path.
A consumer row must call the protected function and honor expiry/cancellation.
The grant's approval block and README explicitly name this gap.
### Pre-existing test change, Mos authorized
`packages/business/tests/role.test.mjs:209`: exactly 1 insertion / 1 deletion.
Only the regex changes from `/service must be one of/` to `/unsupported.*scopes/`.
The refusal of `{service:"github",scopes:[]}` stays. Reason: sage-3620 authorizes
fixed-scope GitHub declarations; caller-provided scopes now fail on the unknown
key, rather than on an unknown service. Mos mos-1518 (cc Violet, 19:29:15Z) permits
this exact change through Sage. A new-file test in business/tests/github.test.mjs
preserves the closed-service-list check with `not-a-service` and the original
`/service must be one of/` pattern. No other old test has changed.
## Static pass before first implementation push
Additional new tests cover a known token left by an interrupted mint (no adoption,
remote cleanup), unsafe slot modes/symlinks, per-agent busy claim, admin rotate
identity/binding invalidation, fire cleanup/refusal and the lifecycle delete's
first revoke job. No existing fixture or existing test other than the authorized
regex changed. Static review corrected the grant.call method/op field collision
and added revocation sequence binding so an old unspent approval cannot revive a
revoked grant. Both corrections are unexecuted and remain unverified.
No idle GitHub startup sweep is emitted for a business with no GitHub residue.
This avoids changing unrelated runtime evidence. The callback's cancellation
signal is also aborted at the token safety deadline, not only on explicit revoke.
Static dependency inspection found createdCredentials in instance-settings.mjs
would generate an invalid day-based `credentials.github` reference for a new
instance. It now excludes this protected grant declaration, as it excludes host;
a new business/github.test.mjs case pins the human add behavior. This is local
schema integration needed by the new declaration, not new agent grant authority.
## Row198 moving base: 397ef58be (sage-3687, 2026-10-23)
Merged Bushroot's fix head `397ef58bebef2e265e6332bc3ede05de30c4624c`
intact after `ab6417475`. No rebase, amend or force push. His round-3 review
remains open; this is a moving, untested dependency, not acceptance evidence.
Row297's own source/test candidate is still `53a454185`.
Sage sage-3681 allocated the five-new-file first baseline/fix run to T6, about
21:55 UTC, with an explicit CLEAR still required. No Node, tests, browser,
webui build, suites or mutants have run in this session. Consumer stop/gap at
Broker.githubWithToken is accepted by sage-3681. Dewey reviews the row297 delta;
the eventual gate must use the latest intact row198 fix head.
## Static review dewey-0944 / dewey-0946 follow-up (UNTESTED)
Dewey found no source blocker in 53a454185 and confirmed the clean carried delta
at 5ca83173e. Required M1: add a per-path SHA-256 list for the 24 row297 paths,
then report that digest file's own SHA separately to avoid a self-reference.
The list will use git blob bytes at the final head sent for the gate.
Lifecycle-delete coverage now includes the second request completing exactly one
instances.set write after the protected revoke job. New tests are unexecuted.
N1: sweep closes an active generation at its conservative bound with reason
expired, preserving the durable grant for a later renewal. N2: installation calls
require an explicit valid token, with no App JWT fallback. N7: grants.run checks
keys/identifier before approval lookup. Each change has new-file regression
coverage, with Node execution still held for Sage CLEAR.
N3 is documented behavior, retained for Gitea parity: human admin remove can
invalidate cleanup while residues exist; serviceLeft remains until a matching
slot returns or conservative expiry. N4: official API-version source added to
DESIGN.md; public GitHub docs list 2026-03-10 as supported. N5: full event scans
remain acceptable at MVP volume; no cache introduced. N6 for Sage: preserve both
BUILD-LOG.md and docs/SESSIONS.md seat appends alongside refactor's appends when
landing; do not replace either append-only history.
Three additional mutants requested by Dewey have named new-file killers:
- accepting DELETE 401 as revoked: `mutant: closeToken must not count a 401 as proven revocation`;
- treating status 0/5xx as not-issued: `lost mint response stays visible through restart until conservative expiry equality` and `mutant: mint 500/503 is uncertain issuance and stays serviceLeft`;
- dropping recheck's controller.signal.aborted: `mutant: shutdown abort alone prevents a pending generation from applying` (no revocation event, so the signal is the only veto).
No mutant has run; every kill still needs gate evidence.
-221
View File
@@ -1,221 +0,0 @@
# Row 297: Q-B3 answer and proposed GitHub method contract
Violet, 2026-10-23. Issue #1803. Reviewer Dewey. Proposal for review before code.
Source contract: #436b r5 section 10, `agents/megavolt/work/436b/DESIGN.md`
at `1be535aca`. Row 236 is already built; this proposal changes none of its behavior.
Row 198 dependency: merged `14fe7513c`, then `4d8123b0a`, preserving every commit.
## Q-B3: S1-S6
| Item | Answer |
| --- | --- |
| S1 | Agree. Keep `{method, agent, rotateBy, target}`, with `method: "github"` and `target: {projects: [...]}`. Code fixes permissions; the caller cannot name permissions, repository IDs, installation IDs, or an API origin. Resolve projects from reviewed business configuration, and bind the resolved identities in the decision. |
| S2 | Agree. Keep `<configDir>/secrets/grants/<business>/github/admin`, human set only, never an agent service. For GitHub this contains an App RSA private key, not a PAT. Nonsecret metadata identifies the App, installation, account ID, API origin, set time and rotate-by day. One installation per business and method. Only Jason performs the live bootstrap. |
| S3 | Agree. Every network call, including preflight, renewal, verification and remote undo, goes through reserved `action.allowed` `grant.call` then `grant.result`, paired by `callId`. Preserve the grant actor and the kind filter, and use `grantDecision` outside the single consume record. No raw response, key, JWT, installation token, authorization header or token digest enters evidence. |
| S4 | Per method, as section 10 permits. GitHub installation tokens expire within one hour. Recommend a durable approved repository grant, with short-lived token generations renewed only by protected code inside that grant's unchanged binding and rotate-by deadline. Never treat the grant's rotate-by day as a token's expiry. Detailed proposal below. Gitea's mint/issue/settle sequence stays unchanged. |
| S5 | Agree with local-first order. Block new issuance/use and abort the agent's pending generation first; durably remove the active grant next; revoke each known token remotely last. Keep unresolved generations in `serviceLeft` and retry with sweep. An unreturned token cannot be revoked by ID; its bounded expiry must remain visible until elapsed. Do not uninstall or suspend a shared installation to revoke one agent. |
| S6 | Agree. Method-specific checks at admin set, every executor start/renewal, and remote undo. Verify App identity, installation identity/account, non-suspension and the required permissions. Pin repository IDs and verify minted reach across every page. No version-number analogy to Gitea; unsupported responses or absent capabilities refuse closed. |
## Provider facts
Checked against official GitHub documentation on 2026-10-23; these are documentation
findings, not live probes. No credential file was read and no authenticated API call was made.
- [Create an installation access token](https://docs.github.com/en/rest/apps/apps#create-an-installation-access-token-for-an-app):
App JWT authentication; explicit `repository_ids` and `permissions` narrow issuance.
Omitting either uses the installation's broader defaults. Tokens expire after one hour.
Token format is opaque; no fixed-length or 40-character assumption.
- [Generate an App JWT](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-json-web-token-jwt-for-a-github-app):
RS256 with bounded `iat`/`exp`, at most ten minutes ahead, authenticated with Bearer.
- [Installation endpoints](https://docs.github.com/en/rest/apps/installations):
`GET /installation/repositories` reports token-accessible repository IDs and total count.
`DELETE /installation/token` revokes the token used to authenticate that request.
This does not provide Gitea's recorded-token-ID deletion or `GET /token` identity contract.
## Approaches and recommendation
1. Mint once per Approve, store with its actual expiry, then require another Approve.
Smallest lifecycle but forces another human approval within an hour of each use.
2. Durable approved grant plus protected renewal, recommended. The approval names the
agent, exact project/repository binding, fixed permissions and latest renewal date.
Token expiry is separate. This needs renewal, interruption and revoke-race coverage.
3. A human PAT or copied Gitea account/user lifecycle is not proposed. It does not supply
the App issuance contract this brief calls out, and GitHub is not an account-creation
API equivalent to Gitea's site-admin path.
The recommendation is Violet's proposal, not an owner decision. Q-B3 must be read by
Dewey and Megavolt before implementation. Sage must settle the scope points below.
## Proposed per-method S4
The durable authority is the approved binding, not a long-lived token. Raise is chief-only;
Approve and admin-slot writes retain their existing human gates. Preflight checks and
binding recheck happen before consume, under an agent claim. Only consume has `decision`.
At grant time, resolve the configured projects to immutable repository IDs and bind the
definition, relevant role/project configuration and admin metadata. Show the App and
installation identity, repositories, fixed permissions, grant deadline and automatic
renewal behavior in the human approval block. Permission proposal: `contents: write`
plus GitHub's implicit `metadata: read`; no workflow, administration, issue or PR write
permission. Adding any permission requires a reviewed method change.
Before every generation: check the grant is active, unchanged, unexpired, the agent is
still eligible, and processing/revocation does not prohibit the operation. Re-run the
capability floor. Write the mint intent before POST and the result after it. POST always
includes the exact nonempty repository ID list and the fixed permission map. Verify the
returned expiry, permissions and all token-accessible repositories before making the
generation usable. A missing/invalid/past expiry or wider reach revokes the candidate and
refuses. Never use a stale token during a failed renewal.
Use the actual provider expiry as a timestamp with a safety margin. Token replacement
must be atomic with respect to revoke: a revoke that wins while mint/verify is pending
prevents publication of that candidate. Only one generation per agent runs at once.
New generations cannot widen the original approved repository IDs; changed configuration
requires a fresh decision. The App private key and JWT never enter the agent credential
store, and the worker never gets the App key or authority to renew.
There is no claim that a stored token can be identified by a Gitea-style token ID.
On restart, an interrupted generation is held out of use. Any recoverable token is
verified/revoked through its own installation authentication; an ambiguous generation
is not adopted as an applied grant. Where the token was minted but its response/file was
lost, record unresolved remote reach until the documented maximum lifetime has elapsed.
An unanswered mint uses a conservative expiry bound that includes the request deadline;
the implementation must document the clock/response assumptions rather than claim remote
revocation succeeded. A new generation never hides an older unresolved one.
The concrete token storage and consumer hook are still scope decisions below. A generic
`Credentials.issue` using only `YYYY-MM-DD` would lose the token lifetime and is unsuitable
without a method-specific timestamp guard on every use and restart load.
## Scope points for Sage, with reviewer input
These are unresolved integration boundaries found in the existing implementation:
1. Repository authority and identity: `SERVICES`, credential dates, role identity and
publish code currently implement Gitea/Vikunja/host, not GitHub. Recommend GitHub App
access to the configured exact repositories, with the App bot as service identity;
no creation of GitHub user accounts or automatic forks. If row 297 must reproduce
per-agent forks, that needs a separate repository/identity contract before code.
2. Renewal consumer: recommend protected on-demand renewal for the GitHub credential
consumer, with timestamp checks and no worker mint endpoint. Confirm whether row 297
owns that consumer integration (including GitHub git publication), or only the grant
executor/management surface. A stored expiring token with no guarded consumer is not
a complete renewal implementation.
## Acceptance and proposed test manifest
Fake API only; never a real GitHub token or a call to github.com/api.github.com. Bind a
loopback fake explicitly in tests; production origins come only from the admin contract.
Prefer new test files. No pre-existing test changes without Mos's OK through Sage.
Mirror row 236 X-G1 through X-G15 where meaningful: binding refusals and no writes;
admin floor/write-on-refusal; approval end to end; foreign/rebound identities; failure
matrix and revoke leftovers; interruption/restart; approval drift; call/result ordering
and secrecy; admin replacement invalidation; token replacement ordering; human gates,
single consume and busy; paginated exact reach; evidence reservation; fire/delete;
look-alike evidence ignored. Explicitly mark Gitea-only user creation/disable, version
floor, token-name and token-whoami cases as replaced by App/installation identity,
fixed permissions, expiry and generation checks, not silently dropped.
Additional expiry cases: expiry before use; past/malformed/missing or excessive lifetime
in mint response; controlled-clock renewal; renewal refusal after grant rotate-by;
changed installation/repository/definition/admin binding; failed renewal with no stale
use; restart with an interrupted mint; revoke during pending mint/verify; unknown remote
token visible until its conservative expiry bound; no shared-installation deletion.
Mutants mirror these claims: omit explicit IDs; omit permissions; skip identity/floor,
binding/expiry/reach check; read only one page; publish before verify; mint before intent;
result before call; skip claim; revive after revoke; keep expired token after renewal
failure; renew after grant deadline; clear unresolved reach early; perform remote undo
before local revocation; bypass human/reserved-evidence gates; log a provider secret.
Full touched suites, every `scripts/test-*.sh`, and mutants wait for Sage's CLEAR. Dewey
approves the final manifest. Row 297 lands only after row 198's own gate passes.
## Build design: rulings and review inputs (2026-10-23)
Sage sage-3620 accepts App bot access to exact configured repositories, no accounts
or forks, and row297 ownership of protected mint/renewal/evidence/revoke/expiry.
New GitHub mirror push, publication and credential helpers are excluded. Stop on
the protected side where the Gitea consumer path cannot carry GitHub safely; Sage
files the consumer gap. Dewey dewey-0931 and Megavolt megavolt-0206 read Q-B3 and
report no design blocker. Approach 2 is selected under these rulings.
### Binding and protected interface
Proposed additive configuration: a definition declares `{service: "github"}` with
no caller scopes; the business role declares `github: {projects: [...]}`, and each
such project has `remote` plus positive safe-integer `githubId`. These declarations
are separate from Gitea `identity` and fork-based `publish`. Unknown keys, including
account/fork requests, refuse. No role policy file or live configuration is changed.
The grant binding stores the exact project IDs, repository IDs and remote names,
role/definition digest and admin metadata digest. Renewal compares those immutable
snapshots to current configuration; it never adopts configuration changes as new
authority. GitHub scope approvals remain human-only (no Q228 scope fast path).
Expose renewal only as a trusted host function `withToken(business, agent, fn)` in
protected code, passing `(token, signal)` to the trusted callback. No worker or
socket verb returns a token or mints one without a durable approval. Token files
are generation-specific protected files, never evidence. Do not load these into
`Credentials`, whose calendar-day guards and Gitea helpers cannot enforce expiry.
That is the consumer gap: publication/credential-helper wiring needs another row.
### Dewey R1-R5 and Megavolt notes
- R1: Before paginated verification, require exact mint `permissions` equality to
`{contents: "write", metadata: "read"}`, `repository_selection === "selected"`,
and returned repository IDs equal to the bound set, without duplicate IDs.
Missing fields, subsets and supersets refuse and revoke the candidate. Mutants
accept extra permissions or extra repositories; both must be killed.
- R2: Production metadata accepts normalized HTTPS origins only; no HTTP or test
bypass. Tests run a fake HTTPS GitHub API with an injected scratch CA and optional
transport, following row236. A named admin-set test rejects HTTP before any call.
- R3 C1 replacement: pinned API version and required App/installation/token-response
shapes, refusing unavailable/unsupported capabilities. No Gitea version comparison.
C2 replacement: RSA key's signed JWT authenticates the configured App ID and the
installation belongs to it and is not suspended. C3 replacement: installation
permissions cover the fixed map; the issued token's map must equal it exactly.
C4 replacement: App ID, installation ID and account ID equal the slot metadata.
No claim that the App is a dedicated site-admin account with an empty repo list.
- R4 constants: `TOKEN_LIFETIME_MS = 3_600_000`, `CLOCK_SKEW_MS = 60_000`,
`TOKEN_MARGIN_MS = 60_000`, `CALL_TIMEOUT_MS = 15_000`. A generation is usable only
before `expiresAt - TOKEN_MARGIN_MS`. Mint expiry cannot exceed the request start
plus lifetime plus skew. Unknown mint reach remains until request start plus
request timeout plus lifetime plus skew. Equality at each deadline is expired.
These assume provider expiry is bounded by the documented lifetime and skew;
a request that never returns has no claim of proven remote revocation. Each
boundary has a controlled-clock test. JWT uses `iat = nowSeconds - 60` and
`exp = nowSeconds + 540` (less than the ten-minute maximum).
- R5: A changed project ID, remote, allowed-project list, role definition or admin
slot between generations refuses renewal, even if the replacement configuration
would independently grant access. Re-approval is required.
- Admin CLI: separate `--app-id`, `--installation-id`, `--account-id`, `--url`,
`--rotate-by` shape. RSA private key on bounded stdin; never misuse `--account`
for an installation ID. Rotation replaces a Jason-provided App key on stdin;
this method does not pretend it can manufacture/delete GitHub App private keys.
- Approval block and README explicitly exclude `.github/workflows/` writes and
account/fork creation. No workflow, issue, PR or administration permission is
granted. All fake JWT claims use the injected clock.
### Review follow-up (dewey-0944 / dewey-0946)
`API_VERSION = "2026-03-10"` is pinned from GitHub's official
[API Versions](https://docs.github.com/en/rest/about-the-rest-api/api-versions)
page, checked 2026-10-23. It lists that version as supported and specifies the
`X-GitHub-Api-Version` request header. This is a public-document check, not a
live API probe; fake tests do not reach GitHub.
`grants sweep github` may close an expired active generation once its conservative
`until` bound has elapsed. It deletes that generation's private file and records
`grant.token.closed` with reason `expired`; the durable grant stays renewable.
It must not close a live active generation early. Installation endpoints now
require an explicit valid installation token and cannot fall back to an App JWT.
Manual run validates keys and identifier before any approval lookup.
Admin remove intentionally retains the human's existing ability to remove the
local slot while grants or residues exist (Gitea parity). This invalidates future
use/renewal, but known residue cannot be remotely retried until a matching slot
returns; expiry still closes it at its conservative bound. Status/sweep do not
claim remote revocation in that case. The App itself and private keys are not
removed at GitHub. Event projection is a full read at MVP volume; no cache is
introduced whose invalidation could weaken revocation.
-99
View File
@@ -1,99 +0,0 @@
# Row297 verification manifest (UNTESTED)
Candidate pending tests. Dewey approves this manifest; Sage owns CLEAR and gate.
All listed tests are fake HTTPS API or scratch configuration. No live key/token,
no GitHub service contact, and no public consumer is installed. No Node/test run
has occurred in Violet's session. Static `git diff --check` is the only check run.
## Files and permission boundary
- New protected modules: bus/src/github-api.mjs (HTTPS, JWT, App slot and floor),
github-files.mjs (private atomic files), github-grant.mjs (event projection,
approval binding, renewal, generation files, revoke/recovery).
- Broker/runtime add the method to existing grant gates and lifecycle revocation;
no token-returning or renewal socket verb. Existing Gitea code paths remain.
- Business role/instance parsing: a role declaration `{service:"github"}` takes no scopes; role.github
names allowed projects; project.githubId pins its provider identity. Generic
token-reference schema stays closed to github. No role JSON policy is changed.
- CLI github-grants.mjs handles method-specific admin identity flags and bounded
stdin. grants.mjs dispatches the method without changing Gitea flag shapes.
- New tests: bus fake-github-api.mjs, github-api.test.mjs,
github-grant.test.mjs, github-broker.test.mjs; business github.test.mjs;
cli github-grants.test.mjs. README and packet/build records document the limits.
- One pre-existing test change: business/tests/role.test.mjs:209, exactly 1/1,
regex only. Mos mos-1518 authorization and replacement closed-list test are in
BUILD.md. No other pre-existing test is edited by Violet.
## Row236 X-G correspondence
| Row236 claim | GitHub replacement / named coverage |
| --- | --- |
| X-G1 binding, no writes | grant binding rejects scopes/account/fork keys; business binding refuses missing declaration/project/ID; R5 drift refuses before consume |
| X-G2 C1-C4 | C1 pinned API/required response shapes; C2 signed App authentication and App/installation identity/non-suspension; C3 installation permissions cover the fixed map; C4 App, installation and account IDs match metadata. API tests name identity, suspension, floor, HTTP and RSA refusals |
| X-G3 approved end to end | broker chief raise, human Yes, exact durable grant, protected callback, revoke, fake token gone |
| X-G4 foreign user | No user account lifecycle. Replaced by App/installation/account identity, exact configured repository IDs/names, no account/fork request |
| X-G5 failures/leftovers | invalid mint candidate revoked; remote delete failure is serviceLeft; failed renewal never calls consumer; revoke wins pending renewal |
| X-G6 restart | active generation needs its recorded binding/timestamp; lost response remains until controlled bound; interrupted call without result is visible; no interrupted generation adoption |
| X-G7 binding drift | project ID, remote, allowed-project list, definition and admin metadata between generations; preflight refusal unspent, restart spends interrupted |
| X-G8 calls/secrecy | intent and call visible while fake receives request; result after call; only consume has decision; no key/JWT/token in evidence |
| X-G9 admin rotation | Human replacement key, same App/installation/account/origin, setAt changes binding; no GitHub private-key creation/deletion claim |
| X-G10 replacement | new generation active before old token DELETE, old token removed afterward |
| X-G11 callers/claim/spend | human-only admin/run; chief-only raise; no socket renewal/token; old approval invalid after revoke; single consume across renewal |
| X-G12 reach | exact mint IDs/permissions/selected mode, paginated GET through page 2, repository names checked; wider GET reach refuses and deletes candidate |
| X-G13 reserved namespace | existing grant actor/trigger retained; new direct human insert refusal test; existing grant-migration tests cover shared reservation |
| X-G14 fire | GitHub local revoke; fire completes only after remote cleanup; remote leftover blocks fire. No shared installation suspend/uninstall |
| X-G15 evidence kind | a look-alike applied record under action.refused cannot establish a grant |
Gitea-only version ordering, user nonce/adoption/disable, token-name minting,
admin token minting and token-ID whoami settlement are replaced, not copied.
No Gitea account or fork API is used by the GitHub method.
## Dewey R1-R5
R1 tests mint permission extra/missing/higher keys, selected mode, repository
extra/missing/duplicate IDs before paginated verification. R2 tests admin HTTP
refusal with no loopback exemption; fake HTTPS injects a scratch CA. R3 maps C1-C4
above. R4 constants are lifetime 3,600,000 ms, skew/margin each 60,000 ms, request
15,000 ms; JWT clock claims, margin equality, excessive expiry and lost-response
bound equality have controlled-clock tests. R5 tests configuration changes between
generations, preserving the original binding rather than granting from new config.
## Runs requested, not yet authorized/executed
1. New-file red/green or baseline diagnostic run: `node --test` on the five new
test files. Tests were written first; the test hold prevented observing red
before implementation, so no TDD execution evidence is claimed.
2. Touched suites: business, bus, cli. Full every `scripts/test-*.sh` on Sage's gate.
If the gate includes webui, roster's required editor build applies; Violet
has not run a webui build or touched row198's inherited webui tests.
3. Mutants: run on a disposable copy only after a green baseline. Every survivor
is an open failure, not a waived requirement. Planned mutations: HTTPS origin,
RSA restriction, App/installation/account identity, permission floor, explicit
mint IDs/permissions, exact response maps/selected mode/IDs, expiry ceiling,
margin boundary, durable config/admin digests, revocation epoch, expiry bound,
intent/call ordering, local-before-remote revoke, evidence kind, auto-dispatch,
lifecycle inclusion and unknown-token retention. Actual kill evidence is pending.
## Remaining evidence gaps before acceptance
No test has run, so no pass count or mutant kill is claimed. New tests now cover
a known interrupted token, unsafe file modes/symlinks, busy claim, admin rotate
and the lifecycle-delete revoke job. Runtime verification may require fixes or
additional new-file coverage; lifecycle-delete now includes its second request
completing after the revoke job. These are in-scope gaps, not deferred acceptance. The consumer gap is separately
ruled out of scope in sage-3620 and recorded in BUILD.md.
## Byte manifest (Dewey M1)
`candidate-manifest.sha256` lists SHA-256 of the git blob bytes of the 24 paths
reviewed for row297, including this MANIFEST.md. It excludes itself to avoid a
self-reference. Its own SHA-256 and the final pushed head are reported together
in Violet's review handoff message. After any byte change, regenerate the list
and report a new checksum before the gate. Inherited row198 paths are excluded.
The three extra mutants from dewey-0944 are required: accept DELETE 401 as
revoked (killer `mutant: closeToken must not count a 401 as proven revocation`);
count 0/5xx as not-issued (lost-response test and `mutant: mint 500/503 is uncertain
issuance and stays serviceLeft`); drop the controller abort check (`mutant:
shutdown abort alone prevents a pending generation from applying`). All kill
evidence remains pending Sage CLEAR.
-69
View File
@@ -1,69 +0,0 @@
# GitHub grant method implementation plan
Goal: implement row297's protected GitHub App grant and renewal lifecycle.
Architecture: a GitHub transport/slot module and event-backed executor, integrated
through the broker's existing human grant gates. No GitHub consumer is added.
Stack: existing Node ESM, node:crypto/node:https/node:fs, append-only bus events.
Spec: DESIGN.md, including sage-3620, dewey-0931 and megavolt-0206.
Execution: Violet inline; Dewey reviews the final manifest. No subagent dispatch.
## Global constraints
Fake HTTPS API and scratch CA only. No credential files, live token, real API call,
Node or test execution before Sage CLEAR. No old-test edits without Mos approval.
Preserve row198 commits; push only violet/*; no amend or force push. No schema
migration for grant state: use reserved grant evidence with the existing actor.
## Review focus
- Lost mint responses remain visible until conservative expiry; never claim undo.
- Revocation racing renewal cannot publish a late token or restore the grant.
- Malformed provider responses cannot authorize a wider token.
- Restart cannot adopt an interrupted generation or changed configuration.
- Refusals and audit records cannot leak RSA/JWT/token values.
## Task 1: configuration, HTTPS transport and admin slot
Files: new bus github-api.mjs and github-files.mjs; modify business role/business
parsing; new business github.test.mjs and bus github-api.test.mjs/fake-github-api.mjs.
Interfaces: checkGrant, binding configuration checks, githubApi({configDir, ca,
transport, now}), adminMeta/setAdmin/removeAdmin/checkAdmin/mint/repos/revoke.
- [ ] Write tests first for HTTPS-only metadata, RSA-only input, App/installation/
account identity, suspension, permission floor and fixed mint response equality.
- [ ] Run red tests on Sage CLEAR; record the exact failure, no invented red claim.
- [ ] Implement strict additive config and slot/transport with no raw response audit.
- [ ] Run new tests green on CLEAR; no old tests changed.
## Task 2: durable executor and renewal
Files: new bus github-grant.mjs; new bus github-grant.test.mjs.
Interfaces: configure({store,business,event,approved,consume,grantable,working});
raiseBinding, run, withToken, revoke, sweep, status, reconcile, idle, close.
The broker supplies private approved/consume/event callbacks, not a worker verb.
- [ ] Write lifecycle/expiry/recovery tests before implementation, including all
R1-R5 cases, revoke races, config drift and lost-response bounds.
- [ ] Run red tests on CLEAR, then implement evidence projection, generation files,
exact reach, expiry-aware guarded use, local-first revoke and retryable cleanup.
- [ ] Verify no untrusted token-returning endpoint and no token in any evidence.
- [ ] Run green and mutant checks on CLEAR; record every unexecuted check honestly.
## Task 3: broker, runtime and CLI integration
Files: broker.mjs, runtime.mjs, cli grants.mjs; new github-broker.test.mjs and cli
github-grants.test.mjs; bus/cli README edits. Existing Gitea behavior stays intact.
- [ ] Write tests first for chief raise, human approval, rejected caller scopes,
admin human-only, run claim, status/revoke/sweep, fire/delete and restart.
- [ ] Dispatch github before existing Gitea branches; connect reserved writer and
consume, automatic run on human yes, reconcile, lifecycle revoke and shutdown.
- [ ] Add method-specific CLI admin flags and RSA stdin; never print secrets.
- [ ] Document fixed scope, workflow/account/fork refusals and consumer gap.
- [ ] Request CLEAR for touched suites and every scripts/test-*.sh; run only after
granted. Present actual diff/numstat, manifest and mutants to Dewey and Sage.
## Acceptance ledger
Static checks can run without Node. All red/green runs, suites, mutants and gate
are currently unexecuted. No success claim is implied by this plan.
@@ -1,24 +0,0 @@
6b54b05eeabaee822ad9ec96e63ba007c6d52b7e66100ab4dc051fa414af103f BUILD-LOG.md
e9dcab5e3125660f177989e6bf726da596149aa237c4f179fa24c13aefc009e1 agents/violet/work/queue-297/BUILD.md
156db930b2e455c7dc8deffdd48fd832d898b32e5c8136f014bd4af84f50f526 agents/violet/work/queue-297/DESIGN.md
99c99d4ca9bdf1397869247c454f45fd0c3fba8fb47b0b9bd424e8532b7d2453 agents/violet/work/queue-297/MANIFEST.md
929d9a5fc28bdcdfd5c7db5cb818186633954d5f60ad2a03e129d062bcb4f725 agents/violet/work/queue-297/PLAN.md
d599b0f0d4c248fba5a32b4f04022ac4676a4cb4029d32d29e0ab1f10ade9162 docs/SESSIONS.md
bed3be579c94c16f6ad840b298f589339e1e0d58e3fca13571e2262a3502aace packages/bus/README.md
e46abbba0514be8bad8c53ec3a0b19133746bb96318c13923d66e9f54c6b2a34 packages/bus/src/broker.mjs
1f09e6a8e3b814a475fb1f7a91bd6ee670e08d7c76399212134e107a2ec4b499 packages/bus/src/github-api.mjs
2f3fce1244446c4be93cdc8ab744de710758bdebb1106e09badd25fa57c355ff packages/bus/src/github-files.mjs
bca3836d62525620d9af372c0c2039e3405579290e8919a1f4f6a35baafee6da packages/bus/src/github-grant.mjs
83a2d294b87e900fcb8357724941be1475ce5ef6afde237a149eb5c138f087b2 packages/bus/src/runtime.mjs
c6e6884471a884663db9446f662669d0977009f6f2f24f2a388e29104c9cb960 packages/bus/tests/fake-github-api.mjs
9e0f43e5b25c3a77d746dbd1b956ff767406548fda2528cab51c8d5b38157e5b packages/bus/tests/github-api.test.mjs
8c78d2ad8aad12632ee40eea8212fd360281bc11b4ae900bf3e38f4618c5d6db packages/bus/tests/github-broker.test.mjs
544f15f07cfaa76394da8618d63573ef91a7c0ebf3f65a6f39339c015e4f96a5 packages/bus/tests/github-grant.test.mjs
87dc329f40f5783a4c2e85c9a1d8c94b6dc4889cdce7191e79af852ab251fac7 packages/business/src/business.mjs
c022a8aa32d84a189b585696b207001ba690660ed1c83c6038be4031703550a4 packages/business/src/instance-settings.mjs
a68518d83a039ecc82ddb4cfbbc6234df8eac384acc78499c94b0d0945a7717b packages/business/src/role.mjs
956981b7ab8f633054831a00133d44a43b5bf9be7c6fd6a326ce20b769c9f822 packages/business/tests/github.test.mjs
445c224530f5545fd4e1e734c7b36c407db6f1b182b6c6ef65e5c9d91fd16ac0 packages/business/tests/role.test.mjs
410b3c3e50c476de39720b0906dcf077739d029e293b444cc6e4a13bb24a9031 packages/cli/src/github-grants.mjs
9fd3589258b83e7cf2c5828811251d0d0a24087a8bded926a44144e3b4a07438 packages/cli/src/grants.mjs
2886fbc1c4c6f0dc22d365e76641f13b7e31f46123b9e7b3e280d2ff79ea52e9 packages/cli/tests/github-grants.test.mjs
+2 -2
View File
@@ -1593,5 +1593,5 @@ are never rewritten or removed; corrections are new entries.
2026-10-23T17:48:43Z | Scrooge (629b6cd3) | Row 252 Console inbox Approve and Deny (#1753) | Rebased onto 306 landing as 580037105 (range-diff all =), slot run bus 907/0 webui 410/0 cli 943/0, in-review revs 3608/3609, Webby r1 APPROVE, J1-J19 specs to Sage; Sage landed 45ea3fa0d (done rev 3616). Part 1 mutant results were never in BUILD.md; they went to Sage by T3 (scrooge-0252-mutant-specs).
2026-10-23 | Sage (1ef1e4f8) | Rows 308 and 252 landings, 571 Connections design amendment, Vikunja 597 | 308 (PRD 1.17, #1815) landed 30e6bb680 and 252 (#1753) landed 45ea3fa0d (replay of 580037105 onto 30e6bb680, range-diff all =), each after a full gate on the landed tree (252 also J1-J19, J11c and J18 equivalent); Gyro's 571 amendment (Fenton APPROVE) landed e8bb13df8; Vikunja 570, 435 and 597 done, canonical webui esbuild 0.28.2; queue revs 3589-3616 committed 59f013247
2026-10-23 | Sage (1ef1e4f8) | Vikunja 571 W build rows; rows 13, 42, 257 and 289 check | Filed the design's ten W rows as queue rows 309-318 (revs 3617-3626, owners set to unassigned 3627-3636, queue commit 293b42fee pushed); Vikunja 571 done (comment 994). Row 13's candidate fd72d2689 is on refactor, gate is Monday's row 7 ledger run; 42 paused under mos-1184; 257 post-v1; 289 waits on 214 and 288. Slot released 17:56Z.
2026-10-23 | Violet (6229eb89, Codex gpt-6.1-sol, mos-1498/Q245) | Row 297 GitHub grant method #1803 | Registered in violet/row297; merge-chained row198 14fe7513c then 4d8123b0a unchanged. Q-B3 S1-S6 proposal prepared for Dewey/Megavolt and Sage; no product code, credentials, node or tests; shared runs require CLEAR.
2026-10-23 | Violet (6229eb89) | Row297 #1803 protected GitHub grant candidate | Q-B3 read by Dewey/Megavolt, sage-3620 scope applied. Candidate and fake HTTPS tests written, no Node/tests run; one Mos-authorized old-test regex 1/1 (mos-1518). Review manifest and consumer gap recorded. Verification/gate remain pending Sage CLEAR; no acceptance or landing claim.
2026-10-23 | Fethry (Codex gpt-6.1-sol, 14f80e3c) | Row 225 C2-10b (#1726), Q245 handoff | Recovered Morgana's 9a131e956 by fast-forward on fethry/row225 in the assigned T3 worktree. Static review and contract confirmation in progress; all Node/tests require Sage CLEAR. No acceptance claim.
2026-10-23 | Fethry (14f80e3c) | Row225 T1 lane A sage-3641/3647 (#1726) | Continued Morgana handoff, fixed nested status limits and wrong-project revert receipt after RED, kept invalid-mapping diagnosis visible with RED/GREEN. Renderer9/0, routes13/0, browser9/0, full library81/0, channel-proof2/0; isolated mutants14/14, restored SHA256 checks and clean worktree sources. Old-test approval mos1510 preserved80/66 and3/1. Slot returned with no processes around19:29Z. Dewey source-review packet prepared; full webui/build and integration gate remain pending.
-1
View File
@@ -586,4 +586,3 @@ git history + Gitea issues.
- 2026-09-26 (sage, lead): Jason's ruling: Sage leads the project, Darkwing is a collaborating seat, more seats to follow, development stays in T3 for now. The fleet Sage seat that took Invoice Ninja coordination outside Jason's instructions is being decommissioned; moving work onto the new stack is the fix. Suites all green. Rows 24 and 25 pushed (1685deb4..43d7574d). Open: about 11 days of uncommitted agent work in the tree (row 16 launch files, row 22, #1512, WUI evidence), 23 untracked aws-* skill directories in skills/ from 2026-09-21, and Jason's target for the next phase.
- 2026-10-13 (sage, lead): Jason's PM re-test on 664199f7 is finished (Mos; PM run rcc0f72be8b17; Vikunja #228). Paging, the Board's held roles, the Messages page and the #229 loop (coder push 010d30cb, review, approve, PM close) all worked; #230 and #231 are done, #232 stays open for the secrets-path probe. Two faults checked and dropped: the reviewer did run the tests before its verdict, and the PM's clone did hold the row 95 Board code. Two filed: row 104 (#1598) seat git identity, row 105 (#1599) a review request says whether a reviewer is running. Rows 96, 101-105 briefed; row 85 released to build. Row 85's live steps, any bus restart and row 102's public/ change each need Mos's go. Row 100 waits on Jason (Vikunja #257). Formatting, Jason's direction: no formatting row now. Chat output will be formatted in the TUI and WebUI the way chat interfaces usually do it, made our own; T3 Code's chat is his example. Carry it into the TUI and WebUI chat work.
- 2026-10-22 (bushroot, row 198 r3): the board runs in-process in the Console (webui, port 7332); control-board `serve` is retired and exits 2, and the Console refuses `--board`. Docs: packages/webui/README.md and packages/control-board/README.md now give the Console start and drop the 7331 serve text. Built on bushroot/row198-r3 from aca16404c; not run, awaiting Sage's CLEAR.
-56
View File
@@ -1071,59 +1071,3 @@ TERM concurrently, then KILL after five seconds and observe for two more.
Unknown targets remain visible and prevent resume. Existing run evidence is
not synthesized or rewritten. First activation stops old processing before
trusted initialization and aligns the copy and image in the human's window.
### GitHub App grants (row 297)
The `github` method uses the same chief raise, human Approve, reserved `grant.call`
then `grant.result`, and local-first revoke boundary as Gitea. Its tokens expire
within an hour, so the durable grant authorizes protected renewal up to its
`rotateBy` day. Every generation remains bound to the original configuration.
A changed definition, allowed-project list, repository ID/remote or admin metadata
requires another human decision. No account or fork is created.
A role definition declares `{ "service": "github" }` without scopes. Its business
role names `"github": { "projects": ["app"] }`, and that project supplies its HTTPS
`remote` and positive integer `githubId`. No generic `credentials.github` reference
is accepted. Code fixes the token permissions to `contents: write, metadata: read`,
with exact selected repository IDs. Workflow-file writes (`.github/workflows/`),
PR/issue writes and administration are excluded. A workflow push refusal is intended.
Jason alone sets the App private key, through bounded stdin, after creating and
installing the App. Agents do not perform this live step:
```sh
mosaic grants admin set github --url https://api.github.com --app-id APP_ID \
--installation-id INSTALLATION_ID --account-id ACCOUNT_ID --rotate-by YYYY-MM-DD --business BUSINESS
mosaic grants status github --business BUSINESS
mosaic grants revoke AGENT github --reason REASON --business BUSINESS
mosaic grants sweep github --business BUSINESS
```
Admin rotation takes a replacement RSA private key on stdin with `admin rotate
github --rotate-by DATE`. It preserves App/installation/account/origin identity and
invalidates old bindings. GitHub App key creation and deletion remain Jason's own
GitHub administration step. Admin remove removes the local slot; it does not claim
to delete the App or remotely revoke its private keys.
Only trusted protected code can call `githubWithToken(business, agent, fn)`. It
checks approval/configuration/expiry, renews when required, and passes the token
and cancellation signal to that trusted callback. No socket verb returns a token
or exposes renewal. Tokens stay in private generation files outside run evidence;
RSA keys, JWTs and token values are never audit fields. Revoke blocks use and
renewal before its remote undo. An interrupted mint with no recoverable token value
stays visible in `serviceLeft` until its conservative expiry bound; a new token
does not hide it. `sweep` retries known tokens and records expiry cleanup.
Consumer gap (sage-3620): this row supplies the protected method and renewal
function, but adds no GitHub mirror push, publication or credential helper. The
existing Gitea credential path has day-level dates and Gitea fork identity; those
cannot safely carry a one-hour GitHub token. GitHub publication therefore needs a
separate consumer row and is not enabled by approving this method.
`sweep` also closes an active generation's private file after its conservative
expiry bound, recording reason `expired` while keeping the durable grant. A
later guarded use renews from the same unchanged approval. Before that cleanup,
status can show an expired generation; its timestamp guard still refuses use.
Removing the admin slot while grants/residue exist invalidates renewal and stops
remote cleanup until a matching slot is restored or the conservative bound
elapses. Remove does not claim remote revocation or delete the App's keys.
+2 -110
View File
@@ -415,7 +415,6 @@ export class Broker {
// Row 236 (#436b): giteaGrant and giteaKeys from gitea-grant.mjs, or null, and the remote halves of
// section 5's restart rows and of revokes a restart cut short, for reconcileGitea.
#giteaGrant;
#githubGrant;
#giteaKeys;
#giteaUndo = [];
// Row 281 (#1786, d6 section 3b): each tracked Gitea promise and the business/agent it acts for, or
@@ -440,7 +439,7 @@ export class Broker {
#clock = 0;
#processing;
// credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use().
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, roleFile = null, giteaGrant = null, giteaKeys = null, githubGrant = null, access = null, accessStamp = null, compactions = null }) {
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, roleFile = null, giteaGrant = null, giteaKeys = null, access = null, accessStamp = null, compactions = null }) {
if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business');
// Row 194 b15 (sage-2501): the function-argument checks come before anything reads the store.
if (harnessOn !== null && typeof harnessOn !== 'function') throw new TypeError('harnessOn must be a function');
@@ -467,16 +466,6 @@ export class Broker {
this.#hostKeys = hostKeys;
this.#giteaGrant = giteaGrant;
this.#giteaKeys = giteaKeys;
this.#githubGrant = githubGrant;
githubGrant?.configure({
store,
business: (business) => this.#business(business),
event: (business, body) => store.transaction(() => this.#grantEvent(business, body, body.agent ?? null)),
approved: (business, decision) => this.#approvedGrant(business, decision),
consume: (business, decision) => store.transaction(() => this.#consumeGrant(business, decision, 'github')),
grantable: (business, agent) => this.#grantableAgent(business, agent),
working: () => { this.#processingWork(); return true; },
});
this.#access = access;
this.#accessStamp = accessStamp;
this.#compactions = compactions;
@@ -2034,16 +2023,6 @@ export class Broker {
for (const m of this.#openRevokes(business)) this.#revokeUndo.push({ business, ...m });
// Row 236 (#436b r5 section 5): the Gitea grants' local part.
if (this.#giteaGrant && this.#giteaKeys) ended.push(...this.#giteaReconcileLocal(business));
if (this.#githubGrant) {
this.#githubGrant.reconcile(business);
for (const decision of this.#openGrantDecisions(business, 'github')) {
this.#store.transaction(() => {
const g = this.#consumeGrant(business, decision, 'github');
this.#grantEvent(business, { action: 'grant.failed', method: 'github', grantDecision: decision, agent: g.agent, code: 'interrupted' }, g.agent);
});
ended.push(decision);
}
}
this.#interruptedJobs(business);
}
return ended;
@@ -2223,8 +2202,6 @@ export class Broker {
for (const d of open) if (d.grant.method === 'host') hosts.add(d.grant.host);
for (const host of [...hosts].sort()) set.push({ label: 'host:' + host, kind: 'host', host });
if (this.#giteaPending(business, agent, open)) set.push({ label: 'gitea', kind: 'gitea' });
if (this.#githubGrant && (open.some((d) => d.grant.method === 'github') || this.#githubGrant.pending(business, agent)))
set.push({ label: 'github', kind: 'github' });
if (this.#hostGrant)
for (const { host } of this.#store.all('SELECT host FROM hosts WHERE business=? ORDER BY host', business)) {
if (hosts.has(host)) continue;
@@ -2243,10 +2220,9 @@ export class Broker {
.map((d) => ({ id: d.id, raise: this.#raised(business, d.id) }))
.filter(({ id, raise }) => {
const g = raise?.grant;
if (g?.agent !== agent || (g.method !== 'host' && g.method !== 'gitea' && g.method !== 'github')) return false;
if (g?.agent !== agent || (g.method !== 'host' && g.method !== 'gitea')) return false;
const r = this.#closed(id);
if (r && !(r.op === 'resolved' && r.choice === raise.approvalChoice)) return false;
if (g.method === 'github') return !this.#spent(business, id) && !this.#githubGrant?.decisionRevoked(business, g);
if (
this.#store.get(
`SELECT 1 FROM events WHERE business=? AND ${GRANT_EVIDENCE} AND json_extract(body,'$.grantDecision')=? AND json_extract(body,'$.action') IN ('grant.applied','grant.failed') LIMIT 1`,
@@ -2335,12 +2311,6 @@ export class Broker {
at = 'gitea';
grants.push(...(await this.#stepGGitea(business, agent, { job, reason, by, flag }, refuse)));
}
if (set.some((x) => x.kind === 'github')) {
at = 'github';
const result = await this.#githubGrant.revoke(business, agent, reason, by);
if (result.serviceLeft.length) refuse('github', 'github-left');
grants.push({ grant: 'github', granted: true });
}
for (const g of set.filter((x) => x.kind === 'line')) {
at = g.label;
const record = this.#hostRecord(business, g.host);
@@ -2590,15 +2560,6 @@ export class Broker {
this.#store.transaction(() => this.#agent(s));
if (a.action !== 'credential.mint' || 'spec_path' in a || 'spec_commit' in a) fail('invalid-request');
if (this.#business(s.business).roles[s.role]?.definition !== STAFF_TO) fail('grant-chief-only');
// Row297: the same chief/human grant gate, with a method-specific protected executor.
if (a.grant?.method === 'github') {
if (!this.#githubGrant) fail('github-not-configured');
return this.#store.transaction(() => {
const result = this.#dispatch(s, 'decision.raise', a, null, this.#githubGrant.raiseBinding(s.business, a.grant));
this.#secretCheck(result);
return result;
});
}
// Row 236 (#436b section 2): no network call at raise, so every check runs in the raise transaction.
if (a.grant?.method === 'gitea') {
if (!this.#giteaGrant) fail('gitea-not-configured');
@@ -2861,7 +2822,6 @@ export class Broker {
async grantsIdle() {
while (this.#grantJobs.size || this.#giteaRuns.size || this.#hostRuns.size)
await Promise.allSettled([...this.#grantJobs, ...this.#giteaRuns.keys(), ...[...this.#hostRuns.values()].flatMap((s) => [...s])]);
await this.#githubGrant?.idle();
}
// Section 4 steps 0 (the remote half) to 8. Never throws: the outcome is in the store and the
// notices, and the return value is 'applied', 'settle-pending' or the code grant.failed records.
@@ -3589,75 +3549,10 @@ export class Broker {
if (this.#business(s.business).roles[s.role]?.definition !== STAFF_TO) fail('grant-chief-only');
return s.role;
}
// Row297 trusted host API. No socket verb returns tokens or renews grants.
githubWithToken(business, agent, fn) {
if (!this.#githubGrant) fail('github-not-configured');
return this.#githubGrant.withToken(business, agent, fn);
}
async reconcileGithub() {
if (!this.#githubGrant) return;
for (const business of Object.keys(this.#businesses))
if (this.#githubGrant.status(business).serviceLeft.length) await this.#githubGrant.sweep(business, 'restart');
}
async closeGithub() { await this.#githubGrant?.close(); }
#startGithub(business, decision) {
if (!this.#githubGrant) fail('github-not-configured');
const g = this.#approvedGrant(business, decision);
if (g?.method !== 'github') fail('not-authorized');
const job = this.#track(this.#githubGrant.run(business, decision), business + '/' + g.agent);
job.then(
() => this.#store.transaction(() => this.#grantNotice(business, decision, `GitHub grant to ${g.agent} applied; protected renewal stays within the approved binding.`)),
(e) => this.#store.transaction(() => this.#grantNotice(business, decision, `GitHub grant to ${g.agent} failed: ${e instanceof BusError ? e.code : 'github-failed'}.`)),
).catch(() => {});
return job;
}
async #requestGithub(s, verb, a, by) {
if (!this.#githubGrant) fail('github-not-configured');
const manager = this.#githubGrant;
if (verb === 'grants.status' || verb === 'grants.sweep') {
keys(a, ['service'], ['service']);
return verb === 'grants.status' ? manager.status(s.business) : manager.sweep(s.business, by);
}
if (verb === 'grants.revoke') {
keys(a, ['service', 'agent', 'reason'], ['service', 'agent', 'reason']);
identifier(a.agent); string(a.reason, 500);
if (!Object.hasOwn(this.#business(s.business).roles, a.agent)) fail('credential-unknown');
if (this.#revoking.has(s.business + '/' + a.agent)) fail('grant-revoke-running');
return manager.revoke(s.business, a.agent, a.reason, by);
}
if (verb === 'grants.admin.remove') {
keys(a, ['service'], ['service']);
manager.api.removeAdmin(s.business);
this.#store.transaction(() => this.#grantEvent(s.business, { action: 'grant.admin', method: 'github', verb: 'remove', by }));
return { removed: true };
}
const replace = verb === 'grants.admin.rotate';
keys(a, replace ? ['service', 'rotateBy', 'token'] : ['service', 'url', 'appId', 'installationId', 'accountId', 'rotateBy', 'token'],
replace ? ['service', 'rotateBy', 'token'] : ['service', 'url', 'appId', 'installationId', 'accountId', 'rotateBy', 'token']);
const previous = replace ? manager.api.adminMeta(s.business) : null;
if (replace && !previous) fail('grant-admin-missing');
const meta = { url: replace ? previous.url : a.url, appId: replace ? previous.appId : a.appId,
installationId: replace ? previous.installationId : a.installationId, accountId: replace ? previous.accountId : a.accountId,
rotateBy: a.rotateBy, setAt: this.#now() };
const voided = this.#openGrantDecisions(s.business, 'github').length;
await manager.api.setAdmin(s.business, meta, a.token, manager.audit(s.business, 'admin:' + by), { replace });
this.#store.transaction(() => this.#grantEvent(s.business, { action: 'grant.admin', method: 'github', verb: replace ? 'rotate' : 'set', ...meta, voided, by }));
return { ...meta, voided };
}
// Row 236: the grants verbs. Async, because each one may call Gitea.
async #requestGrants(s, verb, a) {
try {
const by = this.#grantsCaller(s, verb);
if (verb === 'grants.run') {
keys(a, ['decision'], ['decision']); identifier(a.decision);
}
if (verb === 'grants.run' && this.#approvedGrant(s.business, a.decision)?.method === 'github') {
if (this.#spent(s.business, a.decision)) fail('decision-consumed');
const g = this.#approvedGrant(s.business, a.decision);
this.#startGithub(s.business, a.decision).catch(() => {});
return { decision: a.decision, agent: g.agent, started: true };
}
if (a.service === 'github') return await this.#requestGithub(s, verb, a, by);
if (!this.#giteaGrant || !this.#giteaKeys) fail('gitea-not-configured');
if (verb === 'grants.run') {
keys(a, ['decision'], ['decision']);
@@ -4898,8 +4793,6 @@ export class Broker {
throw new BusError('outcome-unknown');
}
this.#secretCheck(result);
if (verb === 'credentials.delete' && this.#githubGrant?.pending(s.business, a.agent))
this.#track(this.#githubGrant.revoke(s.business, a.agent, a.reason, s.tester ? 'tester' : s.human), s.business + '/' + a.agent).catch(() => {});
// Row 236 (#436b section 6): a gitea revoke, or a fire, of an agent this stack made a Gitea user for
// takes the grant away too. Credentials has done step 1. Step 2 runs here; step 3 and the fire's
// steps run once this returns.
@@ -5764,7 +5657,6 @@ export class Broker {
if (grant?.method === 'host-enroll') setImmediate(() => this.#runEnrollment(s.business, d.id));
if (grant?.method === 'host') setImmediate(() => this.#runHostGrant(s.business, d.id));
if (grant?.method === 'gitea') setImmediate(() => this.#startGitea(s.business, d.id));
if (grant?.method === 'github') setImmediate(() => Promise.resolve().then(() => this.#startGithub(s.business, d.id)).catch(() => {}));
// Row 115 (#1609): a close by anyone but the raising run tells the raising role, routed like message.send's.
// Row 133 (#1627): no messaging check here. decision.raise refused a route that can't message the
// raiser, definitions change only with a pinned copy and arbiters only through the business
-143
View File
@@ -1,143 +0,0 @@
// Row297 GitHub App HTTPS boundary. No PATs, user creation or fork calls.
import { createPrivateKey, sign } from 'node:crypto';
import { request } from 'node:https';
import { BusError } from './broker.mjs';
import { day } from './credentials.mjs';
import { githubDirectory, githubRead, githubWrite, githubUnlink } from './github-files.mjs';
export const FIXED_PERMISSIONS = Object.freeze({ contents: 'write', metadata: 'read' });
export const API_VERSION = '2026-03-10';
export const CALL_TIMEOUT_MS = 15000;
const META_KEYS = ['accountId', 'appId', 'installationId', 'rotateBy', 'setAt', 'url'];
const id = (v) => Number.isSafeInteger(v) && v > 0;
const fail = (code) => { throw new BusError(code); };
export function githubOrigin(value) {
try {
const url = new URL(value);
if (url.protocol !== 'https:' || url.username || url.password || value !== url.origin) fail('invalid-request');
return url.origin;
} catch { fail('invalid-request'); }
}
export function githubMeta(meta) {
if (!meta || typeof meta !== 'object' || Array.isArray(meta) || Object.keys(meta).sort().join() !== META_KEYS.join()) fail('invalid-request');
githubOrigin(meta.url);
if (![meta.appId, meta.installationId, meta.accountId].every(id) || !day(meta.rotateBy) ||
typeof meta.setAt !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(meta.setAt) || !Number.isFinite(Date.parse(meta.setAt)) || new Date(meta.setAt).toISOString() !== meta.setAt) fail('invalid-request');
return { ...meta };
}
export function githubRsa(text) {
try {
if (typeof text !== 'string' || text.length > 16384 || !/^-----BEGIN (RSA )?PRIVATE KEY-----\n/.test(text)) fail('credential-format');
const key = createPrivateKey(text);
if (key.asymmetricKeyType !== 'rsa' || key.asymmetricKeyDetails.modulusLength < 2048) fail('credential-format');
return key;
} catch { fail('credential-format'); }
}
export function githubJwt(key, appId, now) {
const encode = (value) => Buffer.from(JSON.stringify(value)).toString('base64url');
const secs = Math.floor(now / 1000);
const input = encode({ alg: 'RS256', typ: 'JWT' }) + '.' + encode({ iat: secs - 60, exp: secs + 540, iss: String(appId) });
return input + '.' + sign('RSA-SHA256', Buffer.from(input), key).toString('base64url');
}
// Never follows redirects or returns headers/error text. Body limit and whole-call timeout.
export function sendGithub({ url, method, path, token, body, ca, timeoutMs = CALL_TIMEOUT_MS }) {
return new Promise((resolve) => {
let req, ended = false;
const end = (status = 0, json = null) => {
if (ended) return;
ended = true; clearTimeout(timer); resolve({ status, json });
};
const timer = setTimeout(() => { req?.destroy(); end(); }, timeoutMs);
try {
githubOrigin(url);
if (!path.startsWith('/') || path.startsWith('//')) fail('invalid-request');
const data = body === undefined ? null : Buffer.from(JSON.stringify(body));
req = request(new URL(path, url), { method, ca, agent: false, headers: {
accept: 'application/vnd.github+json', authorization: 'Bearer ' + token,
'x-github-api-version': API_VERSION, 'user-agent': 'Mosaic-grant',
...(data ? { 'content-type': 'application/json', 'content-length': String(data.length) } : {}),
} }, (res) => {
let size = 0; const chunks = [];
res.on('data', (part) => {
size += part.length;
if (size > 4 * 1024 * 1024) { req.destroy(); end(); }
else chunks.push(part);
});
res.on('end', () => {
let json = null;
try { json = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch {}
end(res.statusCode, json);
});
res.on('error', () => end());
});
req.on('error', () => end()); req.end(data ?? undefined);
} catch { end(); }
});
}
export function githubApi({ configDir, ca, transport = sendGithub, now = () => Date.now(), timeoutMs = CALL_TIMEOUT_MS }) {
const directory = (business, create = false) => githubDirectory(configDir, ['grants', business, 'github'], create);
const adminMeta = (business) => {
const text = githubRead(directory(business), 'admin.json');
if (text === null) return null;
try { return githubMeta(JSON.parse(text)); } catch { fail('credential-file'); }
};
const call = async (meta, key, log, method, path, { token, body, installation = false } = {}) => {
if (typeof log !== 'function') fail('not-configured');
if (installation && (typeof token !== 'string' || token.length < 16 || token.length > 16384 || !/^[-A-Za-z0-9._~]+$/.test(token))) fail('credential-format');
const authorization = installation ? token : githubJwt(key, meta.appId, now());
return log({ method, path }, () => transport({ url: meta.url, method, path, token: authorization, body, ca, timeoutMs }));
};
const check = async (meta, key, log) => {
if (meta.rotateBy <= new Date(now()).toISOString().slice(0, 10)) fail('grant-admin-rotation-due');
const app = await call(meta, key, log, 'GET', '/app');
if ([401, 403].includes(app.status)) fail('grant-admin-account');
if (app.status !== 200) fail('github-failed');
if (app.json?.id !== meta.appId) fail('grant-admin-account');
const install = await call(meta, key, log, 'GET', `/app/installations/${meta.installationId}`);
if (install.status !== 200) fail([401, 403, 404].includes(install.status) ? 'grant-admin-account' : 'github-failed');
const i = install.json;
if (i?.id !== meta.installationId || i.app_id !== meta.appId || i.account?.id !== meta.accountId || i.suspended_at !== null) fail('grant-admin-account');
if (!i.permissions || !Object.entries(FIXED_PERMISSIONS).every(([k, v]) => i.permissions[k] === v || (v === 'read' && i.permissions[k] === 'write'))) fail('grant-admin-scope');
};
const admin = (business) => {
const meta = adminMeta(business);
if (!meta) fail('grant-admin-missing');
const key = githubRsa(githubRead(directory(business), 'admin'));
return { meta, key };
};
return {
adminMeta,
async setAdmin(business, input, text, log, { replace = false } = {}) {
const meta = githubMeta(input); const key = githubRsa(text);
const old = adminMeta(business);
if (!replace && (old || githubRead(directory(business), 'admin') !== null)) fail('grant-admin-exists');
if (replace && (!old || ['url', 'appId', 'installationId', 'accountId'].some((k) => old[k] !== meta[k]))) fail('grant-admin-account');
await check(meta, key, log);
const dir = directory(business, true);
// Rotation invalidates approved bindings before replacing the signing key.
if (replace) githubWrite(dir, 'admin.json', JSON.stringify(meta) + '\n');
githubWrite(dir, 'admin', text.replace(/\n?$/, '\n'));
if (!replace) githubWrite(dir, 'admin.json', JSON.stringify(meta) + '\n');
return meta;
},
removeAdmin(business) {
const dir = directory(business);
githubUnlink(dir, 'admin.json'); githubUnlink(dir, 'admin');
},
async checkAdmin(business, log) { const { meta, key } = admin(business); await check(meta, key, log); return meta; },
mint(business, repositoryIds, log) {
const { meta, key } = admin(business);
return call(meta, key, log, 'POST', `/app/installations/${meta.installationId}/access_tokens`,
{ body: { repository_ids: repositoryIds, permissions: { ...FIXED_PERMISSIONS } } });
},
repos(business, token, page, log) {
const { meta, key } = admin(business);
return call(meta, key, log, 'GET', `/installation/repositories?per_page=30&page=${page}`, { token, installation: true });
},
revoke(business, token, log) {
const { meta, key } = admin(business);
return call(meta, key, log, 'DELETE', '/installation/token', { token, installation: true });
},
};
}
-64
View File
@@ -1,64 +0,0 @@
// Row297 protected files. Values never enter the bus event store.
import { closeSync, constants, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeSync } from 'node:fs';
import { join } from 'node:path';
import { randomUUID } from 'node:crypto';
import { BusError } from './broker.mjs';
import { privateDir } from './credentials.mjs';
const fail = () => { throw new BusError('credential-file'); };
const checkName = (name) => { if (typeof name !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9.-]*$/.test(name) || name === '..') fail(); };
export function githubDirectory(configDir, parts, create = false) {
let dir = configDir;
for (const part of ['secrets', ...parts]) {
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(part) || part === '..') fail();
dir = join(dir, part);
if (!privateDir(dir, { missing: true })) {
if (!create) return null;
mkdirSync(dir, { mode: 0o700 });
}
privateDir(dir);
}
return dir;
}
export function githubRead(dir, name) {
checkName(name);
if (!dir) return null;
let fd;
try { fd = openSync(join(dir, name), constants.O_RDONLY | constants.O_NOFOLLOW); }
catch (e) { if (e.code === 'ENOENT') return null; fail(); }
try {
const stat = fstatSync(fd);
if (!stat.isFile() || stat.uid !== process.getuid() || (stat.mode & 0o777) !== 0o600 || stat.size > 65536) fail();
return readFileSync(fd, 'utf8');
} catch (e) { if (e instanceof BusError) throw e; fail(); }
finally { closeSync(fd); }
}
export function githubWrite(dir, name, text) {
checkName(name);
const target = join(dir, name);
// A foreign/symlink existing file is a refusal, never something to repair silently.
try {
const stat = lstatSync(target);
if (!stat.isFile() || stat.uid !== process.getuid() || (stat.mode & 0o777) !== 0o600) fail();
} catch (e) { if (e.code !== 'ENOENT') throw e; }
const tmp = target + '.' + randomUUID() + '.tmp';
let fd;
try {
fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
writeSync(fd, text); fsyncSync(fd); closeSync(fd); fd = undefined;
renameSync(tmp, target);
const parent = openSync(dir, constants.O_RDONLY);
try { fsyncSync(parent); } finally { closeSync(parent); }
} finally {
if (fd !== undefined) closeSync(fd);
try { unlinkSync(tmp); } catch (e) { if (e.code !== 'ENOENT') throw e; }
}
}
export function githubUnlink(dir, name) {
if (!dir) return;
// Apply the same checks before deleting any protected file.
if (githubRead(dir, name) === null) return;
unlinkSync(join(dir, name));
const fd = openSync(dir, constants.O_RDONLY);
try { fsyncSync(fd); } finally { closeSync(fd); }
}
-298
View File
@@ -1,298 +0,0 @@
// Row297: protected, event-backed GitHub grants. No worker-facing token or renewal verb.
import { createHash, randomUUID } from 'node:crypto';
import { BusError, GRANT_EVIDENCE } from './broker.mjs';
import { canonicalJson } from '../../business/src/util.mjs';
import { CREATIVE_DEFINITIONS, ID_PATTERN } from '../../business/src/vocabulary.mjs';
import { day } from './credentials.mjs';
import { FIXED_PERMISSIONS, CALL_TIMEOUT_MS } from './github-api.mjs';
import { githubDirectory, githubRead, githubWrite, githubUnlink } from './github-files.mjs';
export { CALL_TIMEOUT_MS };
export const TOKEN_LIFETIME_MS = 3600000;
export const CLOCK_SKEW_MS = 60000;
export const TOKEN_MARGIN_MS = 60000;
const PAGE_SIZE = 30;
const fail = (code) => { throw new BusError(code); };
const hash = (v) => createHash('sha256').update(canonicalJson(v)).digest('hex');
const isId = (v) => Number.isSafeInteger(v) && v > 0;
const object = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
const exact = (v, keys) => object(v) && Object.keys(v).sort().join() === [...keys].sort().join();
const tokenText = (v) => typeof v === 'string' && v.length >= 16 && v.length <= 16384 && /^[-A-Za-z0-9._~]+$/.test(v);
const sameIds = (rows, bound) => Array.isArray(rows) && rows.length === bound.length &&
rows.every((r) => isId(r?.id)) && new Set(rows.map((r) => r.id)).size === rows.length &&
rows.every((r) => bound.some((b) => b.id === r.id));
const identity = (m) => Object.fromEntries(['url', 'appId', 'installationId', 'accountId'].map((k) => [k, m[k]]));
const refusal = (e) => e instanceof BusError ? e.code : 'github-failed';
export function githubGrant({ api, configDir, now = () => Date.now(), timeoutMs = CALL_TIMEOUT_MS }) {
let context = null;
const claims = new Map();
const jobs = new Set();
const key = (b, a) => b + '/' + a;
const at = () => new Date(now()).toISOString();
const ctx = () => context ?? fail('github-not-configured');
const event = (business, body) => ctx().event(business, { ...body, method: 'github' });
const rows = (business) => ctx().store.all(
`SELECT seq,body FROM events WHERE business=? AND ${GRANT_EVIDENCE} AND (json_extract(body,'$.method')='github' OR json_extract(body,'$.operation')='grant.github') ORDER BY seq`, business,
).map((r) => ({ seq: r.seq, ...JSON.parse(r.body) }));
const state = (business) => {
const entries = rows(business), grants = new Map(), intents = new Map(), closed = new Set(), revoked = new Map();
for (const e of entries) {
if (['grant.applied', 'grant.renewed'].includes(e.action)) grants.set(e.agent, e);
if (e.action === 'grant.revoked') { grants.delete(e.agent); revoked.set(e.agent, e.seq); }
if (e.action === 'grant.step' && e.step === 'mint') intents.set(e.generation, e);
if (e.action === 'grant.token.closed') closed.add(e.generation);
}
return { entries, grants, intents, closed, revoked };
};
const log = (business, grantDecision, agent = null, generation = null) => async (op, fn) => {
const callId = randomUUID();
event(business, { action: 'grant.call', grantDecision, agent, generation, callId, op });
let result;
try { result = await fn(); } catch { result = { status: 0, json: null }; }
// Deliberately never include the response body or authorization header.
event(business, { action: 'grant.result', grantDecision, agent, generation, callId, status: result.status });
return result;
};
const tracked = (fn) => {
const job = (async () => fn())(); jobs.add(job);
job.finally(() => jobs.delete(job)).catch(() => {});
return job;
};
const claim = (business, agent) => {
const k = key(business, agent);
if (claims.has(k)) fail('grant-busy');
const controller = new AbortController(); claims.set(k, controller);
return controller;
};
const release = (business, agent, controller) => {
if (claims.get(key(business, agent)) === controller) claims.delete(key(business, agent));
};
const generationDir = (business, agent, create = false) => githubDirectory(configDir, ['grants', business, 'github', 'tokens', agent], create);
const readToken = (business, intent) => {
const text = githubRead(generationDir(business, intent.agent), intent.generation);
if (text === null) return null;
if (!tokenText(text.trimEnd()) || text !== text.trimEnd() + '\n') fail('credential-format');
return text.trimEnd();
};
const roleSnapshot = (business, agent) => {
if (typeof agent !== 'string' || !ID_PATTERN.test(agent)) fail('invalid-request');
const b = ctx().business(business);
const role = Object.hasOwn(b.roles ?? {}, agent) ? b.roles[agent] : null;
const definition = role && Object.hasOwn(b.definitions ?? {}, role.definition) ? b.definitions[role.definition] : null;
if (!definition?.credentials?.some((c) => exact(c, ['service']) && c.service === 'github') ||
CREATIVE_DEFINITIONS.includes(role.definition) || !exact(role.github, ['projects']) ||
!Array.isArray(role.github.projects) || !role.github.projects.length || role.github.projects.some((p) => typeof p !== 'string' || !ID_PATTERN.test(p)) ||
new Set(role.github.projects).size !== role.github.projects.length) fail('github-not-declared');
const meta = api.adminMeta(business);
if (!meta) fail('grant-admin-missing');
if (meta.rotateBy <= at().slice(0, 10)) fail('grant-admin-rotation-due');
const origin = meta.url === 'https://api.github.com' ? 'https://github.com' : meta.url;
const repositories = role.github.projects.map((project) => {
const entry = b.projects?.[project];
if (!isId(entry?.githubId) || typeof entry.remote !== 'string' || entry.kind === 'library') fail('remote-not-granted');
let url;
try { url = new URL(entry.remote); } catch { fail('remote-not-granted'); }
if (url.origin !== origin || url.protocol !== 'https:' || url.username || url.password || url.search || url.hash ||
!/^\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(url.pathname) || entry.remote !== url.origin + url.pathname) fail('remote-host-mismatch');
const name = url.pathname.slice(1).replace(/\.git$/, '');
if (name.split('/').some((p) => p === '.' || p === '..' || p === '')) fail('remote-not-granted');
return { project, id: entry.githubId, name, remote: entry.remote };
});
if (new Set(repositories.map((r) => r.id)).size !== repositories.length) fail('remote-not-granted');
return { meta, repositories, configSha256: hash({ definition, github: role.github, repositories }) };
};
const binding = (business, grant) => {
if (!exact(grant, ['method', 'agent', 'rotateBy', 'target']) || grant.method !== 'github' ||
!exact(grant.target, ['projects']) || !Array.isArray(grant.target.projects) || grant.target.projects.length < 1 ||
grant.target.projects.length > 50 || grant.target.projects.some((p) => typeof p !== 'string' || !ID_PATTERN.test(p)) ||
new Set(grant.target.projects).size !== grant.target.projects.length || !day(grant.rotateBy) || grant.rotateBy <= at().slice(0, 10)) fail('invalid-request');
ctx().grantable(business, grant.agent);
const snap = roleSnapshot(business, grant.agent);
const repositories = grant.target.projects.map((project) => snap.repositories.find((r) => r.project === project) ?? fail('remote-not-granted'));
const body = { ...structuredClone(grant), repositories, configSha256: snap.configSha256,
adminSha256: hash(snap.meta), admin: identity(snap.meta), revocationSeq: state(business).revoked.get(grant.agent) ?? 0 };
return { body, block: `\n\nGrant ${grant.agent} GitHub App ${snap.meta.appId}, installation ${snap.meta.installationId}, account ${snap.meta.accountId}.` +
repositories.map((r) => `\nRepository ${r.project}: ${r.remote} (id ${r.id}).`).join('') +
`\nPermissions: contents:write, metadata:read. Protected renewal until ${grant.rotateBy}.` +
'\nNo workflow-file writes (.github/workflows/), account creation or fork creation. No GitHub publication consumer is installed by this grant.' };
};
const recheck = (business, g, epoch, controller) => {
if (controller.signal.aborted || g.revocationSeq !== epoch || (state(business).revoked.get(g.agent) ?? 0) !== epoch) fail('revoked');
if (g.rotateBy <= at().slice(0, 10)) fail('grant-expired');
if (ctx().working && !ctx().working()) fail('processing-halted');
ctx().grantable(business, g.agent);
const snap = roleSnapshot(business, g.agent);
if (snap.configSha256 !== g.configSha256 || hash(snap.meta) !== g.adminSha256 ||
!sameIds(g.repositories, snap.repositories.filter((r) => g.target.projects.includes(r.project)))) fail('decision-mismatch');
};
const closeToken = async (business, intent, by) => {
if (state(business).closed.has(intent.generation)) return true;
const done = (reason) => {
githubUnlink(generationDir(business, intent.agent), intent.generation);
event(business, { action: 'grant.token.closed', agent: intent.agent, generation: intent.generation, reason, by });
return true;
};
if (now() >= Date.parse(intent.until)) return done('expired');
const token = readToken(business, intent);
if (!token) return false; // Lost response: only bounded expiry can close it.
try {
const meta = api.adminMeta(business);
if (!meta || hash(identity(meta)) !== hash(intent.admin)) return false;
const audit = log(business, intent.grantDecision, intent.agent, intent.generation);
await api.checkAdmin(business, audit);
const result = await api.revoke(business, token, audit);
if (result.status === 204) return done('revoked');
// Do not interpret a 401/403 as proof of revocation, especially with clock drift.
} catch {}
return false;
};
const mint = async (business, decision, g, controller, epoch, initial) => {
recheck(business, g, epoch, controller);
const audit = log(business, decision, g.agent);
await api.checkAdmin(business, audit);
recheck(business, g, epoch, controller);
if (initial) ctx().consume(business, decision);
const generation = randomUUID(), started = now();
const intent = { action: 'grant.step', step: 'mint', agent: g.agent, grantDecision: decision, generation,
until: new Date(started + timeoutMs + TOKEN_LIFETIME_MS + CLOCK_SKEW_MS).toISOString(), admin: g.admin };
event(business, intent);
const callLog = log(business, decision, g.agent, generation);
let candidate;
try {
const response = await api.mint(business, g.repositories.map((r) => r.id), callLog);
if (response.status !== 201) {
// An answered rejection cannot have minted a token. An unanswered call can.
if ([401, 403, 404, 422].includes(response.status))
event(business, { action: 'grant.token.closed', agent: g.agent, generation, reason: 'not-issued' });
fail('github-failed');
}
const body = response.json;
if (tokenText(body?.token)) {
candidate = body.token;
githubWrite(generationDir(business, g.agent, true), generation, candidate + '\n');
}
recheck(business, g, epoch, controller);
const expires = typeof body?.expires_at === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/.test(body.expires_at) ? Date.parse(body.expires_at) : NaN;
if (!candidate || !object(body.permissions) || canonicalJson(body.permissions) !== canonicalJson(FIXED_PERMISSIONS) ||
body.repository_selection !== 'selected' || !sameIds(body.repositories, g.repositories) ||
!Number.isFinite(expires) || expires <= now() + TOKEN_MARGIN_MS || expires > started + TOKEN_LIFETIME_MS + CLOCK_SKEW_MS) fail('github-token-invalid');
const seen = [];
for (let page = 1; page <= 20; page++) {
const response = await api.repos(business, candidate, page, callLog);
recheck(business, g, epoch, controller);
if (response.status !== 200 || !object(response.json) || response.json.total_count !== g.repositories.length ||
!Array.isArray(response.json.repositories) || !response.json.repositories.length || response.json.repositories.length > PAGE_SIZE) fail('github-reach-unknown');
seen.push(...response.json.repositories);
if (seen.length >= response.json.total_count) break;
if (response.json.repositories.length < PAGE_SIZE) fail('github-reach-unknown');
}
if (!sameIds(seen, g.repositories) || seen.some((r) => !g.repositories.some((b) => b.id === r.id && b.name === r.full_name))) fail('github-reach-wider');
recheck(business, g, epoch, controller);
if (now() >= expires - TOKEN_MARGIN_MS) fail('github-token-invalid');
const old = state(business).grants.get(g.agent);
event(business, { action: initial ? 'grant.applied' : 'grant.renewed', agent: g.agent, grantDecision: decision,
generation, binding: g, expiresAt: new Date(expires).toISOString() });
if (old) {
const oldIntent = state(business).intents.get(old.generation);
if (oldIntent) await closeToken(business, oldIntent, 'replacement');
}
recheck(business, g, epoch, controller);
return { agent: g.agent, decision, generation, expiresAt: new Date(expires).toISOString() };
} catch (e) {
await closeToken(business, intent, 'failed');
event(business, { action: 'grant.failed', agent: g.agent, grantDecision: decision, generation, code: refusal(e) });
throw e instanceof BusError ? e : new BusError('github-failed');
}
};
const status = (business) => {
const s = state(business);
const active = new Set([...s.grants.values()].map((g) => g.generation));
return {
admin: api.adminMeta(business),
unclosedCalls: s.entries.filter((e) => e.action === 'grant.call' && !s.entries.some((r) => r.action === 'grant.result' && r.callId === e.callId))
.map((e) => ({ callId: e.callId, op: e.op, generation: e.generation })),
grants: [...s.grants.values()].map((g) => ({ agent: g.agent, decision: g.grantDecision,
generation: g.generation, repositoryIds: g.binding.repositories.map((r) => r.id), projects: g.binding.target.projects,
rotateBy: g.binding.rotateBy, expiresAt: g.expiresAt })),
serviceLeft: [...s.intents.values()].filter((i) => !active.has(i.generation) && !s.closed.has(i.generation))
.map((i) => ({ agent: i.agent, generation: i.generation, until: i.until })),
running: [...claims.keys()].filter((k) => k.startsWith(business + '/')).map((k) => ({ agent: k.slice(business.length + 1) })),
};
};
const manager = {
configure(c) { if (context) fail('github-already-configured'); context = c; },
raiseBinding: binding,
status,
decisionRevoked(business, g) { return g.revocationSeq !== (state(business).revoked.get(g.agent) ?? 0); },
pending(business, agent) {
const s = status(business);
return s.grants.some((g) => g.agent === agent) || s.serviceLeft.some((g) => g.agent === agent) || s.running.some((g) => g.agent === agent);
},
run(business, decision) { return tracked(async () => {
const g = ctx().approved(business, decision);
if (g?.method !== 'github') fail('not-authorized');
if (state(business).entries.some((e) => e.operation === 'grant.github' && e.decision === decision)) fail('decision-consumed');
const controller = claim(business, g.agent), epoch = state(business).revoked.get(g.agent) ?? 0;
try { return await mint(business, decision, g, controller, epoch, true); }
catch (e) {
// Preflight failures are unspent and retryable before restart, like row236.
if (!state(business).entries.some((e) => e.operation === 'grant.github' && e.decision === decision))
event(business, { action: 'grant.failed', agent: g.agent, grantDecision: decision, code: refusal(e), unspent: true });
throw e;
} finally { release(business, g.agent, controller); }
}); },
withToken(business, agent, fn) { return tracked(async () => {
const controller = claim(business, agent), epoch = state(business).revoked.get(agent) ?? 0;
try {
let g = state(business).grants.get(agent);
if (!g) fail('github-not-granted');
if (ctx().approved(business, g.grantDecision)?.method !== 'github') fail('not-authorized');
recheck(business, g.binding, epoch, controller);
if (now() >= Date.parse(g.expiresAt) - TOKEN_MARGIN_MS) {
await mint(business, g.grantDecision, g.binding, controller, epoch, false);
if (controller.signal.aborted) fail('revoked');
g = state(business).grants.get(agent);
if (!g) fail('revoked');
}
const intent = state(business).intents.get(g.generation);
const token = intent && readToken(business, intent);
if (!token) fail('credential-file');
recheck(business, g.binding, epoch, controller);
if (now() >= Date.parse(g.expiresAt) - TOKEN_MARGIN_MS) fail('github-token-expired');
const expiryTimer = setTimeout(() => controller.abort(), Math.max(0, Date.parse(g.expiresAt) - TOKEN_MARGIN_MS - now()));
try { return await fn(token, controller.signal); } finally { clearTimeout(expiryTimer); }
} finally { release(business, agent, controller); }
}); },
revoke(business, agent, reason, by) { return tracked(async () => {
if (typeof agent !== 'string' || !ID_PATTERN.test(agent) || typeof reason !== 'string' || !reason.length || reason.length > 500) fail('invalid-request');
claims.get(key(business, agent))?.abort();
event(business, { action: 'grant.revoked', agent, reason, by });
// The live claim owns cleanup of its in-flight candidate when it returns.
const s = state(business);
for (const intent of s.intents.values()) if (intent.agent === agent && !s.closed.has(intent.generation))
await closeToken(business, intent, by);
return { agent, serviceLeft: status(business).serviceLeft.filter((e) => e.agent === agent) };
}); },
sweep(business, by) { return tracked(async () => {
const s = state(business), active = new Set([...s.grants.values()].map((g) => g.generation));
for (const intent of s.intents.values())
if (!s.closed.has(intent.generation) && (!active.has(intent.generation) || now() >= Date.parse(intent.until)) && !claims.has(key(business, intent.agent))) await closeToken(business, intent, by);
event(business, { action: 'grant.swept', by, left: status(business).serviceLeft });
return status(business);
}); },
reconcile(business) {
// A generation with no applied/renewed record is never loaded or adopted.
const s = state(business);
for (const e of s.entries.filter((e) => e.operation === 'grant.github')) {
if (s.entries.some((r) => r.grantDecision === e.decision && ['grant.applied', 'grant.failed'].includes(r.action) && r.seq > e.seq)) continue;
const intent = [...s.intents.values()].findLast((i) => i.grantDecision === e.decision);
event(business, { action: 'grant.failed', grantDecision: e.decision, agent: intent?.agent ?? null, code: 'interrupted' });
}
},
async idle() { while (jobs.size) await Promise.allSettled([...jobs]); },
async close() { for (const c of claims.values()) c.abort(); await manager.idle(); },
api,
audit: log,
};
return manager;
}
+1 -8
View File
@@ -25,8 +25,6 @@ import { webService } from './web.mjs';
import { hostExecutor, sweepRunDir } from './host-run.mjs';
import { grantKeys, hostGrant, hostReference, sweepHostKeys } from './host-grant.mjs';
import { giteaGrant, giteaKeys, giteaStart } from './gitea-grant.mjs';
import { githubApi } from './github-api.mjs';
import { githubGrant } from './github-grant.mjs';
import { verifyPrincipal } from './principal.mjs';
import { loadAccess } from '../../business/src/access.mjs';
import { inspectInterpreter, recordInterpreterStart } from './interpreter.mjs';
@@ -124,7 +122,7 @@ export async function startBroker({
|| (Object.hasOwn(librarySync, 'intervalMs') && !(Number.isSafeInteger(librarySync.intervalMs) && librarySync.intervalMs > 0))
|| (Object.hasOwn(librarySync, 'configDir') && typeof librarySync.configDir !== 'string')))
throw new BusError('invalid-host-request');
let store, credentials, server, adapter, processing, github = null, syncTimer = null, syncPass = null, accessTimer = null;
let store, credentials, server, adapter, processing, syncTimer = null, syncPass = null, accessTimer = null;
try {
const references = {};
for (const [business, b] of Object.entries(businesses)) {
@@ -273,7 +271,6 @@ export async function startBroker({
return false;
}
}
github = organization ? githubGrant({ api: githubApi({ configDir: organization.dir }), configDir: organization.dir }) : null;
broker = new Broker({
// FED R3: the access file is read per user request; a refusal leaves the user no grant.
access: access ? () => loadAccess(access.file, access.context) : null,
@@ -299,7 +296,6 @@ export async function startBroker({
// Row 236 (#1742): the Gitea grant path. The admin slot is <configDir>/secrets/grants/<business>/gitea.
giteaGrant: organization ? giteaGrant({ configDir: organization.dir }) : null,
giteaKeys: storeRoot ? giteaKeys({ credentials, storeRoot }) : null,
githubGrant: github,
usage,
limits,
store,
@@ -463,7 +459,6 @@ export async function startBroker({
broker.reconcileHosts();
// Row 236 (#436b r5 section 5): the Gitea grants' remote undo, settle and open revokes, likewise.
broker.reconcileGitea();
broker.reconcileGithub().catch(() => {});
// S6 launcher operations. The host decides capacity and the instance list; the broker
// authorizes role.launch and keeps the evidence, refusals included.
// A refusal is recorded against the caller, as request() records one.
@@ -654,7 +649,6 @@ export async function startBroker({
await stopSync();
await server.close();
await adapter?.close?.();
await github?.close();
await processing?.close();
store.close();
credentials.close();
@@ -664,7 +658,6 @@ export async function startBroker({
clearInterval(accessTimer);
await server?.close();
await adapter?.close?.();
await github?.close();
await processing?.close();
store?.close();
credentials?.close();
+3 -1
View File
@@ -9,6 +9,8 @@
// Row 252 (#1753, Webby N5) re-pins human.mjs, whose readProcess also keeps the five
// MOSAIC_BUS_CONSOLE_* keys for the Console wrapper's proof, and webui bus.mjs for consoleCall,
// which shares humanCall's spawn as transport(). The human proof and its spawn are unchanged.
// Row 225 (C2-10b) re-pins webui bus.mjs for one STATUS entry, library-revert-not-agent: 409; no proof
// or transport change.
import test from "node:test";
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
@@ -29,7 +31,7 @@ const baselines = {
// Row 194 (#1692, brief item 7, sage-1610 a): organization-tracker-required 400 and
// organization-tracker-bot-taken 409; no proof or transport change.
// Row 301 (#1808, mos-1449): the user transport moved from user-bus.mjs into bus.mjs; humanCall unchanged.
"packages/webui/src/bus.mjs": "604092cb706b152d458f299ff1071f6cd488774629012f96b672f7e9a16c0b2c",
"packages/webui/src/bus.mjs": "9f33e9ca4feb7265ae33fd3ddf0ead0b75375d07ecbe9d8e55c618d29edea658",
};
const repoFile = path => new URL("../../../" + path, import.meta.url);
-70
View File
@@ -1,70 +0,0 @@
// Row297: fake GitHub over HTTPS, scratch CA only. No live credentials or provider calls.
import { generateKeyPairSync, randomBytes, verify } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { createServer } from 'node:https';
import { scratchCa } from '../../seat/tests/fake-gitea.mjs';
export async function fakeGithubApi(t, dir, { now = () => Date.now() } = {}) {
const tls = scratchCa(dir);
const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
const key = privateKey.export({ type: 'pkcs8', format: 'pem' });
const tokens = new Map();
const requests = [];
const state = { appId: 10, installationId: 20, accountId: 30, suspended: false,
permissions: { contents: 'write', metadata: 'read' }, repositories: [{ id: 101, full_name: 'acme/app' }] };
let intercept = null;
const server = createServer({ key: tls.key, cert: tls.cert }, async (req, res) => {
const url = new URL(req.url, 'https://fake.test');
let body = '';
for await (const part of req) body += part;
let json;
try { json = body ? JSON.parse(body) : null; } catch { res.writeHead(400).end(); return; }
const auth = (req.headers.authorization ?? '').replace(/^Bearer /, '');
const bits = auth.split('.');
let jwt = null;
if (bits.length === 3) {
try {
if (verify('RSA-SHA256', Buffer.from(bits.slice(0, 2).join('.')), publicKey, Buffer.from(bits[2], 'base64url')))
jwt = JSON.parse(Buffer.from(bits[1], 'base64url'));
} catch {}
}
const token = tokens.get(auth);
const entry = { method: req.method, path: url.pathname, query: url.search, body: json,
jwt: jwt ? { ...jwt } : null, auth: jwt ? 'app' : token ? 'installation' : 'none' };
requests.push(entry);
const answer = (status, value) => {
res.writeHead(status, { 'content-type': 'application/json' });
res.end(value === undefined ? '' : JSON.stringify(value));
};
if (intercept && await intercept(entry, answer, { req, res, tokens })) return;
if (url.pathname === '/installation/token' && req.method === 'DELETE' && token) {
tokens.delete(auth); answer(204); return;
}
if (url.pathname === '/installation/repositories' && token && Date.parse(token.expires_at) > now()) {
const page = Number(url.searchParams.get('page') ?? 1);
const per = Number(url.searchParams.get('per_page') ?? 100);
answer(200, { total_count: token.repositories.length, repositories: token.repositories.slice((page - 1) * per, page * per) }); return;
}
const secs = Math.floor(now() / 1000);
if (!jwt || String(jwt.iss) !== String(state.appId) || jwt.iat > secs || jwt.exp <= secs || jwt.exp > secs + 600) {
answer(401, {}); return;
}
if (url.pathname === '/app') { answer(200, { id: state.appId }); return; }
if (url.pathname === `/app/installations/${state.installationId}` && req.method === 'GET') {
answer(200, { id: state.installationId, app_id: state.appId, account: { id: state.accountId },
suspended_at: state.suspended ? '2026-10-23T00:00:00Z' : null, permissions: state.permissions }); return;
}
if (url.pathname === `/app/installations/${state.installationId}/access_tokens` && req.method === 'POST') {
const value = 'ghs_fake_' + randomBytes(24).toString('hex');
const result = { token: value, expires_at: new Date(now() + 3600000).toISOString(),
permissions: json.permissions, repository_selection: 'selected',
repositories: state.repositories.filter((r) => json.repository_ids?.includes(r.id)) };
tokens.set(value, structuredClone(result)); answer(201, result); return;
}
answer(404, {});
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
t.after(async () => { server.closeAllConnections(); await new Promise((resolve) => server.close(resolve)); });
return { url: `https://127.0.0.1:${server.address().port}`, ca: readFileSync(tls.ca, 'utf8'),
key, tokens, requests, state, intercept: (fn) => { intercept = fn; } };
}
-113
View File
@@ -1,113 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { generateKeyPairSync } from 'node:crypto';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, statSync, symlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { githubApi, githubMeta, githubJwt, FIXED_PERMISSIONS } from '../src/github-api.mjs';
import { fakeGithubApi } from './fake-github-api.mjs';
const at = Date.parse('2026-10-23T12:00:00Z');
const refuses = (fn, code) => assert.throws(fn, (e) => e.code === code);
async function fixture(t) {
const dir = mkdtempSync(join(tmpdir(), 'github297-api-'));
t.after(() => rmSync(dir, { recursive: true, force: true }));
const fake = await fakeGithubApi(t, join(dir, 'tls'), { now: () => at });
const cfg = join(dir, 'config'); mkdirSync(cfg, { mode: 0o700 });
const api = githubApi({ configDir: cfg, ca: fake.ca, now: () => at });
const meta = { url: fake.url, appId: 10, installationId: 20, accountId: 30,
rotateBy: '2026-11-23', setAt: new Date(at).toISOString() };
const calls = [];
const log = async (op, fn) => { calls.push(['call', op]); const r = await fn(); calls.push(['result', r.status]); return r; };
return { fake, api, meta, cfg, calls, log };
}
test('R2 admin metadata refuses HTTP without a loopback exception', () => {
refuses(() => githubMeta({ url: 'http://127.0.0.1:1234', appId: 1, installationId: 2, accountId: 3,
rotateBy: '2026-11-23', setAt: new Date(at).toISOString() }), 'invalid-request');
});
test('R4 JWT uses the controlled clock, RS256, 60s backdate and bounded expiry', () => {
const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
const encoded = githubJwt(privateKey, 10, at);
const [header, body] = encoded.split('.').slice(0, 2).map((s) => JSON.parse(Buffer.from(s, 'base64url')));
assert.equal(header.alg, 'RS256');
assert.equal(body.iat, at / 1000 - 60);
assert.equal(body.exp, at / 1000 + 540);
assert.equal(body.iss, '10');
});
test('admin set checks the fake HTTPS identity and stores private files only after checks', async (t) => {
const f = await fixture(t);
await f.api.setAdmin('demo', f.meta, f.fake.key, f.log);
assert.deepEqual(f.api.adminMeta('demo'), f.meta);
assert.equal(statSync(join(f.cfg, 'secrets/grants/demo/github/admin')).mode & 0o777, 0o600);
assert.equal(f.calls.length, 4);
assert.deepEqual(f.fake.requests.map((r) => r.path), ['/app', '/app/installations/20']);
assert.equal(JSON.stringify(f.calls).includes('PRIVATE KEY'), false);
});
for (const [name, mutate, code] of [
['App identity', (f) => { f.meta.appId = 11; }, 'grant-admin-account'],
['installation account identity', (f) => { f.meta.accountId = 31; }, 'grant-admin-account'],
['suspended installation', (f) => { f.fake.state.suspended = true; }, 'grant-admin-account'],
['permission floor', (f) => { f.fake.state.permissions.contents = 'read'; }, 'grant-admin-scope'],
]) test(`C1-C4 replacement: admin set refuses ${name} and writes no slot`, async (t) => {
const f = await fixture(t); mutate(f);
await assert.rejects(f.api.setAdmin('demo', f.meta, f.fake.key, f.log), (e) => e.code === code);
assert.equal(existsSync(join(f.cfg, 'secrets/grants/demo/github/admin')), false);
});
test('admin set refuses a non-RSA key without making an API call', async (t) => {
const f = await fixture(t);
const { privateKey } = generateKeyPairSync('ed25519');
await assert.rejects(f.api.setAdmin('demo', f.meta, privateKey.export({ type: 'pkcs8', format: 'pem' }), f.log), (e) => e.code === 'credential-format');
assert.equal(f.fake.requests.length, 0);
});
test('admin set refuses HTTP before any call, and refuses to overwrite a slot', async (t) => {
const f = await fixture(t);
await assert.rejects(f.api.setAdmin('demo', { ...f.meta, url: f.fake.url.replace('https:', 'http:') }, f.fake.key, f.log), (e) => e.code === 'invalid-request');
assert.equal(f.fake.requests.length, 0);
await f.api.setAdmin('demo', f.meta, f.fake.key, f.log);
await assert.rejects(f.api.setAdmin('demo', f.meta, f.fake.key, f.log), (e) => e.code === 'grant-admin-exists');
});
test('mint sends fixed permissions and explicit repository IDs using an App JWT', async (t) => {
const f = await fixture(t);
await f.api.setAdmin('demo', f.meta, f.fake.key, f.log);
const r = await f.api.mint('demo', [101], f.log);
assert.equal(r.status, 201);
const req = f.fake.requests.at(-1);
assert.deepEqual(req.body, { repository_ids: [101], permissions: FIXED_PERMISSIONS });
assert.equal(req.auth, 'app');
assert.equal(f.fake.requests.every((x) => !JSON.stringify(x).includes('PRIVATE KEY')), true);
});
test('C2 validates the App response ID even with a successful JWT authentication', async (t) => {
const f = await fixture(t);
f.fake.intercept(async (r, answer) => {
if (r.path === '/app') { answer(200, { id: 11 }); return true; }
return false;
});
await assert.rejects(f.api.setAdmin('demo', f.meta, f.fake.key, f.log), (e) => e.code === 'grant-admin-account');
});
test('admin slot refuses unsafe file modes and symbolic links', async (t) => {
const f = await fixture(t); await f.api.setAdmin('demo', f.meta, f.fake.key, f.log);
const metaFile = join(f.cfg, 'secrets/grants/demo/github/admin.json');
chmodSync(metaFile, 0o644);
assert.throws(() => f.api.adminMeta('demo'), (e) => e.code === 'credential-file');
chmodSync(metaFile, 0o600);
const keyFile = join(f.cfg, 'secrets/grants/demo/github/admin');
rmSync(keyFile); symlinkSync(metaFile, keyFile);
await assert.rejects(f.api.checkAdmin('demo', f.log), (e) => e.code === 'credential-file');
});
for (const method of ['repos', 'revoke']) test(`N2 ${method} refuses a missing installation token before any call`, async (t) => {
const f = await fixture(t); await f.api.setAdmin('demo', f.meta, f.fake.key, f.log);
const before = f.fake.requests.length;
const run = () => method === 'repos' ? f.api.repos('demo', undefined, 1, f.log) : f.api.revoke('demo', undefined, f.log);
await assert.rejects(Promise.resolve().then(run), (e) => e.code === 'credential-format');
assert.equal(f.fake.requests.length, before);
});
-158
View File
@@ -1,158 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Broker, GRANT_EVIDENCE } from '../src/broker.mjs';
import { Store } from '../src/store.mjs';
import { githubApi } from '../src/github-api.mjs';
import { githubGrant } from '../src/github-grant.mjs';
import { fakeGithubApi } from './fake-github-api.mjs';
async function fixture(t) {
const dir = mkdtempSync(join(tmpdir(), 'github297-broker-'));
t.after(() => rmSync(dir, { recursive: true, force: true }));
const fake = await fakeGithubApi(t, join(dir, 'tls'));
const configDir = join(dir, 'cfg'); mkdirSync(configDir, { mode: 0o700 });
mkdirSync(join(dir, 'data'), { mode: 0o700 });
const store = new Store(join(dir, 'data')); t.after(() => store.close());
const api = githubApi({ configDir, ca: fake.ca });
const manager = githubGrant({ api, configDir });
const none = { authority: { withinRole: ['message.send'], crossRole: [] } };
const business = { id: 'demo', human: 'jason', arbiters: { technical: 'pm', delivery: 'pm' },
roles: { chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send', 'role.fire'], crossRole: [] } }, pm: { definition: 'pm', ...none },
coder: { definition: 'coder', github: { projects: ['app'] }, ...none } },
definitions: { 'chief-of-staff': { roleVersion: 2, credentials: [] }, pm: { roleVersion: 2, credentials: [] },
coder: { roleVersion: 2, credentials: [{ service: 'github' }] } },
projects: { app: { remote: fake.url + '/acme/app', githubId: 101 } }, launch: { by: 'chief', instances: ['pm', 'coder'] }, vars: { 'bus.tester': true } };
const writes = [];
let broker;
const organization = (id, op, args) => {
writes.push(op);
if (op === 'instance.fire') business.launch.instances = business.launch.instances.filter((n) => n !== args.name);
if (op === 'instances.set' && args.op === 'delete') {
delete business.roles[args.instance];
business.launch.instances = business.launch.instances.filter((n) => n !== args.instance);
}
broker.reloadBusiness('demo', business);
return { revision: 'b'.repeat(64), transaction: 'fake-transaction' };
};
broker = new Broker({ store, businesses: { demo: business }, githubGrant: manager, organization });
const caps = { human: broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }),
tester: broker.bindTester({ business: 'demo' }) };
for (const role of ['chief', 'pm']) {
caps[role] = broker.bindLaunch({ business: 'demo', role, run: role + '-run', harness: 'pi', address: role });
await broker.request(caps[role], { verb: 'role.claim' });
}
const call = (who, verb, args) => broker.request(caps[who], { verb, args });
const admin = { service: 'github', url: fake.url, appId: 10, installationId: 20, accountId: 30,
rotateBy: '2099-01-01', token: fake.key };
await call('human', 'grants.admin.set', admin);
const raise = () => call('chief', 'decision.raise', { action: 'credential.mint', question: 'Grant GitHub?',
options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'yes', blocking: false,
grant: { method: 'github', agent: 'coder', target: { projects: ['app'] }, rotateBy: '2098-01-01' } });
const idle = async () => { await new Promise((resolve) => setImmediate(resolve)); await broker.grantsIdle(); };
const events = () => store.all(`SELECT body FROM events WHERE business='demo' AND ${GRANT_EVIDENCE} ORDER BY seq`).map((e) => JSON.parse(e.body));
return { fake, store, broker, manager, business, call, raise, idle, events, admin, writes };
}
test('chief raise and human Approve start GitHub without a Gitea admin or host executor', async (t) => {
const f = await fixture(t);
const d = await f.raise();
assert.equal(f.fake.tokens.size, 0);
await f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' }); await f.idle();
const status = await f.call('chief', 'grants.status', { service: 'github' });
assert.equal(status.grants.length, 1);
assert.equal(status.grants[0].decision, d.id);
await f.call('human', 'grants.revoke', { service: 'github', agent: 'coder', reason: 'test' });
assert.equal(f.fake.tokens.size, 0);
assert.equal((await f.call('human', 'grants.status', { service: 'github' })).grants.length, 0);
});
test('GitHub admin and manual run are human-only; arbitrary agents cannot raise', async (t) => {
const f = await fixture(t);
for (const who of ['chief', 'tester']) {
await assert.rejects(Promise.resolve().then(() => f.call(who, 'grants.admin.remove', { service: 'github' })), (e) => e.code === 'human-required');
await assert.rejects(Promise.resolve().then(() => f.call(who, 'grants.run', { decision: 'unapproved' })), (e) => e.code === 'human-required');
}
await assert.rejects(Promise.resolve().then(() => f.call('pm', 'decision.raise', { action: 'credential.mint',
question: 'no', options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'yes', blocking: false,
grant: { method: 'github', agent: 'coder', target: { projects: ['app'] }, rotateBy: '2098-01-01' } })), (e) => e.code === 'grant-chief-only');
});
test('changing repository config after raise refuses before consume, then restart spends interrupted', async (t) => {
const f = await fixture(t); const d = await f.raise();
f.business.projects.app.githubId = 102; f.broker.reloadBusiness('demo', f.business);
await f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' }); await f.idle();
assert.equal(f.fake.tokens.size, 0);
assert.equal(f.events().some((e) => e.operation === 'grant.github'), false);
f.broker.reconcileGrants();
assert.equal(f.events().filter((e) => e.operation === 'grant.github' && e.decision === d.id).length, 1);
});
test('no socket verb exposes a token or protected renewal', async (t) => {
const f = await fixture(t);
for (const verb of ['grants.token', 'grants.renew', 'github.token'])
await assert.rejects(Promise.resolve().then(() => f.call('human', verb, { agent: 'coder' })), (e) => e.code === 'unknown-verb');
});
test('an open approval revoked before Yes cannot be used by the late automatic executor', async (t) => {
const f = await fixture(t); const d = await f.raise();
await f.call('human', 'grants.revoke', { service: 'github', agent: 'coder', reason: 'cancel' });
await f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' }); await f.idle();
assert.equal(f.fake.tokens.size, 0);
assert.equal(f.manager.status('demo').grants.length, 0);
});
test('grant evidence remains reserved from an agent or direct human record insert', async (t) => {
const f = await fixture(t);
assert.throws(() => f.store.run("INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES('forged','2026-10-23T00:00:00Z','demo','action.allowed',NULL,NULL,NULL,?)",
JSON.stringify({ action: 'grant.applied', method: 'github', agent: 'coder' })));
assert.equal(f.manager.status('demo').grants.length, 0);
});
for (const failDelete of [false, true]) test(`fire revokes GitHub locally and ${failDelete ? 'stops on remote leftovers' : 'completes only after remote cleanup'}`, async (t) => {
const f = await fixture(t); const d = await f.raise();
await f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' }); await f.idle();
if (failDelete) f.fake.intercept(async (r, answer) => { if (r.method === 'DELETE') { answer(503, {}); return true; } return false; });
await f.call('chief', 'instance.fire', { name: 'coder', reason: 'test' }); await f.idle();
assert.equal(f.manager.status('demo').grants.length, 0);
assert.equal(f.writes.includes('instance.fire'), !failDelete);
assert.equal(f.manager.status('demo').serviceLeft.length, failDelete ? 1 : 0);
});
test('lifecycle delete waits for the protected GitHub revoke job', async (t) => {
const f = await fixture(t); const d = await f.raise();
await f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' }); await f.idle();
const { request } = await f.call('human', 'instances.set', { revision: 'b'.repeat(64), instance: 'coder', op: 'delete' });
assert.equal(f.broker.lifecycleTake({ business: 'demo', request }).go, true);
assert.throws(() => f.broker.lifecycleDelete({ business: 'demo', request }), (e) => e.code === 'grants-revoking');
f.broker.lifecycleDone({ business: 'demo', request, refusal: 'grants-revoking' });
await f.idle();
assert.equal(f.manager.status('demo').grants.length, 0);
assert.equal(f.fake.tokens.size, 0);
assert.equal(Object.hasOwn(f.business.roles, 'coder'), true);
const second = await f.call('human', 'instances.set', { revision: 'b'.repeat(64), instance: 'coder', op: 'delete' });
assert.equal(f.broker.lifecycleTake({ business: 'demo', request: second.request }).go, true);
const removed = f.broker.lifecycleDelete({ business: 'demo', request: second.request });
assert.equal(removed.retry, false);
assert.equal(removed.transaction, 'fake-transaction');
assert.equal(Object.hasOwn(f.business.roles, 'coder'), false);
assert.equal(f.writes.filter((op) => op === 'instances.set').length, 1);
});
test('admin rotate preserves installation identity and invalidates open approved bindings', async (t) => {
const f = await fixture(t); const d = await f.raise();
await f.call('human', 'grants.admin.rotate', { service: 'github', rotateBy: '2099-02-01', token: f.fake.key });
await f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' }); await f.idle();
assert.equal(f.fake.tokens.size, 0);
assert.equal(f.manager.status('demo').admin.installationId, 20);
assert.equal(f.manager.status('demo').grants.length, 0);
});
for (const args of [{}, { decision: null }, { decision: 'bad/id' }, { decision: 'valid', extra: true }])
test('N7 manual run validates shape and identifier before approval lookup: ' + JSON.stringify(args), async (t) => {
const f = await fixture(t);
await assert.rejects(Promise.resolve().then(() => f.call('human', 'grants.run', args)), (e) => e.code === 'invalid-request');
assert.equal(f.fake.tokens.size, 0);
});
-412
View File
@@ -1,412 +0,0 @@
// Row297 contract tests. Run only under Sage CLEAR; fake HTTPS API, no live token.
import test from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { randomUUID } from 'node:crypto';
import { Store } from '../src/store.mjs';
import { GRANT_EVIDENCE, writeGrantEvent } from '../src/broker.mjs';
import { githubApi, FIXED_PERMISSIONS } from '../src/github-api.mjs';
import { githubGrant, TOKEN_LIFETIME_MS, CLOCK_SKEW_MS, TOKEN_MARGIN_MS, CALL_TIMEOUT_MS } from '../src/github-grant.mjs';
import { fakeGithubApi } from './fake-github-api.mjs';
import { githubDirectory, githubWrite } from '../src/github-files.mjs';
const START = Date.parse('2026-10-23T12:00:00Z');
const rejectCode = (p, code) => assert.rejects(p, (e) => e.code === code);
async function fixture(t, options = {}) {
const dir = mkdtempSync(join(tmpdir(), 'github297-grant-'));
t.after(() => rmSync(dir, { recursive: true, force: true }));
let time = START;
const now = () => time;
const fake = await fakeGithubApi(t, join(dir, 'tls'), { now });
const configDir = join(dir, 'config'); mkdirSync(configDir, { mode: 0o700 });
mkdirSync(join(dir, 'data'), { mode: 0o700 });
const store = new Store(join(dir, 'data'));
t.after(() => store.close());
const business = { id: 'demo', roles: { coder: { definition: 'coder', github: { projects: ['app'] } } },
definitions: { coder: { roleVersion: 2, credentials: [{ service: 'github' }] } },
projects: { app: { remote: fake.url + '/acme/app', githubId: 101 } } };
const approved = new Map();
const event = (id, body) => store.transaction(() => writeGrantEvent(store, new Date(now()).toISOString(), id, body, body.agent ?? null));
const api = githubApi({ configDir, ca: fake.ca, now, timeoutMs: options.timeoutMs ?? CALL_TIMEOUT_MS });
const meta = { url: fake.url, appId: 10, installationId: 20, accountId: 30,
rotateBy: '2026-11-23', setAt: new Date(now()).toISOString() };
const log = async (op, fn) => fn(); // Initial admin fixture; executor calls use reserved event writer.
await api.setAdmin('demo', meta, fake.key, log);
const spent = new Set();
let working = true;
const create = () => {
const manager = githubGrant({ api, configDir, now, timeoutMs: options.timeoutMs ?? CALL_TIMEOUT_MS });
manager.configure({ store, business: () => business, event,
approved: (id, decision) => approved.get(decision) ?? null,
consume: (id, decision) => {
if (spent.has(decision)) throw Object.assign(new Error('decision-consumed'), { code: 'decision-consumed' });
spent.add(decision); event(id, { operation: 'grant.github', decision }); return approved.get(decision);
}, grantable: () => {}, working: () => working });
return manager;
};
const manager = create();
const grant = { method: 'github', agent: 'coder', target: { projects: ['app'] }, rotateBy: '2026-11-20' };
const raise = () => {
const decision = randomUUID();
approved.set(decision, manager.raiseBinding('demo', grant).body);
return decision;
};
const events = () => store.all(`SELECT body FROM events WHERE business='demo' AND ${GRANT_EVIDENCE} ORDER BY seq`).map((r) => JSON.parse(r.body));
const apply = async () => { const decision = raise(); await manager.run('demo', decision); return decision; };
return { dir, configDir, store, business, fake, api, manager, meta, grant, now, raise, apply,
events, approved, spent, create, advance: (ms) => { time += ms; }, working: (v) => { working = v; } };
}
test('R4 lifetime, skew, margin and request bounds are explicit constants', () => {
assert.equal(TOKEN_LIFETIME_MS, 3600000);
assert.equal(CLOCK_SKEW_MS, 60000);
assert.equal(TOKEN_MARGIN_MS, 60000);
assert.equal(CALL_TIMEOUT_MS, 15000);
});
test('grant binding cannot name scopes, accounts or fork creation', async (t) => {
const f = await fixture(t);
for (const extra of [{ scopes: ['all'] }, { account: 'foreign' }, { fork: true }])
assert.throws(() => f.manager.raiseBinding('demo', { ...f.grant, ...extra }), (e) => e.code === 'invalid-request');
assert.equal(f.events().length, 0);
});
test('initial approval creates an exact durable grant and guarded token use', async (t) => {
const f = await fixture(t);
const decision = await f.apply();
const [g] = f.manager.status('demo').grants;
assert.equal(g.agent, 'coder');
assert.equal(g.decision, decision);
assert.deepEqual(g.repositoryIds, [101]);
assert.equal(g.expiresAt, new Date(START + TOKEN_LIFETIME_MS).toISOString());
let used = false;
await f.manager.withToken('demo', 'coder', async (token, signal) => {
used = f.fake.tokens.has(token); assert.equal(signal.aborted, false);
});
assert.ok(used);
const events = f.events();
const calls = events.filter((e) => e.action === 'grant.call');
assert.ok(calls.length >= 4);
for (const call of calls) {
const result = events.find((e) => e.action === 'grant.result' && e.callId === call.callId);
assert.ok(result);
assert.ok(events.indexOf(result) > events.indexOf(call));
}
assert.equal(events.filter((e) => Object.hasOwn(e, 'decision')).length, 1);
const text = JSON.stringify(events);
for (const token of f.fake.tokens.keys()) assert.ok(!text.includes(token));
assert.ok(!text.includes('PRIVATE KEY'));
assert.ok(!text.includes('Bearer'));
});
for (const [label, mutation] of [
['extra permission', (b) => { b.permissions.workflows = 'write'; }],
['higher metadata level', (b) => { b.permissions.metadata = 'write'; }],
['missing permission', (b) => { delete b.permissions.metadata; }],
['all repositories', (b) => { b.repository_selection = 'all'; }],
['extra repository', (b) => { b.repositories.push({ id: 102, full_name: 'acme/other' }); }],
['missing repository', (b) => { b.repositories = []; }],
['duplicate repository', (b) => { b.repositories.push({ ...b.repositories[0] }); }],
['past expiry', (b) => { b.expires_at = new Date(START).toISOString(); }],
['missing expiry', (b) => { delete b.expires_at; }],
['excessive lifetime', (b) => { b.expires_at = new Date(START + TOKEN_LIFETIME_MS + CLOCK_SKEW_MS + 1).toISOString(); }],
]) test(`R1/R4 mint refuses ${label} before any guarded use and revokes candidate`, async (t) => {
const f = await fixture(t);
let repoReads = 0;
f.fake.intercept(async (r, answer, { tokens }) => {
if (r.path === '/installation/repositories') repoReads++;
if (r.method !== 'POST') return false;
const token = 'ghs_fake_candidate_12345678901234567890';
const body = { token, expires_at: new Date(START + TOKEN_LIFETIME_MS).toISOString(),
permissions: { ...FIXED_PERMISSIONS }, repository_selection: 'selected', repositories: [{ id: 101, full_name: 'acme/app' }] };
tokens.set(token, structuredClone(body)); mutation(body); answer(201, body); return true;
});
await rejectCode(f.apply(), 'github-token-invalid');
assert.equal(f.manager.status('demo').grants.length, 0);
assert.equal(repoReads, 0);
assert.equal(f.fake.tokens.size, 0);
});
test('R4 renewal starts at the safety margin and uses a new token under the same decision', async (t) => {
const f = await fixture(t); const decision = await f.apply();
const old = [...f.fake.tokens.keys()][0];
f.advance(TOKEN_LIFETIME_MS - TOKEN_MARGIN_MS - 1);
await f.manager.withToken('demo', 'coder', () => {});
assert.equal(f.fake.tokens.size, 1);
f.advance(1);
await f.manager.withToken('demo', 'coder', (token) => { assert.ok(token !== old); });
assert.equal(f.fake.tokens.has(old), false);
assert.equal(f.manager.status('demo').grants[0].decision, decision);
assert.equal(f.events().filter((e) => e.operation === 'grant.github').length, 1);
});
for (const [label, change] of [
['project repository ID', (b) => { b.projects.app.githubId = 102; }],
['project remote', (b) => { b.projects.app.remote += '-changed'; }],
['allowed projects', (b) => { b.roles.coder.github.projects.push('other'); }],
['definition', (b) => { b.definitions.coder.title = 'changed'; }],
]) test(`R5 config change to ${label} refuses renewal and never uses old token`, async (t) => {
const f = await fixture(t); await f.apply();
change(f.business); f.advance(TOKEN_LIFETIME_MS);
let used = false;
await rejectCode(f.manager.withToken('demo', 'coder', () => { used = true; }), 'decision-mismatch');
assert.equal(used, false);
});
test('renewal refuses after the durable grant deadline or processing halt', async (t) => {
const f = await fixture(t); await f.apply();
f.working(false);
await rejectCode(f.manager.withToken('demo', 'coder', () => {}), 'processing-halted');
f.working(true); f.advance(40 * 86400000);
await rejectCode(f.manager.withToken('demo', 'coder', () => {}), 'grant-expired');
});
test('local revoke wins a pending renewal; candidate is revoked and grant never revives', async (t) => {
const f = await fixture(t); await f.apply(); f.advance(TOKEN_LIFETIME_MS);
let release, entered;
const held = new Promise((resolve) => { entered = resolve; });
const gate = new Promise((resolve) => { release = resolve; });
f.fake.intercept(async (r) => {
if (r.method === 'POST') { entered(); await gate; }
return false;
});
const pending = f.manager.withToken('demo', 'coder', () => assert.fail('late token used'));
// Attach the rejection handler before releasing the network call.
const refused = rejectCode(pending, 'revoked');
await held;
await f.manager.revoke('demo', 'coder', 'test', 'jason');
assert.equal(f.manager.status('demo').grants.length, 0);
release(); await refused;
assert.equal(f.manager.status('demo').grants.length, 0);
assert.equal(f.fake.tokens.size, 0);
});
test('revoke blocks guarded use before a remote delete completes; failure is serviceLeft', async (t) => {
const f = await fixture(t); await f.apply();
let localGone = false;
f.fake.intercept(async (r, answer) => {
if (r.method === 'DELETE') { localGone = f.manager.status('demo').grants.length === 0; answer(503, {}); return true; }
return false;
});
await f.manager.revoke('demo', 'coder', 'test', 'jason');
assert.ok(localGone);
await rejectCode(f.manager.withToken('demo', 'coder', () => assert.fail('revoked token used')), 'github-not-granted');
assert.equal(f.manager.status('demo').serviceLeft.length, 1);
f.fake.intercept(null); await f.manager.sweep('demo', 'jason');
assert.equal(f.fake.tokens.size, 0);
assert.equal(f.manager.status('demo').serviceLeft.length, 0);
});
test('lost mint response stays visible through restart until conservative expiry equality', async (t) => {
const f = await fixture(t);
f.fake.intercept(async (r, answer, { req }) => {
if (r.method === 'POST') { req.socket.destroy(); return true; }
return false;
});
await rejectCode(f.apply(), 'github-failed');
const status = f.manager.status('demo');
assert.equal(status.serviceLeft.length, 1);
const until = Date.parse(status.serviceLeft[0].until);
assert.equal(until, START + CALL_TIMEOUT_MS + TOKEN_LIFETIME_MS + CLOCK_SKEW_MS);
const restarted = f.create(); restarted.reconcile('demo');
f.advance(until - f.now() - 1); await restarted.sweep('demo', 'jason');
assert.equal(restarted.status('demo').serviceLeft.length, 1);
f.advance(1); await restarted.sweep('demo', 'jason');
assert.equal(restarted.status('demo').serviceLeft.length, 0);
});
// Kills removal of the approval/revocation boundary check.
test('a revoke invalidates an older unspent approval, even when no token was issued', async (t) => {
const f = await fixture(t); const decision = f.raise();
await f.manager.revoke('demo', 'coder', 'cancel approval', 'jason');
await rejectCode(f.manager.run('demo', decision), 'revoked');
assert.equal(f.spent.has(decision), false);
assert.equal(f.fake.tokens.size, 0);
});
test('verification reads every page and binds repository IDs to their configured names', async (t) => {
const f = await fixture(t);
const projects = [];
f.fake.state.repositories = [];
for (let i = 0; i < 31; i++) {
const p = 'repo' + i; projects.push(p);
f.business.projects[p] = { githubId: 1000 + i, remote: f.fake.url + '/acme/' + p };
f.fake.state.repositories.push({ id: 1000 + i, full_name: 'acme/' + p });
}
f.business.roles.coder.github.projects = projects;
f.grant.target.projects = projects;
await f.apply();
assert.deepEqual(f.fake.requests.filter((r) => r.path === '/installation/repositories').map((r) => r.query),
['?per_page=30&page=1', '?per_page=30&page=2']);
assert.equal(f.manager.status('demo').grants[0].repositoryIds.length, 31);
});
test('verification refuses wider reach and deletes the candidate', async (t) => {
const f = await fixture(t);
f.fake.intercept(async (r, answer) => {
if (r.path !== '/installation/repositories') return false;
answer(200, { total_count: 2, repositories: [{ id: 101, full_name: 'acme/app' }, { id: 102, full_name: 'acme/foreign' }] });
return true;
});
await rejectCode(f.apply(), 'github-reach-unknown');
assert.equal(f.fake.tokens.size, 0);
assert.equal(f.manager.status('demo').grants.length, 0);
});
test('token replacement is usable before old-token delete and not after grant revocation', async (t) => {
const f = await fixture(t); await f.apply();
const oldGeneration = f.manager.status('demo').grants[0].generation;
f.fake.intercept(async (r) => {
if (r.method === 'DELETE') {
const active = f.manager.status('demo').grants[0];
assert.ok(active.generation !== oldGeneration);
assert.equal(f.fake.tokens.size, 2);
}
return false;
});
f.advance(TOKEN_LIFETIME_MS - TOKEN_MARGIN_MS);
await f.manager.withToken('demo', 'coder', () => {});
assert.equal(f.fake.tokens.size, 1);
});
test('failed renewal never calls the consumer with the previous token', async (t) => {
const f = await fixture(t); await f.apply();
f.advance(TOKEN_LIFETIME_MS - TOKEN_MARGIN_MS);
f.fake.intercept(async (r, answer) => {
if (r.method === 'POST') { answer(503, {}); return true; }
return false;
});
let called = false;
await rejectCode(f.manager.withToken('demo', 'coder', () => { called = true; }), 'github-failed');
assert.equal(called, false);
});
test('admin metadata replacement invalidates an approved durable binding', async (t) => {
const f = await fixture(t); await f.apply();
await f.api.setAdmin('demo', { ...f.meta, setAt: new Date(START + 1).toISOString() }, f.fake.key, async (op, fn) => fn(), { replace: true });
f.advance(TOKEN_LIFETIME_MS);
await rejectCode(f.manager.withToken('demo', 'coder', () => {}), 'decision-mismatch');
});
test('preflight identity or permission failure is unspent and a retry can run before restart', async (t) => {
const f = await fixture(t); const decision = f.raise();
f.fake.state.permissions.contents = 'read';
await rejectCode(f.manager.run('demo', decision), 'grant-admin-scope');
assert.equal(f.spent.has(decision), false);
f.fake.state.permissions.contents = 'write';
await f.manager.run('demo', decision);
assert.equal(f.spent.has(decision), true);
});
test('an active generation restarts only under its recorded durable approval and timestamp', async (t) => {
const f = await fixture(t); await f.apply();
const restarted = f.create(); restarted.reconcile('demo');
await restarted.withToken('demo', 'coder', (token) => { assert.ok(f.fake.tokens.has(token)); });
f.advance(TOKEN_LIFETIME_MS);
f.business.projects.app.githubId = 102;
await rejectCode(restarted.withToken('demo', 'coder', () => {}), 'decision-mismatch');
});
test('look-alike applied evidence under another kind cannot make a live grant', async (t) => {
const f = await fixture(t); const decision = f.raise();
f.store.run("INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,?,'action.refused',NULL,'grant',NULL,?)",
randomUUID(), new Date(START).toISOString(), 'demo', JSON.stringify({ action: 'grant.applied', method: 'github',
agent: 'coder', grantDecision: decision, generation: randomUUID(), binding: f.approved.get(decision), expiresAt: new Date(START + TOKEN_LIFETIME_MS).toISOString() }));
assert.equal(f.manager.status('demo').grants.length, 0);
await rejectCode(f.manager.withToken('demo', 'coder', () => {}), 'github-not-granted');
});
test('mint intent and grant.call are durable before the fake sees each request', async (t) => {
const f = await fixture(t); const observed = [];
f.fake.intercept(async (r) => {
const events = f.events();
const call = events.findLast((e) => e.action === 'grant.call' && e.method === 'github' && e.op.method === r.method && e.op.path === r.path + r.query);
observed.push(Boolean(call && !events.some((e) => e.action === 'grant.result' && e.callId === call.callId)));
if (r.method === 'POST') observed.push(events.some((e) => e.action === 'grant.step' && e.step === 'mint'));
return false;
});
await f.apply();
assert.ok(observed.length >= 5);
assert.ok(observed.every(Boolean));
});
test('an interrupted service call without its result stays visible after restart', async (t) => {
const f = await fixture(t); const callId = randomUUID();
writeGrantEvent(f.store, new Date(START).toISOString(), 'demo', { action: 'grant.call', method: 'github', callId,
grantDecision: null, agent: null, generation: null, op: { method: 'GET', path: '/app' } });
const restarted = f.create(); restarted.reconcile('demo');
assert.deepEqual(restarted.status('demo').unclosedCalls.map((e) => e.callId), [callId]);
});
test('restart never adopts a known token from an interrupted mint and sweep revokes it', async (t) => {
const f = await fixture(t); const decision = f.raise(), generation = randomUUID();
const result = await f.api.mint('demo', [101], async (op, fn) => fn());
writeGrantEvent(f.store, new Date(START).toISOString(), 'demo', { operation: 'grant.github', decision });
writeGrantEvent(f.store, new Date(START).toISOString(), 'demo', { action: 'grant.step', method: 'github', step: 'mint', agent: 'coder',
grantDecision: decision, generation, until: new Date(START + CALL_TIMEOUT_MS + TOKEN_LIFETIME_MS + CLOCK_SKEW_MS).toISOString(), admin: f.approved.get(decision).admin });
githubWrite(githubDirectory(f.configDir, ['grants', 'demo', 'github', 'tokens', 'coder'], true), generation, result.json.token + '\n');
const restarted = f.create(); restarted.reconcile('demo');
assert.equal(restarted.status('demo').grants.length, 0);
await rejectCode(restarted.withToken('demo', 'coder', () => {}), 'github-not-granted');
await restarted.sweep('demo', 'restart');
assert.equal(f.fake.tokens.size, 0);
assert.equal(restarted.status('demo').serviceLeft.length, 0);
});
test('the per-agent claim refuses a second guarded use while the first callback is live', async (t) => {
const f = await fixture(t); await f.apply();
let release, entered;
const held = new Promise((r) => { entered = r; }), gate = new Promise((r) => { release = r; });
const first = f.manager.withToken('demo', 'coder', async () => { entered(); await gate; });
await held;
await rejectCode(f.manager.withToken('demo', 'coder', () => assert.fail('second consumer')), 'grant-busy');
release(); await first;
await f.manager.withToken('demo', 'coder', () => {});
});
test('N1 sweep closes an expired active generation while preserving its renewable grant', async (t) => {
const f = await fixture(t); const decision = await f.apply();
const active = f.manager.status('demo').grants[0];
const file = join(f.configDir, 'secrets/grants/demo/github/tokens/coder', active.generation);
const intent = f.events().find((e) => e.action === 'grant.step' && e.generation === active.generation);
f.advance(Date.parse(intent.until) - f.now() - 1); await f.manager.sweep('demo', 'jason');
assert.ok(existsSync(file));
f.advance(1); await f.manager.sweep('demo', 'jason');
assert.equal(existsSync(file), false);
assert.equal(f.manager.status('demo').grants[0].decision, decision);
assert.ok(f.events().some((e) => e.action === 'grant.token.closed' && e.generation === active.generation && e.reason === 'expired'));
await f.manager.withToken('demo', 'coder', (token) => assert.ok(f.fake.tokens.has(token)));
assert.ok(f.manager.status('demo').grants[0].generation !== active.generation);
});
test('mutant: closeToken must not count a 401 as proven revocation', async (t) => {
const f = await fixture(t); await f.apply();
f.fake.intercept(async (r, answer) => { if (r.method === 'DELETE') { answer(401, {}); return true; } return false; });
await f.manager.revoke('demo', 'coder', 'test', 'jason');
assert.equal(f.manager.status('demo').serviceLeft.length, 1);
assert.equal(f.fake.tokens.size, 1);
});
for (const status of [500, 503]) test(`mutant: mint ${status} is uncertain issuance and stays serviceLeft`, async (t) => {
const f = await fixture(t);
f.fake.intercept(async (r, answer) => { if (r.method === 'POST') { answer(status, {}); return true; } return false; });
await rejectCode(f.apply(), 'github-failed');
assert.equal(f.manager.status('demo').serviceLeft.length, 1);
assert.equal(f.events().some((e) => e.action === 'grant.token.closed' && e.reason === 'not-issued'), false);
});
test('mutant: shutdown abort alone prevents a pending generation from applying', async (t) => {
const f = await fixture(t); const decision = f.raise();
let release, entered;
const held = new Promise((resolve) => { entered = resolve; }), gate = new Promise((resolve) => { release = resolve; });
f.fake.intercept(async (r) => { if (r.method === 'POST') { entered(); await gate; } return false; });
const pending = f.manager.run('demo', decision);
const refused = rejectCode(pending, 'revoked');
await held;
const closed = f.manager.close(); release();
await refused; await closed;
assert.equal(f.manager.status('demo').grants.length, 0);
assert.equal(f.fake.tokens.size, 0);
});
+4 -18
View File
@@ -129,7 +129,7 @@ function checkRoles(roles, file, rolesDir, business) {
requireId(instance, `${where} instance name`);
const at = `${where}.${instance}`;
requireObject(entry, at);
rejectUnknownKeys(entry, ["definition", "holder", "vars", "tracker", "credentials", "identity", "publish", "hosts", "github"], at);
rejectUnknownKeys(entry, ["definition", "holder", "vars", "tracker", "credentials", "identity", "publish", "hosts"], at);
requireId(entry.definition, `${at}.definition`);
const definition = Object.hasOwn(definitions, entry.definition) ? definitions[entry.definition] : loadRole(rolesDir, entry.definition);
definitions[entry.definition] = definition;
@@ -137,7 +137,7 @@ function checkRoles(roles, file, rolesDir, business) {
const services = definition.credentials.map((c) => c.service);
if (entry.hosts !== undefined) refuse(`${at}.hosts ${HOSTS_MOVED}`);
// A host.<id> key reference comes from the agent's grant record at bus start (r6 X4), never from here.
const kinds = services.filter((s) => s !== HOST_SERVICE && s !== "github");
const kinds = services.filter((s) => s !== HOST_SERVICE);
let tracker = null;
if (services.includes("vikunja")) {
if (entry.tracker === undefined) refuse(`${at} needs "tracker" with its Vikunja bot, because ${entry.definition} uses vikunja`);
@@ -166,14 +166,6 @@ function checkRoles(roles, file, rolesDir, business) {
if (!out[instance].identity || !services.includes("gitea")) refuse(`${at}.publish needs identity and a gitea definition`);
out[instance].publish = requireDistinctList(entry.publish, `${at}.publish`, (p) => requireId(p, `${at}.publish entry`), { nonEmpty: true });
}
// Row297: App grants have fixed scopes and protected token storage, not a generic credential ref.
if (entry.github !== undefined) {
if (!services.includes("github")) refuse(`${at}.github needs a github definition`);
requireObject(entry.github, `${at}.github`);
rejectUnknownKeys(entry.github, ["projects"], `${at}.github`);
out[instance].github = { projects: requireDistinctList(entry.github.projects, `${at}.github.projects`,
(p) => requireId(p, `${at}.github.projects entry`), { nonEmpty: true }) };
}
checkPersonaVars(out[instance].vars, entry.definition, at);
}
return { roles: out, definitions };
@@ -275,7 +267,7 @@ export function validateBusinessDocument(document, file, { rolesDir }) {
projects[id] = checkMapping(entry, `${file} projects.${id}`, { fail: (code, message) => refuse(`${code}: ${message}`) });
continue;
}
rejectUnknownKeys(entry, ["root", "repo", "remote", "githubId"], `${file} projects.${id}`);
rejectUnknownKeys(entry, ["root", "repo", "remote"], `${file} projects.${id}`);
if (typeof entry.root !== "string" || !isAbsolute(entry.root) || normalize(entry.root) !== entry.root || entry.root.includes("\0")) {
refuse(`${file} projects.${id}.root must be a normalized absolute path`);
}
@@ -285,16 +277,10 @@ export function validateBusinessDocument(document, file, { rolesDir }) {
}
// Row 199 (#1697, design D9 step 2): the upstream; a granted agent's work goes to its fork.
if (Object.hasOwn(entry, "remote")) httpsUrl(entry.remote, `${file} projects.${id}.remote`, { repo: true });
if (entry.githubId !== undefined) requirePositiveInt(entry.githubId, `${file} projects.${id}.githubId`);
projects[id] = { root: entry.root, ...(entry.githubId !== undefined ? { githubId: entry.githubId } : {}), ...(Object.hasOwn(entry, "repo") ? { repo: entry.repo } : {}),
projects[id] = { root: entry.root, ...(Object.hasOwn(entry, "repo") ? { repo: entry.repo } : {}),
...(Object.hasOwn(entry, "remote") ? { remote: entry.remote } : {}) };
}
for (const [instance, role] of Object.entries(roles)) {
for (const project of role.github?.projects ?? []) {
const p = projects[project];
if (!p || p.kind === 'library' || !p.remote || !Number.isSafeInteger(p.githubId) || p.githubId <= 0)
refuse(`${file} roles.${instance}.github names ${project}, which needs a repository remote and githubId`);
}
for (const project of role.publish ?? []) {
if (!Object.hasOwn(projects, project)) refuse(`${file} roles.${instance}.publish names ${project}, which the business file doesn't declare`);
if (projects[project].kind === 'library') refuse(`${file} roles.${instance}.publish names library project ${project}; library repositories cannot be published by agents`);
+1 -1
View File
@@ -108,7 +108,7 @@ function definitionFor(rolesDir, name, { credentials = false } = {}) {
// Vikunja bot the human names. The add makes no Vikunja account. Any other definition refuses it.
function createdCredentials(old, instance, definition, args) {
// webby-0094 N2: a host grant is no token reference (business.mjs checkRoles drops it too).
const services = definition.credentials.map(c => c.service).filter(service => service !== HOST_SERVICE && service !== "github");
const services = definition.credentials.map(c => c.service).filter(service => service !== HOST_SERVICE);
const out = {};
if (services.includes("vikunja")) {
if (!Object.hasOwn(args, "tracker")) {
+3 -5
View File
@@ -131,15 +131,13 @@ function checkCredentials(credentials) {
requireObject(entry, `role credentials[${i}]`);
// Row 210 (#1707, design D1): "host" takes no scopes; the business file names the hosts.
const host = entry.service === HOST_SERVICE;
const github = entry.service === "github";
rejectUnknownKeys(entry, (host || github) ? ["service"] : ["service", "scopes"], `role credentials[${i}]`);
if (!host && !github && !SERVICES.includes(entry.service)) {
refuse(`role credentials[${i}].service must be one of: ${[...SERVICES, HOST_SERVICE, "github"].join(", ")}`);
rejectUnknownKeys(entry, host ? ["service"] : ["service", "scopes"], `role credentials[${i}]`);
if (!host && !SERVICES.includes(entry.service)) {
refuse(`role credentials[${i}].service must be one of: ${[...SERVICES, HOST_SERVICE].join(", ")}`);
}
if (seen.has(entry.service)) refuse(`role credentials name ${entry.service} twice`);
seen.add(entry.service);
if (host) return { service: HOST_SERVICE };
if (github) return { service: "github" };
const scopes = entry.service === "gitea" ? checkGiteaScopes(entry.scopes) : checkVikunjaScopes(entry.scopes);
return { service: entry.service, scopes };
});
-73
View File
@@ -1,73 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { loadRole, validateBusinessDocument } from '../src/index.mjs';
import { instanceSettings } from '../src/instance-settings.mjs';
import { businessDoc, rolesCopy, systemFor, tmp, writeJson } from './helpers.mjs';
function fixture(t) {
const root = tmp(); t.after(() => rmSync(root, { recursive: true, force: true }));
const rolesDir = rolesCopy(root);
const file = join(rolesDir, 'coder.json');
const definition = JSON.parse(readFileSync(file, 'utf8'));
definition.credentials.push({ service: 'github' });
writeJson(file, definition);
const doc = businessDoc(root);
doc.roles.coder.github = { projects: ['stack'] };
doc.projects.stack.remote = 'https://github.com/acme/stack';
doc.projects.stack.githubId = 101;
const validate = () => validateBusinessDocument(doc, join(root, 'businesses/acme.json'), { rolesDir });
return { root, rolesDir, file, definition, doc, validate };
}
test('GitHub fixed declaration and repository binding load without generic token references', (t) => {
const f = fixture(t);
assert.deepEqual(loadRole(f.rolesDir, 'coder').credentials.at(-1), { service: 'github' });
const b = f.validate();
assert.deepEqual(b.roles.coder.github, { projects: ['stack'] });
assert.equal(b.projects.stack.githubId, 101);
assert.equal(Object.hasOwn(b.roles.coder.credentials, 'github'), false);
});
test('GitHub declaration refuses caller scopes and role account/fork requests', (t) => {
const f = fixture(t);
f.definition.credentials.at(-1).scopes = ['all']; writeJson(f.file, f.definition);
assert.throws(() => loadRole(f.rolesDir, 'coder'), /unsupported.*scopes/);
delete f.definition.credentials.at(-1).scopes; writeJson(f.file, f.definition);
for (const field of ['account', 'fork']) {
f.doc.roles.coder.github[field] = true;
assert.throws(f.validate, new RegExp('unsupported.*' + field));
delete f.doc.roles.coder.github[field];
}
});
test('GitHub bindings refuse absent declaration, unknown project and missing repository ID', (t) => {
const f = fixture(t);
delete f.doc.projects.stack.githubId;
assert.throws(f.validate, /githubId/);
f.doc.projects.stack.githubId = 101;
f.doc.roles.coder.github.projects = ['missing'];
assert.throws(f.validate, /github.*missing/);
f.doc.roles.coder.github.projects = ['stack'];
f.definition.credentials.pop(); writeJson(f.file, f.definition);
assert.throws(f.validate, /github.*definition/);
});
// mos-1518: preserve closed-service-list coverage when github becomes a fixed declaration.
test('a service outside the closed list still refuses with service must be one of', (t) => {
const f = fixture(t); f.definition.credentials.at(-1).service = 'not-a-service';
writeJson(f.file, f.definition);
assert.throws(() => loadRole(f.rolesDir, 'coder'), /service must be one of/);
});
test('human add of a GitHub-declaring definition creates only generic service references', (t) => {
const f = fixture(t), dir = join(f.root, 'config');
const file = writeJson(join(dir, 'businesses/acme.json'), f.doc);
const settings = instanceSettings({ dir, rolesDir: f.rolesDir, system: systemFor(f.root) });
settings.set('acme', { revision: settings.read('acme').revision, instance: 'coder-two', op: 'add', definition: 'coder',
tracker: { bot: 'bot-acme-coder-two', botId: 40 } }, 'jason');
const entry = JSON.parse(readFileSync(file, 'utf8')).roles['coder-two'];
assert.deepEqual(Object.keys(entry.credentials).sort(), ['gitea', 'vikunja']);
assert.equal(Object.hasOwn(entry, 'github'), false);
});
+1 -1
View File
@@ -206,7 +206,7 @@ test("credentials: Vikunja scopes are a group-to-verbs map from the grantable li
});
test("credentials: services", () => {
refuses(() => check({ credentials: [{ service: "github", scopes: [] }] }), /unsupported.*scopes/);
refuses(() => check({ credentials: [{ service: "github", scopes: [] }] }), /service must be one of/);
refuses(() => check({ credentials: [{ service: "gitea", scopes: ["read:issue"] }, { service: "gitea", scopes: ["read:user"] }] }), /twice/);
refuses(() => check({ credentials: [{ service: "gitea", scopes: ["read:issue"], token: "x" }] }), /unsupported role credentials\[0\] key/);
assert.deepEqual(check({ credentials: [] }).credentials, []);
-43
View File
@@ -1,43 +0,0 @@
// Row297 CLI shapes. Only the broker parses/uses the RSA private key.
import { CliError } from './errors.mjs';
const USAGE = 'mosaic grants status|sweep github [--json] [--tester] | revoke AGENT github --reason R | admin set github --url HTTPS_ORIGIN --app-id ID --installation-id ID --account-id ID --rotate-by DATE | admin rotate github --rotate-by DATE | admin remove github';
const SHAPES = {
status: { n: 1, flags: ['--json', '--tester'], need: [] },
sweep: { n: 1, flags: ['--tester'], need: [] },
revoke: { n: 2, flags: ['--tester'], need: ['--reason'] },
'admin set': { n: 1, flags: [], need: ['--url', '--app-id', '--installation-id', '--account-id', '--rotate-by'] },
'admin rotate': { n: 1, flags: [], need: ['--rotate-by'] },
'admin remove': { n: 1, flags: [], need: [] },
};
const usage = () => { throw new CliError(USAGE, 4); };
async function stdinKey(stdin) {
let text = '';
for await (const chunk of stdin) { text += chunk; if (text.length > 16384) throw new CliError('refused: App key on stdin is too long', 2); }
if (!text.trim()) throw new CliError('refused: no App key on stdin', 4);
return text;
}
const positive = (text) => {
if (typeof text !== 'string' || !/^[1-9][0-9]*$/.test(text) || !Number.isSafeInteger(Number(text))) usage();
return Number(text);
};
function format(status) {
const lines = [status.admin ? `admin: App ${status.admin.appId}, installation ${status.admin.installationId}, account ${status.admin.accountId} on ${status.admin.url}, rotate by ${status.admin.rotateBy}` : 'admin: none'];
for (const g of status.grants) lines.push(`grant\t${g.agent}\t${g.projects.join(',')}\texpires ${g.expiresAt}\trotate by ${g.rotateBy}\t${g.decision}`);
for (const g of status.serviceLeft) lines.push(`left\t${g.agent}\t${g.generation}\tuntil ${g.until}`);
return lines.join('\n') + '\n';
}
export async function githubGrantsCommand(parsed, verb, rest, io, callFor) {
const shape = Object.hasOwn(SHAPES, verb) ? SHAPES[verb] : null;
const v = parsed.values;
if (!shape || rest.length !== shape.n || [...parsed.flags].some((f) => !shape.flags.includes(f)) ||
Object.keys(v).some((k) => k !== '--business' && !shape.need.includes(k)) || shape.need.some((k) => !Object.hasOwn(v, k))) usage();
let args = { service: 'github' };
if (verb === 'revoke') args = { ...args, agent: rest[0], reason: v['--reason'] };
if (verb === 'admin set') args = { ...args, url: v['--url'], appId: positive(v['--app-id']), installationId: positive(v['--installation-id']), accountId: positive(v['--account-id']), rotateBy: v['--rotate-by'] };
if (verb === 'admin rotate') args.rotateBy = v['--rotate-by'];
if (verb === 'admin set' || verb === 'admin rotate') args.token = await stdinKey(io.stdin);
try {
const result = await callFor(parsed)('grants.' + verb.replace(' ', '.'), args);
io.stdout.write(verb === 'status' && !parsed.flags.has('--json') ? format(result) : JSON.stringify(result, null, 2) + '\n');
} catch (e) { if (e instanceof CliError) throw e; throw new CliError(e.message, 2); }
}
+2 -5
View File
@@ -4,16 +4,14 @@
// and sweep the human's or, with --tester, the tester's. admin set reads the admin token on stdin, never
// argv, and nothing here prints a token: the answers carry ids and names only.
import { CliError } from "./errors.mjs";
import { githubGrantsCommand } from "./github-grants.mjs";
export const GRANTS_USAGE =
"mosaic grants status gitea [--json] [--tester] [--business ID] | run DECISION [--business ID] | " +
"revoke AGENT gitea --reason R [--tester] [--business ID] | sweep gitea [--tester] [--business ID] | " +
"admin set gitea --url URL --account NAME --rotate-by DATE [--business ID] | " +
"admin rotate gitea --rotate-by DATE | admin rotate gitea --finish | admin remove gitea | " +
"status|sweep github | revoke AGENT github --reason R | admin set github --url URL --app-id ID --installation-id ID --account-id ID --rotate-by DATE | admin rotate github --rotate-by DATE | admin remove github";
"admin rotate gitea --rotate-by DATE | admin rotate gitea --finish | admin remove gitea";
const MAX_TOKEN = 16384;
const VALUES = ["--business", "--reason", "--url", "--account", "--rotate-by", "--app-id", "--installation-id", "--account-id"];
const VALUES = ["--business", "--reason", "--url", "--account", "--rotate-by"];
function parse(args) {
const out = { positional: [], flags: new Set(), values: {} };
@@ -74,7 +72,6 @@ export async function grantsCommand(args, io, callFor) {
verb = `admin ${rest[0] ?? ""}`;
rest = rest.slice(1);
}
if (verb !== "run" && rest.at(-1) === "github") return githubGrantsCommand(parsed, verb, rest, io, callFor);
// Own keys only, so `constructor` is a usage error, not a verb.
const shape = Object.hasOwn(SHAPES, verb ?? "") ? SHAPES[verb] : null;
if (
-42
View File
@@ -1,42 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { main } from '../src/cli.mjs';
import { io } from './helpers.mjs';
const setup = () => {
const calls = [];
const transport = () => async (verb, args) => { calls.push({ verb, args }); return { grants: [], serviceLeft: [], admin: null }; };
return { calls, deps: { system: { dataRoot: '/nonexistent' }, transport, testerTransport: transport } };
};
test('GitHub admin set uses separate identity flags and bounded stdin', async () => {
const f = setup(), x = io(); const fakeKey = '-----BEGIN PRIVATE KEY-----\nfake-only-not-a-real-key\n-----END PRIVATE KEY-----\n';
x.stdin.end(fakeKey);
await main(['grants', 'admin', 'set', 'github', '--business', 'demo', '--url', 'https://api.github.com',
'--app-id', '10', '--installation-id', '20', '--account-id', '30', '--rotate-by', '2099-01-01'], x, f.deps);
assert.equal(f.calls.length, 1);
assert.deepEqual(f.calls[0], { verb: 'grants.admin.set', args: { service: 'github', url: 'https://api.github.com',
appId: 10, installationId: 20, accountId: 30, rotateBy: '2099-01-01', token: fakeKey } });
assert.ok(!x.out.text.includes(fakeKey) && !x.err.text.includes(fakeKey));
});
test('GitHub rotate replaces the human-supplied key rather than Gitea token minting', async () => {
const f = setup(), x = io(); x.stdin.end('fake-only-key-value');
await main(['grants', 'admin', 'rotate', 'github', '--business', 'demo', '--rotate-by', '2099-01-01'], x, f.deps);
assert.equal(f.calls[0].verb, 'grants.admin.rotate');
assert.equal(f.calls[0].args.service, 'github');
assert.equal(f.calls[0].args.rotateBy, '2099-01-01');
});
test('GitHub management dispatch and malformed flag shapes', async () => {
const f = setup();
await main(['grants', 'status', 'github', '--business', 'demo', '--json'], io(), f.deps);
await main(['grants', 'revoke', 'coder', 'github', '--business', 'demo', '--reason', 'test'], io(), f.deps);
await main(['grants', 'sweep', 'github', '--business', 'demo'], io(), f.deps);
assert.deepEqual(f.calls.map((c) => c.verb), ['grants.status', 'grants.revoke', 'grants.sweep']);
for (const args of [
['admin', 'set', 'github', '--account', '20'], ['admin', 'rotate', 'github', '--finish'],
['status', 'github', '--app-id', '10'], ['admin', 'remove', 'github', '--tester'],
['admin', 'set', 'github', '--url', 'https://api.github.com', '--app-id', 'NaN', '--installation-id', '20', '--account-id', '30', '--rotate-by', '2099-01-01'],
]) await assert.rejects(main(['grants', ...args, '--business', 'demo'], io(), f.deps), (e) => e.exitCode === 4);
assert.equal(f.calls.length, 3);
});
+1 -1
View File
@@ -142,7 +142,7 @@ test('real pinned install loads bus and Console; same-copy mosaic talk passes pr
host.stdout.on('data',c=>hostOut+=c);host.stderr.on('data',c=>hostErr+=c);
try{await until(()=>hostOut.includes('bus host up')||host.exitCode!==null);assert.match(hostOut,/bus host up/,hostErr);}
finally{if(host.exitCode===null&&host.signalCode===null){const done=once(host,'close');host.kill('SIGTERM');await done;}}
const web=spawn(process.execPath,[join(root,'packages/webui/src/cli.mjs'),'serve','--port','0','--business','acme'],{env,stdio:['ignore','pipe','pipe']});let webOut='',webErr='';
const web=spawn(process.execPath,[join(root,'packages/webui/src/cli.mjs'),'serve','--port','0','--business','acme','--board','http://127.0.0.1:9'],{env,stdio:['ignore','pipe','pipe']});let webOut='',webErr='';
web.stdout.on('data',c=>webOut+=c);web.stderr.on('data',c=>webErr+=c);
try{await until(()=>/Mosaic Console: http:/.test(webOut)||web.exitCode!==null);const port=webOut.match(/127\.0\.0\.1:(\d+)/)?.[1];assert.ok(port,webErr);assert.equal((await fetch(`http://127.0.0.1:${port}/healthz`)).status,200);}
finally{if(web.exitCode===null&&web.signalCode===null){const done=once(web,'close');web.kill();await done;}}
-58
View File
@@ -851,64 +851,6 @@ test('T44 row 180 item 2: --plan lists tester-port-invalid and tester-port-targe
});
});
// Row 198 (design item 8, B2; sage-2834 ruling a; sage-2837): --board is dropped for every op, because
// webui-service.sh install refuses the flag. The reason follows the target's cli.mjs: a target that
// parses --board is pre-198 (7331 retired); a target that names it only in its refusal runs the board
// in the Console. The plan names each drop. setCli rewrites a shared copy's cli.mjs and restores it after.
const BOARD='--board http://127.0.0.1:9';
const REFUSES_BOARD=`// fixture Console, never run\n// serve accepts '--tester-port'\nif (flag === '--board') throw new Error('--board retired: the board runs in the Console (row 198)');\n`;
const PARSES_BOARD=`// fixture Console, never run\n// serve accepts '--tester-port'\nelse if (flag === '--board') board = value;\n`;
const setCli=(t,id,text)=>{const f=join(COPIES,id,'packages/webui/src/cli.mjs'),orig=readFileSync(f,'utf8');chmodSync(f,0o644);writeFileSync(f,text);t.after(()=>{chmodSync(f,0o644);writeFileSync(f,orig);chmodSync(f,0o444)})};
const withBoard=(e)=>{const q=pointer(e);q.console=[...q.console,...BOARD.split(' ')];writeFileSync(e.pointer,JSON.stringify(q)+'\n')};
test('row 198 item 8 (M12): a re-pin plan drops a --board the target Console does not parse, and its Console line has none',async(t)=>{
const e=await mk(t);await seed(e);setCli(t,C1,REFUSES_BOARD);withBoard(e);
const before=fp(e),n=calls(e).length,logs=logLines(e).length;
const r=await run(e,'activate',C1,'acme','--plan');
assert.equal(r.code,0,r.out+r.err);assert.equal(r.err,'');
assert.ok(r.out.split('\n').includes(`drop ${BOARD}; the target Console does not parse it, so the board runs in the Console`),r.out);
assert.doesNotMatch(r.out.split('\n').find((l)=>l.startsWith('Console: ')),/--board/);
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
});
test('row 198 item 8 (M13, activate): an activate to a pre-198 copy that parses --board drops the unit\'s --board, and the install and unit have none',async(t)=>{
// Sage sage-2837: a target that parses --board is pre-198, and webui-service.sh install still refuses the flag.
// The unit carries the flag before, as a pre-198 Console does. The plan is checked first, then a real run.
const e=await mk(t);await seed(e);setCli(t,C1,PARSES_BOARD);
edit(join(e.units,CON),'--business acme',`--business acme ${BOARD}`);withBoard(e);
const before=fp(e),n=calls(e).length,logs=logLines(e).length;
let r=await run(e,'activate',C1,'acme','--plan');
assert.equal(r.code,0,r.out+r.err);assert.equal(r.err,'');
const lines=r.out.split('\n');
assert.ok(lines.includes(`drop ${BOARD}; the 7331 board is retired, so this Console runs without the board`),r.out);
assert.doesNotMatch(lines.find((l)=>l.startsWith('Console: ')),/--board/);
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
r=await run(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
assert.ok(r.out.split('\n').includes(`drop ${BOARD}; the 7331 board is retired, so this Console runs without the board`),r.out);
assert.equal(pointer(e).copy,C1);assert.equal(pointer(e).console.includes('--board'),false);
assert.doesNotMatch(consoleText(e),/--board/);
});
test('row 198 item 8 (M13, rollback): a rollback drops the unit\'s --board even for a copy that parses it, and the install and unit have none',async(t)=>{
// Sage sage-2834 ruling (a), sage-2837: webui-service.sh install refuses --board, so a rollback drops it and
// names the drop with the retired-board reason. The activation runs without --board; the unit then carries it,
// and the rollback target B parses it, which must not keep the flag. The plan is checked first, then a real run.
const e=await mk(t);setCli(t,B,PARSES_BOARD);
await seed(e);
let r=await run(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
edit(join(e.units,CON),'--business acme',`--business acme ${BOARD}`);withBoard(e);
const before=fp(e);
r=await run(e,'rollback','acme','--plan');assert.equal(r.code,0,r.out+r.err);
const lines=r.out.split('\n');
assert.ok(lines.includes(`drop ${BOARD}; the 7331 board is retired, so this Console runs without the board`),r.out);
assert.doesNotMatch(lines.find((l)=>l.startsWith('Console: ')),/--board/);
assert.deepEqual(fp(e),before);
r=await run(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);
assert.equal(pointer(e).copy,B);assert.equal(pointer(e).console.includes('--board'),false);
assert.doesNotMatch(consoleText(e),/--board/);
assert.match(stepText(e),/drop --board http:\/\/127\.0\.0\.1:9; the 7331 board is retired/);
});
// Row 181 (#1679, P19 390): COUNTED and STORE_JS as the script under test has them. The fixture
// tables carry only seq and at, which is all the count reads.
const srText=readFileSync(SR,'utf8');
+12 -17
View File
@@ -100,22 +100,14 @@ service transitions or authorize replacing a running board.
```
node src/cli.mjs scan [--config PATH] [--repo PATH] [--fleet PATH|none] [--liveness tmux|assume-alive] [--print]
node src/cli.mjs serve [same flags] [--port N] [--host 127.0.0.1]
```
`scan` runs once and writes the status files.
`serve` is retired (row 198). It exits 2 with a pointer to the Console. The
board now runs in-process inside the Console (`packages/webui`), behind the
Console's own sign-in. Start it with:
```
node packages/webui/src/cli.mjs serve
```
Then open `http://127.0.0.1:7330/` in a browser. The board's routes are
`/api/board` and the two writes `/api/seen` and `/api/reply`, which need
`--send` on the public name. The page fetches `/api/board` every 10 seconds;
each fetch re-runs the scan, so the page is never staler than that timer.
`scan` runs once and writes the status files. `serve` starts a small local
web server: open `http://127.0.0.1:7331/` in a browser. The page fetches
`/api/board` every 10 seconds; each fetch re-runs the scan, so the page is
never staler than that timer. There is no login, so the server refuses to
bind to anything but a loopback address.
- `--config PATH` — path to the system config file. Defaults to
`~/.config/mosaic-dev/config.json`. This file must exist and name an
@@ -132,8 +124,11 @@ each fetch re-runs the scan, so the page is never staler than that timer.
first column is `*` for a row waiting on you, `s` for a seen row, or blank
otherwise. The final summary line reads
`board: <dir> (N sessions, N waiting on you, N seen)`.
Routes (row 198: served by the Console, `packages/webui`, not by a board port;
the bodies and refusals below are unchanged):
- `--port N` — (`serve`) port to listen on. Default `7331`; `0` picks a free port.
- `--host ADDR` — (`serve`) loopback address to bind. Default `127.0.0.1`.
Any non-loopback address is refused.
Routes served:
- `GET /` — the page.
- `GET /api/board` — rescan, returns `index.json`.
@@ -175,7 +170,7 @@ so the seat's reaction shows without waiting for the next tick.
## Exit codes
- `0` — scan completed and status files were written.
- `0` — scan completed and status files were written, or the server stopped cleanly.
- `2` — refused: bad config, missing/invalid `dataRoot`, or bad arguments.
The message on stderr says why.
-78
View File
@@ -1,78 +0,0 @@
// The control board's routes as plain async functions (row 198). The Console
// (packages/webui) calls these in-process behind its own auth. Nothing here
// opens a socket or binds a port: the 7331 writer is retired.
//
// Every method returns a promise and uses the async child-process paths (B1), so
// a slow tmux call never blocks the Console's event loop.
//
// Write counter (decision 10, Webby N1). A scan must not serve a pre-write view.
// index() joins an in-flight scan only when that scan started at the current
// counter value. markSeen() and reply() bump the counter and never join a scan.
import { scanAsync, markSeen as writeMark, loadRegistrations, runAsync } from "./scan.mjs";
import { replyToRowAsync, conversationResponse, DEFAULT_AGENT_SEND } from "./serve.mjs";
import { createReader, rootsFromSpecs } from "../../conversation/src/reader.mjs";
// onEvent(name) reports "write", "bump", "send" and "scan" in order. Production
// passes nothing; the order test (board.test.mjs) reads it to pin the sequence.
export function createBoard({ specs, boardDir, seatsDir = null, discordDataRoot = null, isAlive, isPidAlive, now = () => new Date(), agentSend = DEFAULT_AGENT_SEND, exec = runAsync, conversationReader = null, host, onEvent = () => {} }) {
let writes = 0;
let inflight = null;
const scanFresh = () => {
onEvent("scan");
return scanAsync(specs, { isAlive, isPidAlive, exec, boardDir, now, seatsDir, discordDataRoot });
};
const reader = conversationReader ?? createReader({ roots: () => rootsFromSpecs(specs, loadRegistrations(seatsDir).registrations) });
// Joins a scan only when it started after the last write and is still running
// (see header). A settled scan, fulfilled or rejected, is cleared so the next
// index() starts a fresh one; the clear is on both branches, so a rejection is
// not left unhandled.
function index() {
if (inflight && inflight.generation === writes) return inflight.promise;
const generation = writes;
const promise = scanFresh();
const current = { generation, promise };
inflight = current;
const clear = () => { if (inflight === current) inflight = null; };
promise.then(clear, clear);
return promise;
}
// Pinned order (Webby, Mos mos-1052): write, then bump the counter, then scan.
// The scan sees the mark, and no scan started before the bump can be joined.
async function markSeenRoute(body) {
writeMark(boardDir, { project: body.project, agent: body.agent, lastActivity: body.lastActivity, seen: body.seen ?? true });
onEvent("write");
writes++;
onEvent("bump");
return scanFresh();
}
// Pinned order: send, then bump the counter, then scan. The planning scan
// before the send is a read and is not part of the pinned sequence. A refusal
// runs no send, so it writes nothing: no bump and no post-send scan. Only a
// send that reached the exec runs the rest. The post-send scan rewrites the
// derived board files, so a reply's effects reach them the same way a mark does.
async function reply(body) {
const planned = await scanFresh();
let sent = false;
const sendExec = (...args) => {
sent = true;
return exec(...args);
};
const out = await replyToRowAsync({ index: planned, key: body.agent, text: body.text, agentSend, exec: sendExec, now, host });
if (!sent) return out;
onEvent("send");
writes++;
onEvent("bump");
await scanFresh();
return out;
}
function conversation(url) {
return conversationResponse(reader, url);
}
return { index, markSeen: markSeenRoute, reply, conversation };
}
+18 -6
View File
@@ -1,18 +1,17 @@
#!/usr/bin/env node
// Usage:
// node packages/control-board/src/cli.mjs scan [--config PATH] [--repo PATH] [--fleet PATH|none] [--liveness tmux|assume-alive] [--print]
// `serve` is retired (row 198): the board runs in-process inside the Console.
// node packages/control-board/src/cli.mjs serve [same flags] [--port N] [--host 127.0.0.1]
// Exit 0 on success, 2 on a config refusal or bad usage.
import { join } from "node:path";
import { homedir } from "node:os";
import { loadConfig, defaultConfigPath, discoverRepoAgents, discoverFleetAgents, scan, tmuxInspect, ConfigError } from "./scan.mjs";
import { startServer } from "./serve.mjs";
const USAGE = "usage: mosaic-board scan [--config PATH] [--repo PATH] [--fleet PATH|none] [--liveness tmux|assume-alive] [--print]";
const RETIRED = "the control board no longer serves a port (retired on 7331, row 198). Open the board in the Console (webui, port 7332) instead.";
const USAGE = "usage: mosaic-board scan|serve [--config PATH] [--repo PATH] [--fleet PATH|none] [--liveness tmux|assume-alive] [--print] [--port N] [--host 127.0.0.1]";
function parseArgs(argv) {
if (argv[0] === "serve") throw new ConfigError(RETIRED);
const opts = { command: argv[0], config: defaultConfigPath(), repo: process.cwd(), fleet: join(homedir(), ".mosaic", "fleet", "agents"), liveness: "tmux", print: false };
const opts = { command: argv[0], config: defaultConfigPath(), repo: process.cwd(), fleet: join(homedir(), ".mosaic", "fleet", "agents"), liveness: "tmux", print: false, port: 7331, host: "127.0.0.1" };
for (let i = 1; i < argv.length; i++) {
const a = argv[i];
const next = () => {
@@ -24,9 +23,13 @@ function parseArgs(argv) {
else if (a === "--fleet") opts.fleet = next();
else if (a === "--liveness") opts.liveness = next();
else if (a === "--print") opts.print = true;
else if (a === "--port") {
opts.port = Number(next());
if (!Number.isInteger(opts.port) || opts.port < 0 || opts.port > 65535) throw new ConfigError("--port must be an integer 0..65535");
} else if (a === "--host") opts.host = next();
else throw new ConfigError(`unknown argument: ${a}`);
}
if (opts.command !== "scan") throw new ConfigError(USAGE);
if (opts.command !== "scan" && opts.command !== "serve") throw new ConfigError(USAGE);
if (!["tmux", "assume-alive"].includes(opts.liveness)) throw new ConfigError(`unknown liveness mode: ${opts.liveness}`);
return opts;
}
@@ -38,6 +41,15 @@ async function main() {
const isAlive = opts.liveness === "tmux" ? tmuxInspect : () => true;
const boardDir = join(dataRoot, "board");
const seatsDir = join(dataRoot, "seats");
if (opts.command === "serve") {
const server = await startServer({ host: opts.host, port: opts.port, specs, boardDir, isAlive, seatsDir, discordDataRoot: dataRoot });
const addr = server.address();
process.stdout.write(`control board: http://${opts.host}:${addr.port}/ (${specs.length} agents; board files in ${boardDir}; Ctrl-C to stop)\n`);
const stop = () => server.close(() => process.exit(0));
process.on("SIGINT", stop);
process.on("SIGTERM", stop);
return;
}
const index = scan(specs, { boardDir, isAlive, seatsDir, discordDataRoot: dataRoot });
for (const line of index.discoveryErrors) process.stderr.write(`${line}\n`);
for (const line of index.registrationErrors) process.stderr.write(`registration skipped: ${line}\n`);
+605
View File
@@ -0,0 +1,605 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Control board</title>
<style>
:root{
--canvas:#f4f6f8;--surface:#fff;--raised:#e9edf1;--text:#16202a;--muted:#5a6774;
--line:#e2e7ec;--action:#1f5f8b;--onAction:#fff;--accent:#c96a2b;
--success:#2e7d4f;--warning:#b26a00;--danger:#b3261e;
--font:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
--mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace;
--radius:8px;--radius-lg:14px;
}
@media (prefers-color-scheme: dark){
:root{
--canvas:#11161c;--surface:#182029;--raised:#1f2833;--text:#e7edf3;--muted:#94a3b0;
--line:#2a343f;--action:#6ba6d6;--onAction:#0b1116;--accent:#e08a4c;
--success:#57b586;--warning:#e0a63c;--danger:#e58077;
}
}
*,*::before,*::after{box-sizing:border-box}
html{font-family:var(--font);background:var(--canvas);color:var(--text);line-height:1.45}
body{margin:0 auto;max-width:1100px;padding:16px}
h1,h2,h3{margin:0 0 .4em;line-height:1.2;font-weight:600}
h1{font-size:1.5rem}h2{font-size:1.1rem;margin-top:1.4em}h3{font-size:.95rem}
:focus-visible{outline:3px solid var(--action);outline-offset:2px;border-radius:4px}
.page-head{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:8px 16px}
.head-controls{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.status-line{margin:0;color:var(--muted);font-size:.9rem}
.btn{font:inherit;font-weight:600;font-size:.85rem;min-height:34px;padding:6px 12px;border-radius:var(--radius);
border:1px solid var(--line);background:var(--surface);color:var(--text);cursor:pointer}
.btn:hover{background:var(--raised)}
.error-banner{margin:12px 0;padding:10px 14px;border-radius:var(--radius);border:1px solid var(--danger);
background:color-mix(in srgb,var(--danger) 10%,var(--surface));color:var(--danger);font-size:.9rem}
.empty{color:var(--muted);font-style:italic}
#seenDetails summary{cursor:pointer;list-style:none;display:flex;align-items:center;gap:8px}
#seenDetails summary::-webkit-details-marker{display:none}
#seenDetails summary::before{content:"\25B8";color:var(--muted);font-size:.9em}
#seenDetails[open] summary::before{content:"\25BE"}
#seenDetails summary h2{display:inline;margin:0}
.table-wrap{overflow-x:auto;border:1px solid var(--line);border-radius:var(--radius-lg);background:var(--surface)}
table{width:100%;border-collapse:collapse;font-size:.88rem}
th,td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--line);vertical-align:top}
th{font-size:.72rem;text-transform:uppercase;letter-spacing:.03em;color:var(--muted);font-weight:600;white-space:nowrap}
tbody tr:last-child td{border-bottom:none}
tr.is-offline{opacity:.55}
.badge{display:inline-flex;align-items:center;gap:5px;font-size:.76rem;font-weight:600;padding:2px 9px;
border-radius:99px;border:1px solid transparent;white-space:nowrap}
.badge::before{content:"";width:6px;height:6px;border-radius:50%;background:currentColor}
.badge-ok{color:var(--success);background:color-mix(in srgb,var(--success) 12%,var(--surface))}
.badge-warn{color:var(--warning);background:color-mix(in srgb,var(--warning) 12%,var(--surface))}
.badge-danger{color:var(--danger);background:color-mix(in srgb,var(--danger) 12%,var(--surface))}
.badge-muted{color:var(--muted);background:var(--raised)}
.badge-accent{color:var(--accent);background:color-mix(in srgb,var(--accent) 12%,var(--surface))}
.row-toggle{font:inherit;font-weight:600;background:none;border:0;padding:2px 0;color:var(--action);
cursor:pointer;text-align:left}
.row-toggle:hover{text-decoration:underline}
.seen-toggle{font:inherit;font-size:.76rem;background:none;border:1px solid var(--border);border-radius:6px;
padding:1px 8px;color:var(--action);cursor:pointer;margin-left:6px;vertical-align:middle}
.seen-toggle:hover{border-color:var(--action)}
.seen-tag{font-size:.72rem;color:var(--muted);margin-left:6px;vertical-align:middle}
.msg-error{color:var(--danger)}
.msg-text,.msg-error{display:block;max-width:36ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.relaunch-notice{display:block;max-width:36ch;overflow-wrap:anywhere;white-space:normal}
.model{display:block;color:var(--muted);font-size:.75em;white-space:nowrap}
.task-text{display:block;max-width:28ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.unknown{color:var(--muted);font-style:italic}
.reply-form{display:flex;gap:.4em;align-items:center;max-width:60ch}
.reply-form input{flex:1;min-width:12ch;font:inherit;padding:.2em .4em}
.reply-receipt{display:block;margin-top:.3em;font-size:.85em}
.reply-ok{color:var(--muted)}
.source{color:var(--muted);font-size:.75em;margin-left:.35em;white-space:nowrap}
.set-by{color:var(--muted);font-size:.75em;margin-left:.35em;white-space:nowrap}
.detail-row td{background:var(--raised)}
.detail-list{display:grid;grid-template-columns:auto 1fr;gap:4px 14px;margin:0;font-size:.85rem;font-family:var(--mono)}
.detail-list dt{color:var(--muted);font-family:var(--font)}
.detail-list dd{margin:0;overflow-wrap:anywhere}
.project-group{margin-bottom:20px}
.project-group-head{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:8px}
.group-toggles{display:flex;flex-wrap:wrap;gap:14px}
.project-group-head label{font-size:.85rem;color:var(--muted);display:flex;align-items:center;gap:6px}
.offline-note{margin:6px 0 0;font-size:.82rem;color:var(--muted)}
.page-footer{margin-top:24px;padding-top:12px;border-top:1px solid var(--line);color:var(--muted);font-size:.85rem}
</style>
</head>
<body>
<header class="page-head">
<h1>Control board</h1>
<div class="head-controls">
<p id="headerStatus" class="status-line" aria-live="polite">Updated never · next refresh in 10s</p>
<button id="refreshBtn" class="btn" type="button">Refresh</button>
<button id="pauseBtn" class="btn" type="button" aria-pressed="false">Pause</button>
</div>
</header>
<div id="errorBanner" class="error-banner" role="alert" hidden></div>
<main id="main">
<section aria-labelledby="waiting-h">
<h2 id="waiting-h">Waiting on you</h2>
<div id="waitingBody"></div>
</section>
<section aria-labelledby="seen-h">
<details id="seenDetails">
<summary><h2 id="seen-h">Seen</h2></summary>
<div id="seenBody"></div>
</details>
</section>
<section aria-labelledby="projects-h">
<h2 id="projects-h">By project</h2>
<div id="projectsBody"></div>
</section>
</main>
<footer id="footer" class="page-footer"></footer>
<script>
(function () {
"use strict";
var STATES = ["working", "waiting", "error", "offline", "idle", "unknown"];
var BADGE_CLASS = { waiting: "badge-warn", error: "badge-danger", working: "badge-ok", offline: "badge-muted", idle: "badge-muted", unknown: "badge-accent" };
var REFRESH_MS = 10000;
var headerStatus = document.getElementById("headerStatus");
var refreshBtn = document.getElementById("refreshBtn");
var pauseBtn = document.getElementById("pauseBtn");
var errorBanner = document.getElementById("errorBanner");
var waitingBody = document.getElementById("waitingBody");
var seenBody = document.getElementById("seenBody");
var seenHeading = document.getElementById("seen-h");
var projectsBody = document.getElementById("projectsBody");
var footer = document.getElementById("footer");
var main = document.getElementById("main");
var lastData = null;
var paused = false;
var fetching = false;
var timerId = null;
var secondsLeft = REFRESH_MS / 1000;
var hideOfflineState = {};
var hideSeenState = {};
// Open detail panels survive a refresh. Keyed by section, project and agent
// because the same agent can appear in both the waiting list and its group.
var openDetails = {};
var rowIdx = 0;
function esc(v) {
if (v === null || v === undefined) return "";
return String(v).replace(/[&<>"']/g, function (c) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
});
}
function humanAge(s) {
if (s === null || s === undefined) return "—";
if (s < 60) return s + "s";
var m = Math.floor(s / 60);
if (m < 60) return m + "m";
var h = Math.floor(m / 60);
if (h < 24) return h + "h";
return Math.floor(h / 24) + "d";
}
function timeAgo(iso) {
if (!iso) return "never";
var ms = Date.now() - Date.parse(iso);
if (Number.isNaN(ms)) return "never";
var s = Math.floor(ms / 1000);
if (s < 5) return "just now";
if (s < 60) return s + "s ago";
var m = Math.floor(s / 60);
if (m < 60) return m + "m ago";
var h = Math.floor(m / 60);
if (h < 24) return h + "h ago";
return Math.floor(h / 24) + "d ago";
}
function cap(s) { return s.charAt(0).toUpperCase() + s.slice(1); }
function badge(state) {
var cls = BADGE_CLASS[state] || "badge-muted";
return '<span class="badge ' + cls + '">' + esc(state) + "</span>";
}
// A "Seen" button on rows that need you; a "seen" tag plus "Unsee" on rows
// you have already marked. The mark clears itself when the agent writes again.
function seenControl(rec) {
var needsYou = rec.state === "waiting" || rec.state === "error";
if (!needsYou || !rec.lastActivity) return "";
var attrs = ' data-project="' + esc(rec.project) + '" data-agent="' + esc(rec.agent) + '" data-last="' + esc(rec.lastActivity) + '"';
if (rec.seen) {
return '<span class="seen-tag">seen</span><button type="button" class="seen-toggle" data-seen="false"' + attrs + ' title="Put this row back under Waiting on you">Unsee</button>';
}
return '<button type="button" class="seen-toggle" data-seen="true"' + attrs + ' title="I have read this; hide it from Waiting on you until the agent writes again">Seen</button>';
}
var SOURCE_LABEL = { "registration": "registered", "first-user-message": "first message", "tmux-pane": "tmux pane", "session-cwd": "session cwd", "workspace-git-root": "git root" };
function sourceLabel(source) { return SOURCE_LABEL[source] || source; }
function sourceTag(source) { return source ? '<span class="source" title="source: ' + esc(source) + '">' + esc(sourceLabel(source)) + "</span>" : ""; }
function fromSource(source) { return source ? " (from " + esc(sourceLabel(source)) + ")" : ""; }
// Who set a registered task (#1511): shown only when the task on the row is
// the registered one (the scan gives null otherwise). It is what the
// caller claimed when it ran `mosaic seat task`, not a verified identity,
// and it never changes what the row lets you do.
function setByTag(rec) { return rec.taskSetBy ? '<span class="set-by" title="set by (as claimed by the caller, not verified): ' + esc(rec.taskSetBy) + '">set by ' + esc(rec.taskSetBy) + "</span>" : ""; }
function setByText(rec) { return rec.taskSetBy ? esc(rec.taskSetBy) + " (as claimed by the caller, not verified)" : "—"; }
// The model a seat is running, e.g. "gpt-6-astra" with the provider in the
// hover title. modelText() already escapes, so the title takes it as is.
// Empty when the log has not named one yet.
function modelTag(rec) { return rec.model ? '<span class="model" title="' + modelText(rec) + '">' + esc(rec.model) + "</span>" : ""; }
function modelText(rec) { return rec.model ? (rec.provider ? esc(rec.provider) + "/" : "") + esc(rec.model) : "unknown"; }
// Reply-from-board (#1505): a box and Send only where the board has an
// address, i.e. a live registration naming a tmux session. Receipts and
// unsent drafts are kept per row id so a refresh does not lose them.
var replyReceipts = {};
var replyDrafts = {};
function canReply(rec) {
if (rec.connector) return false;
var reg = rec.registered;
return !!(reg && reg.alive !== false && reg.tmux && reg.tmux.session);
}
function replyReceipt(id) {
var r = replyReceipts[id];
if (!r) return "";
if (r.delivered) return '<span class="reply-receipt reply-ok">delivered ' + esc(r.sentAt) + " to tmux " + esc(r.session) + "</span>";
return '<span class="reply-receipt msg-error" style="white-space:normal;max-width:none">failed' + (r.exitCode === null || r.exitCode === undefined ? "" : " (exit " + esc(r.exitCode) + ")") + ": " + esc(r.stderr || r.error || "no output") + "</span>";
}
function replyControl(rec) {
var id = [rec.project, rec.agent].join("/");
if (rec.connector) return '<span class="unknown">Board replies disabled for Discord connectors</span>';
if (!canReply(rec)) return '<span class="unknown">reply needs a registered seat</span>' + replyReceipt(id);
return '<form class="reply-form" data-id="' + esc(id) + '">' +
'<input type="text" class="reply-text" maxlength="2000" autocomplete="off" aria-label="Reply to ' + esc(rec.agent) + '" placeholder="Reply to ' + esc(rec.agent) + '" value="' + esc(replyDrafts[id] || "") + '">' +
'<button type="submit">Send</button></form>' + replyReceipt(id);
}
function registeredText(reg) {
if (!reg) return "no (not started through mosaic launch)";
var parts = ["started " + esc(reg.startedAt || "—")];
if (reg.updatedAt) parts.push("task updated " + esc(reg.updatedAt));
if (reg.harness) parts.push("harness " + esc(reg.harness));
if (reg.pid) parts.push("pid " + esc(reg.pid));
if (reg.tmux && reg.tmux.session) parts.push("tmux " + esc(reg.tmux.session) + (reg.tmux.socket ? " (socket " + esc(reg.tmux.socket) + ")" : ""));
if (reg.layout) parts.push(esc(reg.layout) + " layout");
if (reg.alive === false) parts.push("stale: pid " + esc(reg.pid) + " is gone, so the derived values are shown");
return parts.join("; ");
}
function relaunchNotice(rec) { return rec.relaunchedAt ? 'relaunched at ' + esc(rec.relaunchedAt) + ', no messages since' : ''; }
function buildRowPair(rec, showProject) {
var idx = rowIdx++;
var key = (showProject ? "waiting:" : "group:") + [rec.project, rec.agent].join("/");
var open = !!openDetails[key];
var cls = "session-row" + (rec.state === "offline" ? " is-offline" : "");
var notice = relaunchNotice(rec);
var msg = notice ? '<span class="relaunch-notice">' + notice + '</span>' : rec.state === "error" && rec.lastError
? '<span class="msg-error" title="' + esc(rec.lastError) + '">' + esc(rec.lastError) + "</span>"
: '<span class="msg-text" title="' + esc(rec.lastAssistantText || "") + '">' + esc(rec.lastAssistantText || "—") + "</span>";
var projectCell = showProject ? "<td>" + esc(rec.project) + "</td>" : "";
// Gate A fields: from the seat's registration (mosaic launch), else derived
// from the log and tmux, else "unknown". Never guessed. The source tag on
// the row says which one is shown.
var task = rec.task
? '<span class="task-text" title="' + esc(rec.task) + '">' + esc(rec.task) + "</span>" + sourceTag(rec.taskSource) + setByTag(rec)
: '<span class="unknown">unknown</span>';
var activeProject = rec.activeProject
? '<span title="' + esc(rec.workspace || "") + '">' + esc(rec.activeProject) + "</span>" + sourceTag(rec.activeProjectSource)
: '<span class="unknown" title="' + esc(rec.workspace || "") + '">unknown</span>';
var main =
'<tr class="' + cls + '">' +
projectCell +
'<td><button type="button" class="row-toggle" data-idx="' + idx + '" data-key="' + esc(key) + '" aria-expanded="' + (open ? "true" : "false") + '" aria-controls="detail-' + idx + '">' + esc(rec.agent) + "</button>" + modelTag(rec) + "</td>" +
"<td>" + badge(rec.state) + (rec.connector ? '<span class="unknown"> · ' + (rec.connector.braked === true ? 'braked (STOP)' : rec.connector.braked === false ? 'not braked' : 'brake unknown') + ' · owner ' + esc(rec.connector.ownerState) + '</span>' : '') + seenControl(rec) + "</td>" +
"<td>" + (notice ? '<span class="relaunch-notice">' + notice + '</span>' : esc(humanAge(rec.ageSeconds))) + "</td>" +
"<td>" + task + "</td>" +
"<td>" + activeProject + "</td>" +
"<td>" + msg + "</td>" +
"</tr>";
var span = showProject ? 7 : 6;
var tmux = rec.tmux && rec.tmux.session
? esc(rec.tmux.session) + (rec.tmux.socket ? " (socket " + esc(rec.tmux.socket) + ")" : "")
: "—";
var detail =
'<tr class="detail-row" id="detail-' + idx + '"' + (open ? "" : " hidden") + '><td colspan="' + span + '">' +
'<dl class="detail-list">' +
"<dt>Session ID</dt><dd>" + esc(rec.sessionId || "—") + "</dd>" +
"<dt>Session file</dt><dd>" + esc(rec.sessionFile || "—") + "</dd>" +
"<dt>Task</dt><dd>" + (rec.task ? esc(rec.task) : "unknown") + fromSource(rec.taskSource) + "</dd>" +
"<dt>Task set by</dt><dd>" + setByText(rec) + "</dd>" +
"<dt>Active project</dt><dd>" + (rec.activeProject ? esc(rec.activeProject) : "unknown") + fromSource(rec.activeProjectSource) + "</dd>" +
"<dt>Workspace</dt><dd>" + (rec.workspace ? esc(rec.workspace) : "unknown") + fromSource(rec.workspaceSource) + "</dd>" +
"<dt>Model</dt><dd>" + modelText(rec) + "</dd>" +
"<dt>Registered</dt><dd>" + registeredText(rec.registered) + "</dd>" +
"<dt>Reply</dt><dd>" + replyControl(rec) + "</dd>" +
"<dt>Session cwd</dt><dd>" + esc(rec.cwd || "—") + "</dd>" +
"<dt>Tmux session</dt><dd>" + tmux + "</dd>" +
(notice ? "<dt>Current activity</dt><dd>" + notice + "</dd>" : "") +
"<dt>" + (notice ? "Historical last activity" : "Last activity") + "</dt><dd>" + esc(rec.lastActivity || "—") + "</dd>" +
"<dt>Scanned at</dt><dd>" + esc(rec.scannedAt || "—") + "</dd>" +
"<dt>Skipped lines</dt><dd>" + esc(rec.skippedLines) + "</dd>" +
"<dt>" + (notice ? "Historical last message" : "Last message") + "</dt><dd>" + esc(rec.lastAssistantText || "—") + "</dd>" +
"<dt>" + (notice ? "Historical last error" : "Last error") + "</dt><dd>" + esc(rec.lastError || "—") + "</dd>" +
"</dl></td></tr>";
return main + detail;
}
// Rows you marked Seen, in one place, so they can be found and put back.
// Collapsed by default; the open/closed choice survives a refresh.
function renderSeen(data) {
var list = (data.sessions || []).filter(function (r) { return r.seen; });
list.sort(function (a, b) {
var aa = a.ageSeconds === null || a.ageSeconds === undefined ? Infinity : a.ageSeconds;
var bb = b.ageSeconds === null || b.ageSeconds === undefined ? Infinity : b.ageSeconds;
return aa - bb;
});
seenHeading.textContent = "Seen (" + list.length + ")";
if (list.length === 0) {
seenBody.innerHTML = '<p class="empty">Nothing is marked seen. Rows you mark come back here, and return to "Waiting on you" on their own when the agent writes again.</p>';
return;
}
var rows = list.map(function (r) { return buildRowPair(r, true); }).join("");
seenBody.innerHTML =
'<div class="table-wrap"><table><thead><tr>' +
"<th scope=\"col\">Project</th><th scope=\"col\">Agent</th><th scope=\"col\">State</th><th scope=\"col\">Age</th><th scope=\"col\">Task</th><th scope=\"col\">Active project</th><th scope=\"col\">Last message</th>" +
"</tr></thead><tbody>" + rows + "</tbody></table></div>";
}
function renderWaiting(data) {
var list = (data.sessions || []).filter(function (r) { return r.waitingOnYou; });
list.sort(function (a, b) {
var ae = a.state === "error" ? 0 : 1, be = b.state === "error" ? 0 : 1;
if (ae !== be) return ae - be;
var aa = a.ageSeconds === null || a.ageSeconds === undefined ? Infinity : a.ageSeconds;
var bb = b.ageSeconds === null || b.ageSeconds === undefined ? Infinity : b.ageSeconds;
return aa - bb;
});
if (list.length === 0) {
waitingBody.innerHTML = '<p class="empty">Nothing is waiting on you.</p>';
return;
}
var rows = list.map(function (r) { return buildRowPair(r, true); }).join("");
waitingBody.innerHTML =
'<div class="table-wrap"><table><thead><tr>' +
"<th scope=\"col\">Project</th><th scope=\"col\">Agent</th><th scope=\"col\">State</th><th scope=\"col\">Age</th><th scope=\"col\">Task</th><th scope=\"col\">Active project</th><th scope=\"col\">Last message</th>" +
"</tr></thead><tbody>" + rows + "</tbody></table></div>";
}
function projectOrder(a, b) {
if (a === "fleet" && b === "fleet") return 0;
if (a === "fleet") return 1;
if (b === "fleet") return -1;
return a.localeCompare(b);
}
function renderProjects(data) {
var sessions = data.sessions || [];
var byProject = {};
sessions.forEach(function (r) {
(byProject[r.project] = byProject[r.project] || []).push(r);
});
var projects = Object.keys(byProject).sort(projectOrder);
if (projects.length === 0) {
projectsBody.innerHTML = '<p class="empty">No agents are configured.</p>';
return;
}
projectsBody.innerHTML = projects.map(function (project) {
var group = byProject[project].slice().sort(function (a, b) { return a.agent.localeCompare(b.agent); });
if (!(project in hideOfflineState)) hideOfflineState[project] = true;
if (!(project in hideSeenState)) hideSeenState[project] = true;
var hideOffline = hideOfflineState[project];
var hideSeen = hideSeenState[project];
var offlineHidden = 0, seenHidden = 0;
var visible = group.filter(function (r) {
if (hideOffline && r.state === "offline") { offlineHidden += 1; return false; }
if (hideSeen && r.seen) { seenHidden += 1; return false; }
return true;
});
var rows = visible.map(function (r) { return buildRowPair(r, false); }).join("");
var noteParts = [];
if (offlineHidden > 0) noteParts.push(offlineHidden + " offline hidden");
if (seenHidden > 0) noteParts.push(seenHidden + " seen hidden");
var note = noteParts.length > 0 ? '<p class="offline-note">' + noteParts.join(" \u00b7 ") + "</p>" : "";
var headCount = visible.length < group.length ? visible.length + " of " + group.length : String(group.length);
return (
'<div class="project-group">' +
'<div class="project-group-head"><h3>' + esc(project) + ' <span>(' + headCount + ")</span></h3>" +
'<div class="group-toggles">' +
'<label><input type="checkbox" class="hide-offline-toggle" data-project="' + esc(project) + '" ' + (hideOffline ? "checked" : "") + "> Hide offline</label>" +
'<label><input type="checkbox" class="hide-seen-toggle" data-project="' + esc(project) + '" ' + (hideSeen ? "checked" : "") + "> Hide seen</label>" +
"</div></div>" +
'<div class="table-wrap"><table><thead><tr>' +
"<th scope=\"col\">Agent</th><th scope=\"col\">State</th><th scope=\"col\">Age</th><th scope=\"col\">Task</th><th scope=\"col\">Active project</th><th scope=\"col\">Last message</th>" +
"</tr></thead><tbody>" + (rows || '<tr><td colspan="6" class="empty">No agents.</td></tr>') + "</tbody></table></div>" +
note + "</div>"
);
}).join("");
}
function renderFooter(data) {
var counts = data.counts || {};
var parts = STATES.map(function (s) { return cap(s) + " " + (counts[s] || 0); }).join(" · ");
parts += " · Seen " + ((data.seen || []).length);
footer.innerHTML = "<p>" + esc(parts) + " — generated " + esc(timeAgo(data.generatedAt)) + " (" + esc(data.generatedAt || "") + ")</p>";
}
// A refresh rebuilds the tables. If Jason is typing a reply, put the
// caret back where it was so the periodic refresh does not steal it.
function withReplyFocus(render) {
var active = document.activeElement;
var focus = active && active.classList && active.classList.contains("reply-text")
? { id: active.form.dataset.id, pos: active.selectionStart }
: null;
render();
if (!focus) return;
var forms = main.querySelectorAll('.reply-form[data-id="' + focus.id.replace(/"/g, '\\"') + '"]');
var input = forms.length ? forms[0].querySelector(".reply-text") : null;
if (!input) return;
input.focus();
try { input.setSelectionRange(focus.pos, focus.pos); } catch (e) {}
}
function renderAll() {
withReplyFocus(function () {
rowIdx = 0;
renderWaiting(lastData);
renderSeen(lastData);
renderProjects(lastData);
renderFooter(lastData);
});
}
function renderHeaderStatus() {
var updated = lastData ? timeAgo(lastData.generatedAt) : "never";
var refresh = paused ? "auto-refresh paused" : "next refresh in " + secondsLeft + "s";
headerStatus.textContent = "Updated " + updated + " · " + refresh;
}
function showError(msg) {
errorBanner.hidden = false;
errorBanner.textContent = "Could not refresh: " + msg + ". Showing the last known data.";
}
function hideError() {
errorBanner.hidden = true;
errorBanner.textContent = "";
}
function scheduleNextFetch() {
clearTimeout(timerId);
if (paused) return;
secondsLeft = REFRESH_MS / 1000;
timerId = setTimeout(runFetch, REFRESH_MS);
}
function runFetch() {
if (fetching) return;
fetching = true;
fetch("/api/board", { cache: "no-store" })
.then(function (res) {
return res.json().catch(function () {
throw new Error("the server sent a response that was not valid JSON");
}).then(function (body) {
if (!res.ok) throw new Error((body && body.error) || "HTTP " + res.status);
return body;
});
})
.then(function (body) {
lastData = body;
hideError();
renderAll();
})
.catch(function (err) {
console.error("control board refresh failed", err);
showError(err && err.message ? err.message : "unknown error");
})
.then(function () {
fetching = false;
renderHeaderStatus();
scheduleNextFetch();
});
}
function setPaused(p) {
paused = p;
pauseBtn.textContent = paused ? "Resume" : "Pause";
pauseBtn.setAttribute("aria-pressed", String(paused));
if (paused) clearTimeout(timerId);
else scheduleNextFetch();
renderHeaderStatus();
}
function postSeen(btn) {
if (fetching) return;
fetching = true;
btn.disabled = true;
fetch("/api/seen", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ project: btn.dataset.project, agent: btn.dataset.agent, lastActivity: btn.dataset.last, seen: btn.dataset.seen === "true" })
})
.then(function (res) {
return res.json().catch(function () {
throw new Error("the server sent a response that was not valid JSON");
}).then(function (body) {
if (!res.ok) throw new Error((body && body.error) || "HTTP " + res.status);
return body;
});
})
.then(function (body) {
lastData = body;
hideError();
renderAll();
})
.catch(function (err) {
console.error("marking seen failed", err);
showError(err && err.message ? err.message : "unknown error");
btn.disabled = false;
})
.then(function () {
fetching = false;
renderHeaderStatus();
scheduleNextFetch();
});
}
function postReply(form) {
var id = form.dataset.id;
var input = form.querySelector(".reply-text");
var text = input.value;
if (!text.trim()) return;
var sendBtn = form.querySelector("button");
sendBtn.disabled = true;
fetch("/api/reply", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ agent: id, text: text })
})
.then(function (res) {
return res.json().catch(function () {
throw new Error("the server sent a response that was not valid JSON");
}).then(function (body) {
if (!res.ok) throw new Error((body && body.error) || "HTTP " + res.status);
return body;
});
})
.then(function (body) {
replyReceipts[id] = body;
if (body.delivered) delete replyDrafts[id];
renderAll();
// The seat's log shows the effect; fetch now instead of waiting a tick.
if (body.delivered && !paused) runFetch();
})
.catch(function (err) {
console.error("reply failed", err);
replyReceipts[id] = { delivered: false, exitCode: null, error: err && err.message ? err.message : "unknown error" };
renderAll();
});
}
main.addEventListener("submit", function (e) {
var form = e.target.closest(".reply-form");
if (!form) return;
e.preventDefault();
postReply(form);
});
main.addEventListener("input", function (e) {
var input = e.target.closest(".reply-text");
if (!input) return;
replyDrafts[input.form.dataset.id] = input.value;
});
main.addEventListener("click", function (e) {
var seenBtn = e.target.closest(".seen-toggle");
if (seenBtn) return postSeen(seenBtn);
var btn = e.target.closest(".row-toggle");
if (!btn) return;
var detail = document.getElementById("detail-" + btn.dataset.idx);
var expanded = btn.getAttribute("aria-expanded") === "true";
btn.setAttribute("aria-expanded", String(!expanded));
detail.hidden = expanded;
if (expanded) delete openDetails[btn.dataset.key];
else openDetails[btn.dataset.key] = true;
});
main.addEventListener("change", function (e) {
var cb = e.target.closest(".hide-offline-toggle, .hide-seen-toggle");
if (!cb) return;
var stateMap = cb.classList.contains("hide-seen-toggle") ? hideSeenState : hideOfflineState;
stateMap[cb.dataset.project] = cb.checked;
renderProjects(lastData);
});
refreshBtn.addEventListener("click", runFetch);
pauseBtn.addEventListener("click", function () { setPaused(!paused); });
setInterval(function () {
if (!paused && secondsLeft > 0) secondsLeft -= 1;
renderHeaderStatus();
}, 1000);
renderHeaderStatus();
runFetch();
})();
</script>
</body>
</html>
+3 -45
View File
@@ -16,7 +16,7 @@
import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, writeFileSync, renameSync } from "node:fs";
import { join, basename, isAbsolute, resolve } from "node:path";
import { homedir } from "node:os";
import { spawnSync, execFile } from "node:child_process";
import { spawnSync } from "node:child_process";
import { readRegistration, samePath, SeatError, LAYOUTS } from "../../seat/src/seat.mjs";
import { discoverDiscordAgents, inspectDiscord, newestDiscordSession } from './discord.mjs';
@@ -193,46 +193,17 @@ export function parsePanes(listPanesOutput) {
// "unknown", never assumed alive).
// workspace the current path of the first pane running pi, else null.
// `exec` is injectable for tests.
export function tmuxInspect(tmux, { exec = spawnSync } = {}) {
return inspectFromResult(exec("tmux", tmuxArgs(tmux), { encoding: "utf8", timeout: TMUX_TIMEOUT_MS }));
}
const TMUX_TIMEOUT_MS = 5000;
function tmuxArgs({ socket, session }) {
export function tmuxInspect({ socket, session }, { exec = spawnSync } = {}) {
const args = [];
if (socket) args.push("-L", socket);
args.push("list-panes", "-s", "-t", `=${session}`, "-F", "#{pane_current_command}\t#{pane_current_path}");
return args;
}
// Shared by the sync and async paths, so they cannot drift.
function inspectFromResult(r) {
const r = exec("tmux", args, { encoding: "utf8", timeout: 5000 });
if (r.error) return { alive: null, workspace: null };
if (r.status !== 0) return { alive: false, workspace: null };
const pane = parsePanes(r.stdout ?? "").find((p) => PI_COMMANDS.includes(p.command));
return { alive: Boolean(pane), workspace: pane?.path ?? null };
}
// Async child run for the Console's in-process board (B1). Same result shape as
// spawnSync: { status, signal, stdout, stderr, error }. A timeout or a failure to
// start is an error, never a status. The Console's event loop must not block here.
export function runAsync(file, args, { encoding = "utf8", timeout, env } = {}) {
return new Promise((resolvePromise) => {
execFile(file, args, { encoding, timeout, env, maxBuffer: 1 << 20 }, (err, stdout, stderr) => {
if (!err) return resolvePromise({ status: 0, signal: null, stdout, stderr, error: undefined });
if (err.killed) return resolvePromise({ status: null, signal: err.signal ?? null, stdout, stderr, error: Object.assign(new Error(`timed out after ${timeout} ms`), { code: "ETIMEDOUT" }) });
if (typeof err.code === "number") return resolvePromise({ status: err.code, signal: null, stdout, stderr, error: undefined });
return resolvePromise({ status: null, signal: null, stdout, stderr, error: err });
});
});
}
// Async twin of tmuxInspect. `exec` is injectable for tests.
export async function tmuxInspectAsync(tmux, { exec = runAsync } = {}) {
return inspectFromResult(await exec("tmux", tmuxArgs(tmux), { encoding: "utf8", timeout: TMUX_TIMEOUT_MS }));
}
// Liveness only, for callers that do not need the pane path.
export function tmuxIsAlive(tmux, opts) {
return tmuxInspect(tmux, opts).alive;
@@ -491,16 +462,3 @@ export function scan(specs, { boardDir, isAlive, now, seatsDir = null, isPidAliv
writeAtomic(join(boardDir, "index.json"), index);
return index;
}
// Async scan for the Console's in-process board (B1). With no isAlive, every tmux
// check runs through the async exec first, in parallel, and the sync scan body
// then reads those answers. A caller's sync isAlive (the CLI, tests) is used as is.
// One scan body serves both paths, so the records cannot drift.
export async function scanAsync(specs, { isAlive, exec = runAsync, ...rest } = {}) {
if (isAlive) return scan(specs, { ...rest, isAlive });
const checks = new Map();
const keyOf = (tmux) => JSON.stringify([tmux.socket ?? null, tmux.session]);
const pending = [...new Map(specs.filter((s) => !s.connector).map((s) => [keyOf(s.tmux), s.tmux])).values()];
await Promise.all(pending.map(async (tmux) => checks.set(keyOf(tmux), await tmuxInspectAsync(tmux, { exec }))));
return scan(specs, { ...rest, isAlive: (tmux) => checks.get(keyOf(tmux)) ?? { alive: null, workspace: null } });
}
+191 -49
View File
@@ -1,30 +1,55 @@
// Control board: the pure board logic the Console calls in-process (row 198).
// No listener. The writer on 7331 is retired; the Console serves the board
// routes behind its own auth (packages/webui/src/serve.mjs). The board opens no
// socket and never binds a port.
// Control board step 2: a tiny local web server. No dependencies, no auth.
// It only ever binds to a loopback address (fail closed otherwise).
//
// Route logic lives in src/board.mjs. This module keeps the pure helpers the
// Console and the tests share: reply planning and shaping, the Host/Origin check
// (foreignRequest), and the conversation query and refusal map.
// GET / the page (src/page.html)
// GET /api/board re-runs the scanner and returns index.json as JSON
// POST /api/seen {project, agent, lastActivity, seen?} marks a row as seen
// (or clears the mark with seen:false); rescans, returns index
// POST /api/reply {agent: "<project>/<agent>", text} delivers text to the
// seat's tmux pane through tools/tmux/agent-send.sh (#1505);
// answers {delivered, exitCode, stdout, stderr, ...}
// GET /healthz {"ok":true}
// GET /api/conversations
// read-only Pi conversation catalogue (#1507, CHAT-02)
// GET /api/conversation?id=<conversation>[&branch=<branch>][&cursor=<cursor>]
// one CHAT-01 history page: the first page of a branch (the
// default one without branch), or the next page (or a
// follow) for a cursor. A cursor call repeats the page's
// branch; without it the answer is 400. Refusals carry
// {error, refusal: {code, reconcile}}.
//
// POST requires Content-Type: application/json. A plain form post from another
// site in the browser cannot set that header without a CORS preflight, and this
// server answers no preflight, so a stray page cannot flip marks.
//
// Every route first checks Host and Origin (#1507). Binding to loopback does
// not stop DNS rebinding: a page whose name now resolves to 127.0.0.1 reaches
// a Console on this host as its own origin, with its own name as Host, and could
// read the board or post /api/reply into a live pane. A Host that is not a
// loopback name on the listening port, or any Origin other than the server's
// own, gets 403 before anything else runs. The Console applies the same check.
// this server as its own origin, with its own name as Host, and could read
// /api/board or post /api/reply into a live pane. A Host that is not a loopback
// name on this server's port, or any Origin other than this server's own, gets
// 403 before anything else runs. Same check as packages/webui/src/serve.mjs.
// No CORS headers are ever sent.
//
// Every /api/board request rescans, so the page is never staler than its
// refresh timer. The scan rewrites the derived board files as a side effect.
//
// The conversation routes read only: packages/conversation lists the repository
// specs' Pi session roots (never fleet or connector ones), opens files
// O_NOFOLLOW and writes nothing. Registrations are hints there too. The
// conversation id is opaque and no path comes from the request.
// conversation id is opaque and no path comes from the request. Cursors live
// in this server's memory, bound to the one actor this unauthenticated
// loopback route has, local-operator.
import { createServer as createHttpServer } from "node:http";
import { readFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { isIP } from "node:net";
import { hostname } from "node:os";
import { spawnSync } from "node:child_process";
import { resolve } from "node:path";
import { seenKey, runAsync } from "./scan.mjs";
import { scan, markSeen, seenKey, ConfigError, loadRegistrations } from "./scan.mjs";
import { createReader, rootsFromSpecs } from "../../conversation/src/reader.mjs";
const MAX_BODY = 4096;
// Reply-from-board (#1505). The board owns no transport: a reply is handed to
// the repository's own inter-agent channel, tools/tmux/agent-send.sh, which
@@ -46,48 +71,27 @@ const REPLY_TIMEOUT_MS = 15000;
// Refusals (4xx) happen before the tool runs and carry { error }. Once the
// tool has run the answer is 200 with delivered true/false, the exit code and
// both output streams verbatim, whatever the code was.
export function replyToRow(opts) {
return replyWith(opts, (exec, args, env, agentSend) => exec(agentSend, args, { encoding: "utf8", timeout: REPLY_TIMEOUT_MS, env }), { exec: spawnSync });
}
// Async twin of replyToRow for the in-process board (B1). `exec` is async.
export async function replyToRowAsync(opts) {
return replyWith(opts, async (exec, args, env, agentSend) => exec(agentSend, args, { encoding: "utf8", timeout: REPLY_TIMEOUT_MS, env }), { exec: runAsync, async: true });
}
function replyWith({ index, key, text, agentSend = DEFAULT_AGENT_SEND, exec, now = () => new Date(), host = hostname().split(".")[0] }, send, { exec: defaultExec, async = false }) {
const plan = planReply({ index, key, text, host });
if (plan.refusal) return async ? Promise.resolve(plan.refusal) : plan.refusal;
// The registration says which socket the seat is on. A launcher-exported
// MOSAIC_TMUX_SOCKET in this server's own environment must not override it.
const env = { ...process.env };
delete env.MOSAIC_TMUX_SOCKET;
const finish = (r) => replyBody({ r, key, agentSend, session: plan.session, socket: plan.socket, now });
const out = send(exec ?? defaultExec, plan.args, env, agentSend);
return async ? Promise.resolve(out).then(finish) : finish(out);
}
function planReply({ index, key, text, host }) {
const refuse = (status, error) => ({ refusal: { status, body: { error } } });
if (typeof key !== "string" || !key) return refuse(400, "agent must be the row id <project>/<agent>");
if (typeof text !== "string" || !text.trim()) return refuse(400, "text must be a non-empty string");
if (text.length > REPLY_LIMIT) return refuse(400, `text is longer than ${REPLY_LIMIT} characters`);
export function replyToRow({ index, key, text, agentSend = DEFAULT_AGENT_SEND, exec = spawnSync, now = () => new Date(), host = hostname().split(".")[0] }) {
if (typeof key !== "string" || !key) return { status: 400, body: { error: "agent must be the row id <project>/<agent>" } };
if (typeof text !== "string" || !text.trim()) return { status: 400, body: { error: "text must be a non-empty string" } };
if (text.length > REPLY_LIMIT) return { status: 400, body: { error: `text is longer than ${REPLY_LIMIT} characters` } };
const rec = index.sessions.find((r) => seenKey(r) === key);
if (!rec) return refuse(404, `unknown row: ${key}`);
if (rec.connector || / \(discord: [a-z0-9][a-z0-9._-]{0,63}\)$/.test(rec.agent)) return refuse(409, "board replies are disabled for Discord connectors");
if (!rec) return { status: 404, body: { error: `unknown row: ${key}` } };
if (rec.connector || / \(discord: [a-z0-9][a-z0-9._-]{0,63}\)$/.test(rec.agent)) return { status: 409, body: { error: "board replies are disabled for Discord connectors" } };
const reg = rec.registered;
if (!reg) return refuse(409, "reply needs a registered seat (start it through scripts/mosaic launch)");
if (reg.alive === false) return refuse(409, `registration is stale: pid ${reg.pid} is gone`);
if (!reg.tmux || !reg.tmux.session) return refuse(409, "registration has no tmux session to address");
if (!reg) return { status: 409, body: { error: "reply needs a registered seat (start it through scripts/mosaic launch)" } };
if (reg.alive === false) return { status: 409, body: { error: `registration is stale: pid ${reg.pid} is gone` } };
if (!reg.tmux || !reg.tmux.session) return { status: 409, body: { error: "registration has no tmux session to address" } };
const session = reg.tmux.session;
const socket = reg.tmux.socket || null;
const args = ["-s", session, "-S", `${host}:${REPLY_SENDER}`];
if (socket) args.push("-L", socket);
args.push("-m", `${text}\n${REPLY_TRAILER}`);
return { args, session, socket };
}
function replyBody({ r, key, agentSend, session, socket, now }) {
// The registration says which socket the seat is on. A launcher-exported
// MOSAIC_TMUX_SOCKET in this server's own environment must not override it.
const env = { ...process.env };
delete env.MOSAIC_TMUX_SOCKET;
const r = exec(agentSend, args, { encoding: "utf8", timeout: REPLY_TIMEOUT_MS, env });
if (r.error) return { status: 500, body: { error: `could not run ${agentSend}: ${r.error.message}` } };
const exitCode = r.status;
return {
@@ -106,6 +110,39 @@ function replyBody({ r, key, agentSend, session, socket, now }) {
};
}
function sendJson(res, status, body) {
res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
res.end(JSON.stringify(body) + "\n");
}
function readJsonBody(req) {
return new Promise((resolvePromise, reject) => {
const type = String(req.headers["content-type"] || "").split(";")[0].trim().toLowerCase();
if (type !== "application/json") return reject(new Error("Content-Type must be application/json"));
const chunks = [];
let size = 0;
req.on("data", (c) => {
size += c.length;
if (size > MAX_BODY) {
req.destroy();
reject(new Error(`body larger than ${MAX_BODY} bytes`));
return;
}
chunks.push(c);
});
req.on("end", () => {
try {
const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("body must be a JSON object");
resolvePromise(parsed);
} catch (err) {
reject(new Error(`invalid JSON body: ${err.message}`));
}
});
req.on("error", reject);
});
}
const LOOPBACK = new Set(["127.0.0.1", "::1", "localhost"]);
export function isLoopbackHost(host) {
@@ -150,6 +187,11 @@ export const REFUSAL_STATUS = {
};
const QUERY_VALUE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
function sendConversationJson(res, status, body) {
res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store", "x-content-type-options": "nosniff" });
res.end(JSON.stringify(body) + "\n");
}
// GET /api/conversation: id is required; branch and cursor are optional; any
// other, repeated or malformed parameter is a 400.
function conversationQuery(url) {
@@ -178,3 +220,103 @@ export function conversationResponse(reader, url) {
if (out.ok) return { status: 200, body: out };
return { status: REFUSAL_STATUS[out.refusal.code] ?? 422, body: { error: out.refusal.message, refusal: { code: out.refusal.code, reconcile: out.refusal.reconcile } } };
}
export function loadPage(path = join(import.meta.dirname, "page.html")) {
return readFileSync(path, "utf8");
}
// specs: agent specs to scan on each request. boardDir: where scan writes.
export function createServer({ specs, boardDir, isAlive, now, seatsDir = null, discordDataRoot = null, page = loadPage(), isPidAlive, agentSend = DEFAULT_AGENT_SEND, exec = spawnSync, conversationReader = null }) {
const rescan = () => scan(specs, { boardDir, isAlive, now, seatsDir, isPidAlive, discordDataRoot });
const reader = conversationReader ?? createReader({ roots: () => rootsFromSpecs(specs, loadRegistrations(seatsDir).registrations) });
return createHttpServer((req, res) => {
const refused = foreignRequest(req);
if (refused) {
req.resume();
return sendJson(res, 403, { error: refused });
}
const url = new URL(req.url, "http://localhost");
if (req.method === "POST" && url.pathname === "/api/reply") {
return readJsonBody(req)
.then((body) => {
let index;
try {
index = rescan();
} catch (err) {
return sendJson(res, 500, { error: err.message });
}
const out = replyToRow({ index, key: body.agent, text: body.text, agentSend, exec, now });
sendJson(res, out.status, out.body);
})
.catch((err) => sendJson(res, 400, { error: err.message }));
}
if (req.method === "POST" && url.pathname === "/api/seen") {
return readJsonBody(req)
.then((body) => {
try {
markSeen(boardDir, { project: body.project, agent: body.agent, lastActivity: body.lastActivity, seen: body.seen ?? true });
} catch (err) {
return sendJson(res, 400, { error: err.message });
}
try {
sendJson(res, 200, rescan());
} catch (err) {
sendJson(res, 500, { error: err.message });
}
})
.catch((err) => sendJson(res, 400, { error: err.message }));
}
if (req.method !== "GET" && req.method !== "HEAD") {
res.writeHead(405, { "content-type": "text/plain" });
return res.end("method not allowed\n");
}
if (url.pathname === "/" || url.pathname === "/index.html") {
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
return res.end(page);
}
if (url.pathname === "/api/board") {
let index;
try {
index = rescan();
} catch (err) {
res.writeHead(500, { "content-type": "application/json", "cache-control": "no-store" });
return res.end(JSON.stringify({ error: err.message }) + "\n");
}
res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
return res.end(JSON.stringify(index) + "\n");
}
if (url.pathname === "/api/conversations" || url.pathname === "/api/conversation") {
let out;
try {
out = conversationResponse(reader, url);
} catch (err) {
process.stderr.write(`conversation read failed: ${err.message}\n`);
out = { status: 500, body: { error: "conversation read failed" } };
}
return sendConversationJson(res, out.status, out.body);
}
if (url.pathname === "/favicon.ico") {
res.writeHead(204);
return res.end();
}
if (url.pathname === "/healthz") {
res.writeHead(200, { "content-type": "application/json" });
return res.end('{"ok":true}\n');
}
res.writeHead(404, { "content-type": "text/plain" });
res.end("not found\n");
});
}
// Resolves to the listening server. Refuses any non-loopback host.
export async function startServer({ host = "127.0.0.1", port = 7331, ...rest }) {
if (!isLoopbackHost(host)) throw new ConfigError(`refusing to bind to non-loopback host: ${host} (no auth in the MVP)`);
const server = createServer(rest);
return new Promise((resolvePromise, reject) => {
server.once("error", reject);
server.listen(port, host, () => {
server.off("error", reject);
resolvePromise(server);
});
});
}
-129
View File
@@ -1,129 +0,0 @@
import { test, after } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, appendFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { spawnSync } from "node:child_process";
import { createBoard } from "../src/board.mjs";
const pkgRoot = resolve(import.meta.dirname, "..");
const cli = join(pkgRoot, "src", "cli.mjs");
// Track every tmpdir we create so a stray failure never leaves fixtures behind.
const roots = [];
function makeRoot() {
const root = mkdtempSync(join(tmpdir(), "control-board-board-test-"));
roots.push(root);
return root;
}
after(() => {
for (const root of roots) rmSync(root, { recursive: true, force: true });
});
// One agent whose last message is an assistant reply (waiting on the user), so
// a mark on it is visible in the index. No registration; liveness stubbed so no tmux runs.
function fixture() {
const root = makeRoot();
const sessionsDir = join(root, "sessions");
mkdirSync(sessionsDir, { recursive: true });
writeFileSync(
join(sessionsDir, "session.jsonl"),
[
JSON.stringify({ type: "session", id: "s1", timestamp: "2026-10-20T18:59:00Z", cwd: root }),
JSON.stringify({ type: "message", timestamp: "2026-10-20T19:00:00Z", message: { role: "assistant", stopReason: "stop", content: [{ type: "text", text: "Input needed: which branch should I use?" }] } }),
].join("\n") + "\n",
);
const boardDir = join(root, "board");
const specs = [{ agent: "agent1", project: "proj", sessionsDir, tmux: {} }];
const isAlive = () => ({ alive: true, workspace: null });
return { root, boardDir, specs, isAlive };
}
// Pinned order (Webby, Mos mos-1052): markSeen writes, bumps the counter, then scans.
test("markSeen runs write, bump, scan in that order", async () => {
const { boardDir, specs, isAlive } = fixture();
const events = [];
const board = createBoard({ specs, boardDir, isAlive, onEvent: (name) => events.push(name) });
await board.markSeen({ project: "proj", agent: "agent1", lastActivity: "2026-10-20T19:00:00Z" });
assert.deepEqual(events, ["write", "bump", "scan"]);
});
// Mutant target: a scan run before the write, or a join of a pre-write scan,
// would let the answer omit the mark it was asked to show.
test("markSeen answer includes the mark it just wrote", async () => {
const { boardDir, specs, isAlive } = fixture();
const board = createBoard({ specs, boardDir, isAlive });
const before = await board.index();
assert.deepEqual(before.seen, []);
const rec = before.sessions[0];
const index = await board.markSeen({ project: "proj", agent: "agent1", lastActivity: rec.lastActivity });
assert.deepEqual(index.seen, ["proj/agent1"]);
});
// A reply to a row that is not registered is refused before any send, so the
// send and bump events must not appear.
test("reply refuses an unregistered row and never sends", async () => {
const { boardDir, specs, isAlive } = fixture();
const events = [];
const board = createBoard({ specs, boardDir, isAlive, onEvent: (name) => events.push(name), exec: async () => assert.fail("no send on refusal") });
const out = await board.reply({ agent: "proj/agent1", text: "hello" });
assert.equal(out.status, 409);
assert.equal(events.includes("send"), false);
assert.equal(events.includes("bump"), false);
});
// The retired writer refuses to start. Exit 2 with a pointer to the Console.
test("cli serve refuses and points to the Console", () => {
const r = spawnSync(process.execPath, [cli, "serve"], { encoding: "utf8" });
assert.equal(r.status, 2);
assert.match(r.stderr, /retired on 7331/);
assert.match(r.stderr, /webui/);
});
// M15 (Webby N1): index() must not join a scan that started before the last write.
// A scan already answered before the mark would hide the mark; the join is what
// this kills, so the event log must show a fresh scan after the write.
test("index after a write starts a fresh scan and sees the mark (M15)", async () => {
const { boardDir, specs, isAlive } = fixture();
const events = [];
const board = createBoard({ specs, boardDir, isAlive, onEvent: (name) => events.push(name) });
await board.index();
const before = events.length;
await board.markSeen({ project: "proj", agent: "agent1", lastActivity: "2026-10-20T19:00:00Z" });
const after = await board.index();
assert.deepEqual(events.slice(before), ["write", "bump", "scan", "scan"]);
assert.deepEqual(after.seen, ["proj/agent1"]);
});
// B4 (Webby r3): a settled scan is not in flight. With no write between two index()
// calls, the second must start a fresh scan and show activity appended in between.
// The mutant: drop the clear on settle in index(); the second call then joins the
// settled promise, logs one scan, and shows the first view.
test("index after a settled scan starts a fresh scan and shows new activity (B4)", async () => {
const { boardDir, specs, isAlive } = fixture();
const events = [];
const board = createBoard({ specs, boardDir, isAlive, onEvent: (name) => events.push(name) });
await board.index();
appendFileSync(join(specs[0].sessionsDir, "session.jsonl"), JSON.stringify({ type: "message", timestamp: "2026-10-20T19:05:00Z", message: { role: "user", content: [{ type: "text", text: "new activity" }] } }) + "\n");
const second = await board.index();
assert.deepEqual(events, ["scan", "scan"]);
assert.equal(second.sessions[0].lastActivity, "2026-10-20T19:05:00Z");
});
// B4 (Webby r3): a scan that rejects is cleared too. The next index() runs fresh
// and answers, rather than replaying the rejection on every poll.
test("index after a rejected scan starts a fresh scan (B4)", async () => {
const { boardDir, specs, isAlive } = fixture();
const events = [];
let fail = true;
const flaky = (tmux) => {
if (fail) throw new Error("boom");
return isAlive(tmux);
};
const board = createBoard({ specs, boardDir, isAlive: flaky, onEvent: (name) => events.push(name) });
await assert.rejects(board.index(), /boom/);
fail = false;
const answer = await board.index();
assert.deepEqual(events, ["scan", "scan"]);
assert.equal(answer.sessions[0].agent, "agent1");
});
+12 -12
View File
@@ -6,8 +6,7 @@ import { spawnSync } from 'node:child_process';
import { join } from 'node:path';
import { discoverDiscordAgents, inspectDiscord } from '../src/discord.mjs';
import { scan, scanAgent } from '../src/scan.mjs';
import { replyToRow } from '../src/serve.mjs';
import { createBoard } from '../src/board.mjs';
import { replyToRow, startServer } from '../src/serve.mjs';
import { identityOf } from '../../discord/src/journal.mjs';
import { envelope } from '../../discord/src/context.mjs';
import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs';
@@ -116,20 +115,21 @@ test('connector reply refusal precedes forged live tmux registration; ordinary a
r.agent = 'ordinary'; assert.equal(replyToRow({ index: { sessions: [r] }, key: 'fleet/ordinary', text: 'hello', exec }).status, 200); assert.equal(calls, 1);
});
test('board rescans connector discovery and refuses reply without transport', async () => {
const f = fixture();
test('server rescans connector discovery and refuses HTTP reply without transport', async () => {
const f = fixture(); let server;
try {
const sessionsDir = join(f.root, 'sessions', 'discord-demo'), seatsDir = join(f.root, 'seats');
writeRegistration(seatsDir, makeRegistration({ resolved: { seat: 'forged', project: 'fleet', sessionsDir, seatDir: sessionsDir, launchScript: join(f.root, 'unused.sh'), layout: 'repo', defaultWorkspace: f.root }, task: 'forged', tmux: { session: 'forged', socket: null }, pid: process.pid }));
const board = createBoard({ specs: [], seatsDir, boardDir: join(f.root, 'board'), discordDataRoot: f.root, exec: () => { throw Error('transport forbidden'); } });
const payload = await board.index();
assert.equal(payload.sessions[0].state, 'idle'); assert.equal(payload.sessions[0].connector.alive, true);
assert.equal(payload.sessions[0].registered, null); assert.notEqual(payload.sessions[0].task, 'forged');
const { status } = await board.reply({ agent: 'fleet/researcher (discord: demo)', text: 'hello' });
assert.equal(status, 409);
server = await startServer({ port: 0, specs: [], seatsDir, boardDir: join(f.root, 'board'), discordDataRoot: f.root, exec: () => { throw Error('transport forbidden'); } });
const base = `http://127.0.0.1:${server.address().port}`;
let response = await fetch(base + '/api/board'); const board = await response.json();
assert.equal(board.sessions[0].state, 'idle'); assert.equal(board.sessions[0].connector.alive, true);
assert.equal(board.sessions[0].registered, null); assert.notEqual(board.sessions[0].task, 'forged');
response = await fetch(base + '/api/reply', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ agent: 'fleet/researcher (discord: demo)', text: 'hello' }) });
assert.equal(response.status, 409);
rmSync(join(f.root, 'discord', 'demo.json'));
assert.equal((await board.index()).sessions.length, 0);
} finally { f.cleanup(); }
assert.equal((await (await fetch(base + '/api/board')).json()).sessions.length, 0);
} finally { if (server) await new Promise(r => server.close(r)); f.cleanup(); }
});
test('connector session links and linked directories are not read', () => {
@@ -1,44 +0,0 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { existsSync, readFileSync, readdirSync } from "node:fs";
import { join, resolve } from "node:path";
// Row 198: the 7331 writer is retired and the board runs in the Console. These
// are source and doc checks; each names the design mutant it kills (M1, M2, M9).
const repo = resolve(import.meta.dirname, "..", "..", "..");
const read = (path) => readFileSync(join(repo, path), "utf8");
const srcDir = join(repo, "packages", "control-board", "src");
const srcFiles = readdirSync(srcDir).filter((f) => f.endsWith(".mjs"));
// M1: cli.mjs starts no server, not even on the retired port.
test("retired: cli.mjs starts no server (M1)", () => {
assert.doesNotMatch(read("packages/control-board/src/cli.mjs"), /startServer|createServer|\.listen\(/);
});
// M2: nothing in the board's source opens a socket or exports a server.
test("retired: no board source imports http or exports a server (M2)", () => {
for (const file of srcFiles) {
const text = read(join("packages", "control-board", "src", file));
assert.doesNotMatch(text, /from ["']node:http["']|createServer\(|\.listen\(/, file);
assert.doesNotMatch(text, /export (async )?function (startServer|createServer)\b/, file);
}
});
// M9: a live doc or script must not tell anyone to run the retired serve or open 7331.
// A line that says it is retired is allowed. Append-only logs and plans are not scanned.
test("retired: live docs and scripts do not tell anyone to run control-board serve (M9)", () => {
const liveDocs = ["README.md", "AGENTS.md"];
for (const pkg of readdirSync(join(repo, "packages"))) {
if (existsSync(join(repo, "packages", pkg, "README.md"))) liveDocs.push(join("packages", pkg, "README.md"));
}
for (const file of readdirSync(join(repo, "scripts"))) {
if (file.endsWith(".sh")) liveDocs.push(join("scripts", file));
}
const retiredUse = /control-board\/src\/cli\.mjs serve|control-board serve\b|127\.0\.0\.1:7331/;
const retiredNote = /retired|no longer|exits 2/i;
for (const file of liveDocs) {
read(file).split("\n").forEach((line, i) => {
if (retiredUse.test(line) && !retiredNote.test(line)) assert.fail(`${file}:${i + 1} tells people to run the retired board: ${line.trim()}`);
});
}
});
File diff suppressed because it is too large. Load diff
+17 -10
View File
@@ -104,21 +104,28 @@ in an extension other than `.md`, in any case, embeds an attachment, and it is n
a dot and up to 8 letters and digits, at least one a letter. Any other unresolved embed is R4, and so is
an unresolved front matter value written as an embed (C2-01b N3, sage-1900).
## renderItems
## renderReply
`renderItems(body, { scopeOf, changed })` turns a reply body into the plain text both channels show.
It is pure. Item kinds are `fact`, `finding`, `correction` (with `from` and `to`), `question` and
`idea`. Each cite is a path with commit, line range and quote, or a url with a quote.
`renderReply(body, { check, changed, name })` turns a stored reply into what both channels show. It
replaces C2-01's items renderer and is pure. A body is free text, or the design 5.2 object
`{ "text", "citations" }` with library cites `{ path, commit, lines, quote, as? }` and web cites
`{ url, quote }`. Any other body, a JSON object with another key or a bad cite included, renders as
free text: the whole body, with no citations.
`check` is the stored check result (C2-06), `{ citations: [{ scope, agent }] }` in body order. A path
cite's badge (`canon`, `playground` or `supporting`) and its agent-written label come only from it. A
url cite is always `web`, and is a link only when its scheme is `https`. `changed({ path, commit }, i)`
says whether the file's last commit is newer than the cited one. `plain` is the rendering as text:
```
1. Correction: Chapter 1 says grey.
From: "green eyes"
To: "grey eyes"
[canon] Book One/Characters/Mara Vell.md:9 @bbbbbbb "green eyes" (changed since)
Mara's eyes are grey in chapter 3.
[canon] Book One/Chapters/Chapter 03.md:41 @aaaaaaa "her grey eyes"
[supporting] Notes/Eyes.md:9-10 @aaaaaaa "green eyes" (agent-written: acme/scribe) (changed since)
[web] https://example.invalid/tides "spring tides"
```
A body of `{ "items": [], "notice": ... }` gives `No cited answer`, `No answer` or `The turn failed`.
Any other shape gives the fixed line `This reply could not be shown.` and none of the body's text.
The broker's write notice, `{ "notice": "library-write", op, path, commit }`, gives the line
`<name> wrote <path> @<commit>` or `<name> deleted <path> @<commit>`.
## Survey
+1 -1
View File
@@ -4,7 +4,7 @@ export { checkMapping, TYPES } from './mapping.mjs';
export { classify } from './scope.mjs';
export { extractLinks, parseWikilink, resolver, formOf, isAttachment } from './links.mjs';
export { readLibrary, listTree, readTexts, parseFile, parseFrontMatter, headCommit, MAX_BLOB, LINK_FIELDS } from './reader.mjs';
export { renderItems, KINDS, NOTICES, UNREADABLE } from './render.mjs';
export { renderReply, SCOPES, NOTICE_OPS } from './render.mjs';
export { survey, formatSurvey, surveyMain } from './survey.mjs';
export { SYNC_STATE_FILE, mirrorPath, ensureMirror, withLibraryLock, servedCommit, readSyncState, libraryStatus, readLibraryFile } from './mirror.mjs';
export { syncCommand, sync, ASKPASS, SLOT, SYNC_INTERVAL_MS } from './sync.mjs';
+64 -51
View File
@@ -1,59 +1,72 @@
// renderItems(body): how a stored cited reply looks (design 7.4), shared by the Console chat (C2-10b)
// and the Discord relay (C2-18). Pure: no git, no clock. The caller passes what it knows about cites.
// The guard (C2-06) checks limits before a reply is stored. This only refuses to show a body that
// doesn't have the reply shape, and then it shows a fixed line, never the body's text.
export const KINDS = Object.freeze({ fact: 'Fact', finding: 'Finding', correction: 'Correction', question: 'Question', idea: 'Idea' });
export const NOTICES = Object.freeze({ 'no-cited-answer': 'No cited answer', 'no-answer': 'No answer', 'turn-failed': 'The turn failed' });
export const UNREADABLE = 'This reply could not be shown.';
// renderReply(body): how a stored reply looks (design 7.4), shared by the Console chat (C2-10b) and the
// Discord relay (C2-18). It replaces C2-01's items renderer. Pure: no git, no clock, no DOM. The caller
// passes what only the bus knows: the stored check result (C2-06) and whether a cited file changed.
// The guard checks a reply before it is stored. This only decides how a body reads; it trusts nothing
// in the body for a scope or the agent-written label.
export const SCOPES = Object.freeze(['canon', 'playground', 'supporting', 'web']);
export const NOTICE_OPS = Object.freeze({ write: 'wrote', delete: 'deleted' });
const SHA = /^[0-9a-f]{40}$/;
const plain = value => value !== null && typeof value === 'object' && !Array.isArray(value);
const line = n => Number.isSafeInteger(n) && n > 0;
function cite(value, { scopeOf, changed }) {
if (!plain(value) || typeof value.quote !== 'string') return null;
if (Object.hasOwn(value, 'url')) {
if (typeof value.url !== 'string' || Object.hasOwn(value, 'path')) return null;
return { badge: 'web', url: value.url, ref: value.url, quote: value.quote, changed: false };
}
const { path, commit, lines } = value;
if (typeof path !== 'string' || !SHA.test(commit ?? '') || !Array.isArray(lines) || lines.length !== 2
|| !line(lines[0]) || !line(lines[1]) || lines[1] < lines[0]) return null;
const badge = scopeOf?.(path);
const ref = `${path}:${lines[0] === lines[1] ? lines[0] : `${lines[0]}-${lines[1]}`} @${commit.slice(0, 7)}`;
return { badge: badge === 'canon' || badge === 'playground' ? badge : null, path, commit, lines: [...lines], ref,
quote: value.quote, changed: changed?.({ path, commit }) === true };
}
const only = (value, keys) => Object.keys(value).every(k => keys.includes(k));
const quoted = text => JSON.stringify(text);
// webby-0041 N5: only an https url is ever a link. Anything else, javascript: and http: included, is text.
function https(url) {
try { return new URL(url).protocol === 'https:' ? url : null; } catch { return null; }
}
// body: the stored message body (a JSON string or the parsed object).
// options.scopeOf(path) -> 'canon' | 'playground' | undefined, for the scope badge.
// options.changed({path, commit}) -> true when the file's last commit is newer than the cited one.
// Returns { kind: 'items', items, text } | { kind: 'notice', notice, text } | { kind: 'unreadable', text }.
export function renderItems(body, options = {}) {
const unreadable = { kind: 'unreadable', text: UNREADABLE };
// The design 5.2 cite shapes. null when the value isn't one, and then the body is not a cited reply.
function cite(value) {
if (!plain(value) || typeof value.quote !== 'string') return null;
if (Object.hasOwn(value, 'url')) return typeof value.url === 'string' && only(value, ['url', 'quote']) ? { url: value.url, quote: value.quote } : null;
const { path, commit, lines } = value;
if (!only(value, ['path', 'commit', 'lines', 'quote', 'as']) || typeof path !== 'string' || !SHA.test(commit ?? '')
|| !Array.isArray(lines) || lines.length !== 2 || !line(lines[0]) || !line(lines[1]) || lines[1] < lines[0]
|| (Object.hasOwn(value, 'as') && value.as !== 'canon')) return null;
return { path, commit, lines: [...lines], quote: value.quote, canon: value.as === 'canon' };
}
// check: the stored entry for this cite, { scope, agent }, or undefined. A path cite takes its badge only
// from it; a url cite is web by its shape. agent is the Mosaic-Agent trailer value, never the body's.
function shown(c, check, changed, i) {
if (c.url !== undefined) return { badge: 'web', url: c.url, link: https(c.url), ref: c.url, quote: c.quote, agent: null, changed: false };
const scope = plain(check) && SCOPES.includes(check.scope) && check.scope !== 'web' ? check.scope : null;
const agent = plain(check) && typeof check.agent === 'string' && check.agent !== '' ? check.agent : null;
const ref = `${c.path}:${c.lines[0] === c.lines[1] ? c.lines[0] : `${c.lines[0]}-${c.lines[1]}`} @${c.commit.slice(0, 7)}`;
return { badge: scope, path: c.path, commit: c.commit, lines: c.lines, ref, quote: c.quote, canon: c.canon, agent,
changed: changed?.({ path: c.path, commit: c.commit }, i) === true };
}
// The broker's write notice (design, writes): {"notice": "library-write", op, path, commit}, no agent text.
function notice(value, name) {
if (!plain(value) || value.notice !== 'library-write' || !only(value, ['notice', 'op', 'path', 'commit'])
|| !Object.hasOwn(NOTICE_OPS, value.op) || typeof value.path !== 'string' || !SHA.test(value.commit ?? '')) return null;
const who = typeof name === 'string' && name !== '' ? name : 'An agent';
return { kind: 'notice', op: value.op, path: value.path, commit: value.commit,
text: `${who} ${NOTICE_OPS[value.op]} ${value.path} @${value.commit.slice(0, 7)}` };
}
// body: the stored message body, a string (or an already parsed object).
// options.check: the stored check result, { citations: [{ scope, agent }] } in body order, or null.
// options.changed({path, commit}, index) -> true when the file's last commit is newer than the cited one.
// options.name: the sender's name for a notice line.
// Returns { kind: 'text', text, plain } | { kind: 'reply', text, citations, plain } | { kind: 'notice', op, path, commit, text, plain }.
// plain is the whole rendering as text, for a channel that shows no markup.
export function renderReply(body, options = {}) {
const raw = typeof body === 'string' ? body : JSON.stringify(body) ?? String(body);
let value = body;
if (typeof body === 'string') { try { value = JSON.parse(body); } catch { return unreadable; } }
if (!plain(value) || !Array.isArray(value.items)) return unreadable;
const keys = Object.keys(value);
if (value.items.length === 0) {
if (keys.length !== 2 || !Object.hasOwn(NOTICES, value.notice)) return unreadable;
return { kind: 'notice', notice: value.notice, text: NOTICES[value.notice] };
}
if (keys.length !== 1) return unreadable;
const items = [];
for (const item of value.items) {
if (!plain(item) || !Object.hasOwn(KINDS, item.kind) || typeof item.text !== 'string' || !Array.isArray(item.cites)) return unreadable;
const correction = item.kind === 'correction';
if (correction && (typeof item.from !== 'string' || typeof item.to !== 'string')) return unreadable;
const cites = item.cites.map(c => cite(c, options));
if (cites.some(c => c === null)) return unreadable;
items.push({ kind: item.kind, label: KINDS[item.kind], text: item.text,
...(correction ? { from: item.from, to: item.to } : {}), cites });
}
const text = items.map((item, i) => [
`${i + 1}. ${item.label}: ${item.text}`,
...(item.kind === 'correction' ? [` From: ${quoted(item.from)}`, ` To: ${quoted(item.to)}`] : []),
...item.cites.map(c => ` ${c.badge ? `[${c.badge}] ` : ''}${c.ref} ${quoted(c.quote)}${c.changed ? ' (changed since)' : ''}`),
].join('\n')).join('\n');
return { kind: 'items', items, text };
if (typeof body === 'string') { try { value = JSON.parse(body); } catch { value = undefined; } }
const n = notice(value, options.name);
if (n) return { ...n, plain: n.text };
// Design 5.2: a JSON object must be {text, citations} and nothing else. Any other body is a reply whose
// text is the whole body and which carries no citation.
const free = { kind: 'text', text: raw, plain: raw };
if (!plain(value) || typeof value.text !== 'string' || !Array.isArray(value.citations) || !only(value, ['text', 'citations'])) return free;
const cites = value.citations.map(cite);
if (cites.some(c => c === null)) return free;
const checks = plain(options.check) && Array.isArray(options.check.citations) ? options.check.citations : [];
const citations = cites.map((c, i) => shown(c, checks[i], options.changed, i));
const lines = citations.map(c => ` ${c.badge ? `[${c.badge}] ` : ''}${c.ref} ${quoted(c.quote)}${c.agent ? ` (agent-written: ${c.agent})` : ''}${c.changed ? ' (changed since)' : ''}`);
return { kind: 'reply', text: value.text, citations, plain: [value.text, ...lines].join('\n') };
}
+82 -68
View File
@@ -1,80 +1,94 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { renderItems, UNREADABLE } from '../src/index.mjs';
import { renderReply } from '../src/index.mjs';
// Row 225 (C2-10b): renderReply replaces renderItems (design 7.4, the reply schema of 5.2).
const A = 'a'.repeat(40), B = 'b'.repeat(40);
const cite = (path, lines, quote, commit = A) => ({ path, commit, lines, quote });
const options = {
scopeOf: path => path.includes('/Playground/') ? 'playground' : path.endsWith('.md') ? 'canon' : undefined,
changed: ({ path, commit }) => path === 'Book One/Characters/Mara Vell.md' && commit === A,
};
const render = items => renderItems(JSON.stringify({ items }), options);
const cite = (path, lines, quote, commit = A, extra = {}) => ({ path, commit, lines, quote, ...extra });
const web = (url, quote = 'spring tides') => ({ url, quote });
const reply = (text, citations) => JSON.stringify({ text, citations });
const CITED = reply("Mara's eyes are grey in chapter 3.", [
cite('Book One/Chapters/Chapter 03.md', [41, 41], 'her grey eyes', A, { as: 'canon' }),
cite('Book One/Playground/What If.md', [5, 6], 'a storm', B),
cite('Notes/Eyes.md', [9, 10], 'green eyes'),
web('https://example.invalid/tides'),
]);
const CHECK = { citations: [{ scope: 'canon', agent: null }, { scope: 'playground', agent: null },
{ scope: 'supporting', agent: 'acme/scribe' }, { scope: 'web', agent: null }] };
const changed = ({ path, commit }) => path === 'Notes/Eyes.md' && commit === A;
// One snapshot per item kind: the plain text both channels show.
const SNAPSHOTS = {
fact: [{ kind: 'fact', text: 'Tomas is the ferryman.', cites: [cite('Book One/Characters/Tomas.md', [3, 3], 'the ferryman', B)] }],
finding: [{ kind: 'finding', text: 'Eye colour differs.', cites: [cite('Book One/Chapters/Chapter 01.md', [41, 41], 'her grey eyes'),
cite('Book One/Characters/Mara Vell.md', [9, 10], 'green eyes')] }],
correction: [{ kind: 'correction', text: 'Chapter 1 says grey.', from: 'green eyes', to: 'grey eyes',
cites: [cite('Book One/Characters/Mara Vell.md', [9, 9], 'green eyes', B)] }],
question: [{ kind: 'question', text: 'Is the harbor north of the town?', cites: [cite('Book One/Places/Harbor Town.md', [2, 4], 'the harbor', B)] }],
idea: [{ kind: 'idea', text: 'A storm could close the harbor.', cites: [cite('Book One/Playground/What If.md', [5, 5], 'a storm', B),
{ url: 'https://example.invalid/tides', quote: 'spring tides' }] }],
};
const EXPECTED = {
fact: '1. Fact: Tomas is the ferryman.\n [canon] Book One/Characters/Tomas.md:3 @bbbbbbb "the ferryman"',
finding: '1. Finding: Eye colour differs.\n [canon] Book One/Chapters/Chapter 01.md:41 @aaaaaaa "her grey eyes"\n'
+ ' [canon] Book One/Characters/Mara Vell.md:9-10 @aaaaaaa "green eyes" (changed since)',
correction: '1. Correction: Chapter 1 says grey.\n From: "green eyes"\n To: "grey eyes"\n'
+ ' [canon] Book One/Characters/Mara Vell.md:9 @bbbbbbb "green eyes"',
question: '1. Question: Is the harbor north of the town?\n [canon] Book One/Places/Harbor Town.md:2-4 @bbbbbbb "the harbor"',
idea: '1. Idea: A storm could close the harbor.\n [playground] Book One/Playground/What If.md:5 @bbbbbbb "a storm"\n'
+ ' [web] https://example.invalid/tides "spring tides"',
};
for (const kind of Object.keys(SNAPSHOTS)) {
test(`renderItems snapshot: ${kind}`, () => {
const out = render(SNAPSHOTS[kind]);
assert.equal(out.kind, 'items');
assert.equal(out.text, EXPECTED[kind]);
assert.equal(out.items[0].kind, kind);
});
}
test('renderItems numbers several items and takes a parsed body as well as a string', () => {
const items = [...SNAPSHOTS.fact, ...SNAPSHOTS.question];
const out = renderItems({ items }, options);
assert.equal(out.text, `${EXPECTED.fact}\n${EXPECTED.question.replace(/^1\./, '2.')}`);
assert.deepEqual(out.items[1].cites[0], { badge: 'canon', path: 'Book One/Places/Harbor Town.md', commit: B, lines: [2, 4],
ref: 'Book One/Places/Harbor Town.md:2-4 @bbbbbbb', quote: 'the harbor', changed: false });
test('free text renders as itself with no citations', () => {
for (const body of ['Grey, in chapter 3.', '', '"a JSON string"', '[1, 2]', '42', 'null'])
assert.deepEqual(renderReply(body, { check: CHECK }), { kind: 'text', text: body, plain: body });
});
test('without options a path cite has no badge and is never marked changed', () => {
assert.equal(renderItems({ items: SNAPSHOTS.finding }).text,
'1. Finding: Eye colour differs.\n Book One/Chapters/Chapter 01.md:41 @aaaaaaa "her grey eyes"\n'
+ ' Book One/Characters/Mara Vell.md:9-10 @aaaaaaa "green eyes"');
test('a cited reply renders its text, then each cite with the badge from the check result', () => {
const out = renderReply(CITED, { check: CHECK, changed });
assert.equal(out.kind, 'reply');
assert.equal(out.text, "Mara's eyes are grey in chapter 3.");
assert.deepEqual(out.citations.map(c => c.badge), ['canon', 'playground', 'supporting', 'web']);
assert.equal(out.plain, "Mara's eyes are grey in chapter 3.\n"
+ ' [canon] Book One/Chapters/Chapter 03.md:41 @aaaaaaa "her grey eyes"\n'
+ ' [playground] Book One/Playground/What If.md:5-6 @bbbbbbb "a storm"\n'
+ ' [supporting] Notes/Eyes.md:9-10 @aaaaaaa "green eyes" (agent-written: acme/scribe) (changed since)\n'
+ ' [web] https://example.invalid/tides "spring tides"');
assert.deepEqual(out.citations[0], { badge: 'canon', path: 'Book One/Chapters/Chapter 03.md', commit: A, lines: [41, 41],
ref: 'Book One/Chapters/Chapter 03.md:41 @aaaaaaa', quote: 'her grey eyes', canon: true, agent: null, changed: false });
assert.deepEqual(out.citations[3], { badge: 'web', url: 'https://example.invalid/tides', link: 'https://example.invalid/tides',
ref: 'https://example.invalid/tides', quote: 'spring tides', agent: null, changed: false });
});
const NOTICE_SNAPSHOTS = { 'no-cited-answer': 'No cited answer', 'no-answer': 'No answer', 'turn-failed': 'The turn failed' };
for (const [notice, text] of Object.entries(NOTICE_SNAPSHOTS)) {
test(`renderItems snapshot: notice ${notice}`, () => {
assert.deepEqual(renderItems(JSON.stringify({ items: [], notice }), options), { kind: 'notice', notice, text });
});
}
test('a parsed body renders as its string does', () => {
assert.deepEqual(renderReply(JSON.parse(CITED), { check: CHECK, changed }), renderReply(CITED, { check: CHECK, changed }));
});
test('a body without the reply shape shows a fixed line and none of its text', () => {
const secret = 'free prose the guard would refuse';
const bodies = [secret, JSON.stringify(secret), '{"items": [], "notice": "other"}', JSON.stringify({ items: [], notice: 'no-answer', extra: secret }),
JSON.stringify({ items: [] }), JSON.stringify({ items: [{ kind: 'story', text: secret, cites: [] }] }),
JSON.stringify({ items: [{ kind: 'fact', text: secret }] }), JSON.stringify({ items: SNAPSHOTS.fact, prose: secret }),
JSON.stringify({ items: [{ kind: 'correction', text: secret, cites: [] }] }),
JSON.stringify({ items: [{ kind: 'fact', text: secret, cites: [cite('a.md', [2, 1], 'q')] }] }),
JSON.stringify({ items: [{ kind: 'fact', text: secret, cites: [{ path: 'a.md', commit: 'abc', lines: [1, 1], quote: 'q' }] }] }),
JSON.stringify({ items: [{ kind: 'fact', text: secret, cites: [{ url: 'https://x.invalid', path: 'a.md', quote: 'q' }] }] }),
null, 42, '[]'];
for (const body of bodies) {
const out = renderItems(body, options);
assert.deepEqual(out, { kind: 'unreadable', text: UNREADABLE }, String(body));
assert.ok(!JSON.stringify(out).includes(secret));
test('without a check result a path cite has no badge, no label and is never changed', () => {
const out = renderReply(CITED);
assert.deepEqual(out.citations.map(c => [c.badge, c.agent, c.changed]),
[[null, null, false], [null, null, false], [null, null, false], ['web', null, false]]);
});
test('the label and the badge come from the check result only, never from a bad entry', () => {
const bad = { citations: [{ scope: 'web', agent: '' }, { scope: 'story', agent: 7 }, null, { scope: 'canon', agent: 'x/y' }] };
const out = renderReply(CITED, { check: bad });
// A path cite can't be web; a url cite is web by its shape whatever the check says, and never agent-written.
assert.deepEqual(out.citations.map(c => [c.badge, c.agent]), [[null, null], [null, null], [null, null], ['web', null]]);
});
test('a web cite is a link only for https', () => {
const urls = { 'https://example.invalid/a': 'https://example.invalid/a', 'http://example.invalid/a': null,
'javascript:alert(1)': null, 'JAVASCRIPT:alert(1)': null, 'data:text/html,x': null, 'not a url': null, '//example.invalid': null };
for (const [url, link] of Object.entries(urls)) {
const out = renderReply(reply('t', [web(url)]));
assert.equal(out.kind, 'reply', url);
assert.equal(out.citations[0].link, link, url);
assert.equal(out.citations[0].ref, url);
}
});
test('a write notice renders its fixed line', () => {
const body = op => JSON.stringify({ notice: 'library-write', op, path: 'Notes/Eyes.md', commit: B });
assert.deepEqual(renderReply(body('write'), { name: 'Scribe' }),
{ kind: 'notice', op: 'write', path: 'Notes/Eyes.md', commit: B, text: 'Scribe wrote Notes/Eyes.md @bbbbbbb', plain: 'Scribe wrote Notes/Eyes.md @bbbbbbb' });
assert.equal(renderReply(body('delete'), { name: 'Scribe' }).text, 'Scribe deleted Notes/Eyes.md @bbbbbbb');
assert.equal(renderReply(body('write')).text, 'An agent wrote Notes/Eyes.md @bbbbbbb');
});
test('a body without the reply or notice shape is free text: the whole body, no citations', () => {
const bodies = [
JSON.stringify({ text: 't' }), JSON.stringify({ citations: [] }), JSON.stringify({ text: 1, citations: [] }),
JSON.stringify({ text: 't', citations: [], extra: 1 }), JSON.stringify({ items: [] }),
reply('t', [cite('a.md', [2, 1], 'q')]), reply('t', [cite('a.md', [0, 1], 'q')]), reply('t', [cite('a.md', [1], 'q')]),
reply('t', [{ path: 'a.md', commit: 'abc', lines: [1, 1], quote: 'q' }]), reply('t', [cite('a.md', [1, 1], 'q', A, { as: 'playground' })]),
reply('t', [cite('a.md', [1, 1], 'q', A, { scope: 'canon' })]), reply('t', [{ url: 'https://x.invalid', path: 'a.md', quote: 'q' }]),
reply('t', [{ url: 'https://x.invalid' }]), reply('t', [{ url: 'https://x.invalid', quote: 'q', agent: 'x/y' }]),
JSON.stringify({ notice: 'library-write', op: 'move', path: 'a.md', commit: A }),
JSON.stringify({ notice: 'library-write', op: 'write', path: 'a.md', commit: 'abc' }),
JSON.stringify({ notice: 'library-write', op: 'write', path: 'a.md', commit: A, by: 'x' }),
];
for (const body of bodies) assert.deepEqual(renderReply(body, { check: CHECK }), { kind: 'text', text: body, plain: body }, body);
});
test('an empty citations list is a reply with no cites', () => {
assert.deepEqual(renderReply(reply('Grey.', [])), { kind: 'reply', text: 'Grey.', citations: [], plain: 'Grey.' });
});
+9 -13
View File
@@ -4,22 +4,22 @@ Piece 4, #1507. The control board is the first and only WebUI screen.
## Run
The board runs inside the Console (row 198), so there is one server to start:
Start the existing board in one terminal, then the WebUI in another:
```sh
node packages/control-board/src/cli.mjs serve
node packages/webui/src/cli.mjs serve
```
Open http://127.0.0.1:7330/. Optional `--port N` selects another port. There
is no separate board server (`control-board serve` is retired and exits 2) and
no `--board` flag (it is refused).
Open http://127.0.0.1:7330/. Optional `--port N` and
`--board http://127.0.0.1:7331` select another port or loopback board origin.
`--business ID` picks the business the bus views read; without it they read
the running bus host's business (`mosaic bus start <business>`).
Port 0 picks a free port. Ctrl-C stops each foreground server. No daemon,
installation, account, login or deployment is added. The preview mode below
checks one header a sign-in proxy sets; it signs nobody in itself.
The WebUI binds to IPv4 loopback
Only HTTP loopback board origins are accepted. The WebUI binds to IPv4 loopback
by default, rejects a repeated Host (400), nonlocal Host and cross-origin
requests, sends no CORS
headers, refuses redirects and accepts only JSON object POST bodies up to 4096
@@ -60,11 +60,9 @@ In preview mode:
`Host: NAME` would otherwise skip the sign-in check.
- Every method other than GET and HEAD answers 403 `read-only`, whatever the
Host. `/api/seen` and `/api/reply` never reach the board, so Seen and Reply
fail in the page with that refusal. The exceptions are POST `/api/bus/message`
when the Console was started with `--send` (next section), and POST
`/api/seen` and `/api/reply` when the Console runs with `--send` on the
public name (row 198: the board runs in-process, so these writes reach it
directly; without `--send` they answer 403 `read-only`).
fail in the page with that refusal. The one exception is POST
`/api/bus/message` when the Console was started with `--send` (next
section).
- The GET routes are unchanged: the bus views, the Queue, Business and
Settings reads, the board panel and the assets. If the board is down, its
panel shows the existing error and the bus views still answer.
@@ -953,9 +951,7 @@ completion Seen and find it again in Seen. Record any reason to open the board's
own page or a repo seat terminal in DEFERRED.md. Pass is Jason's end-of-day
say-so, not a test result. Keep #1507 open pending that ruling.
Rollback requires only stopping the WebUI. The board runs inside it, so there is no separate board to keep running.
A move to a pre-198 copy, by rollback or by activate, has no board until the next re-pin. The plan drops a recorded `--board` (the 7331 board is retired and the install refuses the flag), and that Console runs without a board until a copy that has the in-process board is activated.
Rollback requires only stopping the WebUI and using the unchanged board at 7331.
Revert the scoped WebUI commit to remove it; there is no data migration to undo.
Seen and reply actions already taken belong to the board and are not rolled back.
+2
View File
@@ -189,5 +189,7 @@ const STATUS = {
// leaves the remote unreachable, like no bus to ask.
'library-sync-busy': 409, 'library-diverged': 409, 'library-accept-stale': 409, 'library-accept-invalid': 400,
'library-credential-missing': 503, 'library-fetch-failed': 503,
// Row 225 (C2-10b): a Revert of a commit that isn't an agent's is hers to read, like stale-base.
'library-revert-not-agent': 409,
};
export const busStatus = code => STATUS[code] ?? 502;
+10 -22
View File
@@ -1,14 +1,12 @@
#!/usr/bin/env node
// MOSAIC_TESTER_SOCKET_V1: private tester ingress, no tester TCP.
import { createChat } from './chat.mjs';
import { startServer, previewOptions, proofOptions } from './serve.mjs';
import { startServer, previewOptions, proofOptions, DEFAULT_BOARD } from './serve.mjs';
import { busCaller, busBusiness, userCaller, consoleCall } from './bus.mjs';
import { consoleBus, userOptionsFor, agentCaller, TESTER_AGENT_VERBS } from './console-bus.mjs';
import { consoleAccess } from './access.mjs';
import { accessContextFor } from '../../cli/src/access.mjs';
import { accessFilePath, loadAccess } from '../../business/src/access.mjs';
import { createBoard } from '../../control-board/src/board.mjs';
import { discoverRepoAgents, discoverFleetAgents } from '../../control-board/src/scan.mjs';
import { startTesterSocket } from './tester-socket.mjs';
import { TESTER_CLI } from '../../cli/src/transport.mjs';
import { views } from '../../bus/src/views.mjs';
@@ -17,15 +15,14 @@ import { configDir } from '../../business/src/business.mjs';
import { consoleReads, redactor } from './reads.mjs';
import { createSso, readSlot } from './sso.mjs';
import { statSync } from 'node:fs';
import { homedir } from 'node:os';
import { isAbsolute, join } from 'node:path';
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-socket] [--checkout PATH] [--business ID] [--sso [--send]] [--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] [--sso [--send]] [--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 = { port: 7330 }, preview = {}, proofFlags = {};
const options = { board: DEFAULT_BOARD, port: 7330 }, preview = {}, proofFlags = {};
let sendOn = false, ssoOn = false, testerSocket = false;
while (args.length) {
const flag = args.shift();
@@ -35,14 +32,12 @@ try {
if (flag === '--sso') { ssoOn = true; continue; }
if (flag === '--tester-socket') { testerSocket = true; continue; }
if (flag === '--tester-port') throw new Error('--tester-port retired; activate explicitly with --tester-socket');
// Row 198: the board runs inside the Console; there is no board origin to point at.
if (flag === '--board') throw new Error('--board retired: the board runs in the Console (row 198)');
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 === '--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.
else if (flag === '--checkout') {
@@ -73,14 +68,14 @@ try {
// the system config, Business can't resolve authority and Settings has
// no notifier. A refusal printed here names no path (redactor).
// --send without a bus refuses to start rather than serve a composer that can't send.
let bus = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, libraryFile = null, libraryStatus = null, librarySync = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, decisionWrite = null, busLine, system = null;
let bus = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, libraryFile = null, libraryStatus = null, librarySync = null, libraryRevert = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, decisionWrite = null, busLine, system = null;
let processing = null, ownerCall = null;
try {
system = loadSystem();
const { dataRoot } = system;
const call = ownerCall = busCaller({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null });
// FED R6 (#1800): the bus-backed options, shared with each granted user's session (console-bus.mjs).
({ processing, chat, bus, projectFiles, projectFile, libraryFile, libraryStatus, send, librarySync, settingsWrite, projectSave, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite } = consoleBus(call, { send: sendOn }));
({ processing, chat, bus, projectFiles, projectFile, libraryFile, libraryStatus, send, librarySync, libraryRevert, settingsWrite, projectSave, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite } = consoleBus(call, { send: sendOn }));
// Row 252 (#1753): Approve and Deny on grant requests, through the console wrapper, only with
// --sso and --send.
if (ssoOn && sendOn) decisionWrite = consoleCall({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null });
@@ -92,13 +87,6 @@ try {
if (testerSocket) throw new Error(`--tester-socket needs the bus configured (${why})`);
busLine = `Bus: not configured (${why})`;
}
// Row 198: the board runs in-process over the system config's data root and reads the same repository
// agents as before, plus the fleet's read-only specs (the old board's default). Without the system
// config there is no data root, so the board routes answer not-configured.
const board = system ? createBoard({
specs: [...discoverRepoAgents(options.checkout ?? REPO), ...discoverFleetAgents(join(homedir(), '.mosaic', 'fleet', 'agents'))],
boardDir: join(system.dataRoot, 'board'), seatsDir: join(system.dataRoot, 'seats'), discordDataRoot: system.dataRoot,
}) : null;
const reads = consoleReads({ root: REPO, queueRoot: options.checkout ?? REPO, system, configDir: configDir(), rolesDir: rolesDir(), business: options.business ?? null });
// The sign-in config is checked again here, then each slot file, before anything listens. A
// provider that can't be reached starts degraded; one whose discovery is refused stops the start.
@@ -119,14 +107,14 @@ try {
}
let server;
try {
server = await startServer({ board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, libraryFile, libraryStatus, librarySync, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, decisionWrite, userOptions, federation: system?.federation ?? null });
server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, libraryFile, libraryStatus, librarySync, libraryRevert, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, decisionWrite, userOptions, federation: system?.federation ?? null });
} catch (err) { sso?.close(); throw err; }
let testerServer = 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 startTesterSocket({ dataRoot: system.dataRoot, board,
testerServer = await startTesterSocket({ dataRoot: system.dataRoot, board: options.board,
tester: true, testerProofHeader: proof?.header ?? null, testerAuthHeader: options.preview?.header ?? null, bus: testerBus, reads,
processing: { read: () => call('processing.status') },
projectFiles: args => call('project.files', args), projectFile: args => call('project.file', args),
@@ -145,7 +133,7 @@ try {
const health = sso?.health() ?? {};
const ssoLines = sso ? `\nSign-in: ${sso.publicOrigin}/ (--sso), ${send ? 'reads and human writes (--send), grant Approve and Deny' : 'read-only'}` + sso.summary().map(p => `\nSign-in provider: ${p.id} issuer ${p.issuer} subjects ${p.subjects} (${health[p.id]})`).join('') : '';
const queueLine = options.checkout ? '\nQueue: read from the --checkout directory' : '';
console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${board ? 'in the Console (no port)' : 'not configured (no system config)'}\n${busLine}${queueLine}${previewLine}${ssoLines}${proofLine}${testerServer ? `\nTester Console: unix:${testerServer.address()} (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}${ssoLines}${proofLine}${testerServer ? `\nTester Console: unix:${testerServer.address()} (binds the tester)` : ''}\nCtrl-C stops this server.`);
for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => { sso?.close(); server.close(); testerServer?.close(); });
} catch (err) {
console.error(`refused: ${err.message}`);
+2 -2
View File
@@ -30,10 +30,10 @@ export function consoleBus(call, { send: sendOn = false, owner = null } = {}) {
projectFiles: args => call('project.files', args),
projectFile: args => call('project.file', args),
libraryFile: args => call('library.file', args), libraryStatus: args => call('library.status', args),
send: null, librarySync: null, settingsWrite: null, projectSave: null, nameWrite: null, assistantStop: null, instancesWrite: null, routesWrite: null, agentWrite: null,
send: null, librarySync: null, libraryRevert: null, settingsWrite: null, projectSave: null, nameWrite: null, assistantStop: null, instancesWrite: null, routesWrite: null, agentWrite: null,
};
if (sendOn) {
o.send = args => call('message.send', args); o.librarySync = args => call('library.sync', args); o.settingsWrite = args => call('limits.set', args);
o.send = args => call('message.send', args); o.librarySync = args => call('library.sync', args); o.libraryRevert = args => call('library.revert', args); o.settingsWrite = args => call('limits.set', args);
o.projectSave = args => call('project.file.save', args); o.nameWrite = args => call('name.set', args); o.assistantStop = args => call('assistant.stop', args);
o.instancesWrite = args => call('instances.set', args); o.routesWrite = args => call('routes.set', args); o.agentWrite = agentCaller(call, AGENT_VERBS);
}
+5 -8
View File
@@ -7,7 +7,7 @@
// Delivered replies with no newer answer yet, by row: the board's sentAt. Page memory only.
const awaiting = new Map();
let data = null, project = null, selected = null, returnFocus = null, paused = false, busy = false, writing = false, reading = null, timer;
let board = 'the Console board', palette = 'harbor', mode = 'system', failed = false;
let board = 'the configured board', palette = 'harbor', mode = 'system', failed = false;
// Show machine sessions (#1589): off by default, kept with the page's settings. While it is off the
// page doesn't read /api/board at all; the held roles above come from the bus (bus.js).
let machine = false;
@@ -20,8 +20,6 @@
// `sign-in required`; that signs the page out too, and the line links to the Console's sign-in.
// Any other 401 stays an error. `sso` comes from /api/config.
let sso = false;
// Row 198 (mos-1512): the reply form and the Seen button draw only when /api/config says send; otherwise a line says so.
let canSend = false;
const signInRequired = (res, body) => res.status === 401 && body?.error === 'sign-in required';
const signedOut = () => Object.assign(new Error('the sign-in proxy redirected it, so it never reached the Console'), { signedOut: true });
const labels = { registration: 'registered', 'first-user-message': 'first message', 'tmux-pane': 'tmux pane', 'session-cwd': 'session cwd', 'workspace-git-root': 'git root' };
@@ -48,7 +46,7 @@
const s = Date.parse(sent), a = Date.parse(r.lastActivity);
return Number.isFinite(s) && Number.isFinite(a) && a < s;
}
const canReply = r => canSend && !r.connector && r.registered && r.registered.alive !== false && r.registered.tmux?.session;
const canReply = r => !r.connector && r.registered && r.registered.alive !== false && r.registered.tmux?.session;
const row = id => data?.sessions.find(r => key(r) === id);
const announce = text => { $('announce').textContent = text; };
// Colours come from tokens.css by data-palette and data-mode. System removes data-mode, so the OS setting picks light or dark.
@@ -68,7 +66,6 @@
$('mode').onchange = e => { mode = e.target.value; theme(); };
function seenButton(r) {
if (!['waiting', 'error'].includes(r.state) || !r.lastActivity) return '';
if (!canSend) return '<span class="small muted">Seen is off: this Console is read-only.</span>';
return `<button type="button" class="btn" data-seen="${esc(key(r))}" aria-label="${r.seen ? 'Unsee' : 'Mark seen'} ${esc(r.agent)}">${r.seen ? 'Unsee' : 'Seen'}</button>`;
}
const historyButton = r => `<button type="button" class="btn conv-open" data-history="${esc(key(r))}" aria-label="History for ${esc(r.agent)}">History</button>`;
@@ -97,7 +94,7 @@
if (r.relaunchedAt) fields.unshift(['Current activity', relaunchNotice(r)]);
if (r.connector) fields.push(['Connector', `${r.connector.braked === true ? 'braked (STOP)' : r.connector.braked === false ? 'not braked' : 'brake unknown'}; owner ${r.connector.ownerState}`]);
const receipt = receipts.get(selected);
$('inspection').innerHTML = `<dl class="kv">${fields.map(([k, v]) => `<dt>${k}</dt><dd>${esc(v)}</dd>`).join('')}${pending(r) ? `<dt>Reply</dt><dd>Waiting for a reply to your message sent ${esc(awaiting.get(selected))}. The page checks every 10 seconds${paused ? ' once you resume' : ''}.</dd><dt>${r.relaunchedAt ? 'Historical assistant text' : 'Previous assistant text'}, before your message</dt>` : `<dt>${r.relaunchedAt ? 'Historical last assistant text' : 'Last assistant text'}</dt>`}<dd><pre class="last-text">${esc(r.lastAssistantText || 'No assistant text yet.')}</pre></dd>${r.lastError ? `<dt>${r.relaunchedAt ? 'Historical last error' : 'Last error'}</dt><dd>${esc(r.lastError)}</dd>` : ''}</dl>${historyButton(r)}${seenButton(r)}${canReply(r) ? `<form class="reply-form"><label for="reply">Reply to ${esc(r.agent)}</label><textarea id="reply" maxlength="2000" rows="3" placeholder="Message this seat">${esc(drafts.get(selected) || '')}</textarea><button class="btn primary" type="submit" ${sending.has(selected) ? 'disabled' : ''}>${sending.has(selected) ? 'Sending…' : 'Send'}</button></form>` : `<p class="muted">${r.connector ? 'Board replies disabled for Discord connectors' : !canSend ? 'Replies are off: this Console is read-only.' : 'reply needs a registered seat'}</p>`}${receipt ? `<p class="receipt ${receipt.delivered ? '' : 'failed'}" role="status">${esc(receipt.delivered ? `delivered ${receipt.sentAt} to tmux ${receipt.session}` : `failed${receipt.exitCode == null ? '' : ' (exit ' + receipt.exitCode + ')'}: ${receipt.stderr || receipt.error || 'no output'}`)}</p>` : ''}`;
$('inspection').innerHTML = `<dl class="kv">${fields.map(([k, v]) => `<dt>${k}</dt><dd>${esc(v)}</dd>`).join('')}${pending(r) ? `<dt>Reply</dt><dd>Waiting for a reply to your message sent ${esc(awaiting.get(selected))}. The page checks every 10 seconds${paused ? ' once you resume' : ''}.</dd><dt>${r.relaunchedAt ? 'Historical assistant text' : 'Previous assistant text'}, before your message</dt>` : `<dt>${r.relaunchedAt ? 'Historical last assistant text' : 'Last assistant text'}</dt>`}<dd><pre class="last-text">${esc(r.lastAssistantText || 'No assistant text yet.')}</pre></dd>${r.lastError ? `<dt>${r.relaunchedAt ? 'Historical last error' : 'Last error'}</dt><dd>${esc(r.lastError)}</dd>` : ''}</dl>${historyButton(r)}${seenButton(r)}${canReply(r) ? `<form class="reply-form"><label for="reply">Reply to ${esc(r.agent)}</label><textarea id="reply" maxlength="2000" rows="3" placeholder="Message this seat">${esc(drafts.get(selected) || '')}</textarea><button class="btn primary" type="submit" ${sending.has(selected) ? 'disabled' : ''}>${sending.has(selected) ? 'Sending…' : 'Send'}</button></form>` : `<p class="muted">${r.connector ? 'Board replies disabled for Discord connectors' : 'reply needs a registered seat'}</p>`}${receipt ? `<p class="receipt ${receipt.delivered ? '' : 'failed'}" role="status">${esc(receipt.delivered ? `delivered ${receipt.sentAt} to tmux ${receipt.session}` : `failed${receipt.exitCode == null ? '' : ' (exit ' + receipt.exitCode + ')'}: ${receipt.stderr || receipt.error || 'no output'}`)}</p>` : ''}`;
}
// Conversation view (#1507, CHAT-02): the full read-only history from the board's
// /api/conversations and /api/conversation routes. Session content is set with
@@ -197,7 +194,7 @@
if (!conv) return;
const r = row(conv.row), can = !!r && !!canReply(r), receipt = receipts.get(conv.row);
$('conv-form').hidden = !can; $('conv-no-reply').hidden = can;
$('conv-no-reply').textContent = !r ? 'This session is no longer in the board scan.' : r.connector ? 'Board replies disabled for Discord connectors' : !canSend ? 'Replies are off: this Console is read-only.' : 'reply needs a registered seat';
$('conv-no-reply').textContent = !r ? 'This session is no longer in the board scan.' : r.connector ? 'Board replies disabled for Discord connectors' : 'reply needs a registered seat';
if (r) $('conv-reply-label').textContent = `Reply to ${r.agent}`;
$('conv-send').disabled = sending.has(conv.row); $('conv-send').textContent = sending.has(conv.row) ? 'Sending…' : 'Send';
$('conv-receipt').hidden = !receipt;
@@ -479,5 +476,5 @@
setTimeout(() => { const a = document.activeElement; if (!a || a === document.body || a.disabled || !a.getClientRects().length) line.focus(); });
});
// Sign out (sage-1347, F7): a plain same-origin form POST to /auth/logout, shown only under --sso.
api('/api/config').then(config => { canSend = config.send === true; board = config.board || 'the Console board'; $('board-url').textContent = `Board: ${board}`; window.MosaicFederation?.render(config.federation, $('federation')); if (config.sso === true) { sso = true; $('sign-out').hidden = out(); } }).catch(err => { if (machine) error(err); }).finally(refresh);
api('/api/config').then(config => { board = config.board; $('board-url').textContent = `Board: ${board}`; window.MosaicFederation?.render(config.federation, $('federation')); if (config.sso === true) { sso = true; $('sign-out').hidden = out(); } }).catch(err => { if (machine) error(err); }).finally(refresh);
})();
+17
View File
@@ -23,3 +23,20 @@
.chat-session-controls label { display: block; }
.chat-session-controls select, .chat-session-controls input { max-width: 100%; }
.chat-session-controls form { margin: .6rem 0; }
/* Row 225 (C2-10b, design 7.4): a reply's cites, a write notice and the library status line. The
badge and labels are text, so they read the same without colour. */
.chat-cites { list-style: none; margin: .5rem 0 0; padding-inline-start: 1rem; display: grid; gap: .35rem; }
.chat-log .chat-cites li { padding: 0; border-bottom: 0; overflow-wrap: anywhere; font-size: .92rem; }
.chat-cites code { font-family: var(--mono); font-size: .85rem; }
.chat-scope, .chat-label { display: inline-block; padding: 0 .4rem; border: 1px solid var(--line); border-radius: var(--r); font-size: .78rem; }
.chat-scope-canon { border-color: var(--success); color: var(--success); }
.chat-scope-playground { border-color: var(--accent); color: var(--accent); }
.chat-scope-web { border-color: var(--action); color: var(--action); }
.chat-agent-written { color: var(--muted); }
.chat-changed { border-color: var(--warning); color: var(--warning); }
.chat-notice { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; white-space: normal; }
.chat-revert-receipt { overflow-wrap: anywhere; }
.chat-library { font-size: .9rem; margin-block: .5rem; overflow-wrap: anywhere; }
.chat-library p { margin: 0; }
.chat-library-problems ul { margin: .4rem 0 0; padding-inline-start: 1.25rem; }
+103 -4
View File
@@ -17,11 +17,22 @@
if (!forms.has(key)) forms.set(key, { name: '', repo: '', instance: '', instanceChosen: false, receipt: '', pending: false });
return forms.get(key);
};
// Row 225 (C2-10b, design 7.4): a reply renders through the library's renderReply, the one renderer the
// Discord relay shares. Until it loads, or if it can't, a body shows as its text. Badges, the
// agent-written label and "changed since" come from the bus's stored check (C2-06), never the body.
const SHA = /^[0-9a-f]{40}$/, CODE = /^[a-z][a-z-]{0,63}$/, NAME = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,200}$/;
// The library verbs name the business project, which is the chat project's repository link. These
// codes mean that repository is no library, or this page may not read it: no status line at all.
const NO_LIBRARY = ['project-not-declared', 'project-not-configured', 'not-configured', 'owner-only'];
const libraryOf = (c, t) => { const repo = c?.projects.find(p => p.id === t?.project)?.repo; return typeof repo === 'string' && NAME.test(repo) ? repo : null; };
let renderReply = null;
import('/render.mjs').then(m => { renderReply = m.renderReply; if (current && !out()) paint(); }, () => {});
const libraries = new Map(), reverts = new Map();
class ChatError extends Error {
constructor(code, status = 0, sent) { super(code); this.code = code; this.status = status; this.sent = sent; }
}
function forget() {
generation++; controller?.abort(); states.clear(); forms.clear();
generation++; controller?.abort(); states.clear(); forms.clear(); libraries.clear(); reverts.clear();
if (current) current.container.replaceChildren();
current = null;
}
@@ -80,11 +91,93 @@
const reason = !Number.isSafeInteger(selected?.revision) ? 'Safe clear is unavailable until its dependency is installed.' : '';
return `<section class="chat-session-controls" aria-label="Sessions"><label>Session<select id="chat-session">${c.sessions.map(r => `<option value="${esc(r.id)}"${r.id === c.session ? ' selected' : ''}>${txt(r.name)}${r.default ? ' (default)' : ''} — ${txt(r.state ?? 'status unavailable')}</option>`).join('')}</select></label><p id="chat-compactions">Compactions: ${txt(countLabel(selected?.compactions))}</p><p>${txt(selected?.state ?? 'Session status unavailable')}${UUID.test(selected?.successor) ? ` · <a href="${sessionPath(t.id, selected.successor)}">Open fresh successor</a>` : ''}</p><form id="chat-session-form" class="chat-create" data-key="session:${esc(t.id)}"><label>New session name<input id="chat-session-name" data-chat-field="name" value="${esc(f.name)}" maxlength="200" required${disabled(f.pending)}></label><button class="btn" type="submit"${disabled(f.pending)}>New session</button><p role="status">${txt(f.receipt)}</p></form><button class="btn" type="button" id="chat-clear"${disabled(clear.pending || !!reason || selected?.state && selected.state !== 'active')}>Clear context</button>${reason ? `<p>${txt(reason)}</p>` : ''}<p id="chat-clear-receipt" role="status">${txt(clear.receipt)}</p><form id="chat-operation-form"><label>Check clear operation<input id="chat-operation" value="${esc(clear.op ?? '')}" maxlength="160"${clear.pending ? ' disabled' : ''}></label><button class="btn" type="submit">Check status</button><button class="btn" type="button" id="chat-cancel"${disabled(clear.pending || !!reason || !clear.op || ['cancelled', 'failed'].includes(clear.record?.state) || clear.record?.start === 'started')}>Cancel clear</button></form></section>`;
}
// A cite is changed when the file's last commit at the served tip (`last`, read with the page) is not
// its last commit at the cited one (`file`, stored with the check). Either one missing marks nothing.
const changedBy = check => (cite, i) => {
const e = Array.isArray(check?.citations) ? check.citations[i] : null;
return SHA.test(e?.file ?? '') && SHA.test(e?.last ?? '') && e.file !== e.last;
};
function cites(list) {
return `<ul class="chat-cites">${list.map(c => `<li>${c.badge ? `<span class="chat-scope chat-scope-${c.badge}">${txt(c.badge)}</span> ` : ''}${c.link ? `<a href="${esc(c.link)}" rel="noopener noreferrer" target="_blank">${txt(c.ref)}</a>` : `<code>${txt(c.ref)}</code>`} <q>${txt(c.quote)}</q>${c.agent ? ` <span class="chat-label chat-agent-written">agent-written: ${txt(c.agent)}</span>` : ''}${c.changed ? ' <span class="chat-label chat-changed">changed since</span>' : ''}</li>`).join('')}</ul>`;
}
function messageBody(m) {
const r = renderReply?.(m.body, { check: m.check ?? null, name: m.from_role, changed: changedBy(m.check) });
if (r?.kind === 'reply') return `<div class="chat-body">${txt(r.text)}</div>${r.citations.length ? cites(r.citations) : ''}`;
if (r?.kind === 'notice') {
const v = reverts.get(r.commit) ?? {};
return `<div class="chat-body chat-notice"><span>${txt(r.text)}</span> <button class="btn" type="button" data-revert="${esc(r.commit)}"${disabled(v.pending)}>Revert</button> <span class="chat-revert-receipt" role="status">${txt(v.receipt ?? '')}</span></div>`;
}
return `<div class="chat-body">${txt(m.body)}</div>`;
}
const ago = iso => {
const minutes = Math.floor((Date.now() - Date.parse(iso)) / 60000);
return !Number.isFinite(minutes) ? 'at an unknown time' : minutes < 1 ? 'just now' : minutes < 60 ? `${minutes} min ago` : minutes < 2880 ? `${Math.floor(minutes / 60)} h ago` : `${Math.floor(minutes / 1440)} days ago`;
};
// library.status (C2-04): the served commit and when it was last fetched, a held divergence, the last
// fetch error, the reply limits in force (C2-06) and the R-problems, listed one click away.
function libraryLine(project) {
const l = libraries.get(project);
if (!l || l.hidden) return '';
if (l.error) return `<div class="chat-library"><p role="status">Library status unavailable (${txt(l.error)}).</p></div>`;
const v = l.value, parts = [];
parts.push(SHA.test(v.served ?? '') ? `Library at <code>${txt(v.served.slice(0, 7))}</code>` : 'Library not served yet');
if (typeof v.lastFetch === 'string') parts.push(`fetched ${txt(ago(v.lastFetch))}`);
if (SHA.test(v.diverged?.commit ?? '')) parts.push(`<b>library-diverged</b> at <code>${txt(v.diverged.commit.slice(0, 7))}</code>`);
if (CODE.test(v.lastError?.code ?? '')) parts.push(`fetch error <code>${txt(v.lastError.code)}</code>`);
const limits = v.replyLimits;
if (limits && typeof limits === 'object') {
parts.push(`citations ${limits.requireCitation === true ? 'required' : 'optional'}${Number.isSafeInteger(limits.citationsPerReply) ? `, up to ${limits.citationsPerReply} per reply` : ''}`);
const fell = Array.isArray(limits.fallback) ? limits.fallback.filter(k => typeof k === 'string') : [];
if (fell.length) parts.push(`limits fell back: ${fell.map(k => `<code>${txt(k)}</code>`).join(', ')}`);
if (CODE.test(limits.problem ?? '')) parts.push(`<code>${txt(limits.problem)}</code>`);
}
const problems = Array.isArray(v.problems) ? v.problems : [];
if (!problems.length) parts.push('no problems');
const list = problems.length ? `<details class="chat-library-problems"><summary>${problems.length} problem${problems.length === 1 ? '' : 's'}</summary><ul>${problems.map(p => `<li><code>${txt(p?.code)}</code> ${txt(p?.path)}${Number.isSafeInteger(p?.line) ? ':' + p.line : ''} ${txt(p?.message)}</li>`).join('')}</ul></details>` : '';
return `<div class="chat-library"><p role="status">${parts.join(' · ')}</p>${list}</div>`;
}
async function loadLibrary(project, g, signal) {
if (project === null) return;
let entry;
try {
const res = await fetch('/api/library/status?project=' + encodeURIComponent(project), { cache: 'no-store', redirect: 'manual', signal });
if (res.type === 'opaqueredirect' || res.status === 401) { signOut(); return; }
const data = await res.json().catch(() => null);
entry = res.ok && data && typeof data === 'object' && !Array.isArray(data) ? { value: data } : { error: CODE.test(data?.error ?? '') ? data.error : 'invalid-response' };
} catch (err) {
if (err.name === 'AbortError') return;
entry = { error: 'unreachable' };
}
if (g !== generation || out()) return;
libraries.set(project, NO_LIBRARY.includes(entry.error) ? { hidden: true } : entry);
paint();
}
// design (writes): Revert is library.revert {commit}, her act. A refusal shows its code; before
// C2-02's verb exists that is the bus's refusal of the verb.
async function revert(commit) {
const c = current, t = c?.threads.find(t => t.id === c.id), v = reverts.get(commit) ?? {};
const project = libraryOf(c, t);
if (!c || !c.canSend || out() || v.pending || !SHA.test(commit) || project === null) return;
if (!window.confirm(`Revert ${commit.slice(0, 7)}? This makes a new commit in the library that puts the file back as it was before it.`)) return;
const unknown = 'Outcome unknown. Read the library status before trying again; nothing was retried.';
reverts.set(commit, { pending: true, receipt: 'Reverting…' }); paint();
let receipt;
try {
const res = await fetch('/api/library/revert', { method: 'POST', cache: 'no-store', redirect: 'manual', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ project, commit }) });
if (res.type === 'opaqueredirect' || res.status === 401) { signOut(); return; }
const data = await res.json().catch(() => null);
receipt = res.ok && SHA.test(data?.commit ?? '') ? `Reverted in ${data.commit.slice(0, 7)}.`
: data?.sent === false && typeof data.error === 'string' ? `Not reverted (${inert(data.error.slice(0, 80))}).` : unknown;
} catch { receipt = unknown; }
if (out()) return;
reverts.set(commit, { pending: false, receipt });
if (current) { paint(); void loadLibrary(project, generation); }
}
function conversation(t) {
const s = state(current.key);
const messages = s.rows.map(m => `<li data-message="${esc(m.id)}"><div class="chat-message-meta"><strong>${txt(m.from_role)}</strong> ${stamp(m.at)}</div><div class="chat-body">${txt(m.body)}</div></li>`).join('');
const messages = s.rows.map(m => `<li data-message="${esc(m.id)}"><div class="chat-message-meta"><strong>${txt(m.from_role)}</strong> ${stamp(m.at)}</div>${messageBody(m)}</li>`).join('');
const receipt = s.receipt;
return `<section class="chat-conversation" data-thread="${esc(t.id)}" data-session="${esc(current.session)}"><p>${link('projects', t.project, current.projects.find(p => p.id === t.project)?.name ?? 'Project')} / ${txt(t.instance)}</p>${heading(t.name)}${sessionControls(t)}<p class="muted">Reading never wakes the assistant. An accepted message is not an answer.</p><button class="btn" type="button" id="chat-resume"${disabledSession(s.pending)}>Resume assistant</button><ol class="chat-log" tabindex="0" aria-label="Thread conversation">${messages || '<li class="muted">No messages yet.</li>'}</ol><div class="chat-paging">${s.more ? '<p>Showing earlier history. Read forward to reach the current conversation.</p><button class="btn" type="button" id="chat-next">Read next messages</button>' : '<p class="muted">Caught up to the last read.</p>'}<button class="btn" type="button" id="chat-new"${s.follow ? ' hidden' : ''}>New messages — go to end</button></div><form id="chat-send-form"><label for="chat-body">Message this thread</label><textarea id="chat-body" rows="4"${disabledSession(s.pending)}>${esc(s.draft)}</textarea><button class="btn primary" type="submit"${disabledSession(s.pending)}>Send</button></form><p id="chat-receipt" role="status">${txt(receipt)}</p></section>`;
return `<section class="chat-conversation" data-thread="${esc(t.id)}" data-session="${esc(current.session)}"><p>${link('projects', t.project, current.projects.find(p => p.id === t.project)?.name ?? 'Project')} / ${txt(t.instance)}</p>${heading(t.name)}${libraryLine(libraryOf(current, t))}${sessionControls(t)}<p class="muted">Reading never wakes the assistant. An accepted message is not an answer.</p><button class="btn" type="button" id="chat-resume"${disabledSession(s.pending)}>Resume assistant</button><ol class="chat-log" tabindex="0" aria-label="Thread conversation">${messages || '<li class="muted">No messages yet.</li>'}</ol><div class="chat-paging">${s.more ? '<p>Showing earlier history. Read forward to reach the current conversation.</p><button class="btn" type="button" id="chat-next">Read next messages</button>' : '<p class="muted">Caught up to the last read.</p>'}<button class="btn" type="button" id="chat-new"${s.follow ? ' hidden' : ''}>New messages — go to end</button></div><form id="chat-send-form"><label for="chat-body">Message this thread</label><textarea id="chat-body" rows="4"${disabledSession(s.pending)}>${esc(s.draft)}</textarea><button class="btn primary" type="submit"${disabledSession(s.pending)}>Send</button></form><p id="chat-receipt" role="status">${txt(receipt)}</p></section>`;
}
function capture() {
if (!current) return null;
@@ -233,7 +326,11 @@
}
} catch (err) { if (err.name === 'AbortError') return; }
}
if (g === generation) paint(navigated);
if (g === generation) {
paint(navigated);
const t = kind === 'thread' ? current.threads.find(t => t.id === id) : null;
if (t) void loadLibrary(libraryOf(current, t), g, signal);
}
} catch (err) {
if (g !== generation || out() || err.name === 'AbortError' || err.code === 'signed-out') return;
current.error = readError(err);
@@ -340,6 +437,8 @@
if (event.target.id === 'chat-cancel') void cancelClear();
if (event.target.id === 'chat-clear') void clearContext();
if (event.target.id === 'chat-next') void refresh(true);
const revertButton = event.target.closest?.('[data-revert]');
if (revertButton) void revert(revertButton.dataset.revert);
if (event.target.id === 'chat-new') { state(current.key ?? current.id).tailRequested = true; state(current.key ?? current.id).follow = true; paint(); }
});
document.addEventListener('change', event => {
+96 -38
View File
@@ -13,15 +13,27 @@ import { chatHTTP } from './chat-http.mjs';
import { processingHTTP } from './processing-http.mjs';
import { DECISION_ROUTE, decisionHTTP, decisionRefuse, createConfirmations } from './decision-http.mjs';
export const DEFAULT_BOARD = 'http://127.0.0.1:7331';
export function isLoopback(host) {
return host === 'localhost' || host === '::1' || (isIP(host) === 4 && host.startsWith('127.'));
}
export function boardURL(value) {
const url = new URL(value);
if (url.protocol !== 'http:' || !isLoopback(url.hostname.replace(/^\[|\]$/g, '')) || url.username || url.password || url.search || url.hash || url.pathname !== '/') {
throw new Error('board must be an HTTP loopback origin without credentials, path, query or fragment');
}
// Avoid hostname resolution for localhost.
if (url.hostname === 'localhost') url.hostname = '127.0.0.1';
return url.origin;
}
const root = resolve(import.meta.dirname, 'public');
const files = new Map([
['/', ['index.html', 'text/html; charset=utf-8']],
...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js', 'processing.js', 'agents.js', 'federation.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
...['shared/app.css', 'tokens.css', 'console.css', 'live.css', 'bus.css', 'shell.css', 'chat.css', 'processing.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]),
['/editor.js', ['editor.js', 'text/javascript; charset=utf-8']],
// Row 225 (C2-10b): the one reply renderer, shared with the Discord relay, imported by chat.js.
['/render.mjs', ['../../../library/src/render.mjs', 'text/javascript; charset=utf-8']],
['/editor.css', ['editor.css', 'text/css; charset=utf-8']],
['/icons.svg', ['icons.svg', 'image/svg+xml']],
...[400, 500, 600, 700].map(w => [`/assets/fonts/manrope-${w}.woff2`, [`assets/fonts/manrope-${w}.woff2`, 'font/woff2']]),
@@ -32,6 +44,7 @@ const files = new Map([
const USER_FILES = new Map([
['/', ['user.html', 'text/html; charset=utf-8']],
...['user.js', 'chat.js', 'federation.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
['/render.mjs', files.get('/render.mjs')],
...['tokens.css', 'console.css', 'chat.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]),
...[...files].filter(([p]) => p.startsWith('/assets/fonts/') || p === '/icons.svg'),
]);
@@ -181,20 +194,6 @@ const one = (req, name) => (req.headersDistinct[name]?.length === 1 ? req.header
// so it carries `sso: true` and the public origin; the preview writer still checks its header.
const signedIn = (req, p) => p.sso === true || one(req, p.header) === p.user;
const ownOrigin = p => p.origin ?? `https://${p.host}`;
// Row 198: the board's writes, in-process. A body the board refuses is a 400; the reply's own answer
// (its status and receipt) comes back as the board gave it, and a failed scan is a 500 with no detail.
async function boardSend(req, res, { board, path }) {
let value;
try { value = JSON.parse((await body(req)).toString('utf8')); } catch (err) { return json(res, 400, { error: err.message }); }
try {
if (path === '/api/seen') return json(res, 200, await board.markSeen(value));
const out = await board.reply(value);
return json(res, out.status, out.body);
} catch (err) {
if (path === '/api/seen') return json(res, 400, { error: err.message });
return json(res, 500, { error: 'board read failed' });
}
}
async function busSend(req, res, { send, preview, log }) {
if (!signedIn(req, preview)) return json(res, 401, { error: 'sign-in required' });
let to = null;
@@ -600,13 +599,55 @@ async function projectFileRead(res, read, path, search) {
return json(res, busStatus(code), { error: code, message: err instanceof BusReadError ? err.message : code });
}
}
// Row 225 (C2-10b, design 7.4): the chat's library status line and a write notice's Revert. The status
// is a read like the file list. The revert is her write (C2-02's `library.revert`), with the save's
// boundary: header user, exact Origin, same-origin Fetch Metadata, JSON, strict keys. Until the verb
// exists the bus refuses it, and the page shows that code.
const REVERT_KEYS = ['project', 'commit'];
async function libraryRevertSend(req, res, { libraryRevert, preview, log }) {
let target = null;
const line = outcome => log(`${new Date().toISOString()} library-revert user=${JSON.stringify(preview.user)} project=${quoted(target?.project)} commit=${quoted(target?.commit)} ${outcome}`);
const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); };
if (!signedIn(req, preview)) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
if (one(req, 'origin') !== ownOrigin(preview)) return refuse(403, 'cross-origin request refused', 'a revert needs the Console\'s own Origin');
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site request refused', 'a revert needs same-origin Fetch Metadata');
let value;
try { value = JSON.parse((await body(req)).toString('utf8')); } catch (err) { return refuse(400, 'invalid-request', err.message); }
if (value === null || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length !== REVERT_KEYS.length ||!REVERT_KEYS.every(k => typeof value[k] === 'string') || !PATH_SHAPE.test(value.project) || !COMMIT.test(value.commit)) {
return refuse(400, 'invalid-request', 'a revert takes a project and a 40-hex commit, nothing else');
}
target = value;
let result;
try { result = await libraryRevert({ project: value.project, commit: value.commit }); } catch (err) {
const { code, sent } = sendOutcome(err);
return sent === false
? refuse(busStatus(code), code, `not reverted: the bus refused it (${code}).`)
: refuse(503, 'outcome-unknown', 'the revert may have been made; nothing was retried. Read the library status before trying again', 'unknown');
}
if (!COMMIT.test(result?.commit) || result.project !== value.project || result.reverted !== value.commit || typeof result.path !== 'string') return refuse(503, 'outcome-unknown', 'the bus answered without the new commit; the revert may have been made', 'unknown');
line(`commit=${result.commit}`);
return json(res, 200, { project: value.project, path: result.path, reverted: result.reverted, commit: result.commit, at: new Date().toISOString() });
}
async function libraryStatusRead(res, read, search) {
const query = new URLSearchParams(search);
if ([...query.keys()].some(k => k !== 'project') || query.getAll('project').length !== 1 || !PATH_SHAPE.test(query.get('project')))
return json(res, 400, { error: 'invalid-request', message: 'a library status read takes a project' });
if (!read) return json(res, 503, { error: 'not-configured', message: 'the Console has no bus configured' });
try {
const result = await read({ project: query.get('project') });
if (result === null || typeof result !== 'object' || Array.isArray(result) || result.project !== query.get('project')) throw new BusReadError('invalid-response');
return json(res, 200, { ...result, at: new Date().toISOString() });
} catch (err) {
const code = err instanceof BusReadError ? err.code : 'invalid-response';
return json(res, busStatus(code), { error: code, message: err instanceof BusReadError ? err.message : code });
}
}
// `send` is the bus write (row 76): `args => Promise<{id}>`, only with the preview mode or `sso`.
// `sso` is createSso() from sso.mjs (Q175 phase 1): the human listener signs people in itself.
// `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.
// `agentWrite` (row 194, D7) is `(verb, args) => Promise` for the Agents section's verbs and instance.request.
// `board` is createBoard() from packages/control-board (row 198), run in-process; null answers the board routes 503.
export async function startServer({ host = '127.0.0.1', port = 7330, board = null, timeout = 20000, bus = null, reads = null, preview = null, sso = 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, agentWrite = null, processing = null, decisionWrite = null, userOptions = null, federation = 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, sso = null, tester = false, testerSocket = null, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, libraryStatus = null, libraryRevert = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, processing = null, decisionWrite = null, userOptions = null, federation = 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');
@@ -614,16 +655,17 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = nul
if (sso !== null && (preview !== null || proof !== null)) throw new Error('--sso cannot be combined with --auth-header, --allow-user or --proof-*');
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 && [send, settingsWrite, projectSave, libraryRevert, 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');
// Row 252 (#1753, design section 4): Console decision answers need --sso and --send, never the tester.
if (decisionWrite !== null && (tester || sso === null || send === null || typeof decisionWrite !== 'function')) throw new Error('Console decision answers need --sso and --send on the main listener');
const confirmations = createConfirmations();
const upstream = boardURL(board);
// `userOptions` (FED R6, #1800) is `session => options`, the bus-backed options over that session
// user's own transport (console-bus.mjs). Only a signed-in listener has user sessions.
if (userOptions !== null && (sso === null || typeof userOptions !== 'function')) throw new Error('user sessions need --sso');
const owner = { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, files };
const owner = { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, libraryStatus, libraryRevert, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, upstream, files };
const hub = federationView(federation);
// 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 }))) {
@@ -631,10 +673,9 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = nul
}
if (proof !== null && (preview === null || tester)) throw new Error('proxy proof needs preview mode');
if (send !== null && ((preview === null && sso === null) || typeof send !== 'function')) throw new Error('--send needs the preview mode (--public-host, --auth-header and --allow-user) or --sso');
const boardLabel = board ? 'in the Console' : null;
const server = createServer(async (req, res) => {
// A granted user's request rebinds these below; every other request keeps the owner's.
let { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, files } = owner, granted = false;
let { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, libraryStatus, libraryRevert, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, upstream, files } = owner, granted = false;
res.setHeader('x-content-type-options', 'nosniff');
res.setHeader('referrer-policy', 'no-referrer');
// Under --sso the Sign out form's POST is redirected to the provider's end-session endpoint, and
@@ -701,7 +742,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = nul
if (!userRoute(path, req.method)) return json(res, 403, { error: 'owner-only', message: 'this part of the Console is the business owner\'s' });
if (userOptions === null) return json(res, 503, { error: 'not-configured', message: 'the Console has no user transport' });
({ bus, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing } = userOptions(session));
reads = null; files = USER_FILES; granted = true;
reads = null; upstream = null; libraryStatus = null; libraryRevert = null; files = USER_FILES; granted = true;
}
}
if (named && preview !== null) {
@@ -753,6 +794,11 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = nul
if ((!tester && (!send || !named)) || !routesWrite) return json(res, 403, { error: 'read-only', sent: false });
return routesSend(req, res, { routesWrite, preview: writer, log });
}
if (path === '/api/library/revert') {
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
if (!send || !libraryRevert || !named) return json(res, 403, { error: 'read-only', sent: false });
return libraryRevertSend(req, res, { libraryRevert, preview: signer, log });
}
if (path === '/api/project-file' && req.method === 'POST') {
if (!send || !projectSave || !named) return json(res, 403, { error: 'read-only', sent: false });
return projectFileSend(req, res, { projectSave, preview: signer, log });
@@ -784,15 +830,31 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = nul
if (path === '/api/chat' || path.startsWith('/api/chat/')) {
return chatHTTP(req, res, { path, search, chat, named, send, preview: writer, log });
}
// Row 198: the board's two writes run in-process, under --send and the public name only.
if (path === '/api/seen' || path === '/api/reply') {
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
if (!send || !named) return json(res, 403, { error: 'read-only', sent: false });
if (!board) return json(res, 503, { error: 'not-configured', message: 'the Console has no board configured' });
return boardSend(req, res, { board, path });
}
// Nothing else but GET and HEAD gets past here in preview mode, whatever the Host.
if ((preview !== null || sso !== null) && req.method !== 'GET' && req.method !== 'HEAD') return json(res, 403, { error: 'read-only', message: 'the Console preview is read-only' });
// The conversation routes (#1507, CHAT-02) keep their query; the board validates it.
const conversation = path === '/api/conversations' || path === '/api/conversation';
const allowed = path === '/api/board' || conversation ? 'GET' : ['/api/seen', '/api/reply'].includes(path) ? 'POST' : null;
if (allowed) {
if (req.method !== allowed) return json(res, 405, { error: 'method not allowed' });
if (upstream === null) return json(res, 503, { error: 'not-configured', message: 'the Console has no board for this session' });
let bytes;
if (allowed === 'POST') {
try { bytes = await body(req); } catch (err) { return json(res, 400, { error: err.message }); }
}
try {
const response = await fetch(upstream + path + (conversation ? search : ''), {
method: allowed, headers: bytes ? { 'content-type': 'application/json' } : {}, body: bytes,
redirect: 'error', signal: AbortSignal.timeout(timeout),
});
const text = await response.text();
JSON.parse(text); // Never serve upstream HTML or scripts as API data.
res.writeHead(response.status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
return res.end(text);
} catch {
return json(res, 502, { error: `Board unreachable or invalid response at ${upstream}. Check the board server. No automatic action retry.`, board: upstream });
}
}
if (path.startsWith('/api/bus/')) {
const verb = path.slice('/api/bus/'.length);
if (!BUS_VERBS.includes(verb)) return json(res, 404, { error: 'not found' });
@@ -805,6 +867,10 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = nul
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' });
return consoleRead(res, () => fieldTable(fieldsFile));
}
if (path === '/api/library/status') {
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' });
return libraryStatusRead(res, libraryStatus, search);
}
if (path === '/api/project-files' || path === '/api/project-file') {
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' });
return projectFileRead(res, path === '/api/project-file' ? projectFile : projectFiles, path, search);
@@ -812,7 +878,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = nul
if (path === '/api/queue' || path.startsWith('/api/queue/') || path === '/api/business' || path === '/api/settings' || path === '/api/bus-status') {
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' });
const a = server.address(), address = `http://${a.family === 'IPv6' ? `[${a.address}]` : a.address}:${a.port}/`;
if (path === '/api/settings') return consoleRead(res, async () => ({ ...(reads ? await reads.settings() : { release: null, business: null, notifier: { refused: 'not-configured', message: 'the Console has no reads configured' } }), board: boardLabel, address }));
if (path === '/api/settings') return consoleRead(res, async () => ({ ...(reads ? await reads.settings() : { release: null, business: null, notifier: { refused: 'not-configured', message: 'the Console has no reads configured' } }), board: upstream, address }));
if (!reads) return json(res, 503, { error: 'not-configured', message: 'the Console has no reads configured' });
if (path === '/api/business') return consoleRead(res, () => reads.business());
if (path === '/api/bus-status') return consoleRead(res, () => reads.busStatus());
@@ -822,18 +888,10 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = nul
return consoleRead(res, () => reads.row(Number(id)));
}
if (req.method !== 'GET' && req.method !== 'HEAD') return json(res, 405, { error: 'method not allowed' });
// Row 198: the board's reads, in-process. The sign-in gate above has already run for each one.
if (path === '/api/board' || path === '/api/conversations' || path === '/api/conversation') {
if (!board) return json(res, 503, { error: 'not-configured', message: 'the Console has no board configured' });
if (path === '/api/board') return consoleRead(res, () => board.index());
let out;
try { out = board.conversation(new URL(req.url, 'http://localhost')); } catch { return json(res, 500, { error: 'conversation read failed' }); }
return json(res, out.status, out.body);
}
// `send`: the page shows the composer only where a send can pass (the public name, --send on).
// `edit`: the editor shows Save only where a save can pass, the same test as the route's.
// `user` and `federation` (FED R6) only when there is one, so the owner's config is unchanged.
if (path === '/api/config') return json(res, 200, { board: granted ? null : boardLabel, ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), ...(granted ? { user: true } : {}), ...(hub ? { federation: hub } : {}), send: !tester && Boolean(send) && named, decide: !tester && !granted && Boolean(send) && Boolean(decisionWrite) && named && sso !== null, edit: !tester && !granted && Boolean(send) && Boolean(projectSave) && named });
if (path === '/api/config') return json(res, 200, { board: upstream, ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), ...(granted ? { user: true } : {}), ...(hub ? { federation: hub } : {}), send: !tester && Boolean(send) && named, decide: !tester && !granted && Boolean(send) && Boolean(decisionWrite) && named && sso !== null, edit: !tester && !granted && Boolean(send) && Boolean(projectSave) && named });
if (path === '/healthz') return json(res, 200, { ok: true, ...(sso ? { sso: sso.health() } : {}) });
if (path === '/favicon.ico') { res.writeHead(204); return res.end(); }
const file = files.get(path);
+2 -2
View File
@@ -17,7 +17,7 @@ const view={business:'acme',revision:REV,launch:true,noHire:['reviewer'],retired
test('row 194 T16 Settings Agents: unavailable read-only, launch follows its request, confirms, reason and refusal',{timeout:90000},async()=>{
const bus={messages:async()=>({messages:[],unread:0}),usage:async()=>{throw Error('no usage');},instances:async()=>view};
const web=await startServer({port:0,board:null,bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})}}),b=await browser();
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})}}),b=await browser();
try{
// POSTs answer from `replies`, request reads from `polls`; both record what was asked.
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`window.posts=[];window.replies=[];window.polls=[];window.reads=[];window.enabled=new URL(location.href).searchParams.has('send');const original=window.fetch;window.fetch=async(url,options={})=>{if(url==='/api/config')return new Response(JSON.stringify({send:window.enabled}));if(options.method==='POST'&&/^\\/api\\/(agent|veto|instances)/.test(url)){posts.push([url,JSON.parse(options.body)]);const [status,body]=replies.shift();return new Response(JSON.stringify(body),{status});}if(String(url).startsWith('/api/agent/request')){reads.push(url);const [status,body]=polls.shift()??[200,{request:'${ID}',action:'launch',instance:'coder',state:'open',done:null,cancelled:null}];return new Response(JSON.stringify(body),{status});}return original(url,options);};`});
@@ -131,7 +131,7 @@ test('row 194 T16 Settings Agents: unavailable read-only, launch follows its req
test('row 256 T7 T8 the human page offers Delete on a creative instance, the tester page never does',{timeout:90000},async()=>{
const human={...view,instances:{...view.instances,bean:{...view.instances.bean,endProtected:false}}};
const bus={messages:async()=>({messages:[],unread:0}),usage:async()=>{throw Error('no usage');},instances:async()=>human};
const web=await startServer({port:0,board:null,bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})}}),b=await browser();
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})}}),b=await browser();
try{
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`const q=new URL(location.href).searchParams;const original=window.fetch;window.fetch=async(url,options={})=>{if(url==='/api/config')return new Response(JSON.stringify(q.has('tester')?{tester:true}:{send:true}));return original(url,options);};`});
const port=web.address().port;
+2 -4
View File
@@ -22,7 +22,6 @@ import { views } from '../../bus/src/views.mjs';
import { startServer } from '../src/serve.mjs';
import { browser } from './browser.mjs';
import { fixture, close } from './fixture.mjs';
import { routeWrites } from './board-route.mjs';
const businesses = {
demo: {
@@ -73,8 +72,7 @@ async function setup(machine, fast = false) {
const reader = b0.bindReader({ business: 'demo' });
const bus = views({ call: async (verb, args = {}) => structuredClone(b0.request(reader, { verb, args })) });
const f = await fixture(), b = await browser({ machine });
const web = await startServer({ port: 0, board: f.board, bus }), base = `http://127.0.0.1:${web.address().port}/`;
const stopRoute = await routeWrites(b, base, f.board);
const web = await startServer({ port: 0, board: f.boardURL, bus }), base = `http://127.0.0.1:${web.address().port}/`;
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: stub(fast) });
await b.viewport(1280);
const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out: '+${JSON.stringify(expr)})})()`);
@@ -83,7 +81,7 @@ async function setup(machine, fast = false) {
const reqs = () => b.evaluate('window.__reqs');
const polls = () => b.evaluate('window.__polls');
const settle = () => new Promise(r => setTimeout(r, 600));
const done = async () => { await stopRoute(); await b.close(); await close(web); await f.close(); store.close(); rmSync(root, { recursive: true, force: true }); };
const done = async () => { await b.close(); await close(web); await f.close(); store.close(); rmSync(root, { recursive: true, force: true }); };
return { b, base, wait, text, shown, reqs, polls, settle, done };
}
@@ -1,144 +0,0 @@
// Row 198: the board runs in-process in the Console. These are the Console-side killers:
// M3 (no proxy fetch to a board URL), M5 (no seen write without --send), M6 and M7 (the
// preview gate runs before any board route), M10 (positive control: the write branches
// reach the board under --send) and M14 (a pending board read never blocks /healthz).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { request } from 'node:http';
import { startServer } from '../src/serve.mjs';
import { createBoard } from '../../control-board/src/board.mjs';
import { seenPath } from '../../control-board/src/scan.mjs';
import { close, fixture } from './fixture.mjs';
const NAME = 'mosaic-preview.example.com', HEADER = 'remote-user', ID = 'jason';
const preview = { host: NAME, header: HEADER, user: ID };
const ok = { [HEADER]: ID, origin: `https://${NAME}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' };
const without = name => Object.fromEntries(Object.entries(ok).filter(([k]) => k !== name));
const BODY = '{"agent":"proj/agent1","text":"hi"}';
const BOARD_ROUTES = [['GET', '/api/board'], ['GET', '/api/conversations'], ['POST', '/api/seen'], ['POST', '/api/reply']];
// node:http, not fetch: fetch can't set Host.
function call(port, { method = 'POST', path, headers = ok, host = NAME, body = BODY } = {}) {
return new Promise((resolve, reject) => {
const length = method === 'GET' ? {} : { 'content-length': Buffer.byteLength(body) };
const req = request({ host: '127.0.0.1', port, method, path, headers: { host, ...length, ...headers } }, res => {
let text = '';
res.on('data', c => (text += c));
res.on('end', () => resolve({ status: res.statusCode, text }));
});
req.on('error', reject);
req.end(method === 'GET' ? undefined : body);
});
}
// A board that counts every call that reaches it.
function countingBoard(hits, index = async () => ({ sessions: [], seen: [] })) {
return {
index: async () => { hits.push('index'); return index(); },
markSeen: async () => { hits.push('markSeen'); return { seen: [] }; },
reply: async () => { hits.push('reply'); return { status: 200, body: { delivered: true } }; },
conversation: async () => { hits.push('conversation'); return { status: 200, body: {} }; },
};
}
function stop(web) {
return new Promise(resolve => { web.closeAllConnections(); web.close(resolve); });
}
// M6, M7: under the preview, every board route refuses a request without the proxy header
// (401) and a request with a foreign Host (403), and the board never runs.
test('preview: every board route refuses without the proxy header (401) or with a foreign Host (403) before the board runs (M6, M7)', async t => {
const hits = [];
const web = await startServer({ port: 0, board: countingBoard(hits), preview });
t.after(() => stop(web));
const port = web.address().port;
for (const [method, path] of BOARD_ROUTES) {
const bare = await call(port, { method, path, headers: without(HEADER) });
assert.equal(bare.status, 401, `${method} ${path} without the header`);
const foreign = await call(port, { method, path, host: 'evil.example' });
assert.equal(foreign.status, 403, `${method} ${path} with a foreign Host`);
}
assert.deepEqual(hits, [], 'the board ran for a refused request');
});
// M5: without --send the seen write refuses and writes no mark file.
test('seen without --send refuses and writes no mark (M5)', async t => {
const f = await fixture();
t.after(() => f.close());
const web = await startServer({ port: 0, board: f.board, preview });
t.after(() => stop(web));
const res = await call(web.address().port, { path: '/api/seen', body: JSON.stringify({ project: 'proj', agent: 'agent1', lastActivity: 'x', seen: true }) });
assert.equal(res.status, 403);
assert.equal(JSON.parse(res.text).sent, false);
assert.equal(existsSync(seenPath(join(f.root, 'board'))), false, 'a mark was written without --send');
});
// M10: positive control. With --send on the public name, both writes reach the in-process
// board. A branch moved below the read-only refusal would answer 403 here and fail.
test('with --send on the public name, seen and reply reach the in-process board (M10)', async t => {
const f = await fixture();
t.after(() => f.close());
const web = await startServer({ port: 0, board: f.board, preview, send: async () => ({ id: 'msg-0001' }) });
t.after(() => stop(web));
const port = web.address().port;
const rec = (await f.board.index()).sessions.find(r => r.agent === 'agent1');
const seen = await call(port, { path: '/api/seen', body: JSON.stringify({ project: rec.project, agent: rec.agent, lastActivity: rec.lastActivity, seen: true }) });
assert.equal(seen.status, 200, seen.text);
assert.equal(existsSync(seenPath(join(f.root, 'board'))), true, 'the seen write did not reach the board');
const reply = await call(port, { path: '/api/reply' });
assert.equal(reply.status, 200, reply.text);
assert.equal(JSON.parse(reply.text).delivered, true);
assert.equal(f.captures.length, 1);
assert.match(f.captures[0].args.at(-1), /^hi\n/);
});
// M3: the Console reads its board in-process. No request leaves the Console for a board URL.
test('a board read makes no request to a board URL (M3)', async t => {
const hits = [];
const web = await startServer({ port: 0, board: countingBoard(hits) });
t.after(() => stop(web));
const base = `http://127.0.0.1:${web.address().port}`;
const urls = [];
const realFetch = globalThis.fetch;
globalThis.fetch = (url, opts) => { urls.push(String(url)); return realFetch(url, opts); };
t.after(() => { globalThis.fetch = realFetch; });
assert.equal((await globalThis.fetch(base + '/api/board')).status, 200);
assert.deepEqual(hits, ['index'], 'the board ran once, in-process');
assert.equal(urls.length, 1, 'the spy saw the test request');
assert.ok(urls.every(u => u.startsWith(base)), `a request left the Console: ${urls.join(', ')}`);
});
// M14: a board read never blocks the Console's loop. The board is a real createBoard, and its tmux
// check runs through a shim on PATH that sleeps 1 s. A 10 ms timer records the longest stall of the
// loop while /api/board is pending; a spawnSync in the read path stalls the loop for the shim's full
// second. /healthz is timed by elapsed time from the same moment.
test('a board read never blocks the loop: the timer gap and /healthz stay short while /api/board is pending (M14)', async t => {
const root = mkdtempSync(join(tmpdir(), 'webui-m14-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const shim = join(root, 'shim'), sessionsDir = join(root, 'sessions'), seatsDir = join(root, 'seats');
for (const d of [shim, sessionsDir, seatsDir]) mkdirSync(d);
writeFileSync(join(shim, 'tmux'), '#!/bin/sh\nsleep 1\nexit 1\n');
chmodSync(join(shim, 'tmux'), 0o755);
const PATH = process.env.PATH;
process.env.PATH = `${shim}:${PATH}`;
t.after(() => { process.env.PATH = PATH; });
const board = createBoard({ specs: [{ agent: 'agent1', project: 'proj', sessionsDir, tmux: { socket: null, session: 'agent1' } }], boardDir: join(root, 'board'), seatsDir, discordDataRoot: root, host: 'm14' });
const web = await startServer({ port: 0, board });
t.after(() => stop(web));
const base = `http://127.0.0.1:${web.address().port}`;
let last = Date.now(), worst = 0;
const tick = setInterval(() => { const now = Date.now(); worst = Math.max(worst, now - last); last = now; }, 10);
t.after(() => clearInterval(tick));
const pending = fetch(base + '/api/board');
const t0 = Date.now();
const health = await fetch(base + '/healthz');
const healthMs = Date.now() - t0;
assert.equal(health.status, 200);
assert.ok(healthMs < 500, `/healthz took ${healthMs} ms while the board read was pending`);
assert.equal((await pending).status, 200);
clearInterval(tick);
assert.ok(worst < 500, `the loop stalled for ${worst} ms during the board read`);
});
-148
View File
@@ -1,148 +0,0 @@
// Row 198 r4 (sage-3618, mos-1506): the board refusal. The in-process board never answers 403 (consoleRead
// has no 403 code), so the browser stubs the answer: while the board is refused, Fetch fulfils /api/board
// and /api/conversation(s) with 403 board-refused, as the old fake board server did. The assertions are the
// refusal block that 4d8123b0a deleted from shell.test.mjs, ported one for one (BUILD.md, r4 mapping).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { browser } from './browser.mjs';
import { close } from './fixture.mjs';
import { startServer } from '../src/serve.mjs';
import { ReadError } from '../src/reads.mjs';
import { sendConfig } from './board-route.mjs';
// The old fake board's data: agent1 waiting on you in proj, plus agent2 and agent3, one conversation.
function board() {
const state = { refused: false, down: false, mode: 'ok' };
let index = { sessions: [], counts: {}, generatedAt: new Date().toISOString() };
const one = { agent: 'agent1', project: 'proj', state: 'waiting', waitingOnYou: true, task: 'Fixture task', lastActivity: '2026-10-10T00:00:00Z', lastAssistantText: 'Input needed: fixture', registered: null };
const catalogue = { conversations: [{ project: 'proj', seat: 'agent1', conversation: 'c1', availability: 'available', title: 'Fixture', conversationCreatedAt: '2026-10-10T00:00:00Z', lastActivityAt: '2026-10-10T00:00:00Z' }] };
const page = { page: { branch: 'b1', entries: [{ message: 'm1', role: 'assistant', createdAt: '2026-10-10T00:00:00Z', content: [{ block: 0, type: 'text', text: 'Fixture history line' }] }] }, view: { incomplete: false, defaultBranch: 'b1', branch: 'b1' }, cursor: null, follow: null };
const set = (sessions, counts) => { index = { sessions, counts, generatedAt: new Date().toISOString() }; state.mode = 'ok'; };
return {
state,
board: {
index: () => {
if (state.down) throw new ReadError('read-failed', 'board unavailable');
if (state.mode === 'fail') throw new Error('board scan failed');
return index;
},
conversation: url => {
if (state.mode === 'fail') throw new Error('catalogue read failed');
return { status: 200, body: url.pathname === '/api/conversations' ? catalogue : page };
},
markSeen: () => ({ status: 200, body: { ok: true } }),
reply: () => ({ status: 200, body: { delivered: true } }),
},
one: () => { state.refused = false; state.down = false; set([one, { ...one, agent: 'agent2', state: 'working', waitingOnYou: false }, { ...one, agent: 'agent3', state: 'idle', waitingOnYou: false, seen: true }], { waiting: 1, working: 1 }); },
registered: () => { state.refused = false; state.down = false; set([{ ...one, registered: { alive: true, tmux: { session: 'fixture' } } }], { waiting: 1 }); },
fail: () => { state.refused = false; state.mode = 'fail'; },
unavailable: () => { state.refused = false; state.down = true; },
refuse: () => { state.refused = true; },
};
}
test('a board refusal fails closed: the page hides, the banner says refused, the conversation closes and clears', { timeout: 180000 }, async () => {
const brd = board(); brd.one();
const web = await startServer({ port: 0, board: brd.board });
const base = `http://127.0.0.1:${web.address().port}/`, b = await browser();
const out = process.env.WEBUI_EVIDENCE;
if (out) mkdirSync(out, { recursive: true });
const shot = async name => { if (out) await b.screenshot(join(out, `shell-${name}.png`)); };
const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out: '+${JSON.stringify(expr)})})()`);
const text = sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.textContent ?? null`);
const count = sel => b.evaluate(`document.querySelectorAll(${JSON.stringify(sel)}).length`);
const flat = async () => assert.equal(await b.evaluate('document.documentElement.scrollWidth<=document.documentElement.clientWidth'), true, 'sideways scroll at 400px');
const refresh = async () => { await b.evaluate('document.querySelector("#refresh").click()'); await wait('!document.querySelector("#refresh").disabled'); };
// The stub: while refused, the board's reads get the 403 the old fake server gave; otherwise they go on to the Console.
// /api/config answers with send on (mos-1524: config only), so the conversation's reply form is drawn.
await b.call('Fetch.enable', { patterns: [{ urlPattern: '*/api/board*', requestStage: 'Request' }, { urlPattern: '*/api/conversation*', requestStage: 'Request' }, { urlPattern: '*/api/config', requestStage: 'Request' }] });
b.on('Fetch.requestPaused', async ({ requestId, request: req }) => {
if (new URL(req.url).pathname === '/api/config') {
await b.call('Fetch.fulfillRequest', { requestId, responseCode: 200, responseHeaders: [{ name: 'content-type', value: 'application/json' }], body: Buffer.from(await sendConfig(base)).toString('base64') }).catch(() => {});
return;
}
const answer = brd.state.refused
? b.call('Fetch.fulfillRequest', { requestId, responseCode: 403, responseHeaders: [{ name: 'content-type', value: 'application/json' }], body: Buffer.from(JSON.stringify({ error: 'board-refused' })).toString('base64') })
: b.call('Fetch.continueRequest', { requestId });
answer.catch(() => {});
});
try {
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: 'window.errors=[];addEventListener("error",e=>errors.push(e.message));addEventListener("unhandledrejection",e=>errors.push(String(e.reason)));' });
await b.viewport(400, 900);
await b.navigate(base); await wait('!document.querySelector("#sessions [aria-busy]")');
await refresh(); await wait('document.querySelectorAll("table.sessions tbody tr").length===2');
// Board, a refusal: fails closed. The kept rows are hidden, and so is everything drawn from
// them: the open inspector, the project filter, the project tree's counts and the footer.
await b.evaluate('document.querySelector("#projects [data-project=proj]").click()');
assert.equal(await b.evaluate('document.querySelector("#projects [data-project=proj]").getAttribute("aria-pressed")'), 'true');
await b.evaluate('document.querySelector("table.sessions [data-open]").click()'); await wait('!document.querySelector("#inspector").hidden');
assert.equal(await text('#footer'), 'waiting 1 · working 1');
assert.equal(await b.evaluate('document.querySelectorAll("#projects .count").length'), 2);
assert.equal(await count('#seen .wait-item'), 1);
brd.refuse(); await refresh();
assert.equal(await b.evaluate('document.querySelector("#error").className'), 'banner ref');
assert.match(await text('#error'), /The board refused the read\. refused: board-refused/);
assert.equal(await b.evaluate('getComputedStyle(document.querySelector("#board-view")).display'), 'none');
assert.equal(await b.evaluate('document.querySelector("#board-refused").hidden'), false);
assert.equal(await b.evaluate('document.querySelector("#inspector").hidden'), true);
assert.equal(await b.evaluate('document.body.classList.contains("has-inspector")'), false);
assert.equal(await b.evaluate('document.querySelectorAll("#projects .count, #projects [data-project]").length'), 0);
assert.equal(await text('#footer'), 'Board not read: refused');
assert.equal(await text('#fresh-board'), 'Board not read: refused');
assert.doesNotMatch(await text('#status'), /Scanned/);
for (const id of ['#waiting-count', '#seen-count', '#session-count']) assert.equal(await text(id), '–', id);
await flat(); await shot('board-refused-400');
// A failed read after the refusal shows the board again, empty: no row, card or count from
// the refused scan, and the footer says what the banner says. With nothing focused, focus stays put.
await b.evaluate('document.activeElement.blur()');
brd.unavailable(); await refresh();
assert.equal(await b.evaluate('document.activeElement === document.body'), true, 'a failure with nothing focused leaves focus alone');
assert.equal(await b.evaluate('document.querySelector("#error").className'), 'banner err');
assert.match(await text('#error'), /board unavailable.*No board data loaded\./);
assert.notEqual(await b.evaluate('getComputedStyle(document.querySelector("#board-view")).display'), 'none');
assert.equal(await count('#sessions tr, #sessions [data-open], #waiting .wait-item, #seen .wait-item, #board-view [data-history]'), 0);
for (const id of ['#waiting-count', '#seen-count', '#session-count']) assert.equal(await text(id), '–', id);
assert.equal(await text('#footer'), 'Board not read: the read failed');
assert.equal(await text('#fresh-board'), 'Board not read: the read failed');
assert.equal(await text('#status'), 'Not read: the read failed');
assert.equal(await text('#projects'), 'Not read: the read failed.');
await flat(); await shot('board-refused-then-failed-400');
brd.one(); await refresh();
assert.equal(await b.evaluate('document.querySelector("#error").hidden'), true);
assert.equal(await b.evaluate('document.querySelector("#projects [data-project=\'\']").getAttribute("aria-pressed")'), 'true');
assert.notEqual(await b.evaluate('getComputedStyle(document.querySelector("#board-view")).display'), 'none');
assert.equal(await text('#footer'), 'waiting 1 · working 1');
// A good read after a failed one drops the stale words.
brd.fail(); await refresh(); assert.match(await text('#fresh-board'), /stale/);
brd.one(); await refresh(); assert.match(await text('#fresh-board'), /^Board scanned \d\d:\d\d:\d\d UTC/); assert.doesNotMatch(await text('#fresh-board'), /stale/);
// A refusal with a conversation open closes it and clears its history and reply form: the
// session is in no scan the page holds.
brd.registered(); await refresh(); await wait('document.querySelectorAll("table.sessions tbody tr").length===1');
await b.evaluate('document.querySelector("table.sessions [data-history]").click()');
await wait('document.querySelector("#conv-log").textContent.includes("Fixture history line")');
assert.equal(await b.evaluate('document.querySelector("#conv-form").hidden'), false);
// Fix to HEAD behaviour: a failed catalogue read leaves no session from the last one in the picker.
assert.equal(await count('#conv-pick option'), 1);
brd.fail(); await b.evaluate('document.querySelector("table.sessions [data-history]").click()');
await wait('document.querySelector("#conv-status").textContent.startsWith("History unavailable")');
assert.equal(await count('#conv-pick option'), 0, 'no stale session in the picker');
brd.registered(); await b.evaluate('document.querySelector("table.sessions [data-history]").click()');
await wait('document.querySelector("#conv-log").textContent.includes("Fixture history line")');
brd.refuse(); await refresh();
assert.equal(await b.evaluate('document.querySelector("#conversation").hidden'), true);
assert.equal(await b.evaluate('document.body.classList.contains("has-conversation")'), false);
assert.equal(await count('#conv-log > *'), 0);
assert.equal(await b.evaluate('document.querySelector("#conv-form").checkVisibility()'), false);
assert.equal(await b.evaluate('getComputedStyle(document.querySelector("#board-view")).display'), 'none');
assert.equal(await b.evaluate('document.querySelector("#board-refused").hidden'), false);
// Fix to HEAD behaviour: focus went back to History, which the refusal removed; it lands on the page, not <body>.
assert.equal(await b.evaluate('document.activeElement.id'), 'main', 'focus after a refusal closes the conversation');
brd.one(); await refresh(); await wait('document.querySelectorAll("table.sessions tbody tr").length===2');
assert.deepEqual(await b.evaluate('window.errors'), []);
} finally { await b.close(); await close(web); }
});
@@ -1,32 +0,0 @@
// Row 198 (mos-1513): the write mapping the browser tests reach through board-route.mjs, pinned in node
// against the named send listener itself. The Console's own gate, parse and status mapping answer here;
// the browser tests only get back what this listener returns.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { close, fixture } from './fixture.mjs';
import { forward, namedListener } from './board-route.mjs';
test('named send listener maps the board writes: seen is 200, a delivered reply is 200 delivered, an exit-4 reply is 200 not delivered', { timeout: 60000 }, async t => {
const f = await fixture();
const named = await namedListener(f.board);
t.after(async () => { await close(named); await f.close(); });
const port = named.address().port;
const rec = (await f.board.index()).sessions.find(r => r.agent === 'agent1');
const row = `${rec.project}/${rec.agent}`;
const seen = await forward(port, '/api/seen', JSON.stringify({ project: rec.project, agent: rec.agent, lastActivity: rec.lastActivity, seen: true }));
assert.equal(seen.status, 200, seen.text);
const delivered = await forward(port, '/api/reply', JSON.stringify({ agent: row, text: 'hi' }));
assert.equal(delivered.status, 200, delivered.text);
assert.equal(JSON.parse(delivered.text).delivered, true);
assert.equal(f.captures.length, 1);
f.failReply();
const refused = await forward(port, '/api/reply', JSON.stringify({ agent: row, text: 'hi' }));
assert.equal(refused.status, 200, refused.text);
const body = JSON.parse(refused.text);
assert.equal(body.delivered, false);
assert.equal(body.exitCode, 4);
assert.equal(f.captures.length, 2);
});
-75
View File
@@ -1,75 +0,0 @@
// Row 198 (mos-1513): the browser tests reach the real board routes. Fetch intercepts the page's
// /api/config (send on) and its /api/reply and /api/seen writes. Node forwards each write to a named
// send listener on the same board, with the preview Host, the https Origin and the signed user header,
// so the server's own gate, parse and status mapping answer and the page gets that answer back.
// Nothing here re-implements boardSend; the node route tests pin what the listener returns.
import { request } from 'node:http';
import { startServer } from '../src/serve.mjs';
import { close } from './fixture.mjs';
export const ROUTE = { host: 'board-route.example', header: 'X-authentik-uid', user: 'board-route-user' };
const JSON_HEADER = [{ name: 'content-type', value: 'application/json' }];
// The named send listener on the board: the preview Host, so the server's own gate and status mapping answer.
export function namedListener(board) {
return startServer({ port: 0, board, preview: ROUTE, send: async () => ({ id: 'board-route' }) });
}
// One POST to the named listener, as the browser's forwarded write would arrive. Resolves { status, text }.
export function forward(port, path, body) {
return new Promise((resolve, reject) => {
const headers = { host: ROUTE.host, origin: `https://${ROUTE.host}`, [ROUTE.header]: ROUTE.user, 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) };
const req = request({ host: '127.0.0.1', port, method: 'POST', path, headers }, res => {
let text = '';
res.setEncoding('utf8');
res.on('data', chunk => { text += chunk; });
res.on('end', () => resolve({ status: res.statusCode, text }));
});
req.on('error', reject);
req.end(body);
});
}
// The page's /api/config body with send on and everything else as the Console answers it. The base URL may end in a slash.
export async function sendConfig(base) {
return JSON.stringify({ ...(await (await fetch(new URL('/api/config', base))).json()), send: true });
}
// Config only (mos-1524): Fetch answers the page's /api/config with send on and leaves every write alone, so the
// writes go to the page's own listener on its existing path. The Fetch domain stays on until the browser closes.
export async function sendOnConfig(b, base) {
await b.call('Fetch.enable', { patterns: [{ urlPattern: '*/api/config', requestStage: 'Request' }] });
b.on('Fetch.requestPaused', async ({ requestId }) => {
try {
await b.call('Fetch.fulfillRequest', { requestId, responseCode: 200, responseHeaders: JSON_HEADER, body: Buffer.from(await sendConfig(base)).toString('base64') });
} catch {
await b.call('Fetch.failRequest', { requestId, errorReason: 'Failed' }).catch(() => {});
}
});
}
// Returns a function that closes the named listener. Call it in the test's finally, before the browser closes.
export async function routeWrites(b, base, board) {
const listener = await namedListener(board);
const port = listener.address().port;
await b.call('Fetch.enable', { patterns: [
{ urlPattern: '*/api/config', requestStage: 'Request' },
{ urlPattern: '*/api/reply', requestStage: 'Request' },
{ urlPattern: '*/api/seen', requestStage: 'Request' },
] });
b.on('Fetch.requestPaused', async ({ requestId, request: req }) => {
try {
const path = new URL(req.url).pathname;
let answer;
if (path === '/api/config') {
answer = { status: 200, text: await sendConfig(base) };
} else {
answer = await forward(port, path, req.postData ?? '');
}
await b.call('Fetch.fulfillRequest', { requestId, responseCode: answer.status, responseHeaders: JSON_HEADER, body: Buffer.from(answer.text).toString('base64') });
} catch {
await b.call('Fetch.failRequest', { requestId, errorReason: 'Failed' }).catch(() => {});
}
});
return () => close(listener);
}
+13 -18
View File
@@ -1,31 +1,26 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { browser } from './browser.mjs';
import { close } from './fixture.mjs';
import { routeWrites } from './board-route.mjs';
import { startServer } from '../src/serve.mjs';
test('browser edge states: loading, empty, malformed, stale, hostile/long values, in-flight reply and appearance fallback', { timeout: 120000 }, async () => {
let index = { sessions: [], counts: {}, generatedAt: 'fixture time' }, held = null, sends = 0, holdBoard = true, releaseBoard;
let index = { sessions: [], counts: {}, generatedAt: 'fixture time' }, held = null, sends = 0, holdBoard = true, boardResponse;
let resolveBoardRequest;
const boardRequested = new Promise(resolve => { resolveBoardRequest = resolve; });
// The board runs in-process (row 198): index() is the page's /api/board read, reply() its reply.
const board = {
index: () => {
if (!holdBoard) return index;
resolveBoardRequest();
return new Promise(resolve => { releaseBoard = resolve; });
},
reply: () => { sends++; return new Promise(resolve => { held = resolve; }); },
};
const web = await startServer({ port: 0, board });
const server = createServer(async (req, res) => {
for await (const chunk of req) { void chunk; }
res.setHeader('content-type', 'application/json');
if (req.url === '/api/board') { if (holdBoard) { boardResponse = res; resolveBoardRequest(); return; } return res.end(JSON.stringify(index)); }
sends++; held = res;
});
await new Promise(r => server.listen(0, '127.0.0.1', r));
const web = await startServer({ port: 0, board: `http://127.0.0.1:${server.address().port}` });
const b = await browser();
const base = `http://127.0.0.1:${web.address().port}`;
let stopRoute;
const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out')})()`);
try {
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: 'Object.defineProperty(window,"localStorage",{get(){throw new Error("storage denied")}});window.errors=[];addEventListener("error",e=>errors.push(e.message));' });
stopRoute = await routeWrites(b, base, board);
await b.viewport(320); await b.navigate(`http://127.0.0.1:${web.address().port}`);
// Row 95: with storage denied the toggle starts off and still turns on; the page throws nothing.
assert.match(await b.evaluate('document.querySelector("#status").textContent'), /Machine sessions hidden/);
@@ -35,7 +30,7 @@ test('browser edge states: loading, empty, malformed, stale, hostile/long values
let timer;
try { await Promise.race([boardRequested, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('Show machine sessions made no /api/board request in 5 s')), 5000); })]); }
finally { clearTimeout(timer); }
holdBoard = false; releaseBoard(index);
holdBoard = false; boardResponse.end(JSON.stringify(index));
await wait('document.querySelector("#sessions").textContent.includes("No sessions")');
assert.equal(await b.evaluate('document.querySelector("#waiting-count").textContent'), '0');
assert.equal(await b.evaluate('document.documentElement.dataset.palette'), 'harbor');
@@ -57,7 +52,7 @@ test('browser edge states: loading, empty, malformed, stale, hostile/long values
await b.evaluate('document.querySelector("#refresh").click()'); await wait('!document.querySelector("#refresh").disabled');
assert.equal(await b.evaluate('document.querySelector(".reply-form button").disabled'), true);
await b.evaluate('document.querySelector("#reply").focus()'); await b.call('Input.insertText', { text: 'newer ' });
held({ status: 200, body: { delivered: true, sentAt: 'fixture', session: agent } });
held.end(JSON.stringify({ delivered: true, sentAt: 'fixture', session: agent }));
await wait('document.querySelector(".receipt")?.textContent.startsWith("delivered")');
assert.equal(sends, 1); assert.match(await b.evaluate('document.querySelector("#reply").value'), /newer/);
// A stale registration removes the form, but does not destroy its draft.
@@ -97,5 +92,5 @@ test('browser edge states: loading, empty, malformed, stale, hostile/long values
assert.match(await b.evaluate('document.querySelector("#error").textContent'), /Invalid board session data/);
assert.equal(await b.evaluate('document.querySelectorAll("table.sessions [data-open]").length'), 1);
assert.deepEqual(await b.evaluate('window.errors'), []);
} finally { releaseBoard?.(index); held?.({ status: 200, body: {} }); await stopRoute?.(); await b.close(); await close(web); }
} finally { boardResponse?.end(); held?.end(); await b.close(); await close(web); await close(server); }
});
Loaded 100 of 150 files, more files were not shown because too many files have changed in this diff. Show more