Compare commits

...
24 Commits
Author SHA1 Message Date
jason.woltje 9fc0ab301e Untested: pin row297 reviewed blobs for gate verification 2026-10-23 14:52:46 -05:00
jason.woltje 734f4d2c5b Untested: row297 review hardening and complete delete coverage 2026-10-23 14:52:32 -05:00
jason.woltje 5ca83173e9 Untested: record row198 fix merge and row297 test window 2026-10-23 14:48:28 -05:00
jason.woltje 29d233793e Untested: merge row198 fix head 397ef58be into row297 2026-10-23 14:48:14 -05:00
jason.woltje 397ef58beb row 198 r4 round 2: config-only send stub for relaunch, shell, sso-browser (mos-1524, untested) 2026-10-23 14:46:47 -05:00
jason.woltje 460355ccc8 row 198 r4 round 2: N1 config URL, sendConfig helper, board-refused config send (untested) 2026-10-23 14:46:41 -05:00
jason.woltje 906267c750 Untested: merge row198 moving head ab6417475 into row297 2026-10-23 14:43:38 -05:00
jason.woltje 53a4541859 Untested: protected GitHub App grant and renewal candidate (#1803) 2026-10-23 14:43:32 -05:00
jason.woltje b0c79751cf row 198 r4 round 2 fixes: connector before send-off in refusal text (mos-1522)
UNTESTED: no node or browser run. Held locally; not pushed.

- app.js: reply refusal text checks r.connector first, then !canSend, in the inspector and conv-no-reply
- send-off-browser.test.mjs: asserts the conversation view's send-off line
- BUILD.md: mos-1522 notes and the two mutant targets
2026-10-23 14:40:35 -05:00
jason.woltje ab64174753 row 198 r4 round 2: UNTESTED, mos-1513 B1 via named listener, N1 refusal stub, canSend gate
Untested: no node or browser run. Pushed early at Sage's and Webby's request for a fixed sha.

- six old browser tests forward writes through tests/board-route.mjs (routeWrites), with /api/config answered send:true
- discord 409 reached through the named send listener
- board-refused.test.mjs (N1 stub), board-route-map.test.mjs (node mapping), send-off-browser.test.mjs (canSend gate, send off)
- app.js: reply form and Seen button gated on /api/config send
- BUILD.md round 2 section: numstat, line maps, N1 mapping, open items
2026-10-23 14:39:17 -05:00
jason.woltje 4818b7ba47 Untested: record row297 packet push and integration boundaries 2026-10-23 14:14:47 -05:00
jason.woltje 6af63e1d34 Untested: row297 Q-B3 GitHub grant contract proposal (#1803) 2026-10-23 14:13:36 -05:00
jason.woltje 1f7371b7c5 Untested: merge row198 follow-up into row297 chain 2026-10-23 14:10:31 -05:00
jason.woltje ad5b8a7fa2 Untested: merge row198 base for row297 GitHub grant method 2026-10-23 14:10:13 -05:00
jason.woltje 4d8123b0a5 row 198 r4: port the four fake-board tests to the in-process board (sage-3597, sage-3603)
browser-edge, preview, tester-listener and shell read an in-process board now, not a
fake HTTP server. Changed expectations and deleted refusal-only assertions are listed in
BUILD.md for Mos (mos-1272). Adds granted-user R6 G4b: a granted session gets 403
owner-only on all five board paths before any read, and the owner reaches them.

UNVERIFIED: these tests were not run (slot rule). Gate still waits for CLEAR.
2026-10-23 14:07:48 -05:00
jason.woltje 14fe7513ce row 198 r4: BUILD.md records the rebase onto 45dca69f6 (sage-3586)
Conflict hunks, the range-diff against r3-253, the auto-merged files to rerun, and the four fake-board tests not yet ported. Status UNVERIFIED; no test has run.
2026-10-23 14:00:07 -05:00
jason.woltje 3c57ebb220 row 198 r3: BUILD.md records N2 as a named follow-up (sage-2868)
boardSend's /api/seen catch returns 400 for a scan failure after writeMark.
Ruled left as built; it goes to Sage's post-253 DEFERRED sweep. Doc-only.
2026-10-23 13:58:36 -05:00
jason.woltje 41b5aae3cc row 198 r3: BUILD.md places the reply join killer (B3, M16)
The M16 killer is in serve.test.mjs, not board.test.mjs, because reply() refuses
an unregistered row before any send. Doc-only; no code or test change.
2026-10-23 13:58:36 -05:00
jason.woltje f913d0e76a row 198 r3: index() clears a settled scan; B4 tests and mutant M17 (webby-0157)
index() kept the last scan in inflight and never cleared it, so after the first
scan settled every index() returned that promise until a write bumped the
counter. The Console's /api/board served the first view it built. A rejected
scan stuck the same way. The clear now runs on both branches of the settled
promise (then(clear, clear)), so a rejection is not left unhandled.

board.test.mjs B4: two index() calls with no write log ["scan","scan"], and a
session line appended between them shows in the second answer. A second case
rejects the first scan and checks that the next index() runs fresh.

BUILD.md: mutant M17 (drop the clear). M15 and M16 event pins were re-read
against the change and still hold.

UNVERIFIED: no node or tests run (Sage's gate; test runs wait for CLEAR).
2026-10-23 13:58:36 -05:00
jason.woltje f2fd6145ad row 198 r3: real-run M13 pairs, reply join killer M16, real-board M14 (sage-2839)
M13 (stack-release.test.mjs) now runs for real, not just --plan, for an
activate to a pre-198 copy and for a rollback. Each checks the plan drop line,
then the run: exit 0, the drop line in the output (rollback: steps.log), and
no --board in the unit or the pointer.

M16 (serve.test.mjs) pins reply()'s event order ["scan","scan","send","bump",
"scan"] and requires the next index() to start a fresh scan. Mutant: the
counter bump at board.mjs:61.

M14 (board-inprocess.test.mjs) is a real createBoard over a tmux shim that
sleeps 1 s. The test measures the timer gap and /healthz elapsed time while
/api/board is pending. The spawnSync mutant in scan.mjs stalls the loop.

BUILD.md: M13 and M14 rows updated, M16 added, the M14 gap closed, N2 left open
with the reason.

UNVERIFIED: no node or tests run (Sage's gate; the test runs wait for CLEAR).
2026-10-23 13:58:36 -05:00
jason.woltje c9c8badc3e row 198 r3: drop --board for every op, reason by target (sage-2837)
install_stack runs webui-service.sh install on activate as well as rollback,
and that install refuses --board. A re-pin to a pre-198 copy would therefore
fail the same way a rollback did. console_for_target now drops the pair for
every op. The reason follows the target's cli.mjs: a target that parses
--board is pre-198 and the 7331 board is retired; one that does not runs the
board in the Console.

M13 covers a kept --board for a parsing target on activate --plan and on
rollback --plan. The webui README rollback note covers any move to a pre-198
copy. BUILD.md's Ruled section is updated to match.

UNVERIFIED: bash -n only; no node or tests run (Sage's gate).
2026-10-23 13:58:36 -05:00
jason.woltje 77c12b5545 row 198 r3: rollback always drops --board (sage-2834 ruling a)
webui-service.sh install has no --board branch and refuses the flag, so a
rollback that kept a recorded --board failed at install-console. The rollback
now drops it unconditionally, with its own plan line naming the retired board.
An activate still keeps --board where the target parses it.

M13 now asserts the rollback drop line and that the Console install line
carries no --board. The webui README gets the pre-198 rollback note.
BUILD.md records the ruling and the M13 expectation.

UNVERIFIED: no node or tests run (Sage's gate).
2026-10-23 13:58:36 -05:00
jason.woltje aad3000fe2 row 198 r3: build design item 8; killers for M1-M15; B4 drops; docs; BUILD.md
Rebased onto aca16404c. Item 8 (scripts/stack-release.sh): a re-pin drops
--board for a target cli.mjs that does not parse it (parses_flag ignores
refusal lines); the plan names the drop. The rollback-install conflict
(webui-service.sh has no --board branch) is open for Sage's ruling and
recorded in BUILD.md.

Killers: retired.test.mjs (M1, M2, M9), board.test.mjs (M15),
board-inprocess.test.mjs (M3, M5, M6, M7, M10, M14), pinned-copy M11,
stack-release M12/M13. M14 is a known gap, recorded in BUILD.md.

B4: stale-pid case ported to createBoard via isPidAlive; bad-JSON moved to
send.test.mjs; page.html and tester board-proxy tests dropped (reasons in
BUILD.md). B5: non-numeric --port test removed.

Docs: webui and control-board READMEs drop the 7331 serve flow; CURRENT.md
dated line appended.

UNVERIFIED: no node or tests run (Sage's gate, sage-2821).
2026-10-23 13:58:36 -05:00
jason.woltje 4288b7b162 row 198: retire the board writer on 7331; the Console serves the board in-process
The Console builds the board context itself (control-board createBoard)
and answers the board routes in-process. The standalone control-board
HTTP writer and page.html are removed; control-board serve exits 2 as
retired, and webui --board is refused. /api/seen and /api/reply need
--send and a named human, else 403 read-only.

Tests are rewritten against the in-process board (inProcessBoard in the
webui fixture; createBoard in control-board). Proxy, unreachable-board
and HTTP-only tests are dropped; the dropped set is listed in BUILD.md.

UNVERIFIED: committed under Sage's slot hold (Gander run 5). No suite or
mutant has run on this commit; do not push before the gate is green.
2026-10-23 13:58:36 -05:00
98 changed files with 4075 additions and 2178 deletions

No files matched your search

+6
View File
@@ -5411,3 +5411,9 @@ Row 299 (573, #1806, Gander, Filbert): before, the P10 band test timed a fault r
2026-10-23T17:29:46Z | Gyro Vikunja571 sage-3542 Connections amendment | Branch gyro/editor-connections-design cut exactly from landed PRD1.17 30e6bb680; read REQ-LIB-5 in that PRD. Replaced stale J-b waits in parent and W4. Proposed W8 owns current authorized Markdown/DOCX links/backlinks list, labels, versioned refresh, leakage refusals and fixture tests. Build order under mos-1470/comment961 is W7 closed routing candidate, W8 landing, then W7 human first-real-import opening. No REQ-LIB-8 condition or acceptance change. Q234/mos-1363/S0 M1-M14 and post-10-27/MVP priority fixed. Static diff, twelve brief sections and append-only checks pass. No repo Node, tests, build, browser, queue mutation or live action. Fenton reviews after authorized docs branch push; Sage lands on approval.
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 | 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 | 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.
+509
View File
@@ -0,0 +1,509 @@
# 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.
+154
View File
@@ -0,0 +1,154 @@
# 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
@@ -0,0 +1,221 @@
# 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
@@ -0,0 +1,99 @@
# 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
@@ -0,0 +1,69 @@
# 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.
@@ -0,0 +1,24 @@
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
View File
@@ -1593,3 +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.
+1
View File
@@ -586,3 +586,4 @@ 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,3 +1071,59 @@ 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.
+110 -2
View File
@@ -415,6 +415,7 @@ 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
@@ -439,7 +440,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, 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, githubGrant = 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');
@@ -466,6 +467,16 @@ 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;
@@ -2023,6 +2034,16 @@ 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;
@@ -2202,6 +2223,8 @@ 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;
@@ -2220,9 +2243,10 @@ 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')) return false;
if (g?.agent !== agent || (g.method !== 'host' && g.method !== 'gitea' && g.method !== 'github')) 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`,
@@ -2311,6 +2335,12 @@ 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);
@@ -2560,6 +2590,15 @@ 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');
@@ -2822,6 +2861,7 @@ 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.
@@ -3549,10 +3589,75 @@ 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']);
@@ -4793,6 +4898,8 @@ 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.
@@ -5657,6 +5764,7 @@ 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
@@ -0,0 +1,143 @@
// 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
@@ -0,0 +1,64 @@
// 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
@@ -0,0 +1,298 @@
// 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;
}
+8 -1
View File
@@ -25,6 +25,8 @@ 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';
@@ -122,7 +124,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, syncTimer = null, syncPass = null, accessTimer = null;
let store, credentials, server, adapter, processing, github = null, syncTimer = null, syncPass = null, accessTimer = null;
try {
const references = {};
for (const [business, b] of Object.entries(businesses)) {
@@ -271,6 +273,7 @@ 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,
@@ -296,6 +299,7 @@ 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,
@@ -459,6 +463,7 @@ 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.
@@ -649,6 +654,7 @@ export async function startBroker({
await stopSync();
await server.close();
await adapter?.close?.();
await github?.close();
await processing?.close();
store.close();
credentials.close();
@@ -658,6 +664,7 @@ export async function startBroker({
clearInterval(accessTimer);
await server?.close();
await adapter?.close?.();
await github?.close();
await processing?.close();
store?.close();
credentials?.close();
+70
View File
@@ -0,0 +1,70 @@
// 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
@@ -0,0 +1,113 @@
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
@@ -0,0 +1,158 @@
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
@@ -0,0 +1,412 @@
// 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);
});
+18 -4
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"], at);
rejectUnknownKeys(entry, ["definition", "holder", "vars", "tracker", "credentials", "identity", "publish", "hosts", "github"], 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);
const kinds = services.filter((s) => s !== HOST_SERVICE && s !== "github");
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,6 +166,14 @@ 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 };
@@ -267,7 +275,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"], `${file} projects.${id}`);
rejectUnknownKeys(entry, ["root", "repo", "remote", "githubId"], `${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`);
}
@@ -277,10 +285,16 @@ 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 });
projects[id] = { root: entry.root, ...(Object.hasOwn(entry, "repo") ? { repo: entry.repo } : {}),
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 } : {}),
...(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);
const services = definition.credentials.map(c => c.service).filter(service => service !== HOST_SERVICE && service !== "github");
const out = {};
if (services.includes("vikunja")) {
if (!Object.hasOwn(args, "tracker")) {
+5 -3
View File
@@ -131,13 +131,15 @@ 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;
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(", ")}`);
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(", ")}`);
}
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
@@ -0,0 +1,73 @@
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: [] }] }), /service must be one of/);
refuses(() => check({ credentials: [{ service: "github", scopes: [] }] }), /unsupported.*scopes/);
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
@@ -0,0 +1,43 @@
// 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); }
}
+5 -2
View File
@@ -4,14 +4,16 @@
// 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";
"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";
const MAX_TOKEN = 16384;
const VALUES = ["--business", "--reason", "--url", "--account", "--rotate-by"];
const VALUES = ["--business", "--reason", "--url", "--account", "--rotate-by", "--app-id", "--installation-id", "--account-id"];
function parse(args) {
const out = { positional: [], flags: new Set(), values: {} };
@@ -72,6 +74,7 @@ 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
@@ -0,0 +1,42 @@
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','--board','http://127.0.0.1:9'],{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'],{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,6 +851,64 @@ 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');
+17 -12
View File
@@ -100,14 +100,22 @@ 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` 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.
`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.
- `--config PATH` — path to the system config file. Defaults to
`~/.config/mosaic-dev/config.json`. This file must exist and name an
@@ -124,11 +132,8 @@ bind to anything but a loopback address.
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)`.
- `--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:
Routes (row 198: served by the Console, `packages/webui`, not by a board port;
the bodies and refusals below are unchanged):
- `GET /` — the page.
- `GET /api/board` — rescan, returns `index.json`.
@@ -170,7 +175,7 @@ so the seat's reaction shows without waiting for the next tick.
## Exit codes
- `0` — scan completed and status files were written, or the server stopped cleanly.
- `0` — scan completed and status files were written.
- `2` — refused: bad config, missing/invalid `dataRoot`, or bad arguments.
The message on stderr says why.
+78
View File
@@ -0,0 +1,78 @@
// 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 };
}
+6 -18
View File
@@ -1,17 +1,18 @@
#!/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]
// node packages/control-board/src/cli.mjs serve [same flags] [--port N] [--host 127.0.0.1]
// `serve` is retired (row 198): the board runs in-process inside the Console.
// 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|serve [--config PATH] [--repo PATH] [--fleet PATH|none] [--liveness tmux|assume-alive] [--print] [--port N] [--host 127.0.0.1]";
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.";
function parseArgs(argv) {
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" };
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 };
for (let i = 1; i < argv.length; i++) {
const a = argv[i];
const next = () => {
@@ -23,13 +24,9 @@ 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" && opts.command !== "serve") throw new ConfigError(USAGE);
if (opts.command !== "scan") throw new ConfigError(USAGE);
if (!["tmux", "assume-alive"].includes(opts.liveness)) throw new ConfigError(`unknown liveness mode: ${opts.liveness}`);
return opts;
}
@@ -41,15 +38,6 @@ 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
@@ -1,605 +0,0 @@
<!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>
+45 -3
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 } from "node:child_process";
import { spawnSync, execFile } from "node:child_process";
import { readRegistration, samePath, SeatError, LAYOUTS } from "../../seat/src/seat.mjs";
import { discoverDiscordAgents, inspectDiscord, newestDiscordSession } from './discord.mjs';
@@ -193,17 +193,46 @@ 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({ socket, session }, { exec = spawnSync } = {}) {
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 }) {
const args = [];
if (socket) args.push("-L", socket);
args.push("list-panes", "-s", "-t", `=${session}`, "-F", "#{pane_current_command}\t#{pane_current_path}");
const r = exec("tmux", args, { encoding: "utf8", timeout: 5000 });
return args;
}
// Shared by the sync and async paths, so they cannot drift.
function inspectFromResult(r) {
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;
@@ -462,3 +491,16 @@ 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 } });
}
+49 -191
View File
@@ -1,55 +1,30 @@
// Control board step 2: a tiny local web server. No dependencies, no auth.
// It only ever binds to a loopback address (fail closed otherwise).
// 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.
//
// 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.
// 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.
//
// 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
// 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.
// 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.
// 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. Cursors live
// in this server's memory, bound to the one actor this unauthenticated
// loopback route has, local-operator.
// conversation id is opaque and no path comes from the request.
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 { scan, markSeen, seenKey, ConfigError, loadRegistrations } from "./scan.mjs";
import { createReader, rootsFromSpecs } from "../../conversation/src/reader.mjs";
const MAX_BODY = 4096;
import { resolve } from "node:path";
import { seenKey, runAsync } from "./scan.mjs";
// 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
@@ -71,27 +46,48 @@ 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({ 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` } };
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`);
const rec = index.sessions.find((r) => seenKey(r) === key);
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" } };
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");
const reg = rec.registered;
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" } };
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");
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}`);
// 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 });
return { args, session, socket };
}
function replyBody({ r, key, agentSend, session, socket, now }) {
if (r.error) return { status: 500, body: { error: `could not run ${agentSend}: ${r.error.message}` } };
const exitCode = r.status;
return {
@@ -110,39 +106,6 @@ export function replyToRow({ index, key, text, agentSend = DEFAULT_AGENT_SEND, e
};
}
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) {
@@ -187,11 +150,6 @@ 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) {
@@ -220,103 +178,3 @@ 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
@@ -0,0 +1,129 @@
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,7 +6,8 @@ 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, startServer } from '../src/serve.mjs';
import { replyToRow } from '../src/serve.mjs';
import { createBoard } from '../src/board.mjs';
import { identityOf } from '../../discord/src/journal.mjs';
import { envelope } from '../../discord/src/context.mjs';
import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs';
@@ -115,21 +116,20 @@ 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('server rescans connector discovery and refuses HTTP reply without transport', async () => {
const f = fixture(); let server;
test('board rescans connector discovery and refuses reply without transport', async () => {
const f = fixture();
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 }));
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);
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);
rmSync(join(f.root, 'discord', 'demo.json'));
assert.equal((await (await fetch(base + '/api/board')).json()).sessions.length, 0);
} finally { if (server) await new Promise(r => server.close(r)); f.cleanup(); }
assert.equal((await board.index()).sessions.length, 0);
} finally { f.cleanup(); }
});
test('connector session links and linked directories are not read', () => {
@@ -0,0 +1,44 @@
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
+13 -9
View File
@@ -4,22 +4,22 @@ Piece 4, #1507. The control board is the first and only WebUI screen.
## Run
Start the existing board in one terminal, then the WebUI in another:
The board runs inside the Console (row 198), so there is one server to start:
```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` and
`--board http://127.0.0.1:7331` select another port or loopback board origin.
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).
`--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.
Only HTTP loopback board origins are accepted. The WebUI binds to IPv4 loopback
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,9 +60,11 @@ 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 one exception is POST
`/api/bus/message` when the Console was started with `--send` (next
section).
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`).
- 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.
@@ -951,7 +953,9 @@ 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 and using the unchanged board at 7331.
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.
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.
+20 -8
View File
@@ -1,12 +1,14 @@
#!/usr/bin/env node
// MOSAIC_TESTER_SOCKET_V1: private tester ingress, no tester TCP.
import { createChat } from './chat.mjs';
import { startServer, previewOptions, proofOptions, DEFAULT_BOARD } from './serve.mjs';
import { startServer, previewOptions, proofOptions } 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';
@@ -15,14 +17,15 @@ 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 { isAbsolute } from 'node:path';
import { homedir } from 'node:os';
import { isAbsolute, join } 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] [--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]]';
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]]';
try {
if (args.length === 1 && ['--help', '-h'].includes(args[0])) { console.log(usage); process.exit(0); }
if (args.shift() !== 'serve') throw new Error(usage);
const options = { board: DEFAULT_BOARD, port: 7330 }, preview = {}, proofFlags = {};
const options = { port: 7330 }, preview = {}, proofFlags = {};
let sendOn = false, ssoOn = false, testerSocket = false;
while (args.length) {
const flag = args.shift();
@@ -32,12 +35,14 @@ 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') {
@@ -87,6 +92,13 @@ 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.
@@ -107,14 +119,14 @@ try {
}
let server;
try {
server = await startServer({ board: options.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, 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 });
} 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: options.board,
testerServer = await startTesterSocket({ dataRoot: system.dataRoot, 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),
@@ -133,7 +145,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: ${options.board}\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: ${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.`);
for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => { sso?.close(); server.close(); testerServer?.close(); });
} catch (err) {
console.error(`refused: ${err.message}`);
+8 -5
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 configured board', palette = 'harbor', mode = 'system', failed = false;
let board = 'the Console 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,6 +20,8 @@
// `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' };
@@ -46,7 +48,7 @@
const s = Date.parse(sent), a = Date.parse(r.lastActivity);
return Number.isFinite(s) && Number.isFinite(a) && a < s;
}
const canReply = r => !r.connector && r.registered && r.registered.alive !== false && r.registered.tmux?.session;
const canReply = r => canSend && !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.
@@ -66,6 +68,7 @@
$('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>`;
@@ -94,7 +97,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' : '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' : !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>` : ''}`;
}
// 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
@@ -194,7 +197,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' : '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' : !canSend ? 'Replies are off: this Console is read-only.' : '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;
@@ -476,5 +479,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 => { 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);
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);
})();
+37 -40
View File
@@ -13,19 +13,9 @@ 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']],
@@ -191,6 +181,20 @@ 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;
@@ -601,7 +605,8 @@ async function projectFileRead(res, read, path, search) {
// `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.
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, 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 } = {}) {
// `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 } = {}) {
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');
@@ -615,11 +620,10 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
// 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, upstream, files };
const owner = { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, 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 }))) {
@@ -627,9 +631,10 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
}
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, upstream, files } = owner, granted = false;
let { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, 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
@@ -696,7 +701,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
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; upstream = null; files = USER_FILES; granted = true;
reads = null; files = USER_FILES; granted = true;
}
}
if (named && preview !== null) {
@@ -779,31 +784,15 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
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' });
@@ -823,7 +812,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
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: upstream, 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: boardLabel, 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());
@@ -833,10 +822,18 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
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: 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 === '/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 === '/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:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})}}),b=await browser();
const web=await startServer({port:0,board:null,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:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})}}),b=await browser();
const web=await startServer({port:0,board:null,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;
+4 -2
View File
@@ -22,6 +22,7 @@ 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: {
@@ -72,7 +73,8 @@ 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.boardURL, bus }), base = `http://127.0.0.1:${web.address().port}/`;
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);
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)})})()`);
@@ -81,7 +83,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 b.close(); await close(web); await f.close(); store.close(); rmSync(root, { recursive: true, force: true }); };
const done = async () => { await stopRoute(); 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 };
}
@@ -0,0 +1,144 @@
// 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
@@ -0,0 +1,148 @@
// 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); }
});
@@ -0,0 +1,32 @@
// 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
@@ -0,0 +1,75 @@
// 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);
}
+18 -13
View File
@@ -1,26 +1,31 @@
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, boardResponse;
let index = { sessions: [], counts: {}, generatedAt: 'fixture time' }, held = null, sends = 0, holdBoard = true, releaseBoard;
let resolveBoardRequest;
const boardRequested = new Promise(resolve => { resolveBoardRequest = resolve; });
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}` });
// 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 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/);
@@ -30,7 +35,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; boardResponse.end(JSON.stringify(index));
holdBoard = false; releaseBoard(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');
@@ -52,7 +57,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.end(JSON.stringify({ delivered: true, sentAt: 'fixture', session: agent }));
held({ status: 200, body: { 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.
@@ -92,5 +97,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 { boardResponse?.end(); held?.end(); await b.close(); await close(web); await close(server); }
} finally { releaseBoard?.(index); held?.({ status: 200, body: {} }); await stopRoute?.(); await b.close(); await close(web); }
});
+7 -4
View File
@@ -4,14 +4,17 @@ import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { browser } from './browser.mjs';
import { fixture } from './fixture.mjs';
import { routeWrites } from './board-route.mjs';
test('served Console browser: real board fixtures, keyboard, drafts, receipts, themes, 320px and failures', { timeout: 120000 }, async () => {
const f = await fixture(), b = await browser();
const out = process.env.WEBUI_EVIDENCE;
if (out) mkdirSync(out, { recursive: true });
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')})()`);
let stopRoute;
try {
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: 'window.errors=[];addEventListener("error",e=>errors.push(e.message));addEventListener("unhandledrejection",e=>errors.push(String(e.reason)));' });
stopRoute = await routeWrites(b, f.base, f.board);
await b.viewport(1440); await b.navigate(f.base); await wait('document.querySelectorAll("table.sessions tbody tr").length===3');
assert.equal(await b.evaluate('document.querySelector("#waiting-count").textContent'), '2');
assert.equal(await b.evaluate('document.querySelectorAll("#projects [data-project]").length'), 3);
@@ -109,15 +112,15 @@ test('served Console browser: real board fixtures, keyboard, drafts, receipts, t
await b.evaluate('document.querySelector("#close").click()');
}
// Empty and unreachable pages are tested as browser responses, never live data claims.
// Stop only the fixture board. The page keeps the last data and reports its URL.
await new Promise(r => f.board.close(r));
// Fail only the fixture board's reads. The page keeps the last data and names the board it could not reach (row 198: the Console).
f.board.setDown(true);
await b.evaluate('document.querySelector("#refresh").click()');
await wait('!document.querySelector("#error").hidden');
const error = await b.evaluate('document.querySelector("#error").textContent');
assert.ok(error.includes(f.boardURL)); assert.match(error, /Showing last known data/);
assert.ok(error.includes('in the Console')); assert.match(error, /Showing last known data/);
assert.ok(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length') > 0);
assert.deepEqual(await b.evaluate('window.errors'), []);
await b.navigate(f.base); await wait('!document.querySelector("#error").hidden');
assert.match(await b.evaluate('document.querySelector("#error").textContent'), /No board data loaded/);
} finally { await b.close(); await f.close(); }
} finally { await stopRoute?.(); await b.close(); await f.close(); }
});
+1 -1
View File
@@ -98,7 +98,7 @@ test('S5 pages in a browser: real broker rows, inert text, Copy, routes, failure
assert.ok(comments.label.includes('\u001b'), 'the label excerpt keeps the ESC byte');
assert.ok(comments.label.includes('\u202e'), 'the label excerpt keeps the bidi character');
writeFileSync(fieldsFile, JSON.stringify(hostile));
const web = await startServer({ port: 0, board: f.boardURL, bus: s.bus, fieldsFile }), base = `http://127.0.0.1:${web.address().port}/`;
const web = await startServer({ port: 0, board: f.board, bus: s.bus, fieldsFile }), base = `http://127.0.0.1:${web.address().port}/`;
const out = process.env.WEBUI_EVIDENCE;
if (out) mkdirSync(out, { recursive: true });
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)})})()`);
+8 -8
View File
@@ -48,7 +48,7 @@ function seeded(t) {
test('bus routes serve the four reads from the reader view, unescaped JSON for the page to escape', async t => {
const { decision, bus, calls } = seeded(t);
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus });
const web = await startServer({ port: 0, board: null, bus });
t.after(() => close(web));
const base = `http://127.0.0.1:${web.address().port}`;
const read = async path => { const r = await fetch(base + path); return { status: r.status, cache: r.headers.get('cache-control'), body: await r.json() }; };
@@ -106,7 +106,7 @@ test('decisions route serves the roles and recent lists from the reader view', a
const reader = b.bindReader({ business: 'demo' });
const calls = [];
const bus = views({ call: async (verb, args = {}) => { calls.push([verb, args]); return structuredClone(b.request(reader, { verb, args })); } });
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus });
const web = await startServer({ port: 0, board: null, bus });
t.after(() => close(web));
const base = `http://127.0.0.1:${web.address().port}`;
const read = async path => { const r = await fetch(base + path); return { status: r.status, cache: r.headers.get('cache-control'), body: await r.json() }; };
@@ -140,7 +140,7 @@ test('credentials route serves the broker credential status from the reader view
const reader = b.bindReader({ business: 'demo' });
const calls = [];
const bus = views({ call: async (verb, args = {}) => { calls.push([verb, args]); return structuredClone(b.request(reader, { verb, args })); } });
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus });
const web = await startServer({ port: 0, board: null, bus });
t.after(() => close(web));
const r = await fetch(`http://127.0.0.1:${web.address().port}/api/bus/credentials`);
assert.equal(r.status, 200); assert.equal(r.headers.get('cache-control'), 'no-store');
@@ -170,7 +170,7 @@ test('replies route serves the replies to one message from the reader view', asy
try { return structuredClone(b.request(reader, { verb, args })); } catch (err) { throw new BusReadError(err.code); }
};
const bus = views({ call });
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus });
const web = await startServer({ port: 0, board: null, bus });
t.after(() => close(web));
const base = `http://127.0.0.1:${web.address().port}`;
const read = async path => { const r = await fetch(base + path); return { status: r.status, cache: r.headers.get('cache-control'), body: await r.json() }; };
@@ -197,7 +197,7 @@ test('replies route serves the replies to one message from the reader view', asy
test('bus routes are GET-only, have no write verb, and keep the same-origin checks', async t => {
const calls = [];
const bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions', 'credentials', 'replies'].map(v => [v, async () => { calls.push(v); return []; }]));
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus });
const web = await startServer({ port: 0, board: null, bus });
t.after(() => close(web));
const base = `http://127.0.0.1:${web.address().port}`;
for (const method of ['POST', 'PUT', 'DELETE', 'HEAD', 'OPTIONS']) {
@@ -218,8 +218,8 @@ test('bus routes are GET-only, have no write verb, and keep the same-origin chec
test('bus refusals map to statuses and never carry a path or a stack', async t => {
let fail;
const bus = { inbox: async () => { throw fail; }, tasks: async () => ({ not: 'rows' }), agents: async () => [], trail: async () => [] };
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus });
const none = await startServer({ port: 0, board: 'http://127.0.0.1:9' });
const web = await startServer({ port: 0, board: null, bus });
const none = await startServer({ port: 0, board: null });
t.after(() => Promise.all([close(web), close(none)]));
const base = `http://127.0.0.1:${web.address().port}`;
const inbox = async () => { const r = await fetch(base + '/api/bus/inbox'); return [r.status, (await r.json())]; };
@@ -275,7 +275,7 @@ test('humanCall speaks the human transport protocol without blocking the server'
assert.equal(await humanCall({ socket: 's', business: 'demo', cli: join(tmpdir(), 'no-such-dir-x', 'cli.mjs') })('inbox').catch(e => e.code), 'invalid-response');
// A slow read stays slow on its own: the server keeps answering meanwhile.
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus: views({ call: (verb) => call(verb, { mode: 'slow' }) }) });
const web = await startServer({ port: 0, board: null, bus: views({ call: (verb) => call(verb, { mode: 'slow' }) }) });
t.after(() => close(web));
const base = `http://127.0.0.1:${web.address().port}`;
const started = Date.now();
+1 -1
View File
@@ -47,7 +47,7 @@ const stub = `(() => {
})();`;
async function setup(t, suffix = '') {
const f = await fixture(); t.after(() => f.close());
const server = await startServer({ port: 0, board: f.boardURL }); t.after(() => close(server));
const server = await startServer({ port: 0, board: f.board }); t.after(() => close(server));
const b = await browser(); t.after(() => b.close());
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: stub });
await b.navigate(`http://127.0.0.1:${server.address().port}/${suffix}#/chat`);
+1 -1
View File
@@ -53,7 +53,7 @@ test('C2-09a metadata uses the human listener bus, projects labels only and refu
test('C2-09a metadata uses the private tester listener own bus and no human transport', async t => {
const root = mkdtempSync(join(tmpdir(), 'chat219-')); t.after(() => rmSync(root, { recursive: true, force: true }));
let reads = 0;
const server = await startTesterSocket({ dataRoot: root, board: 'http://127.0.0.1:9',
const server = await startTesterSocket({ dataRoot: root, board: null,
bus: { instances: async () => { reads++; return { instances: { quill: { definition: 'proofreader', vars: { 'persona.name': 'Quill' } } } }; } } });
t.after(() => close(server));
const answer = await get(server);
+1 -1
View File
@@ -294,7 +294,7 @@ test('configured Console process injects chat through its existing human transpo
const { fileURLToPath } = await import('node:url');
const root = tmp(t, 'webui-chat-cli-'), { env } = fixture(root);
const cli = fileURLToPath(new URL('../src/cli.mjs', import.meta.url));
const child = spawn(process.execPath, [cli, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9',
const child = spawn(process.execPath, [cli, 'serve', '--port', '0',
'--public-host', preview.host, '--auth-header', preview.header, '--allow-user', preview.user, '--send'],
{ env, stdio: ['ignore', 'pipe', 'pipe'] });
t.after(async () => {
+1 -1
View File
@@ -74,7 +74,7 @@ async function setup() {
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();
const web = await startServer({ port: 0, board: f.boardURL, bus }), base = `http://127.0.0.1:${web.address().port}/`;
const web = await startServer({ port: 0, board: f.board, bus }), base = `http://127.0.0.1:${web.address().port}/`;
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: STUB });
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)})})()`);
+2 -2
View File
@@ -56,7 +56,7 @@ async function setup(t, server = {}, access = null) {
};
const bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions'].map(v => [v, async () => []]));
const userOptions = access && { userOptions: userOptionsFor({ owner: async () => ({}), send: true, transport: () => async () => [] }) };
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus, sso, send: async () => ({ id: 'msg-0001' }), decisionWrite, log: l => lines.push(l), ...userOptions, ...server });
const web = await startServer({ port: 0, board: null, bus, sso, send: async () => ({ id: 'msg-0001' }), decisionWrite, log: l => lines.push(l), ...userOptions, ...server });
t.after(() => close(web));
const port = web.address().port;
const signIn = async (sub = undefined) => {
@@ -134,7 +134,7 @@ test('J16: without a decision transport the routes are read-only and /api/config
const write = async () => ({});
await assert.rejects(startServer({ port: 0, decisionWrite: write, send: async () => ({}) }), /need --sso and --send/);
await assert.rejects(startServer({ port: 0, tester: true, testerSocket: {}, decisionWrite: write }), /need --sso and --send/);
const plain = await startServer({ port: 0, board: 'http://127.0.0.1:9' });
const plain = await startServer({ port: 0, board: null });
t.after(() => close(plain));
const port = plain.address().port, host = `127.0.0.1:${port}`;
const r = await call(port, { method: 'POST', path: '/api/decisions/d1/confirmation', host, headers: { origin: `http://${host}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' }, body: '{"choice":"yes"}' });
+10 -8
View File
@@ -3,16 +3,16 @@ import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { startServer as startBoard } from '../../control-board/src/serve.mjs';
import { identityOf } from '../../discord/src/journal.mjs';
import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs';
import { startServer } from '../src/serve.mjs';
import { browser } from './browser.mjs';
import { close } from './fixture.mjs';
import { close, inProcessBoard } from './fixture.mjs';
import { forward, namedListener } from './board-route.mjs';
test('Discord row through real board/WebUI: independent brake/liveness, no Reply, literal content', { timeout: 60000 }, async () => {
const root = mkdtempSync(join(tmpdir(), 'webui-discord-'));
let board, web, b;
let web, b, named;
try {
const dir = join(root, 'discord', 'pilot'); mkdirSync(join(dir, 'run.lock'), { recursive: true });
writeFileSync(join(root, 'discord', 'pilot.json'), JSON.stringify({ name: 'pilot', seat: 'sage', tokenFile: '/PRIVATE-NOT-READ', guildId: 'PRIVATE-ID' }), { mode: 0o600 });
@@ -23,19 +23,21 @@ test('Discord row through real board/WebUI: independent brake/liveness, no Reply
// #1511: a native registration naming the connector's sessions directory, with a setter, must lend it nothing.
const seatsDir = join(root, 'seats');
writeRegistration(seatsDir, { ...makeRegistration({ resolved: { seat: 'sage', project: 'fleet', sessionsDir: sessions, seatDir: dir, launchScript: join(root, 'unused.sh'), layout: 'fleet', defaultWorkspace: null }, task: 'forged native task', tmux: { session: 'sage', socket: null }, pid: process.pid }), taskSetBy: 'forged-setter' });
board = await startBoard({ port: 0, specs: [], seatsDir, boardDir: join(root, 'board'), discordDataRoot: root, exec: () => { throw Error('no transport'); } });
web = await startServer({ port: 0, board: `http://127.0.0.1:${board.address().port}` });
const board = inProcessBoard({ root, specs: [], seatsDir, exec: () => { throw Error('no transport'); } });
web = await startServer({ port: 0, board });
const base = `http://127.0.0.1:${web.address().port}`;
const payload = await (await fetch(base + '/api/board')).json();
assert.equal(payload.sessions[0].connector.braked, true); assert.equal(payload.sessions[0].state, 'idle');
assert.equal(JSON.stringify(payload).includes('PRIVATE'), false);
assert.equal(payload.sessions[0].task, 'Discord connector'); assert.equal(payload.sessions[0].taskSource, 'connector'); assert.equal(payload.sessions[0].taskSetBy, null);
assert.equal(JSON.stringify(payload).includes('forged'), false);
const reply = await fetch(base + '/api/reply', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ agent: 'fleet/sage (discord: pilot)', text: 'refuse me' }) });
// The 409 is the board's refusal, reached through the named send listener; the loopback listener has no send and answers 403 first.
named = await namedListener(board);
const reply = await forward(named.address().port, '/api/reply', JSON.stringify({ agent: 'fleet/sage (discord: pilot)', text: 'refuse me' }));
assert.equal(reply.status, 409);
b = await browser(); await b.viewport(320, 1000);
const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expression})return true;await new Promise(r=>setTimeout(r,50))}throw Error('timeout')})()`);
await b.navigate(`http://127.0.0.1:${board.address().port}`);
await b.navigate(base);
await wait('document.querySelector(".session-row")');
assert.match(await b.evaluate('document.body.textContent'), /braked \(STOP\)/);
assert.match(await b.evaluate('document.body.textContent'), /Board replies disabled for Discord connectors/);
@@ -58,5 +60,5 @@ test('Discord row through real board/WebUI: independent brake/liveness, no Reply
await wait('document.querySelector("#inspection").textContent.includes("owner absent")');
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /not braked; owner absent/);
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /offline/);
} finally { if (b) await b.close(); if (web) await close(web); if (board) await close(board); rmSync(root, { recursive: true, force: true }); }
} finally { if (b) await b.close(); if (named) await close(named); if (web) await close(web); rmSync(root, { recursive: true, force: true }); }
});
+1 -1
View File
@@ -48,7 +48,7 @@ test('the editor: the list, hostile markdown that runs nothing, the draft, and e
const f = await fixture(), b = await browser();
const reads = { files: [], file: [] };
const web = await startServer({
port: 0, board: f.boardURL,
port: 0, board: f.board,
reads: { business: async () => ({ business: { id: 'demo', human: 'jason', projects: ['stack'] } }) },
projectFiles: async args => { reads.files.push(args); return { head: C1, files: Object.keys(files).map(path => ({ path })) }; },
projectFile: async args => { reads.file.push(args); return { path: args.path, head: C1, commit: C1, content: files[args.path], author: 'jason', at: '2026-10-16T00:00:00Z' }; },
+1 -1
View File
@@ -51,7 +51,7 @@ async function session(run) {
const reads = [];
const f = await fixture(), b = await browser();
const web = await startServer({
port: 0, board: f.boardURL,
port: 0, board: f.board,
reads: { business: async () => ({ business: { id: 'demo', human: 'jason', projects: ['stack'] } }) },
projectFiles: async () => ({ head: C1, files: Object.keys(files).map(path => ({ path })) }),
projectFile: async args => { reads.push(args.path); return { path: args.path, head: C1, commit: C1, content: files[args.path], author: 'jason', at: '2026-10-16T00:00:00Z' }; },
+13 -7
View File
@@ -1,12 +1,19 @@
// Same session-line fixture shape as control-board/tests/serve.test.mjs.
// Run the real board scanner/server against temporary data only.
// Run the real board scanner in-process (row 198) against temporary data only.
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { startServer as startBoard } from '../../control-board/src/serve.mjs';
import { createBoard } from '../../control-board/src/board.mjs';
import { makeRegistration, writeRegistration, updateTask } from '../../seat/src/seat.mjs';
import { startServer } from '../src/serve.mjs';
export const close = server => new Promise(resolve => { server.close(resolve); server.closeIdleConnections(); });
// The Console's board: the same createBoard context the CLI builds, over a temporary data root.
// setDown(true) makes the next read fail, which is how a test shows the page a board outage.
export function inProcessBoard({ root, specs, seatsDir, isAlive, exec }) {
const board = createBoard({ specs, boardDir: join(root, 'board'), seatsDir, discordDataRoot: root, isAlive, exec });
let down = false;
return { ...board, index: () => { if (down) throw new Error('fixture board is down'); return board.index(); }, setDown(value) { down = value; } };
}
export async function fixture() {
const root = mkdtempSync(join(tmpdir(), 'webui-fixture-'));
const seatsDir = join(root, 'seats'), specs = [];
@@ -29,12 +36,11 @@ export async function fixture() {
updateTask(seatsDir, agent, 'Registered fixture task', { layout: 'repo', setBy: 'fixture-setter' });
}
}
const board = await startBoard({ port: 0, specs, seatsDir, boardDir: join(root, 'board'), isAlive: tmux => tmux.session !== 'agent4', isPidAlive: () => true,
const board = inProcessBoard({ root, specs, seatsDir, isAlive: tmux => tmux.session !== 'agent4',
exec: (file, args) => { captures.push({ file, args }); return { status: exitCode, stdout: 'fixture transport', stderr: exitCode ? 'fixture refusal <unsafe>' : '' }; },
});
const boardURL = `http://127.0.0.1:${board.address().port}`;
const web = await startServer({ port: 0, board: boardURL });
return { root, board, web, boardURL, base: `http://127.0.0.1:${web.address().port}`, captures, failReply: () => { exitCode = 4; },
async close() { await close(web); await close(board); rmSync(root, { recursive: true, force: true }); },
const web = await startServer({ port: 0, board });
return { root, board, web, base: `http://127.0.0.1:${web.address().port}`, captures, failReply: () => { exitCode = 4; },
async close() { await close(web); rmSync(root, { recursive: true, force: true }); },
};
}
+33 -3
View File
@@ -51,7 +51,7 @@ const ALICE = { id: 'alice', home: 'local', subjects: [{ provider: 'a', sub: 'al
// The fake OP, an in-process broker for business `demo`, createSso with the access hook, and the
// human listener. The owner's options are counting stubs; each user session's options are
// consoleBus over that user's transport. `counts` records every owner stub and user transport call.
async function setup(t, { users = [ALICE], grants = [grant()] } = {}) {
async function setup(t, { users = [ALICE], grants = [grant()], board = null, send = false } = {}) {
const op = await fakeOidc();
t.after(() => op.close());
const iss = op.issuer('a');
@@ -103,7 +103,7 @@ async function setup(t, { users = [ALICE], grants = [grant()] } = {}) {
reads: Object.fromEntries(['settings', 'business', 'busStatus', 'queue', 'row'].map(v => [v, stub(v)])),
chat: Object.fromEntries(['projects', 'threads', 'messages', 'send', 'open', 'createProject', 'createThread'].map(v => [v, stub(v)])),
};
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso, log, ...owner, userOptions, federation: FEDERATION });
const web = await startServer({ port: 0, board, send, sso, log, ...owner, userOptions, federation: FEDERATION });
t.after(() => close(web));
const p1 = broker.request(human, { verb: 'project.create', args: { name: 'shared' } }).id;
const p2 = broker.request(human, { verb: 'project.create', args: { name: 'private' } }).id;
@@ -246,6 +246,36 @@ test('R6 G4 every owner route answers 403 owner-only before any bus call', async
assert.equal(events(env), before, 'something was written');
});
// Row 198 (sage-3603): the board runs in-process behind userRoute alone; upstream's null-origin backstop
// is gone. A granted session is refused on all five board paths before any board read, and the owner's
// session reaches each one. The mutant that drops the userRoute check for these paths must fail here.
test('R6 G4b the five board paths: a granted session is refused before any board read, the owner reaches all five', async t => {
const reads = [];
const board = {
index: async () => { reads.push('index'); return { sessions: [], counts: {}, generatedAt: 'fixture' }; },
conversation: url => { reads.push(url.pathname); return { status: 200, body: { conversations: [] } }; },
markSeen: async () => { reads.push('markSeen'); return { sessions: [] }; },
reply: async () => { reads.push('reply'); return { status: 200, body: { delivered: true } }; },
};
const env = await setup(t, { board, send: true });
const { session: granted } = await signIn(env, 'alice-sub');
const { session: owner } = await signIn(env, 'owner-sub');
const seen = JSON.stringify({ project: 'p', agent: 'a', lastActivity: 'x', seen: true }), reply = JSON.stringify({ agent: 'p/a', text: 'hi' });
const paths = [['GET', '/api/board'], ['GET', '/api/conversations'], ['GET', '/api/conversation?conversation=c1'], ['POST', '/api/seen', seen], ['POST', '/api/reply', reply]];
const headers = (method, cookie) => (method === 'GET' ? { cookie } : writeHeaders(cookie));
for (const [method, path, body] of paths) {
const r = await call(env.port, { method, path, headers: headers(method, granted), body });
assert.equal(r.status, 403, `granted ${method} ${path}`);
assert.equal(r.body.error, 'owner-only', `granted ${method} ${path}`);
}
assert.deepEqual(reads, [], 'a granted session reached the board');
for (const [method, path, body] of paths) {
const r = await call(env.port, { method, path, headers: headers(method, owner), body });
assert.equal(r.status, 200, `owner ${method} ${path}: ${JSON.stringify(r.body)}`);
}
assert.deepEqual(reads, ['index', '/api/conversations', '/api/conversation', 'markSeen', 'reply']);
});
test('R6 G5 the bus refuses every owner verb over the user transport on its own', async t => {
const env = await setup(t);
const o = consoleBus(env.userTransport('alice'), { send: true, owner: env.ownerCall });
@@ -336,7 +366,7 @@ test('R6 G7 the hub label and plain peer links render from config on both pages'
const b = await browser(); t.after(() => b.close());
// The owner page, over the real server.
const f = await fixture(); t.after(() => f.close());
const owner = await startServer({ port: 0, board: f.boardURL, federation: FEDERATION }); t.after(() => close(owner));
const owner = await startServer({ port: 0, board: f.board, federation: FEDERATION }); t.after(() => close(owner));
await b.navigate(`http://127.0.0.1:${owner.address().port}/`);
await wait(b, '!document.querySelector("#federation").hidden');
assert.deepEqual(JSON.parse(await b.evaluate(links)), expected);
+4 -5
View File
@@ -5,10 +5,9 @@
import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { startServer as startBoard } from '../../control-board/src/serve.mjs';
import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs';
import { startServer } from '../src/serve.mjs';
import { close } from './fixture.mjs';
import { close, inProcessBoard } from './fixture.mjs';
export const at = s => new Date(Date.now() + s * 1000).toISOString();
@@ -36,11 +35,11 @@ export async function historyFixture({ seat = 'fixture', project = 'repo' } = {}
mkdirSync(sessionsDir, { recursive: true });
const seatsDir = join(root, 'seats'), captures = [];
writeRegistration(seatsDir, makeRegistration({ resolved: { seat, project, sessionsDir, seatDir: root, launchScript: join(root, 'unused.sh'), layout: 'repo', defaultWorkspace: projectRoot }, task: 'Fixed task', tmux: { session: seat, socket: null }, pid: process.pid, now: () => new Date(Date.now() - 3600000) }));
const board = await startBoard({ port: 0, specs: [{ agent: seat, project, sessionsDir, tmux: { session: seat } }], boardDir: join(root, 'board'), seatsDir, isAlive: () => true, isPidAlive: () => true,
const board = inProcessBoard({ root, specs: [{ agent: seat, project, sessionsDir, tmux: { session: seat } }], seatsDir, isAlive: () => true,
exec: (f, args) => { captures.push(args); return { status: 0, stdout: '', stderr: '' }; } });
const web = await startServer({ port: 0, board: `http://127.0.0.1:${board.address().port}` });
const web = await startServer({ port: 0, board });
return { root, projectRoot, sessionsDir, board, web, base: `http://127.0.0.1:${web.address().port}`, captures,
async close() { await close(web); await close(board); rmSync(root, { recursive: true, force: true }); },
async close() { await close(web); rmSync(root, { recursive: true, force: true }); },
};
}
@@ -8,6 +8,7 @@ import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { join } from 'node:path';
import { browser } from './browser.mjs';
import { routeWrites } from './board-route.mjs';
import { historyFixture, session, user, assistant, toolResult, at, waiter, turns } from './history-fixture.mjs';
const PEER = '[from: darkwing (7525c8e4) -> to: fixture (00000000)] peer note that lands mid-turn';
@@ -16,7 +17,7 @@ const BIG = 'BIG_START ' + Array.from({ length: 900000 }, (_, i) => (i % 1679616
test('return flow through the conversation view: send, tool call, delayed result, peer message, exact long answers, relaunch', { timeout: 240000 }, async () => {
const f = await historyFixture();
let b;
let b, stopRoute;
try {
const file = join(f.sessionsDir, '2026-09-26T10-00-00_s1.jsonl');
const log = session(file, f.projectRoot);
@@ -24,6 +25,7 @@ test('return flow through the conversation view: send, tool call, delayed result
log.add(assistant('Input needed: OLD_ANSWER'), at(-90));
b = await browser(); await b.viewport(1440, 1000);
stopRoute = await routeWrites(b, f.base, f.board);
const wait = waiter(b);
const draft = () => b.evaluate('(() => { const t = document.querySelector("#conv-reply"); return [t.value, t.selectionStart, document.activeElement === t]; })()');
// The seat is waiting on Jason, so the view opens from its card.
@@ -95,5 +97,5 @@ test('return flow through the conversation view: send, tool call, delayed result
assert.notEqual(await b.evaluate('document.querySelector("#conv-pick").value'), opened);
assert.equal(await b.evaluate('document.querySelector("[data-marker=newer]")'), null);
assert.equal(await b.evaluate('document.querySelector("#conv-reply").value'), 'NEXT_DRAFT');
} finally { if (b) await b.close(); await f.close(); }
} finally { if (stopRoute) await stopRoute(); if (b) await b.close(); await f.close(); }
});
@@ -18,7 +18,7 @@ test('row 165 Settings: role instances read, add with a restart named, change wi
// The server's read follows each write, so a poll between steps shows what the stub answered.
let current=view;
const bus={messages:async()=>({messages:[],unread:0}),usage:async()=>{throw Error('no usage');},instances:async()=>current};
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})}}),b=await browser();
const web=await startServer({port:0,board:null,bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})}}),b=await browser();
try{
// The stub answers each POST with the next queued reply and records the body.
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`window.posts=[];window.replies=[];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(url==='/api/instances'&&options.method==='POST'){posts.push(JSON.parse(options.body));const [status,body]=replies.shift();return new Response(JSON.stringify(body),{status});}return original(url,options);};`});
+2 -2
View File
@@ -21,7 +21,7 @@ const TOKENS=[
test('REQ-LIMIT-3 REQ-LIMIT-4 Settings shows token limits counted, not enforced and check failed; Reset posts one path; a save keeps the token limits',{timeout:90000},async()=>{
let answer={revision:launch.revision,limits:launch.limits,tokens:TOKENS};
const bus={messages:async()=>({messages:[],unread:0}),usage:async()=>{throw Error('no usage');},limits:async()=>answer};
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{},launch})}}),b=await browser();
const web=await startServer({port:0,board:null,bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{},launch})}}),b=await browser();
try{
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`window.posts=[];window.enabled=new URL(location.href).searchParams.has('send');window.fixture=${JSON.stringify(launch)};const original=window.fetch;window.fetch=async(url,options={})=>{if(url==='/api/config')return new Response(JSON.stringify({send:window.enabled}));if(url==='/api/settings'&&options.method==='POST'){posts.push(JSON.parse(options.body));return new Response(JSON.stringify({...fixture,revision:'b'.repeat(64)}));}return original(url,options);};`});
const port=web.address().port;
@@ -74,7 +74,7 @@ test('REQ-LIMIT-3 REQ-LIMIT-4 Settings shows token limits counted, not enforced
test('Row 167 with an assistant draft open, a token limit reset gives the assistant submit its revision',{timeout:90000},async()=>{
const withAssistant={...launch,assistants:{assistant:{definition:'assistant',vars:{model:'claude-opus-5-5'},tracker:null}}};
const bus={messages:async()=>({messages:[],unread:0}),usage:async()=>{throw Error('no usage');},limits:async()=>({revision:launch.revision,limits:launch.limits,tokens:TOKENS})};
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{},launch:withAssistant})}}),b=await browser();
const web=await startServer({port:0,board:null,bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{},launch:withAssistant})}}),b=await browser();
try{
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`window.posts=[];window.fixture=${JSON.stringify(withAssistant)};const original=window.fetch;window.fetch=async(url,options={})=>{if(url==='/api/config')return new Response(JSON.stringify({send:true}));if(url==='/api/settings'&&options.method==='POST'){posts.push(JSON.parse(options.body));return new Response(JSON.stringify({...fixture,revision:'bc'[posts.length-1].repeat(64)}));}return original(url,options);};`});
const q=sel=>`document.querySelector(${JSON.stringify(sel)})`;
+3 -3
View File
@@ -15,7 +15,7 @@ function seed(t){
}
test('messages HTTP read pages and preserves metadata; bad arguments refuse before a read; other reads unchanged',async t=>{
const s=seed(t),a=s.send('first'),b=s.send('second');
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus:s.bus});t.after(()=>close(web));
const web=await startServer({port:0,board:null,bus:s.bus});t.after(()=>close(web));
const url=`http://127.0.0.1:${web.address().port}/api/bus/`;
const get=async path=>{const res=await fetch(url+path);return {status:res.status,body:await res.json()}};
const page=await get('messages?limit=1');assert.equal(page.status,200);
@@ -34,7 +34,7 @@ test('messages HTTP read pages and preserves metadata; bad arguments refuse befo
});
test('Console Messages lists late unsolicited messages on polling with sender class time read state and trail; nav count includes beyond the page; reads never deliver', {timeout:45000},async t=>{
const s=seed(t),early=s.send('early');
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus:s.bus});
const web=await startServer({port:0,board:null,bus:s.bus});
const b=await browser({machine:false});
const wait=expr=>b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,25))}throw Error('condition timed out')})()`);
try{
@@ -78,7 +78,7 @@ test('Messages nav count polls on Tasks and Board and clears on a Board refusal;
business:async()=>{localReads.push('business');return {business:{id:'demo',human:'jason',arbiters:{},projects:[],vars:{},instances:[],actions:[],credentials:[]},at:at()};},
settings:async()=>{localReads.push('settings');return {release:'0.0.1',business:'demo',notifier:{binding:null},at:at()};},
};
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads});
const web=await startServer({port:0,board:null,bus,reads});
const b=await browser({machine:false});
const wait=expr=>b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,25))}throw Error('condition timed out: '+${JSON.stringify(expr)})})()`);
const badge=`document.querySelector('#sections a[href="#/messages"] .count')`;
+5 -1
View File
@@ -225,7 +225,7 @@ test('a copy that meets an empty target directory replaces it by rename, not by
// serve from a copy: /healthz, the config check, roles and RELEASE from the
// copy, and the queue read from --checkout or, without it, from the copy.
async function serve(t, cli, args, env) {
const child = spawn(process.execPath, [cli, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', ...args], { env, stdio: ['ignore', 'pipe', 'pipe'] });
const child = spawn(process.execPath, [cli, 'serve', '--port', '0', ...args], { env, stdio: ['ignore', 'pipe', 'pipe'] });
t.after(() => child.kill());
let out = '', err = '';
child.stderr.on('data', (c) => { err += c; });
@@ -252,6 +252,10 @@ test('serve runs from a copy: /healthz 200, the config check, roles and RELEASE
assert.match(out, /^Bus: acme$/m, 'the config check ran from the copy');
assert.match(out, /^Queue: read from the --checkout directory$/m);
assert.deepEqual(await get('/healthz'), { status: 200, body: { ok: true } });
// Row 198 (M11): the copy carries control-board/src, so the in-process board answers.
const board = await get('/api/board');
assert.equal(board.status, 200, JSON.stringify(board.body));
assert.ok(Array.isArray(board.body.sessions), 'the board answers from the copy');
const settings = await get('/api/settings');
assert.equal(settings.status, 200);
assert.equal(settings.body.release, '0.0.0-fixture', 'RELEASE from the copy');
+13 -17
View File
@@ -1,5 +1,3 @@
import { leasePort } from '../../../scripts/testing/lane-fixtures.mjs';
import { after as laneAfter } from 'node:test';
// Row 74 (#1568, lead decision 93): the preview mode. A request for the public name needs the
// proxy's header equal to the one user id, else 401 with nothing read; in preview mode nothing
// but GET and HEAD gets through, whatever the Host; loopback requests work as they did. Round 2:
@@ -7,7 +5,7 @@ import { after as laneAfter } from 'node:test';
// fails closed instead of reaching the loopback path.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createServer, request } from 'node:http';
import { request } from 'node:http';
import { connect } from 'node:net';
import { spawn, spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
@@ -46,20 +44,17 @@ function raw(port, lines) {
const signedIn = { [HEADER]: ID };
const json = { 'content-type': 'application/json' };
// A board that counts what reaches it, and a bus that counts its reads.
// A board that counts what reaches it (in-process, row 198), and a bus that counts its reads.
async function setup(t, options = {}) {
const boardHits = [];
const upstream = createServer((req, res) => {
boardHits.push(`${req.method} ${req.url}`);
res.writeHead(200, json);
res.end(req.url === '/api/board' ? '{"sessions":[]}' : '{"ok":true}');
});
await new Promise(r => upstream.listen(0, '127.0.0.1', r));
// Closed first and on its own, so a refused startServer doesn't leave it holding the process open.
t.after(() => close(upstream));
const board = {
index: async () => { boardHits.push('GET /api/board'); return { sessions: [] }; },
markSeen: async () => { boardHits.push('POST /api/seen'); return { sessions: [] }; },
reply: async () => { boardHits.push('POST /api/reply'); return { status: 200, body: { delivered: true } }; },
};
const busCalls = [];
const bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions'].map(v => [v, async arg => { busCalls.push([v, arg]); return [{ verb: v }]; }]));
const web = await startServer({ port: 0, board: `http://127.0.0.1:${upstream.address().port}`, bus, preview, ...options });
const web = await startServer({ port: 0, board, bus, preview, ...options });
t.after(() => close(web));
return { port: web.address().port, boardHits, busCalls };
}
@@ -104,7 +99,7 @@ test('the CLI refuses a partial or invalid preview flag set at start', () => {
test('the CLI with all three flags serves the preview mode', async t => {
// No system config, so the bus routes answer not-configured; the gate runs before them.
const env = { ...process.env, MOSAIC_CONFIG: fileURLToPath(new URL('./no-such-config.json', import.meta.url)) };
const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', '--public-host', NAME, '--auth-header', 'Remote-User', '--allow-user', ID], { env, stdio: ['ignore', 'pipe', 'pipe'] });
const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', '--public-host', NAME, '--auth-header', 'Remote-User', '--allow-user', ID], { env, stdio: ['ignore', 'pipe', 'pipe'] });
t.after(() => child.kill());
let out = '';
const port = await new Promise((resolve, reject) => {
@@ -207,8 +202,9 @@ test('in preview mode every write answers 403 read-only, from the public name an
});
test('loopback requests are unchanged in preview mode, and the bus views work with the board down', async t => {
const dead = `http://127.0.0.1:${await leasePort({after:laneAfter}, {closed:true})}`;
const { port, busCalls } = await setup(t, { board: dead, timeout: 200 });
// The board is down: every read throws, which the Console answers as invalid-response (502).
const down = { index: async () => { throw new Error('board down'); } };
const { port, busCalls } = await setup(t, { board: down });
const loopback = { host: `127.0.0.1:${port}` };
const inbox = await send(port, { headers: loopback });
assert.equal(inbox.status, 200); assert.deepEqual(inbox.body.rows, [{ verb: 'inbox' }]);
@@ -217,7 +213,7 @@ test('loopback requests are unchanged in preview mode, and the bus views work wi
assert.equal((await send(port, { headers: { host: `127.0.0.1:${port + 1}` } })).status, 403);
// The board panel's existing error; the bus views still answer.
const board = await send(port, { path: '/api/board', headers: signedIn });
assert.equal(board.status, 502); assert.equal(board.body.board, dead);
assert.equal(board.status, 502); assert.equal(board.body.error, 'invalid-response');
assert.equal((await send(port, { path: '/api/bus/agents', headers: signedIn })).status, 200);
assert.deepEqual(busCalls.map(c => c[0]), ['inbox', 'agents']);
});
@@ -6,7 +6,7 @@ import { browser } from './browser.mjs';
const GEN = '12345678-1234-4234-8234-123456789abc';
async function setup(t, readonly = false) {
const f = await fixture(); t.after(() => f.close());
const server = await startServer({ port: 0, board: f.boardURL }); t.after(() => close(server));
const server = await startServer({ port: 0, board: f.board }); t.after(() => close(server));
const b = await browser(); t.after(() => b.close());
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: `(() => {
const real=fetch.bind(window);window.writes=[];window.failWrite=false;window.confirmResume=true;window.confirmed=0;
+3 -3
View File
@@ -37,7 +37,7 @@ test('proof startup refuses partial flags non-preview and invalid fixture file m
writeFileSync(hook, `import fs from 'node:fs';import {syncBuiltinESMExports} from 'node:module';const original=fs.fstatSync;fs.fstatSync=(...args)=>{const s=original(...args);s.uid=process.getuid()+1;return s;};syncBuiltinESMExports();`);
cases.push({ flags: [...PREVIEW, ...proof], env: { ...f.env, NODE_OPTIONS: `--import=${hook}` } });
for (const c of cases) {
const r = spawnSync(process.execPath, [CLI, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', ...c.flags], { env: c.env ?? f.env, encoding: 'utf8', timeout: 5000 });
const r = spawnSync(process.execPath, [CLI, 'serve', '--port', '0', ...c.flags], { env: c.env ?? f.env, encoding: 'utf8', timeout: 5000 });
assert.equal(r.status, 2, 'invalid proof startup must refuse'); assert.match(r.stderr, /proof/i);
assert.ok(!r.stderr.includes(VALUE)); assert.ok(!r.stdout.includes(VALUE));
if (c.value) assert.ok(!r.stderr.includes(c.value));
@@ -46,7 +46,7 @@ test('proof startup refuses partial flags non-preview and invalid fixture file m
test('serve argv carries only fixture proof path and header; startup explicitly reports proof on or off', async t => {
const f = fixture(t);
for (const on of [true, false]) {
const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', ...PREVIEW, ...(on ? ['--proof-header', 'x-console-proof', '--proof-file', f.file] : [])], { env: f.env, stdio: ['ignore', 'pipe', 'pipe'] });
const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', ...PREVIEW, ...(on ? ['--proof-header', 'x-console-proof', '--proof-file', f.file] : [])], { env: f.env, stdio: ['ignore', 'pipe', 'pipe'] });
let out = '', err = ''; child.stdout.on('data', c => out += c); child.stderr.on('data', c => err += c);
t.after(async () => { if (child.exitCode === null && child.signalCode === null) { child.kill('SIGTERM'); await once(child, 'close'); } });
await new Promise((resolve, reject) => {
@@ -66,7 +66,7 @@ test('CLI applies proof on the main listener and rejects that header on its test
const f = await testerFixture(t), file = join(f.root, 'proof.fixture');
writeFileSync(file, VALUE, { mode: 0o600 });
await new Promise(r=>f.web.close(r));
const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', '--tester-socket', '--business', 'acme', ...PREVIEW, '--proof-header', 'x-console-proof', '--proof-file', file], { env: f.f.env, stdio: ['ignore', 'pipe', 'pipe'] });
const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', '--tester-socket', '--business', 'acme', ...PREVIEW, '--proof-header', 'x-console-proof', '--proof-file', file], { env: f.f.env, stdio: ['ignore', 'pipe', 'pipe'] });
let output = ''; child.stdout.on('data', c => output += c); child.stderr.resume();
t.after(async () => { if (child.exitCode === null && child.signalCode === null) { child.kill('SIGTERM'); await once(child, 'close'); } });
await new Promise((resolve, reject) => {
+4 -5
View File
@@ -1,7 +1,7 @@
// Row 178: fixture proof values only. No installed proof, proxy or unit is read.
import test from 'node:test';
import assert from 'node:assert/strict';
import { createServer, request } from 'node:http';
import { request } from 'node:http';
import { mkdtempSync, writeFileSync, chmodSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
@@ -24,9 +24,8 @@ async function setup(t) {
writeFileSync(file, VALUE + '\n', { mode: 0o600 });
const proof = serve.proofOptions({ proofHeader: HEADER, proofFile: file }, preview);
const calls = [], logs = [];
const board = createServer((q, r) => { calls.push('board'); r.setHeader('content-type', 'application/json'); r.end('{"sessions":[]}'); });
await new Promise(r => board.listen(0, '127.0.0.1', r)); t.after(() => close(board));
const server = await serve.startServer({ port: 0, preview, proof, board: `http://127.0.0.1:${board.address().port}`,
const board = { index: async () => { calls.push('board'); return { sessions: [] }; } };
const server = await serve.startServer({ port: 0, preview, proof, board,
bus: { tasks: async () => { calls.push('tasks'); return []; } }, send: async () => { calls.push('send'); return { id: 'receipt-1' }; }, log: line => logs.push(line) });
t.after(() => close(server));
return { port: server.address().port, calls, logs, proof, file };
@@ -70,7 +69,7 @@ test('proxy proof header is refused on other Hosts and on the tester listener',
assert.equal((await ask(f.port, { host: `127.0.0.1:${f.port}`, headers: { 'x-user': undefined, [HEADER]: '' } })).status, 403);
const testerRoot = mkdtempSync(join(tmpdir(),'tester-proof-'));
t.after(()=>rmSync(testerRoot,{recursive:true,force:true}));
const server = await startTesterSocket({ dataRoot: testerRoot, testerProofHeader: HEADER, board: 'http://127.0.0.1:9' });
const server = await startTesterSocket({ dataRoot: testerRoot, testerProofHeader: HEADER, board: null });
t.after(() => close(server));
const result = await testerRequest({dataRoot:testerRoot,headers:{[HEADER]:VALUE},path:'/api/config'});
assert.equal(result.status, 403); assert.deepEqual(f.calls, []);
+1 -1
View File
@@ -185,7 +185,7 @@ test('settings: release, board, loopback address, notifier binding name only', a
assert.equal(body.launch.instances.pm.provider, 'zai');
assert.equal(body.launch.limits.models.zai['claude-opus-5-5'], 2);
assert.match(body.launch.revision, /^[a-f0-9]{64}$/);
assert.deepEqual({ ...body, launch: undefined, at: undefined }, { release: '0.0.99', business: 'acme', notifier: { binding: 'jason-dm' }, board: 'http://127.0.0.1:7331', address: `${base}/`, launch: undefined, at: undefined });
assert.deepEqual({ ...body, launch: undefined, at: undefined }, { release: '0.0.99', business: 'acme', notifier: { binding: 'jason-dm' }, board: null, address: `${base}/`, launch: undefined, at: undefined });
clean(r.text, s.base, repo.base);
// No notifier config: still 200, so appearance works; the reason is redacted.
const bare = seeded(t, { notify: false });
+9 -10
View File
@@ -3,18 +3,18 @@ import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { startServer as startBoard } from '../../control-board/src/serve.mjs';
import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs';
import { startServer } from '../src/serve.mjs';
import { browser } from './browser.mjs';
import { close } from './fixture.mjs';
import { close, inProcessBoard } from './fixture.mjs';
import { sendOnConfig } from './board-route.mjs';
const oldAt = '2026-09-14T10:00:00.000Z', launchAt = '2026-09-15T10:00:00.000Z', newAt = '2026-09-15T10:01:00.000Z';
const notice = `relaunched at ${launchAt}, no messages since`;
test('both presentations replace old activity with relaunch notice, label retained history, then resume after new activity', { timeout: 60000 }, async () => {
const root = mkdtempSync(join(tmpdir(), 'webui-relaunch-'));
let board, web, b;
let web, b;
try {
const sessionsDir = join(root, 'sessions'); mkdirSync(sessionsDir);
const file = join(sessionsDir, 's.jsonl');
@@ -23,16 +23,15 @@ test('both presentations replace old activity with relaunch notice, label retain
writeFileSync(file, history);
const seatsDir = join(root, 'seats');
writeRegistration(seatsDir, { ...makeRegistration({ resolved: { seat: 'fixture', project: 'repo', sessionsDir, seatDir: root, launchScript: join(root, 'unused.sh'), layout: 'repo', defaultWorkspace: root }, task: 'Fixed task', tmux: { session: 'fixture', socket: null }, pid: process.pid, now: () => new Date(launchAt) }), taskSetBy: 'fixture-setter' });
board = await startBoard({ port: 0, specs: [{ agent: 'fixture', project: 'repo', sessionsDir, tmux: {} }], boardDir: join(root, 'board'), seatsDir, isAlive: () => true, isPidAlive: () => true, exec: () => { throw Error('no transport expected'); } });
const boardURL = `http://127.0.0.1:${board.address().port}`;
web = await startServer({ port: 0, board: boardURL });
const board = inProcessBoard({ root, specs: [{ agent: 'fixture', project: 'repo', sessionsDir, tmux: {} }], seatsDir, isAlive: () => true, exec: () => { throw Error('no transport expected'); } });
web = await startServer({ port: 0, board });
const webURL = `http://127.0.0.1:${web.address().port}`;
const row = (await (await fetch(webURL + '/api/board')).json()).sessions[0];
assert.equal(row.relaunchedAt, launchAt); assert.equal(row.lastActivity, oldAt);
assert.match(row.lastAssistantText, /OLD_FIXTURE_REPLY/); assert.equal(row.state, 'waiting'); assert.equal(row.taskSetBy, 'fixture-setter');
b = await browser(); await b.viewport(320, 1000);
b = await browser(); await sendOnConfig(b, webURL); await b.viewport(320, 1000);
const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expression})return true;await new Promise(r=>setTimeout(r,50))}throw Error('timeout')})()`);
await b.navigate(boardURL); await wait('document.querySelector(".session-row")');
await b.navigate(webURL); await wait('document.querySelector(".session-row")');
const legacyRow = await b.evaluate('document.querySelector(".session-row").textContent');
assert.ok(legacyRow.includes(notice)); assert.equal(legacyRow.includes('OLD_FIXTURE_REPLY'), false);
await b.evaluate('document.querySelector(".row-toggle").click()');
@@ -55,7 +54,7 @@ test('both presentations replace old activity with relaunch notice, label retain
await wait('document.querySelector("#inspection").textContent.includes("NEW_FIXTURE_REPLY")');
assert.equal((await b.evaluate('document.querySelector("#inspection").textContent')).includes('relaunched at'), false);
assert.equal((await b.evaluate('document.querySelector("#inspection").textContent')).includes('Historical last'), false);
await b.navigate(boardURL); await wait('document.querySelector(".session-row")?.textContent.includes("NEW_FIXTURE_REPLY")');
await b.navigate(webURL); await wait('document.querySelector(".session-row")?.textContent.includes("NEW_FIXTURE_REPLY")');
assert.equal((await b.evaluate('document.querySelector(".session-row").textContent')).includes('relaunched at'), false);
} finally { if (b) await b.close(); if (web) await close(web); if (board) await close(board); rmSync(root, { recursive: true, force: true }); }
} finally { if (b) await b.close(); if (web) await close(web); rmSync(root, { recursive: true, force: true }); }
});
+7 -6
View File
@@ -10,11 +10,11 @@ import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { startServer as startBoard } from '../../control-board/src/serve.mjs';
import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs';
import { startServer } from '../src/serve.mjs';
import { browser } from './browser.mjs';
import { close } from './fixture.mjs';
import { close, inProcessBoard } from './fixture.mjs';
import { routeWrites } from './board-route.mjs';
const at = s => new Date(Date.now() + s * 1000).toISOString();
const message = (timestamp, m) => JSON.stringify({ type: 'message', timestamp, message: m }) + '\n';
@@ -22,17 +22,18 @@ const assistant = (timestamp, text) => message(timestamp, { role: 'assistant', s
test('reported return flow and relative Age: reply sent from the inspector, then the new answer appears there without manual refresh', { timeout: 90000 }, async () => {
const root = mkdtempSync(join(tmpdir(), 'webui-return-'));
let board, web, b;
let web, b, stopRoute;
try {
const sessionsDir = join(root, 'sessions'); mkdirSync(sessionsDir);
const file = join(sessionsDir, 's.jsonl');
writeFileSync(file, message(at(-120), { role: 'user', content: [{ type: 'text', text: 'Start' }] }) + assistant(at(-90), 'Input needed: OLD_ANSWER'));
const seatsDir = join(root, 'seats'), captures = [];
writeRegistration(seatsDir, makeRegistration({ resolved: { seat: 'fixture', project: 'repo', sessionsDir, seatDir: root, launchScript: join(root, 'unused.sh'), layout: 'repo', defaultWorkspace: root }, task: 'Fixed task', tmux: { session: 'fixture', socket: null }, pid: process.pid, now: () => new Date(Date.now() - 300000) }));
board = await startBoard({ port: 0, specs: [{ agent: 'fixture', project: 'repo', sessionsDir, tmux: {} }], boardDir: join(root, 'board'), seatsDir, isAlive: () => true, isPidAlive: () => true,
const board = inProcessBoard({ root, specs: [{ agent: 'fixture', project: 'repo', sessionsDir, tmux: {} }], seatsDir, isAlive: () => true,
exec: (f, args) => { captures.push(args); return { status: 0, stdout: '', stderr: '' }; } });
web = await startServer({ port: 0, board: `http://127.0.0.1:${board.address().port}` });
web = await startServer({ port: 0, board });
b = await browser(); await b.viewport(1440, 1000);
stopRoute = await routeWrites(b, `http://127.0.0.1:${web.address().port}`, board);
const wait = (expression, ms = 20000) => b.evaluate(`(async()=>{for(const end=Date.now()+${ms};Date.now()<end;){if(${expression})return true;await new Promise(r=>setTimeout(r,100))}throw Error('timeout: '+${JSON.stringify(expression)})})()`);
const inspection = () => b.evaluate('document.querySelector("#inspection").textContent');
await b.navigate(`http://127.0.0.1:${web.address().port}`); await wait('document.querySelector("table.sessions [data-open]")');
@@ -72,5 +73,5 @@ test('reported return flow and relative Age: reply sent from the inspector, then
const later = await inspection();
assert.doesNotMatch(later, /Waiting for a reply|before your message/);
assert.match(later, /Last assistant text/);
} finally { if (b) await b.close(); if (web) await close(web); if (board) await close(board); rmSync(root, { recursive: true, force: true }); }
} finally { if (stopRoute) await stopRoute(); if (b) await b.close(); if (web) await close(web); rmSync(root, { recursive: true, force: true }); }
});
+1 -1
View File
@@ -41,7 +41,7 @@ test('row 65 Settings: the routing table lists every instance, saves a harness,
try{return structuredClone(host.broker.request(human,{verb,args}));}catch(e){if(e instanceof BusError)throw new BusReadError(e.code);throw e;}
};
const bus={messages:async()=>({messages:[],unread:0}),usage:async()=>{throw Error('no usage');},routes:()=>call('routes')};
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})},log:()=>{}});
const web=await startServer({port:0,board:null,bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})},log:()=>{}});
const lines=[];
const writer=await startServer({port:0,preview,send:async()=>({}),routesWrite:args=>call('routes.set',args),log:line=>lines.push(line)});
const b=await browser();
+2 -2
View File
@@ -64,7 +64,7 @@ test('the send box: held roles, the draft and caret across a refresh, each recei
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();
const web = await startServer({ port: 0, board: f.boardURL, bus }), base = `http://127.0.0.1:${web.address().port}/`;
const web = await startServer({ port: 0, board: f.board, bus }), base = `http://127.0.0.1:${web.address().port}/`;
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 value = sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.value ?? null`);
@@ -240,7 +240,7 @@ test('a sent receipt lists its replies as the page refreshes, oldest first and c
},
});
const f = await fixture(), b = await browser();
const web = await startServer({ port: 0, board: f.boardURL, bus }), base = `http://127.0.0.1:${web.address().port}/`;
const web = await startServer({ port: 0, board: f.board, bus }), base = `http://127.0.0.1:${web.address().port}/`;
const wait = (expr, tries = 100) => b.evaluate(`(async()=>{for(let i=0;i<${tries};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 set = (sel, v) => b.evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(sel)});e.value=${JSON.stringify(v)};e.dispatchEvent(new Event('input',{bubbles:true}));return true})()`);
@@ -0,0 +1,31 @@
// Row 198 (mos-1512): with send off, the Console is read-only in the page. The reply form and the Seen
// button are not drawn, and the inspector says so in words. The Console here is a plain listener with no
// send, so /api/config answers send: false and the page's gate is what is under test.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { browser } from './browser.mjs';
import { fixture } from './fixture.mjs';
test('send off: the reply form and the Seen button are not drawn, and the inspector says the Console is read-only', { timeout: 60000 }, async () => {
const f = await fixture(), b = await browser();
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)})})()`);
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(1440); await b.navigate(f.base);
await wait('document.querySelector("table.sessions [data-open=\'proj/agent1\']")');
assert.equal(await b.evaluate('(await fetch("/api/config").then(r => r.json())).send'), false);
await b.evaluate('document.querySelector("table.sessions [data-open=\'proj/agent1\']").click()');
await wait('!document.querySelector("#inspector").hidden');
await wait('document.querySelector("#inspection").textContent.includes("Replies are off: this Console is read-only.")');
assert.equal(await b.evaluate('!!document.querySelector(".reply-form")'), false);
assert.equal(await b.evaluate('!!document.querySelector("#reply")'), false);
assert.equal(await b.evaluate('!!document.querySelector("#inspection [data-seen]")'), false);
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /Seen is off: this Console is read-only\./);
// The conversation view's reply line says the same thing, with no form under it.
await b.evaluate('document.querySelector("table.sessions [data-history]").click()');
await wait('!document.querySelector("#conversation").hidden');
await wait('document.querySelector("#conv-no-reply").textContent === "Replies are off: this Console is read-only."');
assert.equal(await b.evaluate('document.querySelector("#conv-form").hidden'), true);
assert.deepEqual(await b.evaluate('window.errors'), []);
} finally { await b.close(); await f.close(); }
});
+21 -8
View File
@@ -5,7 +5,7 @@
// Every other write is still 403 read-only. Nothing here starts the real human transport.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createServer, request } from 'node:http';
import { request } from 'node:http';
import { spawn, spawnSync } from 'node:child_process';
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
@@ -36,18 +36,20 @@ function post(port, { method = 'POST', path = '/api/bus/message', headers = ok,
});
}
// A board that counts what reaches it, a bus that counts its reads, and a send that records
// each call and answers from `answer`.
// A board that counts what reaches it (the in-process board's reads and writes), a bus that counts its
// reads, and a send that records each call and answers from `answer`.
async function setup(t, options = {}) {
const boardHits = [];
const upstream = createServer((req, res) => { boardHits.push(`${req.method} ${req.url}`); res.writeHead(200, { 'content-type': 'application/json' }); res.end('{"ok":true}'); });
await new Promise(r => upstream.listen(0, '127.0.0.1', r));
t.after(() => close(upstream));
const board = {
index: async () => { boardHits.push('index'); return { sessions: [] }; },
markSeen: async () => { boardHits.push('markSeen'); return { seen: [] }; },
reply: async () => { boardHits.push('reply'); return { status: 200, body: { delivered: true } }; },
};
const busCalls = [], sends = [], lines = [];
const bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions'].map(v => [v, async arg => { busCalls.push([v, arg]); return [{ verb: v }]; }]));
const state = { answer: async () => ({ id: 'msg-0001' }) };
const send = async args => { sends.push(args); return state.answer(args); };
const web = await startServer({ port: 0, board: `http://127.0.0.1:${upstream.address().port}`, bus, preview, send, log: l => lines.push(l), ...options });
const web = await startServer({ port: 0, board, bus, preview, send, log: l => lines.push(l), ...options });
t.after(() => close(web));
return { port: web.address().port, boardHits, busCalls, sends, lines, state };
}
@@ -81,7 +83,7 @@ test('the CLI with --send and a configured bus sends through the human transport
// No bus host is running, so the send is a definite refusal: no-bus-host, nothing written.
const root = tmp(t, 'webui-send-cli-');
const { env } = fixture(root);
const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', '--public-host', NAME, '--auth-header', HEADER, '--allow-user', ID, '--send'], { env, stdio: ['ignore', 'pipe', 'pipe'] });
const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', '--public-host', NAME, '--auth-header', HEADER, '--allow-user', ID, '--send'], { env, stdio: ['ignore', 'pipe', 'pipe'] });
t.after(() => child.kill());
let out = '';
const port = await new Promise((resolve, reject) => {
@@ -118,6 +120,17 @@ test('with --send every other write is still 403 read-only, and nothing reaches
assert.deepEqual([sends, lines, boardHits, busCalls], [[], [], [], []]);
});
// Row 198 (B4): the bad-JSON refusal moved here from control-board's HTTP tests. The in-process board
// answers 400 before any write, under --send and the public name.
test('a board write with a body that is not JSON is 400 under --send and the public name, and the board runs nothing', async t => {
const { port, boardHits, sends } = await setup(t);
for (const path of ['/api/seen', '/api/reply']) {
const r = await post(port, { path, body: '{not json' });
assert.equal(r.status, 400, path); assert.match(r.body.error, /JSON/, path);
}
assert.deepEqual([boardHits, sends], [[], []]);
});
test('config says send only on the public name with --send on', async t => {
const { port } = await setup(t);
assert.equal((await post(port, { method: 'GET', path: '/api/config', headers: { [HEADER]: ID }, body: undefined })).body.send, true);
+29 -50
View File
@@ -1,24 +1,19 @@
import { leasePort } from '../../../scripts/testing/lane-fixtures.mjs';
import { after as laneAfter } from 'node:test';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createServer, get } from 'node:http';
import { get } from 'node:http';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { startServer, boardURL, isLoopback } from '../src/serve.mjs';
import { startServer, isLoopback } from '../src/serve.mjs';
import { fixture, close } from './fixture.mjs';
const post = (base, path, body, extra = {}) => fetch(base + path, { method: 'POST', headers: { 'content-type': 'application/json', ...extra }, body: typeof body === 'string' ? body : JSON.stringify(body) });
test('loopback host and board origin fail closed', async () => {
test('loopback host fails closed', async () => {
for (const h of ['127.0.0.1', '127.2.3.4', '::1', 'localhost']) assert.equal(isLoopback(h), true);
for (const h of ['0.0.0.0', '192.168.1.1', '::', 'example.com', '']) {
assert.equal(isLoopback(h), false);
await assert.rejects(startServer({ host: h, port: 0 }), /non-loopback/);
}
for (const u of ['https://127.0.0.1', 'http://example.com', 'http://127.0.0.1/path', 'http://user:[email protected]', 'http://127.0.0.1?x', 'http://127.0.0.1#x']) assert.throws(() => boardURL(u));
assert.equal(boardURL('http://localhost:7331'), 'http://127.0.0.1:7331');
assert.equal(boardURL('http://[::1]:7331'), 'http://[::1]:7331');
});
test('real board fixture passes through WebUI; assets and isolated seen/reply work', async () => {
@@ -54,59 +49,43 @@ test('real board fixture passes through WebUI; assets and isolated seen/reply wo
} finally { await f.close(); }
});
test('proxy preserves exact request bytes, status and receipt, rejects forms and malformed JSON, never follows redirect', async () => {
const requests = [];
const upstream = createServer(async (req, res) => {
let body = ''; for await (const c of req) body += c;
requests.push({ url: req.url, method: req.method, body });
if (req.url === '/api/board') { res.writeHead(302, { location: 'http://192.0.2.1/' }); return res.end('{}'); }
if (req.url.startsWith('/api/conversation')) { res.writeHead(404, { 'content-type': 'application/json' }); return res.end('{"error":"fixture unknown <x>","refusal":{"code":"unknown-branch","reconcile":true}}'); }
res.writeHead(409, { 'content-type': 'application/json' }); res.end('{"error":"fixture refusal <unsafe>"}');
});
await new Promise(r => upstream.listen(0, '127.0.0.1', r));
const web = await startServer({ port: 0, board: `http://127.0.0.1:${upstream.address().port}` });
test('in-process board routes: writes refused without --send, reads pass the board result through, a failing read answers an error', async () => {
const f = await fixture();
const web = await startServer({ port: 0, board: f.board });
const base = `http://127.0.0.1:${web.address().port}`;
try {
for (const path of ['/api/reply', '/api/seen']) {
const raw = '{ "agent": "proj/agent1", "text": "hello Ω", "seen": false }';
const res = await post(base, path, raw);
assert.equal(res.status, 409); assert.equal(await res.text(), '{"error":"fixture refusal <unsafe>"}');
assert.deepEqual(requests.at(-1), { url: path, method: 'POST', body: raw });
// Without --send both writes refuse before the board runs.
for (const path of ['/api/seen', '/api/reply']) {
const res = await post(base, path, { agent: 'proj/agent1', text: 'blocked', seen: true });
assert.equal(res.status, 403, path); assert.equal((await res.json()).sent, false);
}
for (const raw of ['{', 'null', '[]', JSON.stringify({ text: 'x'.repeat(5000) })]) {
try { assert.equal((await post(base, '/api/reply', raw)).status, 400); } catch (err) { if (raw.length < 4096) throw err; }
}
assert.equal((await post(base, '/api/reply', '{}', { 'content-type': 'text/plain' })).status, 400);
assert.equal(requests.length, 2);
assert.equal((await fetch(base + '/api/board')).status, 502);
assert.equal(requests.length, 3);
// CHAT-02 conversation routes: GET only, the query passes unchanged, the board's status and body come back as sent.
for (const path of ['/api/conversations', '/api/conversation?id=pi-0a&branch=b.e5&cursor=c-1', '/api/conversation?id=x&id=y&unknown=%3C']) {
assert.equal(f.captures.length, 0, 'the board never ran a write');
assert.equal((await fetch(base + '/api/reply')).status, 405);
assert.equal((await fetch(base + '/api/board', { method: 'OPTIONS' })).status, 405);
// Reads: the board's own status and body come back as the board sent them.
for (const path of ['/api/conversations', '/api/conversation?id=x']) {
const res = await fetch(base + path);
assert.equal(res.status, 404, path);
assert.equal(await res.text(), '{"error":"fixture unknown <x>","refusal":{"code":"unknown-branch","reconcile":true}}');
assert.deepEqual(requests.at(-1), { url: path, method: 'GET', body: '' });
const want = await f.board.conversation(new URL(path, 'http://localhost'));
assert.equal(res.status, want.status, path);
assert.deepEqual(await res.json(), want.body, path);
}
const before = requests.length;
assert.equal((await post(base, '/api/conversation?id=x', '{}')).status, 405);
assert.equal((await post(base, '/api/conversations', '{}')).status, 405);
assert.equal((await fetch(base + '/api/conversation?id=x', { headers: { origin: 'https://evil.example' } })).status, 403);
assert.equal(requests.length, before, 'refused before the board');
// Only the conversation routes carry a query upstream.
await fetch(base + '/api/board?x=1');
assert.equal(requests.at(-1).url, '/api/board');
} finally { await close(web); await close(upstream); }
// A failing board read is an error answer, not a crash.
f.board.setDown(true);
assert.ok((await fetch(base + '/api/board')).status >= 400);
} finally { await close(web); await f.close(); }
});
test('unreachable board reports URL; CLI rejects unsupported options', async () => {
const url = `http://127.0.0.1:${await leasePort({after:laneAfter}, {closed:true})}`;
const web = await startServer({ port: 0, board: url, timeout: 100 });
test('no board configured: the board routes answer 503; CLI rejects retired --board and other bad options', async () => {
const web = await startServer({ port: 0, board: null });
try {
const res = await fetch(`http://127.0.0.1:${web.address().port}/api/board`);
assert.equal(res.status, 502); const result = await res.json(); assert.equal(result.board, url); assert.ok(result.error.includes(url));
const base = `http://127.0.0.1:${web.address().port}`;
assert.equal((await fetch(base + '/api/board')).status, 503);
assert.equal((await fetch(base + '/api/conversations')).status, 503);
} finally { await close(web); }
for (const args of [[], ['serve', '--port', 'abc'], ['serve', '--host', '0.0.0.0'], ['serve', '--board', 'http://example.com']]) {
const result = spawnSync(process.execPath, [fileURLToPath(new URL('../src/cli.mjs', import.meta.url)), ...args], { encoding: 'utf8' });
assert.equal(result.status, 2); assert.match(result.stderr, /^refused:/);
}
});
+6 -5
View File
@@ -217,9 +217,9 @@ test('render prints the unit without the preview flags', async (t) => {
test('render carries every serve flag, the preview set included', async (t) => {
const dir = tmp(t);
const r = await run(dir, ['render', '--port', '7340', '--business', 'mosaic.dev_1', '--board', 'http://127.0.0.1:7331', ...PREVIEW]);
const r = await run(dir, ['render', '--port', '7340', '--business', 'mosaic.dev_1', ...PREVIEW]);
assert.equal(r.status, 0, r.stderr);
assert.equal(execStart(r.stdout), `ExecStart=${process.execPath} ${REPO}/packages/webui/src/cli.mjs serve --port 7340 --business mosaic.dev_1 --board http://127.0.0.1:7331 --public-host console.example.com --auth-header X-authentik-uid --allow-user ${UID}`);
assert.equal(execStart(r.stdout), `ExecStart=${process.execPath} ${REPO}/packages/webui/src/cli.mjs serve --port 7340 --business mosaic.dev_1 --public-host console.example.com --auth-header X-authentik-uid --allow-user ${UID}`);
});
test('a partial preview set is refused', async (t) => {
@@ -240,9 +240,9 @@ test('a value the unit cannot carry is refused, for every flag', async (t) => {
if (i >= 0) { preview[i + 1] = value; return preview; }
return [flag, value];
};
for (const flag of ['--business', '--board', '--public-host', '--auth-header', '--allow-user']) {
for (const flag of ['--business', '--public-host', '--auth-header', '--allow-user']) {
for (const bad of [' x', '%n', '\n', '$HOME', '"', '\\', '|', '&']) {
const value = flag === '--board' ? `http://127.0.0.1:7331${bad}` : `a${bad}b`;
const value = `a${bad}b`;
const r = await run(dir, ['render', ...set(flag, value)]);
assert.equal(r.status, 1, `${flag} ${JSON.stringify(value)}`);
assert.equal(r.stdout, '', `${flag} ${JSON.stringify(value)}`);
@@ -252,6 +252,7 @@ test('a value the unit cannot carry is refused, for every flag', async (t) => {
const r = await run(dir, ['render', '--port', port]);
assert.equal(r.status, 1, `port ${JSON.stringify(port)}`);
}
// --board is retired (row 198): the unit has no board flag, so a render carrying one is refused.
for (const args of [['--business', 'Mosaic'], ['--business', '-x'], ['--business', 'a'.repeat(65)], ['--board', 'https://127.0.0.1:7331'], ['--board', '127.0.0.1:7331'], set('--public-host', 'console.example.com:443'), set('--public-host', 'https://console.example.com'), set('--auth-header', 'X_uid')]) {
const r = await run(dir, ['render', ...args]);
assert.equal(r.status, 1, args.join(' '));
@@ -386,7 +387,7 @@ test('without --send, render is the template with its placeholders filled, byte
[['--port', '7340'], '--port 7340'],
[['--business', 'mosaic'], '--port 7332 --business mosaic'],
[PREVIEW, `--port 7332 ${PREVIEW_FLAGS}`],
[['--port', '7340', '--business', 'mosaic.dev_1', '--board', 'http://127.0.0.1:7331', ...PREVIEW], `--port 7340 --business mosaic.dev_1 --board http://127.0.0.1:7331 ${PREVIEW_FLAGS}`],
[['--port', '7340', '--business', 'mosaic.dev_1', ...PREVIEW], `--port 7340 --business mosaic.dev_1 ${PREVIEW_FLAGS}`],
]) {
const r = await run(dir, ['render', ...args]);
assert.equal(r.status, 0, r.stderr);
+4 -4
View File
@@ -6,7 +6,7 @@ import {startServer} from '../src/serve.mjs';
import {close} from './fixture.mjs';
const launch={business:'acme',revision:'a'.repeat(64),models:{opus:{provider:'anthropic',model:'claude-opus-5-5'},sonnet:{provider:'anthropic',model:'claude-sonnet-5-5'}},limits:{models:{anthropic:{'claude-opus-5-5':4,'claude-sonnet-5-5':4}}},instances:{pm:{modelKey:'opus'},cto:{modelKey:'opus'},coder:{modelKey:'sonnet'},reviewer:{modelKey:'opus'}},usage:{total:1,providers:{anthropic:1},models:{anthropic:{'claude-opus-5-5':1}}}};
test('Settings form shows every scope and held role; gated save, exact payload, retained draft and no automatic retry', {timeout:90000},async()=>{
const bus={inbox:async()=>[],tasks:async()=>[],agents:async()=>[],messages:async()=>({messages:[],unread:0})};const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{binding:'fixture'},launch})}}),b=await browser();
const bus={inbox:async()=>[],tasks:async()=>[],agents:async()=>[],messages:async()=>({messages:[],unread:0})};const web=await startServer({port:0,board:null,bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{binding:'fixture'},launch})}}),b=await browser();
try{
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`window.posts=[];window.enabled=new URL(location.href).searchParams.has('send');window.mode='success';window.fixture=${JSON.stringify(launch)};const original=window.fetch;window.fetch=async(url,options={})=>{if(url==='/api/config')return new Response(JSON.stringify({send:window.enabled}));if(url==='/api/settings'&&options.method==='POST'){posts.push(JSON.parse(options.body));if(mode==='unknown')throw Error('fixture lost answer');if(mode==='refused')return new Response(JSON.stringify({message:'fixture conflict',sent:false}),{status:409});return new Response(JSON.stringify({...fixture,...posts.at(-1),revision:'b'.repeat(64)}));}return original(url,options);};`});
await b.navigate(`http://127.0.0.1:${web.address().port}/#/settings`);
@@ -28,7 +28,7 @@ test('Settings form shows every scope and held role; gated save, exact payload,
test('Q169 Assistant section: gated, changed fields only, name and stop posts, retained draft and no automatic retry',{timeout:90000},async()=>{
const withAssistant={...launch,assistants:{assistant:{definition:'assistant',vars:{model:'claude-sonnet-5-5'},tracker:null}}};
const bus={inbox:async()=>[],tasks:async()=>[],agents:async()=>[],messages:async()=>({messages:[],unread:0}),names:async()=>[{instance:'assistant',name:'piper',at:'2026-10-18T00:00:00.000Z'}]};
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{binding:'fixture'},launch:withAssistant})}}),b=await browser();
const web=await startServer({port:0,board:null,bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{binding:'fixture'},launch:withAssistant})}}),b=await browser();
try{
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`window.posts=[];window.enabled=new URL(location.href).searchParams.has('send');window.mode='success';window.fixture=${JSON.stringify(withAssistant)};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'){const body=JSON.parse(options.body);posts.push([url,body]);if(mode==='refused')return new Response(JSON.stringify({message:'fixture conflict',sent:false}),{status:409});if(url==='/api/name')return new Response(JSON.stringify({...body,at:'2026-10-18T01:00:00.000Z'}));if(url==='/api/assistant-stop')return new Response(JSON.stringify({stopped:[{thread:'t-1',run:'a-run'},{thread:'t-2',run:'b-run'}]}));const a=body.assistants.assistant;window.saved={...fixture,revision:'b'.repeat(64),assistants:{assistant:{...fixture.assistants.assistant,...a}}};return new Response(JSON.stringify(window.saved));}if(url==='/api/settings'&&window.saved){const read=await (await original(url,options)).json();return new Response(JSON.stringify({...read,launch:window.saved}));}return original(url,options);};`});
const port=web.address().port, q=sel=>`document.querySelector(${JSON.stringify(sel)})`;
@@ -56,7 +56,7 @@ test('Q169 Assistant section: gated, changed fields only, name and stop posts, r
test('Row 167 a refused names read gives the refused usage page; another names failure keeps the Assistant section',{timeout:90000},async()=>{
const withAssistant={...launch,assistants:{assistant:{definition:'assistant',vars:{model:'claude-sonnet-5-5'},tracker:null}}};
const bus={inbox:async()=>[],tasks:async()=>[],agents:async()=>[],messages:async()=>({messages:[],unread:0}),names:async()=>[{instance:'assistant',name:'piper',at:'2026-10-18T00:00:00.000Z'}]};
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{binding:'fixture'},launch:withAssistant})}}),b=await browser();
const web=await startServer({port:0,board:null,bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{binding:'fixture'},launch:withAssistant})}}),b=await browser();
try{
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`const query=new URL(location.href).searchParams,refuse=query.get('refuse'),fail=query.get('fail'),original=window.fetch;window.fetch=async(url,options={})=>{if(refuse&&url.startsWith('/api/bus/'+refuse))return new Response(JSON.stringify({error:'human-required',message:'fixture refusal'}),{status:403});if(fail&&url.startsWith('/api/bus/'+fail))return new Response(JSON.stringify({error:'bus-unreachable',message:'fixture failure'}),{status:503});return original(url,options);};`});
const port=web.address().port;
@@ -79,7 +79,7 @@ const oneSnapshot=`window.posts=[];window.saved=null;const original=window.fetch
async function snapshotPage(){
const withAssistant={...launch,assistants:{assistant:{definition:'assistant',vars:{model:'claude-sonnet-5-5'},tracker:null}}};
const bus={inbox:async()=>[],tasks:async()=>[],agents:async()=>[],messages:async()=>({messages:[],unread:0}),names:async()=>[{instance:'assistant',name:'piper',at:'2026-10-18T00:00:00.000Z'}]};
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{binding:'fixture'},launch:withAssistant})}}),b=await browser();
const web=await startServer({port:0,board:null,bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{binding:'fixture'},launch:withAssistant})}}),b=await browser();
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`window.fixture=${JSON.stringify(withAssistant)};${oneSnapshot}`});
const q=sel=>`document.querySelector(${JSON.stringify(sel)})`;
const wait=expr=>b.evaluate(`(async()=>{for(let n=0;n<200;n++){if(${expr})return true;await new Promise(r=>setTimeout(r,30));}throw Error('wait failed: '+${JSON.stringify(expr)});})()`);
+46 -63
View File
@@ -7,13 +7,14 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { createServer } from 'node:http';
import { mkdirSync, readFileSync, readdirSync } 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 { BusReadError } from '../src/bus.mjs';
import { ReadError } from '../src/reads.mjs';
import { sendOnConfig } from './board-route.mjs';
const pub = new URL('../src/public/', import.meta.url), design = new URL('../../../docs/design/', import.meta.url);
const read = (name, base = pub) => readFileSync(new URL(name, base));
@@ -68,31 +69,41 @@ test('shipped strings are unchanged, and the web keeps its write boundary', () =
assert.equal(read('icons.svg').toString().includes('<script'), false);
});
// The Console's board, in-process (row 198). Each mode sets what the next read answers. A thrown
// ReadError answers its own code; any other throw answers invalid-response (consoleRead's mapping).
// A scan that fails answers the same as a board that is down: the page keeps or drops data as before.
function board() {
let reply = { status: 200, body: { sessions: [], counts: {}, generatedAt: new Date().toISOString() } };
let mode = 'empty', gate = null;
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 };
// While the board answers, agent1 has one conversation with one message. A failure or refusal answers every path.
// While the board answers, agent1 has one conversation with one message. A failure answers every path.
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 };
let gate = null;
const server = createServer(async (req, res) => {
for await (const chunk of req) { void chunk; }
await gate;
const path = new URL(req.url, 'http://localhost').pathname;
const body = reply.status !== 200 ? reply.body : path === '/api/conversations' ? catalogue : path === '/api/conversation' ? page : reply.body;
res.writeHead(reply.status, { 'content-type': 'application/json' }); res.end(JSON.stringify(body));
});
const answer = () => {
if (mode === 'fail') throw new Error('board scan failed');
if (mode === 'unavailable') throw new ReadError('read-failed', 'board unavailable');
if (mode === 'one') return { sessions: [one, { ...one, agent: 'agent2', state: 'working', waitingOnYou: false }, { ...one, agent: 'agent3', state: 'idle', waitingOnYou: false, seen: true }], counts: { waiting: 1, working: 1 }, generatedAt: new Date().toISOString() };
if (mode === 'registered') return { sessions: [{ ...one, registered: { alive: true, tmux: { session: 'fixture' } } }], counts: { waiting: 1 }, generatedAt: new Date().toISOString() };
return { sessions: [], counts: {}, generatedAt: new Date().toISOString() };
};
const set = next => () => { mode = next; };
return {
server,
// Holds every reply until the returned function is called.
board: {
// Waits while held, so the page shows its loading state.
index: async () => { await gate; return answer(); },
// The catalogue and the page are sync, as the Console calls them; a failing mode throws, which the route answers 500.
conversation: url => {
if (mode === 'fail' || mode === 'unavailable') throw new Error('conversation read failed');
return { status: 200, body: url.pathname === '/api/conversations' ? catalogue : page };
},
},
// Holds every board read until the returned function is called.
hold: () => { let go; gate = new Promise(r => { go = r; }); return () => { gate = null; go(); }; },
empty: () => { reply = { status: 200, body: { sessions: [], counts: {}, generatedAt: new Date().toISOString() } }; },
one: () => { reply = { status: 200, body: { sessions: [one, { ...one, agent: 'agent2', state: 'working', waitingOnYou: false }, { ...one, agent: 'agent3', state: 'idle', waitingOnYou: false, seen: true }], counts: { waiting: 1, working: 1 }, generatedAt: new Date().toISOString() } }; },
fail: () => { reply = { status: 500, body: { error: 'board scan failed' } }; },
refuse: () => { reply = { status: 403, body: { error: 'board-refused' } }; },
empty: set('empty'),
one: set('one'),
fail: set('fail'),
// One seat a reply can reach: registered, alive, with a tmux session.
registered: () => { reply = { status: 200, body: { sessions: [{ ...one, registered: { alive: true, tmux: { session: 'fixture' } } }], counts: { waiting: 1 }, generatedAt: new Date().toISOString() } }; },
unavailable: () => { reply = { status: 503, body: { error: 'board unavailable' } }; },
registered: set('registered'),
unavailable: set('unavailable'),
};
}
@@ -106,10 +117,11 @@ function stubBus() {
}
test('shell in a browser: System default, seeded states, 400px, focus and the Ctrl+K palette', { timeout: 180000 }, async () => {
const brd = board(); await new Promise(r => brd.server.listen(0, '127.0.0.1', r));
const brd = board();
const s = stubBus();
const web = await startServer({ port: 0, board: `http://127.0.0.1:${brd.server.address().port}`, bus: s.bus });
const web = await startServer({ port: 0, board: brd.board, bus: s.bus });
const base = `http://127.0.0.1:${web.address().port}/`, b = await browser();
await sendOnConfig(b, base);
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`)); };
@@ -170,39 +182,18 @@ test('shell in a browser: System default, seeded states, 400px, focus and the Ct
assert.equal(await b.evaluate('getComputedStyle(document.querySelector("table.sessions .st-waiting"),"::before").content'), '"◐" / ""');
brd.fail(); await refresh();
assert.equal(await b.evaluate('document.querySelector("#error").className'), 'banner err');
assert.match(await text('#error'), /board scan failed.*Showing last known data\. Scanned /);
assert.match(await text('#error'), /invalid-response.*Showing last known data\. Scanned /);
assert.equal(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length'), 2);
assert.match(await text('#fresh-board'), /stale: the last refresh failed/);
await flat(); await shot('board-stale-400');
// 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.
// A failed first read on a fresh load: the board is drawn empty and the footer says what the banner says.
// With nothing focused, focus stays put. (Before row 198 this followed a refusal; the refusal is gone.)
brd.unavailable(); await b.navigate(base); await wait('!document.querySelector("#error").hidden');
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.match(await text('#error'), /read-failed.*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);
@@ -210,7 +201,7 @@ test('shell in a browser: System default, seeded states, 400px, focus and the Ct
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');
await flat(); await shot('board-unavailable-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');
@@ -219,8 +210,7 @@ test('shell in a browser: System default, seeded states, 400px, focus and the Ct
// 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.
// A conversation is opened from a registered session, and a failed catalogue read clears the picker.
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")');
@@ -232,15 +222,8 @@ test('shell in a browser: System default, seeded states, 400px, focus and the Ct
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');
// Back to the board, closed as the refusal used to close it.
await b.evaluate('document.getElementById("conv-back").click()'); await wait('document.querySelector("#conversation").hidden');
brd.one(); await refresh(); await wait('document.querySelectorAll("table.sessions tbody tr").length===2');
// Keyboard on the board: down moves between rows, Enter opens, Esc closes and returns focus.
@@ -410,21 +393,21 @@ test('shell in a browser: System default, seeded states, 400px, focus and the Ct
assert.ok(scrollH > innerH, `the board page is taller than ${innerH}px`);
assert.deepEqual([tree[0], tree[1]], [bar[1], foot[0]]);
assert.deepEqual(await b.evaluate('window.errors'), []);
} finally { await b.close(); await close(web); await close(brd.server); }
} finally { await b.close(); await close(web); }
});
// Every restyled view in its five states: loading (a held read), normal, empty, a failed read
// over kept data, and a refusal. Empty is seeded per view above; here each view is read once
// with rows, then made to fail, then refused, at 400px.
test('five states on Board, Inbox, Tasks, Agents and Trail', { timeout: 180000 }, async () => {
const brd = board(); await new Promise(r => brd.server.listen(0, '127.0.0.1', r));
const brd = board();
const s = stubBus();
const at = new Date().toISOString(), id = 'd'.repeat(32);
s.rows.inbox = [{ id, question: 'Push it?', class: 'gated', action: 'git.push.protected', target: 'refactor', blocking: true, options: [{ key: 'yes', text: 'Yes' }], recommendation: 'yes', at, raised_by: 'coder' }];
s.rows.trail = [{ table: 'decisions', seq: 1, at, id, class: 'gated', action: 'git.push.protected', route_to: 'human', question: 'Push it?', options: [{ key: 'yes', text: 'Yes' }], recommendation: 'yes', blocking: 1, raised_by_role: 'coder', raised_by_run: 'coder-run' }];
s.rows.tasks = [{ task_ref: 'vikunja:32/7', fields: { title: 'Kept task', bucket: 2, done: false, assignees: [] }, source: 'self' }, { task_ref: 'vikunja:32/8', fields: { title: 'Second task', bucket: 2, done: false, assignees: [] }, source: 'self' }];
s.rows.agents = [{ role: 'coder', holder_run: 'coder-run', harness: 'pi', address: 'coder@bus', at }];
const web = await startServer({ port: 0, board: `http://127.0.0.1:${brd.server.address().port}`, bus: s.bus });
const web = await startServer({ port: 0, board: brd.board, bus: s.bus });
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 });
@@ -525,5 +508,5 @@ test('five states on Board, Inbox, Tasks, Agents and Trail', { timeout: 180000 }
assert.deepEqual(await b.evaluate('window.errors'), []);
assert.deepEqual(await b.evaluate('window.requests.filter(([m])=>m!=="GET")'), []);
} finally { await b.close(); await close(web); await close(brd.server); }
} finally { await b.close(); await close(web); }
});
+2 -2
View File
@@ -30,7 +30,7 @@ const stub = `(() => {
window.__reqs.push(path);
if (window.__signIn.includes(path)) return answer(401, { error: 'sign-in required' });
if (window.__other.includes(path)) return answer(401, { error: 'unauthenticated' });
if (path === '/api/config' && /sso/.test(location.search)) return answer(200, { ...(await (await real(url, init)).json()), sso: true });
if (path === '/api/config') return answer(200, { ...(await (await real(url, init)).json()), send: true, ...(/sso/.test(location.search) ? { sso: true } : {}) });
return real(url, init);
};
})();`;
@@ -42,7 +42,7 @@ async function setup() {
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: true });
const web = await startServer({ port: 0, board: f.boardURL, bus }), base = `http://127.0.0.1:${web.address().port}/`;
const web = await startServer({ port: 0, board: f.board, bus }), base = `http://127.0.0.1:${web.address().port}/`;
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: stub });
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)})})()`);
+3 -3
View File
@@ -60,7 +60,7 @@ async function setup(t, { issuers = [{ name: 'a' }], provider = {}, before = nul
t.after(() => sso.close());
const sends = [];
const bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions'].map(v => [v, async () => [{ verb: v }]]));
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus, sso, send: async args => { sends.push(args); return { id: 'msg-0001' }; }, log, ...server });
const web = await startServer({ port: 0, board: null, bus, sso, send: async args => { sends.push(args); return { id: 'msg-0001' }; }, log, ...server });
t.after(() => close(web));
return { op, iss, sso, port: web.address().port, lines, sends, config, bus };
}
@@ -389,7 +389,7 @@ test('12: under --sso the tester listener refuses each kind of sign-in, has no /
const env = await setup(t);
const dataRoot = mkdtempSync(join(tmpdir(),'g196-sso-'));
t.after(()=>rmSync(dataRoot,{recursive:true,force:true}));
const tester = await startTesterSocket({ dataRoot, board: 'http://127.0.0.1:9', bus: env.bus, log: () => {} });
const tester = await startTesterSocket({ dataRoot, board: null, bus: env.bus, log: () => {} });
t.after(() => close(tester));
const callTester = options => testerRequest({dataRoot,...options});
const { session, g, begun } = await signIn(env);
@@ -455,7 +455,7 @@ test('13: the CSP lets the Sign out form reach the provider\'s end-session origi
const env = await setup(t);
const r = await call(env.port, { path: '/healthz', host: `127.0.0.1:${env.port}` });
assert.match(r.headers['content-security-policy'], new RegExp(`; form-action 'self' ${env.op.base.replace(/[.]/g, '\\.')}$`));
const plain = await startServer({ port: 0, board: 'http://127.0.0.1:9' });
const plain = await startServer({ port: 0, board: null });
t.after(() => close(plain));
const p = await call(plain.address().port, { path: '/healthz', host: `127.0.0.1:${plain.address().port}` });
assert.match(p.headers['content-security-policy'], /; form-action 'self'$/);
+1 -1
View File
@@ -36,7 +36,7 @@ test('CLI tester listener binds a private Unix socket and spawns TESTER_CLI for
const hook = join(f.root, 'spawn-hook.mjs');
writeFileSync(log, '');
writeFileSync(hook, `import cp from 'node:child_process';import {syncBuiltinESMExports} from 'node:module';import {appendFileSync} from 'node:fs';const original=cp.spawn;cp.spawn=function(cmd,args,options){if(Array.isArray(args)&&args[0]?.endsWith('-cli.mjs'))appendFileSync(${JSON.stringify(log)},JSON.stringify({cli:args[0]})+'\\n');return original.call(this,cmd,args,options);};syncBuiltinESMExports();`);
const child = spawn(process.execPath, [CLI, 'serve', '--board', 'http://127.0.0.1:9', '--port', '0', '--tester-socket', '--business', 'acme', '--public-host', 'console.example', '--auth-header', 'x-user', '--allow-user', 'jason'], { env: { ...f.f.env, NODE_OPTIONS: `--import=${hook}` }, stdio: ['ignore', 'pipe', 'pipe'] });
const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', '--tester-socket', '--business', 'acme', '--public-host', 'console.example', '--auth-header', 'x-user', '--allow-user', 'jason'], { env: { ...f.f.env, NODE_OPTIONS: `--import=${hook}` }, stdio: ['ignore', 'pipe', 'pipe'] });
let output = '', error = '';
child.stdout.on('data', c => output += c); child.stderr.on('data', c => error += c);
t.after(async () => { if (child.exitCode === null) { child.kill('SIGTERM'); await once(child, 'close'); } });
+2 -2
View File
@@ -53,7 +53,7 @@ export async function testerFixture(t, { enabled = true } = {}) {
};
const bus = { ...views({ call }), names: () => call('names'), instances: () => call('instances'), routes: () => call('routes') };
const lines = [];
const web = await startTesterSocket({ dataRoot: f.dataRoot, board: 'http://127.0.0.1:9', tester: true, testerAuthHeader: 'x-user', bus,
const web = await startTesterSocket({ dataRoot: f.dataRoot, board: null, tester: true, testerAuthHeader: 'x-user', bus,
reads: { settings: async () => ({ business: 'acme', release: 'fixture', notifier: {}, launch: await call('limits') }) },
projectFiles: args => call('project.files', args), projectFile: args => call('project.file', args),
instancesWrite: args => call('instances.set', args), routesWrite: args => call('routes.set', args), nameWrite: args => call('name.set', args),
@@ -90,7 +90,7 @@ export async function testerChat(t, { enabled = true } = {}) {
} catch (err) { if (err instanceof BusError) throw new BusReadError(err.code); throw err; }
} });
const lines = [];
const web = await startTesterSocket({ dataRoot: root, board: 'http://127.0.0.1:9', tester: true, testerAuthHeader: 'x-user', chat, log: line => lines.push(line) });
const web = await startTesterSocket({ dataRoot: root, board: null, tester: true, testerAuthHeader: 'x-user', chat, log: line => lines.push(line) });
t.after(() => close(web));
return { store, broker, human, calls, web, lines };
}
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { request, createServer } from 'node:http';
import { request } from 'node:http';
import { startServer } from '../src/serve.mjs';
import { startTesterSocket, TESTER_ORIGIN } from '../src/tester-socket.mjs';
import { tmp } from '../../cli/tests/helpers.mjs';
@@ -24,9 +24,9 @@ export const post = (web, path, data, headers = {}) => http(web, path, { method:
test('main listener refuses x-mosaic-tester before any board or bus read for every Host', async t => {
let hits = 0;
const upstream = createServer((q, r) => { hits++; r.end('{}'); });
await new Promise(r => upstream.listen(0, '127.0.0.1', r)); t.after(() => close(upstream));
const web = await startServer({ port: 0, board: `http://127.0.0.1:${upstream.address().port}`, preview: { host: 'console.example', header: 'x-user', user: 'jason' }, bus: { agents: async () => { hits++; return []; } } });
// The board runs in-process (row 198): a read that reaches it counts as a hit.
const board = { index: async () => { hits++; return { sessions: [] }; } };
const web = await startServer({ port: 0, board, preview: { host: 'console.example', header: 'x-user', user: 'jason' }, bus: { agents: async () => { hits++; return []; } } });
t.after(() => close(web));
for (const host of [`127.0.0.1:${web.address().port}`, 'console.example', 'other.example'])
for (const path of ['/api/board', '/api/bus/agents']) {
@@ -91,7 +91,7 @@ test('tester-off and tester-required reads return HTTP 403', async t => {
const s = await testerFixture(t, { enabled: false });
const r = await http(s.web, '/api/bus/names');
assert.equal(r.status, 403); assert.equal(r.body.error, 'tester-off');
const required = await startTesterSocket({ dataRoot: tmp(t,'tester-required-'), board: 'http://127.0.0.1:9', bus: { names: async () => { throw new BusReadError('tester-required'); } } });
const required = await startTesterSocket({ dataRoot: tmp(t,'tester-required-'), board: null, bus: { names: async () => { throw new BusReadError('tester-required'); } } });
t.after(() => close(required));
const missing = await http(required, '/api/bus/names');
assert.equal(missing.status, 403); assert.equal(missing.body.error, 'tester-required');
@@ -172,7 +172,7 @@ test('row 177 W3: the human thread through the tester chat is thread-not-owned,
test('row 177 W4: the main listener refuses x-mosaic-tester on chat reads and writes before the chat', async t => {
const calls = [];
const chat = Object.fromEntries(['projects', 'createProject', 'threads', 'createThread', 'messages', 'send', 'open'].map(k => [k, async () => { calls.push(k); return []; }]));
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', preview: { host: 'console.example', header: 'x-user', user: 'jason' }, send: async () => ({}), chat, log: () => {} });
const web = await startServer({ port: 0, board: null, preview: { host: 'console.example', header: 'x-user', user: 'jason' }, send: async () => ({}), chat, log: () => {} });
t.after(() => close(web));
const signed = { host: 'console.example', 'x-user': 'jason', origin: 'https://console.example', 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' };
for (const [method, path, data] of [['GET', '/api/chat/threads'], ['POST', '/api/chat/threads/20000000-0000-4000-8000-000000000002/messages', { body: 'x' }]]) {
+1 -1
View File
@@ -12,7 +12,7 @@ async function refused(options, pattern) {
}
test('private tester HTTP serves on 0600 socket in 0700 parent and fixed origin',async t=>{
const root=mkdtempSync(join(tmpdir(),'g196-w-'));t.after(()=>rmSync(root,{recursive:true,force:true}));
const web=await startTesterSocket({dataRoot:root,board:'http://127.0.0.1:9'});t.after(()=>new Promise(r=>web.close(r)));
const web=await startTesterSocket({dataRoot:root,board:null});t.after(()=>new Promise(r=>web.close(r)));
assert.equal(web.address(),testerSocketPath(root));
assert.equal(lstatSync(join(root,'console')).mode&0o777,0o700);
assert.equal(lstatSync(web.address()).mode&0o777,0o600);
@@ -26,7 +26,7 @@ async function setup(t) {
return typeof answer === 'function' ? answer() : answer;
};
const bus = { agents: read('agents', [role]), inbox: read('inbox', []), messages: read('messages', { messages: [], unread: 0 }), 'thread.claims': read('thread.claims', () => state.rows) };
const web = await startServer({ port: 0, board: f.boardURL, bus }); t.after(() => close(web));
const web = await startServer({ port: 0, board: f.board, bus }); t.after(() => close(web));
const b = await browser(); t.after(() => b.close());
const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expression})return true;await new Promise(r=>setTimeout(r,30));}throw Error(${JSON.stringify(expression)})})()`);
await b.navigate(`http://127.0.0.1:${web.address().port}/#/agents`);
@@ -100,7 +100,7 @@ test('row278 browser sign-out removes claims and prevents reuse of cached answer
test('row278 tester browser keeps role rows without requesting the owner claims section', async t => {
const dataRoot = tmp(t, 'claims-view-'), calls = [], reads = [];
const web = await startTesterSocket({ dataRoot, board: 'http://127.0.0.1:9', bus: {
const web = await startTesterSocket({ dataRoot, board: null, bus: {
agents: async () => [{ role: 'coder', holder_run: 'role-run', harness: 'pi', at: AT }],
messages: async () => ({ messages: [], unread: 0 }),
'thread.claims': async () => { calls.push('thread.claims'); return [claim]; },
+1 -1
View File
@@ -17,7 +17,7 @@ const rows = [{ thread: '11111111-1111-4111-8111-111111111111', instance: 'assis
test('row278 HTTP read returns held claims without send and refuses arguments before the bus', async t => {
const calls = [];
const bus = { 'thread.claims': async arg => { calls.push(arg); return rows; } };
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus });
const web = await startServer({ port: 0, board: null, bus });
t.after(() => close(web));
const url = `http://127.0.0.1:${web.address().port}/api/bus/thread.claims`;
const result = await fetch(url);
+1 -1
View File
@@ -8,7 +8,7 @@ test('REQ-LIMIT-4 Settings shows all token kinds and unknown turns beside every
const hostile = '<img src=x onerror="window.injected=true">';
const usage = { total: count, byRun: [{ run: 'run1', ...count }], byInstance: [{ instance: hostile, ...count }],
byModel: [{ provider: 'provider', model: 'model', ...count }], byProvider: [{ provider: 'provider', ...count }], byAccount: [{ account: null, ...count }] };
const server = await startServer({ port: 0, board: 'http://127.0.0.1:9',
const server = await startServer({ port: 0, board: null,
bus: { messages: async () => ({ messages: [], unread: 0 }), usage: async () => usage },
reads: { settings: async () => ({ business: 'demo', notifier: {}, launch: { refused: 'not-configured' } }) },
});
@@ -99,7 +99,7 @@ async function setup(t) {
reads: Object.fromEntries(['settings', 'business', 'busStatus', 'queue', 'row'].map(v => [v, stub(`reads.${v}`)])),
chat: Object.fromEntries(['projects', 'threads', 'messages', 'send', 'open', 'createProject', 'createThread'].map(v => [v, stub(`chat.${v}`)])),
};
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso, log: l => lines.push(l), ...options, userOptions });
const web = await startServer({ port: 0, board: null, sso, log: l => lines.push(l), ...options, userOptions });
t.after(() => close(web));
return { op, iss, owner, user, opened, lines, port: web.address().port };
}
+2 -2
View File
@@ -64,7 +64,7 @@ test('Queue, Business and Settings in their five states, with no move button', {
const s = stubReads();
// A bus that answers, so the palette's own inbox and tasks reads succeed and forget nothing.
const bus = { inbox: async () => [], tasks: async () => [], agents: async () => [], trail: async () => [] };
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', reads: s.reads, bus });
const web = await startServer({ port: 0, board: null, reads: s.reads, bus });
const b = await browser();
try {
const { wait, text, count, flat, refresh, go } = await page(b, `http://127.0.0.1:${web.address().port}/`);
@@ -204,7 +204,7 @@ test('Queue, Business and Settings in their five states, with no move button', {
test('seeded secrets, Discord ids and paths reach no response and no page; business refusals in the module\'s words', { timeout: 180000 }, async t => {
const s = seeded(t), repo = queueRepo(t);
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', reads: consoleReads({ root: repo.root, env: repo.env, system: s.system, configDir: s.configDir, rolesDir: REPO_ROLES, business: 'acme' }) });
const web = await startServer({ port: 0, board: null, reads: consoleReads({ root: repo.root, env: repo.env, system: s.system, configDir: s.configDir, rolesDir: REPO_ROLES, business: 'acme' }) });
const b = await browser();
try {
const { wait, text, go } = await page(b, `http://127.0.0.1:${web.address().port}/`);
+2
View File
@@ -34,6 +34,8 @@ COPY_PATHS=(
packages/seat/src packages/tasks/src packages/tasks/fields.json packages/tasks/requirements.json
packages/processing-control
packages/webui/src packages/webui/README.md packages/cli/README.md
# Row 198: the board runs in-process inside the Console, and its replies go through tools/tmux/agent-send.sh.
packages/control-board/src tools/tmux
packages/queue/src packages/library/src packages/library/bin
# Whole directory: every in-repo plug-in module ships with the copy (row 131).
packages/plugins
+21 -1
View File
@@ -809,6 +809,21 @@ console_for_target() {
local cli="$COPIES/$TO/packages/webui/src/cli.mjs"
local -a without_tester=()
sso_base
# Row 198 (design item 8, Sage sage-2834 ruling a, sage-2837): --board is dropped for every op.
# webui-service.sh install refuses the flag, so a kept flag fails at install-console, on a rollback
# and on an activate to a pre-198 copy alike. The reason follows the target: a target that parses
# --board is pre-198 (7331 is retired); one that does not runs the board in the Console. Each drop
# is named in the plan and the steps. The check reads the target's own cli.mjs, as --tester-port does.
DROP_BOARD="" DROP_BOARD_WHY=""
local -a kept=()
for ((i = 0; i < ${#BASE_CONSOLE[@]}; i++)); do
if [ "${BASE_CONSOLE[i]}" != --board ]; then kept+=("${BASE_CONSOLE[i]}"); continue; fi
i=$((i + 1))
DROP_BOARD="--board ${BASE_CONSOLE[i]:-}"
if parses_flag "$cli" --board; then DROP_BOARD_WHY="the 7331 board is retired, so this Console runs without the board"
else DROP_BOARD_WHY="the target Console does not parse it, so the board runs in the Console"; fi
done
BASE_CONSOLE=("${kept[@]}")
if grep -q 'MOSAIC_TESTER_SOCKET_V1' "$cli"; then socket_supported=1
elif grep -q -- '--tester-port' "$cli"; then supported=1; fi
for ((i = 0; i < ${#BASE_CONSOLE[@]}; i++)); do
@@ -859,6 +874,9 @@ console_for_target() {
sso_supported() { grep -q -- '--sso' "$COPIES/$TO/packages/webui/src/cli.mjs"; }
has_flag() { local f="$1"; shift; [[ " $* " == *" $f "* ]]; }
# Row 198 (design N2): true when cli $1 holds a parser branch for flag $2 that does not throw. The usage
# text and the refusal name --board too, so a bare grep would count a copy that refuses it as parsing it.
parses_flag() { grep -F -- "flag === '$2'" "$1" | grep -qv throw; }
# Row 200 (#1698, R1): a rollback from an --sso pointer to a copy whose Console has no --sso. The
# pointer no longer holds the preview set that Console ran with, so the flags come from the rollback
@@ -1448,6 +1466,7 @@ plan() {
[ "$RUN_PENDING" = 0 ] || echo "pm: the bus starts without --pm and the PM stays pending; next: Jason's plugins set of harness, then pm-on"
echo "bus: scripts/bus-service.sh install $flags--copy $TO --copies $COPIES$([ "$RUN_PM" = 0 ] || echo ' --pm') --claude $P_CLAUDE"
[ -z "$DROP_TESTER" ] || echo "drop $DROP_TESTER; the target Console does not support it"
[ -z "$DROP_BOARD" ] || echo "drop $DROP_BOARD; $DROP_BOARD_WHY"
echo "$PROOF_PLAN"
echo "$SEND_PLAN"
[ -z "$SSO_PLAN" ] || echo "$SSO_PLAN"
@@ -1468,6 +1487,7 @@ run_steps() {
else log_line result started; fi
capture || refuse capture-failed "could not write the rollback set $RS; nothing was stopped"
[ -z "$DROP_TESTER" ] || say "drop $DROP_TESTER; the target Console does not support it"
[ -z "$DROP_BOARD" ] || say "drop $DROP_BOARD; $DROP_BOARD_WHY"
[ -z "$PROOF_STEP" ] || say "$PROOF_STEP"
[ -z "$SEND_STEP" ] || say "$SEND_STEP"
[ -z "$SSO_STEP" ] || say "$SSO_STEP"
@@ -2321,7 +2341,7 @@ STORE="$DATA/bus/bus.sqlite"
BUS_UNIT="${BUS_TEMPLATE%.service}$BUSINESS.service"
FROM="" TO="" ACT="" RS="" POST="" BACKUP="" TAKEN="" RESTORED="" STEPS="" COUNT="" UNCLEAN="" FAIL=""
STORE_SCHEMA="" START_EPOCH="" START_MS=0 GATE="" GATE_WHY="" S_CHANGED=""
P_COPY="" P_PM=0 P_PENDING=0 P_SCHEMA="" P_CLAUDE="" P_CONSOLE=() RUN_PM=0 RUN_PENDING=0 RUN_CONSOLE=() DROP_TESTER="" PROOF_PLAN="" PROOF_STEP="" SEND_PLAN="" SEND_STEP="" PLAN_REFUSE=()
P_COPY="" P_PM=0 P_PENDING=0 P_SCHEMA="" P_CLAUDE="" P_CONSOLE=() RUN_PM=0 RUN_PENDING=0 RUN_CONSOLE=() DROP_TESTER="" DROP_BOARD="" DROP_BOARD_WHY="" PROOF_PLAN="" PROOF_STEP="" SEND_PLAN="" SEND_STEP="" PLAN_REFUSE=()
BASE_CONSOLE=() SERVE_FLAGS=() SSO_PLAN="" SSO_STEP="" SSO_DROP="" SSO_NO_TARGET=""
R="" R_RC=0 R_EARLY=() R_BK="" R_HOST_PID="" R_CONSOLE_PID="" R_STARTED="" LIVE_CFG="" LIVE_BIZ=""
O_ACT="" FLOOR=0
+2 -10
View File
@@ -42,7 +42,7 @@
# FLAGS are serve's: --port N (default 7332; 7330 is the operator's own
# Console), --tester-socket (private Unix tester), or legacy --tester-port N
# (1024..65535, neither --port nor the tunnel's 7332; loopback on old copies),
# --business ID, --board URL, and the preview set --public-host
# --business ID, and the preview set --public-host
# NAME --auth-header HEADER --allow-user ID, all three or none, and --send
# (row 83), taken only with the preview set: the unit's Console then sends
# human messages to roles and needs the system config at start. systemd
@@ -93,7 +93,7 @@ instance_select
UNIT_NAME="$CONSOLE_UNIT"
BUS_UNIT_PREFIX="${BUS_TEMPLATE%.service}"
USAGE="usage: scripts/webui-service.sh copy COMMIT [--copies DIR] [--allow-worktree] | [--instance NAME] render [--copy ID [--copies DIR]] [FLAGS] | install --copy ID [--copies DIR] [--dir DIR] [--no-reload] [--allow-worktree] [FLAGS] | uninstall [--dir DIR] [--no-reload] | status [--dir DIR]
FLAGS: [--port N] [--business ID] [--board URL] [--sso [--send] | --public-host NAME --auth-header HEADER --allow-user ID [--send]] [--tester-socket | --tester-port N (legacy copies)] [--proof-header NAME --proof-file PATH]
FLAGS: [--port N] [--business ID] [--sso [--send] | --public-host NAME --auth-header HEADER --allow-user ID [--send]] [--tester-socket | --tester-port N (legacy copies)] [--proof-header NAME --proof-file PATH]
--tester-port: legacy copies only; 1024..65535, different from --port and never 7332; loopback tester listener.
--allow-worktree is for the tests only: copy and install otherwise refuse outside the canonical checkout."
@@ -113,7 +113,6 @@ TESTER_PORT=""
TESTER_SET=0
TESTER_SOCKET=0
BUSINESS=""
BOARD=""
PUBLIC_HOST=""
AUTH_HEADER=""
ALLOW_USER=""
@@ -136,7 +135,6 @@ parse_flags() {
--tester-socket) TESTER_SOCKET=1; SERVE_SET=1; shift ;;
--tester-port) [ $# -ge 2 ] || usage; TESTER_PORT="$2"; TESTER_SET=1; SERVE_SET=1; shift 2 ;;
--business) [ $# -ge 2 ] || usage; BUSINESS="$2"; SERVE_SET=1; shift 2 ;;
--board) [ $# -ge 2 ] || usage; BOARD="$2"; SERVE_SET=1; shift 2 ;;
--public-host) [ $# -ge 2 ] || usage; PUBLIC_HOST="$2"; SERVE_SET=1; shift 2 ;;
--auth-header) [ $# -ge 2 ] || usage; AUTH_HEADER="$2"; SERVE_SET=1; shift 2 ;;
--allow-user) [ $# -ge 2 ] || usage; ALLOW_USER="$2"; SERVE_SET=1; shift 2 ;;
@@ -212,12 +210,6 @@ serve_flags() {
[ "${#BUSINESS}" -le 64 ] || die "business must be a business id: $BUSINESS"
flags="$flags --business $BUSINESS"
fi
if [ -n "$BOARD" ]; then
# serve checks the rest (a loopback origin, no path or credentials).
case "$BOARD" in http://?*) ;; *) die "board must be an http:// URL: $BOARD" ;; esac
case "$BOARD" in *[!A-Za-z0-9.:/_-]*) die "board contains a character the unit cannot carry: $BOARD" ;; esac
flags="$flags --board $BOARD"
fi
local preview=0
[ -z "$PUBLIC_HOST" ] || preview=$((preview + 1))
[ -z "$AUTH_HEADER" ] || preview=$((preview + 1))