Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9fc0ab301e | ||
|
|
734f4d2c5b | ||
|
|
5ca83173e9 | ||
|
|
29d233793e | ||
|
|
397ef58beb | ||
|
|
460355ccc8 | ||
|
|
906267c750 | ||
|
|
53a4541859 | ||
|
|
b0c79751cf | ||
|
|
ab64174753 | ||
|
|
4818b7ba47 | ||
|
|
6af63e1d34 | ||
|
|
1f7371b7c5 | ||
|
|
ad5b8a7fa2 | ||
|
|
4d8123b0a5 | ||
|
|
14fe7513ce | ||
|
|
3c57ebb220 | ||
|
|
41b5aae3cc | ||
|
|
f913d0e76a | ||
|
|
f2fd6145ad | ||
|
|
c9c8badc3e | ||
|
|
77c12b5545 | ||
|
|
aad3000fe2 | ||
|
|
4288b7b162 |
No files matched your search
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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; } };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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`);
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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, []);
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;}}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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`);
|
||||
|
||||
@@ -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 { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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>
|
||||
@@ -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 } });
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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); }
|
||||
});
|
||||
@@ -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(); }
|
||||
});
|
||||
@@ -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)})})()`);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)})})()`);
|
||||
|
||||
@@ -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"}' });
|
||||
|
||||
@@ -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 }); }
|
||||
});
|
||||
@@ -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' }; },
|
||||
|
||||
@@ -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' }; },
|
||||
|
||||
@@ -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 }); },
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);};`});
|
||||
|
||||
@@ -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)})`;
|
||||
|
||||
@@ -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')`;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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, []);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 }); }
|
||||
});
|
||||
@@ -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 }); }
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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(); }
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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:/);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)});})()`);
|
||||
|
||||
@@ -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); }
|
||||
});
|
||||
@@ -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)})})()`);
|
||||
|
||||
@@ -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'$/);
|
||||
|
||||
@@ -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'); } });
|
||||
|
||||
@@ -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' }]]) {
|
||||
|
||||
@@ -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]; },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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}/`);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in new issue
Block a user