Compare commits

..
129 changed files with 2334 additions and 9910 deletions

No files matched your search

-14
View File
@@ -5403,17 +5403,3 @@ Row 299 (573, #1806, Gander, Filbert): before, the P10 band test timed a fault r
2026-10-23 | Huey | Row 272 round 2 on row 219 landing 5fdfd7275, after 295. Replayed round 1 and B1/N1/N2; seven conflicts resolved retaining both log histories, both test sets, 219 instance chooser and 295 owner-settings-only transport. Static comparison confirms old tests differ only by mos-1388 approved hunks. Sage3357/Mos1442 authorize bounded granted-user legacy fallback: only session.list access-refused and omitted-session route; no new session grants or owner routing. RED first under3358 on new file only: 1 expected legacy assertion failure, 3 guards pass, 3.504s, TMPDIR mktemp/no underscore, no compose network; slot returned as huey0035. Then implemented fallback and added timeout/bus/malformed guards plus isolated-copy mutant runner, all unrun afterward. Candidate commit/push precedes Sage's full gate by3354; no green claim.
2026-10-23 | Huey | Row 272 round 3 per mos-1455 / sage-3435. Gate run 1 on b44332a93 RED: bus 891/0, business 231/0, CLI 940/1, plugins 110/1, webui 372/20; no mutants. Fixed only new CLI fixture role authority and three null-old render guards. Catching browser tests unchanged. manifest.r3 freezes the same 17 paths. Authorized pre-gate follow-up commit/push; no local tests, Node or queue writes; separate compactions dependency and Sage gate remain pending.
2026-10-23 | Sage (1ef1e4f8) | Row 272 landing (#1777, 264e) | Gate r4 on bea0c8e1c over 1e629c892: bus 891/0, cli 941/0, webui 392/0, business 231/0; plugins 110/1, the single red being row 65 harness.test.mjs:192 naming compactions.mjs, which row 307 owned and fixed. M1-M6 and F1-F4 killed, restore clean. Landed d33989230, queue commit 8dc132bec.
2026-10-23 | Sage (1ef1e4f8) | Row 307 landing (#1814, Vikunja 598) | Six first-parent commits replayed onto 8dc132bec with matching patch-ids, 16 paths equal to 6fdfe3790, manifest digest 17c9f8d9 rebuilt from git. Gate green 14:37:21Z: bus 891/0, cli 941/0, plugins 119/0, webui 392/0, business 231/0, every other package 0 failed 0 skipped, every scripts/test-*.sh passed (skips: test-queue canonical root, test-task live provider x2). M1-M4 killed, restore clean. Landed d243f6b25, DEFERRED 307 N1 0621c439e, queue commit fe4ee8c7b. Correction: my first note said Vikunja 598 done when I had only commented (951); Mos closed it (952).
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.
-8
View File
@@ -47,14 +47,6 @@ its launch configuration remains fixed, including on resume. Use Filbert's
wrapper to select that configuration; a direct shared-helper invocation uses
its own supplied flags or the system defaults.
Any run that includes the webui suite, whether a seat's slot run or a landing
gate, sets `MOSAIC_REQUIRE_EDITOR_BUILD=1`, so a missing editor toolchain fails
the run instead of skipping the rebuild test. A worktree that links the
canonical `node_modules` also runs `npm ci --ignore-scripts --no-audit --no-fund`
in its own `packages/webui` first. The root install carries esbuild 0.28.1
through pi-coding-agent, and the rebuild needs the 0.28.2 that
`packages/webui/package-lock.json` pins (Vikunja 597, mos-1453).
All agents follow repository governance and current user direction. Team
leadership does not add deployment or push authority. Coordinate overlapping
work with Sage and preserve other sessions' changes.
-509
View File
@@ -1,509 +0,0 @@
# Row 198 build: retire the board writer on 7331; the Console serves the board in-process
Branch `bushroot/row198-r3`, rebased onto `aca16404c` (queue revs 3195-3216 on refactor). Supersedes
`bushroot/row198-r2` (base b79ad5f2c), which is kept and not force-pushed.
Issue #1495 family, Gitea #1696. Ruling source: Sage sage-2821 answering Webby webby-0150
(CHANGES B1-B5, N1-N3); earlier Q220 ruling sage-2803 (Mos mos-1301), reviewer Webby.
## Status: UNVERIFIED
No test suite, mutant, or node process has run for this commit. Sage's gate (sage-2803, sage-2821):
"no node of any kind and no https git; no test runs until my CLEAR." This commit is local.
It must not be pushed until the gate runs green and Sage opens a gap for the push.
## Ruled: `--board` is dropped for every op (Sage sage-2834 ruling a, sage-2837)
Design item 8's "keep the flag where the target parses it" part is withdrawn. `webui-service.sh
install` has no `--board` branch (exit 4), and `c3b60e513` removed the flag from its parse_flags,
so a kept flag fails at install-console. `install_stack` (stack-release.sh:565) runs that install on
activate as well as rollback, so a re-pin to a pre-198 copy has the same failure. sage-2837 makes
the rule per target, not per op.
Code: `console_for_target` drops every `--board` pair from `BASE_CONSOLE`, for any op. The reason
follows the target's `cli.mjs`:
- `parses_flag` true (pre-198 target): `drop --board <url>; the 7331 board is retired, so this
Console runs without the board`.
- `parses_flag` false (post-198 target): `drop --board <url>; the target Console does not parse it,
so the board runs in the Console`.
`DROP_BOARD_WHY` carries the reason to both print sites (plan and steps).
Tests: M12 unchanged (post-198 target, activate --plan). M13 now covers a kept `--board` for a target
that parses it, on both `activate --plan` to a pre-198 copy and `rollback --plan`. Each asserts the
drop line with the retired-board reason and no `--board` on the `Console:` install line, and that the
plan leaves state unchanged. The README rollback note covers any move to a pre-198 copy.
## What changed in the source
- `scripts/stack-release.sh` (B2, design item 8): `console_for_target` drops a `--board` pair from
`BASE_CONSOLE` unless the target's `cli.mjs` parses `--board`. `parses_flag` matches
`flag === '--board'` and ignores lines that contain `throw` (N2: the target names `--board` only
in a refusal). A dropped pair sets `DROP_BOARD` and `DROP_BOARD_WHY` (reason by target, see the
Ruled section), printed in the plan and the steps. Init at the globals. `bash -n` passes; not run.
- `packages/control-board/src/board.mjs` (B4): `createBoard` takes `isPidAlive` and passes it to
`scanAsync`, so the stale-pid refusal can be tested with an injected liveness check.
- `packages/control-board/src/cli.mjs`: `serve` throws RETIRED, exit 2 (`ConfigError` path).
- `packages/webui/src/serve.mjs`: `/api/seen` and `/api/reply` answer 405 for a non-POST, 403
`{error:'read-only', sent:false}` without `--send` on the public name, 503 with no board, and
otherwise run the in-process board. `boardSend` answers 400 on a body that is not JSON, before
any board call.
- `packages/webui/src/cli.mjs`: `--board` throws `--board retired: the board runs in the Console
(row 198)`. The board is built over `system.dataRoot` (`board/`, `seats/`).
- `scripts/webui-service.sh`: the unit no longer carries `--board`; render and install refuse it.
- `scripts/stack-copy.sh`: COPY_PATHS adds `packages/control-board/src` and `tools/tmux`.
## What changed in the tests
- `packages/cli/tests/stack-release.test.mjs` (B2): new block before the Row 181 section.
`setCli` rewrites a copy's `cli.mjs` and restores it with mode 0444 in `t.after`; `withBoard`
appends the flags to the pointer console.
- M12: `seed`, `setCli` to a refusal-only `cli.mjs`, `withBoard`, `activate --plan`. Expects the
drop line, no `--board` in the Console line, and no state change.
- M13: `setCli` to a parser, real `activate` without the flag, then the unit gets `--board` and
`rollback --plan`. Expects the rollback drop line with the retired-board reason and no `--board`
in the `Console:` install line. Activation runs without the flag because `webui-service.sh
install` refuses it.
- `packages/control-board/tests/serve.test.mjs` (B4, B5): `replyBoard(f, agentSend, isPidAlive)`
passes `isPidAlive` through. New refusal row: a stale registration (pid 4242, `isPidAlive` false)
answers 409 with `/stale/`. B5: the non-numeric `--port` test is deleted, since `serve` no
longer takes a port.
- `packages/webui/tests/send.test.mjs` (B4): `setup` builds an in-process board object
(`index`, `markSeen`, `reply`, each recording into `boardHits`); the `upstream` server is gone.
New case: a board write with a body that is not JSON answers 400 under `--send`, and the board
runs nothing.
- `packages/control-board/tests/retired.test.mjs` (new, B3): M1 `cli.mjs` starts no server;
M2 no board source imports `node:http`, calls `createServer`/`.listen(`, or exports a server;
M9 live READMEs, AGENTS.md and `scripts/*.sh` do not tell anyone to run `control-board serve`
or open 127.0.0.1:7331, except a line that says it is retired. Append-only logs and plans are
not scanned. Grep check on the current tree: no hits.
- `packages/control-board/tests/board.test.mjs` (B3): new test, M15. After a write, `index()` must
start a fresh scan. The event log must read `write, bump, scan, scan`, and the answer must show
the mark.
- The reply join killer (B3, M16) lives in `packages/control-board/tests/serve.test.mjs`, not
`board.test.mjs`. `reply()` refuses an unregistered row before any send, so the killer needs a
registered fixture (`replyFixture`), which `serve.test.mjs` already builds.
- `packages/webui/tests/board-inprocess.test.mjs` (new, B3): M6 and M7 (preview: each board route
answers 401 without the proxy header and 403 with a foreign Host, and the board never runs);
M5 (`/api/seen` without `--send` writes no `seen.json`); M10 (positive control: with `--send` on
the public name, `/api/seen` writes the mark and `/api/reply` runs exec once); M3 (a board read
makes no request to a URL other than the Console's own); M14 (a real board over a 1 s tmux shim:
the loop never stalls and `/healthz` answers while `/api/board` is pending).
- `packages/webui/tests/pinned-copy.test.mjs` (B3, M11): the existing "serve runs from a copy"
test also asserts `/api/board` answers 200 with a `sessions` array from the copy.
## Dropped coverage (B4), with reasons
- Stale-pid reply case. Not dropped: moved. It runs against `createBoard` with the new
`isPidAlive` passthrough (`serve.test.mjs`, the 409 `/stale/` row).
- Bad-JSON request case. Moved to `packages/webui/tests/send.test.mjs`. The HTTP writer is gone;
body parsing now happens in the Console's `boardSend`.
- `page.html` tests. Dropped: the page is deleted, and the Console serves the board page.
- Tester board-proxy tests. Dropped: there is no proxy. The tester socket is built with the
in-process board (N1 below).
## Corrections to r2 (N1)
The r2 note said the tester socket gets `board: null`. That was wrong. Only
`packages/webui/tests/proof.test.mjs` passes `board: null`. `cli.mjs` passes the in-process board
to `startTesterSocket`.
## Mutant map (M1-M15, design rev 5)
Each mutant runs on a scratch copy with the new tests. Not run yet.
| # | Mutant | Killer |
|---|---|---|
| M1 | `cli.mjs serve` starts a server | `retired.test.mjs` M1; also `board.test.mjs` "cli serve refuses" |
| M2 | serve source restores or exports `createServer`/`startServer` | `retired.test.mjs` M2 |
| M3 | Console proxy fetch to a board URL returns | `board-inprocess.test.mjs` "makes no request to a board URL" |
| M4 | `/api/reply` skips the `send` check | `webui serve.test.mjs` "writes refused without --send" (existing; zero captures) |
| M5 | `/api/seen` skips the `send` check | `board-inprocess.test.mjs` "writes no mark"; `serve.test.mjs` as M4 |
| M6 | board block runs before the preview and header gate | `board-inprocess.test.mjs` preview 401 |
| M7 | board route accepts a foreign Host | `board-inprocess.test.mjs` preview 403 |
| M8 | `webui-service.sh` re-accepts `--board` | `service.test.mjs` render refusal list (status 1) |
| M9 | live doc or script tells people to run `control-board serve` | `retired.test.mjs` M9 |
| M10 | `/api/reply` and `/api/seen` branches move below the read-only refusal | `board-inprocess.test.mjs` positive control (200, exec once) |
| M11 | `COPY_PATHS` drops `packages/control-board/src` or `tools/tmux` | `pinned-copy.test.mjs` "serve runs from a copy": `/api/board` 200 |
| M12 | `console_for_target` keeps `--board` for a target that does not parse it | `stack-release.test.mjs` M12 |
| M13 | a recorded `--board` is kept for a target that parses it (activate or rollback) | `stack-release.test.mjs` M13 (activate and rollback): plan drop line, then a real run: drop line in the output (rollback: steps.log), no `--board` in the unit or pointer |
| M14 | a board read blocks the loop: `tmuxInspectAsync` in `scan.mjs` swapped for `tmuxInspect` (spawnSync) | `board-inprocess.test.mjs` M14: real `createBoard`, tmux shim on PATH sleeps 1 s; the timer gap while `/api/board` is pending and `/healthz` elapsed time |
| M15 | `index()` joins a scan that started before the last write | `board.test.mjs` M15 (event log) |
| M16 | `reply()` drops the counter bump: `writes++` at `board.mjs:61` (the event at line 62 is a second mutant, caught by the event log) | `serve.test.mjs` M16: event log `["scan","scan","send","bump","scan"]`, then the next `index()` must log `scan` |
| M17 | `index()` never clears a settled scan: the `promise.then(clear, clear)` line in `board.mjs` `index()` is dropped (Webby B4, r3) | `board.test.mjs` B4: two `index()` calls with no write log `["scan","scan"]` and the second shows a session line appended between them; `board.test.mjs` B4 rejected scan: the next `index()` runs fresh. M15 and M16 still pass after the clear, so they do not kill M17 |
### M14: closed by the real-board test (Sage sage-2839, Webby N1)
The earlier gap came from a stub board: a `spawnSync` in `scan.mjs` never ran under the test. The
M14 test now builds a real `createBoard` whose tmux check runs through a shim on PATH. The shim is
`#!/bin/sh`, `sleep 1`, `exit 1`. The test measures two things, both by elapsed time:
- a 10 ms timer's largest gap while `/api/board` is pending (a blocked loop stalls the timer);
- `/healthz` elapsed time, fetched after `/api/board` is sent.
The bound is 500 ms for each. The async path (`runAsync`) keeps both near 10 ms. The M14 mutant
(`tmuxInspectAsync` calls `spawnSync`) stalls the loop for the shim's full second, so both checks
fail. The real-board test is not run yet (gate). Known soft spots, to check on the first run: the
shim must win the PATH lookup, and the `specs` entry is minimal (no registration), which may not
match a real spec.
## Checks done without running anything
- Read whole: `packages/webui/tests/fixture.mjs`, `packages/webui/tests/send.test.mjs` (first
70 lines), `packages/webui/tests/serve.test.mjs` (first 80 lines), `packages/control-board/
tests/board.test.mjs`, `packages/control-board/src/board.mjs`, `packages/webui/tests/pinned-copy.test.mjs`
(the serve-from-copy test).
- Grep, current tree: `--board` and `boardURL` in `packages/*/tests` and `scripts/test-*.sh`
show only intended refusals and the unrelated case2 suite (`case2-unit.test.mjs`, `case2-unit.sh`).
- The M9 and M2 rules run as greps over the live docs and `packages/control-board/src`: no hits.
- sha256 pins: no changed file pins a file sha256. The 64-hex literal at `service.test.mjs:23` is
a fixture uid, not a hash.
- `packages/webui/tests/library-cli.test.mjs:16`: a fake serve module exports `DEFAULT_BOARD`.
Harmless; may be dropped later.
## Docs (N2)
- `packages/webui/README.md`: the start block is one command (`node packages/webui/src/cli.mjs
serve`); `--board` is refused; the proxy-origin sentence is gone; the Host and write paragraph
says seen and reply reach the in-process board under `--send` on the public name; rollback has
no 7331 step.
- `packages/control-board/README.md`: `serve` is retired and exits 2; the board runs in the
Console; `scan` is kept; the serve-only flags are removed; the route list is marked as served by
the Console; exit 0 is scan only.
- `docs/plans/CURRENT.md`: one dated line appended at the end (append-only).
Open, ruled sage-2868: boardSend's `/api/seen` catch returns 400 for a scan failure after writeMark. Left as built: a scan failure is not a client error, so this is a real gap. Named follow-up in Sage's post-253 DEFERRED sweep (N2).
Still in the README, on purpose: `packages/webui/README.md` line 826 runs the control-board suite
(`node --test packages/control-board/tests/`). The suite still exists, so the line stays.
## Gate, not run
- control-board, webui and cli suites; `scripts/test-*.sh`; the mutants M1-M15 on a scratch copy.
All wait for Sage's CLEAR.
- The per-target drop (sage-2834 ruling a, sage-2837) is implemented and documented; M13 has its
expectation for activate and rollback. Pushed at 2a758a5ba (sage-2852 gap, 17:20Z).
- Webby webby-0152/0153 and Sage sage-2839, after 2a758a5ba: B1 and N3 met at 2a758a5ba. B2 (real
activate and rollback runs, unit and pointer checked), B3 and M16 (reply() join), N1 and M14 (real
board over a tmux shim) are written and uncommitted; the test runs wait for the CLEAR.
- N2 is open, not changed: `boardSend`'s `/api/seen` catch answers 400 with `err.message` for a scan
that fails after the mark is written. Webby marked it non-blocking; Sage has not ruled on it. A
500 for that case would need `ConfigError` to tell refusals from scan failures.
- M14: Sage accepted the gap as recorded (sage-2834). The mutant run is the check.
- Push: not done. Sage's gaps govern it.
## r4: rebase onto 45dca69f6 (sage-3575 step 1, sage-3586 ruling a)
Branch `bushroot/row198-r4-45dca69f6`: the eight r3 commits replayed on `origin/refactor` 45dca69f6.
`bushroot/row198-r3-253` stays at 1fb66792e and is not touched. Seven commits replayed clean. Only the
retire commit (0bde5c8d7, now 4288b7b16) conflicted, in three files and seven hunks.
Ruling applied: every upstream feature stays (federation view, decisionWrite, userOptions, the
`/api/config` fields, granted-user gating). The board runs in-process. The URL proxy to a board origin is
removed, because the proxy is the route to the 7331 writer that row 198 retires. This is the one place an
upstream behaviour changes. Board routes now answer from the in-process board with the same response
shapes. A granted user still gets `owner-only` 403 on board routes, because the granted-user gate runs before
them.
### Conflict hunks
Each hunk: upstream side, our side, resolution, reason.
1. `packages/webui/src/serve.mjs`, `startServer` signature. Upstream: `board = DEFAULT_BOARD`, with
`decisionWrite`, `userOptions`, `federation`. Ours: `board = null`, none of the three. Resolved: upstream
signature with `board = null`, and our doc comment above it. Reason: keeps upstream's parameters; no board
is now the default, not port 7331.
2. `packages/webui/src/serve.mjs`, body after the tester checks. Upstream: decision check, `confirmations`,
`const upstream = boardURL(board)`, `userOptions` check, `owner` with `upstream`, `hub`. Ours: nothing.
Resolved: upstream minus `const upstream = boardURL(board);`; `owner` drops `upstream`. Reason:
`boardURL` is the proxy's loopback validator; there is no proxy. The `let {...}` destructure and the
granted-user `upstream = null` line lose the same name.
3. `packages/webui/src/serve.mjs`, the board proxy block (`allowed`, `fetch(upstream + path ...)`, 502 on
failure). Upstream: the proxy, after the read-only gate. Ours: nothing here; in-process routes sit at two
points. Writes (`/api/seen`, `/api/reply`) answer 405 for a non-POST, 403 `{error:'read-only', sent:false}`
without `--send` and a named human, 503 with no board, then `boardSend`. Reads (`/api/board`,
`/api/conversations`, `/api/conversation`) answer 405 for a non-GET, 503 with no board, then
`consoleRead(board.index())` or `board.conversation`. Resolved: ours, upstream block removed. Reason: this
block is the 7331 route. Auth check order: the sign-in gate (401 for an unsigned request under `--sso`)
and the granted-user owner-only gate both run before the write routes and the read routes.
4. `packages/webui/src/serve.mjs`, `/api/config`. Upstream: `board: upstream`. Ours: `board: boardLabel`.
Resolved: `board: granted ? null : boardLabel`, with every upstream field after it unchanged. Reason:
upstream sent `null` to a granted user (its `upstream` was null); `boardLabel` is `'in the Console'` for the
owner and `null` with no system config.
5. `packages/webui/src/cli.mjs`, imports. Upstream: `DEFAULT_BOARD` in the `./serve.mjs` import, plus the bus
and console imports. Ours: `createBoard`, the two scan discovery imports, and `busCaller` again. Resolved:
upstream imports minus `DEFAULT_BOARD`, plus `createBoard` and `discoverRepoAgents`/`discoverFleetAgents`.
Reason: `DEFAULT_BOARD` no longer exists; `busCaller` was already imported by upstream.
6. `packages/webui/src/cli.mjs`, the `startServer` call. Upstream: `board: options.board`, with
`federation: system?.federation ?? null`. Ours: `board`, without `federation`, `decisionWrite`,
`userOptions`. Resolved: upstream call with `board`. Reason: `--board` is refused at `cli.mjs:39`, so
`options.board` no longer exists; the new `board` is the in-process object.
7. `packages/webui/src/public/app.js`, `/api/config` handler. Upstream: `board = config.board`, the federation
render, the sso line. Ours: `board = config.board || 'the Console board'`. Resolved: upstream handler with
our fallback label. Reason: `config.board` is `null` for a granted user and with no system config, which
would print `Board: null`.
### Upstream tests changed to match (same commit, 4288b7b16)
Upstream added tests that pass a URL board string or `f.boardURL`. Each now means "no board" (`board: null`,
which answers the board routes 503) or the in-process fixture (`f.board`). `board: 'http://127.0.0.1:9'` and
`board:'http://127.0.0.1:9'` became `board: null`; `f.boardURL` became `f.board` in decide-browser,
thread-claims-browser, processing-browser and granted-user. No assertion compared the URL string. The fake
`DEFAULT_BOARD` export strings in library-cli and thread-claims are left: they are fake module text, not board
config.
### Not ported: fake HTTP board servers (known to fail; port before the gate)
These still pass a fake HTTP origin as `board`. The in-process board never calls it, so each test's
board-route assumptions are false now. Each needs a read and a port to an in-process `board` object:
- `packages/webui/tests/browser-edge.test.mjs:19` (waits for an HTTP `/api/board` request, lines 15 and 31)
- `packages/webui/tests/preview.test.mjs:62` (fake board answers `/api/board`; line 160 asserts 200)
- `packages/webui/tests/tester-listener.test.mjs:29` (`hits` counter on the fake board)
- `packages/webui/tests/shell.test.mjs:111` and `:427` (fake board with unavailable, empty, one, fail, refuse modes)
Also a risk, not checked: any test that reaches a board route with no `board` argument. The default moved
from 7331 (a 502 on a dead origin) to `null` (503). A test asserting 502 there would now fail.
### Range-diff
`git range-diff 48e450ce8..bushroot/row198-r3-253 45dca69f6..HEAD`, markers only:
1: 0bde5c8d7 ! 1: 4288b7b16 retire the board writer on 7331 (resolved above)
2: 69ac924bc = 2: aad3000fe build design item 8; killers for M1-M15; B4 drops; docs; BUILD.md
3: 716b46adf = 3: 77c12b554 rollback always drops --board (sage-2834 ruling a)
4: 0bb5f230f = 4: c9c8badc3 drop --board for every op, reason by target (sage-2837)
5: d388f47ec = 5: f2fd6145a real-run M13 pairs, reply join killer M16, real-board M14 (sage-2839)
6: 02d5bd9b4 = 6: f913d0e76 index() clears a settled scan; B4 tests and mutant M17 (webby-0157)
7: 25565fa97 = 7: 41b5aae3c BUILD.md places the reply join killer (B3, M16)
8: 1fb66792e = 8: 3c57ebb22 BUILD.md records N2 as a named follow-up (sage-2868)
Seven commits are unchanged by patch-id. Only the retire commit is `!`.
### Files to rerun (auto-merged or changed on rebase)
The drift check found nine paths touched by both r3 and upstream. Six auto-merged and need a test rerun:
- `packages/cli/tests/stack-release.test.mjs`
- `packages/webui/README.md`
- `packages/webui/tests/browser.test.mjs`
- `packages/webui/tests/chat-browser.test.mjs`
- `packages/webui/tests/chat.test.mjs`
- `scripts/stack-release.sh`
The three conflicted src files are in the hunk list above. The 21 upstream-stub test files listed above also
need a rerun, because the drift check did not cover them: they are upstream-only changes.
### Gate status
UNVERIFIED. No node, no test run, and no mutant has run on r4. The gate waits for Sage's CLEAR, after 226,
220/216 and 285 in gate order. The four fake-board tests above are known red. The rest are unknown until the
run.
## r4: the four fake-board tests ported to the in-process board (sage-3597, sage-3603)
Scope: the four tests that read the retired fake HTTP board server, plus one new granted-user test. Numstat is
against 14fe7513c, the r4 tip before this entry. Nothing in this section has been run. The gate is still
UNVERIFIED, and the tests here were not executed (slot rule). Sage sends the list below to Mos (mos-1272).
| File | +/- | What changed |
|---|---|---|
| `packages/webui/tests/browser-edge.test.mjs` | 14/13 | The fake server is gone. `index()` holds while `holdBoard` is set and resolves to the page's board on release. `reply()` holds in place of the `held` response and returns `{status, body}`. The 5 s "made no /api/board request" check still runs: it now watches `index()` being called. Every assertion is kept. |
| `packages/webui/tests/preview.test.mjs` | 12/16 | `setup()` builds an in-process board that records `GET /api/board`, `POST /api/seen` and `POST /api/reply` in `boardHits`. The 401 and read-only tests keep their `boardHits` checks. The dead-board case is a board whose `index()` throws, so the answer is 502 `invalid-response`. The upstream origin is no longer in the body (see the deleted assertion below). |
| `packages/webui/tests/tester-listener.test.mjs` | 4/4 | The first test's `hits` counter moves into an in-process `index()`. Every bus and board read still counts, and both 403 checks are unchanged. The `createServer` import is removed. |
| `packages/webui/tests/shell.test.mjs` | 44/63 | `board()` is an in-process board. Modes: `empty`, `one`, `registered`, `fail` (throws, so 502 `invalid-response`), `unavailable` (throws `ReadError('read-failed')`, so 503). `hold()` gates `index()`. The conversation catalogue and page are sync. Changed expectations (listed below). Refusal-only steps removed (listed below). |
| `packages/webui/tests/granted-user.test.mjs` | 32/2 | `setup()` takes `board` and `send` options, defaulting to the old `null` and `false`. New test `R6 G4b`: a granted session gets 403 `owner-only` on all five board paths with no board read, and the owner session gets 200 on each, with the read order checked. |
### Changed expectations (not loosened; each is a product change, for Mos)
These expectations now name the in-process contract. The message text came from the fake server before. Each one is
an old assertion with a new value.
- `shell.test.mjs` stale-data check: `/board scan failed.*Showing last known data/` is now `/invalid-response.*Showing last known data/`. A scan throw is mapped by `consoleRead` to `invalid-response` (502). Before, the fake returned 500 with the text.
- `shell.test.mjs` unavailable check: `/board unavailable.*No board data loaded/` is now `/read-failed.*No board data loaded/`. `consoleRead` sends the code as `error`, and the page shows `err.message`, which is that code. The page shows `read-failed` now, where it showed `board unavailable` before.
- `preview.test.mjs` dead board: the status stays 502, and `body.error` is now `invalid-response`.
### Deleted assertions (proxy-only, no in-process counterpart)
- `preview.test.mjs` `assert.equal(board.body.board, dead)`. The in-process board has no origin, so the body
can't name one. The status and the bus checks around it are kept.
- `shell.test.mjs` the refusal block: the fake's HTTP 403 `board-refused`. `consoleRead` has no 403 code: `STATUS`
in `reads.mjs` maps only `not-configured`, `no-bus-host`, `queue-refused`, `read-failed`, `not-found` and
`invalid-request`, and any other code is 502. So the in-process board cannot refuse, and `app.js`'s
`err.status === 403` branch ("The board refused the read", `#board-refused`, the fail-closed drop of kept
rows, the inspector and the counts) has no trigger in these tests. The block that was deleted asserted every
one of these. The conversation refusal is deleted too: a 403 from `/api/conversation` closed the open
conversation, and the new step closes it with `#conv-back`.
This is a coverage loss, and it needs a decision. Either `reads.mjs` maps a board refusal to 403 (a product
change, which Mos rules on), or the refusal branch is retired. The granted-user 403 `owner-only` answer goes
through the same `err.status === 403` branch, so the page path is still reached by granted users. It is not
covered by a shell check.
### Status change, dead board (Webby's question, webby-0229)
A board failure used to answer by the origin's state: a dead origin was 502 with the origin named, a board 500
was 500, and a 503 passed through. Now:
- no board configured (`board` null): 503 `not-configured`;
- a `ReadError` with a code in `STATUS`: that code's status;
- any other throw (a scan failure): 502 `invalid-response` on `/api/board`. The 500 applies only on the
conversation routes (`conversation read failed`).
No test under `packages/webui/tests` asserts 502 on a board path other than the preview dead-board case above.
The grep: `usage`, `limits-route`, `routes`, `chat`, `library-status`, `thread-claims`, `send`, `bus`,
`project-file`, `tester-listener`, `send-browser`, `instances`, `tester-cli` and `agents`. Those 502s belong to bus,
chat, or project-file routes, which this change does not touch.
### Granted-user gate (sage-3603): the test and its mutant
The board is a closure that the granted rebind doesn't touch, so `userRoute` is the only gate in front of it
for a granted session. Upstream's null-origin backstop is gone. Test `R6 G4b` pins that: the granted session gets
403 `owner-only` on `GET /api/board`, `GET /api/conversations`, `GET /api/conversation`, `POST /api/seen` and
`POST /api/reply`, and `reads` is still empty after those five requests. The owner session gets 200 on each, and
`reads` shows the five calls in order.
Mutant M18 (not run): in `packages/webui/src/serve.mjs`, change the granted gate
`if (!userRoute(path, req.method)) return json(res, 403, ...)` so it passes when `path` is one of the five board
paths. Expected: `R6 G4b` fails at the granted 403 assertion, because the granted request reaches the board and
`reads` is not empty. Run it with the rest of the granted-user suite once Sage gives CLEAR. The kill is not yet shown.
### Gate status
UNVERIFIED. These four files and the granted-user test are edited but not run. The rule "commit only after suites
are green" is not yet met, and this commit says so. The coverage loss above needs Sage's ruling before the gate.
## r4 round 2: mos-1513 (B1 fix), N1 test, route helper (19:23Z 10-23)
Not run. Node and browser suites wait for Sage's CLEAR.
### B1: CDP interception instead of a named listener on the old files
Per mos-1513, the browser tests keep the real in-process board. Each one calls `routeWrites(b, base, board)` from
`packages/webui/tests/board-route.mjs` before it navigates. Fetch intercepts the page's `/api/config` (answered
with the real body and `send: true`), `/api/reply` and `/api/seen`. Each write is forwarded over node http to a
named send listener on the same board, with the preview Host, the `https://` Origin and the signed user header.
So the server's own gate, parse and status mapping answer, and the page gets that answer back. Nothing re-implements
`boardSend`.
Shared helper, named for webby-0243: `board-route.mjs` exports `ROUTE`, `namedListener(board)`, `forward(port, path,
body)` and `routeWrites(b, base, board)`. `namedListener` and `forward` are the same code `routeWrites` uses.
### Numstat (git diff --numstat, packages/webui/tests, against 4d8123b0a)
3 1 board-held.test.mjs
7 2 browser-edge.test.mjs
6 2 browser.test.mjs
6 3 discord.test.mjs
4 2 history-return-flow.test.mjs
4 2 return-flow.test.mjs
New files: `board-route.mjs` (helper), `board-route-map.test.mjs` (node, see below), `board-refused.test.mjs` (N1).
### Line map: every removed line in the six old tests
No assertion line is changed. Every removed line is one of these, and each is listed by old line:
- board-held.test.mjs: `done` teardown (adds `stopRoute()` before `b.close()`); the `routeWrites` line is added in
`setup`. No assertion.
- browser-edge.test.mjs: the `await b.viewport(320); await b.navigate(...)` line is rewritten as the same two
statements with the `routeWrites` line added before it; the `finally` adds `stopRoute?.()`. The `board` object
is unchanged. `held` and `releaseBoard` are the same. No assertion.
- browser.test.mjs: the `finally` adds `stopRoute?.()`; `routeWrites` is added before the `viewport`/`navigate`
line, which is byte-identical. No assertion.
- discord.test.mjs: the `fetch(base + '/api/reply', ...)` request line becomes `namedListener` + `forward`, the
request setup, not the assertion; `assert.equal(reply.status, 409)` is byte-identical. The `finally` closes the
named listener.
- history-return-flow.test.mjs: `let b` gains `stopRoute`; `finally` adds `stopRoute()`. No assertion.
- return-flow.test.mjs: `let web, b` gains `stopRoute`; `routeWrites` added after `browser()`; `finally` adds
`stopRoute()`. No assertion.
### N1 (sage-3618, mos-1506 b): the refusal block, old to new
Old line numbers are in `shell.test.mjs` at 14fe7513c, the block 178-245. New line numbers are in
`board-refused.test.mjs`. Every old assertion is restored with its text unchanged:
181 -> 74 183 -> 76 184 -> 77 185 -> 78 187 -> 80
188 -> 81 189 -> 82 190 -> 83 191 -> 84 192 -> 85
193 -> 86 194 -> 87 195 -> 88 196 -> 89 197 -> 90
203 -> 97 204 -> 98 205 -> 99 206 -> 100 207 -> 101
208 -> 102 209 -> 103 210 -> 104 211 -> 105 212 -> 106
215 -> 109 216 -> 110 217 -> 111 218 -> 112 220 -> 114
221 -> 115 227 -> 122 229 -> 124 232 -> 127 236 -> 131
237 -> 132 238 -> 133 239 -> 134 240 -> 135 241 -> 136
243 -> 138
Changes in the port: the page-level 403 stub (`Fetch` on `*/api/board*` and `*/api/conversation*`) is the one
place a page-level stub is used, per N1. The board is the in-process one with `refuse()` set. The fake-board
data in `board()` is the old fake board's data, moved.
Pinned N1 mutant (not run): in `packages/webui/public/app.js` (the `error(err)` branch that sets
`refused = err.status === 403`), drop the `#board-refused` show and the `refused` banner. Expected:
`board-refused.test.mjs` fails at line 80 (`banner ref`) and line 83 (`#board-refused` hidden). Run after CLEAR.
### Node coverage for the real route
`board-route-map.test.mjs` (new) starts the named listener on a fixture board and pins the mapping the browser
tests now rely on: seen is 200; a delivered reply is 200 with `delivered: true`; an exit-4 reply is 200 with
`delivered: false` and `exitCode: 4` (this is `replyWith` in control-board/src/serve.mjs, which maps a nonzero exit
to 200, not a status code). Existing coverage cited, not duplicated: `board-inprocess.test.mjs:84-97` (seen 200 and
reply delivered through a named listener); `send.test.mjs:114-119` (loopback Host writes 403 read-only, nothing
reaches the board); `preview.test.mjs:185-199` (every write 403 read-only from the public name and loopback; 401
unsigned on `/api/reply`).
The discord 409 is now reached through the named listener, so `discord.test.mjs` pins the connector refusal on the
real route.
### Not done in this round
- The plain-listener browser check for the reply form and Seen line with send off (the `canSend` gate in app.js).
The node and browser coverage for that gate is not written yet.
- `send-browser.test.mjs:67,243` is unchanged. Webby to confirm next round.
- Mutant M18 and the N1 mutant are not run.
### Gate status
UNVERIFIED. Nothing in this round is run. Waiting for Sage's CLEAR before any node or browser run.
### Correction to the r4 round 2 entry above (19:24Z 10-23)
- The N1 mutant's file is `packages/webui/src/public/app.js`, not `packages/webui/public/app.js`.
- `board-refused.test.mjs` does not use the in-process board. Its `board()` is an inline stub object with a `refuse()`
flag; the 403 comes from a page-level Fetch stub on `*/api/board*` and `*/api/conversation*`, as the N1 ruling
allows. The "in-process board with refuse() set" sentence above is wrong.
## r4 round 2 fixes: mos-1522 (19:40Z 10-23)
Not run. Commit held locally until the suites pass (mos-1522).
- B1 (discord.test.mjs:43, :50): app.js now checks `r.connector` first, then `!canSend`, then the registered-seat text, in both the inspector reply line (app.js:100) and `convForm` (app.js:200, conv-no-reply). Discord still asserts "Board replies disabled for Discord connectors" with no routeWrites added. Assert lines are unchanged.
- B2: `conv-no-reply` with send off reads "Replies are off: this Console is read-only.", asserted in send-off-browser.test.mjs.
- routeWrites flips only `send` to true, over the real `/api/config` answer. Nothing else in the config body is changed.
- Mutants (not run): (a) drop `canSend &&` from `canReply` (app.js:51); (b) drop the `seenButton` guard (app.js:71). Expected: send-off-browser fails on `.reply-form` absent (a) and on `[data-seen]` absent (b).
## r4 round 2 follow-up: Webby CHANGES on ab6417475 (N1, N2, B1 board-refused) (10-23 19:46Z)
Not run. Gate waits for Sage's CLEAR. Commit A of the follow-up; the old-test setup is commit B, below.
- N1: `routeWrites` builds the config URL with `new URL('/api/config', base)`, so a trailing-slash base no longer requests `//api/config` (which parses as host `api`). The config body now comes from one new export, `sendConfig(base)` (board-route.mjs), which `routeWrites` and `sendOnConfig` both use.
- New export `sendOnConfig(b, base)` (board-route.mjs): Fetch answers the page's `/api/config` with send on and leaves every write alone. It is config only (mos-1524); its callers are in commit B.
- board-refused.test.mjs: line 13 imports `sendConfig`; the Fetch pattern list adds `*/api/config` (line 59 onward); the handler answers `/api/config` with `sendConfig(base)` and returns before the refusal branch. The 403 refusal stub is unchanged. The assert that was line 122 (`#conv-form` hidden is false) is now line 128, unchanged; it sees send on.
- N2, the "Not done" list above is out of date. Its line "the canSend check is not written" is wrong: the check is in app.js (ab6417475, mos-1522 fixes in b0c79751c). Written as of this follow-up:
- app.js:24 `canSend` (set from `/api/config` at app.js:482); app.js:51 `canReply` gated on `canSend`; app.js:71 `seenButton` guard.
- app.js:100 inspector reply line and app.js:200 `convForm` (conv-no-reply): connector first, then the read-only line, then the registered-seat line.
- New user-visible strings: "Replies are off: this Console is read-only." and "Seen is off: this Console is read-only.".
- New test files: board-route.mjs (helper), board-route-map.test.mjs (node), board-refused.test.mjs, send-off-browser.test.mjs.
- Mutants planned, not run: (a) drop `canSend &&` from `canReply` (app.js:51); (b) drop the `seenButton` guard (app.js:71). Both must fail in send-off-browser.test.mjs.
## r4 round 2 follow-up, commit B: old-test setup for the canSend gate (Webby B1, mos-1524) (10-23 19:46Z)
Not run. Setup only: no assert line changed. Approved on the mos-1522 terms (mos-1524: config-only stub, not routeWrites). Gate waits for Sage's CLEAR.
Numstat for this commit (`git diff --numstat`):
| file | added | removed | change |
| --- | --- | --- | --- |
| packages/webui/tests/relaunch.test.mjs | 2 | 1 | line 10 imports `sendOnConfig`; line 32 `await sendOnConfig(b, webURL)` after `browser()` |
| packages/webui/tests/shell.test.mjs | 2 | 0 | line 17 imports `sendOnConfig`; line 124 `await sendOnConfig(b, base)`, first test only |
| packages/webui/tests/sso-browser.test.mjs | 1 | 1 | line 33 page stub: `/api/config` gets `send: true` on every page; `sso: true` still only with `sso` in the URL |
- relaunch.test.mjs:49 (`#reply` present, "reply eligibility unchanged") is unchanged and now sees send on. No teardown is needed: `sendOnConfig` starts no server; `b.close()` in the finally ends the Fetch domain.
- shell.test.mjs:217 (`#conv-form` hidden is false) is unchanged and now sees send on. The five-states test (from about line 410) has no reply, Seen or conv-form assertion and is not changed.
- sso-browser.test.mjs:91-92 (`[data-seen]` wait and click) is unchanged. Its `/api/seen` write is still answered 401 `sign-in required` by the page stub before it reaches the server, so the sign-in path is what runs. The sign-in lines and the `__signIn` list are unchanged.
- board-refused.test.mjs:128 (`#conv-form` hidden is false) is in commit A, with its config stub; it needs no change here.
- Totals for this commit: 5 added, 2 removed, 3 files. Assert lines changed: 0.
-652
View File
@@ -1,652 +0,0 @@
# PRD 1.17 draft: Case 2 story files are DOCX (Q235 to Q237, P1 to P6)
Sage, 2026-10-23, for mos-1366 item 1 and Vikunja 570. Draft 9. It is d8 plus Jason's Q41 answer O2 (DECISIONS Q239, mos-1467), which makes J-b yes, Webby's round 5 N15 to N17 (webby-0210) and Webby's round 6 B1, N19 and N20 (webby-0212) (see "d9 changes"). d8 is d7 plus Mos's d8 fix list mos-1443 (see "d8 changes"). d7 is d6 plus Mos's ruling mos-1379 on Fenton's 571 B2 (see "d7 changes"). d6 is d5 with Webby's round 3 (webby-0207) fixed: B1 to B3 and N10 to N13, with N14 left for Mos (see "d6 changes"). d5 folds in:
- Mos's d4 check ruling mos-1372 (D1 to D14; D15 was dropped).
- mos-1374, which replaces J-c.
- The impact map mos-1368 (jason-master-restart d183246, `projects/mosaic-mvp/case2-docx-impact-map.md`: A1 to A16, critic items C1 to C6, items 2 to 7).
- Mos's F4 measurement.
Review order (mos-1367 B5): Webby, then Mos, then Jason. Base: PRD 1.16, `docs/prd/mosaic-stack.md` blob a6db9b4a at 482e865ac.
1.16 lands first (row 291), and this text is a new version on top of it. Nothing here is built or queued. The impact map's queue changes (A24, A57 to A59) go through `scripts/mosaic queue` after 1.17 is approved (C9).
Sources, verbatim in Mos's DECISIONS:
- Q235, Jason's Q36 answer.
- Q236: chapters are DOCX, Markdown for agent files and docs.
- Q237: "It is correct for now (subject to future change)".
- The P1 to P6 reading as restated to Jason in Q38.
## Settled points (mos-1367)
These stand as ruled. d5 changes only what mos-1372 changed.
- **O1. Where a converted chapter lives.** The DOCX lives in the writing store of
O2 design D12, and the archived Markdown stays in the user's repository under a
human-marked archive folder. Ruled yes in B1, on the condition that the text
says plainly that a converted chapter leaves the Gitea-mirrored repository and
lives in the stack's writing store. mos-1372 D7 and mos-1375 add a remote for
the store, off the stack's host.
- **O2. The unit of conversion.** One story file per import, refusing a file
that is already converted. Ruled yes in B2: each file gets its own fidelity
report, human confirmation, manifest and commit.
- **O3. Whether conversion is a vocabulary action.** A human-only verb outside
the agent vocabulary, with no role grant and no agent request path. Ruled yes
in B3.
- **O4. Reading archived Markdown.** A new `archived` scope. Lookups leave it out
unless the caller asks, every answer marks it archived, and a canon citation
can't name it. Ruled yes in B4.
## d3 choices after mos-1372
- **C1 is replaced by D1, keep-source-scope.** A DOCX keeps its source's scope:
universe, book, and canon or playground. A playground DOCX follows the
playground rules and is never returned as canon. The import accepts canon and
playground story files. Type-folder files and supporting files stay Markdown
(P4). This also settles the impact map's Q18, item 4. It leaves one narrowing
for Jason, J-i. A playground DOCX can't become canon in 1.17.
- **C2 stands.** The move keeps the file's path under the archive folder:
`book1/ch3.md` goes to `<archive>/book1/ch3.md`. An archived file's universe
and book are those of its old path.
- **C3 is replaced by D3 and D14.** The archive commit is written by a new
human-only bus step (Vikunja 571) on the C2-02 write plumbing and pushed through
C2-03b. The order is the store commit and the remote's confirmation that it
holds that commit, then the archive commit and its push, then publication to the
lookups.
- **N4 stands.** REQ-LIB-8 says "a real user's books". The PRD names no user
anywhere in Case 2.
## Text changes
### Status and sources
In the Status bullet's first line, "- Status: approved, version 1.16." becomes
"- Status: approved, version 1.17." Status gains, after the two 1.16 sentences at
the end of the bullet: "Version 1.17 records Jason's rulings Q235 to Q237 of
2026-10-23: Case 2 story files are DOCX, Markdown stays for agent files and
documentation, and a human-started import converts a Markdown story file one way.
It also adds REQ-LIB-9, the Console's DOCX editor, from Jason's rulings Q195, Q226
and Q234. It changes what Case 2 requirements allow, so Mos puts the text to Jason
as written, and it lands only after his yes (mos-1366, Vikunja 570)."
New source line, above the 1.16 one: "Version 1.17 source: Jason's Q235, answering
Q36: 'How about we see a markdown file, we convert the markdown file to docx, then
archive or mark the markdown file as converted. The new standard for files in the
creating writing use case is to use docx to handle versioning and WYSIWYG. We
eliminate the potential for confusion based on the files. Agents must understand
which files are what format and where they live. The system must allow for import
and conversion. We will not run the systems in parallel. There will be an
irreversible conversion of the files.' His Q236 says: 'For creative writing
chapters, we need to utilize docx as the standard. Markdown is acceptable for
agent files, instructions, PRDs, documentation, etc. The creative writing files
should be docx for familiarity for the user.' His Q237 says 'Q38: It is correct
for now (subject to future change)', which confirms Mos's reading P1 to P6.
REQ-LIB-9 rests on his Q195 request for 'a full WYSIWYG editor within Mosaic
Stack', his Q226 answer 'F41: We want to move editing into the WYSIWYG in the
WebUI. All changes are instantly committed and available to the agents.' and his
Q234 answer 'Q37: yes' to the exact security header for the editor's frame. The
storage follows the O2 design D10 to D12 as rewritten under Vikunja 571, at
b99ca86b9 on `refactor`."
### Creative library, section lead
Replace "The library is the user's own writing repository." with "The library is
the user's own writing repository plus the stack's writing store, which holds the
DOCX story files (REQ-LIB-8, version 1.17)."
### REQ-LIB-1
Add, before "(Design 1.1": "The mapping also names one archive folder, set by the
human, where converted Markdown story files go. A book's DOCX story files are in
the stack's writing store, under the same universe and book (REQ-LIB-8)." Version
tag, inside the closing parenthesis: "Version 1.15: supporting folders.)" becomes
"Version 1.15: supporting folders. Version 1.17: the archive folder and the
writing store.)"
### REQ-LIB-2
Replace "A file's scope comes from its path alone, prefixes first: a Markdown
file under a supporting prefix is supporting, one under a playground prefix at
any level is playground, any other Markdown file in a book folder is canon, and
everything else is unmapped and never returned. A folder in both lists is
supporting." with:
"A Markdown file's scope comes from its path and from past imports, prefixes
first: a Markdown file under the archive folder is archived, one under a
supporting prefix is supporting, a Markdown file at a converted file's old path is
unmapped under REQ-LIB-8 but flagged in the format map, one under a playground
prefix at any level is playground, any other Markdown file in a book folder is
canon, and everything else is unmapped and never returned. A folder in both lists
is supporting. The archive folder follows the same placement rules and may not
overlap another prefix or a book folder. An archived file keeps the path it had
under the archive folder, and its universe and book are those of that old path. A
DOCX document in the writing store keeps its source's scope: the universe, the
book if there is one, and canon or playground. Its source is the Markdown file it
was converted from or, for one the human starts in the editor, the universe and
book or the playground the human picks (REQ-LIB-8). A playground DOCX follows the
playground rules and is never returned as canon. It stays playground after its
draft universe is made real. In version 1.17 no promotion or mapping change makes
a playground DOCX canon. A later version of this PRD says how one becomes canon."
Replace "Lookups return canon unless the caller asks for playground or
supporting." with "Lookups return canon unless the caller asks for playground,
supporting or archived. Every answer marks an archived file as archived and names
the DOCX that replaced it."
Replace "so an agent can't cite a playground or supporting file as canon" with "so
an agent can't cite a playground, supporting or archived file as canon".
In REQ-LIB-2 only, replace "The Console shows each cite's scope." with "The Console shows each cite's
scope and format, and whether a cited Markdown file was converted."
Version tag, inside the closing parenthesis: "Version 1.15: supporting.)" becomes
"Version 1.15: supporting. Version 1.17: archived scope and DOCX scope.)"
### REQ-LIB-4
Replace "The user commits and pushes with their own tools, today Obsidian with
obsidian-git." with "The user commits and pushes Markdown with their own tools,
today Obsidian with obsidian-git. A converted story file's current text is no
longer in that repository, which keeps only its archived Markdown (REQ-LIB-8)."
Add, after "Bus writes and pushes come in later rows (C2-02, C2-03b).": "A DOCX
story file is saved in the Console's editor, and each accepted save is one commit
by the human in the stack's writing store (REQ-LIB-9). Each writing-store commit
is also pushed to a remote off the stack's host, never by force-push. The remote
is a setting only a human changes, and it starts unset. A repository on the
stack's host never counts as that remote. A missing remote or a failed push never
undoes a save, and the user sees each push that is pending or failed. Only the
stack's host pushes to that remote, and no agent can reach it with any credential.
Agents read the local commit."
Replace "Agents read only the mirror's served commit and never hold a copy of
their own, so they see the user's push within about a minute." with "Agents read
the user's repository only at the mirror's served commit, except a story file
during its import under REQ-LIB-8. They read a DOCX story file only at a committed
version in the writing store, under REQ-LIB-5. They never hold a copy of their
own, so they see the user's Markdown push within about a minute and a DOCX save at
their next lookup after the editor reports it as committed."
Version tag: "and the sync items.)" becomes "and the sync items. Version 1.17,
with Q222 for the remote setting.)"
### REQ-LIB-5
Add, after "Each answer names the commit it read.": "Each answer also names the
file's format and where it lives: its path, or its document id in the writing
store. A DOCX story file is read from its latest committed save, never from
unsaved text. A lookup that starts after the editor reports a save as committed
returns that save or a later one, even while its push to the remote is pending or
has failed, and the answer names the version. Get, search and manuscript read a
DOCX story file as its extracted text with DOCX locators, never as lines. That
text is the file with its tracked changes accepted. An agent reads a story file's
comments, or its text with tracked deletions shown, only after a human turns on
the matching setting. Both settings are off by default, and only a human changes
them. The reader in this version can't read some things a DOCX story file can
hold, such as a web link, a text box, a field or hidden text. The editor warns the
human when an edit adds one, and the human can still save it. Until a later save
takes it out, lookups give agents none of the text of the part that holds it,
whether that is the chapter's body, a note or a comment. The manuscript lookup
follows the book's chapter order across both formats (O2 design D10), and the next
chapter it names is never an archived file. Links out and in resolve across both
formats. The universe lookup returns the format map (REQ-LIB-8)."
Replace "The user sees them in Obsidian's graph and backlinks." with "The user sees
a Markdown file's links in Obsidian's graph and backlinks. A DOCX story file's
wikilinks stay clickable in the Console's editor (O2 design D11), and no graph
shows them yet. The Console's Connections view lists the current links and
backlinks of each story file the user may see, in both formats, with each one's
scope and format."
Version tag: 'and "Connections are always visible".)' becomes 'and "Connections
are always visible". Version 1.17, with Q222 for the two settings and Q239 for
the Connections view.)'
### REQ-LIB-6
Replace "A reply may cite lines of the repository at a commit or pages the agent
fetched, and the broker checks every citation before it stores the message." with
"A reply may cite lines of a Markdown file at a commit, a place in a committed
DOCX story file, or pages the agent fetched, and the broker checks every citation
before it stores the message. A DOCX citation never calls a paragraph a line. It
names the document id, the committed version and a DOCX locator. A citation that
names an archived file as current is refused. The `requireCitation` setting below
applies to Markdown and DOCX citations alike, and so does the rule for a citation
marked as canon."
Add, after "the broker refuses such a write before any git call.": "The writing
store and the archive folder are read-only to a creative agent as well, and the
broker's path check treats the archive folder as story. No agent creates, edits,
deletes, converts, archives or restores a story file in any format, and no agent
writes a DOCX. No creative agent starts or undoes a conversion, and none asks for
one through a tool or a decision it raises, as REQ-LIB-8 says. An agent may still
suggest a conversion to the human in chat. Each creative role's contract states
which files are DOCX and which are Markdown, and cites a DOCX story file by its
locator, never by line."
Version tag, inside the closing parenthesis: "Version 1.15.)" becomes "Version
1.15. Version 1.17.)"
### New REQ-LIB-8
This bullet goes after REQ-LIB-7 and before the "### Installer" heading.
"- **REQ-LIB-8.** Story files become DOCX. DOCX is the standard for chapters and
the other story text in a Case 2 writing project, canon and playground alike. A
story file becomes DOCX through a human's import (below), or when the human
starts it in the editor. From then on DOCX is its only working format, edited in
the Console's editor (REQ-LIB-9). A Markdown story file stays Markdown until its
import. Files in a type folder (REQ-LIB-1), such as character and place notes,
stay Markdown. So do supporting files, agent files, instructions, PRDs and
documentation, including the character sheets, summaries and cross-links agents
keep under REQ-LIB-6.
- A converted story file's current text leaves the user's Gitea-mirrored
repository. Its DOCX lives in the stack's writing store, on the stack's host,
and not in the repository the user pushes or opens in Obsidian. Only its
archived Markdown stays there.
- An import converts one Markdown story file, canon or playground, outside every
type folder (REQ-LIB-1), to a DOCX document in the writing store. It refuses
an unmapped, supporting, archived or type-folder file, and a file that is
already converted. Each import has its own fidelity report, the human's
confirmation of that report, a manifest naming the source file and its commit,
and one commit in the store. The DOCX keeps every wiki link of the source as
written, and a lost link to another library file fails the fidelity report. A
web link becomes plain text that keeps its label and address, and the fidelity
report lists it for the human to confirm. A web link that can't be kept safely
as plain text refuses the import.
- The human can also start a new story file in the editor, in a universe and
book or a playground the human picks. It is a DOCX document in the writing
store from its first commit, with no Markdown copy, and it opens under the
same gate as the import.
- The same import marks the Markdown file converted, naming the document, and
moves it to the archive folder under its old path (REQ-LIB-1, REQ-LIB-2).
The mark and the move are one commit authored by the human in the user's
repository. A new human-only bus step (Vikunja 571) on the C2-02 write
plumbing writes it, and C2-03b pushes it. The move keeps the bytes and the
git history, so it isn't `data.delete`.
- The store commit comes first, and the writing store's remote must confirm it
holds that commit. Then come the archive commit and its push, then publication
to the lookups. Until publication the Markdown at its old path keeps its canon
or playground scope. Agents read that file at the commit the import started
from, even after the mirror's served commit reaches the archive commit or a
later one. No lookup returns the file's archived copy before publication, even
one that asks for archived files. A refusal or failure before the archive push
leaves the Markdown unmoved and no DOCX that a lookup returns. A failure after
the archive push leaves a pending import that the human sees and can resume,
never a claim that nothing moved. An archive push whose result is unknown
counts as a failure after the archive push until the stack reads the result
back from the user's remote.
- Each stage of an import writes a logged event. The import's record on the
stack's host names the source file's path, commit and blob and the new
document id, and each event points to that record.
- After a conversion, a Markdown file at the converted file's old path is
neither canon nor playground. Lookups leave it out, the format map flags it,
and the human is told.
- A new Markdown story file in a book folder that holds converted files stays
canon until its import, unless it sits at a converted file's old path or under
a playground prefix. The Console flags it and offers the import. Nothing
converts it on its own.
- A hybrid's exclusion that covers a converted file's old path also covers its
DOCX document (REQ-LIB-3). An exclusion of a whole book folder also covers
every DOCX document in that book, including one started in the editor. An
exclusion can also name a DOCX document by its id.
- Only a human starts an import or a new story file, from the CLI or a Console
session signed in as that human. Both are human verbs outside the agent action
vocabulary. Neither is added to `ACTIONS` in the business vocabulary or to the
broker's always-gated list, including the import's action, `library.convert`.
No role holds them, no agent can request them, and the hard-wait list in
REQ-WAIT-1 doesn't change. In an organization with several humans, who may
start them is a setting only a human changes, and the default is the owner
only.
- Conversion is one way, with no parallel run. Neither the Console nor any agent
edits a converted story file as Markdown, and no DOCX edit syncs back to
Markdown. The Console's Markdown save refuses every file under the archive
folder. The user's own tools can still change the archived Markdown, and no
such change reaches the DOCX. The old Markdown stays in git history, and
nothing rewrites history. A promotion into a book folder (C2-19) runs the
import for a Markdown story file or is refused. A promotion of a playground
DOCX is refused in version 1.17.
- Agents get a format map: for each mapped folder and story file, its format,
scope and where it lives, archived files, flagged files and pending imports
included.
- The import and new story files ship closed. They open only after four things.
The editor ships. The human has set the writing store's remote, and the stack
has checked that it can push there. Agents can read committed DOCX story files
through the lookups and cite them (REQ-LIB-5, REQ-LIB-6). Any earlier agent
system that writes the user's files has stopped, which for Case 2 is the
Hermes retirement (#423 step 5). So the import itself never cuts agents off
from a chapter, the chapter's current text never lives only on the stack's
host, and no second system writes the story. The first import of a real user's
books waits for Jason's go.
(Q235, Q236, Q237: P1 to P6. Q222 for the setting. O2 design D10 to D12.
Version 1.17.)"
### New REQ-LIB-9
This bullet goes after REQ-LIB-8.
"- **REQ-LIB-9.** The Console's DOCX editor. The human edits a DOCX story file in
the Console's editor, in a split pane with the agent chat alongside it. The
human can pin an agent chat to a document. The human can select a word,
right-click it, and choose a synonym from a thesaurus. Only a human saves. Saves
happen on their own while the human types (Q226), at intervals only a human
changes, and each accepted save is one commit with the human as author, pushed
as REQ-LIB-4 says. A save made against an older version than the current one is
refused. Agents never write a DOCX. Versioning is the editor's tracked changes
plus one git commit per save. The editor's code is vendored and pinned, with a
hash per file and the licenses that its independent security review accepted. At
runtime the editor loads only files that the stack itself serves, and it
requests nothing from any other host. The editor's frame sends exactly the
content security policy Jason approved in Q234, and the WebUI shell's own header
is unchanged. REQ-PROJ-3's project docs editor is unchanged. (Q195, Q226, Q234,
Q236. O2 design D3, D4, D6, D8, D10, D13, D14, D16 and D22. Version 1.17.)"
### Version 1.17 notes
Added after the 1.16 notes:
"- Version 1.17:
- Jason confirmed the reading 'for now (subject to future change)' (Q237). The
rulings stand until he revises them.
- The 10-27 Case 2 release ships on Markdown as built (P6). The 1.11 line 'The
Case 2 MVP for 10-27 is the Markdown library (#416). The WYSIWYG editor (Q195,
Q196) is an add-on that doesn't gate it.' still holds. Conversion opens after
10-27, and only once REQ-LIB-8's four conditions are met. The editor ships,
the writing store's remote is set and the stack has checked that it can push
there, agents can read and cite committed DOCX story files through the
lookups, and Hermes is retired, which is #423 step 5.
- The 1.12 note says five bus write and Console editor rows, C2-02, C2-03b,
C2-11a, C2-11b and C2-19, don't gate 10-27. Jason's Q226 F42 later moved
C2-02, C2-03b and C2-11b into the 10-27 slice. The Case 2 design brings C2-11a
in with them, because C2-11b needs it. C2-19 stays outside the slice. Mos's
reading of F42 is that C2-02 and C2-03b now gate 10-27, because design 9.2
puts them in 'the smallest slice that lets her start'. C2-11a and C2-11b are
slice rows that still don't gate 10-27, because the editor is the Q195 add-on.
The DOCX change in 1.17 leaves this slice as it is, and C2-11b's Markdown
editor never edits a converted story file.
- Q236 narrows Q176's editor choice to DOCX for story files (REQ-LIB-9). The
1.12 note that the Case 2 design keeps Obsidian now holds only for Markdown
files, including story files not yet imported. A DOCX story file is edited
only in the Console's editor under REQ-LIB-9.
- This replaces two parts of the O2 design at 25c09f19f. The superseded-notice
proposal from Fenton's F1 in D12 is withdrawn, and the
keep-Markdown-at-its-path model of D11 is replaced. Gyro's rewrite of the O2
design for Vikunja 571, landed on `refactor` at b99ca86b9, carries the
details, and where it differs from this text, this text holds:
- the import and archive steps, the mark, and the new bus step;
- links from supporting files to a moved chapter;
- the DOCX reader;
- the user's unpushed or later changes to an archived file or at its old path,
and pushing and closing the file in Obsidian before each import;
- the writing store's remote in W4 section 7.3, which replaces the no-remote
and no-backup rule that W4 section 7.4 held before the rewrite, at
062646487;
- import events counted by a bus restore, and recovery when a bus restore or a
restore of the whole host predates an import;
- manifest order for the manuscript lookup and its next chapter;
- `library.convert` kept out of `ACTIONS`, out of the hard-wait note and out
of the pinned `GATED_ONLY` list in packages/business/src/vocabulary.mjs.
- Agents read a converted story file through the Case 2 lookups under the same
project and universe access as Markdown canon, in any thread or room. W4's
earlier O2-only conversations, per-document grants and per-send sharing don't
gate these lookups. The 571 rewrite replaced them in W4 sections 7.1 and 7.2.
The creative tool list in REQ-LIB-6 doesn't change.
- The six creative role contracts row 213 landed say 'Every item cites the
lines of the user's material it is about'. A build row under 571 changes that
line for DOCX story files (REQ-LIB-6).
- A new Markdown file in a book folder is still canon in 1.17. The 1.17
exception is a file at a converted file's old path, which REQ-LIB-2 makes
unmapped.
- Story files convert whether canon or playground. Type-folder and supporting
files stay Markdown.
- Measured 2026-10-23 at about 01:21Z: the Case 2 user's library repository
tracks 213 files, 203 of them Markdown, and none is a .docx or .doc. It has no
untracked files. The user's .doc files (Q196) are outside it. So the landed
code has no DOCX there to report as unreadable at 10-27.
- Text replaced in 1.17, kept here as in earlier notes:
- Creative library lead, the 1.16 sentence: "The library is the user's own
writing repository."
- REQ-LIB-2, the 1.16 sentences: "A file's scope comes from its path alone,
prefixes first: a Markdown file under a supporting prefix is supporting,
one under a playground prefix at any level is playground, any other
Markdown file in a book folder is canon, and everything else is unmapped
and never returned. A folder in both lists is supporting." "Lookups return
canon unless the caller asks for playground or supporting." "so an agent
can't cite a playground or supporting file as canon" "The Console shows
each cite's scope."
- REQ-LIB-4, the 1.16 sentences: "The user commits and pushes with their own
tools, today Obsidian with obsidian-git." "Agents read only the mirror's
served commit and never hold a copy of their own, so they see the user's
push within about a minute."
- REQ-LIB-5, the 1.16 sentence: "The user sees them in Obsidian's graph and
backlinks." A converted chapter's connections show in Obsidian only as they
stood at conversion, and the Console's Connections view shows their
current links.
- REQ-LIB-6, the 1.16 sentence: "A reply may cite lines of the repository at
a commit or pages the agent fetched, and the broker checks every citation
before it stores the message."
- REQ-LIB-1 only gains sentences.
- REQ-LIB-3's text and REQ-LIB-7 are unchanged. REQ-LIB-8 adds one form of
exclusion REQ-LIB-3 doesn't have: naming a DOCX document by its id. A path
exclusion also covers DOCX documents, through a converted file's old path or a
whole book folder. An archived file and a DOCX document keep a universe, and a
book where their source has one, so every lookup still names one universe.
REQ-PROJ-3, the Console editor for a project's spec and docs, is unchanged.
C2-11b opens the library's Markdown files in that same editor.
- In `packages/tasks/requirements.json` the titles of REQ-LIB-1, REQ-LIB-2 and
REQ-LIB-4 change, and those of REQ-LIB-5 and REQ-LIB-6 stay. REQ-LIB-1 becomes
'A human-set library maps the user's repository onto universes, books, types,
playgrounds, supporting and archive folders'. REQ-LIB-2 becomes 'Scope comes
from the path or a DOCX's source, lookups default to canon, and only canon
files are ever cited as canon'. REQ-LIB-4 becomes 'Git holds every change. The
bus fast-forwards a read-only mirror every 60 seconds, the one place agents
read Markdown'. REQ-LIB-8 and REQ-LIB-9 add two ids, so the count goes from 85
to 87. REQ-LIB-8 is titled 'Case 2 story files become DOCX by a human-only
one-way import or in the editor; Markdown stays for notes and docs', and
REQ-LIB-9 'The Console's DOCX editor: only a human saves, each save is one
commit, and the editor code is vendored and pinned'.
- Open points O1 to O4 of draft 1 were ruled by Mos in mos-1367 B1 to B4.
- Sage files the build rows under Vikunja 570 and 571 after the rewrite is
approved. This amendment adds the requirements only."
### Risks
The new lines go at the end of "## Risks and open questions", after the bullet
that begins "Trusted mode lets an agent" and before the "## Milestones" heading.
- "A DOCX file is a zip archive, so git can't show a line diff of a chapter.
Review of a story change goes through the editor and the committed reader,
not `git diff`. (Version 1.17.)"
- "A converted chapter's DOCX is not in Obsidian's graph. Obsidian shows the
chapter only through its archived Markdown, and no DOCX edit reaches that file's
links. The DOCX's current links show in the Console's editor and its
Connections view, and no graph shows them yet. (Version 1.17.)"
- "Conversion can't be undone in place. Front matter, wiki link forms and
unsupported blocks may not survive it, and the mirror can't refuse the user's
push to an archived file. The controls are the fidelity report, the human's
confirmation, an untouched original on refusal, and the kept archive.
(Version 1.17.)"
- "A restore of the stack's bus alone from a backup taken before an import drops
the import's logged events, while the archive commit and the DOCX stay. A
restore of the stack's whole host from such a backup also takes the writing
store and the host's import records back to before the import. The archive
commit stays in the user's Gitea repository and the DOCX stays on the writing
store's remote, but the host keeps no record of the import. A bus restore counts
import events, and the import's recovery covers both cases (Vikunja 571).
(Version 1.17.)"
## d9 changes (Q239 and Webby round 5)
- J-b yes (Q239, mos-1467), in the five edits mos-1443 R3 set for a yes (Webby round 6 B1). R3.1: REQ-LIB-5's replacement gains the Connections view sentence after "no graph shows them yet.". R3.2: the graph Risks line ends with the editor and the Connections view. R3.3: the A6 line moves from "Not taken" under A6. R3.5: the REQ-LIB-5 note ends with the Connections view, which drops "until a Console view of their links exists" and "Q226 records that view as a later option". R3.4 is Gyro's design edit at 767bb62c7 (writing-editor.md:188-190, :240-244 and :1272, docx-citations.md:66), and it follows in his docs round. Beyond these and N21, nothing else in the PRD text changes.
- N19 and N20 (Webby round 6): J-b's question text is in the past tense, and the line that the import doesn't wait for the view is cited as Mos's R3 default. Mos confirmed it at his J-b check (mos-1470).
- N21 (Webby round 6, Mos yes in mos-1470): REQ-LIB-5's version tag cites Q239 for the Connections view beside Q222 for the two settings.
- N15: the D7 landing line gives R8's wording beside the impact map's quote.
- N16: the d7 changes line notes that R8 replaces "no agent credential reaches it" in d8.
- N17: the REQ-LIB-2 Replace quote and the REQ-LIB-8 old-path bullet are rewrapped under 82 columns. Their words are unchanged.
## d8 changes (Mos's d8 fix list, check wf_63bba879-180)
- R1: REQ-LIB-4's tail covers DOCX saves, which agents see at their next lookup after the editor reports them as committed. The notes quote the whole 1.16 sentence. Source: nit/jason/F10.
- R2: REQ-LIB-4's new title says the mirror is "the one place agents read Markdown". Source: decisions.n14.
- R3: J-b asks whether 1.17 requires the Console Connections view, with Mos's recommendation of yes. Sources: mos1372/F9, jason/F2, anchors/F5.
- R4: C2-02 and C2-03b gate 10-27, C2-11a and C2-11b are slice rows that don't, and "F42 wins" is gone. New J-k. Sources: anchors/F6, jason/F5.
- R5: where the landed 571 design differs, this text holds. Sources: anchors/F8, adversarial/F8 item 1, jason/F8, critic/C6, G16's design follow-on.
- R6: `library.convert` stays out of `ACTIONS`, and REQ-WAIT-1's hard-wait list doesn't change. Sources: gyro571/F1, adversarial/F8 item 3, critic/C6.
- R7: a repository on the stack's host never counts as the writing store's remote, and Item 2 says so. Sources: mos1372/F2, adversarial/F1, gyro571/F2.
- R8: "no agent credential reaches it" becomes "no agent can reach it with any credential". Source: Fenton's B1 under mos-1379.
- R9: no playground DOCX becomes canon in 1.17, and a promotion of one is refused. New J-i. Sources: mos1372/F8, adversarial/F2.
- R10: REQ-LIB-9 restates Q195's pin and right-click thesaurus, and its cite gains D4 and D22. Source: critic/C4.
- R11: REQ-LIB-9's license bar is the licenses its independent security review accepted. New J-l. Source: critic/C3.
- R12: no text for D16's narrow-window tabs. Mos records that reading in STATE. Sources: jason/F1, second verifier vote.
- R13: no lookup returns a file's archived copy before publication. Source: adversarial/F5 part 3.
- R14: a restore of the whole host from a backup taken before an import is a fourth recovery case. Source: adversarial/F6.
- M1: REQ-LIB-4's writing-store sentences follow the later bus rows sentence and add the off-host remote rules and DOCX read timing. The note quotes both 1.16 sentences. Sources: mos1372/F2, adversarial/F1, gyro571/F2, anchors/F4, nit/jason/F10, adversarial/F5 part 1, Fenton B1.
- M2: REQ-LIB-8's order bullet adds the remote's confirmation, the old-path read rule and "archive push". Sources: mos1372/F2 and the other remote findings, mos1372/F7, adversarial/F5 parts 2 to 4.
- M3: REQ-LIB-8's gate bullet has four conditions, adding the remote. Sources: the remote findings, critic/C1 and C2 edit 2.
- M4: the 1.17 note on when conversion opens restates the four conditions and drops W4b. Sources: jason/F5, the remote findings.
- M5: Item 2 rewritten with the remote rules. Sources: the remote findings.
- M6: the 1.12 gating note rewritten. Sources: anchors/F6, jason/F5.
- M7: REQ-LIB-9 gains the split pane, the pin, the thesaurus, the license bar and the runtime load rule, and cites more of the design. Sources: jason/F1, critic/C4, critic/C3, nit/anchors/F12, nit/gyro571/F9.
- M8: REQ-LIB-2, the one-way bullet and the C1 note keep a playground DOCX out of canon in 1.17. Sources: mos1372/F8, adversarial/F2.
- M9: REQ-LIB-5 says what the reader can't read, and the import turns web links into plain text. New J-j. Sources: critic/C1, critic/C2.
- M10: an exclusion of a whole book folder covers that book's DOCX documents. Source: adversarial/F3, with B5.
- M11: the human-verbs sentence and the 571 list item name `library.convert` and REQ-WAIT-1. Sources: gyro571/F1, adversarial/F8 item 3, critic/C6.
- M12: J-b rewritten. Sources: mos1372/F9, jason/F2, anchors/F5, gyro571/F3, critic/C6.
- M13: J-d puts the agent read model to Jason as his call. Source: mos1372/F1.
- M14: Status moves to version 1.17 and names REQ-LIB-9. Sources: anchors/F2, mos1372/F4, critic/C7.
- M15: the 1.17 source line quotes Q235 in full (mos-1446) and the REQ-LIB-9 sources, at b99ca86b9. Sources: critic/C5, anchors/F9, jason/F8 pin part, nit/gyro571/F8, critic/C6 pin part.
- M16: the 571 list lead names the two replaced design parts and the landed design. Sources: anchors/F9, anchors/F8, adversarial/F8 item 1, jason/F8, critic/C6.
- Landing swap: Gyro's docs round for R5, R6 and R14 landed as b99ca86b9 (Fenton fenton-0015), so M15 and M16 cite b99ca86b9 in place of 767bb62c7, as the d8 fix list's header says. J-b keeps 767bb62c7 for its line numbers.
- M17: W4 section numbers in the remote item and the lookups note. Sources: gyro571/F5, nit/adversarial/F11.
- M18: the D7 landing line. Sources: gyro571/F5, the remote findings.
- M19: a logged event for each import stage, in REQ-LIB-8 and A2. Source: gyro571/F7.
- M20: the Risks line on restores adds the whole-host case. Sources: adversarial/F6, gyro571/F7.
- M21: the 571 restore item adds the whole-host case. Source: adversarial/F6.
- M22: the graph Risks line and the REQ-LIB-5 note name a Console view. Sources: anchors/F5, jason/F2.
- M23: the d6 B3 line drops "F42 wins". Source: anchors/F6.
- M24: new Markdown in a converted book, with the old-path and playground exceptions. Source: mos1372/F6.
- M25: J-c's last sentence is Mos's reading. Source: mos1372/F10.
- M26: J-h rewritten with the D13 defaults. Source: jason/F3.
- M27: Item 4 rewritten. Sources: jason/F4, gyro571/F4, decisions.unmarked_folders.
- M28: the "Not taken" note on unmarked folders. Sources: jason/F4, gyro571/F4, decisions.unmarked_folders.
- M29: the import refuses an unmapped file. Sources: gyro571/F4, decisions.unmarked_folders.
- M30: placement notes for REQ-LIB-8, REQ-LIB-9 and the Risks lines. Sources: anchors/F3, nit/anchors/F14.
- M31: REQ-LIB-4's title and the D11 landing line. Source: decisions.n14.
- M32: the Q236 note limits the 1.12 Obsidian note to Markdown files. Source: critic/C8.
- M33: the exclusion note adds path exclusions of DOCX documents. Source: anchors/F7.
- M34: A9 moves from "Mine" to the impact-map list. Source: decisions.exclusion_by_id.
- M35: the D5 landing line. Sources: anchors/F10, mos1372/F5, nit/adversarial/F12.
- M36: the D9 and D14 landing lines. Source: mos1372/F1.
- M37: the O1 and C3 notes add the off-host remote and its confirmation. Sources: the remote findings.
- M38: the REQ-PROJ-3 note. Source: nit/anchors/F11.
- M39: REQ-LIB-5's freshness sentence keys on the editor reporting a save as committed. Source: nit/adversarial/F9.
- M40: REQ-LIB-6 bars agent requests for a conversion and allows a suggestion in chat. Source: nit/jason/F9.
- M41: REQ-LIB-2's opening and old-path rule, and the "Mine" note. Source: nit/adversarial/F10.
- M42: REQ-LIB-8's lead sentence on Markdown story files. Source: nit/mos1372/F12.
- M43: REQ-LIB-6's citation sentence. Source: nit/anchors/F13.
- M44: REQ-LIB-5's version tag uses plain quotes. Source: nit/anchors/F15.
- M45: reference codes B5, D12, D10 and B4 name their source. Source: nit/mos1372/F11.
- Files the row would own: four files, with the id count going to 87. Sources: anchors/F1, adversarial/F4, decisions.n14 F4.
## d7 changes (mos-1379, Fenton's 571 B2)
- REQ-LIB-5 gains Mos's three sentences after the extracted-text sentence. The text agents read has tracked changes accepted. Comments, and text with tracked deletions shown, each wait for a setting only a human turns on, off by default. Its version tag cites Q222 for the two settings, which is the only text here beyond Mos's.
- J-d gains Mos's sentence on comments and tracked deletions.
- Fenton's B1 is design only. d6's "no agent credential reaches it" holds (R8 replaces it in d8), and the shared helper stays #431.
## d6 changes (Webby round 3, webby-0207)
- B1: titles for REQ-LIB-8 and REQ-LIB-9 in the requirements.json note and the file list. REQ-LIB-9 takes Webby's title. REQ-LIB-8 keeps mine, because Webby's ("...one-way import that archives the Markdown under its old path") leaves out D2's story files started in the editor.
- B2: REQ-LIB-2 makes a Markdown file at a converted file's old path unmapped, ahead of the playground and canon rules, so a playground conversion is covered too. REQ-LIB-8's old-path bullet says "neither canon nor playground".
- B3: the slice note names Q226 F42's three rows (C2-02, C2-03b, C2-11b). C2-11a joined the slice with C2-11b, because C2-11b needs it. Both are slice rows that don't gate 10-27, as design 9.2, case2-rows.md:1655 and the impact map's A57 say. The 'F42 wins' sentence is gone from the note in d8, so no queue change follows from it.
- N10: the cite-scope replacement is scoped to REQ-LIB-2. The 1.14 note's copy stays.
- N11: the notes say REQ-LIB-8 adds the by-id exclusion form REQ-LIB-3 doesn't have.
- N12: J-h marks the autosave intervals for Jason.
- N13: J-b names REQ-LIB-5's "Connections are always visible" tag and Q226's "another way to handle the visual brain map".
- N14, not taken: REQ-LIB-4's title stays internal F3's text, as mos-1372 D11 ruled. Webby's alternative, "...and agents read Markdown from it alone" (120 chars), is Mos's call.
## Where each mos-1372 item landed
- D1: REQ-LIB-2's DOCX sentences, the REQ-LIB-8 lead and import bullet, C1 above, and the notes. "Playground files stay Markdown in 1.17" is gone.
- D2: REQ-LIB-2's "for one the human starts in the editor" and the REQ-LIB-8 new-story-file bullet. The universe-and-book note is now true.
- D3: the REQ-LIB-8 order bullet. This also settles N8.
- D4: REQ-LIB-8's one-way bullet, which carries the archive refusal and consequence F2's text, and the 571 list item.
- D5: it landed in REQ-LIB-2's "a Markdown file at a converted file's old path is unmapped" in Webby's B2 form, and in the REQ-LIB-8 old-path bullet.
- D6: REQ-LIB-5's freshness sentences.
- D7 and mos-1375: REQ-LIB-4's remote sentences, the remote step in REQ-LIB-8's order bullet, the fourth opening condition and the 1.17 note on when conversion opens, plus the 571 list item on W4 section 7.3, which replaces the no-remote rule of the old W4 section 7.4. The impact map's item 2 adds "only the stack's host pushes, and no agent credential reaches it", which R8 words in d8 as "no agent can reach it with any credential".
- D8: the REQ-LIB-5 replacement, the import bullet's wiki-link sentence, the Risks line, the note quoting the old sentence, and "REQ-LIB-1 only gains sentences".
- D9: the 1.17 note that agents read a converted story file through the Case 2 lookups, from the d4 check's consistency B2, and J-d, which puts the read model to Jason as his call.
- D10: the d4 check's B5 sentence, in REQ-LIB-8.
- D11: internal F3's clause in the REQ-LIB-4 section, and in the notes the title as Mos amended it for N14.
- D12: the whole 1.16 sentence replaced, and quoted in full in the notes.
- D13: consequence F1's text, in REQ-LIB-4 and REQ-LIB-8's first bullet.
- D14: the row text from the d4 check's consistency B1, in the mark-and-move bullet and C3 above.
## What d5 adds beyond mos-1372 (for Webby and Mos to check)
From the impact map, folded as mos-1368 item 1 asked:
- A1: the Console shows a cite's format and converted state (REQ-LIB-2).
- A2: the logged events and the several-humans setting, default owner only, under Q222.
- A3: the import is outside the vocabulary, the hard-wait list is unchanged, and the archive move isn't `data.delete`. The C6 code site is in the 571 list.
- A4: the section lead and REQ-LIB-1's writing-store sentence.
- A6: format and location in every answer, extracted text with DOCX locators, links across formats.
- A6's named chapter-links view. D8 took B8's "no graph shows them yet", and J-b put the view to Jason. His O2 (Q239) takes it in d9 as REQ-LIB-5's Connections view (mos-1443 R3).
- A7: read-only extends to the store and the archive, no agent action on a story file in any format, no agent DOCX write, and role contracts state the formats. C1's role contract line is a 571 build item.
- A8: the DOCX cite shape, and a cite naming an archived file as current is refused.
- A9: a hybrid sees a source's DOCX canon through REQ-LIB-2's source scope, and REQ-LIB-8 lets an exclusion name a DOCX document by its id. The id form lets the human exclude a single DOCX started in the editor, which has no old path. An archived file isn't canon, so no hybrid sees it.
- A10: REQ-LIB-9, so the count is 87, not 86.
- A11: the Hermes gate, from item 3.
- A12: the C2-19 promotion sentence.
- A13: the broker's path check treats the archive folder as story.
- A14 and C5: two Risks lines.
- A15: the flagged-new-file bullet. It sits beside the note from the d4 check's B4, "a new Markdown file in a book folder is still canon". The file stays canon and is offered for import.
- A16: three titles change and two stay.
- C2: manifest order for the manuscript lookup.
- F3: the C2-02 gating note.
- F4: Mos's measurement.
Mine, beyond either source:
- REQ-LIB-2 opens "A Markdown file's scope comes from its path and from past imports". DOCX scope comes from the source, so "a file's" would be false.
- REQ-LIB-9's autosave sentence. It is Mos's reading of F41's "instantly committed", with human-set intervals. The exact intervals stay in the design.
Not taken:
- Impact map item 4's "files in unmarked folders are eligible one at a time", for folders outside every book folder and playground only. Their files are unmapped under REQ-LIB-2, so a DOCX made from one would have no universe or book to keep. The human maps such a folder into a book folder or a playground first, under REQ-LIB-1. A file in an unmarked folder inside a book folder or a playground is canon or playground, and the import takes it one file at a time.
## Marked for Jason (additions in the text he sees)
- J-a. Whether "We will not run the systems in parallel" applies per chapter or as one switch per book. His call. As written, it applies per chapter.
- J-b. A converted chapter's DOCX is not in Obsidian's graph. Obsidian shows the chapter only through its archived Markdown, and no DOCX edit reaches that file's links. REQ-LIB-5 still cites two TARGET lines, "Visualization of data via Obsidian or equivalent is required" and "Connections are always visible". Mos's reading was that 1.17 as d8 wrote it met neither line for converted chapters. Their current links showed to the user only as clickable text inside the editor, and no graph or list showed them. Q226 leaves room for "another way to handle the visual brain map". The O2 design that landed for Vikunja 571 at 767bb62c7 held a Console Connections view and its tests as later work that waited for this answer. His call was whether 1.17 requires that view. As d8 was written, it did not. Mos recommended that it does, because impact map A6 says "Recommended: name the view" and TARGET says "Connections are always visible". Jason answered O2 (Q239): 1.17 requires the view. d9 writes it into REQ-LIB-5, its note and the Risks line, in the text of mos-1443 R3. The view comes with the conversion work after 10-27, and the 10-27 Case 2 release stays on Markdown (Q235 P6, Q237). That the import does not wait for the view is Mos's default in mos-1443 R3. Q239 doesn't address it, and Mos confirmed it at his J-b check (mos-1470).
- J-c (mos-1374): "The store pushes to a remote (D7), and the Case 2 VM is in the nightly PBS backup. For a fixed restore point before her first real import, a manual backup of VM 1012 with PBS protect runs first. That step is reversible and needs no reboot." Mos's reading is that this sits inside the P5 go step, so it needs no new question.
- J-d. How agents read a converted chapter, D9. His call. As written, an agent reads it through the ordinary Case 2 lookups in any thread or room, under the same project and universe access as Markdown canon. This is Mos's reading of F41 and REQ-LIB-7. The other choice is the model in the earlier O2 design, W4 sections 7.1 and 7.2. Under it, an agent reads a converted chapter only in a new writing conversation that holds one human and one agent, only after the business owner grants that agent access to that document, and by default only what the human attaches to each message. Comments and tracked deletions stay hidden from agents until a human turns on a setting for each. Markdown has neither, so the default keeps agents at the access Markdown gives them. Those two sentences are Mos's ruling mos-1379. A yes to the text as written settles it.
- J-e. Versioning means the editor's tracked changes plus one git commit per save.
- J-f. A converted chapter can be edited only in the Console while the stack is reachable. Obsidian no longer edits it offline.
- J-g. The import takes Markdown only. Importing .doc files stays a separate task.
- J-h. Autosave under REQ-LIB-9. Jason's Q226 F41 says "All changes are instantly committed and available to the agents." Mos's reading, recorded with that ruling, is "Each save is a commit at once, and agents read it at their next lookup." At the O2 design's D13 defaults, a save commits at most 2 seconds after a pause in typing or 15 seconds into steady typing, with no human action needed. Mos's reading in mos-1359 counts that delay as "instantly committed". Only a human changes the intervals. The PRD names no values, and the defaults stay in the design. Whether a delay of seconds fits his word "instantly" is his call, so this is marked for him.
- J-i. In 1.17 a playground story that is DOCX can't become canon. That covers a playground Markdown file the human imports, a story the human starts in a playground in the editor, and the DOCX of a draft universe after it is made real. Today Melanie promotes a playground file by moving it into a book folder in Obsidian, and a DOCX in the writing store can't be moved that way. She can start a new story in the book and paste the text in, but it gets a new document id and the playground copy keeps the history. A later version adds DOCX promotion with C2-19, which comes after the slice.
- J-j. Web links, text boxes and fields in a converted chapter. Jason's Q226 F41 says "All changes are instantly committed and available to the agents." The reader in this version can't read a web link, a text box, a field or hidden text. As written, the import turns each web link into plain text that keeps its label and address, and the human confirms that in the fidelity report. A web link that can't be kept safely as plain text refuses the import. If a later edit adds one of these, the save keeps it and the editor warns the human. Agents then get none of that part's text until a save takes it out.
- J-k. The 1.12 note Jason approved says bus writes, C2-02 and C2-03b, don't gate 10-27. His Q226 "F42: ok" moved them into the 10-27 slice, and design 9.2 counts them in the smallest slice that lets Melanie start. Mos reads that as making them gate 10-27. The editor rows C2-11a and C2-11b still don't gate it, per his Q195 words "we need to get it moving as an add-on".
- J-l. REQ-LIB-9 does not use REQ-PROJ-3's "permissive license" bar. It takes the licenses that the editor's independent security review accepted, because that review cleared fonts under the SIL Open Font License and the Ubuntu Font License, which not every reader counts as permissive. Mos's reading.
Marked as changes beyond P1 to P6, from the impact map (Q17, item 5):
- A1 (archived scope and canon cites)
- A2 (the conversion requirement, REQ-LIB-8)
- A3 (outside the vocabulary, hard-wait list unchanged)
- A6 (lookups across formats)
- A7 (agent bounds)
- A8 (DOCX cites)
- Item 2 and mos-1375: each writing-store commit is pushed to a remote off the stack's host. Only a human sets it, it starts unset, and a repository on the stack's host doesn't count. Only the stack's host pushes to it, and no agent can reach it with any credential. Import and new story files stay closed until the remote is set and checked, and each import's archive push waits until the remote holds the DOCX commit. This keeps "Git is used for all changes -- local and remote" true for DOCX chapters.
- Item 3 (the Hermes gate)
- Item 4. Canon and playground story files convert one file at a time, including files in unmarked folders inside a book folder or a playground. Type-folder and supporting files stay Markdown. A story file outside every book folder and playground has no universe or book, so it converts only after a human maps its folder into a book folder or a playground. The mapping step is Mos's reading of P1 and Q30.
The F4 measurement goes with them.
## Files the row would own
The id count goes from 85 to 87. 1.16 owned three files because its count stayed at 85. Like 1.13, the last version that added ids, the row owns four files:
- `docs/prd/mosaic-stack.md`
- `packages/tasks/requirements.json`: the version 1.17, the two new ids REQ-LIB-8 and REQ-LIB-9 with their titles, and the changed titles of REQ-LIB-1, REQ-LIB-2 and REQ-LIB-4. The new ids go after REQ-LIB-7 and before REQ-INST-1, in the same order as the PRD's bullets.
- `packages/tasks/tests/requirements.test.mjs`: the version pin 1.17, the id count of 87, and the duplicate-id message, which goes from `requirements[85].id: REQ-ROLE-1 twice` to `requirements[87].id: REQ-ROLE-1 twice`.
- `packages/bus/tests/requirements.test.mjs`: the list length, which goes from 85 to 87.
The gate would match 1.16's:
1. A reviewer approves the text.
2. Jason says yes to the exact text through Mos.
3. Both `requirements.test.mjs` files, in `packages/tasks/tests` and `packages/bus/tests`, and every `scripts/test-*.sh` that reads the PRD or `requirements.json` are green on Sage's gate.
4. Mos gets the landing commit.
-652
View File
@@ -1,652 +0,0 @@
# PRD 1.17 draft: Case 2 story files are DOCX (Q235 to Q237, P1 to P6)
Sage, 2026-10-23, for mos-1366 item 1 and Vikunja 570. Draft 9. It is d8 plus Jason's Q41 answer O2 (DECISIONS Q239, mos-1467), which makes J-b yes, Webby's round 5 N15 to N17 (webby-0210) and Webby's round 6 B1, N19 and N20 (webby-0212) (see "d9 changes"). d8 is d7 plus Mos's d8 fix list mos-1443 (see "d8 changes"). d7 is d6 plus Mos's ruling mos-1379 on Fenton's 571 B2 (see "d7 changes"). d6 is d5 with Webby's round 3 (webby-0207) fixed: B1 to B3 and N10 to N13, with N14 left for Mos (see "d6 changes"). d5 folds in:
- Mos's d4 check ruling mos-1372 (D1 to D14; D15 was dropped).
- mos-1374, which replaces J-c.
- The impact map mos-1368 (jason-master-restart d183246, `projects/mosaic-mvp/case2-docx-impact-map.md`: A1 to A16, critic items C1 to C6, items 2 to 7).
- Mos's F4 measurement.
Review order (mos-1367 B5): Webby, then Mos, then Jason. Base: PRD 1.16, `docs/prd/mosaic-stack.md` blob a6db9b4a at 482e865ac.
1.16 lands first (row 291), and this text is a new version on top of it. Nothing here is built or queued. The impact map's queue changes (A24, A57 to A59) go through `scripts/mosaic queue` after 1.17 is approved (C9).
Sources, verbatim in Mos's DECISIONS:
- Q235, Jason's Q36 answer.
- Q236: chapters are DOCX, Markdown for agent files and docs.
- Q237: "It is correct for now (subject to future change)".
- The P1 to P6 reading as restated to Jason in Q38.
## Settled points (mos-1367)
These stand as ruled. d5 changes only what mos-1372 changed.
- **O1. Where a converted chapter lives.** The DOCX lives in the writing store of
O2 design D12, and the archived Markdown stays in the user's repository under a
human-marked archive folder. Ruled yes in B1, on the condition that the text
says plainly that a converted chapter leaves the Gitea-mirrored repository and
lives in the stack's writing store. mos-1372 D7 and mos-1375 add a remote for
the store, off the stack's host.
- **O2. The unit of conversion.** One story file per import, refusing a file
that is already converted. Ruled yes in B2: each file gets its own fidelity
report, human confirmation, manifest and commit.
- **O3. Whether conversion is a vocabulary action.** A human-only verb outside
the agent vocabulary, with no role grant and no agent request path. Ruled yes
in B3.
- **O4. Reading archived Markdown.** A new `archived` scope. Lookups leave it out
unless the caller asks, every answer marks it archived, and a canon citation
can't name it. Ruled yes in B4.
## d3 choices after mos-1372
- **C1 is replaced by D1, keep-source-scope.** A DOCX keeps its source's scope:
universe, book, and canon or playground. A playground DOCX follows the
playground rules and is never returned as canon. The import accepts canon and
playground story files. Type-folder files and supporting files stay Markdown
(P4). This also settles the impact map's Q18, item 4. It leaves one narrowing
for Jason, J-i. A playground DOCX can't become canon in 1.17.
- **C2 stands.** The move keeps the file's path under the archive folder:
`book1/ch3.md` goes to `<archive>/book1/ch3.md`. An archived file's universe
and book are those of its old path.
- **C3 is replaced by D3 and D14.** The archive commit is written by a new
human-only bus step (Vikunja 571) on the C2-02 write plumbing and pushed through
C2-03b. The order is the store commit and the remote's confirmation that it
holds that commit, then the archive commit and its push, then publication to the
lookups.
- **N4 stands.** REQ-LIB-8 says "a real user's books". The PRD names no user
anywhere in Case 2.
## Text changes
### Status and sources
In the Status bullet's first line, "- Status: approved, version 1.16." becomes
"- Status: approved, version 1.17." Status gains, after the two 1.16 sentences at
the end of the bullet: "Version 1.17 records Jason's rulings Q235 to Q237 of
2026-10-23: Case 2 story files are DOCX, Markdown stays for agent files and
documentation, and a human-started import converts a Markdown story file one way.
It also adds REQ-LIB-9, the Console's DOCX editor, from Jason's rulings Q195, Q226
and Q234. It changes what Case 2 requirements allow, so Mos puts the text to Jason
as written, and it lands only after his yes (mos-1366, Vikunja 570)."
New source line, above the 1.16 one: "Version 1.17 source: Jason's Q235, answering
Q36: 'How about we see a markdown file, we convert the markdown file to docx, then
archive or mark the markdown file as converted. The new standard for files in the
creating writing use case is to use docx to handle versioning and WYSIWYG. We
eliminate the potential for confusion based on the files. Agents must understand
which files are what format and where they live. The system must allow for import
and conversion. We will not run the systems in parallel. There will be an
irreversible conversion of the files.' His Q236 says: 'For creative writing
chapters, we need to utilize docx as the standard. Markdown is acceptable for
agent files, instructions, PRDs, documentation, etc. The creative writing files
should be docx for familiarity for the user.' His Q237 says 'Q38: It is correct
for now (subject to future change)', which confirms Mos's reading P1 to P6.
REQ-LIB-9 rests on his Q195 request for 'a full WYSIWYG editor within Mosaic
Stack', his Q226 answer 'F41: We want to move editing into the WYSIWYG in the
WebUI. All changes are instantly committed and available to the agents.' and his
Q234 answer 'Q37: yes' to the exact security header for the editor's frame. The
storage follows the O2 design D10 to D12 as rewritten under Vikunja 571, at
b99ca86b9 on `refactor`."
### Creative library, section lead
Replace "The library is the user's own writing repository." with "The library is
the user's own writing repository plus the stack's writing store, which holds the
DOCX story files (REQ-LIB-8, version 1.17)."
### REQ-LIB-1
Add, before "(Design 1.1": "The mapping also names one archive folder, set by the
human, where converted Markdown story files go. A book's DOCX story files are in
the stack's writing store, under the same universe and book (REQ-LIB-8)." Version
tag, inside the closing parenthesis: "Version 1.15: supporting folders.)" becomes
"Version 1.15: supporting folders. Version 1.17: the archive folder and the
writing store.)"
### REQ-LIB-2
Replace "A file's scope comes from its path alone, prefixes first: a Markdown
file under a supporting prefix is supporting, one under a playground prefix at
any level is playground, any other Markdown file in a book folder is canon, and
everything else is unmapped and never returned. A folder in both lists is
supporting." with:
"A Markdown file's scope comes from its path and from past imports, prefixes
first: a Markdown file under the archive folder is archived, one under a
supporting prefix is supporting, a Markdown file at a converted file's old path is
unmapped under REQ-LIB-8 but flagged in the format map, one under a playground
prefix at any level is playground, any other Markdown file in a book folder is
canon, and everything else is unmapped and never returned. A folder in both lists
is supporting. The archive folder follows the same placement rules and may not
overlap another prefix or a book folder. An archived file keeps the path it had
under the archive folder, and its universe and book are those of that old path. A
DOCX document in the writing store keeps its source's scope: the universe, the
book if there is one, and canon or playground. Its source is the Markdown file it
was converted from or, for one the human starts in the editor, the universe and
book or the playground the human picks (REQ-LIB-8). A playground DOCX follows the
playground rules and is never returned as canon. It stays playground after its
draft universe is made real. In version 1.17 no promotion or mapping change makes
a playground DOCX canon. A later version of this PRD says how one becomes canon."
Replace "Lookups return canon unless the caller asks for playground or
supporting." with "Lookups return canon unless the caller asks for playground,
supporting or archived. Every answer marks an archived file as archived and names
the DOCX that replaced it."
Replace "so an agent can't cite a playground or supporting file as canon" with "so
an agent can't cite a playground, supporting or archived file as canon".
In REQ-LIB-2 only, replace "The Console shows each cite's scope." with "The Console shows each cite's
scope and format, and whether a cited Markdown file was converted."
Version tag, inside the closing parenthesis: "Version 1.15: supporting.)" becomes
"Version 1.15: supporting. Version 1.17: archived scope and DOCX scope.)"
### REQ-LIB-4
Replace "The user commits and pushes with their own tools, today Obsidian with
obsidian-git." with "The user commits and pushes Markdown with their own tools,
today Obsidian with obsidian-git. A converted story file's current text is no
longer in that repository, which keeps only its archived Markdown (REQ-LIB-8)."
Add, after "Bus writes and pushes come in later rows (C2-02, C2-03b).": "A DOCX
story file is saved in the Console's editor, and each accepted save is one commit
by the human in the stack's writing store (REQ-LIB-9). Each writing-store commit
is also pushed to a remote off the stack's host, never by force-push. The remote
is a setting only a human changes, and it starts unset. A repository on the
stack's host never counts as that remote. A missing remote or a failed push never
undoes a save, and the user sees each push that is pending or failed. Only the
stack's host pushes to that remote, and no agent can reach it with any credential.
Agents read the local commit."
Replace "Agents read only the mirror's served commit and never hold a copy of
their own, so they see the user's push within about a minute." with "Agents read
the user's repository only at the mirror's served commit, except a story file
during its import under REQ-LIB-8. They read a DOCX story file only at a committed
version in the writing store, under REQ-LIB-5. They never hold a copy of their
own, so they see the user's Markdown push within about a minute and a DOCX save at
their next lookup after the editor reports it as committed."
Version tag: "and the sync items.)" becomes "and the sync items. Version 1.17,
with Q222 for the remote setting.)"
### REQ-LIB-5
Add, after "Each answer names the commit it read.": "Each answer also names the
file's format and where it lives: its path, or its document id in the writing
store. A DOCX story file is read from its latest committed save, never from
unsaved text. A lookup that starts after the editor reports a save as committed
returns that save or a later one, even while its push to the remote is pending or
has failed, and the answer names the version. Get, search and manuscript read a
DOCX story file as its extracted text with DOCX locators, never as lines. That
text is the file with its tracked changes accepted. An agent reads a story file's
comments, or its text with tracked deletions shown, only after a human turns on
the matching setting. Both settings are off by default, and only a human changes
them. The reader in this version can't read some things a DOCX story file can
hold, such as a web link, a text box, a field or hidden text. The editor warns the
human when an edit adds one, and the human can still save it. Until a later save
takes it out, lookups give agents none of the text of the part that holds it,
whether that is the chapter's body, a note or a comment. The manuscript lookup
follows the book's chapter order across both formats (O2 design D10), and the next
chapter it names is never an archived file. Links out and in resolve across both
formats. The universe lookup returns the format map (REQ-LIB-8)."
Replace "The user sees them in Obsidian's graph and backlinks." with "The user sees
a Markdown file's links in Obsidian's graph and backlinks. A DOCX story file's
wikilinks stay clickable in the Console's editor (O2 design D11), and no graph
shows them yet. The Console's Connections view lists the current links and
backlinks of each story file the user may see, in both formats, with each one's
scope and format."
Version tag: 'and "Connections are always visible".)' becomes 'and "Connections
are always visible". Version 1.17, with Q222 for the two settings and Q239 for
the Connections view.)'
### REQ-LIB-6
Replace "A reply may cite lines of the repository at a commit or pages the agent
fetched, and the broker checks every citation before it stores the message." with
"A reply may cite lines of a Markdown file at a commit, a place in a committed
DOCX story file, or pages the agent fetched, and the broker checks every citation
before it stores the message. A DOCX citation never calls a paragraph a line. It
names the document id, the committed version and a DOCX locator. A citation that
names an archived file as current is refused. The `requireCitation` setting below
applies to Markdown and DOCX citations alike, and so does the rule for a citation
marked as canon."
Add, after "the broker refuses such a write before any git call.": "The writing
store and the archive folder are read-only to a creative agent as well, and the
broker's path check treats the archive folder as story. No agent creates, edits,
deletes, converts, archives or restores a story file in any format, and no agent
writes a DOCX. No creative agent starts or undoes a conversion, and none asks for
one through a tool or a decision it raises, as REQ-LIB-8 says. An agent may still
suggest a conversion to the human in chat. Each creative role's contract states
which files are DOCX and which are Markdown, and cites a DOCX story file by its
locator, never by line."
Version tag, inside the closing parenthesis: "Version 1.15.)" becomes "Version
1.15. Version 1.17.)"
### New REQ-LIB-8
This bullet goes after REQ-LIB-7 and before the "### Installer" heading.
"- **REQ-LIB-8.** Story files become DOCX. DOCX is the standard for chapters and
the other story text in a Case 2 writing project, canon and playground alike. A
story file becomes DOCX through a human's import (below), or when the human
starts it in the editor. From then on DOCX is its only working format, edited in
the Console's editor (REQ-LIB-9). A Markdown story file stays Markdown until its
import. Files in a type folder (REQ-LIB-1), such as character and place notes,
stay Markdown. So do supporting files, agent files, instructions, PRDs and
documentation, including the character sheets, summaries and cross-links agents
keep under REQ-LIB-6.
- A converted story file's current text leaves the user's Gitea-mirrored
repository. Its DOCX lives in the stack's writing store, on the stack's host,
and not in the repository the user pushes or opens in Obsidian. Only its
archived Markdown stays there.
- An import converts one Markdown story file, canon or playground, outside every
type folder (REQ-LIB-1), to a DOCX document in the writing store. It refuses
an unmapped, supporting, archived or type-folder file, and a file that is
already converted. Each import has its own fidelity report, the human's
confirmation of that report, a manifest naming the source file and its commit,
and one commit in the store. The DOCX keeps every wiki link of the source as
written, and a lost link to another library file fails the fidelity report. A
web link becomes plain text that keeps its label and address, and the fidelity
report lists it for the human to confirm. A web link that can't be kept safely
as plain text refuses the import.
- The human can also start a new story file in the editor, in a universe and
book or a playground the human picks. It is a DOCX document in the writing
store from its first commit, with no Markdown copy, and it opens under the
same gate as the import.
- The same import marks the Markdown file converted, naming the document, and
moves it to the archive folder under its old path (REQ-LIB-1, REQ-LIB-2).
The mark and the move are one commit authored by the human in the user's
repository. A new human-only bus step (Vikunja 571) on the C2-02 write
plumbing writes it, and C2-03b pushes it. The move keeps the bytes and the
git history, so it isn't `data.delete`.
- The store commit comes first, and the writing store's remote must confirm it
holds that commit. Then come the archive commit and its push, then publication
to the lookups. Until publication the Markdown at its old path keeps its canon
or playground scope. Agents read that file at the commit the import started
from, even after the mirror's served commit reaches the archive commit or a
later one. No lookup returns the file's archived copy before publication, even
one that asks for archived files. A refusal or failure before the archive push
leaves the Markdown unmoved and no DOCX that a lookup returns. A failure after
the archive push leaves a pending import that the human sees and can resume,
never a claim that nothing moved. An archive push whose result is unknown
counts as a failure after the archive push until the stack reads the result
back from the user's remote.
- Each stage of an import writes a logged event. The import's record on the
stack's host names the source file's path, commit and blob and the new
document id, and each event points to that record.
- After a conversion, a Markdown file at the converted file's old path is
neither canon nor playground. Lookups leave it out, the format map flags it,
and the human is told.
- A new Markdown story file in a book folder that holds converted files stays
canon until its import, unless it sits at a converted file's old path or under
a playground prefix. The Console flags it and offers the import. Nothing
converts it on its own.
- A hybrid's exclusion that covers a converted file's old path also covers its
DOCX document (REQ-LIB-3). An exclusion of a whole book folder also covers
every DOCX document in that book, including one started in the editor. An
exclusion can also name a DOCX document by its id.
- Only a human starts an import or a new story file, from the CLI or a Console
session signed in as that human. Both are human verbs outside the agent action
vocabulary. Neither is added to `ACTIONS` in the business vocabulary or to the
broker's always-gated list, including the import's action, `library.convert`.
No role holds them, no agent can request them, and the hard-wait list in
REQ-WAIT-1 doesn't change. In an organization with several humans, who may
start them is a setting only a human changes, and the default is the owner
only.
- Conversion is one way, with no parallel run. Neither the Console nor any agent
edits a converted story file as Markdown, and no DOCX edit syncs back to
Markdown. The Console's Markdown save refuses every file under the archive
folder. The user's own tools can still change the archived Markdown, and no
such change reaches the DOCX. The old Markdown stays in git history, and
nothing rewrites history. A promotion into a book folder (C2-19) runs the
import for a Markdown story file or is refused. A promotion of a playground
DOCX is refused in version 1.17.
- Agents get a format map: for each mapped folder and story file, its format,
scope and where it lives, archived files, flagged files and pending imports
included.
- The import and new story files ship closed. They open only after four things.
The editor ships. The human has set the writing store's remote, and the stack
has checked that it can push there. Agents can read committed DOCX story files
through the lookups and cite them (REQ-LIB-5, REQ-LIB-6). Any earlier agent
system that writes the user's files has stopped, which for Case 2 is the
Hermes retirement (#423 step 5). So the import itself never cuts agents off
from a chapter, the chapter's current text never lives only on the stack's
host, and no second system writes the story. The first import of a real user's
books waits for Jason's go.
(Q235, Q236, Q237: P1 to P6. Q222 for the setting. O2 design D10 to D12.
Version 1.17.)"
### New REQ-LIB-9
This bullet goes after REQ-LIB-8.
"- **REQ-LIB-9.** The Console's DOCX editor. The human edits a DOCX story file in
the Console's editor, in a split pane with the agent chat alongside it. The
human can pin an agent chat to a document. The human can select a word,
right-click it, and choose a synonym from a thesaurus. Only a human saves. Saves
happen on their own while the human types (Q226), at intervals only a human
changes, and each accepted save is one commit with the human as author, pushed
as REQ-LIB-4 says. A save made against an older version than the current one is
refused. Agents never write a DOCX. Versioning is the editor's tracked changes
plus one git commit per save. The editor's code is vendored and pinned, with a
hash per file and the licenses that its independent security review accepted. At
runtime the editor loads only files that the stack itself serves, and it
requests nothing from any other host. The editor's frame sends exactly the
content security policy Jason approved in Q234, and the WebUI shell's own header
is unchanged. REQ-PROJ-3's project docs editor is unchanged. (Q195, Q226, Q234,
Q236. O2 design D3, D4, D6, D8, D10, D13, D14, D16 and D22. Version 1.17.)"
### Version 1.17 notes
Added after the 1.16 notes:
"- Version 1.17:
- Jason confirmed the reading 'for now (subject to future change)' (Q237). The
rulings stand until he revises them.
- The 10-27 Case 2 release ships on Markdown as built (P6). The 1.11 line 'The
Case 2 MVP for 10-27 is the Markdown library (#416). The WYSIWYG editor (Q195,
Q196) is an add-on that doesn't gate it.' still holds. Conversion opens after
10-27, and only once REQ-LIB-8's four conditions are met. The editor ships,
the writing store's remote is set and the stack has checked that it can push
there, agents can read and cite committed DOCX story files through the
lookups, and Hermes is retired, which is #423 step 5.
- The 1.12 note says five bus write and Console editor rows, C2-02, C2-03b,
C2-11a, C2-11b and C2-19, don't gate 10-27. Jason's Q226 F42 later moved
C2-02, C2-03b and C2-11b into the 10-27 slice. The Case 2 design brings C2-11a
in with them, because C2-11b needs it. C2-19 stays outside the slice. Mos's
reading of F42 is that C2-02 and C2-03b now gate 10-27, because design 9.2
puts them in 'the smallest slice that lets her start'. C2-11a and C2-11b are
slice rows that still don't gate 10-27, because the editor is the Q195 add-on.
The DOCX change in 1.17 leaves this slice as it is, and C2-11b's Markdown
editor never edits a converted story file.
- Q236 narrows Q176's editor choice to DOCX for story files (REQ-LIB-9). The
1.12 note that the Case 2 design keeps Obsidian now holds only for Markdown
files, including story files not yet imported. A DOCX story file is edited
only in the Console's editor under REQ-LIB-9.
- This replaces two parts of the O2 design at 25c09f19f. The superseded-notice
proposal from Fenton's F1 in D12 is withdrawn, and the
keep-Markdown-at-its-path model of D11 is replaced. Gyro's rewrite of the O2
design for Vikunja 571, landed on `refactor` at b99ca86b9, carries the
details, and where it differs from this text, this text holds:
- the import and archive steps, the mark, and the new bus step;
- links from supporting files to a moved chapter;
- the DOCX reader;
- the user's unpushed or later changes to an archived file or at its old path,
and pushing and closing the file in Obsidian before each import;
- the writing store's remote in W4 section 7.3, which replaces the no-remote
and no-backup rule that W4 section 7.4 held before the rewrite, at
062646487;
- import events counted by a bus restore, and recovery when a bus restore or a
restore of the whole host predates an import;
- manifest order for the manuscript lookup and its next chapter;
- `library.convert` kept out of `ACTIONS`, out of the hard-wait note and out
of the pinned `GATED_ONLY` list in packages/business/src/vocabulary.mjs.
- Agents read a converted story file through the Case 2 lookups under the same
project and universe access as Markdown canon, in any thread or room. W4's
earlier O2-only conversations, per-document grants and per-send sharing don't
gate these lookups. The 571 rewrite replaced them in W4 sections 7.1 and 7.2.
The creative tool list in REQ-LIB-6 doesn't change.
- The six creative role contracts row 213 landed say 'Every item cites the
lines of the user's material it is about'. A build row under 571 changes that
line for DOCX story files (REQ-LIB-6).
- A new Markdown file in a book folder is still canon in 1.17. The 1.17
exception is a file at a converted file's old path, which REQ-LIB-2 makes
unmapped.
- Story files convert whether canon or playground. Type-folder and supporting
files stay Markdown.
- Measured 2026-10-23 at about 01:21Z: the Case 2 user's library repository
tracks 213 files, 203 of them Markdown, and none is a .docx or .doc. It has no
untracked files. The user's .doc files (Q196) are outside it. So the landed
code has no DOCX there to report as unreadable at 10-27.
- Text replaced in 1.17, kept here as in earlier notes:
- Creative library lead, the 1.16 sentence: "The library is the user's own
writing repository."
- REQ-LIB-2, the 1.16 sentences: "A file's scope comes from its path alone,
prefixes first: a Markdown file under a supporting prefix is supporting,
one under a playground prefix at any level is playground, any other
Markdown file in a book folder is canon, and everything else is unmapped
and never returned. A folder in both lists is supporting." "Lookups return
canon unless the caller asks for playground or supporting." "so an agent
can't cite a playground or supporting file as canon" "The Console shows
each cite's scope."
- REQ-LIB-4, the 1.16 sentences: "The user commits and pushes with their own
tools, today Obsidian with obsidian-git." "Agents read only the mirror's
served commit and never hold a copy of their own, so they see the user's
push within about a minute."
- REQ-LIB-5, the 1.16 sentence: "The user sees them in Obsidian's graph and
backlinks." A converted chapter's connections show in Obsidian only as they
stood at conversion, and the Console's Connections view shows their
current links.
- REQ-LIB-6, the 1.16 sentence: "A reply may cite lines of the repository at
a commit or pages the agent fetched, and the broker checks every citation
before it stores the message."
- REQ-LIB-1 only gains sentences.
- REQ-LIB-3's text and REQ-LIB-7 are unchanged. REQ-LIB-8 adds one form of
exclusion REQ-LIB-3 doesn't have: naming a DOCX document by its id. A path
exclusion also covers DOCX documents, through a converted file's old path or a
whole book folder. An archived file and a DOCX document keep a universe, and a
book where their source has one, so every lookup still names one universe.
REQ-PROJ-3, the Console editor for a project's spec and docs, is unchanged.
C2-11b opens the library's Markdown files in that same editor.
- In `packages/tasks/requirements.json` the titles of REQ-LIB-1, REQ-LIB-2 and
REQ-LIB-4 change, and those of REQ-LIB-5 and REQ-LIB-6 stay. REQ-LIB-1 becomes
'A human-set library maps the user's repository onto universes, books, types,
playgrounds, supporting and archive folders'. REQ-LIB-2 becomes 'Scope comes
from the path or a DOCX's source, lookups default to canon, and only canon
files are ever cited as canon'. REQ-LIB-4 becomes 'Git holds every change. The
bus fast-forwards a read-only mirror every 60 seconds, the one place agents
read Markdown'. REQ-LIB-8 and REQ-LIB-9 add two ids, so the count goes from 85
to 87. REQ-LIB-8 is titled 'Case 2 story files become DOCX by a human-only
one-way import or in the editor; Markdown stays for notes and docs', and
REQ-LIB-9 'The Console's DOCX editor: only a human saves, each save is one
commit, and the editor code is vendored and pinned'.
- Open points O1 to O4 of draft 1 were ruled by Mos in mos-1367 B1 to B4.
- Sage files the build rows under Vikunja 570 and 571 after the rewrite is
approved. This amendment adds the requirements only."
### Risks
The new lines go at the end of "## Risks and open questions", after the bullet
that begins "Trusted mode lets an agent" and before the "## Milestones" heading.
- "A DOCX file is a zip archive, so git can't show a line diff of a chapter.
Review of a story change goes through the editor and the committed reader,
not `git diff`. (Version 1.17.)"
- "A converted chapter's DOCX is not in Obsidian's graph. Obsidian shows the
chapter only through its archived Markdown, and no DOCX edit reaches that file's
links. The DOCX's current links show in the Console's editor and its
Connections view, and no graph shows them yet. (Version 1.17.)"
- "Conversion can't be undone in place. Front matter, wiki link forms and
unsupported blocks may not survive it, and the mirror can't refuse the user's
push to an archived file. The controls are the fidelity report, the human's
confirmation, an untouched original on refusal, and the kept archive.
(Version 1.17.)"
- "A restore of the stack's bus alone from a backup taken before an import drops
the import's logged events, while the archive commit and the DOCX stay. A
restore of the stack's whole host from such a backup also takes the writing
store and the host's import records back to before the import. The archive
commit stays in the user's Gitea repository and the DOCX stays on the writing
store's remote, but the host keeps no record of the import. A bus restore counts
import events, and the import's recovery covers both cases (Vikunja 571).
(Version 1.17.)"
## d9 changes (Q239 and Webby round 5)
- J-b yes (Q239, mos-1467), in the five edits mos-1443 R3 set for a yes (Webby round 6 B1). R3.1: REQ-LIB-5's replacement gains the Connections view sentence after "no graph shows them yet.". R3.2: the graph Risks line ends with the editor and the Connections view. R3.3: the A6 line moves from "Not taken" under A6. R3.5: the REQ-LIB-5 note ends with the Connections view, which drops "until a Console view of their links exists" and "Q226 records that view as a later option". R3.4 is Gyro's design edit at 767bb62c7 (writing-editor.md:188-190, :240-244 and :1272, docx-citations.md:66), and it follows in his docs round. Beyond these and N21, nothing else in the PRD text changes.
- N19 and N20 (Webby round 6): J-b's question text is in the past tense, and the line that the import doesn't wait for the view is cited as Mos's R3 default. Mos confirmed it at his J-b check (mos-1470).
- N21 (Webby round 6, Mos yes in mos-1470): REQ-LIB-5's version tag cites Q239 for the Connections view beside Q222 for the two settings.
- N15: the D7 landing line gives R8's wording beside the impact map's quote.
- N16: the d7 changes line notes that R8 replaces "no agent credential reaches it" in d8.
- N17: the REQ-LIB-2 Replace quote and the REQ-LIB-8 old-path bullet are rewrapped under 82 columns. Their words are unchanged.
## d8 changes (Mos's d8 fix list, check wf_63bba879-180)
- R1: REQ-LIB-4's tail covers DOCX saves, which agents see at their next lookup after the editor reports them as committed. The notes quote the whole 1.16 sentence. Source: nit/jason/F10.
- R2: REQ-LIB-4's new title says the mirror is "the one place agents read Markdown". Source: decisions.n14.
- R3: J-b asks whether 1.17 requires the Console Connections view, with Mos's recommendation of yes. Sources: mos1372/F9, jason/F2, anchors/F5.
- R4: C2-02 and C2-03b gate 10-27, C2-11a and C2-11b are slice rows that don't, and "F42 wins" is gone. New J-k. Sources: anchors/F6, jason/F5.
- R5: where the landed 571 design differs, this text holds. Sources: anchors/F8, adversarial/F8 item 1, jason/F8, critic/C6, G16's design follow-on.
- R6: `library.convert` stays out of `ACTIONS`, and REQ-WAIT-1's hard-wait list doesn't change. Sources: gyro571/F1, adversarial/F8 item 3, critic/C6.
- R7: a repository on the stack's host never counts as the writing store's remote, and Item 2 says so. Sources: mos1372/F2, adversarial/F1, gyro571/F2.
- R8: "no agent credential reaches it" becomes "no agent can reach it with any credential". Source: Fenton's B1 under mos-1379.
- R9: no playground DOCX becomes canon in 1.17, and a promotion of one is refused. New J-i. Sources: mos1372/F8, adversarial/F2.
- R10: REQ-LIB-9 restates Q195's pin and right-click thesaurus, and its cite gains D4 and D22. Source: critic/C4.
- R11: REQ-LIB-9's license bar is the licenses its independent security review accepted. New J-l. Source: critic/C3.
- R12: no text for D16's narrow-window tabs. Mos records that reading in STATE. Sources: jason/F1, second verifier vote.
- R13: no lookup returns a file's archived copy before publication. Source: adversarial/F5 part 3.
- R14: a restore of the whole host from a backup taken before an import is a fourth recovery case. Source: adversarial/F6.
- M1: REQ-LIB-4's writing-store sentences follow the later bus rows sentence and add the off-host remote rules and DOCX read timing. The note quotes both 1.16 sentences. Sources: mos1372/F2, adversarial/F1, gyro571/F2, anchors/F4, nit/jason/F10, adversarial/F5 part 1, Fenton B1.
- M2: REQ-LIB-8's order bullet adds the remote's confirmation, the old-path read rule and "archive push". Sources: mos1372/F2 and the other remote findings, mos1372/F7, adversarial/F5 parts 2 to 4.
- M3: REQ-LIB-8's gate bullet has four conditions, adding the remote. Sources: the remote findings, critic/C1 and C2 edit 2.
- M4: the 1.17 note on when conversion opens restates the four conditions and drops W4b. Sources: jason/F5, the remote findings.
- M5: Item 2 rewritten with the remote rules. Sources: the remote findings.
- M6: the 1.12 gating note rewritten. Sources: anchors/F6, jason/F5.
- M7: REQ-LIB-9 gains the split pane, the pin, the thesaurus, the license bar and the runtime load rule, and cites more of the design. Sources: jason/F1, critic/C4, critic/C3, nit/anchors/F12, nit/gyro571/F9.
- M8: REQ-LIB-2, the one-way bullet and the C1 note keep a playground DOCX out of canon in 1.17. Sources: mos1372/F8, adversarial/F2.
- M9: REQ-LIB-5 says what the reader can't read, and the import turns web links into plain text. New J-j. Sources: critic/C1, critic/C2.
- M10: an exclusion of a whole book folder covers that book's DOCX documents. Source: adversarial/F3, with B5.
- M11: the human-verbs sentence and the 571 list item name `library.convert` and REQ-WAIT-1. Sources: gyro571/F1, adversarial/F8 item 3, critic/C6.
- M12: J-b rewritten. Sources: mos1372/F9, jason/F2, anchors/F5, gyro571/F3, critic/C6.
- M13: J-d puts the agent read model to Jason as his call. Source: mos1372/F1.
- M14: Status moves to version 1.17 and names REQ-LIB-9. Sources: anchors/F2, mos1372/F4, critic/C7.
- M15: the 1.17 source line quotes Q235 in full (mos-1446) and the REQ-LIB-9 sources, at b99ca86b9. Sources: critic/C5, anchors/F9, jason/F8 pin part, nit/gyro571/F8, critic/C6 pin part.
- M16: the 571 list lead names the two replaced design parts and the landed design. Sources: anchors/F9, anchors/F8, adversarial/F8 item 1, jason/F8, critic/C6.
- Landing swap: Gyro's docs round for R5, R6 and R14 landed as b99ca86b9 (Fenton fenton-0015), so M15 and M16 cite b99ca86b9 in place of 767bb62c7, as the d8 fix list's header says. J-b keeps 767bb62c7 for its line numbers.
- M17: W4 section numbers in the remote item and the lookups note. Sources: gyro571/F5, nit/adversarial/F11.
- M18: the D7 landing line. Sources: gyro571/F5, the remote findings.
- M19: a logged event for each import stage, in REQ-LIB-8 and A2. Source: gyro571/F7.
- M20: the Risks line on restores adds the whole-host case. Sources: adversarial/F6, gyro571/F7.
- M21: the 571 restore item adds the whole-host case. Source: adversarial/F6.
- M22: the graph Risks line and the REQ-LIB-5 note name a Console view. Sources: anchors/F5, jason/F2.
- M23: the d6 B3 line drops "F42 wins". Source: anchors/F6.
- M24: new Markdown in a converted book, with the old-path and playground exceptions. Source: mos1372/F6.
- M25: J-c's last sentence is Mos's reading. Source: mos1372/F10.
- M26: J-h rewritten with the D13 defaults. Source: jason/F3.
- M27: Item 4 rewritten. Sources: jason/F4, gyro571/F4, decisions.unmarked_folders.
- M28: the "Not taken" note on unmarked folders. Sources: jason/F4, gyro571/F4, decisions.unmarked_folders.
- M29: the import refuses an unmapped file. Sources: gyro571/F4, decisions.unmarked_folders.
- M30: placement notes for REQ-LIB-8, REQ-LIB-9 and the Risks lines. Sources: anchors/F3, nit/anchors/F14.
- M31: REQ-LIB-4's title and the D11 landing line. Source: decisions.n14.
- M32: the Q236 note limits the 1.12 Obsidian note to Markdown files. Source: critic/C8.
- M33: the exclusion note adds path exclusions of DOCX documents. Source: anchors/F7.
- M34: A9 moves from "Mine" to the impact-map list. Source: decisions.exclusion_by_id.
- M35: the D5 landing line. Sources: anchors/F10, mos1372/F5, nit/adversarial/F12.
- M36: the D9 and D14 landing lines. Source: mos1372/F1.
- M37: the O1 and C3 notes add the off-host remote and its confirmation. Sources: the remote findings.
- M38: the REQ-PROJ-3 note. Source: nit/anchors/F11.
- M39: REQ-LIB-5's freshness sentence keys on the editor reporting a save as committed. Source: nit/adversarial/F9.
- M40: REQ-LIB-6 bars agent requests for a conversion and allows a suggestion in chat. Source: nit/jason/F9.
- M41: REQ-LIB-2's opening and old-path rule, and the "Mine" note. Source: nit/adversarial/F10.
- M42: REQ-LIB-8's lead sentence on Markdown story files. Source: nit/mos1372/F12.
- M43: REQ-LIB-6's citation sentence. Source: nit/anchors/F13.
- M44: REQ-LIB-5's version tag uses plain quotes. Source: nit/anchors/F15.
- M45: reference codes B5, D12, D10 and B4 name their source. Source: nit/mos1372/F11.
- Files the row would own: four files, with the id count going to 87. Sources: anchors/F1, adversarial/F4, decisions.n14 F4.
## d7 changes (mos-1379, Fenton's 571 B2)
- REQ-LIB-5 gains Mos's three sentences after the extracted-text sentence. The text agents read has tracked changes accepted. Comments, and text with tracked deletions shown, each wait for a setting only a human turns on, off by default. Its version tag cites Q222 for the two settings, which is the only text here beyond Mos's.
- J-d gains Mos's sentence on comments and tracked deletions.
- Fenton's B1 is design only. d6's "no agent credential reaches it" holds (R8 replaces it in d8), and the shared helper stays #431.
## d6 changes (Webby round 3, webby-0207)
- B1: titles for REQ-LIB-8 and REQ-LIB-9 in the requirements.json note and the file list. REQ-LIB-9 takes Webby's title. REQ-LIB-8 keeps mine, because Webby's ("...one-way import that archives the Markdown under its old path") leaves out D2's story files started in the editor.
- B2: REQ-LIB-2 makes a Markdown file at a converted file's old path unmapped, ahead of the playground and canon rules, so a playground conversion is covered too. REQ-LIB-8's old-path bullet says "neither canon nor playground".
- B3: the slice note names Q226 F42's three rows (C2-02, C2-03b, C2-11b). C2-11a joined the slice with C2-11b, because C2-11b needs it. Both are slice rows that don't gate 10-27, as design 9.2, case2-rows.md:1655 and the impact map's A57 say. The 'F42 wins' sentence is gone from the note in d8, so no queue change follows from it.
- N10: the cite-scope replacement is scoped to REQ-LIB-2. The 1.14 note's copy stays.
- N11: the notes say REQ-LIB-8 adds the by-id exclusion form REQ-LIB-3 doesn't have.
- N12: J-h marks the autosave intervals for Jason.
- N13: J-b names REQ-LIB-5's "Connections are always visible" tag and Q226's "another way to handle the visual brain map".
- N14, not taken: REQ-LIB-4's title stays internal F3's text, as mos-1372 D11 ruled. Webby's alternative, "...and agents read Markdown from it alone" (120 chars), is Mos's call.
## Where each mos-1372 item landed
- D1: REQ-LIB-2's DOCX sentences, the REQ-LIB-8 lead and import bullet, C1 above, and the notes. "Playground files stay Markdown in 1.17" is gone.
- D2: REQ-LIB-2's "for one the human starts in the editor" and the REQ-LIB-8 new-story-file bullet. The universe-and-book note is now true.
- D3: the REQ-LIB-8 order bullet. This also settles N8.
- D4: REQ-LIB-8's one-way bullet, which carries the archive refusal and consequence F2's text, and the 571 list item.
- D5: it landed in REQ-LIB-2's "a Markdown file at a converted file's old path is unmapped" in Webby's B2 form, and in the REQ-LIB-8 old-path bullet.
- D6: REQ-LIB-5's freshness sentences.
- D7 and mos-1375: REQ-LIB-4's remote sentences, the remote step in REQ-LIB-8's order bullet, the fourth opening condition and the 1.17 note on when conversion opens, plus the 571 list item on W4 section 7.3, which replaces the no-remote rule of the old W4 section 7.4. The impact map's item 2 adds "only the stack's host pushes, and no agent credential reaches it", which R8 words in d8 as "no agent can reach it with any credential".
- D8: the REQ-LIB-5 replacement, the import bullet's wiki-link sentence, the Risks line, the note quoting the old sentence, and "REQ-LIB-1 only gains sentences".
- D9: the 1.17 note that agents read a converted story file through the Case 2 lookups, from the d4 check's consistency B2, and J-d, which puts the read model to Jason as his call.
- D10: the d4 check's B5 sentence, in REQ-LIB-8.
- D11: internal F3's clause in the REQ-LIB-4 section, and in the notes the title as Mos amended it for N14.
- D12: the whole 1.16 sentence replaced, and quoted in full in the notes.
- D13: consequence F1's text, in REQ-LIB-4 and REQ-LIB-8's first bullet.
- D14: the row text from the d4 check's consistency B1, in the mark-and-move bullet and C3 above.
## What d5 adds beyond mos-1372 (for Webby and Mos to check)
From the impact map, folded as mos-1368 item 1 asked:
- A1: the Console shows a cite's format and converted state (REQ-LIB-2).
- A2: the logged events and the several-humans setting, default owner only, under Q222.
- A3: the import is outside the vocabulary, the hard-wait list is unchanged, and the archive move isn't `data.delete`. The C6 code site is in the 571 list.
- A4: the section lead and REQ-LIB-1's writing-store sentence.
- A6: format and location in every answer, extracted text with DOCX locators, links across formats.
- A6's named chapter-links view. D8 took B8's "no graph shows them yet", and J-b put the view to Jason. His O2 (Q239) takes it in d9 as REQ-LIB-5's Connections view (mos-1443 R3).
- A7: read-only extends to the store and the archive, no agent action on a story file in any format, no agent DOCX write, and role contracts state the formats. C1's role contract line is a 571 build item.
- A8: the DOCX cite shape, and a cite naming an archived file as current is refused.
- A9: a hybrid sees a source's DOCX canon through REQ-LIB-2's source scope, and REQ-LIB-8 lets an exclusion name a DOCX document by its id. The id form lets the human exclude a single DOCX started in the editor, which has no old path. An archived file isn't canon, so no hybrid sees it.
- A10: REQ-LIB-9, so the count is 87, not 86.
- A11: the Hermes gate, from item 3.
- A12: the C2-19 promotion sentence.
- A13: the broker's path check treats the archive folder as story.
- A14 and C5: two Risks lines.
- A15: the flagged-new-file bullet. It sits beside the note from the d4 check's B4, "a new Markdown file in a book folder is still canon". The file stays canon and is offered for import.
- A16: three titles change and two stay.
- C2: manifest order for the manuscript lookup.
- F3: the C2-02 gating note.
- F4: Mos's measurement.
Mine, beyond either source:
- REQ-LIB-2 opens "A Markdown file's scope comes from its path and from past imports". DOCX scope comes from the source, so "a file's" would be false.
- REQ-LIB-9's autosave sentence. It is Mos's reading of F41's "instantly committed", with human-set intervals. The exact intervals stay in the design.
Not taken:
- Impact map item 4's "files in unmarked folders are eligible one at a time", for folders outside every book folder and playground only. Their files are unmapped under REQ-LIB-2, so a DOCX made from one would have no universe or book to keep. The human maps such a folder into a book folder or a playground first, under REQ-LIB-1. A file in an unmarked folder inside a book folder or a playground is canon or playground, and the import takes it one file at a time.
## Marked for Jason (additions in the text he sees)
- J-a. Whether "We will not run the systems in parallel" applies per chapter or as one switch per book. His call. As written, it applies per chapter.
- J-b. A converted chapter's DOCX is not in Obsidian's graph. Obsidian shows the chapter only through its archived Markdown, and no DOCX edit reaches that file's links. REQ-LIB-5 still cites two TARGET lines, "Visualization of data via Obsidian or equivalent is required" and "Connections are always visible". Mos's reading was that 1.17 as d8 wrote it met neither line for converted chapters. Their current links showed to the user only as clickable text inside the editor, and no graph or list showed them. Q226 leaves room for "another way to handle the visual brain map". The O2 design that landed for Vikunja 571 at 767bb62c7 held a Console Connections view and its tests as later work that waited for this answer. His call was whether 1.17 requires that view. As d8 was written, it did not. Mos recommended that it does, because impact map A6 says "Recommended: name the view" and TARGET says "Connections are always visible". Jason answered O2 (Q239): 1.17 requires the view. d9 writes it into REQ-LIB-5, its note and the Risks line, in the text of mos-1443 R3. The view comes with the conversion work after 10-27, and the 10-27 Case 2 release stays on Markdown (Q235 P6, Q237). That the import does not wait for the view is Mos's default in mos-1443 R3. Q239 doesn't address it, and Mos confirmed it at his J-b check (mos-1470).
- J-c (mos-1374): "The store pushes to a remote (D7), and the Case 2 VM is in the nightly PBS backup. For a fixed restore point before her first real import, a manual backup of VM 1012 with PBS protect runs first. That step is reversible and needs no reboot." Mos's reading is that this sits inside the P5 go step, so it needs no new question.
- J-d. How agents read a converted chapter, D9. His call. As written, an agent reads it through the ordinary Case 2 lookups in any thread or room, under the same project and universe access as Markdown canon. This is Mos's reading of F41 and REQ-LIB-7. The other choice is the model in the earlier O2 design, W4 sections 7.1 and 7.2. Under it, an agent reads a converted chapter only in a new writing conversation that holds one human and one agent, only after the business owner grants that agent access to that document, and by default only what the human attaches to each message. Comments and tracked deletions stay hidden from agents until a human turns on a setting for each. Markdown has neither, so the default keeps agents at the access Markdown gives them. Those two sentences are Mos's ruling mos-1379. A yes to the text as written settles it.
- J-e. Versioning means the editor's tracked changes plus one git commit per save.
- J-f. A converted chapter can be edited only in the Console while the stack is reachable. Obsidian no longer edits it offline.
- J-g. The import takes Markdown only. Importing .doc files stays a separate task.
- J-h. Autosave under REQ-LIB-9. Jason's Q226 F41 says "All changes are instantly committed and available to the agents." Mos's reading, recorded with that ruling, is "Each save is a commit at once, and agents read it at their next lookup." At the O2 design's D13 defaults, a save commits at most 2 seconds after a pause in typing or 15 seconds into steady typing, with no human action needed. Mos's reading in mos-1359 counts that delay as "instantly committed". Only a human changes the intervals. The PRD names no values, and the defaults stay in the design. Whether a delay of seconds fits his word "instantly" is his call, so this is marked for him.
- J-i. In 1.17 a playground story that is DOCX can't become canon. That covers a playground Markdown file the human imports, a story the human starts in a playground in the editor, and the DOCX of a draft universe after it is made real. Today Melanie promotes a playground file by moving it into a book folder in Obsidian, and a DOCX in the writing store can't be moved that way. She can start a new story in the book and paste the text in, but it gets a new document id and the playground copy keeps the history. A later version adds DOCX promotion with C2-19, which comes after the slice.
- J-j. Web links, text boxes and fields in a converted chapter. Jason's Q226 F41 says "All changes are instantly committed and available to the agents." The reader in this version can't read a web link, a text box, a field or hidden text. As written, the import turns each web link into plain text that keeps its label and address, and the human confirms that in the fidelity report. A web link that can't be kept safely as plain text refuses the import. If a later edit adds one of these, the save keeps it and the editor warns the human. Agents then get none of that part's text until a save takes it out.
- J-k. The 1.12 note Jason approved says bus writes, C2-02 and C2-03b, don't gate 10-27. His Q226 "F42: ok" moved them into the 10-27 slice, and design 9.2 counts them in the smallest slice that lets Melanie start. Mos reads that as making them gate 10-27. The editor rows C2-11a and C2-11b still don't gate it, per his Q195 words "we need to get it moving as an add-on".
- J-l. REQ-LIB-9 does not use REQ-PROJ-3's "permissive license" bar. It takes the licenses that the editor's independent security review accepted, because that review cleared fonts under the SIL Open Font License and the Ubuntu Font License, which not every reader counts as permissive. Mos's reading.
Marked as changes beyond P1 to P6, from the impact map (Q17, item 5):
- A1 (archived scope and canon cites)
- A2 (the conversion requirement, REQ-LIB-8)
- A3 (outside the vocabulary, hard-wait list unchanged)
- A6 (lookups across formats)
- A7 (agent bounds)
- A8 (DOCX cites)
- Item 2 and mos-1375: each writing-store commit is pushed to a remote off the stack's host. Only a human sets it, it starts unset, and a repository on the stack's host doesn't count. Only the stack's host pushes to it, and no agent can reach it with any credential. Import and new story files stay closed until the remote is set and checked, and each import's archive push waits until the remote holds the DOCX commit. This keeps "Git is used for all changes -- local and remote" true for DOCX chapters.
- Item 3 (the Hermes gate)
- Item 4. Canon and playground story files convert one file at a time, including files in unmarked folders inside a book folder or a playground. Type-folder and supporting files stay Markdown. A story file outside every book folder and playground has no universe or book, so it converts only after a human maps its folder into a book folder or a playground. The mapping step is Mos's reading of P1 and Q30.
The F4 measurement goes with them.
## Files the row would own
The id count goes from 85 to 87. 1.16 owned three files because its count stayed at 85. Like 1.13, the last version that added ids, the row owns four files:
- `docs/prd/mosaic-stack.md`
- `packages/tasks/requirements.json`: the version 1.17, the two new ids REQ-LIB-8 and REQ-LIB-9 with their titles, and the changed titles of REQ-LIB-1, REQ-LIB-2 and REQ-LIB-4. The new ids go after REQ-LIB-7 and before REQ-INST-1, in the same order as the PRD's bullets.
- `packages/tasks/tests/requirements.test.mjs`: the version pin 1.17, the id count of 87, and the duplicate-id message, which goes from `requirements[85].id: REQ-ROLE-1 twice` to `requirements[87].id: REQ-ROLE-1 twice`.
- `packages/bus/tests/requirements.test.mjs`: the list length, which goes from 85 to 87.
The gate would match 1.16's:
1. A reviewer approves the text.
2. Jason says yes to the exact text through Mos.
3. Both `requirements.test.mjs` files, in `packages/tasks/tests` and `packages/bus/tests`, and every `scripts/test-*.sh` that reads the PRD or `requirements.json` are green on Sage's gate.
4. Mos gets the landing commit.
-132
View File
@@ -1,132 +0,0 @@
--- DRAFT-d8.md 2026-10-23 06:16:54.374694135 -0500
+++ DRAFT-d9.md 2026-10-23 10:40:54.806553704 -0500
@@ -1,6 +1,6 @@
# PRD 1.17 draft: Case 2 story files are DOCX (Q235 to Q237, P1 to P6)
-Sage, 2026-10-23, for mos-1366 item 1 and Vikunja 570. Draft 8. It is d7 plus Mos's d8 fix list mos-1443 (see "d8 changes"). d7 is d6 plus Mos's ruling mos-1379 on Fenton's 571 B2 (see "d7 changes"). d6 is d5 with Webby's round 3 (webby-0207) fixed: B1 to B3 and N10 to N13, with N14 left for Mos (see "d6 changes"). d5 folds in:
+Sage, 2026-10-23, for mos-1366 item 1 and Vikunja 570. Draft 9. It is d8 plus Jason's Q41 answer O2 (DECISIONS Q239, mos-1467), which makes J-b yes, Webby's round 5 N15 to N17 (webby-0210) and Webby's round 6 B1, N19 and N20 (webby-0212) (see "d9 changes"). d8 is d7 plus Mos's d8 fix list mos-1443 (see "d8 changes"). d7 is d6 plus Mos's ruling mos-1379 on Fenton's 571 B2 (see "d7 changes"). d6 is d5 with Webby's round 3 (webby-0207) fixed: B1 to B3 and N10 to N13, with N14 left for Mos (see "d6 changes"). d5 folds in:
- Mos's d4 check ruling mos-1372 (D1 to D14; D15 was dropped).
- mos-1374, which replaces J-c.
- The impact map mos-1368 (jason-master-restart d183246, `projects/mosaic-mvp/case2-docx-impact-map.md`: A1 to A16, critic items C1 to C6, items 2 to 7).
@@ -103,10 +103,11 @@
### REQ-LIB-2
-Replace "A file's scope comes from its path alone, prefixes first: a Markdown file
-under a supporting prefix is supporting, one under a playground prefix at any level
-is playground, any other Markdown file in a book folder is canon, and everything
-else is unmapped and never returned. A folder in both lists is supporting." with:
+Replace "A file's scope comes from its path alone, prefixes first: a Markdown
+file under a supporting prefix is supporting, one under a playground prefix at
+any level is playground, any other Markdown file in a book folder is canon, and
+everything else is unmapped and never returned. A folder in both lists is
+supporting." with:
"A Markdown file's scope comes from its path and from past imports, prefixes
first: a Markdown file under the archive folder is archived, one under a
@@ -191,10 +192,13 @@
Replace "The user sees them in Obsidian's graph and backlinks." with "The user sees
a Markdown file's links in Obsidian's graph and backlinks. A DOCX story file's
wikilinks stay clickable in the Console's editor (O2 design D11), and no graph
-shows them yet."
+shows them yet. The Console's Connections view lists the current links and
+backlinks of each story file the user may see, in both formats, with each one's
+scope and format."
Version tag: 'and "Connections are always visible".)' becomes 'and "Connections
-are always visible". Version 1.17, with Q222 for the two settings.)'
+are always visible". Version 1.17, with Q222 for the two settings and Q239 for
+the Connections view.)'
### REQ-LIB-6
@@ -273,8 +277,9 @@
- Each stage of an import writes a logged event. The import's record on the
stack's host names the source file's path, commit and blob and the new
document id, and each event points to that record.
- - After a conversion, a Markdown file at the converted file's old path is neither
- canon nor playground. Lookups leave it out, the format map flags it, and the human is told.
+ - After a conversion, a Markdown file at the converted file's old path is
+ neither canon nor playground. Lookups leave it out, the format map flags it,
+ and the human is told.
- A new Markdown story file in a book folder that holds converted files stays
canon until its import, unless it sits at a converted file's old path or under
a playground prefix. The Console flags it and offers the import. Nothing
@@ -413,8 +418,8 @@
push within about a minute."
- REQ-LIB-5, the 1.16 sentence: "The user sees them in Obsidian's graph and
backlinks." A converted chapter's connections show in Obsidian only as they
- stood at conversion, until a Console view of their links exists. Q226
- records that view as a later option.
+ stood at conversion, and the Console's Connections view shows their
+ current links.
- REQ-LIB-6, the 1.16 sentence: "A reply may cite lines of the repository at
a commit or pages the agent fetched, and the broker checks every citation
before it stores the message."
@@ -451,8 +456,8 @@
not `git diff`. (Version 1.17.)"
- "A converted chapter's DOCX is not in Obsidian's graph. Obsidian shows the
chapter only through its archived Markdown, and no DOCX edit reaches that file's
- links. The DOCX's current links show only in the Console's editor until a
- Console view of them exists. (Version 1.17.)"
+ links. The DOCX's current links show in the Console's editor and its
+ Connections view, and no graph shows them yet. (Version 1.17.)"
- "Conversion can't be undone in place. Front matter, wiki link forms and
unsupported blocks may not survive it, and the mirror can't refuse the user's
push to an archived file. The controls are the fidelity report, the human's
@@ -467,6 +472,15 @@
import events, and the import's recovery covers both cases (Vikunja 571).
(Version 1.17.)"
+## d9 changes (Q239 and Webby round 5)
+
+- J-b yes (Q239, mos-1467), in the five edits mos-1443 R3 set for a yes (Webby round 6 B1). R3.1: REQ-LIB-5's replacement gains the Connections view sentence after "no graph shows them yet.". R3.2: the graph Risks line ends with the editor and the Connections view. R3.3: the A6 line moves from "Not taken" under A6. R3.5: the REQ-LIB-5 note ends with the Connections view, which drops "until a Console view of their links exists" and "Q226 records that view as a later option". R3.4 is Gyro's design edit at 767bb62c7 (writing-editor.md:188-190, :240-244 and :1272, docx-citations.md:66), and it follows in his docs round. Beyond these and N21, nothing else in the PRD text changes.
+- N19 and N20 (Webby round 6): J-b's question text is in the past tense, and the line that the import doesn't wait for the view is cited as Mos's R3 default. Mos confirmed it at his J-b check (mos-1470).
+- N21 (Webby round 6, Mos yes in mos-1470): REQ-LIB-5's version tag cites Q239 for the Connections view beside Q222 for the two settings.
+- N15: the D7 landing line gives R8's wording beside the impact map's quote.
+- N16: the d7 changes line notes that R8 replaces "no agent credential reaches it" in d8.
+- N17: the REQ-LIB-2 Replace quote and the REQ-LIB-8 old-path bullet are rewrapped under 82 columns. Their words are unchanged.
+
## d8 changes (Mos's d8 fix list, check wf_63bba879-180)
- R1: REQ-LIB-4's tail covers DOCX saves, which agents see at their next lookup after the editor reports them as committed. The notes quote the whole 1.16 sentence. Source: nit/jason/F10.
@@ -535,7 +549,7 @@
- REQ-LIB-5 gains Mos's three sentences after the extracted-text sentence. The text agents read has tracked changes accepted. Comments, and text with tracked deletions shown, each wait for a setting only a human turns on, off by default. Its version tag cites Q222 for the two settings, which is the only text here beyond Mos's.
- J-d gains Mos's sentence on comments and tracked deletions.
-- Fenton's B1 is design only. d6's "no agent credential reaches it" holds, and the shared helper stays #431.
+- Fenton's B1 is design only. d6's "no agent credential reaches it" holds (R8 replaces it in d8), and the shared helper stays #431.
## d6 changes (Webby round 3, webby-0207)
@@ -556,7 +570,7 @@
- D4: REQ-LIB-8's one-way bullet, which carries the archive refusal and consequence F2's text, and the 571 list item.
- D5: it landed in REQ-LIB-2's "a Markdown file at a converted file's old path is unmapped" in Webby's B2 form, and in the REQ-LIB-8 old-path bullet.
- D6: REQ-LIB-5's freshness sentences.
-- D7 and mos-1375: REQ-LIB-4's remote sentences, the remote step in REQ-LIB-8's order bullet, the fourth opening condition and the 1.17 note on when conversion opens, plus the 571 list item on W4 section 7.3, which replaces the no-remote rule of the old W4 section 7.4. The impact map's item 2 adds "only the stack's host pushes, and no agent credential reaches it".
+- D7 and mos-1375: REQ-LIB-4's remote sentences, the remote step in REQ-LIB-8's order bullet, the fourth opening condition and the 1.17 note on when conversion opens, plus the 571 list item on W4 section 7.3, which replaces the no-remote rule of the old W4 section 7.4. The impact map's item 2 adds "only the stack's host pushes, and no agent credential reaches it", which R8 words in d8 as "no agent can reach it with any credential".
- D8: the REQ-LIB-5 replacement, the import bullet's wiki-link sentence, the Risks line, the note quoting the old sentence, and "REQ-LIB-1 only gains sentences".
- D9: the 1.17 note that agents read a converted story file through the Case 2 lookups, from the d4 check's consistency B2, and J-d, which puts the read model to Jason as his call.
- D10: the d4 check's B5 sentence, in REQ-LIB-8.
@@ -573,6 +587,7 @@
- A3: the import is outside the vocabulary, the hard-wait list is unchanged, and the archive move isn't `data.delete`. The C6 code site is in the 571 list.
- A4: the section lead and REQ-LIB-1's writing-store sentence.
- A6: format and location in every answer, extracted text with DOCX locators, links across formats.
+ - A6's named chapter-links view. D8 took B8's "no graph shows them yet", and J-b put the view to Jason. His O2 (Q239) takes it in d9 as REQ-LIB-5's Connections view (mos-1443 R3).
- A7: read-only extends to the store and the archive, no agent action on a story file in any format, no agent DOCX write, and role contracts state the formats. C1's role contract line is a 571 build item.
- A8: the DOCX cite shape, and a cite naming an archived file as current is refused.
- A9: a hybrid sees a source's DOCX canon through REQ-LIB-2's source scope, and REQ-LIB-8 lets an exclusion name a DOCX document by its id. The id form lets the human exclude a single DOCX started in the editor, which has no old path. An archived file isn't canon, so no hybrid sees it.
@@ -593,12 +608,11 @@
Not taken:
- Impact map item 4's "files in unmarked folders are eligible one at a time", for folders outside every book folder and playground only. Their files are unmapped under REQ-LIB-2, so a DOCX made from one would have no universe or book to keep. The human maps such a folder into a book folder or a playground first, under REQ-LIB-1. A file in an unmarked folder inside a book folder or a playground is canon or playground, and the import takes it one file at a time.
-- A6's named chapter-links view. D8 takes B8's "no graph shows them yet", and J-b puts the view to Jason.
## Marked for Jason (additions in the text he sees)
- J-a. Whether "We will not run the systems in parallel" applies per chapter or as one switch per book. His call. As written, it applies per chapter.
-- J-b. A converted chapter's DOCX is not in Obsidian's graph. Obsidian shows the chapter only through its archived Markdown, and no DOCX edit reaches that file's links. REQ-LIB-5 still cites two TARGET lines, "Visualization of data via Obsidian or equivalent is required" and "Connections are always visible". Mos's reading is that 1.17 as written meets neither line for converted chapters. Their current links show to the user only as clickable text inside the editor, and no graph or list shows them. Q226 leaves room for "another way to handle the visual brain map". The O2 design that landed for Vikunja 571 at 767bb62c7 holds a Console Connections view and its tests as later work that waits for this answer. His call whether 1.17 requires that view. As written, it does not. Mos recommends that it does, because impact map A6 says "Recommended: name the view" and TARGET says "Connections are always visible".
+- J-b. A converted chapter's DOCX is not in Obsidian's graph. Obsidian shows the chapter only through its archived Markdown, and no DOCX edit reaches that file's links. REQ-LIB-5 still cites two TARGET lines, "Visualization of data via Obsidian or equivalent is required" and "Connections are always visible". Mos's reading was that 1.17 as d8 wrote it met neither line for converted chapters. Their current links showed to the user only as clickable text inside the editor, and no graph or list showed them. Q226 leaves room for "another way to handle the visual brain map". The O2 design that landed for Vikunja 571 at 767bb62c7 held a Console Connections view and its tests as later work that waited for this answer. His call was whether 1.17 requires that view. As d8 was written, it did not. Mos recommended that it does, because impact map A6 says "Recommended: name the view" and TARGET says "Connections are always visible". Jason answered O2 (Q239): 1.17 requires the view. d9 writes it into REQ-LIB-5, its note and the Risks line, in the text of mos-1443 R3. The view comes with the conversion work after 10-27, and the 10-27 Case 2 release stays on Markdown (Q235 P6, Q237). That the import does not wait for the view is Mos's default in mos-1443 R3. Q239 doesn't address it, and Mos confirmed it at his J-b check (mos-1470).
- J-c (mos-1374): "The store pushes to a remote (D7), and the Case 2 VM is in the nightly PBS backup. For a fixed restore point before her first real import, a manual backup of VM 1012 with PBS protect runs first. That step is reversible and needs no reboot." Mos's reading is that this sits inside the P5 go step, so it needs no new question.
- J-d. How agents read a converted chapter, D9. His call. As written, an agent reads it through the ordinary Case 2 lookups in any thread or room, under the same project and universe access as Markdown canon. This is Mos's reading of F41 and REQ-LIB-7. The other choice is the model in the earlier O2 design, W4 sections 7.1 and 7.2. Under it, an agent reads a converted chapter only in a new writing conversation that holds one human and one agent, only after the business owner grants that agent access to that document, and by default only what the human attaches to each message. Comments and tracked deletions stay hidden from agents until a human turns on a setting for each. Markdown has neither, so the default keeps agents at the access Markdown gives them. Those two sentences are Mos's ruling mos-1379. A yes to the text as written settles it.
- J-e. Versioning means the editor's tracked changes plus one git commit per save.
-269
View File
@@ -1,269 +0,0 @@
# Row 252 build notes: Console inbox Approve and Deny for grant requests
Seat: Scrooge. Reviewer: Webby. Issue #1753, Vikunja #435 (J2).
Brief: `docs/plans/2026-10-21_console-inbox-approve.md` (blob b41676d06).
Design: Gander's DESIGN.md at d218e14bd, approved by Webby at 5d8f0f4b5 (webby-0082) with manifest checks N1-N5.
Base: aca16404c on branch `scrooge/row252`. Rebase onto the row 253 tip under a new branch name once 253 lands.
## Status
- Code, tests and docs are written. No test has been run yet, because Gander holds the gate slot for row 253 run 5 and Sage ruled no node until a CLEAR.
- Everything in the Results section is empty until that run. Nothing below claims a pass.
## Q188
No agent creates a grant. Approve and Deny are human-only Console actions.
- Only a console cap reaches `decision.preview.console` and `decision.resolve.console`. A console cap reaches nothing else (console-decide `J1, Q188`).
- The socket refuses a console proof for any other verb before the proof check (`server.mjs`).
- A console cap binds only through `authenticateConsole`, which needs the console proof (section 2): the same-tree `console-cli.mjs` wrapper, its own nonce, a direct same-tree `serve` parent, and a configured owner subject from the spawn environment.
- `recordEvent` keeps `human.input` reserved, so no agent writes the record that lets a console close start the executor (console-decide `recordEvent keeps human.input reserved`).
- A `via console` close without its matching `human.input` approves nothing (console-decide `J14`).
## Manifest checks N1-N5
- **N1.** Every `console.sso` subject is an owner of the business. Name is display only.
- `cli/src/config.mjs` carries `{provider, iss, sub}` per subject into the boot as `consoleOwners`. No sso means no key.
- `bus/src/runtime.mjs` validates the list (array, at most 512, exactly three non-empty strings each) and passes it to `verifyPrincipal`.
- Tests: cli config.test `Row 252 (Webby N1)` and principal.test.
- **N2.** `human.input` is written in the same transaction, before `#approvedGrant`. The executor starts once, and a deny starts none.
- Tests: console-decide `N2, J13` checks the order by seq and that the grant runs once. `J15` checks that a deny starts nothing.
- **N3.** The close notice comes from `from_run 'human-console'`, and `public/bus.js` shows "via console". `landing` and `#protectedEnd` stay CLI-only.
- Tests: console-decide `N2, J13` checks the notice. decide-browser `J17` checks the close-elsewhere line.
- **N4.** `decision.resolve` is not a Console verb.
- `CONSOLE_VERBS` is exactly the two console verbs. The webui serve `BUS_VERBS` read list has no decision verb, and webui `cli.mjs` has no `decision.` string. `consoleCall` refuses anything outside `CONSOLE_VERBS`.
- Tests: console-decide `N4`, and decide-sso `N4`, which hits `/api/bus/decision.*` for 404 and covers the cli.mjs scan and the consoleCall refusals.
- **N5.** `readProcess` keeps the five `MOSAIC_BUS_CONSOLE_*` keys in the env allowlist (`bus/src/human.mjs`). The human proof and its spawn are unchanged.
## Design choices and tradeoffs
- **Webui imports from the broker.** `webui/src/bus.mjs` imports `CONSOLE_VERBS` from `bus/src/broker.mjs`, which loads the broker module into the Console at import.
- I chose one source for the verb list over a second copy that could drift.
- If Webby prefers the copy, the alternative is a literal list plus a test pinning it equal to `CONSOLE_VERBS`.
- **The Console transport shares humanCall's spawn.** `consoleCall` and `humanCall` share it as `transport()`, so `webui/src/bus.mjs` changed. The busCaller `business` default keeps `||` (the old falsy semantics), not `??`.
- **Confirmations are held in memory in the serve process.** Their lifetime is 2 minutes, they are single use, and they bind the session, decision, choice and digest. The cap is a limit that never drops a live confirmation.
- A restart loses all held confirmations. The user clicks again and gets a new preview. Nothing is written until the resolve.
- **No retry anywhere.** A resolve whose bus reply is lost answers `outcome-unknown`, the confirmation is spent, and the page says "Nothing was sent again."
## Old tests changed (for Mos, per mos-1272)
Mos ruled both changes allowed in mos-1321 (17:12:28Z), each with one condition.
- **shell.test condition:** reword the comment that opened "No Resolve control", since it is false once the row lands. It now says row 252's Approve and Deny is the one decision control, that its labels come from ACTIONS, and that the doesNotMatch keeps any literal label out of the markup. Webby checks the wording in review.
- **channel-proof condition:** a header comment is not enough, because human.mjs changes what readProcess keeps. In the review, Webby reads the human.mjs and webui bus.mjs diffs and states two things:
- the human proof's checks are unchanged
- only the console verifier reads the five MOSAIC_BUS_CONSOLE_* keys, and each case is covered by a named test in console-decide or decide-sso
- **Second row to land (252 or 239):** it recomputes the webui bus.mjs hash, adds its own header line, and its reviewer re-reads bus.mjs from the base to the combined tip.
- **`webui/tests/shell.test.mjs`**, write-boundary test. Allowed under mos-1321, with the comment reworded.
- The `writes` list gains the new `fetch(url, ...)` in `decidePost`. It is first in file order (bus.js:362).
- The `method:` count goes from 7 to 8.
- A new assertion pins `decidePost`'s callers to `url` plus the two literal `/api/decisions/${enc(a.id)}/...` routes.
- No existing entry was loosened.
- **`bus/tests/channel-proof.test.mjs`**, sha256 re-pins with a header comment:
- `bus/src/human.mjs` (N5 env keys) to bb13c146…c885.
- `webui/src/bus.mjs` (consoleCall and `transport()`) to 701f009e…5272.
- Any later edit to either file needs a re-pin.
- `bus/tests/principal.test.mjs`, `webui/tests/tester-listener.test.mjs` and `cli/tests/config.test.mjs` have new tests appended only.
## Overlap with row 239 (Honker, b0c28366f, sage-2835)
- Row 239 also re-pins `channel-proof.test.mjs`:
- `packages/webui/src/bus.mjs`, to 1eba9774…e5cf. That is the pin both rows change.
- `packages/cli/src/transport.mjs`, which is 239 only.
- `packages/bus/src/human.mjs` is a 252-only pin.
- The two rows also share source files: `bus/src/broker.mjs`, `bus/src/runtime.mjs`, `webui/src/bus.mjs` and `webui/src/serve.mjs`.
- Whichever row lands second rebases onto the first and recomputes the webui `bus.mjs` pin from the merged file. Both header comments are kept. It then reruns every touched test file whole.
- Mos needs to see that re-pin (re-pins need Mos).
## Mutant plan (design section 6, J1-J17)
Each mutant is one edit to the named source. Run the killing test, then restore and compare the sha256 against the pre-mutant hash with `git status` clean apart from the build. Kill rules are written before the runs.
| J | Mutant | Source | Expected kill |
|---|---|---|---|
| J1 | eligibility from the request, `#consoleResolution` returns non-null for any decision | broker.mjs | console-decide `J1, J2` (ineligible) |
| J2 | hardcode `approve = yes`, `deny = no` | broker.mjs | console-decide `J2` (go/stop, deny listed first) |
| J3 | owner from name or header, skip the owner match in verifyPrincipal | principal.mjs | principal.test `console requires…` |
| J4 | skip the serve-parent walk | principal.mjs | principal.test `console requires…` |
| J5 | accept the human nonce namespace | principal.mjs | principal.test `console requires…` |
| J6 | reuse the session from the confirmation, skip the live session check at resolve | decision-http.mjs | decision-http `session that ends`, decide-sso `listener gates` (new session) |
| J7 | keep the console cap bound after the call (skip `release` for kind console) | server.mjs | **open**: no new test pins release for a console proof. Before the run, check whether an existing server test kills it. If not, add one and name it in the report. |
| J8 | skip the same-origin check | decision-http.mjs | decision-http `gates`, decide-sso `listener gates` |
| J9 | resolve without a confirmation token | decision-http.mjs | decision-http `a confirmation then a resolve`, `bad body` |
| J10 | drop the choice, decision or session binding | decision-http.mjs | decision-http `binds the session, decision and choice` |
| J11 | skip the digest, closed or business check | broker.mjs | console-decide `J11`, `N2, J13` (second answer closed) |
| J12 | do not spend the confirmation | decision-http.mjs | decision-http `a second resolve refuses`, `confirmations… one use` |
| J13 | write via cli, or omit the subject in human.input | broker.mjs | console-decide `N2, J13` |
| J14 | `#approvedGrant` accepts via console without the human.input | broker.mjs | console-decide `J14` |
| J15 | start the executor on any resolved choice | broker.mjs | console-decide `J15` and `J2` (stop starts nothing) |
| J16 | `decide` true without sso or send | serve.mjs, public/bus.js | decide-sso `J16`, decide-browser `J16` |
| J17 | retry on outcome-unknown or auto-submit after preview | public/bus.js | decide-browser `J17` (post count stays at 7) |
## Manifest
Source:
- packages/bus/src/broker.mjs
- packages/bus/src/client.mjs
- packages/bus/src/console-cli.mjs (new)
- packages/bus/src/human.mjs
- packages/bus/src/principal.mjs
- packages/bus/src/runtime.mjs
- packages/bus/src/server.mjs
- packages/cli/src/config.mjs
- packages/webui/src/bus.mjs
- packages/webui/src/cli.mjs
- packages/webui/src/decision-http.mjs (new)
- packages/webui/src/public/bus.js
- packages/webui/src/serve.mjs
Tests:
- packages/bus/tests/channel-proof.test.mjs (re-pins)
- packages/bus/tests/console-decide.test.mjs (new)
- packages/bus/tests/principal.test.mjs
- packages/cli/tests/config.test.mjs
- packages/webui/tests/decide-browser.test.mjs (new)
- packages/webui/tests/decide-sso.test.mjs (new)
- packages/webui/tests/decision-http.test.mjs (new)
- packages/webui/tests/shell.test.mjs (old test changed, see above)
- packages/webui/tests/tester-listener.test.mjs
Docs:
- packages/webui/README.md
- agents/scrooge/work/queue-252/BUILD.md (this file)
## Results
Not run. At the first CLEAR:
1. Run each touched test file whole, with TMPDIR under /mnt/storage/scratch/tmp.
2. Run the bus, webui and cli suites.
3. Run the mutants above, then the drift check against refactor.
## Rebase onto fe4ee8c7b (sage-3482, 2026-10-23)
The old base aca16404c had drifted. Since then refactor changed 8 of the row's 24 paths, across 15 commits. I made a new branch, scrooge/row252-fe4ee8c7b, so scrooge/row252 22cc74645 is untouched and nothing was force-pushed. I rebased with local git, `-c core.hooksPath=/dev/null`, and no stash.
Commit map:
- 4bd273bce -> 1891d36e4 (product and tests; six files conflicted)
- ca4537cc9 -> 9e5980acd (BUILD.md; clean)
- 22cc74645 -> 6fd206e80 (shell.test comment and BUILD.md; clean)
Conflicts, all in 4bd273bce, and how each was resolved:
1. packages/bus/src/broker.mjs, verb lists. On the base, row 206 added the share verbs to USER_VERBS, and row 275 added VIEWER_VERBS, userLevel and BOARD_VERBS and removed the USER_LEVEL const. I kept the base's lists and dropped USER_LEVEL, because nothing on the base reads it. CONSOLE_VERBS goes in after USER_VERBS with its comment unchanged. The request() gate (`!!s.console !== CONSOLE_VERBS.includes(verb)`) auto-merged after the FED R4 access sweep and the user check. Its order relative to the user gate is the same as on the old base.
2. packages/bus/src/runtime.mjs, startBroker. FED R4 (#1801) added `access.intervalMs` to the access check. I kept the consoleOwners validation and the frozen owners first, as in the row, and then the base's access check with intervalMs.
3. packages/cli/src/config.mjs. FED R6 (#1800) added accessFor and `config.access`. I kept both: accessFor and then consoleOwners, and in bootConfig `config.access` and then `config.consoleOwners`. process.mjs passes the boot config to startBroker whole, so both keys reach it.
4. packages/webui/src/cli.mjs. FED R6 moved the owner's bus options into consoleBus() (console-bus.mjs) and added userCaller and userOptions. I kept the base's form, added consoleCall to the bus.mjs import, and added decisionWrite to the declarations. I kept the row's `if (ssoOn && sendOn) decisionWrite = consoleCall(...)` after the consoleBus() call, and pass decisionWrite to startServer next to userOptions and federation.
5. packages/webui/src/serve.mjs. FED R6 added granted-user sessions: userRoute gates them, and userOptions rebinds the options per request.
- The signature now has both decisionWrite and userOptions/federation.
- The session hunk keeps the base's signer and granted-user block. ssoSession is set only for a session with no `user`, which is the owner's. A granted user is refused at userRoute before the decision route anyway, because /api/decisions/* is not on userRoute's list. Under the new base this keeps the design section 2 rule that only the owner answers a decision.
- /api/config keeps the base's `user` and `federation` keys. `decide` gains `!granted`, matching how the base gates `edit`, so a granted user's config never offers Approve and Deny.
- The decisionWrite startup check and createConfirmations() auto-merged unchanged.
6. packages/bus/tests/channel-proof.test.mjs, webui bus.mjs pin. Row 301 (#1808, mos-1449) moved the user transport into bus.mjs and re-pinned it to 39fcb27c. The row's consoleCall auto-merged on top. I kept row 301's comment and re-pinned to the merged blob's sha256, 604092cb706b152d458f299ff1071f6cd488774629012f96b672f7e9a16c0b2c. The human.mjs pin (bb13c146) auto-merged and matches the blob at the new tip. The row's existing header comment (Webby N5) still describes why bus.mjs is re-pinned. humanCall and transport() are unchanged.
Auto-merged without conflict: webui bus.mjs (consoleCall next to row 301's userCaller), webui README.md, bus client.mjs, human.mjs, principal.mjs, server.mjs and public/bus.js.
Two new-base reads I checked:
- granted-user.test.mjs reads `/api/config` key by key and has no deepEqual on the whole object, so the extra `decide` key doesn't break it.
- No other test pins one of the row's source files by sha256, apart from channel-proof.
Not run: no test, node or syntax check during this step. Sage's next CLEAR covers the push, the whole webui, bus and cli suites, every scripts/test-*.sh, and J1-J17. Plugins must show 0 failures (mos-1456/1462). J7 still needs a killer test. The serve.mjs `!granted` and ssoSession lines are new since the build, so they get mutants too. I'll add them to the J list at the run.
## Runs (sage-3488 CLEAR, 2026-10-23)
Gate tree: detached worktree of 3e6de9807 with the canonical node_modules linked, so
`MOSAIC_REQUIRE_EDITOR_BUILD=1` is set (mos-1453). TMPDIR comes from mktemp under
/mnt/storage/scratch/tmp. Each step runs under `timeout -k 10` (mos-1275):
| Step | Limit (s) |
|---|---|
| pkg bus, cli, plugins, webui, business (`node --test tests/*.test.mjs`) | 1800 each |
| test-auth / conductor / config / discord / extension-package | 126 / 145 / 130 / 147 / 130 |
| test-foundation / land-work / queue / release / task | 269 / 171 / 226 / 157 / 231 |
| mutant single file | 300, chat-browser 600 |
The script limits are 3x the longest runs recorded in queue-253 (receipt 3b91cfdaf).
### First-run reds placed (mos-1272)
- pkg-bus at 3e6de9807: 902 pass, 1 fail. The failure is `N2, J13` in console-decide.test.mjs:180, a new
test (0 human.input rows found, 1 expected). The cause is in the test, not the product. `events()` spread
the parsed body after the row's `kind`, so an answer body's own `kind: 'answer'` overwrote
`'human.input'` and the filter could never match. Three more filters on the same field had the same
defect, :250 and :290 (count of human.input must be 0) and :252 (an action.refused exists). The two
zero counts passed vacuously. Fix: the row's kind is also kept as `event`, set after the body, and all
four filters read `event`. The deepEqual at :181 now names `event: 'human.input'`. The file then ran
whole at 11 pass, 0 fail.
- J7 (open in the plan): no existing test bound a console proof over the socket. The new test
`J7: a console proof over the socket binds a cap for its one call and the reply releases it` in
console-decide.test.mjs serves a real socket with authenticateConsole, makes one console call that
answers and one that refuses (`decision-not-found`), and checks that both caps are released
(`unauthenticated`). J7 is applied as `boundCap = kind === 'console' ? null : transient;` in server.mjs,
which skips every release path for a console cap (finish, the finally, and socket close). It ran
at 11 pass, 1 fail, failing only the new J7 test. KILLED. The restored server.mjs matched its pre-mutant sha256.
- decide-sso.test.mjs (new) ran 2 pass, 2 fail before any mutant:
- `J16`: its `{ send: null }` case reached `setup`, and startServer refused it, because a decision
transport without `--send` never starts (serve.mjs guard). That case now asserts the refusal, and the
`{ decisionWrite: null }` case still checks the read-only routes and `decide: false`.
- `N4`: a POST to `/api/bus/decision.*` gets row 74's preview refusal (403 `read-only`, for every
write without a route of its own) before any verb lookup. POST now asserts that refusal and GET
still asserts 404. Either way the transport sees no call.
- The file then ran whole at 4 pass, 0 fail.
- Old-test reds, sent to Sage before any fix (status 2):
- cli `access-boot.test.mjs` R6 B1-B3 come from this row. The fixture's SSO provider has no issuer,
so `consoleOwners` maps `iss: undefined` and startBroker refuses the owner list
(`invalid-host-request`). The file is 4/0 on fe4ee8c7b.
- cli `compactions-launch.test.mjs` REQ-THREAD-2 T3, 5 tests, each "timed out waiting". They also fail
0/5 on a fe4ee8c7b tree, so they are not this row's.
- Correction (sage-3492): the compactions-launch reds were the environment, not a base red. The gate
TMPDIR (`<gate>/tmp`) was long enough to break the launcher/bwrap paths. Alone in a short
`mktemp -d /mnt/storage/scratch/tmp/scrXXXXXX` TMPDIR the file ran 5 pass, 0 fail, 0 skipped, and
Sage's own gate ran it green. The file is unchanged.
### Rulings and further placements (sage-3492 to sage-3496)
- access-boot (Mos GO mos-1464, relayed in sage-3494): `access-boot.test.mjs:17`'s `authentik` provider
gains `issuer: "https://idp.test"`, and nothing else changes. numstat against fe4ee8c7b reads 1 1.
It is committed alone (b316e0a55). access.test.mjs:273 uses the same fixture but never reaches
bootConfig, so it is untouched. The file ran 4 pass, 0 fail.
- decide-browser.test.mjs (new) ran 0 pass, 3 fail before any mutant, then 1 pass, 2 fail after the
first fix:
- setup's `UPDATE decisions SET question=?` hit migration 3b's `decisions_no_update` trigger, because
decisions is append-only. The test now writes its own open credential.mint grant decision, with the
hostile question in the INSERT. It is the same two rows host-fixtures' grantDecision writes, and
host-fixtures.mjs is unchanged.
- `page()` visiting the URL it was already on was a same-document hash change, not a load. So J17's
"closed elsewhere" step never re-read the bus, and the redirect test's second visit never redrew
after signing out. `page()` now loads about:blank first, as editor-browser.test.mjs:216 does.
- The file then ran whole at 3 pass, 0 fail (9e1601a20).
- decide-sso J16 (sage-3492): the startServer refusal stays. The one config that starts without send
also lacks a decision transport, since startServer refuses a transport without `--send`. That config,
`{ decisionWrite: null, send: null }`, is now in the J16 loop and gets `decide: false` and the
read-only routes. Webby rules in the manifest round.
- J19 killer (new test in decide-sso.test.mjs): an access document with one granted user (alice). The
listener has `--send`, a decision transport and a user transport. The owner's config says
`decide: true`. Alice's says `user: true`, `send: true` and `decide: false`. Her POSTs to both
decision steps get 403 `owner-only`, and the transport sees no call.
- J18 is equivalent alone, because a granted session never reaches decisionHTTP. At serve.mjs:694 a
session with `user !== undefined` meets `userRoute` before the decision branch. userRoute admits only
`/api/chat*` and GETs of `/api/config` and the user files, never `/api/decisions/*`, so the 403
`owner-only` comes before `ssoSession` is read. The `session.user === undefined` guard is a second
layer behind that. The J19 test's owner-only POSTs pin the first layer.
- J11c is equivalent. `s.human !== b.human` at broker.mjs:5604 can't hold for a console cap:
- bindConsole (broker.mjs:1459) refuses `record.human !== b.human` with `human-required`.
- Its cap carries `human: b.human` for its own business, and #cap freezes the session (:1094).
- At resolve, `b` is `this.#business(s.business)` (:5324), the same business.
- reloadBusiness refuses a document whose human differs (:517).
So for any session with `s.console` set, the two humans are equal. Webby rules on it in the
manifest round.
- Mutants, part 2, on the tip test files (decide-sso 5/0, decide-browser 3/0 as the baseline).
Every restore matched its pre-mutant sha256.
- J16a: KILLED by decide-sso J16.
- J16b: KILLED by decide-browser J16.
- J17a: KILLED by decide-browser J17 and the redirect test.
- J17b: KILLED by decide-browser J17.
- J19: KILLED by decide-sso J19. granted-user stays 19/0 under it.
- J18: SURVIVED decide-sso 5/0 and granted-user 19/0. It is equivalent alone, for the reason above.
- Old webui stubs (Mos GO mos-1465, relayed in sage-3497): library-cli.test.mjs:17 and
thread-claims.test.mjs:58 build a loader-hook stub of bus.mjs with no `consoleCall` export, and cli.mjs
now imports it. Each busSource gains `export const consoleCall=()=>{throw Error('consoleCall outside --sso')};`
in the neighbours' throwing form, and nothing else changes. numstat against fe4ee8c7b is 1 1 for each.
Alone they ran 1/0 and 4/0. No mos-1316 item for compactions-launch: the whole cli rerun is the evidence.
-154
View File
@@ -1,154 +0,0 @@
# Row 297: GitHub grant method (#1803)
Owner Violet (thread `6229eb89-192c-4ca8-9a07-204a33458de3`), reviewer Dewey.
Assignment: Mos mos-1498 / Q245, 2026-10-23. Branch `violet/row297`.
Brief: `docs/plans/2026-10-23_mvp-readiness-rows.md`, GitHub grant method section.
## Status: Untested; Q-B3 contract review before implementation
First commit merges row 198 at `14fe7513c`; second merges its next head `4d8123b0a`.
Both are merge commits; Bushroot's commits remain byte-identical. New 198 heads will
be merged, never rebased/amended/force-pushed. Row 198 is unverified and its old-test
approval is pending. Row 297 cannot land before row 198 passes its own gate.
Read AGENTS.md, agents/README.md, Bushroot's queue-198 BUILD.md and the supplied brief.
Recovered #436b r5 at `1be535aca`, section 10 S1-S6, and inspected row 236's Gitea
implementation, current credential schema, CLI and broker integration. Bushroot
confirmed there are no prior Q-B3 answers or started row 297 work.
`DESIGN.md` answers Q-B3, proposes per-method S4, and names the repository identity
and renewal consumer boundaries requiring Sage's ruling. No implementation starts
before Dewey and Megavolt read the answer, as brief Step 1 requires. Shared Gitea
behavior remains unchanged. No credential file or live token was read; research
used public official GitHub documentation only.
## Verification and slot
- `scripts/mosaic queue next violet` in this worktree refused linked-worktree execution.
- Retried in `/mnt/storage/src/mosaic-stack`: reported an unknown rendered view and
`nothing` for Violet. No queue file changed. Sage owns registration and queue changes.
- No node, test, webui build, gate or mutant run. Sage sage-3614 requires CLEAR first.
- Merge commits are explicitly labeled Untested per Mos's assignment.
## Pre-existing tests changed by Violet
None. Inherited row 198 test changes belong to Bushroot and are recorded in that
row's BUILD.md; Violet has neither changed nor claimed verification of those files.
## Push and landing
Only `violet/*` may be pushed. No push, PR, landing or queue movement yet. First push
ETA communicated as 20:00 UTC, subject to Q-B3 review. Sage owns the gate and landing.
## First push: 6af63e1d3 (2026-10-23)
Pushed only `violet/row297`, normal new-branch push. Packet sent as violet-0004 to
Sage, Dewey, Megavolt and Mos with its commit link and the two explicit scope
rulings. `git diff --cached --check` passed before the packet commit. Verified
14fe7513c and 4d8123b0a are ancestors; only the packet, this BUILD and append-only
session/build-log entries differ from 4d8123b0a. No product or test delta.
Independent inspection after the packet confirms the boundaries: runtime.mjs
constructs only giteaGrant/giteaKeys; broker grants.run routes only to Gitea;
credential reference dates are calendar days, and publication identity is Gitea
fork-based. A GitHub renewal consumer cannot safely reuse those assumptions
without the method-specific contract. Implementation waits on the required
Q-B3 reading and Sage's scope ruling, not on a routine coding choice.
## Implementation in progress (sage-3620; dewey-0931; megavolt-0206)
Both Q-B3 reads completed with no design blocker. Sage selected the App bot on
configured repositories, no accounts/forks, and protected mint/renewal/evidence/
revoke/expiry in this row. Consumer creation is excluded. DESIGN.md now names
R1-R5 and exact clock constants, plus method-specific CLI shapes and workflow
exclusion. PLAN.md records inline implementation. Tests were authored before
their implementations, but no red or green execution is claimed: Sage's Node/test
hold still applies. No test, syntax check through Node, mutant or suite has run.
Code in progress: private file/HTTPS/admin boundary; event-backed durable grants
and generation cleanup; callback-only renewal; broker/CLI/runtime dispatch;
additive reviewed schema support without changing role policy files. New tests
use a fake HTTPS API and generated scratch CA/key. Their fake origin is injected
only through tests; metadata still rejects every HTTP origin, including loopback.
### Consumer gap for Sage's follow-up row
No GitHub consumer, git helper, mirror push or publication is implemented. The
protected `Broker.githubWithToken` callback is the stop point. Gitea's generic
Credentials path has calendar-day dates and fork-specific identity; GitHub tokens
remain protected generation files rather than loading into that unguarded path.
A consumer row must call the protected function and honor expiry/cancellation.
The grant's approval block and README explicitly name this gap.
### Pre-existing test change, Mos authorized
`packages/business/tests/role.test.mjs:209`: exactly 1 insertion / 1 deletion.
Only the regex changes from `/service must be one of/` to `/unsupported.*scopes/`.
The refusal of `{service:"github",scopes:[]}` stays. Reason: sage-3620 authorizes
fixed-scope GitHub declarations; caller-provided scopes now fail on the unknown
key, rather than on an unknown service. Mos mos-1518 (cc Violet, 19:29:15Z) permits
this exact change through Sage. A new-file test in business/tests/github.test.mjs
preserves the closed-service-list check with `not-a-service` and the original
`/service must be one of/` pattern. No other old test has changed.
## Static pass before first implementation push
Additional new tests cover a known token left by an interrupted mint (no adoption,
remote cleanup), unsafe slot modes/symlinks, per-agent busy claim, admin rotate
identity/binding invalidation, fire cleanup/refusal and the lifecycle delete's
first revoke job. No existing fixture or existing test other than the authorized
regex changed. Static review corrected the grant.call method/op field collision
and added revocation sequence binding so an old unspent approval cannot revive a
revoked grant. Both corrections are unexecuted and remain unverified.
No idle GitHub startup sweep is emitted for a business with no GitHub residue.
This avoids changing unrelated runtime evidence. The callback's cancellation
signal is also aborted at the token safety deadline, not only on explicit revoke.
Static dependency inspection found createdCredentials in instance-settings.mjs
would generate an invalid day-based `credentials.github` reference for a new
instance. It now excludes this protected grant declaration, as it excludes host;
a new business/github.test.mjs case pins the human add behavior. This is local
schema integration needed by the new declaration, not new agent grant authority.
## Row198 moving base: 397ef58be (sage-3687, 2026-10-23)
Merged Bushroot's fix head `397ef58bebef2e265e6332bc3ede05de30c4624c`
intact after `ab6417475`. No rebase, amend or force push. His round-3 review
remains open; this is a moving, untested dependency, not acceptance evidence.
Row297's own source/test candidate is still `53a454185`.
Sage sage-3681 allocated the five-new-file first baseline/fix run to T6, about
21:55 UTC, with an explicit CLEAR still required. No Node, tests, browser,
webui build, suites or mutants have run in this session. Consumer stop/gap at
Broker.githubWithToken is accepted by sage-3681. Dewey reviews the row297 delta;
the eventual gate must use the latest intact row198 fix head.
## Static review dewey-0944 / dewey-0946 follow-up (UNTESTED)
Dewey found no source blocker in 53a454185 and confirmed the clean carried delta
at 5ca83173e. Required M1: add a per-path SHA-256 list for the 24 row297 paths,
then report that digest file's own SHA separately to avoid a self-reference.
The list will use git blob bytes at the final head sent for the gate.
Lifecycle-delete coverage now includes the second request completing exactly one
instances.set write after the protected revoke job. New tests are unexecuted.
N1: sweep closes an active generation at its conservative bound with reason
expired, preserving the durable grant for a later renewal. N2: installation calls
require an explicit valid token, with no App JWT fallback. N7: grants.run checks
keys/identifier before approval lookup. Each change has new-file regression
coverage, with Node execution still held for Sage CLEAR.
N3 is documented behavior, retained for Gitea parity: human admin remove can
invalidate cleanup while residues exist; serviceLeft remains until a matching
slot returns or conservative expiry. N4: official API-version source added to
DESIGN.md; public GitHub docs list 2026-03-10 as supported. N5: full event scans
remain acceptable at MVP volume; no cache introduced. N6 for Sage: preserve both
BUILD-LOG.md and docs/SESSIONS.md seat appends alongside refactor's appends when
landing; do not replace either append-only history.
Three additional mutants requested by Dewey have named new-file killers:
- accepting DELETE 401 as revoked: `mutant: closeToken must not count a 401 as proven revocation`;
- treating status 0/5xx as not-issued: `lost mint response stays visible through restart until conservative expiry equality` and `mutant: mint 500/503 is uncertain issuance and stays serviceLeft`;
- dropping recheck's controller.signal.aborted: `mutant: shutdown abort alone prevents a pending generation from applying` (no revocation event, so the signal is the only veto).
No mutant has run; every kill still needs gate evidence.
-221
View File
@@ -1,221 +0,0 @@
# Row 297: Q-B3 answer and proposed GitHub method contract
Violet, 2026-10-23. Issue #1803. Reviewer Dewey. Proposal for review before code.
Source contract: #436b r5 section 10, `agents/megavolt/work/436b/DESIGN.md`
at `1be535aca`. Row 236 is already built; this proposal changes none of its behavior.
Row 198 dependency: merged `14fe7513c`, then `4d8123b0a`, preserving every commit.
## Q-B3: S1-S6
| Item | Answer |
| --- | --- |
| S1 | Agree. Keep `{method, agent, rotateBy, target}`, with `method: "github"` and `target: {projects: [...]}`. Code fixes permissions; the caller cannot name permissions, repository IDs, installation IDs, or an API origin. Resolve projects from reviewed business configuration, and bind the resolved identities in the decision. |
| S2 | Agree. Keep `<configDir>/secrets/grants/<business>/github/admin`, human set only, never an agent service. For GitHub this contains an App RSA private key, not a PAT. Nonsecret metadata identifies the App, installation, account ID, API origin, set time and rotate-by day. One installation per business and method. Only Jason performs the live bootstrap. |
| S3 | Agree. Every network call, including preflight, renewal, verification and remote undo, goes through reserved `action.allowed` `grant.call` then `grant.result`, paired by `callId`. Preserve the grant actor and the kind filter, and use `grantDecision` outside the single consume record. No raw response, key, JWT, installation token, authorization header or token digest enters evidence. |
| S4 | Per method, as section 10 permits. GitHub installation tokens expire within one hour. Recommend a durable approved repository grant, with short-lived token generations renewed only by protected code inside that grant's unchanged binding and rotate-by deadline. Never treat the grant's rotate-by day as a token's expiry. Detailed proposal below. Gitea's mint/issue/settle sequence stays unchanged. |
| S5 | Agree with local-first order. Block new issuance/use and abort the agent's pending generation first; durably remove the active grant next; revoke each known token remotely last. Keep unresolved generations in `serviceLeft` and retry with sweep. An unreturned token cannot be revoked by ID; its bounded expiry must remain visible until elapsed. Do not uninstall or suspend a shared installation to revoke one agent. |
| S6 | Agree. Method-specific checks at admin set, every executor start/renewal, and remote undo. Verify App identity, installation identity/account, non-suspension and the required permissions. Pin repository IDs and verify minted reach across every page. No version-number analogy to Gitea; unsupported responses or absent capabilities refuse closed. |
## Provider facts
Checked against official GitHub documentation on 2026-10-23; these are documentation
findings, not live probes. No credential file was read and no authenticated API call was made.
- [Create an installation access token](https://docs.github.com/en/rest/apps/apps#create-an-installation-access-token-for-an-app):
App JWT authentication; explicit `repository_ids` and `permissions` narrow issuance.
Omitting either uses the installation's broader defaults. Tokens expire after one hour.
Token format is opaque; no fixed-length or 40-character assumption.
- [Generate an App JWT](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-json-web-token-jwt-for-a-github-app):
RS256 with bounded `iat`/`exp`, at most ten minutes ahead, authenticated with Bearer.
- [Installation endpoints](https://docs.github.com/en/rest/apps/installations):
`GET /installation/repositories` reports token-accessible repository IDs and total count.
`DELETE /installation/token` revokes the token used to authenticate that request.
This does not provide Gitea's recorded-token-ID deletion or `GET /token` identity contract.
## Approaches and recommendation
1. Mint once per Approve, store with its actual expiry, then require another Approve.
Smallest lifecycle but forces another human approval within an hour of each use.
2. Durable approved grant plus protected renewal, recommended. The approval names the
agent, exact project/repository binding, fixed permissions and latest renewal date.
Token expiry is separate. This needs renewal, interruption and revoke-race coverage.
3. A human PAT or copied Gitea account/user lifecycle is not proposed. It does not supply
the App issuance contract this brief calls out, and GitHub is not an account-creation
API equivalent to Gitea's site-admin path.
The recommendation is Violet's proposal, not an owner decision. Q-B3 must be read by
Dewey and Megavolt before implementation. Sage must settle the scope points below.
## Proposed per-method S4
The durable authority is the approved binding, not a long-lived token. Raise is chief-only;
Approve and admin-slot writes retain their existing human gates. Preflight checks and
binding recheck happen before consume, under an agent claim. Only consume has `decision`.
At grant time, resolve the configured projects to immutable repository IDs and bind the
definition, relevant role/project configuration and admin metadata. Show the App and
installation identity, repositories, fixed permissions, grant deadline and automatic
renewal behavior in the human approval block. Permission proposal: `contents: write`
plus GitHub's implicit `metadata: read`; no workflow, administration, issue or PR write
permission. Adding any permission requires a reviewed method change.
Before every generation: check the grant is active, unchanged, unexpired, the agent is
still eligible, and processing/revocation does not prohibit the operation. Re-run the
capability floor. Write the mint intent before POST and the result after it. POST always
includes the exact nonempty repository ID list and the fixed permission map. Verify the
returned expiry, permissions and all token-accessible repositories before making the
generation usable. A missing/invalid/past expiry or wider reach revokes the candidate and
refuses. Never use a stale token during a failed renewal.
Use the actual provider expiry as a timestamp with a safety margin. Token replacement
must be atomic with respect to revoke: a revoke that wins while mint/verify is pending
prevents publication of that candidate. Only one generation per agent runs at once.
New generations cannot widen the original approved repository IDs; changed configuration
requires a fresh decision. The App private key and JWT never enter the agent credential
store, and the worker never gets the App key or authority to renew.
There is no claim that a stored token can be identified by a Gitea-style token ID.
On restart, an interrupted generation is held out of use. Any recoverable token is
verified/revoked through its own installation authentication; an ambiguous generation
is not adopted as an applied grant. Where the token was minted but its response/file was
lost, record unresolved remote reach until the documented maximum lifetime has elapsed.
An unanswered mint uses a conservative expiry bound that includes the request deadline;
the implementation must document the clock/response assumptions rather than claim remote
revocation succeeded. A new generation never hides an older unresolved one.
The concrete token storage and consumer hook are still scope decisions below. A generic
`Credentials.issue` using only `YYYY-MM-DD` would lose the token lifetime and is unsuitable
without a method-specific timestamp guard on every use and restart load.
## Scope points for Sage, with reviewer input
These are unresolved integration boundaries found in the existing implementation:
1. Repository authority and identity: `SERVICES`, credential dates, role identity and
publish code currently implement Gitea/Vikunja/host, not GitHub. Recommend GitHub App
access to the configured exact repositories, with the App bot as service identity;
no creation of GitHub user accounts or automatic forks. If row 297 must reproduce
per-agent forks, that needs a separate repository/identity contract before code.
2. Renewal consumer: recommend protected on-demand renewal for the GitHub credential
consumer, with timestamp checks and no worker mint endpoint. Confirm whether row 297
owns that consumer integration (including GitHub git publication), or only the grant
executor/management surface. A stored expiring token with no guarded consumer is not
a complete renewal implementation.
## Acceptance and proposed test manifest
Fake API only; never a real GitHub token or a call to github.com/api.github.com. Bind a
loopback fake explicitly in tests; production origins come only from the admin contract.
Prefer new test files. No pre-existing test changes without Mos's OK through Sage.
Mirror row 236 X-G1 through X-G15 where meaningful: binding refusals and no writes;
admin floor/write-on-refusal; approval end to end; foreign/rebound identities; failure
matrix and revoke leftovers; interruption/restart; approval drift; call/result ordering
and secrecy; admin replacement invalidation; token replacement ordering; human gates,
single consume and busy; paginated exact reach; evidence reservation; fire/delete;
look-alike evidence ignored. Explicitly mark Gitea-only user creation/disable, version
floor, token-name and token-whoami cases as replaced by App/installation identity,
fixed permissions, expiry and generation checks, not silently dropped.
Additional expiry cases: expiry before use; past/malformed/missing or excessive lifetime
in mint response; controlled-clock renewal; renewal refusal after grant rotate-by;
changed installation/repository/definition/admin binding; failed renewal with no stale
use; restart with an interrupted mint; revoke during pending mint/verify; unknown remote
token visible until its conservative expiry bound; no shared-installation deletion.
Mutants mirror these claims: omit explicit IDs; omit permissions; skip identity/floor,
binding/expiry/reach check; read only one page; publish before verify; mint before intent;
result before call; skip claim; revive after revoke; keep expired token after renewal
failure; renew after grant deadline; clear unresolved reach early; perform remote undo
before local revocation; bypass human/reserved-evidence gates; log a provider secret.
Full touched suites, every `scripts/test-*.sh`, and mutants wait for Sage's CLEAR. Dewey
approves the final manifest. Row 297 lands only after row 198's own gate passes.
## Build design: rulings and review inputs (2026-10-23)
Sage sage-3620 accepts App bot access to exact configured repositories, no accounts
or forks, and row297 ownership of protected mint/renewal/evidence/revoke/expiry.
New GitHub mirror push, publication and credential helpers are excluded. Stop on
the protected side where the Gitea consumer path cannot carry GitHub safely; Sage
files the consumer gap. Dewey dewey-0931 and Megavolt megavolt-0206 read Q-B3 and
report no design blocker. Approach 2 is selected under these rulings.
### Binding and protected interface
Proposed additive configuration: a definition declares `{service: "github"}` with
no caller scopes; the business role declares `github: {projects: [...]}`, and each
such project has `remote` plus positive safe-integer `githubId`. These declarations
are separate from Gitea `identity` and fork-based `publish`. Unknown keys, including
account/fork requests, refuse. No role policy file or live configuration is changed.
The grant binding stores the exact project IDs, repository IDs and remote names,
role/definition digest and admin metadata digest. Renewal compares those immutable
snapshots to current configuration; it never adopts configuration changes as new
authority. GitHub scope approvals remain human-only (no Q228 scope fast path).
Expose renewal only as a trusted host function `withToken(business, agent, fn)` in
protected code, passing `(token, signal)` to the trusted callback. No worker or
socket verb returns a token or mints one without a durable approval. Token files
are generation-specific protected files, never evidence. Do not load these into
`Credentials`, whose calendar-day guards and Gitea helpers cannot enforce expiry.
That is the consumer gap: publication/credential-helper wiring needs another row.
### Dewey R1-R5 and Megavolt notes
- R1: Before paginated verification, require exact mint `permissions` equality to
`{contents: "write", metadata: "read"}`, `repository_selection === "selected"`,
and returned repository IDs equal to the bound set, without duplicate IDs.
Missing fields, subsets and supersets refuse and revoke the candidate. Mutants
accept extra permissions or extra repositories; both must be killed.
- R2: Production metadata accepts normalized HTTPS origins only; no HTTP or test
bypass. Tests run a fake HTTPS GitHub API with an injected scratch CA and optional
transport, following row236. A named admin-set test rejects HTTP before any call.
- R3 C1 replacement: pinned API version and required App/installation/token-response
shapes, refusing unavailable/unsupported capabilities. No Gitea version comparison.
C2 replacement: RSA key's signed JWT authenticates the configured App ID and the
installation belongs to it and is not suspended. C3 replacement: installation
permissions cover the fixed map; the issued token's map must equal it exactly.
C4 replacement: App ID, installation ID and account ID equal the slot metadata.
No claim that the App is a dedicated site-admin account with an empty repo list.
- R4 constants: `TOKEN_LIFETIME_MS = 3_600_000`, `CLOCK_SKEW_MS = 60_000`,
`TOKEN_MARGIN_MS = 60_000`, `CALL_TIMEOUT_MS = 15_000`. A generation is usable only
before `expiresAt - TOKEN_MARGIN_MS`. Mint expiry cannot exceed the request start
plus lifetime plus skew. Unknown mint reach remains until request start plus
request timeout plus lifetime plus skew. Equality at each deadline is expired.
These assume provider expiry is bounded by the documented lifetime and skew;
a request that never returns has no claim of proven remote revocation. Each
boundary has a controlled-clock test. JWT uses `iat = nowSeconds - 60` and
`exp = nowSeconds + 540` (less than the ten-minute maximum).
- R5: A changed project ID, remote, allowed-project list, role definition or admin
slot between generations refuses renewal, even if the replacement configuration
would independently grant access. Re-approval is required.
- Admin CLI: separate `--app-id`, `--installation-id`, `--account-id`, `--url`,
`--rotate-by` shape. RSA private key on bounded stdin; never misuse `--account`
for an installation ID. Rotation replaces a Jason-provided App key on stdin;
this method does not pretend it can manufacture/delete GitHub App private keys.
- Approval block and README explicitly exclude `.github/workflows/` writes and
account/fork creation. No workflow, issue, PR or administration permission is
granted. All fake JWT claims use the injected clock.
### Review follow-up (dewey-0944 / dewey-0946)
`API_VERSION = "2026-03-10"` is pinned from GitHub's official
[API Versions](https://docs.github.com/en/rest/about-the-rest-api/api-versions)
page, checked 2026-10-23. It lists that version as supported and specifies the
`X-GitHub-Api-Version` request header. This is a public-document check, not a
live API probe; fake tests do not reach GitHub.
`grants sweep github` may close an expired active generation once its conservative
`until` bound has elapsed. It deletes that generation's private file and records
`grant.token.closed` with reason `expired`; the durable grant stays renewable.
It must not close a live active generation early. Installation endpoints now
require an explicit valid installation token and cannot fall back to an App JWT.
Manual run validates keys and identifier before any approval lookup.
Admin remove intentionally retains the human's existing ability to remove the
local slot while grants or residues exist (Gitea parity). This invalidates future
use/renewal, but known residue cannot be remotely retried until a matching slot
returns; expiry still closes it at its conservative bound. Status/sweep do not
claim remote revocation in that case. The App itself and private keys are not
removed at GitHub. Event projection is a full read at MVP volume; no cache is
introduced whose invalidation could weaken revocation.
-99
View File
@@ -1,99 +0,0 @@
# Row297 verification manifest (UNTESTED)
Candidate pending tests. Dewey approves this manifest; Sage owns CLEAR and gate.
All listed tests are fake HTTPS API or scratch configuration. No live key/token,
no GitHub service contact, and no public consumer is installed. No Node/test run
has occurred in Violet's session. Static `git diff --check` is the only check run.
## Files and permission boundary
- New protected modules: bus/src/github-api.mjs (HTTPS, JWT, App slot and floor),
github-files.mjs (private atomic files), github-grant.mjs (event projection,
approval binding, renewal, generation files, revoke/recovery).
- Broker/runtime add the method to existing grant gates and lifecycle revocation;
no token-returning or renewal socket verb. Existing Gitea code paths remain.
- Business role/instance parsing: a role declaration `{service:"github"}` takes no scopes; role.github
names allowed projects; project.githubId pins its provider identity. Generic
token-reference schema stays closed to github. No role JSON policy is changed.
- CLI github-grants.mjs handles method-specific admin identity flags and bounded
stdin. grants.mjs dispatches the method without changing Gitea flag shapes.
- New tests: bus fake-github-api.mjs, github-api.test.mjs,
github-grant.test.mjs, github-broker.test.mjs; business github.test.mjs;
cli github-grants.test.mjs. README and packet/build records document the limits.
- One pre-existing test change: business/tests/role.test.mjs:209, exactly 1/1,
regex only. Mos mos-1518 authorization and replacement closed-list test are in
BUILD.md. No other pre-existing test is edited by Violet.
## Row236 X-G correspondence
| Row236 claim | GitHub replacement / named coverage |
| --- | --- |
| X-G1 binding, no writes | grant binding rejects scopes/account/fork keys; business binding refuses missing declaration/project/ID; R5 drift refuses before consume |
| X-G2 C1-C4 | C1 pinned API/required response shapes; C2 signed App authentication and App/installation identity/non-suspension; C3 installation permissions cover the fixed map; C4 App, installation and account IDs match metadata. API tests name identity, suspension, floor, HTTP and RSA refusals |
| X-G3 approved end to end | broker chief raise, human Yes, exact durable grant, protected callback, revoke, fake token gone |
| X-G4 foreign user | No user account lifecycle. Replaced by App/installation/account identity, exact configured repository IDs/names, no account/fork request |
| X-G5 failures/leftovers | invalid mint candidate revoked; remote delete failure is serviceLeft; failed renewal never calls consumer; revoke wins pending renewal |
| X-G6 restart | active generation needs its recorded binding/timestamp; lost response remains until controlled bound; interrupted call without result is visible; no interrupted generation adoption |
| X-G7 binding drift | project ID, remote, allowed-project list, definition and admin metadata between generations; preflight refusal unspent, restart spends interrupted |
| X-G8 calls/secrecy | intent and call visible while fake receives request; result after call; only consume has decision; no key/JWT/token in evidence |
| X-G9 admin rotation | Human replacement key, same App/installation/account/origin, setAt changes binding; no GitHub private-key creation/deletion claim |
| X-G10 replacement | new generation active before old token DELETE, old token removed afterward |
| X-G11 callers/claim/spend | human-only admin/run; chief-only raise; no socket renewal/token; old approval invalid after revoke; single consume across renewal |
| X-G12 reach | exact mint IDs/permissions/selected mode, paginated GET through page 2, repository names checked; wider GET reach refuses and deletes candidate |
| X-G13 reserved namespace | existing grant actor/trigger retained; new direct human insert refusal test; existing grant-migration tests cover shared reservation |
| X-G14 fire | GitHub local revoke; fire completes only after remote cleanup; remote leftover blocks fire. No shared installation suspend/uninstall |
| X-G15 evidence kind | a look-alike applied record under action.refused cannot establish a grant |
Gitea-only version ordering, user nonce/adoption/disable, token-name minting,
admin token minting and token-ID whoami settlement are replaced, not copied.
No Gitea account or fork API is used by the GitHub method.
## Dewey R1-R5
R1 tests mint permission extra/missing/higher keys, selected mode, repository
extra/missing/duplicate IDs before paginated verification. R2 tests admin HTTP
refusal with no loopback exemption; fake HTTPS injects a scratch CA. R3 maps C1-C4
above. R4 constants are lifetime 3,600,000 ms, skew/margin each 60,000 ms, request
15,000 ms; JWT clock claims, margin equality, excessive expiry and lost-response
bound equality have controlled-clock tests. R5 tests configuration changes between
generations, preserving the original binding rather than granting from new config.
## Runs requested, not yet authorized/executed
1. New-file red/green or baseline diagnostic run: `node --test` on the five new
test files. Tests were written first; the test hold prevented observing red
before implementation, so no TDD execution evidence is claimed.
2. Touched suites: business, bus, cli. Full every `scripts/test-*.sh` on Sage's gate.
If the gate includes webui, roster's required editor build applies; Violet
has not run a webui build or touched row198's inherited webui tests.
3. Mutants: run on a disposable copy only after a green baseline. Every survivor
is an open failure, not a waived requirement. Planned mutations: HTTPS origin,
RSA restriction, App/installation/account identity, permission floor, explicit
mint IDs/permissions, exact response maps/selected mode/IDs, expiry ceiling,
margin boundary, durable config/admin digests, revocation epoch, expiry bound,
intent/call ordering, local-before-remote revoke, evidence kind, auto-dispatch,
lifecycle inclusion and unknown-token retention. Actual kill evidence is pending.
## Remaining evidence gaps before acceptance
No test has run, so no pass count or mutant kill is claimed. New tests now cover
a known interrupted token, unsafe file modes/symlinks, busy claim, admin rotate
and the lifecycle-delete revoke job. Runtime verification may require fixes or
additional new-file coverage; lifecycle-delete now includes its second request
completing after the revoke job. These are in-scope gaps, not deferred acceptance. The consumer gap is separately
ruled out of scope in sage-3620 and recorded in BUILD.md.
## Byte manifest (Dewey M1)
`candidate-manifest.sha256` lists SHA-256 of the git blob bytes of the 24 paths
reviewed for row297, including this MANIFEST.md. It excludes itself to avoid a
self-reference. Its own SHA-256 and the final pushed head are reported together
in Violet's review handoff message. After any byte change, regenerate the list
and report a new checksum before the gate. Inherited row198 paths are excluded.
The three extra mutants from dewey-0944 are required: accept DELETE 401 as
revoked (killer `mutant: closeToken must not count a 401 as proven revocation`);
count 0/5xx as not-issued (lost-response test and `mutant: mint 500/503 is uncertain
issuance and stays serviceLeft`); drop the controller abort check (`mutant:
shutdown abort alone prevents a pending generation from applying`). All kill
evidence remains pending Sage CLEAR.
-69
View File
@@ -1,69 +0,0 @@
# GitHub grant method implementation plan
Goal: implement row297's protected GitHub App grant and renewal lifecycle.
Architecture: a GitHub transport/slot module and event-backed executor, integrated
through the broker's existing human grant gates. No GitHub consumer is added.
Stack: existing Node ESM, node:crypto/node:https/node:fs, append-only bus events.
Spec: DESIGN.md, including sage-3620, dewey-0931 and megavolt-0206.
Execution: Violet inline; Dewey reviews the final manifest. No subagent dispatch.
## Global constraints
Fake HTTPS API and scratch CA only. No credential files, live token, real API call,
Node or test execution before Sage CLEAR. No old-test edits without Mos approval.
Preserve row198 commits; push only violet/*; no amend or force push. No schema
migration for grant state: use reserved grant evidence with the existing actor.
## Review focus
- Lost mint responses remain visible until conservative expiry; never claim undo.
- Revocation racing renewal cannot publish a late token or restore the grant.
- Malformed provider responses cannot authorize a wider token.
- Restart cannot adopt an interrupted generation or changed configuration.
- Refusals and audit records cannot leak RSA/JWT/token values.
## Task 1: configuration, HTTPS transport and admin slot
Files: new bus github-api.mjs and github-files.mjs; modify business role/business
parsing; new business github.test.mjs and bus github-api.test.mjs/fake-github-api.mjs.
Interfaces: checkGrant, binding configuration checks, githubApi({configDir, ca,
transport, now}), adminMeta/setAdmin/removeAdmin/checkAdmin/mint/repos/revoke.
- [ ] Write tests first for HTTPS-only metadata, RSA-only input, App/installation/
account identity, suspension, permission floor and fixed mint response equality.
- [ ] Run red tests on Sage CLEAR; record the exact failure, no invented red claim.
- [ ] Implement strict additive config and slot/transport with no raw response audit.
- [ ] Run new tests green on CLEAR; no old tests changed.
## Task 2: durable executor and renewal
Files: new bus github-grant.mjs; new bus github-grant.test.mjs.
Interfaces: configure({store,business,event,approved,consume,grantable,working});
raiseBinding, run, withToken, revoke, sweep, status, reconcile, idle, close.
The broker supplies private approved/consume/event callbacks, not a worker verb.
- [ ] Write lifecycle/expiry/recovery tests before implementation, including all
R1-R5 cases, revoke races, config drift and lost-response bounds.
- [ ] Run red tests on CLEAR, then implement evidence projection, generation files,
exact reach, expiry-aware guarded use, local-first revoke and retryable cleanup.
- [ ] Verify no untrusted token-returning endpoint and no token in any evidence.
- [ ] Run green and mutant checks on CLEAR; record every unexecuted check honestly.
## Task 3: broker, runtime and CLI integration
Files: broker.mjs, runtime.mjs, cli grants.mjs; new github-broker.test.mjs and cli
github-grants.test.mjs; bus/cli README edits. Existing Gitea behavior stays intact.
- [ ] Write tests first for chief raise, human approval, rejected caller scopes,
admin human-only, run claim, status/revoke/sweep, fire/delete and restart.
- [ ] Dispatch github before existing Gitea branches; connect reserved writer and
consume, automatic run on human yes, reconcile, lifecycle revoke and shutdown.
- [ ] Add method-specific CLI admin flags and RSA stdin; never print secrets.
- [ ] Document fixed scope, workflow/account/fork refusals and consumer gap.
- [ ] Request CLEAR for touched suites and every scripts/test-*.sh; run only after
granted. Present actual diff/numstat, manifest and mutants to Dewey and Sage.
## Acceptance ledger
Static checks can run without Node. All red/green runs, suites, mutants and gate
are currently unexecuted. No success claim is implied by this plan.
@@ -1,24 +0,0 @@
6b54b05eeabaee822ad9ec96e63ba007c6d52b7e66100ab4dc051fa414af103f BUILD-LOG.md
e9dcab5e3125660f177989e6bf726da596149aa237c4f179fa24c13aefc009e1 agents/violet/work/queue-297/BUILD.md
156db930b2e455c7dc8deffdd48fd832d898b32e5c8136f014bd4af84f50f526 agents/violet/work/queue-297/DESIGN.md
99c99d4ca9bdf1397869247c454f45fd0c3fba8fb47b0b9bd424e8532b7d2453 agents/violet/work/queue-297/MANIFEST.md
929d9a5fc28bdcdfd5c7db5cb818186633954d5f60ad2a03e129d062bcb4f725 agents/violet/work/queue-297/PLAN.md
d599b0f0d4c248fba5a32b4f04022ac4676a4cb4029d32d29e0ab1f10ade9162 docs/SESSIONS.md
bed3be579c94c16f6ad840b298f589339e1e0d58e3fca13571e2262a3502aace packages/bus/README.md
e46abbba0514be8bad8c53ec3a0b19133746bb96318c13923d66e9f54c6b2a34 packages/bus/src/broker.mjs
1f09e6a8e3b814a475fb1f7a91bd6ee670e08d7c76399212134e107a2ec4b499 packages/bus/src/github-api.mjs
2f3fce1244446c4be93cdc8ab744de710758bdebb1106e09badd25fa57c355ff packages/bus/src/github-files.mjs
bca3836d62525620d9af372c0c2039e3405579290e8919a1f4f6a35baafee6da packages/bus/src/github-grant.mjs
83a2d294b87e900fcb8357724941be1475ce5ef6afde237a149eb5c138f087b2 packages/bus/src/runtime.mjs
c6e6884471a884663db9446f662669d0977009f6f2f24f2a388e29104c9cb960 packages/bus/tests/fake-github-api.mjs
9e0f43e5b25c3a77d746dbd1b956ff767406548fda2528cab51c8d5b38157e5b packages/bus/tests/github-api.test.mjs
8c78d2ad8aad12632ee40eea8212fd360281bc11b4ae900bf3e38f4618c5d6db packages/bus/tests/github-broker.test.mjs
544f15f07cfaa76394da8618d63573ef91a7c0ebf3f65a6f39339c015e4f96a5 packages/bus/tests/github-grant.test.mjs
87dc329f40f5783a4c2e85c9a1d8c94b6dc4889cdce7191e79af852ab251fac7 packages/business/src/business.mjs
c022a8aa32d84a189b585696b207001ba690660ed1c83c6038be4031703550a4 packages/business/src/instance-settings.mjs
a68518d83a039ecc82ddb4cfbbc6234df8eac384acc78499c94b0d0945a7717b packages/business/src/role.mjs
956981b7ab8f633054831a00133d44a43b5bf9be7c6fd6a326ce20b769c9f822 packages/business/tests/github.test.mjs
445c224530f5545fd4e1e734c7b36c407db6f1b182b6c6ef65e5c9d91fd16ac0 packages/business/tests/role.test.mjs
410b3c3e50c476de39720b0906dcf077739d029e293b444cc6e4a13bb24a9031 packages/cli/src/github-grants.mjs
9fd3589258b83e7cf2c5828811251d0d0a24087a8bded926a44144e3b4a07438 packages/cli/src/grants.mjs
2886fbc1c4c6f0dc22d365e76641f13b7e31f46123b9e7b3e280d2ff79ea52e9 packages/cli/tests/github-grants.test.mjs
-10
View File
@@ -1585,13 +1585,3 @@ are never rewritten or removed; corrections are new entries.
2026-10-23 | Huey (cabd45ca) | Row 272 round 2, sage-3354/3357/3358/3361 | Rebased onto 5fdfd7275 on huey/row272-5fdfd7275, preserving 219/295 and approved old-test hunks; rev3510 in-progress. New fallback test ran RED first in Sage's slot: expected legacy failure, 3 guards pass; slot returned. Bounded fallback and extra guards/mutants prepared for Sage's full gate; no post-implementation run or green claim.
2026-10-23 | Huey (cabd45ca) | Row 272 round 3, mos-1455 / sage-3435 | Added empty role authority arrays to new CLI fixture and guarded null old at all three chat render sites; browser tests byte-identical. Same 17-path manifest.r3, no local execution or queue writes. Follow-up candidate awaits Scrooge delta review and Sage gate; gate run 1 remains RED.
2026-10-23 | Sage (1ef1e4f8) | Rows 272 and 307 landings, row 306 design, row 252 rebase check | 272 landed d33989230 and 307 landed d243f6b25, both on full five-suite gates with mutants killed. Row 306 design r2 approved by Dewey (c31197), build going under mos-1463. Row 252 rebase 3e6de9807 on fe4ee8c7b checked and Scrooge given the slot (sage-3488).
2026-10-23 | Sage (1ef1e4f8) | Rows 291 and 306 landings, row 308 launch | 291 (PRD 1.16, #1796) landed 4874d1e83 and 306 (#1813) landed 1676ad0de..2d0318a44 by temp-index replay, patch-ids equal, both on all-suite gates (mos-1455, plugins 0 fail, 306 mutants M1-M3 killed). 306 N1/N2 in DEFERRED 9c8877bdf. Row 308 (PRD 1.17, #1815) briefed and launched to Gosalyn; slot passed to them for claim, push and in-review. Scrooge told to rebase 252 on 9c8877bdf. Mos told (sage-3515, sage-3520).
2026-10-23T17:29:46Z | Gyro (Codex gpt-6.1-sol, sage-3542) | 571 REQ-LIB-5/Q239 Connections design amendment | Proposed W8 plus parent/W4 alignment; Mos build order kept distinct from import acceptance. Static docs checks only, no repo Node/tests/live action. Fenton review, Sage landing.
2026-10-23T17:32:37Z | Gyro (Codex gpt-6.1-sol, sage-3544) | 571 Connections N1/N2 | W7 opening-order cross-references and human-view comment/deletion exclusion clarified. Static docs checks only; no repo Node/tests/live action. Delta to Fenton, Sage lands.
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.
+8 -98
View File
@@ -187,10 +187,8 @@ canon Markdown chapter takes its position by that rule or a human's committed or
import preserves its chapter ID and position. Human reordering commits the manifest.
library_manuscript and next_chapter use this order, never archived paths. Each
answer/page names format, owned location/ID and captured commit. DOCX wikilinks stay clickable
in the Console editor, and no graph shows them yet. Under PRD 1.17 REQ-LIB-5 and Q239,
the Console's Connections view lists the current links and backlinks of each story file
the user may see, in both formats, with each one's scope and format. W8 owns that view
and its tests. Obsidian's archive
in the Console editor; no graph shows their current links yet. A Console Connections view
and its tests are later work, waiting for Jason's J-b answer in PRD 1.17. Obsidian's archive
graph is historical, not a live DOCX graph. No graph implementation is in these W rows. New Story chooses universe/book or playground and gets
its place in that manifest from its first DOCX commit, without an MD copy.
@@ -244,8 +242,8 @@ follow stable identities without an agent rewriting story links.
Obsidian's old Markdown graph is historical after conversion. It does not track later DOCX
edits, and it is not a parallel writing surface. The format map names current files and
clickable editor links resolve across formats; it is not a graph view. No graph shows DOCX
links yet. W8 supplies REQ-LIB-5's Console Connections view: current links and backlinks
of each story file the user may see, in both formats, with each one's scope and format. No Markdown export or Obsidian graph parity is promised. `.doc`/`.qxd` imports remain separate D23 work.
links yet. The later Connections view waits for Jason's J-b answer; it and its tests are
outside the current W3/W7 scope. No Markdown export or Obsidian graph parity is promised. `.doc`/`.qxd` imports remain separate D23 work.
D12. Q235–Q237 withdraw the superseded-notice proposal and its open-Q36 boundary. Current
Case 2 chapter reads after conversion use W4b's committed DOCX reader/citations. Agent
@@ -581,19 +579,12 @@ runs Jason's layout agent nor automatically grants it access or sends book text
All rows follow BRIEF-TEMPLATE. They are not queue entries. S0 M1–M14 remain binding throughout W2/W6; source/target
changes return to security review. W7 adds the policy conversion/routing row. S0 and the original W4 design have reviewed verdicts; this policy revision goes to Fenton.
Their implementation gates remain separate. No code row W1/W2/W6/W3a/W3b/W3/W4b/W5/W7/W8 enters the queue
Their implementation gates remain separate. No code row W1/W2/W6/W3a/W3b/W3/W4b/W5/W7 enters the queue
before S0 PASS (recorded for this baseline, with M1–M14 still binding). Each also observes its explicit dependencies, source ownership, the CSP gate
and the MVP gate-slot priority. Q177 permits chaining reviewed candidates, not overlapping
owners. Sage assigns model-only seats. Each implementation reviewer is Opus 5.5 and did not
author the code. Fenton reviews this design and can review code only when not its author.
Build order (mos-1470, Vikunja 571 comment 961): W7 first supplies reviewed conversion/format
routing code with production import still closed, then W8 lands the Connections view, then
W7's human opening step may open the first real chapter import. W8 is after W3/W4b and
W7's closed routing candidate, before that W7 opening step. This is Mos's build-order rule,
not a new REQ-LIB-8 condition or an addition to import acceptance. No converted real chapter
exists without the view. O2 code remains after 10-27 and never ahead of MVP rows.
G1. Future code acceptance runs each touched test file independently and affected package
suites on Sage's slot after MVP priority, plus required repository checks. Source builds need
pinned tools, reproducible hashes, license/source manifests and independent review. Integration browser
@@ -1267,7 +1258,6 @@ Independent conversion/parser/authorization review, writing/library/bus/harness/
pinned-copy and sandbox closure tests, plus W4 A19/A20/A33–A35 and G1. Source archive, off-host acknowledged DOCX and
format-map snapshot must match the new DOCX publication before completion. No real import
opens before all D12 gates. The October 27 Markdown and supporting-doc fixtures still pass.
The human opening step follows W8's landing (mos-1470, build order, not an acceptance condition).
### Handoffs
@@ -1275,83 +1265,6 @@ Owner sends commit/blob and fixture publication/crash evidence directly to the r
Sage through T3. Reviewer sends verdict to owner/Sage. Copy Mos for the later first-real-book
go or any proposed policy change, never ask an agent to execute that go.
## W8 Console Connections view for Markdown and DOCX story files
### Problem
PRD 1.17 REQ-LIB-5 at 30e6bb680, approved through Q239, requires the Console to list
current links and backlinks of every story file the user may see, in both formats, with
scope and format. Clickable editor wikilinks and Obsidian's historical archive graph do
not supply this view.
### Owner and reviewer
- Owner: Sage assigns the seat, using Codex gpt-6.1-sol for cross-format lookup and authorization.
- Reviewer: an independent Opus 5.5 seat assigned by Sage, not the author.
- After: W3, W4b and W7's reviewed closed conversion/format-routing candidate; serial handoff
from W7 for shared library/WebUI files and from C2-11a/11b and C2-10b for shared UI paths.
- Before: W7's human opening step for the first real chapter import, under mos-1470. This
orders builds, not REQ-LIB-8 acceptance, and does not change its four opening conditions.
Post-10-27 only, with no gate slot ahead of MVP rows.
### Files owned
New: `packages/library/src/connections.mjs`, `packages/library/tests/connections.test.mjs`,
`packages/webui/src/connections-http.mjs`,
`packages/webui/src/public/{writing-connections.js,writing-connections.css}`,
`packages/webui/tests/{writing-connections.test.mjs,writing-connections-browser.test.mjs}`.
Existing or handed off from W7/W3: `packages/library/src/{index.mjs,links.mjs}`,
`packages/webui/src/{serve.mjs,bus.mjs}`,
`packages/webui/src/public/{index.html,writing-pane.js,writing-pane.css}`,
`packages/webui/README.md`.
### What ships
A Console Connections control beside story navigation opens a keyboard-accessible list
with Links and Backlinks sections for the selected story. Each item shows its title,
scope and format, and opens the current authorized story through the existing resolver.
It works for Markdown, DOCX and a partly converted book. It uses committed link metadata
and W7's stable identities/format routing, never unsaved editor buffers or archive content.
The response names the captured versions; after a committed save or conversion publication,
a refresh uses the current authorized snapshot. An in-flight stale result cannot replace
that refresh. An empty section says there are no visible links; it does not reveal hidden
counts or titles. Unresolved links remain visibly unresolved and cannot open guessed paths.
A human-only Console read route uses existing authenticated project/universe/source access
and exclusions. Authorize both endpoints of every displayed edge before returning titles,
paths or counts. Backlinks cannot reveal another business, a denied universe, excluded
story or archived source. Links inside comments or tracked-deletion text are not current
story links, so the human Connections view never shows them and never depends on an agent
setting. Final-view story links use the same committed extraction rules as W4b. Archive links are historical
and excluded from this current-story view. No new agent grant, tool or conversation gate.
`connections.test.mjs` covers forward/backlinks across both formats, mixed-book identity
preservation through conversion, current committed saves, scope/format labels, exclusions,
archive/old-path refusal, unresolved targets and denial without metadata leaks. The HTTP/UI
suite in `writing-connections.test.mjs` covers authentication, access revocation, response
version binding, stale refresh rejection and refusal rendering. The browser fixture suite
covers keyboard/focus, Links/Backlinks empty states, labels, cross-format navigation and
refresh after save/publication. Fixtures only, never Melanie's text or a live stack.
### Out of scope
A node graph, Obsidian graph parity, editing links or story text, agent tools, private-book
conversion, changes to REQ-LIB-8 acceptance, Q234's header, mos-1363 or S0 M1–M14.
### Gate
Independent Opus 5.5 review of access filtering and the human view, the named fixture suites
and G1 on Sage's slot. Evidence shows both formats and mixed conversion state without
archive or denied-source leakage. Sage records W8 landing before W7's first-real-import
opening step per mos-1470; no import acceptance test or PRD condition is added here.
### Handoffs
Owner sends pushed commit/blob and fixture evidence directly to the reviewer and Sage via
T3. Reviewer sends verdict to owner and Sage. Sage orders W8 landing before the W7 human
opening step, and hands the shared files back before further W7 edits. Copy Mos for the
first-real-book go or any proposed policy change.
## Engineering choices from mos-1368
This table resolves the map's engineering prompts; these are design decisions, not questions
@@ -1369,7 +1282,7 @@ Later mos-1372/1375 override A32/A33's per-document/dedicated-session assumption
| Q7 / A32/A33 | mos-1372 D9: ordinary Case 2 access in any thread/room, no per-document grant/share/dedicated session; existing supporting writes continue; marked for PRD 1.17 approval |
| Q8 / A36 | W4b/W7: preserve Case 2 find/get/search/manuscript/universe tools via host committed-reader adapters, not fewer tools or raw ZIP access |
| Q9 / A73 / C2 | W7: D10 manifest order, typed next_chapter, paragraph cursors bound to snapshot/access; both W4 byte cap and harness REPLY_CAP apply, never cut paragraphs or invent line equivalents |
| Q10 / A22 | W3/W7 keep clickable cross-format editor links; W8 supplies current links/backlinks for visible story files in both formats with scope/format (REQ-LIB-5, Q239); no DOCX graph yet; Obsidian archive graph is historical |
| Q10 / A22 | W3/W7 keep clickable cross-format editor links; no DOCX graph yet. Connections view/tests are later work, waiting on Jason's J-b answer; Obsidian archive graph is historical |
| Q11 / A37 | W7: Console archive saves refuse; sync flags later archive edits or recreated old paths and excludes them from current canon; no remote-push refusal/protected-pattern guarantee |
| Q12 / A21 | W7: reviewed markdown-it AST + first-party DOCX adapter; no new runtime converter; input cap 1 MiB default, W4 ZIP/XML validation, construct/token/extractor fidelity before archiving |
| Q13 | W7 survey: synthetic construct corpus and aggregate-only count capability; no seat reads real text or claims a corpus census happened |
@@ -1404,9 +1317,7 @@ renderer does not enable it. No change is imposed on the October27 release.
Alignment note: sage-3121 checks this revision against PRD 1.17 d6,
SHA-256 `886b93c571f87bfbb2f27ee3113b6800a32d8f89196d95ace33566031bee380a`.
That historical d6 alignment is superseded for Connections by landed PRD 1.17 at 30e6bb680:
REQ-LIB-5/Q239 require W8's list view, ordered before real import under mos-1470.
New non-old-path Markdown stays canon until import;
Connections graph/tests wait for J-b; new non-old-path Markdown stays canon until import;
archive folder is human-set in the library mapping with no default.
## Current verification and remaining gates
@@ -1421,5 +1332,4 @@ append-only logs. No build, browser run, conversion, queue entry or live change
Jason's format and CSP choices are settled by Q234–Q237. No question repeats Q1 or Q36.
Before real conversion: Sage's Case 2 PRD amendment (Vikunja 570) needs Jason's approval;
W4b/W7, Hermes-retired and post-10-27 gates must pass; Jason must explicitly give the first real Melanie-book
conversion go. W8 lands before the human opening step under mos-1470 as build order, not
an acceptance condition. These are later authorization gates, not requests to convert anything now.
conversion go. These are later authorization gates, not requests to convert anything now.
+1 -5
View File
@@ -66,11 +66,7 @@ A Markdown file recreated at a converted old path is unmapped, neither canon nor
excluded from lookups and flagged to the human. A new Markdown story at a different path in
a converted book stays canon until import, flagged and offered for human import; no automatic
conversion. Existing playground scope rules remain. Clickable DOCX editor links stay; no
DOCX graph exists yet. PRD 1.17 REQ-LIB-5 and Q239 require the Console's Connections view
to list the current links and backlinks of each story file the user may see, in both
formats, with each one's scope and format. Parent design W8 owns the view and its tests.
Mos-1470 orders its landing before W7's first-real-import opening step as build order,
not an additional REQ-LIB-8 acceptance condition.
DOCX graph exists yet. A Connections graph and its tests wait for Jason's J-b answer.
Every conversion runs the pinned extractor before archiving and refuses unsupported output.
An independently reviewed importer preserves every wiki token and front-matter key.
-82
View File
@@ -1,82 +0,0 @@
# PRD 1.17 brief: Case 2 story files are DOCX
Sage, 2026-10-23, for mos-1366 item 1, mos-1467 and mos-1470. This follows
`docs/plans/BRIEF-TEMPLATE.md`. Gitea #1815, Vikunja 570.
## PRD 1.17 Case 2 story files are DOCX (Q235 to Q237, Q239)
### Problem
Jason's Q235 to Q237 make Case 2 story files DOCX, with Markdown kept for notes
and docs. PRD 1.16 still says every story file is Markdown in the user's
repository, seen in Obsidian. The 1.17 text is approved: Jason approved d8 at
435a7458d in Q239 with O2, which adds the Console Connections view (mark J-b).
d9 carries that view in Mos's mos-1443 R3 text. Webby approved d9 in rounds 7
and 8 (webby-0214, webby-0217), and Mos passed the J-b check in mos-1470.
The approved text is `agents/sage/work/prd-1-17/DRAFT-d9.md` at 4607e15b8, blob
sha256 02f28decad7f946f. Its base is PRD 1.16, `docs/prd/mosaic-stack.md` blob
a6db9b4a at 482e865ac (row 291).
### Owner and reviewer
- Owner: Gosalyn.
- Reviewer: Webby.
- After: row 291 (PRD 1.16).
### Files owned
The four files in DRAFT-d9.md's "Files the row would own", as it lists them:
- `docs/prd/mosaic-stack.md`.
- `packages/tasks/requirements.json`: version 1.17, the titles of REQ-LIB-1,
REQ-LIB-2 and REQ-LIB-4, and the new ids REQ-LIB-8 and REQ-LIB-9 after
REQ-LIB-7 and before REQ-INST-1.
- `packages/tasks/tests/requirements.test.mjs`: the version pin 1.17, the count
87 (:21), and the duplicate-id message `requirements[87].id: REQ-ROLE-1 twice`
(:77).
- `packages/bus/tests/requirements.test.mjs`: the list length 87 (:48).
### What ships
- Every "Replace", "Add, after", "becomes" and "New" instruction in DRAFT-d9.md's
PRD sections, applied to the 1.16 text word for word. Each quoted old text
occurs once in a6db9b4a, except "The Console shows each cite's scope.", which
d9 scopes to REQ-LIB-2. Webby checked those counts in round 6.
- The 1.17 header, Status line, source lines and notes as d9 writes them.
- requirements.json as d9's requirements.json note says: REQ-LIB-1, REQ-LIB-2 and
REQ-LIB-4 take the new titles, REQ-LIB-5 and REQ-LIB-6 keep theirs, and
REQ-LIB-8 and REQ-LIB-9 are added with the titles d9 gives, so the count is 87.
REQ-LIB-4's title is 117 characters, under the 120 limit in `requirements.mjs`
(mos-1443 R2).
- Nothing from d9's review sections goes into the PRD. That covers "Settled
points", the "d2 changes" through "d9 changes" lists, "What d5 adds", "Marked
for Jason" and the impact map notes.
### Out of scope
- The build rows for Vikunja 570 and 571, which Sage files after the landing.
- The queue changes in the impact map (A24, A57 to A59), which go through
`scripts/mosaic queue` (C9).
- Gyro's design edit R3.4 at 767bb62c7.
- Mos's build-order call (mos-1470): the Connections view lands before the first
real chapter import opens. That belongs to Gyro's 571 rows, not the PRD.
### Gate
- Webby checks the candidate's PRD diff against DRAFT-d9.md, one instruction at a
time, and checks that nothing else changed.
- No new Jason question. Q239 approved the text, and a change beyond d9 would
need a new version and his approval.
- On Sage's gate:
- tasks, run whole;
- bus, run whole, because the broker loads requirements.json;
- cli `stack-copy.test.mjs`;
- every `scripts/test-*.sh`, under `timeout -k 10`.
- Mos gets the landing commit (mos-1470).
### Handoffs
Every seat hands off directly (DECISIONS Q210, mos-1221). When the owner pushes a
round or the reviewer posts a verdict, that seat messages the next seat's T3
thread and Sage's with `mcp__t3__t3_send_message`. Mos has one step here, which
is receiving the landing commit.
-1
View File
@@ -586,4 +586,3 @@ git history + Gitea issues.
- 2026-09-26 (sage, lead): Jason's ruling: Sage leads the project, Darkwing is a collaborating seat, more seats to follow, development stays in T3 for now. The fleet Sage seat that took Invoice Ninja coordination outside Jason's instructions is being decommissioned; moving work onto the new stack is the fix. Suites all green. Rows 24 and 25 pushed (1685deb4..43d7574d). Open: about 11 days of uncommitted agent work in the tree (row 16 launch files, row 22, #1512, WUI evidence), 23 untracked aws-* skill directories in skills/ from 2026-09-21, and Jason's target for the next phase.
- 2026-10-13 (sage, lead): Jason's PM re-test on 664199f7 is finished (Mos; PM run rcc0f72be8b17; Vikunja #228). Paging, the Board's held roles, the Messages page and the #229 loop (coder push 010d30cb, review, approve, PM close) all worked; #230 and #231 are done, #232 stays open for the secrets-path probe. Two faults checked and dropped: the reviewer did run the tests before its verdict, and the PM's clone did hold the row 95 Board code. Two filed: row 104 (#1598) seat git identity, row 105 (#1599) a review request says whether a reviewer is running. Rows 96, 101-105 briefed; row 85 released to build. Row 85's live steps, any bus restart and row 102's public/ change each need Mos's go. Row 100 waits on Jason (Vikunja #257). Formatting, Jason's direction: no formatting row now. Chat output will be formatted in the TUI and WebUI the way chat interfaces usually do it, made our own; T3 Code's chat is his example. Carry it into the TUI and WebUI chat work.
- 2026-10-22 (bushroot, row 198 r3): the board runs in-process in the Console (webui, port 7332); control-board `serve` is retired and exits 2, and the Console refuses `--board`. Docs: packages/webui/README.md and packages/control-board/README.md now give the Console start and drop the 7331 serve text. Built on bushroot/row198-r3 from aca16404c; not run, awaiting Sage's CLEAR.
-14
View File
@@ -664,17 +664,3 @@ collection never checks it again and the count reads as known with 0. The row 30
behavior as it stands, so the move didn't change it. Bound to a follow-up row on row 270's
store-unreadable path. The fix keeps the unreadable state until the header reads clean, or re-checks the
header on every collection. Either way it's an old-test change and goes to Mos.
## Row306 N1: the revoke Set doesn't survive a broker restart
Morgana, row 306 BUILD.md (#1813, landed 2d0318a44; Dewey agrees, dewey-0918). `#accessRevoked` lives in
memory. After a broker restart, recover() rebinds an earlier host's run, and if that run makes a bus call
before the launcher's SIGTERM, it answers `not-holder` and the run exits 22, as every revoked run did before
306. Seeding the Set at a rebind needs a mark only `#revokeLost` writes; `reason` and `by` on the ended
claim aren't one (design D1's alternative). Bound to the next row that changes the broker's recover() path.
## Row306 N2: a pending limit stop answers before the revoke
Morgana, row 306 BUILD.md (#1813, landed 2d0318a44). A revoked run that also has a pending limit stop gets
`#limitStop`'s `not-holder` on `message.receive` and `state`, because that check runs before `#agent`. It's
rare and the run still stops. Bound to the next row that changes `#limitStop` or the order of those checks.
+4 -15
View File
@@ -33,7 +33,7 @@ Gaps found while working go to `docs/plans/DEFERRED.md`, not here.
<!-- mosaic-queue:begin -->
Generated from `docs/plans/queue.json` revision 3636 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
Generated from `docs/plans/queue.json` revision 3588 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
| # | Piece | Owner | Issues | State | After | Gate | Brief | Note |
|---|---|---|---|---|---|---|---|---|
@@ -288,7 +288,7 @@ Generated from `docs/plans/queue.json` revision 3636 by `queue render`. Do not e
| 249 | row 235 follow-up: the grant undo records hostLineLeft when install never returned, since the remote add can outlive an abort or timeout (mos-1166) | gizmoduck; reviewers duckworth | #1751 | done | — | Duckworth approves the manifest. On Sage's gate the bus suite and the whole touched test files are green, and a mutant that drops the new catch branch is killed. (sage) | `docs/plans/2026-10-20_host-credentials.md` § Machine grant row b follow-up: the undo can't prove the remote add has ended | LANDED 9de5a57ab on refactor, pushed with 7b9737953 (DEFERRED N1). Range-diff: 8a04f4aa4..05595794b vs 8ca064158..9de5a57ab, same 3 paths, patch-id 99551b122e66 on both. Gate sage-2026 14:53Z: grant-executor whole 39/0, bus 598/0, no stub left, C1 (drop :1025) killed by G8 and both G23. Duckworth approve c30070. |
| 250 | K12 (cohort.test.mjs) reads cgroup.events after cgroup.kill under load: print the shim's reason, then fix the cause at the source (Vikunja #450) | gizmoduck; reviewers filbert | #1755 | done | 197 done | reviewer approves the manifest; conversation and control-board suites whole on Sage's slot (sage) | `docs/plans/2026-10-21_k12-cohort-events.md` § Brief: K12 reads cgroup.events after cgroup.kill under load | LANDED 1b6158ce2 on refactor 19:42Z: candidate c1a858975 on 3b0aef995, manifest eca0b8e2, Filbert APPROVE rev 2957 (#1755 c30130). patch-id 29786caa = candidate; blob diff empty; tree 59f1e4781 = merge-tree. Gate on Sage slot sage-2109 (gizmoduck-0150): P1/P2/M1 as predicted, conversation whole 3x 183/183 plus concurrent pair, control-board 124/124. No drift on paths since base. |
| 251 | T27 (host-run.test.mjs:453) lists no identities right after ssh-add -t 1: print timing and agent state, then fix the cause at the source (Vikunja #453) | darkwing; reviewers dewey | #1756 | done | 197 done | reviewer approves the manifest; bus suite whole on Sage's slot (sage) | `docs/plans/2026-10-21_t27-ssh-add-lifetime.md` § Brief: T27 lists no identities right after ssh-add -t 1 | LANDED d37210288 on b79ad5f2c: candidate 27f01ef63 manifest f2bdd684, patch-id 440d2442c976 equal, blob diff empty, tree matches merge-tree. Dewey r2 approve rev 2996 (c30168). Gate: bus suite whole in a clean worktree, 627/627 at 21:30:33Z on Sage's slot. Overlap check vs 194/198/244/253/254 branches: none. DEFERRED sleep-orphan entry follows as a doc commit. |
| 252 | Console inbox Approve and Deny for grant requests (#435, J2) | scrooge; reviewers webby | #1753 | done | — | Webby approves DESIGN.md then manifest; webui, bus, cli suites and every test-*.sh green on Sage's gate; DESIGN mutants killed; REQ-WEB-2 PRD amendment landed first (sage) | `docs/plans/2026-10-21_console-inbox-approve.md` § Gate | Landed 45ea3fa0d (replay of Scrooge 580037105 onto 30e6bb680, range-diff all =, 10 commits). Webby APPROVE c31229 rev 3612. Sage gate on 45ea3fa0d: every package suite 0 fail 0 skipped (bus 907, cli 943, webui 410), every scripts/test-*.sh rc 0, J1-J19 all killed except J11c and J18 (equivalent per BUILD.md), all restores match. Old-test numstat 1 1 vs fe4ee8c7b held. |
| 252 | Console inbox Approve and Deny for grant requests (#435, J2) | scrooge; reviewers webby | — | in-progress | — | Webby approves DESIGN.md then manifest; webui, bus, cli suites and every test-*.sh green on Sage's gate; DESIGN mutants killed; REQ-WEB-2 PRD amendment landed first (sage) | `docs/plans/2026-10-21_console-inbox-approve.md` § Gate | mos-1193. Chained on row 196's candidate (Q177): design starts now on Gander's row 196 build; code rebases onto 196's landing. Vikunja #435. |
| 253 | F27: case2-unit.sh and case1-accept.sh move to --tester-socket and refuse --tester-port with stack-release's migration wording; no .53/.54 copy with 196 until this lands (mos-1198 item 4) | gander; reviewers duckworth | #1758 | done | 196 done | reviewer approves DESIGN.md then the manifest; cli and webui suites whole and every scripts/test-*.sh on Sage's slot; mutants per DESIGN.md; static check covers scripts and tests (sage) | `docs/plans/2026-10-21_tester-socket-scripts.md` § Brief: case2-unit.sh and case1-accept.sh move to --tester-socket (F27) | Landed 48e450ce8 on refactor (fast-forward from aca16404c, 22 commits; landed sha = candidate, so range-diff and patch-id are identical by construction). Manifest 591020a1 (7 paths) rechecked against the commit: 0 mismatches. Gate: run 5 whole, cli 55 files, webui 58 files, all 10 scripts/test-*.sh green; M1-M40 killed 40/40. Duckworth APPROVE round 1, Gitea #1758 comment 30696. |
| 254 | Library sync follow-up: client timeout, STATUS codes, README (222 deferrals, #1759) | megavolt; reviewers dewey | #1759 | done | 196 done | Dewey approves the manifest; bus, library and webui suites whole and every scripts/test-*.sh green on Sage's slot; the timeout and STATUS mutants killed (sage) | `docs/plans/2026-10-21_library-sync-followup.md` § Gate | Landed 331597709 (candidate b31300d79 blobs and modes on 41b0cf755; patch-id 96216216 equal, range-diff differs only in the message) plus doc eb9672c91 (Dewey N1 README sentence closed; N2 four 502 codes to DEFERRED, binding the Console library routes row). Merged-tree rerun on Sage's slot 23:44-23:55Z: library 77/0, bus 628/0, webui 305/0 with 1 skip, cli 749/0. Megavolt's gate megavolt-0102 covers scripts and both mutants. |
| 255 | Bundled Pocket ID for users with no OIDC provider, design (Q203, #1760) | launchpad; reviewers webby | — | queued | 252 done | Webby approves DESIGN.md; design only, build rows follow after Sage checks it against Q203, O6, REQ-WEB-2 and REQ-INST-1 (sage) | `docs/plans/2026-10-21_bundled-pocket-id.md` § Gate | Vikunja 458, Gitea #1760, mos-1202 item 2. REQ-INST-2 lands in PRD 1.10 as post-v1 (mos-1204). Design only: no install, no download, no .53/.54, dragon-lin keeps Authentik. |
@@ -327,7 +327,7 @@ Generated from `docs/plans/queue.json` revision 3636 by `queue render`. Do not e
| 288 | Case 2 C2-02 agent writes, the notice, revert and her Console saves | honker; reviewers webby | #1793 | briefed | 215 done, 282 done, 283 done, 284 done, 285 done, 286 done | Webby approves the manifest. Library, bus and harness suites and every scripts/test-*.sh green on Sage's gate, mutants killed. A search of the run output for the fixture tokens finds nothing. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-02 agent writes, the notice, revert and her Console saves | — |
| 289 | Case 2 C2-11b Console library pages and her saves (Haiku subagent, decision 129) | researcher; reviewers filbert | #1794 | briefed | 214 done, 288 done | Filbert approves the manifest. The webui suite and every scripts/test-*.sh are green on Sage's gate. Grep for hash pins on bus.js and serve.mjs before in-review. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-11b Console library pages and her saves | — |
| 290 | Case 2 C2-12b the setup sheet rewrite for free chat, supporting folders and saves | louie; reviewers webby | #1795 | briefed | 217 done, 218 done, 285 done, 291 done | Doc only. Webby approves it against the ruling. Before landing, grep packages/*/tests and scripts/test-*.sh to confirm no test reads the file. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-12b the setup sheet rewrite for free chat, supporting folders and saves | — |
| 291 | PRD 1.16 library.write routes under REQ-LIB-6 (mos-1328) | gosalyn; reviewers webby | #1796 | done | 282 done | Webby approves the text. Jason says yes to the exact text (Mos asks him, mos-1328 step 3); no landing before that. requirements.test.mjs and every scripts/test-*.sh that reads the PRD or requirements.json are green on Sage's gate. Mos gets the landing commit. (sage) | `docs/plans/2026-10-22_prd-1-16-brief.md` § PRD 1.16 library.write routes under REQ-LIB-6 (mos-1328) | Landed 4874d1e83 on e909ef51a: 482e865ac replayed, patch-id equal (cef7de74), range-diff =. Webby APPROVE c30848 on 482e865ac; Jason approved the text in Q229. Gate 16:15Z on the tip tree: every package suite but queue, 0 fail 0 skipped (tasks 100, bus 891, cli 941, plugins 119, webui 392, business 231), every scripts/test-*.sh rc 0 (queue-root and row 244 skips only). PRD 1.16. 1.17 is row 308. |
| 291 | PRD 1.16 library.write routes under REQ-LIB-6 (mos-1328) | gosalyn; reviewers webby | #1796 | in-review, round 1, request posted | 282 done | Webby approves the text. Jason says yes to the exact text (Mos asks him, mos-1328 step 3); no landing before that. requirements.test.mjs and every scripts/test-*.sh that reads the PRD or requirements.json are green on Sage's gate. Mos gets the landing commit. (sage) | `docs/plans/2026-10-22_prd-1-16-brief.md` § PRD 1.16 library.write routes under REQ-LIB-6 (mos-1328) | — |
| 292 | stack-release.sh config_checks runs the target loader on any config (row 270 rollback hazard) | gizmoduck; reviewers duckworth | #1797 | done | 203 done, 270 done, 271 done | Duckworth approves the manifest. The touched test files run whole, the cli suite and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-22_config-checks-any-key-brief.md` § stack-release.sh config_checks runs the target loader on any config (row 270 rollback hazard) | Landed 6aab979e6 (cherry-pick of c902a767d..90be6cccd, range-diff all =, patch-id a45c9ba4 equal). Tested head 0d0021454: stack-release.test 224/224, cli suite r2 red only compactions-launch:229 session-invalid 22 (idle-stop race, Vikunja 588, row 302), r3 rerun 910/910 under mos-1411, ten shells green, M1-M9 killed. Duckworth APPROVE rev 3433 on 2fa7ae17. |
| 293 | 574: maintenance-free test fixture commits (Vikunja 574) | gander; reviewers filbert | #1805 | done | — | Filbert approves. The four whole files, then the seat and cli suites, are green on Sage's gate. (sage) | `docs/plans/2026-10-23_mvp-readiness-rows.md` § Brief: 574, maintenance-free test fixture commits (Vikunja 574) | LANDED e51d11640 03:55Z 10-23 (cherry-pick of gander b23783f39..a9cae2bd6 onto 464010fbe; range-diff 6/6 equal, range patch-id equal). Filbert APPROVE c30982 cdda41f5; run 2 4 files 153/0, seat 202/0, cli 905/0, 312 flagged/0 maintenance children. N1: lifecycle.test needs TMPDIR of 50 bytes or less. Residual: queue store.mjs, discord git.mjs, harness tools.mjs unflagged. #1805 closed c31013, P19 574 c888. |
| 294 | FED R6: Console granted-user sign-in, user-cli transport, peer links | launchpad; reviewers duckworth | #1800 | done | — | Duckworth approves the manifest. The webui, cli and bus suites and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-23_mvp-readiness-rows.md` § Brief: FED R6, Console granted-user sign-in | landed d3059c2c3 on refactor 07:04Z: cherry-pick of c902a767d..24b745447 onto 09d0d6faf, range-diff 11 =, patch-id 8371405a equal. Duckworth approve c31053 rev 3469 on a1ff5d67, C1/C2 met. Run 4 on 8616b9a4f from npm ci: webui 365, cli 909, bus 873, 10 scripts, M1-M11 killed. Merged tree: stack-release-external 25, stack-upgrade 21, stack-release 223/224 (R9 transient, row 1810) then 224/224 rerun under mos-1422. Follow-ups rows 300, 301. Closes #1800. |
@@ -342,19 +342,8 @@ Generated from `docs/plans/queue.json` revision 3636 by `queue render`. Do not e
| 303 | R9 shows its output when rehearse makes no directory (Vikunja 589) | launchpad; reviewers duckworth | #1810 | done | 294 done | Duckworth approves the diff; stack-release.test.mjs whole and green on Sage's gate, in a gap (sage) | `docs/plans/2026-10-23_r9-guard-row.md` § Brief: R9 shows its output when rehearse makes no directory (Vikunja 589) | Landed e551cd846 from launchpad 88ffecaf7, patch-id 559c802b equal, test blob e936808b matches the manifest. Gate: Duckworth approve c31079; Sage gate on c13c337e1 plus the test blob, stack-release.test.mjs whole 224/0 rc 0 in 595 s, alnum mktemp TMPDIR, default networks 6 before and 6 after. No overlap with in-review 272, 304, 305. |
| 304 | claude-session test builds the memory slug the way the CLI does (Vikunja 590) | rocko; reviewers dewey | #1811 | done | — | Dewey approves the diff; claude-session.test.mjs whole and green 3x (mktemp, underscore, space TMPDIRs) on Sage's gate, in a gap; old-regex mutant red at :298 (sage) | `docs/plans/2026-10-23_gate-env-rows.md` § Brief: claude-session test builds the memory slug the way the CLI does (Vikunja 590) | Landed a863015a5 from rocko 5e8354ebb, range-diff equal, patch-id 808fa765. Gate: Dewey approve c31129 rev 3533; Sage gate on e551cd846 with file 7690a4a9, claude 2.1.309: claude-session.test.mjs whole 10/0 under mktemp, underscore and space TMPDIRs; old-regex mutant under underscore 9/1 at :298. Gates may drop the alnum TMPDIR rule (mos-1432 ends here). |
| 305 | Shell suites remove the compose network they create (Vikunja 591) | gizmoduck; reviewers filbert | #1812 | done | — | Filbert approves the diff; test-release.sh and test-task.sh whole and green on Sage's gate, in a gap, project network absent after each run; dropped-rm mutant red (sage) | `docs/plans/2026-10-23_gate-env-rows.md` § Brief: shell suites remove the compose network they create (Vikunja 591) | Landed 353d65f57..d52d9f1f2 from gizmoduck 541dee2b7..7e8218a2e, range-diff all equal, on top of filbert 2c289f6f0. Gate: Filbert approve c31127 rev 3534; Sage gate on e551cd846 with task 7d6fbf67, release b29850ce: test-task 92/0, test-release 15/0, network check OK; mutants a and b red in both, c a known survivor; own mutant networks removed, none left. Gates stop the manual network rm (mos-1438 ends here). |
| 306 | A revoked run's late bus call stops it instead of exiting 22 (295 N1) | morgana; reviewers dewey | #1813 | done | 272 done | Dewey approves the design, then the manifest; bus, harness and cli suites and every scripts/test-*.sh green on Sage's gate (sage) | `docs/plans/2026-10-23_gate-env-rows.md` § Brief: a revoked run's late bus call stops it instead of exiting 22 (295 N1) | Landed 2d0318a44 on 4874d1e83: 33d3844a0..683833205 replayed, 4 patch-ids equal (a1960f36 55fadbc3 af1a3d88 35cf81a3), range-diff all =. Dewey APPROVE c31206 rev 3596 on 683833205. access-revoke.test numstat 7 7 (mos-1463). Sage gate 16:33Z: every package suite 0 fail 0 skipped (bus 894, cli 942, plugins 119, webui 392, business 231, harness 151), every scripts/test-*.sh rc 0, M1-M3 each killed on every named file, restores equal. N1/N2 to DEFERRED next. |
| 306 | A revoked run's late bus call stops it instead of exiting 22 (295 N1) | morgana; reviewers dewey | #1813 | briefed | 272 done | Dewey approves the design, then the manifest; bus, harness and cli suites and every scripts/test-*.sh green on Sage's gate (sage) | `docs/plans/2026-10-23_gate-env-rows.md` § Brief: a revoked run's late bus call stops it instead of exiting 22 (295 N1) | — |
| 307 | Row 270's pi compaction reading moves behind the harness kind (REQ-PLUG-2, Vikunja 598) | gizmoduck; reviewers duckworth | #1814 | done | — | Duckworth approves the design, then the manifest; bus, cli, plugins (0 failures), webui and business suites, every scripts/test-*.sh green and the mutants killed on Sage's gate (sage) | `docs/plans/2026-10-23_harness-kind-compactions.md` § Brief: row 270's pi compaction reading moves behind the harness kind (Vikunja 598) | LANDED d243f6b25: 6 row commits replayed onto 8dc132bec, every non-log patch-id equal, candidate paths = 6fdfe3790 (manifest 17c9f8d9). Duckworth APPROVE rev 3586 c31189 round 1, C2 met. Sage gate: bus, cli 0 skipped, plugins fully green incl harness.test.mjs:192, webui, business, seat, every scripts/test-*.sh; M1-M4 killed. P19 598 commented. DEFERRED: 307 N1 store-unreadable not sticky. N3 doc-only BUILD.md red list one short, next BUILD.md change. |
| 308 | PRD 1.17 Case 2 story files are DOCX (Q235 to Q237, Q239) | gosalyn; reviewers webby | #1815 | done | 291 done | Webby checks the PRD diff against DRAFT-d9.md at 4607e15b8 one instruction at a time and that nothing else changed. No new Jason question (Q239 approved the text). tasks whole, bus whole, cli stack-copy.test and every scripts/test-*.sh green on Sage's gate. Mos gets the landing commit (mos-1470). (sage) | `docs/plans/2026-10-23_prd-1-17-brief.md` § PRD 1.17 Case 2 story files are DOCX (Q235 to Q237, Q239) | Landed 30e6bb680: Gosalyn 45fe7b3ce replayed onto 7defb58e9, patch-id 4658c831 equal, range-diff =, four blobs equal. Webby r1 APPROVE c31224 rev 3606, d9 at 4607e15b8 applied word for word. Sage gate 17:19Z: every package suite 0 fail 0 skipped (tasks 100, bus 894, cli 942, plugins 119, webui 392), every scripts/test-*.sh rc 0. Mos gets the sha (mos-1470). 570/571 build rows next. |
| 309 | 571 W1: reviewed local thesaurus data (post-10-27) | unassigned | — | queued | — | Independent Opus 5.5 reviewer, not the author, approves the manifest; the brief section's Gate and G1 pass on Sage's slot: touched test files whole, affected package suites green, named mutants killed. Post-10-27; MVP rows keep slot priority (sage) | `docs/plans/2026-10-21_q195-writing-editor.md` § W1 reviewed local thesaurus data | — |
| 310 | 571 W2: WordCraft source import and reproducible restricted web build (post-10-27) | unassigned | — | queued | — | Independent Opus 5.5 reviewer, not the author, approves the manifest; the brief section's Gate and G1 pass on Sage's slot: touched test files whole, affected package suites green, named mutants killed. Post-10-27; MVP rows keep slot priority (sage) | `docs/plans/2026-10-21_q195-writing-editor.md` § W2 WordCraft source import and reproducible restricted web build | — |
| 311 | 571 W3a: bus pins and human-only editor settings (post-10-27) | unassigned | — | queued | — | Independent Opus 5.5 reviewer, not the author, approves the manifest; the brief section's Gate and G1 pass on Sage's slot: touched test files whole, affected package suites green, named mutants killed. Post-10-27; MVP rows keep slot priority (sage) | `docs/plans/2026-10-21_q195-writing-editor.md` § W3a bus pins and human-only editor settings | — |
| 312 | 571 W3b: human DOCX store and instant commits (post-10-27) | unassigned | — | queued | 311 done | Independent Opus 5.5 reviewer, not the author, approves the manifest; the brief section's Gate and G1 pass on Sage's slot: touched test files whole, affected package suites green, named mutants killed. Post-10-27; MVP rows keep slot priority (sage) | `docs/plans/2026-10-21_q195-writing-editor.md` § W3b human DOCX store and instant commits | — |
| 313 | 571 W6: owned browser bridge and editor-only route (post-10-27) | unassigned | — | queued | 309 done, 310 done, 312 done | Independent Opus 5.5 reviewer, not the author, approves the manifest; the brief section's Gate and G1 pass on Sage's slot: touched test files whole, affected package suites green, named mutants killed. Post-10-27; MVP rows keep slot priority (sage) | `docs/plans/2026-10-21_q195-writing-editor.md` § W6 owned browser bridge and editor-only route | — |
| 314 | 571 W3: full editor pane, navigation, pins and right-click synonyms (post-10-27) | unassigned | — | queued | 311 done, 312 done, 313 done | Independent Opus 5.5 reviewer, not the author, approves the manifest; the brief section's Gate and G1 pass on Sage's slot: touched test files whole, affected package suites green, named mutants killed. Post-10-27; MVP rows keep slot priority (sage) | `docs/plans/2026-10-21_q195-writing-editor.md` § W3 full editor pane, navigation, pins and right-click synonyms | — |
| 315 | 571 W4b: committed reader, citation resolver and typed tools (post-10-27) | unassigned | — | queued | 314 done | Independent Opus 5.5 reviewer, not the author, approves the manifest; the brief section's Gate and G1 pass on Sage's slot: touched test files whole, affected package suites green, named mutants killed. Post-10-27; MVP rows keep slot priority (sage) | `docs/plans/2026-10-21_q195-writing-editor.md` § W4b committed reader, citation resolver and typed tools | — |
| 316 | 571 W5: read-only flags, citations and chat suggestions (post-10-27) | unassigned | — | queued | 315 done | Independent Opus 5.5 reviewer, not the author, approves the manifest; the brief section's Gate and G1 pass on Sage's slot: touched test files whole, affected package suites green, named mutants killed. Post-10-27; MVP rows keep slot priority (sage) | `docs/plans/2026-10-21_q195-writing-editor.md` § W5 read-only flags, citations and chat suggestions | — |
| 317 | 571 W7: human one-way chapter import and Case 2 format routing, production import closed; the first real import opens only after W8 lands (post-10-27, mos-1470) | unassigned | — | queued | 314 done, 315 done | Independent Opus 5.5 reviewer, not the author, approves the manifest; the brief section's Gate and G1 pass on Sage's slot: touched test files whole, affected package suites green, named mutants killed. Post-10-27; MVP rows keep slot priority (sage) | `docs/plans/2026-10-21_q195-writing-editor.md` § W7 human one-way chapter import and Case 2 format routing | — |
| 318 | 571 W8: Console Connections view for Markdown and DOCX story files, before W7's first real import (post-10-27, mos-1470) | unassigned | — | queued | 314 done, 315 done, 317 done | Independent Opus 5.5 reviewer, not the author, approves the manifest; the brief section's Gate and G1 pass on Sage's slot: touched test files whole, affected package suites green, named mutants killed. Post-10-27; MVP rows keep slot priority (sage) | `docs/plans/2026-10-21_q195-writing-editor.md` § W8 Console Connections view for Markdown and DOCX story files | fenton-0017 carry: connections.test.mjs must test that links inside comments and tracked deletions are excluded. W8 follows W7's closed routing candidate and lands before W7's first real import opens (mos-1470) |
<!-- mosaic-queue:end -->
+23 -1828
View File
File diff suppressed because it is too large. Load diff
+44 -399
View File
@@ -1,6 +1,6 @@
# PRD: Mosaic Stack
- Status: approved, version 1.17. Jason approved draft 0.4 unchanged as
- Status: approved, version 1.15. Jason approved draft 0.4 unchanged as
1.0 on 2026-10-05 (lead decision 61). They approved version 1.1, the
Organization requirements, in decision 03130d08-ddce-480d-a601-e59f64744fc3
on 2026-10-14. They approved version 1.2, the chief of staff, the
@@ -44,44 +44,7 @@
as 1.11 (mos-1233, mos-1238, mos-1241, vikunja:19/464). Version 1.15
rewrites REQ-LIB-6 to Jason's Q223 and Q226 rulings and adds supporting
folders to REQ-LIB-1 and REQ-LIB-2. Mos ordered it in mos-1320 item 8,
and Jason gets a report. Version 1.16 routes the new action
`library.write` under REQ-LIB-6 (REQ-WAIT-3). A change to the hard waits
needs Jason's approval under REQ-WAIT-2, so Mos puts the text to him as
written, and it lands only after his yes (mos-1328, mos-1329). Version
1.17 records Jason's rulings Q235 to Q237 of 2026-10-23: Case 2 story
files are DOCX, Markdown stays for agent files and documentation, and a
human-started import converts a Markdown story file one way. It also
adds REQ-LIB-9, the Console's DOCX editor, from Jason's rulings Q195,
Q226 and Q234. It changes what Case 2 requirements allow, so Mos puts
the text to Jason as written, and it lands only after his yes (mos-1366,
Vikunja 570).
- Version 1.17 source: Jason's Q235, answering Q36: 'How about we see a
markdown file, we convert the markdown file to docx, then archive or
mark the markdown file as converted. The new standard for files in the
creating writing use case is to use docx to handle versioning and
WYSIWYG. We eliminate the potential for confusion based on the files.
Agents must understand which files are what format and where they live.
The system must allow for import and conversion. We will not run the
systems in parallel. There will be an irreversible conversion of the
files.' His Q236 says: 'For creative writing chapters, we need to
utilize docx as the standard. Markdown is acceptable for agent files,
instructions, PRDs, documentation, etc. The creative writing files
should be docx for familiarity for the user.' His Q237 says 'Q38: It is
correct for now (subject to future change)', which confirms Mos's
reading P1 to P6. REQ-LIB-9 rests on his Q195 request for 'a full
WYSIWYG editor within Mosaic Stack', his Q226 answer 'F41: We want to
move editing into the WYSIWYG in the WebUI. All changes are instantly
committed and available to the agents.' and his Q234 answer 'Q37: yes'
to the exact security header for the editor's frame. The storage follows
the O2 design D10 to D12 as rewritten under Vikunja 571, at b99ca86b9 on
`refactor`.
- Version 1.16 source: Webby's review webby-0164 B2, from Scrooge's row
285. Version 1.15 lets creative agents write in supporting folders, but
the write action stayed a hard wait under REQ-WAIT-3, and REQ-WAIT-2
refuses every role file that grants one. Mos ruled in mos-1328 and
mos-1329 that the routing is its own version. The bounds come from
REQ-LIB-6 and the Case 2 design's ruling after r11, f6e3e7f20 on
`gosalyn/case2-design`.
and Jason gets a report.
- Version 1.15 source: Jason's Q223, answering Q29: "The agents are not
allowed to write the prose into the writing projects. Agents are
allowed to CRUD character sheets, info summaries, cross-link info, and
@@ -743,14 +706,6 @@ Added in version 1.5 (Q167).
`host.sudo` was added as a hard wait and routes under REQ-CRED-4 since
version 1.8.
`library.write` was added as a hard wait and routes under REQ-LIB-6
since version 1.16. It is granted within-role to the six creative roles
only, and only for Markdown files under the supporting folders a human
marks. It never writes a story file. A write also needs the writing
agent's own live Gitea grant on the library project. Each write is one
commit by that agent's own Gitea account with a trailer naming the
agent, and the user sees a notice for it and can revert it.
### Messages
- **REQ-MSG-1.** Messages are addressed to a role. They are append-only
@@ -1223,8 +1178,7 @@ design `docs/plans/2026-10-21_t1-sessions.md`, 2c843d190 on `huey/t1-sessions-de
Added in version 1.12 (Q176 Case 2, Q211). Drafted from the Case 2
design r11, fb20a61b4 on `gosalyn/case2-design`, approved by Webby in
82f50d127, with the rulings recorded after r11 up to 624212842. The
library is the user's own writing repository plus the stack's writing
store, which holds the DOCX story files (REQ-LIB-8, version 1.17).
library is the user's own writing repository.
- **REQ-LIB-1.** A library project maps the user's writing repository
onto universes, books, entity types such as character and place, the
@@ -1233,12 +1187,9 @@ store, which holds the DOCX story files (REQ-LIB-8, version 1.17).
human changes it, and an agent's organization write that changes it
is refused. So
`universe.book.{character,place,...}` reads as universe, book folder,
type folder. The mapping also names one archive folder, set by the
human, where converted Markdown story files go. A book's DOCX story
files are in the stack's writing store, under the same universe and book
(REQ-LIB-8). (Design 1.1 and M9. Q176 Case 2 "Data is stored in
little-endian organizational scheme". Version 1.15: supporting folders.
Version 1.17: the archive folder and the writing store.)
type folder. (Design 1.1 and M9. Q176 Case 2 "Data is stored in
little-endian organizational scheme". Version 1.15: supporting
folders.)
- **REQ-LIB-2.** Playgrounds are never confused with canon. A playground
has one of three levels, set by where its prefix sits. A book-level
prefix lies inside one of its universe's book folders, and its files
@@ -1253,34 +1204,18 @@ store, which holds the DOCX story files (REQ-LIB-8, version 1.17).
its own universe, when two prefixes overlap, when a draft has books or
no prefix, and when a hybrid names a draft as a source. Supporting
prefixes, the folders agents may write in (REQ-LIB-6), follow the same
placement rules. A Markdown file's scope comes from its path and from
past imports, prefixes first: a Markdown file under the archive folder
is archived, one under a supporting prefix is supporting, a Markdown
file at a converted file's old path is unmapped under REQ-LIB-8 but
flagged in the format map, one under a playground prefix at any level is
playground, any other Markdown file in a book folder is canon, and
everything else is unmapped and never returned. A folder in both lists
is supporting. The archive folder follows the same placement rules and
may not overlap another prefix or a book folder. An archived file keeps
the path it had under the archive folder, and its universe and book are
those of that old path. A DOCX document in the writing store keeps its
source's scope: the universe, the book if there is one, and canon or
playground. Its source is the Markdown file it was converted from or,
for one the human starts in the editor, the universe and book or the
playground the human picks (REQ-LIB-8). A playground DOCX follows the
playground rules and is never returned as canon. It stays playground
after its draft universe is made real. In version 1.17 no promotion or
mapping change makes a playground DOCX canon. A later version of this
PRD says how one becomes canon. Lookups return canon unless the caller
asks for playground, supporting or archived. Every answer marks an
archived file as archived and names the DOCX that replaced it. A
citation marked as canon must name a canon file, so an agent can't cite
a playground, supporting or archived file as canon. The Console shows
each cite's scope and format, and whether a cited Markdown file was
converted. (Design 1.2, 5.2 and 7.4, the playground ruling, 51fdfb7e2,
and the ruling after r11. Q176 Case 2, the playground items,
TARGET.md:92-94. Version 1.14: the levels and the draft. Version 1.15:
supporting. Version 1.17: archived scope and DOCX scope.)
placement rules. A file's scope comes from its path alone, prefixes
first: a Markdown file under a supporting prefix is supporting, one
under a playground prefix at any level is playground, any other
Markdown file in a book folder is canon, and everything else is
unmapped and never returned. A folder in both lists is supporting.
Lookups return canon unless the caller asks for playground or
supporting. A citation marked as canon must name a canon file, so an
agent can't cite a playground or supporting file as canon. The Console
shows each cite's scope. (Design 1.2, 5.2 and 7.4, the playground
ruling, 51fdfb7e2, and the ruling after r11. Q176 Case 2, the
playground items, TARGET.md:92-94. Version 1.14: the levels and the
draft. Version 1.15: supporting.)
- **REQ-LIB-3.** Universes stay separate. Every lookup names one universe,
and its results never leave it. A draft universe (REQ-LIB-2) counts as
a universe here. It has no canon, so a lookup in a draft asks for
@@ -1296,62 +1231,23 @@ store, which holds the DOCX story files (REQ-LIB-8, version 1.17).
A pin doesn't stop the change. The continuity agent reads the human's other
constraints and cites them. (Design 1.4 and 3. Q176 Case 2, the
crossover items.)
- **REQ-LIB-4.** Git holds every change. The user commits and pushes
Markdown with their own tools, today Obsidian with obsidian-git. A
converted story file's current text is no longer in that repository,
which keeps only its archived Markdown (REQ-LIB-8). The bus keeps a
- **REQ-LIB-4.** Git holds every change. The user commits and pushes with
their own tools, today Obsidian with obsidian-git. The bus keeps a
mirror of the remote, fetched with a read-only token every 60 seconds
and before any lookup when the last fetch is older than that. It only
fast-forwards. A force-push is held as `library-diverged` until a human
accepts it. Agents read the user's repository only at the mirror's
served commit, except a story file during its import under REQ-LIB-8.
They read a DOCX story file only at a committed version in the writing
store, under REQ-LIB-5. They never hold a copy of their own, so they see
the user's Markdown push within about a minute and a DOCX save at their
next lookup after the editor reports it as committed. Bus writes and
pushes come in later rows (C2-02, C2-03b). A DOCX story file is saved in
the Console's editor, and each accepted save is one commit by the human
in the stack's writing store (REQ-LIB-9). Each writing-store commit is
also pushed to a remote off the stack's host, never by force-push. The
remote is a setting only a human changes, and it starts unset. A
repository on the stack's host never counts as that remote. A missing
remote or a failed push never undoes a save, and the user sees each push
that is pending or failed. Only the stack's host pushes to that remote,
and no agent can reach it with any credential. Agents read the local
commit. (Design D5, D6, 7.1, 7.2 and 7.5. Q176 Case 2 "Git is used for
all changes -- local and remote" and the sync items. Version 1.17, with
Q222 for the remote setting.)
accepts it. Agents read only the mirror's served commit and never hold
a copy of their own, so they see the user's push within about a minute.
Bus writes and pushes come in later rows (C2-02, C2-03b). (Design D5,
D6, 7.1, 7.2 and 7.5. Q176 Case 2 "Git is used for all changes -- local
and remote" and the sync items.)
- **REQ-LIB-5.** Agents check the library through typed lookup tools:
find, get, search, manuscript and universe. Each answer names the commit
it read. Each answer also names the file's format and where it lives:
its path, or its document id in the writing store. A DOCX story file is
read from its latest committed save, never from unsaved text. A lookup
that starts after the editor reports a save as committed returns that
save or a later one, even while its push to the remote is pending or has
failed, and the answer names the version. Get, search and manuscript
read a DOCX story file as its extracted text with DOCX locators, never
as lines. That text is the file with its tracked changes accepted. An
agent reads a story file's comments, or its text with tracked deletions
shown, only after a human turns on the matching setting. Both settings
are off by default, and only a human changes them. The reader in this
version can't read some things a DOCX story file can hold, such as a web
link, a text box, a field or hidden text. The editor warns the human
when an edit adds one, and the human can still save it. Until a later
save takes it out, lookups give agents none of the text of the part that
holds it, whether that is the chapter's body, a note or a comment. The
manuscript lookup follows the book's chapter order across both formats
(O2 design D10), and the next chapter it names is never an archived
file. Links out and in resolve across both formats. The universe lookup
returns the format map (REQ-LIB-8). Get returns a file's links out and
links in, so the connections are visible to agents. The user sees a
Markdown file's links in Obsidian's graph and backlinks. A DOCX story
file's wikilinks stay clickable in the Console's editor (O2 design D11),
and no graph shows them yet. The Console's Connections view lists the
current links and backlinks of each story file the user may see, in both
formats, with each one's scope and format. (Design 3 and 7.1. Q176 Case
2 "The agents have tools to check character, place, and other data", the
visualization item and "Connections are always visible". Version 1.17,
with Q222 for the two settings and Q239 for the Connections view.)
find, get, search, manuscript and universe. Each answer names the
commit it read. Get returns a file's links out and links in, so the
connections are visible to agents. The user sees them in Obsidian's
graph and backlinks. (Design 3 and 7.1. Q176 Case 2 "The agents have
tools to check character, place, and other data", the visualization
item and "Connections are always visible".)
- **REQ-LIB-6.** The creative roles are ideas, research, proofreader,
fact-checker and continuity, plus a companion role that the user's own
named agents run as. Agents never write the story. A creative agent may
@@ -1359,37 +1255,22 @@ store, which holds the DOCX story files (REQ-LIB-8, version 1.17).
folders a human marks (REQ-LIB-1), such as character sheets, summaries,
cross-link notes and research notes. Every other path in the user's
repository is read-only to it, chapters and unmarked folders included,
and the broker refuses such a write before any git call. The writing
store and the archive folder are read-only to a creative agent as well,
and the broker's path check treats the archive folder as story. No agent
creates, edits, deletes, converts, archives or restores a story file in
any format, and no agent writes a DOCX. No creative agent starts or
undoes a conversion, and none asks for one through a tool or a decision
it raises, as REQ-LIB-8 says. An agent may still suggest a conversion to
the human in chat. Each creative role's contract states which files are
DOCX and which are Markdown, and cites a DOCX story file by its locator,
never by line. Each agent write is one commit by that agent's own Gitea
account, with a trailer naming the agent. The user sees a notice for it
and can revert it. Its tools are the lookups, a message to the human and
the two write tools, and research and companion also fetch web pages
through the broker. The write tools need the action `library.write`,
which routes under this requirement within the bounds REQ-WAIT-3 names
(version 1.16). Agents talk with the user in free text. A reply may cite
lines of a Markdown file at a commit, a place in a committed DOCX story
file, or pages the agent fetched, and the broker checks every citation
before it stores the message. A DOCX citation never calls a paragraph a
line. It names the document id, the committed version and a DOCX
locator. A citation that names an archived file as current is refused.
The `requireCitation` setting below applies to Markdown and DOCX
citations alike, and so does the rule for a citation marked as canon. A
setting, `requireCitation`, makes every reply carry a citation, except
the runner's fixed notices for no answer, a failed turn, no answer
passing the citation check and an answer too long to send. Only the
human changes it, and it is off by default. The user sees only stored
and the broker refuses such a write before any git call. Each agent
write is one commit by that agent's own Gitea account, with a trailer
naming the agent. The user sees a notice for it and can revert it. Its
tools are the lookups, a message to the human and the two write tools,
and research and companion also fetch web pages through the broker.
Agents talk with the user in free text. A reply may cite lines of the
repository at a commit or pages the agent fetched, and the broker
checks every citation before it stores the message. A setting,
`requireCitation`, makes every reply carry a citation, except the
runner's fixed notices for no answer, a failed turn, no answer passing
the citation check and an answer too long to send. Only the human
changes it, and it is off by default. The user sees only stored
messages, on every channel. (Design 4, 5.2 and the ruling after r11.
Q223 and Q226. Q180 for Bean and Chet. Q176 Case 2, the agent list,
"User sets strict guardrails for agent capabilities" and TARGET.md:101
"Agents NEVER write prose for the user". Version 1.15. Version 1.17.)
"Agents NEVER write prose for the user". Version 1.15.)
- **REQ-LIB-7.** The user talks to several creative agents in one chat. A
room holds one thread per creative agent in a project. A message goes
to every agent in the room, or only to the agents named by @tags at its
@@ -1397,110 +1278,6 @@ store, which holds the DOCX story files (REQ-LIB-8, version 1.17).
the threads as one timeline, and each agent sees only its own thread.
(Design D4 and 6, row C2-09b. Q176 Case 2 "converse with N agents in
one chat session with @tagged messages".)
- **REQ-LIB-8.** Story files become DOCX. DOCX is the standard for chapters and
the other story text in a Case 2 writing project, canon and playground alike. A
story file becomes DOCX through a human's import (below), or when the human
starts it in the editor. From then on DOCX is its only working format, edited in
the Console's editor (REQ-LIB-9). A Markdown story file stays Markdown until its
import. Files in a type folder (REQ-LIB-1), such as character and place notes,
stay Markdown. So do supporting files, agent files, instructions, PRDs and
documentation, including the character sheets, summaries and cross-links agents
keep under REQ-LIB-6.
- A converted story file's current text leaves the user's Gitea-mirrored
repository. Its DOCX lives in the stack's writing store, on the stack's host,
and not in the repository the user pushes or opens in Obsidian. Only its
archived Markdown stays there.
- An import converts one Markdown story file, canon or playground, outside every
type folder (REQ-LIB-1), to a DOCX document in the writing store. It refuses
an unmapped, supporting, archived or type-folder file, and a file that is
already converted. Each import has its own fidelity report, the human's
confirmation of that report, a manifest naming the source file and its commit,
and one commit in the store. The DOCX keeps every wiki link of the source as
written, and a lost link to another library file fails the fidelity report. A
web link becomes plain text that keeps its label and address, and the fidelity
report lists it for the human to confirm. A web link that can't be kept safely
as plain text refuses the import.
- The human can also start a new story file in the editor, in a universe and
book or a playground the human picks. It is a DOCX document in the writing
store from its first commit, with no Markdown copy, and it opens under the
same gate as the import.
- The same import marks the Markdown file converted, naming the document, and
moves it to the archive folder under its old path (REQ-LIB-1, REQ-LIB-2).
The mark and the move are one commit authored by the human in the user's
repository. A new human-only bus step (Vikunja 571) on the C2-02 write
plumbing writes it, and C2-03b pushes it. The move keeps the bytes and the
git history, so it isn't `data.delete`.
- The store commit comes first, and the writing store's remote must confirm it
holds that commit. Then come the archive commit and its push, then publication
to the lookups. Until publication the Markdown at its old path keeps its canon
or playground scope. Agents read that file at the commit the import started
from, even after the mirror's served commit reaches the archive commit or a
later one. No lookup returns the file's archived copy before publication, even
one that asks for archived files. A refusal or failure before the archive push
leaves the Markdown unmoved and no DOCX that a lookup returns. A failure after
the archive push leaves a pending import that the human sees and can resume,
never a claim that nothing moved. An archive push whose result is unknown
counts as a failure after the archive push until the stack reads the result
back from the user's remote.
- Each stage of an import writes a logged event. The import's record on the
stack's host names the source file's path, commit and blob and the new
document id, and each event points to that record.
- After a conversion, a Markdown file at the converted file's old path is
neither canon nor playground. Lookups leave it out, the format map flags it,
and the human is told.
- A new Markdown story file in a book folder that holds converted files stays
canon until its import, unless it sits at a converted file's old path or under
a playground prefix. The Console flags it and offers the import. Nothing
converts it on its own.
- A hybrid's exclusion that covers a converted file's old path also covers its
DOCX document (REQ-LIB-3). An exclusion of a whole book folder also covers
every DOCX document in that book, including one started in the editor. An
exclusion can also name a DOCX document by its id.
- Only a human starts an import or a new story file, from the CLI or a Console
session signed in as that human. Both are human verbs outside the agent action
vocabulary. Neither is added to `ACTIONS` in the business vocabulary or to the
broker's always-gated list, including the import's action, `library.convert`.
No role holds them, no agent can request them, and the hard-wait list in
REQ-WAIT-1 doesn't change. In an organization with several humans, who may
start them is a setting only a human changes, and the default is the owner
only.
- Conversion is one way, with no parallel run. Neither the Console nor any agent
edits a converted story file as Markdown, and no DOCX edit syncs back to
Markdown. The Console's Markdown save refuses every file under the archive
folder. The user's own tools can still change the archived Markdown, and no
such change reaches the DOCX. The old Markdown stays in git history, and
nothing rewrites history. A promotion into a book folder (C2-19) runs the
import for a Markdown story file or is refused. A promotion of a playground
DOCX is refused in version 1.17.
- Agents get a format map: for each mapped folder and story file, its format,
scope and where it lives, archived files, flagged files and pending imports
included.
- The import and new story files ship closed. They open only after four things.
The editor ships. The human has set the writing store's remote, and the stack
has checked that it can push there. Agents can read committed DOCX story files
through the lookups and cite them (REQ-LIB-5, REQ-LIB-6). Any earlier agent
system that writes the user's files has stopped, which for Case 2 is the
Hermes retirement (#423 step 5). So the import itself never cuts agents off
from a chapter, the chapter's current text never lives only on the stack's
host, and no second system writes the story. The first import of a real user's
books waits for Jason's go.
(Q235, Q236, Q237: P1 to P6. Q222 for the setting. O2 design D10 to D12.
Version 1.17.)
- **REQ-LIB-9.** The Console's DOCX editor. The human edits a DOCX story file in
the Console's editor, in a split pane with the agent chat alongside it. The
human can pin an agent chat to a document. The human can select a word,
right-click it, and choose a synonym from a thesaurus. Only a human saves. Saves
happen on their own while the human types (Q226), at intervals only a human
changes, and each accepted save is one commit with the human as author, pushed
as REQ-LIB-4 says. A save made against an older version than the current one is
refused. Agents never write a DOCX. Versioning is the editor's tracked changes
plus one git commit per save. The editor's code is vendored and pinned, with a
hash per file and the licenses that its independent security review accepted. At
runtime the editor loads only files that the stack itself serves, and it
requests nothing from any other host. The editor's frame sends exactly the
content security policy Jason approved in Q234, and the WebUI shell's own header
is unchanged. REQ-PROJ-3's project docs editor is unchanged. (Q195, Q226, Q234,
Q236. O2 design D3, D4, D6, D8, D10, D13, D14, D16 and D22. Version 1.17.)
### Installer
@@ -1973,118 +1750,6 @@ latest-by date and a hard target (Q176).
ruling after r11 and in the Q226 briefs of
`docs/plans/2026-10-20_case2-rows.md`. Sage files the rows under
Vikunja 416. This amendment adds the requirements only.
- Version 1.16:
- REQ-WAIT-3 gains one paragraph, the `library.write` routing and its
bounds. REQ-LIB-6 gains one sentence naming the action. No other
requirement text changes.
- `packages/tasks/requirements.json` changes its version to 1.16 and
nothing else. The titles stay and the id count stays 85.
- The commit that adds `library.write` to `ROUTED` in the business
vocabulary and the broker is C2-08b, row 285. It lands only after this
version.
- Version 1.17:
- Jason confirmed the reading 'for now (subject to future change)' (Q237). The
rulings stand until he revises them.
- The 10-27 Case 2 release ships on Markdown as built (P6). The 1.11 line 'The
Case 2 MVP for 10-27 is the Markdown library (#416). The WYSIWYG editor (Q195,
Q196) is an add-on that doesn't gate it.' still holds. Conversion opens after
10-27, and only once REQ-LIB-8's four conditions are met. The editor ships,
the writing store's remote is set and the stack has checked that it can push
there, agents can read and cite committed DOCX story files through the
lookups, and Hermes is retired, which is #423 step 5.
- The 1.12 note says five bus write and Console editor rows, C2-02, C2-03b,
C2-11a, C2-11b and C2-19, don't gate 10-27. Jason's Q226 F42 later moved
C2-02, C2-03b and C2-11b into the 10-27 slice. The Case 2 design brings C2-11a
in with them, because C2-11b needs it. C2-19 stays outside the slice. Mos's
reading of F42 is that C2-02 and C2-03b now gate 10-27, because design 9.2
puts them in 'the smallest slice that lets her start'. C2-11a and C2-11b are
slice rows that still don't gate 10-27, because the editor is the Q195 add-on.
The DOCX change in 1.17 leaves this slice as it is, and C2-11b's Markdown
editor never edits a converted story file.
- Q236 narrows Q176's editor choice to DOCX for story files (REQ-LIB-9). The
1.12 note that the Case 2 design keeps Obsidian now holds only for Markdown
files, including story files not yet imported. A DOCX story file is edited
only in the Console's editor under REQ-LIB-9.
- This replaces two parts of the O2 design at 25c09f19f. The superseded-notice
proposal from Fenton's F1 in D12 is withdrawn, and the
keep-Markdown-at-its-path model of D11 is replaced. Gyro's rewrite of the O2
design for Vikunja 571, landed on `refactor` at b99ca86b9, carries the
details, and where it differs from this text, this text holds:
- the import and archive steps, the mark, and the new bus step;
- links from supporting files to a moved chapter;
- the DOCX reader;
- the user's unpushed or later changes to an archived file or at its old path,
and pushing and closing the file in Obsidian before each import;
- the writing store's remote in W4 section 7.3, which replaces the no-remote
and no-backup rule that W4 section 7.4 held before the rewrite, at
062646487;
- import events counted by a bus restore, and recovery when a bus restore or a
restore of the whole host predates an import;
- manifest order for the manuscript lookup and its next chapter;
- `library.convert` kept out of `ACTIONS`, out of the hard-wait note and out
of the pinned `GATED_ONLY` list in packages/business/src/vocabulary.mjs.
- Agents read a converted story file through the Case 2 lookups under the same
project and universe access as Markdown canon, in any thread or room. W4's
earlier O2-only conversations, per-document grants and per-send sharing don't
gate these lookups. The 571 rewrite replaced them in W4 sections 7.1 and 7.2.
The creative tool list in REQ-LIB-6 doesn't change.
- The six creative role contracts row 213 landed say 'Every item cites the
lines of the user's material it is about'. A build row under 571 changes that
line for DOCX story files (REQ-LIB-6).
- A new Markdown file in a book folder is still canon in 1.17. The 1.17
exception is a file at a converted file's old path, which REQ-LIB-2 makes
unmapped.
- Story files convert whether canon or playground. Type-folder and supporting
files stay Markdown.
- Measured 2026-10-23 at about 01:21Z: the Case 2 user's library repository
tracks 213 files, 203 of them Markdown, and none is a .docx or .doc. It has no
untracked files. The user's .doc files (Q196) are outside it. So the landed
code has no DOCX there to report as unreadable at 10-27.
- Text replaced in 1.17, kept here as in earlier notes:
- Creative library lead, the 1.16 sentence: "The library is the user's own
writing repository."
- REQ-LIB-2, the 1.16 sentences: "A file's scope comes from its path alone,
prefixes first: a Markdown file under a supporting prefix is supporting,
one under a playground prefix at any level is playground, any other
Markdown file in a book folder is canon, and everything else is unmapped
and never returned. A folder in both lists is supporting." "Lookups return
canon unless the caller asks for playground or supporting." "so an agent
can't cite a playground or supporting file as canon" "The Console shows
each cite's scope."
- REQ-LIB-4, the 1.16 sentences: "The user commits and pushes with their own
tools, today Obsidian with obsidian-git." "Agents read only the mirror's
served commit and never hold a copy of their own, so they see the user's
push within about a minute."
- REQ-LIB-5, the 1.16 sentence: "The user sees them in Obsidian's graph and
backlinks." A converted chapter's connections show in Obsidian only as they
stood at conversion, and the Console's Connections view shows their
current links.
- REQ-LIB-6, the 1.16 sentence: "A reply may cite lines of the repository at
a commit or pages the agent fetched, and the broker checks every citation
before it stores the message."
- REQ-LIB-1 only gains sentences.
- REQ-LIB-3's text and REQ-LIB-7 are unchanged. REQ-LIB-8 adds one form of
exclusion REQ-LIB-3 doesn't have: naming a DOCX document by its id. A path
exclusion also covers DOCX documents, through a converted file's old path or a
whole book folder. An archived file and a DOCX document keep a universe, and a
book where their source has one, so every lookup still names one universe.
REQ-PROJ-3, the Console editor for a project's spec and docs, is unchanged.
C2-11b opens the library's Markdown files in that same editor.
- In `packages/tasks/requirements.json` the titles of REQ-LIB-1, REQ-LIB-2 and
REQ-LIB-4 change, and those of REQ-LIB-5 and REQ-LIB-6 stay. REQ-LIB-1 becomes
'A human-set library maps the user's repository onto universes, books, types,
playgrounds, supporting and archive folders'. REQ-LIB-2 becomes 'Scope comes
from the path or a DOCX's source, lookups default to canon, and only canon
files are ever cited as canon'. REQ-LIB-4 becomes 'Git holds every change. The
bus fast-forwards a read-only mirror every 60 seconds, the one place agents
read Markdown'. REQ-LIB-8 and REQ-LIB-9 add two ids, so the count goes from 85
to 87. REQ-LIB-8 is titled 'Case 2 story files become DOCX by a human-only
one-way import or in the editor; Markdown stays for notes and docs', and
REQ-LIB-9 'The Console's DOCX editor: only a human saves, each save is one
commit, and the editor code is vendored and pinned'.
- Open points O1 to O4 of draft 1 were ruled by Mos in mos-1367 B1 to B4.
- Sage files the build rows under Vikunja 570 and 571 after the rewrite is
approved. This amendment adds the requirements only.
## Risks and open questions
@@ -2123,26 +1788,6 @@ latest-by date and a hard target (Q176).
seat sandbox with its own network namespace closes it. Until then the
host's own audit log is the check after the fact (row 210 design section
6).
- A DOCX file is a zip archive, so git can't show a line diff of a chapter.
Review of a story change goes through the editor and the committed reader,
not `git diff`. (Version 1.17.)
- A converted chapter's DOCX is not in Obsidian's graph. Obsidian shows the
chapter only through its archived Markdown, and no DOCX edit reaches that file's
links. The DOCX's current links show in the Console's editor and its
Connections view, and no graph shows them yet. (Version 1.17.)
- Conversion can't be undone in place. Front matter, wiki link forms and
unsupported blocks may not survive it, and the mirror can't refuse the user's
push to an archived file. The controls are the fidelity report, the human's
confirmation, an untouched original on refusal, and the kept archive.
(Version 1.17.)
- A restore of the stack's bus alone from a backup taken before an import drops
the import's logged events, while the archive commit and the DOCX stay. A
restore of the stack's whole host from such a backup also takes the writing
store and the host's import records back to before the import. The archive
commit stays in the user's Gitea repository and the DOCX stays on the writing
store's remote, but the host keeps no record of the import. A bus restore counts
import events, and the import's recovery covers both cases (Vikunja 571).
(Version 1.17.)
## Milestones
-56
View File
@@ -1071,59 +1071,3 @@ TERM concurrently, then KILL after five seconds and observe for two more.
Unknown targets remain visible and prevent resume. Existing run evidence is
not synthesized or rewritten. First activation stops old processing before
trusted initialization and aligns the copy and image in the human's window.
### GitHub App grants (row 297)
The `github` method uses the same chief raise, human Approve, reserved `grant.call`
then `grant.result`, and local-first revoke boundary as Gitea. Its tokens expire
within an hour, so the durable grant authorizes protected renewal up to its
`rotateBy` day. Every generation remains bound to the original configuration.
A changed definition, allowed-project list, repository ID/remote or admin metadata
requires another human decision. No account or fork is created.
A role definition declares `{ "service": "github" }` without scopes. Its business
role names `"github": { "projects": ["app"] }`, and that project supplies its HTTPS
`remote` and positive integer `githubId`. No generic `credentials.github` reference
is accepted. Code fixes the token permissions to `contents: write, metadata: read`,
with exact selected repository IDs. Workflow-file writes (`.github/workflows/`),
PR/issue writes and administration are excluded. A workflow push refusal is intended.
Jason alone sets the App private key, through bounded stdin, after creating and
installing the App. Agents do not perform this live step:
```sh
mosaic grants admin set github --url https://api.github.com --app-id APP_ID \
--installation-id INSTALLATION_ID --account-id ACCOUNT_ID --rotate-by YYYY-MM-DD --business BUSINESS
mosaic grants status github --business BUSINESS
mosaic grants revoke AGENT github --reason REASON --business BUSINESS
mosaic grants sweep github --business BUSINESS
```
Admin rotation takes a replacement RSA private key on stdin with `admin rotate
github --rotate-by DATE`. It preserves App/installation/account/origin identity and
invalidates old bindings. GitHub App key creation and deletion remain Jason's own
GitHub administration step. Admin remove removes the local slot; it does not claim
to delete the App or remotely revoke its private keys.
Only trusted protected code can call `githubWithToken(business, agent, fn)`. It
checks approval/configuration/expiry, renews when required, and passes the token
and cancellation signal to that trusted callback. No socket verb returns a token
or exposes renewal. Tokens stay in private generation files outside run evidence;
RSA keys, JWTs and token values are never audit fields. Revoke blocks use and
renewal before its remote undo. An interrupted mint with no recoverable token value
stays visible in `serviceLeft` until its conservative expiry bound; a new token
does not hide it. `sweep` retries known tokens and records expiry cleanup.
Consumer gap (sage-3620): this row supplies the protected method and renewal
function, but adds no GitHub mirror push, publication or credential helper. The
existing Gitea credential path has day-level dates and Gitea fork identity; those
cannot safely carry a one-hour GitHub token. GitHub publication therefore needs a
separate consumer row and is not enabled by approving this method.
`sweep` also closes an active generation's private file after its conservative
expiry bound, recording reason `expired` while keeping the durable grant. A
later guarded use renews from the same unchanged approval. Before that cleanup,
status can show an expired generation; its timestamp guard still refuses use.
Removing the admin slot while grants/residue exist invalidates renewal and stops
remote cleanup until a matching slot is restored or the conservative bound
elapses. Remove does not claim remote revocation or delete the App's keys.
+27 -251
View File
@@ -137,9 +137,6 @@ export const TESTER_VERBS = Object.freeze(['message.send', 'message.receive', 'm
// collaborator grant, so a viewer gets access-refused on each.
export const USER_VERBS = Object.freeze(['project.list', 'thread.create', 'thread.list', 'thread.open', 'thread.send', 'thread.messages', 'names',
'thread.share', 'thread.unshare', 'thread.shares']);
// Row 252 (#1753, design sections 2-3): the only verbs a Console owner cap reaches. Every other verb
// refuses it human-required before its own checks, and these refuse every other cap.
export const CONSOLE_VERBS = Object.freeze(['decision.preview.console', 'decision.resolve.console']);
// Row 275 (193b1b, #1780, row 193 design 4.3 and 9): the user verbs a viewer grant reaches. They
// read, so a live grant at any level covers them. thread.create, thread.open and thread.send need a
// collaborator grant on the project and instance, so a viewer gets access-refused on each.
@@ -415,7 +412,6 @@ export class Broker {
// Row 236 (#436b): giteaGrant and giteaKeys from gitea-grant.mjs, or null, and the remote halves of
// section 5's restart rows and of revokes a restart cut short, for reconcileGitea.
#giteaGrant;
#githubGrant;
#giteaKeys;
#giteaUndo = [];
// Row 281 (#1786, d6 section 3b): each tracked Gitea promise and the business/agent it acts for, or
@@ -440,7 +436,7 @@ export class Broker {
#clock = 0;
#processing;
// credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use().
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, roleFile = null, giteaGrant = null, giteaKeys = null, githubGrant = null, access = null, accessStamp = null, compactions = null }) {
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, roleFile = null, giteaGrant = null, giteaKeys = null, access = null, accessStamp = null, compactions = null }) {
if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business');
// Row 194 b15 (sage-2501): the function-argument checks come before anything reads the store.
if (harnessOn !== null && typeof harnessOn !== 'function') throw new TypeError('harnessOn must be a function');
@@ -467,16 +463,6 @@ export class Broker {
this.#hostKeys = hostKeys;
this.#giteaGrant = giteaGrant;
this.#giteaKeys = giteaKeys;
this.#githubGrant = githubGrant;
githubGrant?.configure({
store,
business: (business) => this.#business(business),
event: (business, body) => store.transaction(() => this.#grantEvent(business, body, body.agent ?? null)),
approved: (business, decision) => this.#approvedGrant(business, decision),
consume: (business, decision) => store.transaction(() => this.#consumeGrant(business, decision, 'github')),
grantable: (business, agent) => this.#grantableAgent(business, agent),
working: () => { this.#processingWork(); return true; },
});
this.#access = access;
this.#accessStamp = accessStamp;
this.#compactions = compactions;
@@ -1470,26 +1456,6 @@ export class Broker {
if (!this.#userGrants(s).length) fail('access-revoked');
return this.#cap(s);
}
// Row 252 (#1753, design section 2): the trusted Console transport invokes this only after
// verifyPrincipal checked the console-cli wrapper, its webui `serve` parent and the signed-in owner.
// The cap is the business human's with via 'console' and the owner subject. request() lets it reach
// CONSOLE_VERBS only, and #human refuses it, so no cli verb accepts it.
bindConsole(record) {
keys(record, ['business', 'human', 'subject'], ['business', 'human', 'subject']);
const b = this.#business(record.business);
const x = record.subject;
if (record.human !== b.human || !object(x) || Object.keys(x).length !== 3 ||
!['provider', 'iss', 'sub'].every((k) => typeof x[k] === 'string' && x[k].length > 0 && x[k].length <= 512))
fail('human-required');
return this.#cap({
business: record.business,
human: b.human,
via: 'console',
console: Object.freeze({ provider: x.provider, iss: x.iss, sub: x.sub }),
role: null,
run: null,
});
}
bindReader({ business }) {
this.#business(business);
return this.#cap({ business, reader: true, role: null, run: null, human: false });
@@ -1538,61 +1504,10 @@ export class Broker {
const raise = this.#raised(business, decision);
if (d?.action !== 'credential.mint' || d.route_to !== 'human' || !raise?.grant) return null;
const r = this.#closed(decision);
if (r?.op !== 'resolved' || r.choice !== raise.approvalChoice || r.by !== this.#business(business).human) return null;
// Row 252 (#1753, design section 3, J14): a console close counts only with its own human.input,
// which only the broker writes (recordEvent reserves the kind).
if (!(r.via === 'console' ? this.#consoleInput(business, decision, r) : r.via === 'cli')) return null;
if (r?.op !== 'resolved' || r.choice !== raise.approvalChoice || r.via !== 'cli' || r.by !== this.#business(business).human)
return null;
return raise.grant;
}
// Row 252: the console answer's human.input for close row r. Same decision, same seq, same choice,
// no role or run, and a three-string owner subject.
#consoleInput(business, decision, r) {
const e = this.#store.get(
`SELECT body FROM events WHERE business=? AND kind='human.input' AND subject=? AND actor_role IS NULL AND actor_run IS NULL
AND json_extract(body,'$.via')='console' AND json_extract(body,'$.decisionSeq')=? ORDER BY seq LIMIT 1`,
business,
decision,
r.seq,
);
const x = e && JSON.parse(e.body);
return (
!!x &&
x.kind === 'answer' &&
x.decision === decision &&
x.op === 'resolved' &&
x.choice === r.choice &&
object(x.subject) &&
['provider', 'iss', 'sub'].every((k) => typeof x.subject[k] === 'string' && x.subject[k].length > 0)
);
}
// Row 252 (design section 1): a Console-eligible grant request, or null. Open is the caller's
// check. Eligible is route_to human, action credential.mint, a raise with a validated grant binding
// and exactly two distinct option keys, one of them approvalChoice. Approve is that key and deny is
// the other. The summary carries only the binding's method, agent, host and target projects. The
// digest binds the id, business, options, approval key and binding, so any change reads stale.
#consoleResolution(d) {
if (d.route_to !== 'human' || d.action !== 'credential.mint') return null;
const raise = this.#raised(d.business, d.id);
if (!object(raise?.grant) || typeof raise.grant.method !== 'string') return null;
const options = JSON.parse(d.options);
if (options.length !== 2 || options[0].key === options[1].key) return null;
const approve = options.find((o) => o.key === raise.approvalChoice);
if (!approve) return null;
const deny = options.find((o) => o.key !== raise.approvalChoice);
const g = raise.grant;
const grant = {
method: g.method,
...(typeof g.agent === 'string' ? { agent: g.agent } : {}),
...(typeof g.host === 'string' ? { host: g.host } : {}),
...(Array.isArray(g.target?.projects) ? { projects: g.target.projects.filter((p) => typeof p === 'string') } : {}),
};
return {
approve: { key: approve.key, text: approve.text },
deny: { key: deny.key, text: deny.text },
grant,
digest: sha256(JSON.stringify([d.id, d.business, options, raise.approvalChoice, raise.grant])),
};
}
// r6 X1: the executor's own spend. The caller owns the transaction. The consume record is the only
// guarded body with `decision` (#436b section 7), so #spent counts it.
#consumeGrant(business, decision, method) {
@@ -2034,16 +1949,6 @@ export class Broker {
for (const m of this.#openRevokes(business)) this.#revokeUndo.push({ business, ...m });
// Row 236 (#436b r5 section 5): the Gitea grants' local part.
if (this.#giteaGrant && this.#giteaKeys) ended.push(...this.#giteaReconcileLocal(business));
if (this.#githubGrant) {
this.#githubGrant.reconcile(business);
for (const decision of this.#openGrantDecisions(business, 'github')) {
this.#store.transaction(() => {
const g = this.#consumeGrant(business, decision, 'github');
this.#grantEvent(business, { action: 'grant.failed', method: 'github', grantDecision: decision, agent: g.agent, code: 'interrupted' }, g.agent);
});
ended.push(decision);
}
}
this.#interruptedJobs(business);
}
return ended;
@@ -2223,8 +2128,6 @@ export class Broker {
for (const d of open) if (d.grant.method === 'host') hosts.add(d.grant.host);
for (const host of [...hosts].sort()) set.push({ label: 'host:' + host, kind: 'host', host });
if (this.#giteaPending(business, agent, open)) set.push({ label: 'gitea', kind: 'gitea' });
if (this.#githubGrant && (open.some((d) => d.grant.method === 'github') || this.#githubGrant.pending(business, agent)))
set.push({ label: 'github', kind: 'github' });
if (this.#hostGrant)
for (const { host } of this.#store.all('SELECT host FROM hosts WHERE business=? ORDER BY host', business)) {
if (hosts.has(host)) continue;
@@ -2243,10 +2146,9 @@ export class Broker {
.map((d) => ({ id: d.id, raise: this.#raised(business, d.id) }))
.filter(({ id, raise }) => {
const g = raise?.grant;
if (g?.agent !== agent || (g.method !== 'host' && g.method !== 'gitea' && g.method !== 'github')) return false;
if (g?.agent !== agent || (g.method !== 'host' && g.method !== 'gitea')) return false;
const r = this.#closed(id);
if (r && !(r.op === 'resolved' && r.choice === raise.approvalChoice)) return false;
if (g.method === 'github') return !this.#spent(business, id) && !this.#githubGrant?.decisionRevoked(business, g);
if (
this.#store.get(
`SELECT 1 FROM events WHERE business=? AND ${GRANT_EVIDENCE} AND json_extract(body,'$.grantDecision')=? AND json_extract(body,'$.action') IN ('grant.applied','grant.failed') LIMIT 1`,
@@ -2335,12 +2237,6 @@ export class Broker {
at = 'gitea';
grants.push(...(await this.#stepGGitea(business, agent, { job, reason, by, flag }, refuse)));
}
if (set.some((x) => x.kind === 'github')) {
at = 'github';
const result = await this.#githubGrant.revoke(business, agent, reason, by);
if (result.serviceLeft.length) refuse('github', 'github-left');
grants.push({ grant: 'github', granted: true });
}
for (const g of set.filter((x) => x.kind === 'line')) {
at = g.label;
const record = this.#hostRecord(business, g.host);
@@ -2590,15 +2486,6 @@ export class Broker {
this.#store.transaction(() => this.#agent(s));
if (a.action !== 'credential.mint' || 'spec_path' in a || 'spec_commit' in a) fail('invalid-request');
if (this.#business(s.business).roles[s.role]?.definition !== STAFF_TO) fail('grant-chief-only');
// Row297: the same chief/human grant gate, with a method-specific protected executor.
if (a.grant?.method === 'github') {
if (!this.#githubGrant) fail('github-not-configured');
return this.#store.transaction(() => {
const result = this.#dispatch(s, 'decision.raise', a, null, this.#githubGrant.raiseBinding(s.business, a.grant));
this.#secretCheck(result);
return result;
});
}
// Row 236 (#436b section 2): no network call at raise, so every check runs in the raise transaction.
if (a.grant?.method === 'gitea') {
if (!this.#giteaGrant) fail('gitea-not-configured');
@@ -2861,7 +2748,6 @@ export class Broker {
async grantsIdle() {
while (this.#grantJobs.size || this.#giteaRuns.size || this.#hostRuns.size)
await Promise.allSettled([...this.#grantJobs, ...this.#giteaRuns.keys(), ...[...this.#hostRuns.values()].flatMap((s) => [...s])]);
await this.#githubGrant?.idle();
}
// Section 4 steps 0 (the remote half) to 8. Never throws: the outcome is in the store and the
// notices, and the return value is 'applied', 'settle-pending' or the code grant.failed records.
@@ -3589,75 +3475,10 @@ export class Broker {
if (this.#business(s.business).roles[s.role]?.definition !== STAFF_TO) fail('grant-chief-only');
return s.role;
}
// Row297 trusted host API. No socket verb returns tokens or renews grants.
githubWithToken(business, agent, fn) {
if (!this.#githubGrant) fail('github-not-configured');
return this.#githubGrant.withToken(business, agent, fn);
}
async reconcileGithub() {
if (!this.#githubGrant) return;
for (const business of Object.keys(this.#businesses))
if (this.#githubGrant.status(business).serviceLeft.length) await this.#githubGrant.sweep(business, 'restart');
}
async closeGithub() { await this.#githubGrant?.close(); }
#startGithub(business, decision) {
if (!this.#githubGrant) fail('github-not-configured');
const g = this.#approvedGrant(business, decision);
if (g?.method !== 'github') fail('not-authorized');
const job = this.#track(this.#githubGrant.run(business, decision), business + '/' + g.agent);
job.then(
() => this.#store.transaction(() => this.#grantNotice(business, decision, `GitHub grant to ${g.agent} applied; protected renewal stays within the approved binding.`)),
(e) => this.#store.transaction(() => this.#grantNotice(business, decision, `GitHub grant to ${g.agent} failed: ${e instanceof BusError ? e.code : 'github-failed'}.`)),
).catch(() => {});
return job;
}
async #requestGithub(s, verb, a, by) {
if (!this.#githubGrant) fail('github-not-configured');
const manager = this.#githubGrant;
if (verb === 'grants.status' || verb === 'grants.sweep') {
keys(a, ['service'], ['service']);
return verb === 'grants.status' ? manager.status(s.business) : manager.sweep(s.business, by);
}
if (verb === 'grants.revoke') {
keys(a, ['service', 'agent', 'reason'], ['service', 'agent', 'reason']);
identifier(a.agent); string(a.reason, 500);
if (!Object.hasOwn(this.#business(s.business).roles, a.agent)) fail('credential-unknown');
if (this.#revoking.has(s.business + '/' + a.agent)) fail('grant-revoke-running');
return manager.revoke(s.business, a.agent, a.reason, by);
}
if (verb === 'grants.admin.remove') {
keys(a, ['service'], ['service']);
manager.api.removeAdmin(s.business);
this.#store.transaction(() => this.#grantEvent(s.business, { action: 'grant.admin', method: 'github', verb: 'remove', by }));
return { removed: true };
}
const replace = verb === 'grants.admin.rotate';
keys(a, replace ? ['service', 'rotateBy', 'token'] : ['service', 'url', 'appId', 'installationId', 'accountId', 'rotateBy', 'token'],
replace ? ['service', 'rotateBy', 'token'] : ['service', 'url', 'appId', 'installationId', 'accountId', 'rotateBy', 'token']);
const previous = replace ? manager.api.adminMeta(s.business) : null;
if (replace && !previous) fail('grant-admin-missing');
const meta = { url: replace ? previous.url : a.url, appId: replace ? previous.appId : a.appId,
installationId: replace ? previous.installationId : a.installationId, accountId: replace ? previous.accountId : a.accountId,
rotateBy: a.rotateBy, setAt: this.#now() };
const voided = this.#openGrantDecisions(s.business, 'github').length;
await manager.api.setAdmin(s.business, meta, a.token, manager.audit(s.business, 'admin:' + by), { replace });
this.#store.transaction(() => this.#grantEvent(s.business, { action: 'grant.admin', method: 'github', verb: replace ? 'rotate' : 'set', ...meta, voided, by }));
return { ...meta, voided };
}
// Row 236: the grants verbs. Async, because each one may call Gitea.
async #requestGrants(s, verb, a) {
try {
const by = this.#grantsCaller(s, verb);
if (verb === 'grants.run') {
keys(a, ['decision'], ['decision']); identifier(a.decision);
}
if (verb === 'grants.run' && this.#approvedGrant(s.business, a.decision)?.method === 'github') {
if (this.#spent(s.business, a.decision)) fail('decision-consumed');
const g = this.#approvedGrant(s.business, a.decision);
this.#startGithub(s.business, a.decision).catch(() => {});
return { decision: a.decision, agent: g.agent, started: true };
}
if (a.service === 'github') return await this.#requestGithub(s, verb, a, by);
if (!this.#giteaGrant || !this.#giteaKeys) fail('gitea-not-configured');
if (verb === 'grants.run') {
keys(a, ['decision'], ['decision']);
@@ -4730,9 +4551,6 @@ export class Broker {
grants = this.#userGrants(s, doc);
if (!grants.length) fail('access-revoked');
}
// Row 252 (design section 2, N4): a console cap reaches the two console verbs only, and nothing
// else reaches them. decision.resolve stays off the list.
if (!!s.console !== CONSOLE_VERBS.includes(request.verb)) fail('human-required');
const args = request.args ?? {};
if (!object(args)) fail('invalid-request');
this.#secretCheck(args);
@@ -4898,8 +4716,6 @@ export class Broker {
throw new BusError('outcome-unknown');
}
this.#secretCheck(result);
if (verb === 'credentials.delete' && this.#githubGrant?.pending(s.business, a.agent))
this.#track(this.#githubGrant.revoke(s.business, a.agent, a.reason, s.tester ? 'tester' : s.human), s.business + '/' + a.agent).catch(() => {});
// Row 236 (#436b section 6): a gitea revoke, or a fire, of an agent this stack made a Gitea user for
// takes the grant away too. Credentials has done step 1. Step 2 runs here; step 3 and the fire's
// steps run once this returns.
@@ -5265,7 +5081,6 @@ export class Broker {
const s = this.#session(cap);
try {
if (s.user) fail('access-refused');
if (s.console) fail('human-required');
this.#processingWork();
keys(request, ['verb', 'args'], ['verb']);
if (!TASK_VERBS.includes(request.verb)) fail('unknown-verb');
@@ -5304,7 +5119,6 @@ export class Broker {
let args;
try {
if (s.user) fail('access-refused');
if (s.console) fail('human-required');
keys(request, ['verb', 'args'], ['verb']);
this.#processingWork();
if (!verb) fail('unknown-verb');
@@ -5364,7 +5178,6 @@ export class Broker {
let a, entry, timeout, bound;
try {
if (s.user) fail('access-refused');
if (s.console) fail('human-required');
this.#processingWork();
keys(request, ['verb', 'args'], ['verb']);
if (!HOST_VERBS.includes(request.verb)) fail('unknown-verb');
@@ -5688,38 +5501,15 @@ export class Broker {
}
return this.#decisionView(this.#decision(s, did));
}
// Row 252 (#1753, design section 3): the Console's read of one grant request before its
// confirmation. It returns the two answers, a safe grant summary and the digest the answer must
// carry. Anything not eligible refuses, so the browser never decides eligibility (J1).
case 'decision.preview.console': {
keys(a, ['id'], ['id']);
identifier(a.id);
if (!s.console || s.human !== b.human) fail('human-required');
const d = this.#decision(s, a.id);
if (this.#closed(d.id)) fail('decision-closed');
const c = this.#consoleResolution(d);
if (!c) fail('decision-not-eligible');
return { id: d.id, business: d.business, question: d.question, ...c };
}
case 'decision.resolve.console':
case 'decision.resolve':
case 'decision.seen':
case 'decision.withdraw': {
const viaConsole = verb === 'decision.resolve.console';
if (viaConsole) keys(a, ['id', 'choice', 'digest'], ['id', 'choice', 'digest']);
else keys(a, ['id', 'choice', 'note'], ['id']);
keys(a, ['id', 'choice', 'note'], ['id']);
identifier(a.id);
if (a.note !== undefined) string(a.note);
const d = this.#decision(s, a.id);
if (this.#closed(d.id)) fail('decision-closed');
// Row 252 (design section 3): the Console answer rechecks eligibility, the digest and the choice
// in this transaction. It answers one of the two keys, never a key the browser names (J2).
if (viaConsole) {
if (!s.console || s.human !== b.human) fail('human-required');
const c = this.#consoleResolution(d);
if (!c) fail('decision-not-eligible');
if (a.digest !== c.digest || ![c.approve.key, c.deny.key].includes(a.choice)) fail('decision-stale');
} else if (verb === 'decision.withdraw') {
if (verb === 'decision.withdraw') {
if (s.human) {
this.#human(s);
} else {
@@ -5731,40 +5521,32 @@ export class Broker {
this.#agent(s);
if (s.role !== d.route_to) fail('not-resolver');
}
const op = verb === 'decision.resolve' || viaConsole ? 'resolved' : verb === 'decision.seen' ? 'seen' : 'withdrawn';
const op = verb === 'decision.resolve' ? 'resolved' : verb === 'decision.seen' ? 'seen' : 'withdrawn';
if (op === 'resolved' && !JSON.parse(d.options).some((o) => o.key === a.choice))
fail('invalid-choice');
const seq = Number(
this.#store.run(
'INSERT INTO decision_events(decision,at,op,by,choice,note,via) VALUES(?,?,?,?,?,?,?)',
d.id,
this.#now(),
op,
by,
op === 'resolved' ? a.choice : null,
a.note ?? null,
s.console ? 'console' : s.human ? 'cli' : 'broker',
).lastInsertRowid,
this.#store.run(
'INSERT INTO decision_events(decision,at,op,by,choice,note,via) VALUES(?,?,?,?,?,?,?)',
d.id,
this.#now(),
op,
by,
op === 'resolved' ? a.choice : null,
a.note ?? null,
s.human ? 'cli' : 'broker',
);
// Row 252 (N2): the human.input comes before #approvedGrant, which needs a console answer's
// record to start the executor. Its body names the owner subject and the close's seq.
if (s.human)
this.#event(
s,
'human.input',
s.console
? { kind: 'answer', decision: d.id, op, choice: a.choice, via: 'console', subject: s.console, decisionSeq: seq }
: { kind: 'answer', decision: d.id, op, choice: a.choice ?? null },
d.id,
);
// Row 234 (#1740, r6 X1): the human's cli approve of a grant mint starts the executor once this
// transaction commits. It rechecks all of this itself, so a rollback starts nothing. Row 252: a
// Console approve does too. A deny closes the decision with the other key and starts nothing.
// transaction commits. It rechecks all of this itself, so a rollback starts nothing.
const grant = op === 'resolved' ? this.#approvedGrant(s.business, d.id) : null;
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(() => {}));
if (s.human)
this.#event(
s,
'human.input',
{ kind: 'answer', decision: d.id, op, choice: a.choice ?? null },
d.id,
);
// 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
@@ -5791,7 +5573,7 @@ export class Broker {
this.#now(),
s.business,
s.human ? 'human' : s.role,
s.console ? 'human-console' : s.human ? 'human-cli' : s.run,
s.human ? 'human-cli' : s.run,
d.raised_by_role,
'DECISION',
null,
@@ -5801,8 +5583,7 @@ export class Broker {
);
this.#route(s.business, mid, d.raised_by_role, session);
}
const answer = viaConsole ? { id: d.id, op, choice: a.choice } : { id: d.id, op };
return grant ? { ...answer, grant: 'started' } : answer;
return grant ? { id: d.id, op, grant: 'started' } : { id: d.id, op };
}
case 'message.send': {
keys(a, ['to', 'body', 'class', 'in_reply_to', 'corrects', 'decision'], ['to', 'body']);
@@ -6158,12 +5939,7 @@ export class Broker {
s.business,
s.human || s.reader ? 'human' : s.role,
)
.map((d) => {
// Row 252: a grant request the Console can answer carries its two answers. The Console
// shows buttons from this, and the answer path rechecks it at the bus.
const c = s.human || s.reader ? this.#consoleResolution(d) : null;
return c ? { ...this.#decisionView(d), console: c } : this.#decisionView(d);
});
.map((d) => this.#decisionView(d));
if (verb === 'agents') {
const claims = this.#store.all(
"SELECT * FROM role_claims c WHERE business=? AND op='claim' AND seq=(SELECT max(seq) FROM role_claims WHERE business=c.business AND role=c.role) ORDER BY role",
+2 -3
View File
@@ -1,19 +1,18 @@
import { connect } from 'node:net';
import { BusError, PROJECT_SAVE_VERB, PROJECT_SAVE_REQUEST_MAX } from './broker.mjs';
export class Client {
constructor({ path, cap, human, tester, user, console: owner, timeout = 5000 }) {
constructor({ path, cap, human, tester, user, timeout = 5000 }) {
this.path = path;
this.cap = cap;
this.human = human;
this.tester = tester;
this.user = user;
this.console = owner;
this.timeout = timeout;
}
call(verb, args = {}) {
const request =
JSON.stringify({
...(this.human ? { human: this.human } : this.tester ? { tester: this.tester } : this.user ? { user: this.user } : this.console ? { console: this.console } : { cap: this.cap }),
...(this.human ? { human: this.human } : this.tester ? { tester: this.tester } : this.user ? { user: this.user } : { cap: this.cap }),
verb,
args,
}) + '\n';
-41
View File
@@ -1,41 +0,0 @@
// Row 252 (#1753, design section 2): the Console owner's transport shim, spawned only by the webui
// `cli.mjs serve` process for one preview or one answer. Not a second product CLI. The Console sets
// the nonce, the business and the signed-in (provider, iss, sub) in the spawn environment, and the
// bus reads them back from this process's initial environment. Nothing respawns here, so the
// Console stays the direct parent. Input is a verb and its args only: a body that names a business,
// a subject or any other key is refused, never merged.
import { readFileSync } from 'node:fs';
import { Client } from './client.mjs';
import { readProcess } from './human.mjs';
import { CONSOLE_VERBS } from './broker.mjs';
const env = process.env;
try {
if (!/^[a-f0-9]{64}$/.test(env.MOSAIC_BUS_CONSOLE_NONCE ?? '')) throw Object.assign(Error(), { code: 'human-required' });
const request = JSON.parse(readFileSync(0, 'utf8'));
if (
!request ||
typeof request !== 'object' ||
Array.isArray(request) ||
Object.keys(request).some((k) => !['verb', 'args'].includes(k)) ||
!CONSOLE_VERBS.includes(request.verb)
)
throw Error();
const me = readProcess(process.pid);
const client = new Client({
path: process.argv[2],
console: {
business: env.MOSAIC_BUS_CONSOLE_BUSINESS,
provider: env.MOSAIC_BUS_CONSOLE_PROVIDER,
iss: env.MOSAIC_BUS_CONSOLE_ISS,
sub: env.MOSAIC_BUS_CONSOLE_SUB,
pid: me.pid,
startTime: me.startTime,
nonce: env.MOSAIC_BUS_CONSOLE_NONCE,
},
});
const result = await client.call(request.verb, request.args ?? {});
process.stdout.write(JSON.stringify(result) + '\n');
} catch (e) {
process.stderr.write((/^[a-z-]{1,64}$/.test(e.code ?? '') ? e.code : 'invalid-request') + '\n');
process.exitCode = 2;
}
-143
View File
@@ -1,143 +0,0 @@
// Row297 GitHub App HTTPS boundary. No PATs, user creation or fork calls.
import { createPrivateKey, sign } from 'node:crypto';
import { request } from 'node:https';
import { BusError } from './broker.mjs';
import { day } from './credentials.mjs';
import { githubDirectory, githubRead, githubWrite, githubUnlink } from './github-files.mjs';
export const FIXED_PERMISSIONS = Object.freeze({ contents: 'write', metadata: 'read' });
export const API_VERSION = '2026-03-10';
export const CALL_TIMEOUT_MS = 15000;
const META_KEYS = ['accountId', 'appId', 'installationId', 'rotateBy', 'setAt', 'url'];
const id = (v) => Number.isSafeInteger(v) && v > 0;
const fail = (code) => { throw new BusError(code); };
export function githubOrigin(value) {
try {
const url = new URL(value);
if (url.protocol !== 'https:' || url.username || url.password || value !== url.origin) fail('invalid-request');
return url.origin;
} catch { fail('invalid-request'); }
}
export function githubMeta(meta) {
if (!meta || typeof meta !== 'object' || Array.isArray(meta) || Object.keys(meta).sort().join() !== META_KEYS.join()) fail('invalid-request');
githubOrigin(meta.url);
if (![meta.appId, meta.installationId, meta.accountId].every(id) || !day(meta.rotateBy) ||
typeof meta.setAt !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(meta.setAt) || !Number.isFinite(Date.parse(meta.setAt)) || new Date(meta.setAt).toISOString() !== meta.setAt) fail('invalid-request');
return { ...meta };
}
export function githubRsa(text) {
try {
if (typeof text !== 'string' || text.length > 16384 || !/^-----BEGIN (RSA )?PRIVATE KEY-----\n/.test(text)) fail('credential-format');
const key = createPrivateKey(text);
if (key.asymmetricKeyType !== 'rsa' || key.asymmetricKeyDetails.modulusLength < 2048) fail('credential-format');
return key;
} catch { fail('credential-format'); }
}
export function githubJwt(key, appId, now) {
const encode = (value) => Buffer.from(JSON.stringify(value)).toString('base64url');
const secs = Math.floor(now / 1000);
const input = encode({ alg: 'RS256', typ: 'JWT' }) + '.' + encode({ iat: secs - 60, exp: secs + 540, iss: String(appId) });
return input + '.' + sign('RSA-SHA256', Buffer.from(input), key).toString('base64url');
}
// Never follows redirects or returns headers/error text. Body limit and whole-call timeout.
export function sendGithub({ url, method, path, token, body, ca, timeoutMs = CALL_TIMEOUT_MS }) {
return new Promise((resolve) => {
let req, ended = false;
const end = (status = 0, json = null) => {
if (ended) return;
ended = true; clearTimeout(timer); resolve({ status, json });
};
const timer = setTimeout(() => { req?.destroy(); end(); }, timeoutMs);
try {
githubOrigin(url);
if (!path.startsWith('/') || path.startsWith('//')) fail('invalid-request');
const data = body === undefined ? null : Buffer.from(JSON.stringify(body));
req = request(new URL(path, url), { method, ca, agent: false, headers: {
accept: 'application/vnd.github+json', authorization: 'Bearer ' + token,
'x-github-api-version': API_VERSION, 'user-agent': 'Mosaic-grant',
...(data ? { 'content-type': 'application/json', 'content-length': String(data.length) } : {}),
} }, (res) => {
let size = 0; const chunks = [];
res.on('data', (part) => {
size += part.length;
if (size > 4 * 1024 * 1024) { req.destroy(); end(); }
else chunks.push(part);
});
res.on('end', () => {
let json = null;
try { json = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch {}
end(res.statusCode, json);
});
res.on('error', () => end());
});
req.on('error', () => end()); req.end(data ?? undefined);
} catch { end(); }
});
}
export function githubApi({ configDir, ca, transport = sendGithub, now = () => Date.now(), timeoutMs = CALL_TIMEOUT_MS }) {
const directory = (business, create = false) => githubDirectory(configDir, ['grants', business, 'github'], create);
const adminMeta = (business) => {
const text = githubRead(directory(business), 'admin.json');
if (text === null) return null;
try { return githubMeta(JSON.parse(text)); } catch { fail('credential-file'); }
};
const call = async (meta, key, log, method, path, { token, body, installation = false } = {}) => {
if (typeof log !== 'function') fail('not-configured');
if (installation && (typeof token !== 'string' || token.length < 16 || token.length > 16384 || !/^[-A-Za-z0-9._~]+$/.test(token))) fail('credential-format');
const authorization = installation ? token : githubJwt(key, meta.appId, now());
return log({ method, path }, () => transport({ url: meta.url, method, path, token: authorization, body, ca, timeoutMs }));
};
const check = async (meta, key, log) => {
if (meta.rotateBy <= new Date(now()).toISOString().slice(0, 10)) fail('grant-admin-rotation-due');
const app = await call(meta, key, log, 'GET', '/app');
if ([401, 403].includes(app.status)) fail('grant-admin-account');
if (app.status !== 200) fail('github-failed');
if (app.json?.id !== meta.appId) fail('grant-admin-account');
const install = await call(meta, key, log, 'GET', `/app/installations/${meta.installationId}`);
if (install.status !== 200) fail([401, 403, 404].includes(install.status) ? 'grant-admin-account' : 'github-failed');
const i = install.json;
if (i?.id !== meta.installationId || i.app_id !== meta.appId || i.account?.id !== meta.accountId || i.suspended_at !== null) fail('grant-admin-account');
if (!i.permissions || !Object.entries(FIXED_PERMISSIONS).every(([k, v]) => i.permissions[k] === v || (v === 'read' && i.permissions[k] === 'write'))) fail('grant-admin-scope');
};
const admin = (business) => {
const meta = adminMeta(business);
if (!meta) fail('grant-admin-missing');
const key = githubRsa(githubRead(directory(business), 'admin'));
return { meta, key };
};
return {
adminMeta,
async setAdmin(business, input, text, log, { replace = false } = {}) {
const meta = githubMeta(input); const key = githubRsa(text);
const old = adminMeta(business);
if (!replace && (old || githubRead(directory(business), 'admin') !== null)) fail('grant-admin-exists');
if (replace && (!old || ['url', 'appId', 'installationId', 'accountId'].some((k) => old[k] !== meta[k]))) fail('grant-admin-account');
await check(meta, key, log);
const dir = directory(business, true);
// Rotation invalidates approved bindings before replacing the signing key.
if (replace) githubWrite(dir, 'admin.json', JSON.stringify(meta) + '\n');
githubWrite(dir, 'admin', text.replace(/\n?$/, '\n'));
if (!replace) githubWrite(dir, 'admin.json', JSON.stringify(meta) + '\n');
return meta;
},
removeAdmin(business) {
const dir = directory(business);
githubUnlink(dir, 'admin.json'); githubUnlink(dir, 'admin');
},
async checkAdmin(business, log) { const { meta, key } = admin(business); await check(meta, key, log); return meta; },
mint(business, repositoryIds, log) {
const { meta, key } = admin(business);
return call(meta, key, log, 'POST', `/app/installations/${meta.installationId}/access_tokens`,
{ body: { repository_ids: repositoryIds, permissions: { ...FIXED_PERMISSIONS } } });
},
repos(business, token, page, log) {
const { meta, key } = admin(business);
return call(meta, key, log, 'GET', `/installation/repositories?per_page=30&page=${page}`, { token, installation: true });
},
revoke(business, token, log) {
const { meta, key } = admin(business);
return call(meta, key, log, 'DELETE', '/installation/token', { token, installation: true });
},
};
}
-64
View File
@@ -1,64 +0,0 @@
// Row297 protected files. Values never enter the bus event store.
import { closeSync, constants, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeSync } from 'node:fs';
import { join } from 'node:path';
import { randomUUID } from 'node:crypto';
import { BusError } from './broker.mjs';
import { privateDir } from './credentials.mjs';
const fail = () => { throw new BusError('credential-file'); };
const checkName = (name) => { if (typeof name !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9.-]*$/.test(name) || name === '..') fail(); };
export function githubDirectory(configDir, parts, create = false) {
let dir = configDir;
for (const part of ['secrets', ...parts]) {
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(part) || part === '..') fail();
dir = join(dir, part);
if (!privateDir(dir, { missing: true })) {
if (!create) return null;
mkdirSync(dir, { mode: 0o700 });
}
privateDir(dir);
}
return dir;
}
export function githubRead(dir, name) {
checkName(name);
if (!dir) return null;
let fd;
try { fd = openSync(join(dir, name), constants.O_RDONLY | constants.O_NOFOLLOW); }
catch (e) { if (e.code === 'ENOENT') return null; fail(); }
try {
const stat = fstatSync(fd);
if (!stat.isFile() || stat.uid !== process.getuid() || (stat.mode & 0o777) !== 0o600 || stat.size > 65536) fail();
return readFileSync(fd, 'utf8');
} catch (e) { if (e instanceof BusError) throw e; fail(); }
finally { closeSync(fd); }
}
export function githubWrite(dir, name, text) {
checkName(name);
const target = join(dir, name);
// A foreign/symlink existing file is a refusal, never something to repair silently.
try {
const stat = lstatSync(target);
if (!stat.isFile() || stat.uid !== process.getuid() || (stat.mode & 0o777) !== 0o600) fail();
} catch (e) { if (e.code !== 'ENOENT') throw e; }
const tmp = target + '.' + randomUUID() + '.tmp';
let fd;
try {
fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
writeSync(fd, text); fsyncSync(fd); closeSync(fd); fd = undefined;
renameSync(tmp, target);
const parent = openSync(dir, constants.O_RDONLY);
try { fsyncSync(parent); } finally { closeSync(parent); }
} finally {
if (fd !== undefined) closeSync(fd);
try { unlinkSync(tmp); } catch (e) { if (e.code !== 'ENOENT') throw e; }
}
}
export function githubUnlink(dir, name) {
if (!dir) return;
// Apply the same checks before deleting any protected file.
if (githubRead(dir, name) === null) return;
unlinkSync(join(dir, name));
const fd = openSync(dir, constants.O_RDONLY);
try { fsyncSync(fd); } finally { closeSync(fd); }
}
-298
View File
@@ -1,298 +0,0 @@
// Row297: protected, event-backed GitHub grants. No worker-facing token or renewal verb.
import { createHash, randomUUID } from 'node:crypto';
import { BusError, GRANT_EVIDENCE } from './broker.mjs';
import { canonicalJson } from '../../business/src/util.mjs';
import { CREATIVE_DEFINITIONS, ID_PATTERN } from '../../business/src/vocabulary.mjs';
import { day } from './credentials.mjs';
import { FIXED_PERMISSIONS, CALL_TIMEOUT_MS } from './github-api.mjs';
import { githubDirectory, githubRead, githubWrite, githubUnlink } from './github-files.mjs';
export { CALL_TIMEOUT_MS };
export const TOKEN_LIFETIME_MS = 3600000;
export const CLOCK_SKEW_MS = 60000;
export const TOKEN_MARGIN_MS = 60000;
const PAGE_SIZE = 30;
const fail = (code) => { throw new BusError(code); };
const hash = (v) => createHash('sha256').update(canonicalJson(v)).digest('hex');
const isId = (v) => Number.isSafeInteger(v) && v > 0;
const object = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
const exact = (v, keys) => object(v) && Object.keys(v).sort().join() === [...keys].sort().join();
const tokenText = (v) => typeof v === 'string' && v.length >= 16 && v.length <= 16384 && /^[-A-Za-z0-9._~]+$/.test(v);
const sameIds = (rows, bound) => Array.isArray(rows) && rows.length === bound.length &&
rows.every((r) => isId(r?.id)) && new Set(rows.map((r) => r.id)).size === rows.length &&
rows.every((r) => bound.some((b) => b.id === r.id));
const identity = (m) => Object.fromEntries(['url', 'appId', 'installationId', 'accountId'].map((k) => [k, m[k]]));
const refusal = (e) => e instanceof BusError ? e.code : 'github-failed';
export function githubGrant({ api, configDir, now = () => Date.now(), timeoutMs = CALL_TIMEOUT_MS }) {
let context = null;
const claims = new Map();
const jobs = new Set();
const key = (b, a) => b + '/' + a;
const at = () => new Date(now()).toISOString();
const ctx = () => context ?? fail('github-not-configured');
const event = (business, body) => ctx().event(business, { ...body, method: 'github' });
const rows = (business) => ctx().store.all(
`SELECT seq,body FROM events WHERE business=? AND ${GRANT_EVIDENCE} AND (json_extract(body,'$.method')='github' OR json_extract(body,'$.operation')='grant.github') ORDER BY seq`, business,
).map((r) => ({ seq: r.seq, ...JSON.parse(r.body) }));
const state = (business) => {
const entries = rows(business), grants = new Map(), intents = new Map(), closed = new Set(), revoked = new Map();
for (const e of entries) {
if (['grant.applied', 'grant.renewed'].includes(e.action)) grants.set(e.agent, e);
if (e.action === 'grant.revoked') { grants.delete(e.agent); revoked.set(e.agent, e.seq); }
if (e.action === 'grant.step' && e.step === 'mint') intents.set(e.generation, e);
if (e.action === 'grant.token.closed') closed.add(e.generation);
}
return { entries, grants, intents, closed, revoked };
};
const log = (business, grantDecision, agent = null, generation = null) => async (op, fn) => {
const callId = randomUUID();
event(business, { action: 'grant.call', grantDecision, agent, generation, callId, op });
let result;
try { result = await fn(); } catch { result = { status: 0, json: null }; }
// Deliberately never include the response body or authorization header.
event(business, { action: 'grant.result', grantDecision, agent, generation, callId, status: result.status });
return result;
};
const tracked = (fn) => {
const job = (async () => fn())(); jobs.add(job);
job.finally(() => jobs.delete(job)).catch(() => {});
return job;
};
const claim = (business, agent) => {
const k = key(business, agent);
if (claims.has(k)) fail('grant-busy');
const controller = new AbortController(); claims.set(k, controller);
return controller;
};
const release = (business, agent, controller) => {
if (claims.get(key(business, agent)) === controller) claims.delete(key(business, agent));
};
const generationDir = (business, agent, create = false) => githubDirectory(configDir, ['grants', business, 'github', 'tokens', agent], create);
const readToken = (business, intent) => {
const text = githubRead(generationDir(business, intent.agent), intent.generation);
if (text === null) return null;
if (!tokenText(text.trimEnd()) || text !== text.trimEnd() + '\n') fail('credential-format');
return text.trimEnd();
};
const roleSnapshot = (business, agent) => {
if (typeof agent !== 'string' || !ID_PATTERN.test(agent)) fail('invalid-request');
const b = ctx().business(business);
const role = Object.hasOwn(b.roles ?? {}, agent) ? b.roles[agent] : null;
const definition = role && Object.hasOwn(b.definitions ?? {}, role.definition) ? b.definitions[role.definition] : null;
if (!definition?.credentials?.some((c) => exact(c, ['service']) && c.service === 'github') ||
CREATIVE_DEFINITIONS.includes(role.definition) || !exact(role.github, ['projects']) ||
!Array.isArray(role.github.projects) || !role.github.projects.length || role.github.projects.some((p) => typeof p !== 'string' || !ID_PATTERN.test(p)) ||
new Set(role.github.projects).size !== role.github.projects.length) fail('github-not-declared');
const meta = api.adminMeta(business);
if (!meta) fail('grant-admin-missing');
if (meta.rotateBy <= at().slice(0, 10)) fail('grant-admin-rotation-due');
const origin = meta.url === 'https://api.github.com' ? 'https://github.com' : meta.url;
const repositories = role.github.projects.map((project) => {
const entry = b.projects?.[project];
if (!isId(entry?.githubId) || typeof entry.remote !== 'string' || entry.kind === 'library') fail('remote-not-granted');
let url;
try { url = new URL(entry.remote); } catch { fail('remote-not-granted'); }
if (url.origin !== origin || url.protocol !== 'https:' || url.username || url.password || url.search || url.hash ||
!/^\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(url.pathname) || entry.remote !== url.origin + url.pathname) fail('remote-host-mismatch');
const name = url.pathname.slice(1).replace(/\.git$/, '');
if (name.split('/').some((p) => p === '.' || p === '..' || p === '')) fail('remote-not-granted');
return { project, id: entry.githubId, name, remote: entry.remote };
});
if (new Set(repositories.map((r) => r.id)).size !== repositories.length) fail('remote-not-granted');
return { meta, repositories, configSha256: hash({ definition, github: role.github, repositories }) };
};
const binding = (business, grant) => {
if (!exact(grant, ['method', 'agent', 'rotateBy', 'target']) || grant.method !== 'github' ||
!exact(grant.target, ['projects']) || !Array.isArray(grant.target.projects) || grant.target.projects.length < 1 ||
grant.target.projects.length > 50 || grant.target.projects.some((p) => typeof p !== 'string' || !ID_PATTERN.test(p)) ||
new Set(grant.target.projects).size !== grant.target.projects.length || !day(grant.rotateBy) || grant.rotateBy <= at().slice(0, 10)) fail('invalid-request');
ctx().grantable(business, grant.agent);
const snap = roleSnapshot(business, grant.agent);
const repositories = grant.target.projects.map((project) => snap.repositories.find((r) => r.project === project) ?? fail('remote-not-granted'));
const body = { ...structuredClone(grant), repositories, configSha256: snap.configSha256,
adminSha256: hash(snap.meta), admin: identity(snap.meta), revocationSeq: state(business).revoked.get(grant.agent) ?? 0 };
return { body, block: `\n\nGrant ${grant.agent} GitHub App ${snap.meta.appId}, installation ${snap.meta.installationId}, account ${snap.meta.accountId}.` +
repositories.map((r) => `\nRepository ${r.project}: ${r.remote} (id ${r.id}).`).join('') +
`\nPermissions: contents:write, metadata:read. Protected renewal until ${grant.rotateBy}.` +
'\nNo workflow-file writes (.github/workflows/), account creation or fork creation. No GitHub publication consumer is installed by this grant.' };
};
const recheck = (business, g, epoch, controller) => {
if (controller.signal.aborted || g.revocationSeq !== epoch || (state(business).revoked.get(g.agent) ?? 0) !== epoch) fail('revoked');
if (g.rotateBy <= at().slice(0, 10)) fail('grant-expired');
if (ctx().working && !ctx().working()) fail('processing-halted');
ctx().grantable(business, g.agent);
const snap = roleSnapshot(business, g.agent);
if (snap.configSha256 !== g.configSha256 || hash(snap.meta) !== g.adminSha256 ||
!sameIds(g.repositories, snap.repositories.filter((r) => g.target.projects.includes(r.project)))) fail('decision-mismatch');
};
const closeToken = async (business, intent, by) => {
if (state(business).closed.has(intent.generation)) return true;
const done = (reason) => {
githubUnlink(generationDir(business, intent.agent), intent.generation);
event(business, { action: 'grant.token.closed', agent: intent.agent, generation: intent.generation, reason, by });
return true;
};
if (now() >= Date.parse(intent.until)) return done('expired');
const token = readToken(business, intent);
if (!token) return false; // Lost response: only bounded expiry can close it.
try {
const meta = api.adminMeta(business);
if (!meta || hash(identity(meta)) !== hash(intent.admin)) return false;
const audit = log(business, intent.grantDecision, intent.agent, intent.generation);
await api.checkAdmin(business, audit);
const result = await api.revoke(business, token, audit);
if (result.status === 204) return done('revoked');
// Do not interpret a 401/403 as proof of revocation, especially with clock drift.
} catch {}
return false;
};
const mint = async (business, decision, g, controller, epoch, initial) => {
recheck(business, g, epoch, controller);
const audit = log(business, decision, g.agent);
await api.checkAdmin(business, audit);
recheck(business, g, epoch, controller);
if (initial) ctx().consume(business, decision);
const generation = randomUUID(), started = now();
const intent = { action: 'grant.step', step: 'mint', agent: g.agent, grantDecision: decision, generation,
until: new Date(started + timeoutMs + TOKEN_LIFETIME_MS + CLOCK_SKEW_MS).toISOString(), admin: g.admin };
event(business, intent);
const callLog = log(business, decision, g.agent, generation);
let candidate;
try {
const response = await api.mint(business, g.repositories.map((r) => r.id), callLog);
if (response.status !== 201) {
// An answered rejection cannot have minted a token. An unanswered call can.
if ([401, 403, 404, 422].includes(response.status))
event(business, { action: 'grant.token.closed', agent: g.agent, generation, reason: 'not-issued' });
fail('github-failed');
}
const body = response.json;
if (tokenText(body?.token)) {
candidate = body.token;
githubWrite(generationDir(business, g.agent, true), generation, candidate + '\n');
}
recheck(business, g, epoch, controller);
const expires = typeof body?.expires_at === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/.test(body.expires_at) ? Date.parse(body.expires_at) : NaN;
if (!candidate || !object(body.permissions) || canonicalJson(body.permissions) !== canonicalJson(FIXED_PERMISSIONS) ||
body.repository_selection !== 'selected' || !sameIds(body.repositories, g.repositories) ||
!Number.isFinite(expires) || expires <= now() + TOKEN_MARGIN_MS || expires > started + TOKEN_LIFETIME_MS + CLOCK_SKEW_MS) fail('github-token-invalid');
const seen = [];
for (let page = 1; page <= 20; page++) {
const response = await api.repos(business, candidate, page, callLog);
recheck(business, g, epoch, controller);
if (response.status !== 200 || !object(response.json) || response.json.total_count !== g.repositories.length ||
!Array.isArray(response.json.repositories) || !response.json.repositories.length || response.json.repositories.length > PAGE_SIZE) fail('github-reach-unknown');
seen.push(...response.json.repositories);
if (seen.length >= response.json.total_count) break;
if (response.json.repositories.length < PAGE_SIZE) fail('github-reach-unknown');
}
if (!sameIds(seen, g.repositories) || seen.some((r) => !g.repositories.some((b) => b.id === r.id && b.name === r.full_name))) fail('github-reach-wider');
recheck(business, g, epoch, controller);
if (now() >= expires - TOKEN_MARGIN_MS) fail('github-token-invalid');
const old = state(business).grants.get(g.agent);
event(business, { action: initial ? 'grant.applied' : 'grant.renewed', agent: g.agent, grantDecision: decision,
generation, binding: g, expiresAt: new Date(expires).toISOString() });
if (old) {
const oldIntent = state(business).intents.get(old.generation);
if (oldIntent) await closeToken(business, oldIntent, 'replacement');
}
recheck(business, g, epoch, controller);
return { agent: g.agent, decision, generation, expiresAt: new Date(expires).toISOString() };
} catch (e) {
await closeToken(business, intent, 'failed');
event(business, { action: 'grant.failed', agent: g.agent, grantDecision: decision, generation, code: refusal(e) });
throw e instanceof BusError ? e : new BusError('github-failed');
}
};
const status = (business) => {
const s = state(business);
const active = new Set([...s.grants.values()].map((g) => g.generation));
return {
admin: api.adminMeta(business),
unclosedCalls: s.entries.filter((e) => e.action === 'grant.call' && !s.entries.some((r) => r.action === 'grant.result' && r.callId === e.callId))
.map((e) => ({ callId: e.callId, op: e.op, generation: e.generation })),
grants: [...s.grants.values()].map((g) => ({ agent: g.agent, decision: g.grantDecision,
generation: g.generation, repositoryIds: g.binding.repositories.map((r) => r.id), projects: g.binding.target.projects,
rotateBy: g.binding.rotateBy, expiresAt: g.expiresAt })),
serviceLeft: [...s.intents.values()].filter((i) => !active.has(i.generation) && !s.closed.has(i.generation))
.map((i) => ({ agent: i.agent, generation: i.generation, until: i.until })),
running: [...claims.keys()].filter((k) => k.startsWith(business + '/')).map((k) => ({ agent: k.slice(business.length + 1) })),
};
};
const manager = {
configure(c) { if (context) fail('github-already-configured'); context = c; },
raiseBinding: binding,
status,
decisionRevoked(business, g) { return g.revocationSeq !== (state(business).revoked.get(g.agent) ?? 0); },
pending(business, agent) {
const s = status(business);
return s.grants.some((g) => g.agent === agent) || s.serviceLeft.some((g) => g.agent === agent) || s.running.some((g) => g.agent === agent);
},
run(business, decision) { return tracked(async () => {
const g = ctx().approved(business, decision);
if (g?.method !== 'github') fail('not-authorized');
if (state(business).entries.some((e) => e.operation === 'grant.github' && e.decision === decision)) fail('decision-consumed');
const controller = claim(business, g.agent), epoch = state(business).revoked.get(g.agent) ?? 0;
try { return await mint(business, decision, g, controller, epoch, true); }
catch (e) {
// Preflight failures are unspent and retryable before restart, like row236.
if (!state(business).entries.some((e) => e.operation === 'grant.github' && e.decision === decision))
event(business, { action: 'grant.failed', agent: g.agent, grantDecision: decision, code: refusal(e), unspent: true });
throw e;
} finally { release(business, g.agent, controller); }
}); },
withToken(business, agent, fn) { return tracked(async () => {
const controller = claim(business, agent), epoch = state(business).revoked.get(agent) ?? 0;
try {
let g = state(business).grants.get(agent);
if (!g) fail('github-not-granted');
if (ctx().approved(business, g.grantDecision)?.method !== 'github') fail('not-authorized');
recheck(business, g.binding, epoch, controller);
if (now() >= Date.parse(g.expiresAt) - TOKEN_MARGIN_MS) {
await mint(business, g.grantDecision, g.binding, controller, epoch, false);
if (controller.signal.aborted) fail('revoked');
g = state(business).grants.get(agent);
if (!g) fail('revoked');
}
const intent = state(business).intents.get(g.generation);
const token = intent && readToken(business, intent);
if (!token) fail('credential-file');
recheck(business, g.binding, epoch, controller);
if (now() >= Date.parse(g.expiresAt) - TOKEN_MARGIN_MS) fail('github-token-expired');
const expiryTimer = setTimeout(() => controller.abort(), Math.max(0, Date.parse(g.expiresAt) - TOKEN_MARGIN_MS - now()));
try { return await fn(token, controller.signal); } finally { clearTimeout(expiryTimer); }
} finally { release(business, agent, controller); }
}); },
revoke(business, agent, reason, by) { return tracked(async () => {
if (typeof agent !== 'string' || !ID_PATTERN.test(agent) || typeof reason !== 'string' || !reason.length || reason.length > 500) fail('invalid-request');
claims.get(key(business, agent))?.abort();
event(business, { action: 'grant.revoked', agent, reason, by });
// The live claim owns cleanup of its in-flight candidate when it returns.
const s = state(business);
for (const intent of s.intents.values()) if (intent.agent === agent && !s.closed.has(intent.generation))
await closeToken(business, intent, by);
return { agent, serviceLeft: status(business).serviceLeft.filter((e) => e.agent === agent) };
}); },
sweep(business, by) { return tracked(async () => {
const s = state(business), active = new Set([...s.grants.values()].map((g) => g.generation));
for (const intent of s.intents.values())
if (!s.closed.has(intent.generation) && (!active.has(intent.generation) || now() >= Date.parse(intent.until)) && !claims.has(key(business, intent.agent))) await closeToken(business, intent, by);
event(business, { action: 'grant.swept', by, left: status(business).serviceLeft });
return status(business);
}); },
reconcile(business) {
// A generation with no applied/renewed record is never loaded or adopted.
const s = state(business);
for (const e of s.entries.filter((e) => e.operation === 'grant.github')) {
if (s.entries.some((r) => r.grantDecision === e.decision && ['grant.applied', 'grant.failed'].includes(r.action) && r.seq > e.seq)) continue;
const intent = [...s.intents.values()].findLast((i) => i.grantDecision === e.decision);
event(business, { action: 'grant.failed', grantDecision: e.decision, agent: intent?.agent ?? null, code: 'interrupted' });
}
},
async idle() { while (jobs.size) await Promise.allSettled([...jobs]); },
async close() { for (const c of claims.values()) c.abort(); await manager.idle(); },
api,
audit: log,
};
return manager;
}
+1 -5
View File
@@ -10,10 +10,6 @@ const MARKERS = [
'CODEX_THREAD_ID',
'PI_AGENT_DIR',
];
// Row 252 (#1753, Webby N5): the Console wrapper's nonce and the subject its spawn bound. The
// verifier reads each from the wrapper's initial environment, never from its proof alone.
export const CONSOLE_ENV = Object.freeze(['MOSAIC_BUS_CONSOLE_NONCE', 'MOSAIC_BUS_CONSOLE_BUSINESS', 'MOSAIC_BUS_CONSOLE_PROVIDER', 'MOSAIC_BUS_CONSOLE_ISS', 'MOSAIC_BUS_CONSOLE_SUB']);
const NONCE_KEYS = ['MOSAIC_BUS_CLI_NONCE', 'MOSAIC_BUS_USER_NONCE', ...CONSOLE_ENV];
const refuse = () => {
throw new BusError('human-required');
};
@@ -35,7 +31,7 @@ export function readProcess(pid, { readFile = readFileSync, stat = lstatSync } =
for (const entry of environment.split('\0')) {
const i = entry.indexOf('=');
const key = entry.slice(0, i);
if (NONCE_KEYS.includes(key) || MARKERS.includes(key)) env[key] = entry.slice(i + 1);
if (['MOSAIC_BUS_CLI_NONCE', 'MOSAIC_BUS_USER_NONCE'].includes(key) || MARKERS.includes(key)) env[key] = entry.slice(i + 1);
}
return {
pid,
+2 -38
View File
@@ -5,8 +5,8 @@ import { verifyTester, testerNonce } from './tester.mjs';
// Trusted transport API. Identity is held only by this connection's transient session.
// User mapping and live grant authorization belong to R3, not to this verifier.
export function verifyPrincipal(request, { peer, paths, businesses, launches = [], readProcess = defaultRead, readTesterNonce = testerNonce, consoleOwners = [] }) {
const kinds = ['human', 'tester', 'user', 'console'].filter(k => request?.[k]);
export function verifyPrincipal(request, { peer, paths, businesses, launches = [], readProcess = defaultRead, readTesterNonce = testerNonce }) {
const kinds = ['human', 'tester', 'user'].filter(k => request?.[k]);
if (kinds.length !== 1 || request.cap) throw new BusError('invalid-envelope');
const kind = kinds[0], proof = request[kind];
const error = kind === 'tester' ? 'tester-required' : 'human-required';
@@ -16,7 +16,6 @@ export function verifyPrincipal(request, { peer, paths, businesses, launches = [
const { business } = verifyTester(proof, { cliPath: paths.tester, launches, readProcess, readNonce: readTesterNonce });
return { kind: 'tester', business, id: 'tester' };
}
if (kind === 'console') return verifyConsole(proof, { peer, paths, businesses, launches, readProcess, consoleOwners });
let checked = proof, parent;
if (kind === 'user') {
if (!proof || typeof proof.user !== 'string' || !proof.user ||
@@ -43,38 +42,3 @@ export function verifyPrincipal(request, { peer, paths, businesses, launches = [
if (!id) throw new BusError('unknown-business');
return { kind: 'owner', business, id };
}
// Row 252 (#1753, design section 2): the Console owner. The same-tree console-cli.mjs wrapper, its own
// nonce namespace and the human walk, with the exact same-tree webui `cli.mjs serve` as its direct
// parent, read again after the walk as the user kind does. Business and (provider, iss, sub) must be
// the wrapper's initial spawn environment and one of the checked console.sso subjects the runtime
// was started with (Webby N1: every configured subject is an owner). The Console's mapping is trusted
// as row 196 permits; the bus does not exchange an ID token itself.
const SUBJECT_KEYS = ['provider', 'iss', 'sub'];
const SUBJECT_ENV = { business: 'MOSAIC_BUS_CONSOLE_BUSINESS', provider: 'MOSAIC_BUS_CONSOLE_PROVIDER', iss: 'MOSAIC_BUS_CONSOLE_ISS', sub: 'MOSAIC_BUS_CONSOLE_SUB' };
function verifyConsole(proof, { peer, paths, businesses, launches, readProcess, consoleOwners }) {
const refuse = () => { throw new BusError('human-required'); };
if (!proof || typeof proof !== 'object' ||
Object.keys(proof).some(k => !['pid', 'startTime', 'nonce', 'business', ...SUBJECT_KEYS].includes(k)) ||
SUBJECT_KEYS.some(k => typeof proof[k] !== 'string' || !proof[k] || proof[k].length > 512)) refuse();
if (typeof paths.consoleCli !== 'string' || typeof paths.console !== 'string') refuse();
const { provider, iss, sub, ...checked } = proof;
let child, parent;
try {
child = readProcess(proof.pid);
parent = readProcess(child.ppid);
if (parent.uid !== peer.uid || parent.argv[1] !== resolve(paths.console) || parent.argv[2] !== 'serve') refuse();
for (const [key, name] of Object.entries(SUBJECT_ENV)) if (child.env?.[name] !== proof[key]) refuse();
} catch { refuse(); }
const { business } = verifyHuman(checked, { cliPath: paths.consoleCli, launches, readProcess, nonceKey: 'MOSAIC_BUS_CONSOLE_NONCE' });
try {
const again = readProcess(proof.pid), up = readProcess(parent.pid);
if (again.ppid !== parent.pid || again.startTime !== child.startTime || up.startTime !== parent.startTime ||
up.ppid !== parent.ppid || up.uid !== parent.uid || up.argv[1] !== parent.argv[1] || up.argv[2] !== 'serve') refuse();
for (const [key, name] of Object.entries(SUBJECT_ENV)) if (again.env?.[name] !== proof[key]) refuse();
} catch { refuse(); }
if (!Array.isArray(consoleOwners) || !consoleOwners.some(o => o && o.provider === provider && o.iss === iss && o.sub === sub)) refuse();
const id = businesses[business]?.human;
if (!id) throw new BusError('unknown-business');
return { kind: 'console', business, id, via: 'console', subject: { provider, iss, sub } };
}
+1 -22
View File
@@ -25,8 +25,6 @@ import { webService } from './web.mjs';
import { hostExecutor, sweepRunDir } from './host-run.mjs';
import { grantKeys, hostGrant, hostReference, sweepHostKeys } from './host-grant.mjs';
import { giteaGrant, giteaKeys, giteaStart } from './gitea-grant.mjs';
import { githubApi } from './github-api.mjs';
import { githubGrant } from './github-grant.mjs';
import { verifyPrincipal } from './principal.mjs';
import { loadAccess } from '../../business/src/access.mjs';
import { inspectInterpreter, recordInterpreterStart } from './interpreter.mjs';
@@ -108,15 +106,7 @@ export async function startBroker({
// Without it no user binds. R6 and R8 pass it; the live boot config doesn't yet. FED R4 (#1801):
// intervalMs, the revocation timer's period (design 3.7, 30 s), in-process only like librarySync's.
access = null,
// Row 252 (#1753, design section 2): the checked console.sso subjects, [{provider, iss, sub}], each an
// owner of the business (Webby N1). The boot config carries them; no socket verb sets them. Empty, no
// Console owner binds and the terminal owner is unchanged.
consoleOwners = [],
}) {
if (!Array.isArray(consoleOwners) || consoleOwners.length > 512 || consoleOwners.some((o) => o === null || typeof o !== 'object' || Array.isArray(o) ||
Object.keys(o).length !== 3 || ['provider', 'iss', 'sub'].some((k) => typeof o[k] !== 'string' || !o[k] || o[k].length > 512)))
throw new BusError('invalid-host-request');
const owners = Object.freeze(consoleOwners.map((o) => Object.freeze({ provider: o.provider, iss: o.iss, sub: o.sub })));
if (access !== null && (typeof access?.file !== 'string' || access.context === null || typeof access.context !== 'object'
|| (Object.hasOwn(access, 'intervalMs') && !(Number.isSafeInteger(access.intervalMs) && access.intervalMs > 0))))
throw new BusError('invalid-host-request');
@@ -124,7 +114,7 @@ export async function startBroker({
|| (Object.hasOwn(librarySync, 'intervalMs') && !(Number.isSafeInteger(librarySync.intervalMs) && librarySync.intervalMs > 0))
|| (Object.hasOwn(librarySync, 'configDir') && typeof librarySync.configDir !== 'string')))
throw new BusError('invalid-host-request');
let store, credentials, server, adapter, processing, github = null, syncTimer = null, syncPass = null, accessTimer = null;
let store, credentials, server, adapter, processing, syncTimer = null, syncPass = null, accessTimer = null;
try {
const references = {};
for (const [business, b] of Object.entries(businesses)) {
@@ -273,7 +263,6 @@ export async function startBroker({
return false;
}
}
github = organization ? githubGrant({ api: githubApi({ configDir: organization.dir }), configDir: organization.dir }) : null;
broker = new Broker({
// FED R3: the access file is read per user request; a refusal leaves the user no grant.
access: access ? () => loadAccess(access.file, access.context) : null,
@@ -299,7 +288,6 @@ export async function startBroker({
// Row 236 (#1742): the Gitea grant path. The admin slot is <configDir>/secrets/grants/<business>/gitea.
giteaGrant: organization ? giteaGrant({ configDir: organization.dir }) : null,
giteaKeys: storeRoot ? giteaKeys({ credentials, storeRoot }) : null,
githubGrant: github,
usage,
limits,
store,
@@ -420,7 +408,6 @@ export async function startBroker({
tester: fileURLToPath(new URL('./tester-cli.mjs', import.meta.url)),
user: fileURLToPath(new URL('./user-cli.mjs', import.meta.url)),
console: fileURLToPath(new URL('../../webui/src/cli.mjs', import.meta.url)),
consoleCli: fileURLToPath(new URL('./console-cli.mjs', import.meta.url)),
};
const path = join(store.directory, 'broker.sock');
// The existing host executor owns SSH and its key lifecycle. Combine its revocation
@@ -452,18 +439,12 @@ export async function startBroker({
const p = verifyPrincipal({ user: proof }, { peer, paths: principalPaths, businesses, launches: launchRecords });
return broker.bindUser({ business: p.business, user: p.id });
},
// Row 252 (#1753): a fresh proof per connection, a cap for the Console verbs only.
authenticateConsole: (proof, peer) => {
const p = verifyPrincipal({ console: proof }, { peer, paths: principalPaths, businesses, launches: launchRecords, consoleOwners: owners });
return broker.bindConsole({ business: p.business, human: p.id, subject: p.subject });
},
});
// Row 235 (r6 X3): the remote undo of interrupted host grants, once the bus is up, so a start doesn't
// wait on the network.
broker.reconcileHosts();
// Row 236 (#436b r5 section 5): the Gitea grants' remote undo, settle and open revokes, likewise.
broker.reconcileGitea();
broker.reconcileGithub().catch(() => {});
// S6 launcher operations. The host decides capacity and the instance list; the broker
// authorizes role.launch and keeps the evidence, refusals included.
// A refusal is recorded against the caller, as request() records one.
@@ -654,7 +635,6 @@ export async function startBroker({
await stopSync();
await server.close();
await adapter?.close?.();
await github?.close();
await processing?.close();
store.close();
credentials.close();
@@ -664,7 +644,6 @@ export async function startBroker({
clearInterval(accessTimer);
await server?.close();
await adapter?.close?.();
await github?.close();
await processing?.close();
store?.close();
credentials?.close();
+7 -10
View File
@@ -1,6 +1,6 @@
import { createServer } from 'node:net';
import { lstatSync, chmodSync, unlinkSync } from 'node:fs';
import { BusError, TASK_VERBS, WEB_VERBS, HOST_VERBS, HOSTS_VERBS, GRANTS_VERBS, PROJECT_FILE_VERBS, LIBRARY_VERBS, PROJECT_SAVE_VERB, PROJECT_SAVE_REQUEST_MAX, CONSOLE_VERBS } from './broker.mjs';
import { BusError, TASK_VERBS, WEB_VERBS, HOST_VERBS, HOSTS_VERBS, GRANTS_VERBS, PROJECT_FILE_VERBS, LIBRARY_VERBS, PROJECT_SAVE_VERB, PROJECT_SAVE_REQUEST_MAX } from './broker.mjs';
import { inspectInterpreter } from './interpreter.mjs';
import { createPeerReader } from './peercred.mjs';
const LIMIT = 65536;
@@ -13,9 +13,8 @@ const SAVE_VERB_AT_START = new RegExp(`"verb":"${PROJECT_SAVE_VERB.replaceAll('.
// the web verbs reach it, and without one they refuse web-not-configured (or web-not-granted).
// `host` (row 210) is the trusted host.run executor (host-run.mjs), or null: host.run then refuses
// host-not-configured.
// A request carries exactly one of cap, human (row S4), tester (row 110), user (FED R3) and console
// (row 252) proof.
export async function serve({ broker, path, authenticateHuman = null, authenticateTester = null, authenticateUser = null, authenticateConsole = null, peerReader = null, timeout = 5000, tasks = null, web = null, host = null }) {
// A request carries exactly one of cap, human (row S4) and tester (row 110) proof.
export async function serve({ broker, path, authenticateHuman = null, authenticateTester = null, authenticateUser = null, peerReader = null, timeout = 5000, tasks = null, web = null, host = null }) {
try {
lstatSync(path);
throw new BusError('socket-exists');
@@ -65,17 +64,15 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
!r ||
Array.isArray(r) ||
typeof r !== 'object' ||
Object.keys(r).some((k) => !['cap', 'human', 'tester', 'user', 'console', 'verb', 'args'].includes(k)) ||
Object.keys(r).some((k) => !['cap', 'human', 'tester', 'user', 'verb', 'args'].includes(k)) ||
typeof r.verb !== 'string' ||
[r.cap, r.human, r.tester, r.user, r.console].filter(Boolean).length !== 1
[r.cap, r.human, r.tester, r.user].filter(Boolean).length !== 1
)
throw new BusError('invalid-envelope');
let cap = r.cap;
const kind = r.human ? 'human' : r.tester ? 'tester' : r.user ? 'user' : r.console ? 'console' : null;
// Row 252 (#1753): a Console owner proof reaches its two verbs only, refused before any proof check.
if (kind === 'console' && !CONSOLE_VERBS.includes(r.verb)) throw new BusError('human-required');
const kind = r.human ? 'human' : r.tester ? 'tester' : r.user ? 'user' : null;
if (kind) {
const authenticate = kind === 'human' ? authenticateHuman : kind === 'tester' ? authenticateTester : kind === 'user' ? authenticateUser : authenticateConsole;
const authenticate = kind === 'human' ? authenticateHuman : kind === 'tester' ? authenticateTester : authenticateUser;
if (!authenticate) throw new BusError(kind === 'tester' ? 'tester-required' : 'human-required');
socket.pause();
let peer;
+2 -5
View File
@@ -6,9 +6,6 @@
// bus.mjs for six library-* STATUS entries; no proof or transport change.
// Row 236 (#1742) re-pins human-cli.mjs for the same 130 s wait on the grants verbs; no proof or
// transport change.
// Row 252 (#1753, Webby N5) re-pins human.mjs, whose readProcess also keeps the five
// MOSAIC_BUS_CONSOLE_* keys for the Console wrapper's proof, and webui bus.mjs for consoleCall,
// which shares humanCall's spawn as transport(). The human proof and its spawn are unchanged.
import test from "node:test";
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
@@ -18,7 +15,7 @@ import { provedRuntime } from "../../cli/tests/channel-fixture.mjs";
import { Client } from "../src/client.mjs";
const baselines = {
"packages/bus/src/human.mjs": "bb13c146717cdc46d75d157177e400edac4d3e10f14fe511027a25fd44cec885",
"packages/bus/src/human.mjs": "0c6d96abe4607cba0b02b0638a2fc217ff8135bac9a47761a0ef015fbe9d3079",
"packages/bus/src/human-cli.mjs": "271b9781d73cf31cc562a56180e3ebcd725b5fde148a83347a201fdef2fc28af",
"packages/cli/src/transport.mjs": "5129c44b4d9f642e825344df65c9bfb4bac4fc8d6b25343b42d39853dbd40774",
// Row 165 (#1662): seven STATUS entries for the role instance routes (organization-instance-exists,
@@ -29,7 +26,7 @@ const baselines = {
// Row 194 (#1692, brief item 7, sage-1610 a): organization-tracker-required 400 and
// organization-tracker-bot-taken 409; no proof or transport change.
// Row 301 (#1808, mos-1449): the user transport moved from user-bus.mjs into bus.mjs; humanCall unchanged.
"packages/webui/src/bus.mjs": "604092cb706b152d458f299ff1071f6cd488774629012f96b672f7e9a16c0b2c",
"packages/webui/src/bus.mjs": "39fcb27c82e917ef05e1a7a650d522c9f3c656bf8dbade87b8ddb3661b67ad6c",
};
const repoFile = path => new URL("../../../" + path, import.meta.url);
-318
View File
@@ -1,318 +0,0 @@
// Row 252 (#1753, Gander's design approved by Webby at 5d8f0f4b5, N1-N5): the broker side of the
// Console's Approve and Deny for a grant request. A console cap, made by bindConsole after the
// transport checked the wrapper and the signed-in owner, reaches only decision.preview.console and
// decision.resolve.console, and no other cap reaches those. The answer records via 'console' and a
// human.input that names the owner, written before the grant check (N2), and only that record lets
// an approve start the executor (J13, J14). Q188: no agent caller answers. The grant uses row 234's
// host-enroll raise with a fake keyscan and fake enroll and check steps, so nothing connects anywhere.
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Broker, CONSOLE_VERBS } from '../src/broker.mjs';
import { Client } from '../src/client.mjs';
import { serve } from '../src/server.mjs';
import { Store } from '../src/store.mjs';
import { hostGrant, fingerprint } from '../src/host-grant.mjs';
import { grantDecision } from './host-fixtures.mjs';
const options = [{ key: 'yes', text: 'Yes, enroll it' }, { key: 'no', text: 'No' }];
const SUBJECT = { provider: 'fake-oidc', iss: 'https://idp.test', sub: 'owner-1' };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const keyLine = (dir, name) => {
execFileSync('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-C', 'x', '-f', join(dir, name)]);
return readFileSync(join(dir, name + '.pub'), 'utf8').trim().split(' ').slice(0, 2).join(' ');
};
const refusesSync = (fn, code) => assert.throws(fn, (e) => (assert.equal(e.code, code), true));
function setup(t) {
const root = mkdtempSync(join(tmpdir(), 'c252-'));
const store = new Store(root);
t.after(() => {
store.close();
rmSync(root, { recursive: true, force: true });
});
const hostKeys = [keyLine(root, 'hk1'), keyLine(root, 'hk2')];
const configDir = join(root, 'config');
const real = hostGrant({ configDir, runRoot: join(configDir, 'secrets', 'run'), scan: async () => hostKeys });
const enrolls = [];
const hg = {
...real,
enroll: async (...args) => (enrolls.push(args), real.enroll(...args)),
check: async (...args) => ({ installerFingerprint: args[2].installer_fingerprint, exit: 0 }),
};
const business = {
id: 'demo',
human: 'jason',
arbiters: { technical: 'pm', delivery: 'pm' },
roles: {
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
pm: { definition: 'pm', authority: { withinRole: ['message.send'], crossRole: [] } },
},
vars: { 'bus.tester': true },
};
const make = () => new Broker({ store, businesses: { demo: business }, hostGrant: hg });
const broker = make();
const caps = {};
for (const role of ['chief', 'pm']) {
caps[role] = broker.bindLaunch({ business: 'demo', role, run: role + '-run', harness: 'pi', address: role });
broker.request(caps[role], { verb: 'role.claim' });
}
caps.human = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
caps.tester = broker.bindTester({ business: 'demo' });
caps.reader = broker.bindReader({ business: 'demo' });
caps.console = broker.bindConsole({ business: 'demo', human: 'jason', subject: SUBJECT });
const grant = { method: 'host-enroll', host: 'web1', address: '192.0.2.10', port: 22, user: 'jarvis', sudo: true, fingerprints: hostKeys.map(fingerprint) };
const call = (who, verb, args = {}) => broker.request(caps[who], { verb, args });
const raise = (extra = {}) =>
call('chief', 'decision.raise', { action: 'credential.mint', question: 'Enroll web1?', options, recommendation: 'yes', blocking: false, grant, ...extra });
const events = () =>
store
.all("SELECT seq, kind, actor_role, actor_run, subject, body FROM events WHERE business='demo' ORDER BY seq")
// The row's subject is `about`, since a console answer's body has its own `subject`. The row's kind
// is `event`, set after the body, since an answer's body has its own `kind`.
.map((e) => ({ seq: e.seq, kind: e.kind, actor_role: e.actor_role, actor_run: e.actor_run, about: e.subject, ...JSON.parse(e.body), event: e.kind }));
const grantEvents = (decision) => events().filter((e) => e.actor_run === 'grant' && (e.decision === decision || e.grantDecision === decision));
const settle = async (decision) => {
for (let i = 0; i < 250; i++) {
if (grantEvents(decision).some((e) => ['grant.host.enrolled', 'grant.failed'].includes(e.action))) return;
await sleep(20);
}
throw new Error('enrollment did not end');
};
const closes = (decision) => store.all('SELECT seq, op, by, choice, via FROM decision_events WHERE decision=? ORDER BY seq', decision);
return { store, broker, make, caps, call, raise, events, grantEvents, settle, closes, enrolls, grant };
}
test('N4: the console verbs are exactly the preview and the console resolve, and decision.resolve is not one', () => {
assert.deepEqual([...CONSOLE_VERBS], ['decision.preview.console', 'decision.resolve.console']);
assert.equal(CONSOLE_VERBS.includes('decision.resolve'), false);
assert.ok(Object.isFrozen(CONSOLE_VERBS));
});
test('bindConsole takes only the business human and a three-string owner subject', (t) => {
const f = setup(t);
const bind = (record) => () => f.broker.bindConsole(record);
refusesSync(bind({ business: 'demo', human: 'someone', subject: SUBJECT }), 'human-required');
refusesSync(bind({ business: 'demo', human: 'jason', subject: { ...SUBJECT, name: 'Jason' } }), 'human-required');
refusesSync(bind({ business: 'demo', human: 'jason', subject: { provider: 'p', iss: 'i' } }), 'human-required');
refusesSync(bind({ business: 'demo', human: 'jason', subject: { ...SUBJECT, sub: '' } }), 'human-required');
refusesSync(bind({ business: 'demo', human: 'jason', subject: { ...SUBJECT, sub: 'x'.repeat(513) } }), 'human-required');
refusesSync(bind({ business: 'demo', human: 'jason', subject: { ...SUBJECT, sub: 7 } }), 'human-required');
refusesSync(bind({ business: 'demo', human: 'jason', subject: 'owner-1' }), 'human-required');
assert.throws(bind({ business: 'demo', human: 'jason', subject: SUBJECT, via: 'cli' }));
assert.throws(bind({ business: 'demo', human: 'jason' }));
assert.throws(bind({ business: 'other', human: 'jason', subject: SUBJECT }));
assert.deepEqual(f.broker.identity(f.caps.console), { business: 'demo', role: null, run: null, human: 'jason' });
});
test('J1, Q188: only a console cap reaches the console verbs, and a console cap reaches nothing else', async (t) => {
const f = setup(t);
const d = await f.raise();
const preview = f.call('console', 'decision.preview.console', { id: d.id });
const args = { id: d.id, choice: 'yes', digest: preview.digest };
// Agents, the cli human, the tester and the reader are refused before the verb runs.
for (const who of ['chief', 'pm', 'human', 'tester', 'reader']) {
refusesSync(() => f.call(who, 'decision.preview.console', { id: d.id }), 'human-required');
refusesSync(() => f.call(who, 'decision.resolve.console', args), 'human-required');
}
// N4: the console cap answers nothing through decision.resolve, and reaches no other verb.
for (const [verb, a] of [
['decision.resolve', { id: d.id, choice: 'yes' }],
['decision.seen', { id: d.id }],
['decision.withdraw', { id: d.id }],
['decision.raise', { action: 'credential.mint', question: 'q', options, recommendation: 'yes', blocking: false, grant: f.grant }],
['inbox', {}],
['message.send', { to: 'chief', body: 'hi' }],
['role.claim', {}],
])
refusesSync(() => f.call('console', verb, a), 'human-required');
assert.deepEqual(f.closes(d.id), []);
assert.equal(f.enrolls.length, 0);
});
test('preview: the two real answers, a grant summary and the digest, for an open grant request only', async (t) => {
const f = setup(t);
const d = await f.raise();
const p = f.call('console', 'decision.preview.console', { id: d.id });
assert.equal(p.id, d.id);
assert.equal(p.business, 'demo');
assert.match(p.question, /^Enroll web1\?/);
assert.deepEqual(p.approve, { key: 'yes', text: 'Yes, enroll it' });
assert.deepEqual(p.deny, { key: 'no', text: 'No' });
assert.deepEqual(p.grant, { method: 'host-enroll', host: 'web1' });
assert.match(p.digest, /^[a-f0-9]{64}$/);
// Read twice, the digest is the same; a preview writes nothing.
assert.equal(f.call('console', 'decision.preview.console', { id: d.id }).digest, p.digest);
assert.deepEqual(f.closes(d.id), []);
refusesSync(() => f.call('console', 'decision.preview.console', { id: d.id, choice: 'yes' }), 'invalid-request');
refusesSync(() => f.call('console', 'decision.preview.console', { id: 'no-such-decision' }), 'decision-not-found');
// The inbox row says the same, for the human and the reader. The agent-facing views do not.
for (const who of ['human', 'reader']) {
const row = f.call(who, 'inbox').find((r) => r.id === d.id);
assert.deepEqual(row.console, { approve: p.approve, deny: p.deny, grant: p.grant, digest: p.digest });
}
});
test('J1, J2: a decision that is not an eligible grant request refuses both console verbs', async (t) => {
const f = setup(t);
const grant = { method: 'host', agent: 'pm', host: 'web1', commands: ['uptime'], rotateBy: '2099-01-01' };
const ineligible = [
grantDecision(f.store, { action: 'deploy', grant, close: null }),
grantDecision(f.store, { route: 'pm', grant, close: null }),
grantDecision(f.store, { grant: undefined, close: null }),
];
for (const id of ineligible) {
refusesSync(() => f.call('console', 'decision.preview.console', { id }), 'decision-not-eligible');
refusesSync(() => f.call('console', 'decision.resolve.console', { id, choice: 'yes', digest: '0'.repeat(64) }), 'decision-not-eligible');
assert.equal(f.call('human', 'inbox').find((r) => r.id === id)?.console, undefined);
}
});
test('N2, J13: a Console approve records via console and the owner, before the executor starts, and the grant runs once', async (t) => {
const f = setup(t);
const d = await f.raise();
const { digest } = f.call('console', 'decision.preview.console', { id: d.id });
const r = f.call('console', 'decision.resolve.console', { id: d.id, choice: 'yes', digest });
assert.deepEqual(r, { id: d.id, op: 'resolved', choice: 'yes', grant: 'started' });
const [close] = f.closes(d.id);
assert.deepEqual({ ...close, seq: undefined }, { seq: undefined, op: 'resolved', by: 'jason', choice: 'yes', via: 'console' });
const input = f.events().filter((e) => e.event === 'human.input' && e.about === d.id);
assert.equal(input.length, 1);
assert.deepEqual(
{ ...input[0], seq: undefined },
{ seq: undefined, kind: 'answer', actor_role: null, actor_run: null, about: d.id, decision: d.id, op: 'resolved', choice: 'yes', via: 'console', subject: SUBJECT, decisionSeq: close.seq, event: 'human.input' },
);
await f.settle(d.id);
assert.equal(f.enrolls.length, 1);
const spend = f.grantEvents(d.id).find((e) => e.operation === 'grant.host-enroll');
assert.ok(spend && input[0].seq < spend.seq, 'the human.input precedes the executor spend');
assert.ok(f.grantEvents(d.id).some((e) => e.action === 'grant.host.enrolled'));
// N3: the close notice to the raiser comes from the human through the Console.
const notices = f.store.all("SELECT from_run, body FROM messages WHERE business='demo' AND decision=? AND class='DECISION' AND from_role='human'", d.id);
assert.equal(notices.length, 1);
const [notice] = notices;
assert.equal(notice.from_run, 'human-console');
assert.match(notice.body, /resolved by jason: yes \(Yes, enroll it\)/);
// A second answer of either kind finds it closed, and nothing starts again.
refusesSync(() => f.call('console', 'decision.resolve.console', { id: d.id, choice: 'yes', digest }), 'decision-closed');
refusesSync(() => f.call('console', 'decision.preview.console', { id: d.id }), 'decision-closed');
refusesSync(() => f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' }), 'decision-closed');
await sleep(50);
assert.equal(f.enrolls.length, 1);
assert.equal(f.closes(d.id).length, 1);
});
test('J15: a Console deny closes with the other key and starts nothing', async (t) => {
const f = setup(t);
const d = await f.raise();
const { digest } = f.call('console', 'decision.preview.console', { id: d.id });
const r = f.call('console', 'decision.resolve.console', { id: d.id, choice: 'no', digest });
assert.deepEqual(r, { id: d.id, op: 'resolved', choice: 'no' });
assert.deepEqual(f.closes(d.id).map((c) => [c.op, c.choice, c.via]), [['resolved', 'no', 'console']]);
await sleep(100);
assert.equal(f.enrolls.length, 0);
assert.deepEqual(f.grantEvents(d.id), []);
assert.deepEqual(f.make().reconcileGrants(), []);
});
test('J2: approve and deny come from approvalChoice and the options, not a fixed yes and no', async (t) => {
const f = setup(t);
// Deny listed first, so a build that takes options[0] as approve fails too.
const keyed = { options: [{ key: 'stop', text: 'Stop' }, { key: 'go', text: 'Go ahead' }], approvalChoice: 'go', recommendation: 'go' };
const denied = await f.raise({ ...keyed, question: 'Enroll web1? (deny)' });
const p = f.call('console', 'decision.preview.console', { id: denied.id });
assert.deepEqual([p.approve, p.deny], [{ key: 'go', text: 'Go ahead' }, { key: 'stop', text: 'Stop' }]);
for (const choice of ['yes', 'no']) refusesSync(() => f.call('console', 'decision.resolve.console', { id: denied.id, choice, digest: p.digest }), 'decision-stale');
assert.deepEqual(f.call('console', 'decision.resolve.console', { id: denied.id, choice: 'stop', digest: p.digest }), { id: denied.id, op: 'resolved', choice: 'stop' });
await sleep(100);
assert.equal(f.enrolls.length, 0);
const approved = await f.raise({ ...keyed, question: 'Enroll web1? (approve)' });
const { digest } = f.call('console', 'decision.preview.console', { id: approved.id });
assert.deepEqual(f.call('console', 'decision.resolve.console', { id: approved.id, choice: 'go', digest }), { id: approved.id, op: 'resolved', choice: 'go', grant: 'started' });
await f.settle(approved.id);
assert.equal(f.enrolls.length, 1);
});
test('J11: a stale digest or a key outside the two refuses decision-stale and writes nothing', async (t) => {
const f = setup(t);
const d = await f.raise();
const { digest } = f.call('console', 'decision.preview.console', { id: d.id });
refusesSync(() => f.call('console', 'decision.resolve.console', { id: d.id, choice: 'yes', digest: 'f'.repeat(64) }), 'decision-stale');
refusesSync(() => f.call('console', 'decision.resolve.console', { id: d.id, choice: 'maybe', digest }), 'decision-stale');
refusesSync(() => f.call('console', 'decision.resolve.console', { id: d.id, choice: 'yes' }), 'invalid-request');
refusesSync(() => f.call('console', 'decision.resolve.console', { id: d.id, choice: 'yes', digest, note: 'x' }), 'invalid-request');
// Another decision's digest does not answer this one.
const other = await f.raise({ question: 'Enroll web1 again?' });
const od = f.call('console', 'decision.preview.console', { id: other.id }).digest;
assert.notEqual(od, digest);
refusesSync(() => f.call('console', 'decision.resolve.console', { id: d.id, choice: 'yes', digest: od }), 'decision-stale');
assert.deepEqual(f.closes(d.id), []);
assert.equal(f.events().filter((e) => e.event === 'human.input').length, 0);
// A refusal is recorded, as for every verb.
assert.ok(f.events().some((e) => e.event === 'action.refused' && e.code === 'decision-stale'));
// The decision closed by the cli meanwhile: the console answer finds it closed.
f.call('human', 'decision.resolve', { id: d.id, choice: 'no' });
refusesSync(() => f.call('console', 'decision.resolve.console', { id: d.id, choice: 'yes', digest }), 'decision-closed');
});
test('J14: a via console close with no matching human.input approves nothing', (t) => {
const f = setup(t);
const grant = { method: 'host-enroll', host: 'web2', address: '192.0.2.11', port: 22, user: 'jarvis', sudo: true, fingerprints: [] };
const cli = grantDecision(f.store, { grant });
const bare = grantDecision(f.store, { grant: { ...grant, host: 'web3' }, close: { op: 'resolved', by: 'jason', choice: 'yes', via: 'console' } });
const wrong = grantDecision(f.store, { grant: { ...grant, host: 'web4' }, close: { op: 'resolved', by: 'jason', choice: 'yes', via: 'console' } });
const good = grantDecision(f.store, { grant: { ...grant, host: 'web5' }, close: { op: 'resolved', by: 'jason', choice: 'yes', via: 'console' } });
const seqOf = (id) => f.store.get('SELECT seq FROM decision_events WHERE decision=?', id).seq;
const input = (id, body, role = null) =>
f.store.run(
"INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,?,'human.input',?,?,?,?)",
'e-' + id + '-' + Math.random(), '2026-10-22T00:00:00.000Z', 'demo', role, role && role + '-run', id, JSON.stringify(body),
);
const answer = (id) => ({ kind: 'answer', decision: id, op: 'resolved', choice: 'yes', via: 'console', subject: SUBJECT, decisionSeq: seqOf(id) });
// `bare` has no human.input at all. `wrong` has records that each break one part: another seq, choice, empty subject, an agent actor.
input(wrong, { ...answer(wrong), decisionSeq: seqOf(wrong) + 1000 });
input(wrong, { ...answer(wrong), choice: 'no' });
input(wrong, { ...answer(wrong), subject: { ...SUBJECT, sub: '' } });
input(wrong, answer(wrong), 'chief');
input(good, answer(good));
// reconcileGrants ends every approved, unspent host-enroll: exactly the cli one and the good console one.
assert.deepEqual(f.make().reconcileGrants().sort(), [cli, good].sort());
assert.equal(f.enrolls.length, 0);
assert.equal(f.store.get("SELECT count(*) AS n FROM events WHERE business='demo' AND actor_run='grant' AND json_extract(body,'$.decision') IN (?,?)", bare, wrong).n, 0);
});
test('recordEvent keeps human.input reserved, so no agent forges a console answer', async (t) => {
const f = setup(t);
const d = await f.raise();
const forged = { kind: 'human.input', subject: d.id, body: { kind: 'answer', decision: d.id, op: 'resolved', choice: 'yes', via: 'console', subject: SUBJECT, decisionSeq: 1 } };
for (const who of ['chief', 'pm']) refusesSync(() => f.broker.recordEvent(f.caps[who], forged), 'reserved-event');
refusesSync(() => f.broker.recordEvent(f.caps.console, forged), 'agent-required');
assert.equal(f.events().filter((e) => e.event === 'human.input').length, 0);
});
// J7: the socket transport binds a console proof's cap for its one call only. The reply and a refusal
// both release it, so the owner cap can't outlive the request that proved it.
test('J7: a console proof over the socket binds a cap for its one call and the reply releases it', async (t) => {
const f = setup(t);
const d = await f.raise();
const dir = mkdtempSync(join(tmpdir(), 'c252s-'));
const bound = [];
const server = await serve({
broker: f.broker,
path: join(dir, 'bus.sock'),
authenticateConsole: () => (bound.push(f.broker.bindConsole({ business: 'demo', human: 'jason', subject: SUBJECT })), bound.at(-1)),
});
t.after(async () => {
await server.close();
rmSync(dir, { recursive: true, force: true });
});
const client = new Client({ path: join(dir, 'bus.sock'), console: { fixture: true } });
const preview = await client.call('decision.preview.console', { id: d.id });
assert.equal(preview.id, d.id);
await assert.rejects(client.call('decision.preview.console', { id: 'nope' }), { code: 'decision-not-found' });
assert.equal(bound.length, 2);
for (const cap of bound) refusesSync(() => f.broker.identity(cap), 'unauthenticated');
});
-70
View File
@@ -1,70 +0,0 @@
// Row297: fake GitHub over HTTPS, scratch CA only. No live credentials or provider calls.
import { generateKeyPairSync, randomBytes, verify } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { createServer } from 'node:https';
import { scratchCa } from '../../seat/tests/fake-gitea.mjs';
export async function fakeGithubApi(t, dir, { now = () => Date.now() } = {}) {
const tls = scratchCa(dir);
const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
const key = privateKey.export({ type: 'pkcs8', format: 'pem' });
const tokens = new Map();
const requests = [];
const state = { appId: 10, installationId: 20, accountId: 30, suspended: false,
permissions: { contents: 'write', metadata: 'read' }, repositories: [{ id: 101, full_name: 'acme/app' }] };
let intercept = null;
const server = createServer({ key: tls.key, cert: tls.cert }, async (req, res) => {
const url = new URL(req.url, 'https://fake.test');
let body = '';
for await (const part of req) body += part;
let json;
try { json = body ? JSON.parse(body) : null; } catch { res.writeHead(400).end(); return; }
const auth = (req.headers.authorization ?? '').replace(/^Bearer /, '');
const bits = auth.split('.');
let jwt = null;
if (bits.length === 3) {
try {
if (verify('RSA-SHA256', Buffer.from(bits.slice(0, 2).join('.')), publicKey, Buffer.from(bits[2], 'base64url')))
jwt = JSON.parse(Buffer.from(bits[1], 'base64url'));
} catch {}
}
const token = tokens.get(auth);
const entry = { method: req.method, path: url.pathname, query: url.search, body: json,
jwt: jwt ? { ...jwt } : null, auth: jwt ? 'app' : token ? 'installation' : 'none' };
requests.push(entry);
const answer = (status, value) => {
res.writeHead(status, { 'content-type': 'application/json' });
res.end(value === undefined ? '' : JSON.stringify(value));
};
if (intercept && await intercept(entry, answer, { req, res, tokens })) return;
if (url.pathname === '/installation/token' && req.method === 'DELETE' && token) {
tokens.delete(auth); answer(204); return;
}
if (url.pathname === '/installation/repositories' && token && Date.parse(token.expires_at) > now()) {
const page = Number(url.searchParams.get('page') ?? 1);
const per = Number(url.searchParams.get('per_page') ?? 100);
answer(200, { total_count: token.repositories.length, repositories: token.repositories.slice((page - 1) * per, page * per) }); return;
}
const secs = Math.floor(now() / 1000);
if (!jwt || String(jwt.iss) !== String(state.appId) || jwt.iat > secs || jwt.exp <= secs || jwt.exp > secs + 600) {
answer(401, {}); return;
}
if (url.pathname === '/app') { answer(200, { id: state.appId }); return; }
if (url.pathname === `/app/installations/${state.installationId}` && req.method === 'GET') {
answer(200, { id: state.installationId, app_id: state.appId, account: { id: state.accountId },
suspended_at: state.suspended ? '2026-10-23T00:00:00Z' : null, permissions: state.permissions }); return;
}
if (url.pathname === `/app/installations/${state.installationId}/access_tokens` && req.method === 'POST') {
const value = 'ghs_fake_' + randomBytes(24).toString('hex');
const result = { token: value, expires_at: new Date(now() + 3600000).toISOString(),
permissions: json.permissions, repository_selection: 'selected',
repositories: state.repositories.filter((r) => json.repository_ids?.includes(r.id)) };
tokens.set(value, structuredClone(result)); answer(201, result); return;
}
answer(404, {});
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
t.after(async () => { server.closeAllConnections(); await new Promise((resolve) => server.close(resolve)); });
return { url: `https://127.0.0.1:${server.address().port}`, ca: readFileSync(tls.ca, 'utf8'),
key, tokens, requests, state, intercept: (fn) => { intercept = fn; } };
}
-113
View File
@@ -1,113 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { generateKeyPairSync } from 'node:crypto';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, statSync, symlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { githubApi, githubMeta, githubJwt, FIXED_PERMISSIONS } from '../src/github-api.mjs';
import { fakeGithubApi } from './fake-github-api.mjs';
const at = Date.parse('2026-10-23T12:00:00Z');
const refuses = (fn, code) => assert.throws(fn, (e) => e.code === code);
async function fixture(t) {
const dir = mkdtempSync(join(tmpdir(), 'github297-api-'));
t.after(() => rmSync(dir, { recursive: true, force: true }));
const fake = await fakeGithubApi(t, join(dir, 'tls'), { now: () => at });
const cfg = join(dir, 'config'); mkdirSync(cfg, { mode: 0o700 });
const api = githubApi({ configDir: cfg, ca: fake.ca, now: () => at });
const meta = { url: fake.url, appId: 10, installationId: 20, accountId: 30,
rotateBy: '2026-11-23', setAt: new Date(at).toISOString() };
const calls = [];
const log = async (op, fn) => { calls.push(['call', op]); const r = await fn(); calls.push(['result', r.status]); return r; };
return { fake, api, meta, cfg, calls, log };
}
test('R2 admin metadata refuses HTTP without a loopback exception', () => {
refuses(() => githubMeta({ url: 'http://127.0.0.1:1234', appId: 1, installationId: 2, accountId: 3,
rotateBy: '2026-11-23', setAt: new Date(at).toISOString() }), 'invalid-request');
});
test('R4 JWT uses the controlled clock, RS256, 60s backdate and bounded expiry', () => {
const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
const encoded = githubJwt(privateKey, 10, at);
const [header, body] = encoded.split('.').slice(0, 2).map((s) => JSON.parse(Buffer.from(s, 'base64url')));
assert.equal(header.alg, 'RS256');
assert.equal(body.iat, at / 1000 - 60);
assert.equal(body.exp, at / 1000 + 540);
assert.equal(body.iss, '10');
});
test('admin set checks the fake HTTPS identity and stores private files only after checks', async (t) => {
const f = await fixture(t);
await f.api.setAdmin('demo', f.meta, f.fake.key, f.log);
assert.deepEqual(f.api.adminMeta('demo'), f.meta);
assert.equal(statSync(join(f.cfg, 'secrets/grants/demo/github/admin')).mode & 0o777, 0o600);
assert.equal(f.calls.length, 4);
assert.deepEqual(f.fake.requests.map((r) => r.path), ['/app', '/app/installations/20']);
assert.equal(JSON.stringify(f.calls).includes('PRIVATE KEY'), false);
});
for (const [name, mutate, code] of [
['App identity', (f) => { f.meta.appId = 11; }, 'grant-admin-account'],
['installation account identity', (f) => { f.meta.accountId = 31; }, 'grant-admin-account'],
['suspended installation', (f) => { f.fake.state.suspended = true; }, 'grant-admin-account'],
['permission floor', (f) => { f.fake.state.permissions.contents = 'read'; }, 'grant-admin-scope'],
]) test(`C1-C4 replacement: admin set refuses ${name} and writes no slot`, async (t) => {
const f = await fixture(t); mutate(f);
await assert.rejects(f.api.setAdmin('demo', f.meta, f.fake.key, f.log), (e) => e.code === code);
assert.equal(existsSync(join(f.cfg, 'secrets/grants/demo/github/admin')), false);
});
test('admin set refuses a non-RSA key without making an API call', async (t) => {
const f = await fixture(t);
const { privateKey } = generateKeyPairSync('ed25519');
await assert.rejects(f.api.setAdmin('demo', f.meta, privateKey.export({ type: 'pkcs8', format: 'pem' }), f.log), (e) => e.code === 'credential-format');
assert.equal(f.fake.requests.length, 0);
});
test('admin set refuses HTTP before any call, and refuses to overwrite a slot', async (t) => {
const f = await fixture(t);
await assert.rejects(f.api.setAdmin('demo', { ...f.meta, url: f.fake.url.replace('https:', 'http:') }, f.fake.key, f.log), (e) => e.code === 'invalid-request');
assert.equal(f.fake.requests.length, 0);
await f.api.setAdmin('demo', f.meta, f.fake.key, f.log);
await assert.rejects(f.api.setAdmin('demo', f.meta, f.fake.key, f.log), (e) => e.code === 'grant-admin-exists');
});
test('mint sends fixed permissions and explicit repository IDs using an App JWT', async (t) => {
const f = await fixture(t);
await f.api.setAdmin('demo', f.meta, f.fake.key, f.log);
const r = await f.api.mint('demo', [101], f.log);
assert.equal(r.status, 201);
const req = f.fake.requests.at(-1);
assert.deepEqual(req.body, { repository_ids: [101], permissions: FIXED_PERMISSIONS });
assert.equal(req.auth, 'app');
assert.equal(f.fake.requests.every((x) => !JSON.stringify(x).includes('PRIVATE KEY')), true);
});
test('C2 validates the App response ID even with a successful JWT authentication', async (t) => {
const f = await fixture(t);
f.fake.intercept(async (r, answer) => {
if (r.path === '/app') { answer(200, { id: 11 }); return true; }
return false;
});
await assert.rejects(f.api.setAdmin('demo', f.meta, f.fake.key, f.log), (e) => e.code === 'grant-admin-account');
});
test('admin slot refuses unsafe file modes and symbolic links', async (t) => {
const f = await fixture(t); await f.api.setAdmin('demo', f.meta, f.fake.key, f.log);
const metaFile = join(f.cfg, 'secrets/grants/demo/github/admin.json');
chmodSync(metaFile, 0o644);
assert.throws(() => f.api.adminMeta('demo'), (e) => e.code === 'credential-file');
chmodSync(metaFile, 0o600);
const keyFile = join(f.cfg, 'secrets/grants/demo/github/admin');
rmSync(keyFile); symlinkSync(metaFile, keyFile);
await assert.rejects(f.api.checkAdmin('demo', f.log), (e) => e.code === 'credential-file');
});
for (const method of ['repos', 'revoke']) test(`N2 ${method} refuses a missing installation token before any call`, async (t) => {
const f = await fixture(t); await f.api.setAdmin('demo', f.meta, f.fake.key, f.log);
const before = f.fake.requests.length;
const run = () => method === 'repos' ? f.api.repos('demo', undefined, 1, f.log) : f.api.revoke('demo', undefined, f.log);
await assert.rejects(Promise.resolve().then(run), (e) => e.code === 'credential-format');
assert.equal(f.fake.requests.length, before);
});
-158
View File
@@ -1,158 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Broker, GRANT_EVIDENCE } from '../src/broker.mjs';
import { Store } from '../src/store.mjs';
import { githubApi } from '../src/github-api.mjs';
import { githubGrant } from '../src/github-grant.mjs';
import { fakeGithubApi } from './fake-github-api.mjs';
async function fixture(t) {
const dir = mkdtempSync(join(tmpdir(), 'github297-broker-'));
t.after(() => rmSync(dir, { recursive: true, force: true }));
const fake = await fakeGithubApi(t, join(dir, 'tls'));
const configDir = join(dir, 'cfg'); mkdirSync(configDir, { mode: 0o700 });
mkdirSync(join(dir, 'data'), { mode: 0o700 });
const store = new Store(join(dir, 'data')); t.after(() => store.close());
const api = githubApi({ configDir, ca: fake.ca });
const manager = githubGrant({ api, configDir });
const none = { authority: { withinRole: ['message.send'], crossRole: [] } };
const business = { id: 'demo', human: 'jason', arbiters: { technical: 'pm', delivery: 'pm' },
roles: { chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send', 'role.fire'], crossRole: [] } }, pm: { definition: 'pm', ...none },
coder: { definition: 'coder', github: { projects: ['app'] }, ...none } },
definitions: { 'chief-of-staff': { roleVersion: 2, credentials: [] }, pm: { roleVersion: 2, credentials: [] },
coder: { roleVersion: 2, credentials: [{ service: 'github' }] } },
projects: { app: { remote: fake.url + '/acme/app', githubId: 101 } }, launch: { by: 'chief', instances: ['pm', 'coder'] }, vars: { 'bus.tester': true } };
const writes = [];
let broker;
const organization = (id, op, args) => {
writes.push(op);
if (op === 'instance.fire') business.launch.instances = business.launch.instances.filter((n) => n !== args.name);
if (op === 'instances.set' && args.op === 'delete') {
delete business.roles[args.instance];
business.launch.instances = business.launch.instances.filter((n) => n !== args.instance);
}
broker.reloadBusiness('demo', business);
return { revision: 'b'.repeat(64), transaction: 'fake-transaction' };
};
broker = new Broker({ store, businesses: { demo: business }, githubGrant: manager, organization });
const caps = { human: broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }),
tester: broker.bindTester({ business: 'demo' }) };
for (const role of ['chief', 'pm']) {
caps[role] = broker.bindLaunch({ business: 'demo', role, run: role + '-run', harness: 'pi', address: role });
await broker.request(caps[role], { verb: 'role.claim' });
}
const call = (who, verb, args) => broker.request(caps[who], { verb, args });
const admin = { service: 'github', url: fake.url, appId: 10, installationId: 20, accountId: 30,
rotateBy: '2099-01-01', token: fake.key };
await call('human', 'grants.admin.set', admin);
const raise = () => call('chief', 'decision.raise', { action: 'credential.mint', question: 'Grant GitHub?',
options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'yes', blocking: false,
grant: { method: 'github', agent: 'coder', target: { projects: ['app'] }, rotateBy: '2098-01-01' } });
const idle = async () => { await new Promise((resolve) => setImmediate(resolve)); await broker.grantsIdle(); };
const events = () => store.all(`SELECT body FROM events WHERE business='demo' AND ${GRANT_EVIDENCE} ORDER BY seq`).map((e) => JSON.parse(e.body));
return { fake, store, broker, manager, business, call, raise, idle, events, admin, writes };
}
test('chief raise and human Approve start GitHub without a Gitea admin or host executor', async (t) => {
const f = await fixture(t);
const d = await f.raise();
assert.equal(f.fake.tokens.size, 0);
await f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' }); await f.idle();
const status = await f.call('chief', 'grants.status', { service: 'github' });
assert.equal(status.grants.length, 1);
assert.equal(status.grants[0].decision, d.id);
await f.call('human', 'grants.revoke', { service: 'github', agent: 'coder', reason: 'test' });
assert.equal(f.fake.tokens.size, 0);
assert.equal((await f.call('human', 'grants.status', { service: 'github' })).grants.length, 0);
});
test('GitHub admin and manual run are human-only; arbitrary agents cannot raise', async (t) => {
const f = await fixture(t);
for (const who of ['chief', 'tester']) {
await assert.rejects(Promise.resolve().then(() => f.call(who, 'grants.admin.remove', { service: 'github' })), (e) => e.code === 'human-required');
await assert.rejects(Promise.resolve().then(() => f.call(who, 'grants.run', { decision: 'unapproved' })), (e) => e.code === 'human-required');
}
await assert.rejects(Promise.resolve().then(() => f.call('pm', 'decision.raise', { action: 'credential.mint',
question: 'no', options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'yes', blocking: false,
grant: { method: 'github', agent: 'coder', target: { projects: ['app'] }, rotateBy: '2098-01-01' } })), (e) => e.code === 'grant-chief-only');
});
test('changing repository config after raise refuses before consume, then restart spends interrupted', async (t) => {
const f = await fixture(t); const d = await f.raise();
f.business.projects.app.githubId = 102; f.broker.reloadBusiness('demo', f.business);
await f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' }); await f.idle();
assert.equal(f.fake.tokens.size, 0);
assert.equal(f.events().some((e) => e.operation === 'grant.github'), false);
f.broker.reconcileGrants();
assert.equal(f.events().filter((e) => e.operation === 'grant.github' && e.decision === d.id).length, 1);
});
test('no socket verb exposes a token or protected renewal', async (t) => {
const f = await fixture(t);
for (const verb of ['grants.token', 'grants.renew', 'github.token'])
await assert.rejects(Promise.resolve().then(() => f.call('human', verb, { agent: 'coder' })), (e) => e.code === 'unknown-verb');
});
test('an open approval revoked before Yes cannot be used by the late automatic executor', async (t) => {
const f = await fixture(t); const d = await f.raise();
await f.call('human', 'grants.revoke', { service: 'github', agent: 'coder', reason: 'cancel' });
await f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' }); await f.idle();
assert.equal(f.fake.tokens.size, 0);
assert.equal(f.manager.status('demo').grants.length, 0);
});
test('grant evidence remains reserved from an agent or direct human record insert', async (t) => {
const f = await fixture(t);
assert.throws(() => f.store.run("INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES('forged','2026-10-23T00:00:00Z','demo','action.allowed',NULL,NULL,NULL,?)",
JSON.stringify({ action: 'grant.applied', method: 'github', agent: 'coder' })));
assert.equal(f.manager.status('demo').grants.length, 0);
});
for (const failDelete of [false, true]) test(`fire revokes GitHub locally and ${failDelete ? 'stops on remote leftovers' : 'completes only after remote cleanup'}`, async (t) => {
const f = await fixture(t); const d = await f.raise();
await f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' }); await f.idle();
if (failDelete) f.fake.intercept(async (r, answer) => { if (r.method === 'DELETE') { answer(503, {}); return true; } return false; });
await f.call('chief', 'instance.fire', { name: 'coder', reason: 'test' }); await f.idle();
assert.equal(f.manager.status('demo').grants.length, 0);
assert.equal(f.writes.includes('instance.fire'), !failDelete);
assert.equal(f.manager.status('demo').serviceLeft.length, failDelete ? 1 : 0);
});
test('lifecycle delete waits for the protected GitHub revoke job', async (t) => {
const f = await fixture(t); const d = await f.raise();
await f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' }); await f.idle();
const { request } = await f.call('human', 'instances.set', { revision: 'b'.repeat(64), instance: 'coder', op: 'delete' });
assert.equal(f.broker.lifecycleTake({ business: 'demo', request }).go, true);
assert.throws(() => f.broker.lifecycleDelete({ business: 'demo', request }), (e) => e.code === 'grants-revoking');
f.broker.lifecycleDone({ business: 'demo', request, refusal: 'grants-revoking' });
await f.idle();
assert.equal(f.manager.status('demo').grants.length, 0);
assert.equal(f.fake.tokens.size, 0);
assert.equal(Object.hasOwn(f.business.roles, 'coder'), true);
const second = await f.call('human', 'instances.set', { revision: 'b'.repeat(64), instance: 'coder', op: 'delete' });
assert.equal(f.broker.lifecycleTake({ business: 'demo', request: second.request }).go, true);
const removed = f.broker.lifecycleDelete({ business: 'demo', request: second.request });
assert.equal(removed.retry, false);
assert.equal(removed.transaction, 'fake-transaction');
assert.equal(Object.hasOwn(f.business.roles, 'coder'), false);
assert.equal(f.writes.filter((op) => op === 'instances.set').length, 1);
});
test('admin rotate preserves installation identity and invalidates open approved bindings', async (t) => {
const f = await fixture(t); const d = await f.raise();
await f.call('human', 'grants.admin.rotate', { service: 'github', rotateBy: '2099-02-01', token: f.fake.key });
await f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' }); await f.idle();
assert.equal(f.fake.tokens.size, 0);
assert.equal(f.manager.status('demo').admin.installationId, 20);
assert.equal(f.manager.status('demo').grants.length, 0);
});
for (const args of [{}, { decision: null }, { decision: 'bad/id' }, { decision: 'valid', extra: true }])
test('N7 manual run validates shape and identifier before approval lookup: ' + JSON.stringify(args), async (t) => {
const f = await fixture(t);
await assert.rejects(Promise.resolve().then(() => f.call('human', 'grants.run', args)), (e) => e.code === 'invalid-request');
assert.equal(f.fake.tokens.size, 0);
});
-412
View File
@@ -1,412 +0,0 @@
// Row297 contract tests. Run only under Sage CLEAR; fake HTTPS API, no live token.
import test from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { randomUUID } from 'node:crypto';
import { Store } from '../src/store.mjs';
import { GRANT_EVIDENCE, writeGrantEvent } from '../src/broker.mjs';
import { githubApi, FIXED_PERMISSIONS } from '../src/github-api.mjs';
import { githubGrant, TOKEN_LIFETIME_MS, CLOCK_SKEW_MS, TOKEN_MARGIN_MS, CALL_TIMEOUT_MS } from '../src/github-grant.mjs';
import { fakeGithubApi } from './fake-github-api.mjs';
import { githubDirectory, githubWrite } from '../src/github-files.mjs';
const START = Date.parse('2026-10-23T12:00:00Z');
const rejectCode = (p, code) => assert.rejects(p, (e) => e.code === code);
async function fixture(t, options = {}) {
const dir = mkdtempSync(join(tmpdir(), 'github297-grant-'));
t.after(() => rmSync(dir, { recursive: true, force: true }));
let time = START;
const now = () => time;
const fake = await fakeGithubApi(t, join(dir, 'tls'), { now });
const configDir = join(dir, 'config'); mkdirSync(configDir, { mode: 0o700 });
mkdirSync(join(dir, 'data'), { mode: 0o700 });
const store = new Store(join(dir, 'data'));
t.after(() => store.close());
const business = { id: 'demo', roles: { coder: { definition: 'coder', github: { projects: ['app'] } } },
definitions: { coder: { roleVersion: 2, credentials: [{ service: 'github' }] } },
projects: { app: { remote: fake.url + '/acme/app', githubId: 101 } } };
const approved = new Map();
const event = (id, body) => store.transaction(() => writeGrantEvent(store, new Date(now()).toISOString(), id, body, body.agent ?? null));
const api = githubApi({ configDir, ca: fake.ca, now, timeoutMs: options.timeoutMs ?? CALL_TIMEOUT_MS });
const meta = { url: fake.url, appId: 10, installationId: 20, accountId: 30,
rotateBy: '2026-11-23', setAt: new Date(now()).toISOString() };
const log = async (op, fn) => fn(); // Initial admin fixture; executor calls use reserved event writer.
await api.setAdmin('demo', meta, fake.key, log);
const spent = new Set();
let working = true;
const create = () => {
const manager = githubGrant({ api, configDir, now, timeoutMs: options.timeoutMs ?? CALL_TIMEOUT_MS });
manager.configure({ store, business: () => business, event,
approved: (id, decision) => approved.get(decision) ?? null,
consume: (id, decision) => {
if (spent.has(decision)) throw Object.assign(new Error('decision-consumed'), { code: 'decision-consumed' });
spent.add(decision); event(id, { operation: 'grant.github', decision }); return approved.get(decision);
}, grantable: () => {}, working: () => working });
return manager;
};
const manager = create();
const grant = { method: 'github', agent: 'coder', target: { projects: ['app'] }, rotateBy: '2026-11-20' };
const raise = () => {
const decision = randomUUID();
approved.set(decision, manager.raiseBinding('demo', grant).body);
return decision;
};
const events = () => store.all(`SELECT body FROM events WHERE business='demo' AND ${GRANT_EVIDENCE} ORDER BY seq`).map((r) => JSON.parse(r.body));
const apply = async () => { const decision = raise(); await manager.run('demo', decision); return decision; };
return { dir, configDir, store, business, fake, api, manager, meta, grant, now, raise, apply,
events, approved, spent, create, advance: (ms) => { time += ms; }, working: (v) => { working = v; } };
}
test('R4 lifetime, skew, margin and request bounds are explicit constants', () => {
assert.equal(TOKEN_LIFETIME_MS, 3600000);
assert.equal(CLOCK_SKEW_MS, 60000);
assert.equal(TOKEN_MARGIN_MS, 60000);
assert.equal(CALL_TIMEOUT_MS, 15000);
});
test('grant binding cannot name scopes, accounts or fork creation', async (t) => {
const f = await fixture(t);
for (const extra of [{ scopes: ['all'] }, { account: 'foreign' }, { fork: true }])
assert.throws(() => f.manager.raiseBinding('demo', { ...f.grant, ...extra }), (e) => e.code === 'invalid-request');
assert.equal(f.events().length, 0);
});
test('initial approval creates an exact durable grant and guarded token use', async (t) => {
const f = await fixture(t);
const decision = await f.apply();
const [g] = f.manager.status('demo').grants;
assert.equal(g.agent, 'coder');
assert.equal(g.decision, decision);
assert.deepEqual(g.repositoryIds, [101]);
assert.equal(g.expiresAt, new Date(START + TOKEN_LIFETIME_MS).toISOString());
let used = false;
await f.manager.withToken('demo', 'coder', async (token, signal) => {
used = f.fake.tokens.has(token); assert.equal(signal.aborted, false);
});
assert.ok(used);
const events = f.events();
const calls = events.filter((e) => e.action === 'grant.call');
assert.ok(calls.length >= 4);
for (const call of calls) {
const result = events.find((e) => e.action === 'grant.result' && e.callId === call.callId);
assert.ok(result);
assert.ok(events.indexOf(result) > events.indexOf(call));
}
assert.equal(events.filter((e) => Object.hasOwn(e, 'decision')).length, 1);
const text = JSON.stringify(events);
for (const token of f.fake.tokens.keys()) assert.ok(!text.includes(token));
assert.ok(!text.includes('PRIVATE KEY'));
assert.ok(!text.includes('Bearer'));
});
for (const [label, mutation] of [
['extra permission', (b) => { b.permissions.workflows = 'write'; }],
['higher metadata level', (b) => { b.permissions.metadata = 'write'; }],
['missing permission', (b) => { delete b.permissions.metadata; }],
['all repositories', (b) => { b.repository_selection = 'all'; }],
['extra repository', (b) => { b.repositories.push({ id: 102, full_name: 'acme/other' }); }],
['missing repository', (b) => { b.repositories = []; }],
['duplicate repository', (b) => { b.repositories.push({ ...b.repositories[0] }); }],
['past expiry', (b) => { b.expires_at = new Date(START).toISOString(); }],
['missing expiry', (b) => { delete b.expires_at; }],
['excessive lifetime', (b) => { b.expires_at = new Date(START + TOKEN_LIFETIME_MS + CLOCK_SKEW_MS + 1).toISOString(); }],
]) test(`R1/R4 mint refuses ${label} before any guarded use and revokes candidate`, async (t) => {
const f = await fixture(t);
let repoReads = 0;
f.fake.intercept(async (r, answer, { tokens }) => {
if (r.path === '/installation/repositories') repoReads++;
if (r.method !== 'POST') return false;
const token = 'ghs_fake_candidate_12345678901234567890';
const body = { token, expires_at: new Date(START + TOKEN_LIFETIME_MS).toISOString(),
permissions: { ...FIXED_PERMISSIONS }, repository_selection: 'selected', repositories: [{ id: 101, full_name: 'acme/app' }] };
tokens.set(token, structuredClone(body)); mutation(body); answer(201, body); return true;
});
await rejectCode(f.apply(), 'github-token-invalid');
assert.equal(f.manager.status('demo').grants.length, 0);
assert.equal(repoReads, 0);
assert.equal(f.fake.tokens.size, 0);
});
test('R4 renewal starts at the safety margin and uses a new token under the same decision', async (t) => {
const f = await fixture(t); const decision = await f.apply();
const old = [...f.fake.tokens.keys()][0];
f.advance(TOKEN_LIFETIME_MS - TOKEN_MARGIN_MS - 1);
await f.manager.withToken('demo', 'coder', () => {});
assert.equal(f.fake.tokens.size, 1);
f.advance(1);
await f.manager.withToken('demo', 'coder', (token) => { assert.ok(token !== old); });
assert.equal(f.fake.tokens.has(old), false);
assert.equal(f.manager.status('demo').grants[0].decision, decision);
assert.equal(f.events().filter((e) => e.operation === 'grant.github').length, 1);
});
for (const [label, change] of [
['project repository ID', (b) => { b.projects.app.githubId = 102; }],
['project remote', (b) => { b.projects.app.remote += '-changed'; }],
['allowed projects', (b) => { b.roles.coder.github.projects.push('other'); }],
['definition', (b) => { b.definitions.coder.title = 'changed'; }],
]) test(`R5 config change to ${label} refuses renewal and never uses old token`, async (t) => {
const f = await fixture(t); await f.apply();
change(f.business); f.advance(TOKEN_LIFETIME_MS);
let used = false;
await rejectCode(f.manager.withToken('demo', 'coder', () => { used = true; }), 'decision-mismatch');
assert.equal(used, false);
});
test('renewal refuses after the durable grant deadline or processing halt', async (t) => {
const f = await fixture(t); await f.apply();
f.working(false);
await rejectCode(f.manager.withToken('demo', 'coder', () => {}), 'processing-halted');
f.working(true); f.advance(40 * 86400000);
await rejectCode(f.manager.withToken('demo', 'coder', () => {}), 'grant-expired');
});
test('local revoke wins a pending renewal; candidate is revoked and grant never revives', async (t) => {
const f = await fixture(t); await f.apply(); f.advance(TOKEN_LIFETIME_MS);
let release, entered;
const held = new Promise((resolve) => { entered = resolve; });
const gate = new Promise((resolve) => { release = resolve; });
f.fake.intercept(async (r) => {
if (r.method === 'POST') { entered(); await gate; }
return false;
});
const pending = f.manager.withToken('demo', 'coder', () => assert.fail('late token used'));
// Attach the rejection handler before releasing the network call.
const refused = rejectCode(pending, 'revoked');
await held;
await f.manager.revoke('demo', 'coder', 'test', 'jason');
assert.equal(f.manager.status('demo').grants.length, 0);
release(); await refused;
assert.equal(f.manager.status('demo').grants.length, 0);
assert.equal(f.fake.tokens.size, 0);
});
test('revoke blocks guarded use before a remote delete completes; failure is serviceLeft', async (t) => {
const f = await fixture(t); await f.apply();
let localGone = false;
f.fake.intercept(async (r, answer) => {
if (r.method === 'DELETE') { localGone = f.manager.status('demo').grants.length === 0; answer(503, {}); return true; }
return false;
});
await f.manager.revoke('demo', 'coder', 'test', 'jason');
assert.ok(localGone);
await rejectCode(f.manager.withToken('demo', 'coder', () => assert.fail('revoked token used')), 'github-not-granted');
assert.equal(f.manager.status('demo').serviceLeft.length, 1);
f.fake.intercept(null); await f.manager.sweep('demo', 'jason');
assert.equal(f.fake.tokens.size, 0);
assert.equal(f.manager.status('demo').serviceLeft.length, 0);
});
test('lost mint response stays visible through restart until conservative expiry equality', async (t) => {
const f = await fixture(t);
f.fake.intercept(async (r, answer, { req }) => {
if (r.method === 'POST') { req.socket.destroy(); return true; }
return false;
});
await rejectCode(f.apply(), 'github-failed');
const status = f.manager.status('demo');
assert.equal(status.serviceLeft.length, 1);
const until = Date.parse(status.serviceLeft[0].until);
assert.equal(until, START + CALL_TIMEOUT_MS + TOKEN_LIFETIME_MS + CLOCK_SKEW_MS);
const restarted = f.create(); restarted.reconcile('demo');
f.advance(until - f.now() - 1); await restarted.sweep('demo', 'jason');
assert.equal(restarted.status('demo').serviceLeft.length, 1);
f.advance(1); await restarted.sweep('demo', 'jason');
assert.equal(restarted.status('demo').serviceLeft.length, 0);
});
// Kills removal of the approval/revocation boundary check.
test('a revoke invalidates an older unspent approval, even when no token was issued', async (t) => {
const f = await fixture(t); const decision = f.raise();
await f.manager.revoke('demo', 'coder', 'cancel approval', 'jason');
await rejectCode(f.manager.run('demo', decision), 'revoked');
assert.equal(f.spent.has(decision), false);
assert.equal(f.fake.tokens.size, 0);
});
test('verification reads every page and binds repository IDs to their configured names', async (t) => {
const f = await fixture(t);
const projects = [];
f.fake.state.repositories = [];
for (let i = 0; i < 31; i++) {
const p = 'repo' + i; projects.push(p);
f.business.projects[p] = { githubId: 1000 + i, remote: f.fake.url + '/acme/' + p };
f.fake.state.repositories.push({ id: 1000 + i, full_name: 'acme/' + p });
}
f.business.roles.coder.github.projects = projects;
f.grant.target.projects = projects;
await f.apply();
assert.deepEqual(f.fake.requests.filter((r) => r.path === '/installation/repositories').map((r) => r.query),
['?per_page=30&page=1', '?per_page=30&page=2']);
assert.equal(f.manager.status('demo').grants[0].repositoryIds.length, 31);
});
test('verification refuses wider reach and deletes the candidate', async (t) => {
const f = await fixture(t);
f.fake.intercept(async (r, answer) => {
if (r.path !== '/installation/repositories') return false;
answer(200, { total_count: 2, repositories: [{ id: 101, full_name: 'acme/app' }, { id: 102, full_name: 'acme/foreign' }] });
return true;
});
await rejectCode(f.apply(), 'github-reach-unknown');
assert.equal(f.fake.tokens.size, 0);
assert.equal(f.manager.status('demo').grants.length, 0);
});
test('token replacement is usable before old-token delete and not after grant revocation', async (t) => {
const f = await fixture(t); await f.apply();
const oldGeneration = f.manager.status('demo').grants[0].generation;
f.fake.intercept(async (r) => {
if (r.method === 'DELETE') {
const active = f.manager.status('demo').grants[0];
assert.ok(active.generation !== oldGeneration);
assert.equal(f.fake.tokens.size, 2);
}
return false;
});
f.advance(TOKEN_LIFETIME_MS - TOKEN_MARGIN_MS);
await f.manager.withToken('demo', 'coder', () => {});
assert.equal(f.fake.tokens.size, 1);
});
test('failed renewal never calls the consumer with the previous token', async (t) => {
const f = await fixture(t); await f.apply();
f.advance(TOKEN_LIFETIME_MS - TOKEN_MARGIN_MS);
f.fake.intercept(async (r, answer) => {
if (r.method === 'POST') { answer(503, {}); return true; }
return false;
});
let called = false;
await rejectCode(f.manager.withToken('demo', 'coder', () => { called = true; }), 'github-failed');
assert.equal(called, false);
});
test('admin metadata replacement invalidates an approved durable binding', async (t) => {
const f = await fixture(t); await f.apply();
await f.api.setAdmin('demo', { ...f.meta, setAt: new Date(START + 1).toISOString() }, f.fake.key, async (op, fn) => fn(), { replace: true });
f.advance(TOKEN_LIFETIME_MS);
await rejectCode(f.manager.withToken('demo', 'coder', () => {}), 'decision-mismatch');
});
test('preflight identity or permission failure is unspent and a retry can run before restart', async (t) => {
const f = await fixture(t); const decision = f.raise();
f.fake.state.permissions.contents = 'read';
await rejectCode(f.manager.run('demo', decision), 'grant-admin-scope');
assert.equal(f.spent.has(decision), false);
f.fake.state.permissions.contents = 'write';
await f.manager.run('demo', decision);
assert.equal(f.spent.has(decision), true);
});
test('an active generation restarts only under its recorded durable approval and timestamp', async (t) => {
const f = await fixture(t); await f.apply();
const restarted = f.create(); restarted.reconcile('demo');
await restarted.withToken('demo', 'coder', (token) => { assert.ok(f.fake.tokens.has(token)); });
f.advance(TOKEN_LIFETIME_MS);
f.business.projects.app.githubId = 102;
await rejectCode(restarted.withToken('demo', 'coder', () => {}), 'decision-mismatch');
});
test('look-alike applied evidence under another kind cannot make a live grant', async (t) => {
const f = await fixture(t); const decision = f.raise();
f.store.run("INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,?,'action.refused',NULL,'grant',NULL,?)",
randomUUID(), new Date(START).toISOString(), 'demo', JSON.stringify({ action: 'grant.applied', method: 'github',
agent: 'coder', grantDecision: decision, generation: randomUUID(), binding: f.approved.get(decision), expiresAt: new Date(START + TOKEN_LIFETIME_MS).toISOString() }));
assert.equal(f.manager.status('demo').grants.length, 0);
await rejectCode(f.manager.withToken('demo', 'coder', () => {}), 'github-not-granted');
});
test('mint intent and grant.call are durable before the fake sees each request', async (t) => {
const f = await fixture(t); const observed = [];
f.fake.intercept(async (r) => {
const events = f.events();
const call = events.findLast((e) => e.action === 'grant.call' && e.method === 'github' && e.op.method === r.method && e.op.path === r.path + r.query);
observed.push(Boolean(call && !events.some((e) => e.action === 'grant.result' && e.callId === call.callId)));
if (r.method === 'POST') observed.push(events.some((e) => e.action === 'grant.step' && e.step === 'mint'));
return false;
});
await f.apply();
assert.ok(observed.length >= 5);
assert.ok(observed.every(Boolean));
});
test('an interrupted service call without its result stays visible after restart', async (t) => {
const f = await fixture(t); const callId = randomUUID();
writeGrantEvent(f.store, new Date(START).toISOString(), 'demo', { action: 'grant.call', method: 'github', callId,
grantDecision: null, agent: null, generation: null, op: { method: 'GET', path: '/app' } });
const restarted = f.create(); restarted.reconcile('demo');
assert.deepEqual(restarted.status('demo').unclosedCalls.map((e) => e.callId), [callId]);
});
test('restart never adopts a known token from an interrupted mint and sweep revokes it', async (t) => {
const f = await fixture(t); const decision = f.raise(), generation = randomUUID();
const result = await f.api.mint('demo', [101], async (op, fn) => fn());
writeGrantEvent(f.store, new Date(START).toISOString(), 'demo', { operation: 'grant.github', decision });
writeGrantEvent(f.store, new Date(START).toISOString(), 'demo', { action: 'grant.step', method: 'github', step: 'mint', agent: 'coder',
grantDecision: decision, generation, until: new Date(START + CALL_TIMEOUT_MS + TOKEN_LIFETIME_MS + CLOCK_SKEW_MS).toISOString(), admin: f.approved.get(decision).admin });
githubWrite(githubDirectory(f.configDir, ['grants', 'demo', 'github', 'tokens', 'coder'], true), generation, result.json.token + '\n');
const restarted = f.create(); restarted.reconcile('demo');
assert.equal(restarted.status('demo').grants.length, 0);
await rejectCode(restarted.withToken('demo', 'coder', () => {}), 'github-not-granted');
await restarted.sweep('demo', 'restart');
assert.equal(f.fake.tokens.size, 0);
assert.equal(restarted.status('demo').serviceLeft.length, 0);
});
test('the per-agent claim refuses a second guarded use while the first callback is live', async (t) => {
const f = await fixture(t); await f.apply();
let release, entered;
const held = new Promise((r) => { entered = r; }), gate = new Promise((r) => { release = r; });
const first = f.manager.withToken('demo', 'coder', async () => { entered(); await gate; });
await held;
await rejectCode(f.manager.withToken('demo', 'coder', () => assert.fail('second consumer')), 'grant-busy');
release(); await first;
await f.manager.withToken('demo', 'coder', () => {});
});
test('N1 sweep closes an expired active generation while preserving its renewable grant', async (t) => {
const f = await fixture(t); const decision = await f.apply();
const active = f.manager.status('demo').grants[0];
const file = join(f.configDir, 'secrets/grants/demo/github/tokens/coder', active.generation);
const intent = f.events().find((e) => e.action === 'grant.step' && e.generation === active.generation);
f.advance(Date.parse(intent.until) - f.now() - 1); await f.manager.sweep('demo', 'jason');
assert.ok(existsSync(file));
f.advance(1); await f.manager.sweep('demo', 'jason');
assert.equal(existsSync(file), false);
assert.equal(f.manager.status('demo').grants[0].decision, decision);
assert.ok(f.events().some((e) => e.action === 'grant.token.closed' && e.generation === active.generation && e.reason === 'expired'));
await f.manager.withToken('demo', 'coder', (token) => assert.ok(f.fake.tokens.has(token)));
assert.ok(f.manager.status('demo').grants[0].generation !== active.generation);
});
test('mutant: closeToken must not count a 401 as proven revocation', async (t) => {
const f = await fixture(t); await f.apply();
f.fake.intercept(async (r, answer) => { if (r.method === 'DELETE') { answer(401, {}); return true; } return false; });
await f.manager.revoke('demo', 'coder', 'test', 'jason');
assert.equal(f.manager.status('demo').serviceLeft.length, 1);
assert.equal(f.fake.tokens.size, 1);
});
for (const status of [500, 503]) test(`mutant: mint ${status} is uncertain issuance and stays serviceLeft`, async (t) => {
const f = await fixture(t);
f.fake.intercept(async (r, answer) => { if (r.method === 'POST') { answer(status, {}); return true; } return false; });
await rejectCode(f.apply(), 'github-failed');
assert.equal(f.manager.status('demo').serviceLeft.length, 1);
assert.equal(f.events().some((e) => e.action === 'grant.token.closed' && e.reason === 'not-issued'), false);
});
test('mutant: shutdown abort alone prevents a pending generation from applying', async (t) => {
const f = await fixture(t); const decision = f.raise();
let release, entered;
const held = new Promise((resolve) => { entered = resolve; }), gate = new Promise((resolve) => { release = resolve; });
f.fake.intercept(async (r) => { if (r.method === 'POST') { entered(); await gate; } return false; });
const pending = f.manager.run('demo', decision);
const refused = rejectCode(pending, 'revoked');
await held;
const closed = f.manager.close(); release();
await refused; await closed;
assert.equal(f.manager.status('demo').grants.length, 0);
assert.equal(f.fake.tokens.size, 0);
});
-49
View File
@@ -46,52 +46,3 @@ test('human leaf identity is reread after walking the parents',()=>{
c.readProcess=pid=>{const p=read(pid);if(pid===50&&n++>0)p.startTime='101';return p;};
assert.throws(()=>verifyPrincipal({human:proof},c),/human-required/);
});
// Row 252 (#1753, design section 2, J3-J5): the Console owner. The same-tree console-cli wrapper with
// its own nonce, a direct same-tree `serve` parent, the subject from the wrapper's spawn environment,
// and that subject one of the configured owners (N1).
const owner={provider:'fake-oidc',iss:'https://idp.test',sub:'owner-1'};
function consoleContext() {
const c=context('user');c.paths={...paths,consoleCli:'/tree/console-cli.mjs'};c.consoleOwners=[{provider:'other',iss:'https://idp.test',sub:'owner-2'},owner];
Object.assign(c.table.get(50),{argv:['node','/tree/console-cli.mjs'],env:{MOSAIC_BUS_CONSOLE_NONCE:nonce,MOSAIC_BUS_CONSOLE_BUSINESS:'demo',MOSAIC_BUS_CONSOLE_PROVIDER:owner.provider,MOSAIC_BUS_CONSOLE_ISS:owner.iss,MOSAIC_BUS_CONSOLE_SUB:owner.sub}});
return c;
}
const consoleProof={...proof,...owner};
test('console requires its own wrapper, nonce and serve parent, and a configured owner from the spawn env',()=>{
assert.deepEqual(verifyPrincipal({console:consoleProof},consoleContext()),{kind:'console',business:'demo',id:'owner',via:'console',subject:owner});
// J3: the subject is the spawn's, never the body's alone, and must be a configured owner.
for(const change of [{sub:'owner-2'},{iss:'https://evil.test'},{provider:'other'}])
assert.throws(()=>verifyPrincipal({console:{...consoleProof,...change}},consoleContext()),/human-required/);
const unlisted=consoleContext();unlisted.consoleOwners=[];
assert.throws(()=>verifyPrincipal({console:consoleProof},unlisted),/human-required/);
for(const extra of [{name:'Jason'},{human:'owner'},{user:'owner'},{sub:''},{sub:'x'.repeat(513)}])
assert.throws(()=>verifyPrincipal({console:{...consoleProof,...extra}},consoleContext()),/human-required/);
// J4: the parent is the same-tree serve, direct, and not an agent.
for(const change of [{argv:['bash']},{argv:['node','/other/console.mjs','serve']},{argv:['node',paths.console,'other']},{env:{CODEX_THREAD_ID:'agent'}},{uid:uid+1}]) {
const c=consoleContext();Object.assign(c.table.get(40),change);
assert.throws(()=>verifyPrincipal({console:consoleProof},c),/human-required/);
}
const launched=consoleContext();launched.launches=[{pid:40,startTime:'90'}];
assert.throws(()=>verifyPrincipal({console:consoleProof},launched),/human-required/);
// J5: another nonce namespace, or the wrapper path of another kind, refuses.
for(const env of [{MOSAIC_BUS_CLI_NONCE:nonce},{MOSAIC_BUS_USER_NONCE:nonce}]) {
const c=consoleContext();const t=c.table.get(50);t.env={...t.env,...env};delete t.env.MOSAIC_BUS_CONSOLE_NONCE;
assert.throws(()=>verifyPrincipal({console:consoleProof},c),/human-required/);
}
for(const argv of [['node',paths.human],['node',paths.user],['node','/other/console-cli.mjs']]) {
const c=consoleContext();c.table.get(50).argv=argv;
assert.throws(()=>verifyPrincipal({console:consoleProof},c),/human-required/);
}
// The Console's own nonce does not answer as the cli human or the user.
assert.throws(()=>verifyPrincipal({human:proof},consoleContext()),/human-required/);
assert.throws(()=>verifyPrincipal({user:{...proof,user:'owner'}},consoleContext()),/human-required/);
// Each piece is read again after the walk: a parent or a spawn subject that changed refuses.
for(const [pid,mutate] of [[40,p=>{p.startTime='91';}],[50,p=>{p.env={...p.env,MOSAIC_BUS_CONSOLE_SUB:'owner-2'};}],[50,p=>{p.ppid=41;}]]) {
const c=consoleContext(),read=c.readProcess;let n=0;
// The last read of each pid is verifyConsole's own reread: 40 is read 3 times, 50 is read 4 times.
c.readProcess=x=>{const p=read(x);if(x===pid&&n++===(pid===40?2:3))mutate(p);return p;};
assert.throws(()=>verifyPrincipal({console:consoleProof},c),/human-required/);
}
for(const peer of [{pid:51,uid,gid:uid},{pid:50,uid:uid+1,gid:uid}])assert.throws(()=>verifyPrincipal({console:consoleProof},{...consoleContext(),peer}),/human-required/);
assert.throws(()=>verifyPrincipal({console:consoleProof,human:proof},consoleContext()),/invalid-envelope/);
});
+1 -1
View File
@@ -45,7 +45,7 @@ test('requirements returns the committed list to a holding role session, and no
const pm = agent('pm');
const list = call(b, pm, 'requirements');
assert.deepEqual(list, loadRequirements());
assert.equal(list.requirements.length, 87);
assert.equal(list.requirements.length, 85);
assert.deepEqual(call(b, agent('coder'), 'requirements'), list);
assert.throws(() => call(b, human, 'requirements'), /agent-required/);
assert.throws(() => call(b, reader, 'requirements'), /read-only/);
+4 -18
View File
@@ -129,7 +129,7 @@ function checkRoles(roles, file, rolesDir, business) {
requireId(instance, `${where} instance name`);
const at = `${where}.${instance}`;
requireObject(entry, at);
rejectUnknownKeys(entry, ["definition", "holder", "vars", "tracker", "credentials", "identity", "publish", "hosts", "github"], at);
rejectUnknownKeys(entry, ["definition", "holder", "vars", "tracker", "credentials", "identity", "publish", "hosts"], at);
requireId(entry.definition, `${at}.definition`);
const definition = Object.hasOwn(definitions, entry.definition) ? definitions[entry.definition] : loadRole(rolesDir, entry.definition);
definitions[entry.definition] = definition;
@@ -137,7 +137,7 @@ function checkRoles(roles, file, rolesDir, business) {
const services = definition.credentials.map((c) => c.service);
if (entry.hosts !== undefined) refuse(`${at}.hosts ${HOSTS_MOVED}`);
// A host.<id> key reference comes from the agent's grant record at bus start (r6 X4), never from here.
const kinds = services.filter((s) => s !== HOST_SERVICE && s !== "github");
const kinds = services.filter((s) => s !== HOST_SERVICE);
let tracker = null;
if (services.includes("vikunja")) {
if (entry.tracker === undefined) refuse(`${at} needs "tracker" with its Vikunja bot, because ${entry.definition} uses vikunja`);
@@ -166,14 +166,6 @@ function checkRoles(roles, file, rolesDir, business) {
if (!out[instance].identity || !services.includes("gitea")) refuse(`${at}.publish needs identity and a gitea definition`);
out[instance].publish = requireDistinctList(entry.publish, `${at}.publish`, (p) => requireId(p, `${at}.publish entry`), { nonEmpty: true });
}
// Row297: App grants have fixed scopes and protected token storage, not a generic credential ref.
if (entry.github !== undefined) {
if (!services.includes("github")) refuse(`${at}.github needs a github definition`);
requireObject(entry.github, `${at}.github`);
rejectUnknownKeys(entry.github, ["projects"], `${at}.github`);
out[instance].github = { projects: requireDistinctList(entry.github.projects, `${at}.github.projects`,
(p) => requireId(p, `${at}.github.projects entry`), { nonEmpty: true }) };
}
checkPersonaVars(out[instance].vars, entry.definition, at);
}
return { roles: out, definitions };
@@ -275,7 +267,7 @@ export function validateBusinessDocument(document, file, { rolesDir }) {
projects[id] = checkMapping(entry, `${file} projects.${id}`, { fail: (code, message) => refuse(`${code}: ${message}`) });
continue;
}
rejectUnknownKeys(entry, ["root", "repo", "remote", "githubId"], `${file} projects.${id}`);
rejectUnknownKeys(entry, ["root", "repo", "remote"], `${file} projects.${id}`);
if (typeof entry.root !== "string" || !isAbsolute(entry.root) || normalize(entry.root) !== entry.root || entry.root.includes("\0")) {
refuse(`${file} projects.${id}.root must be a normalized absolute path`);
}
@@ -285,16 +277,10 @@ export function validateBusinessDocument(document, file, { rolesDir }) {
}
// Row 199 (#1697, design D9 step 2): the upstream; a granted agent's work goes to its fork.
if (Object.hasOwn(entry, "remote")) httpsUrl(entry.remote, `${file} projects.${id}.remote`, { repo: true });
if (entry.githubId !== undefined) requirePositiveInt(entry.githubId, `${file} projects.${id}.githubId`);
projects[id] = { root: entry.root, ...(entry.githubId !== undefined ? { githubId: entry.githubId } : {}), ...(Object.hasOwn(entry, "repo") ? { repo: entry.repo } : {}),
projects[id] = { root: entry.root, ...(Object.hasOwn(entry, "repo") ? { repo: entry.repo } : {}),
...(Object.hasOwn(entry, "remote") ? { remote: entry.remote } : {}) };
}
for (const [instance, role] of Object.entries(roles)) {
for (const project of role.github?.projects ?? []) {
const p = projects[project];
if (!p || p.kind === 'library' || !p.remote || !Number.isSafeInteger(p.githubId) || p.githubId <= 0)
refuse(`${file} roles.${instance}.github names ${project}, which needs a repository remote and githubId`);
}
for (const project of role.publish ?? []) {
if (!Object.hasOwn(projects, project)) refuse(`${file} roles.${instance}.publish names ${project}, which the business file doesn't declare`);
if (projects[project].kind === 'library') refuse(`${file} roles.${instance}.publish names library project ${project}; library repositories cannot be published by agents`);
+1 -1
View File
@@ -108,7 +108,7 @@ function definitionFor(rolesDir, name, { credentials = false } = {}) {
// Vikunja bot the human names. The add makes no Vikunja account. Any other definition refuses it.
function createdCredentials(old, instance, definition, args) {
// webby-0094 N2: a host grant is no token reference (business.mjs checkRoles drops it too).
const services = definition.credentials.map(c => c.service).filter(service => service !== HOST_SERVICE && service !== "github");
const services = definition.credentials.map(c => c.service).filter(service => service !== HOST_SERVICE);
const out = {};
if (services.includes("vikunja")) {
if (!Object.hasOwn(args, "tracker")) {
+3 -5
View File
@@ -131,15 +131,13 @@ function checkCredentials(credentials) {
requireObject(entry, `role credentials[${i}]`);
// Row 210 (#1707, design D1): "host" takes no scopes; the business file names the hosts.
const host = entry.service === HOST_SERVICE;
const github = entry.service === "github";
rejectUnknownKeys(entry, (host || github) ? ["service"] : ["service", "scopes"], `role credentials[${i}]`);
if (!host && !github && !SERVICES.includes(entry.service)) {
refuse(`role credentials[${i}].service must be one of: ${[...SERVICES, HOST_SERVICE, "github"].join(", ")}`);
rejectUnknownKeys(entry, host ? ["service"] : ["service", "scopes"], `role credentials[${i}]`);
if (!host && !SERVICES.includes(entry.service)) {
refuse(`role credentials[${i}].service must be one of: ${[...SERVICES, HOST_SERVICE].join(", ")}`);
}
if (seen.has(entry.service)) refuse(`role credentials name ${entry.service} twice`);
seen.add(entry.service);
if (host) return { service: HOST_SERVICE };
if (github) return { service: "github" };
const scopes = entry.service === "gitea" ? checkGiteaScopes(entry.scopes) : checkVikunjaScopes(entry.scopes);
return { service: entry.service, scopes };
});
-73
View File
@@ -1,73 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { loadRole, validateBusinessDocument } from '../src/index.mjs';
import { instanceSettings } from '../src/instance-settings.mjs';
import { businessDoc, rolesCopy, systemFor, tmp, writeJson } from './helpers.mjs';
function fixture(t) {
const root = tmp(); t.after(() => rmSync(root, { recursive: true, force: true }));
const rolesDir = rolesCopy(root);
const file = join(rolesDir, 'coder.json');
const definition = JSON.parse(readFileSync(file, 'utf8'));
definition.credentials.push({ service: 'github' });
writeJson(file, definition);
const doc = businessDoc(root);
doc.roles.coder.github = { projects: ['stack'] };
doc.projects.stack.remote = 'https://github.com/acme/stack';
doc.projects.stack.githubId = 101;
const validate = () => validateBusinessDocument(doc, join(root, 'businesses/acme.json'), { rolesDir });
return { root, rolesDir, file, definition, doc, validate };
}
test('GitHub fixed declaration and repository binding load without generic token references', (t) => {
const f = fixture(t);
assert.deepEqual(loadRole(f.rolesDir, 'coder').credentials.at(-1), { service: 'github' });
const b = f.validate();
assert.deepEqual(b.roles.coder.github, { projects: ['stack'] });
assert.equal(b.projects.stack.githubId, 101);
assert.equal(Object.hasOwn(b.roles.coder.credentials, 'github'), false);
});
test('GitHub declaration refuses caller scopes and role account/fork requests', (t) => {
const f = fixture(t);
f.definition.credentials.at(-1).scopes = ['all']; writeJson(f.file, f.definition);
assert.throws(() => loadRole(f.rolesDir, 'coder'), /unsupported.*scopes/);
delete f.definition.credentials.at(-1).scopes; writeJson(f.file, f.definition);
for (const field of ['account', 'fork']) {
f.doc.roles.coder.github[field] = true;
assert.throws(f.validate, new RegExp('unsupported.*' + field));
delete f.doc.roles.coder.github[field];
}
});
test('GitHub bindings refuse absent declaration, unknown project and missing repository ID', (t) => {
const f = fixture(t);
delete f.doc.projects.stack.githubId;
assert.throws(f.validate, /githubId/);
f.doc.projects.stack.githubId = 101;
f.doc.roles.coder.github.projects = ['missing'];
assert.throws(f.validate, /github.*missing/);
f.doc.roles.coder.github.projects = ['stack'];
f.definition.credentials.pop(); writeJson(f.file, f.definition);
assert.throws(f.validate, /github.*definition/);
});
// mos-1518: preserve closed-service-list coverage when github becomes a fixed declaration.
test('a service outside the closed list still refuses with service must be one of', (t) => {
const f = fixture(t); f.definition.credentials.at(-1).service = 'not-a-service';
writeJson(f.file, f.definition);
assert.throws(() => loadRole(f.rolesDir, 'coder'), /service must be one of/);
});
test('human add of a GitHub-declaring definition creates only generic service references', (t) => {
const f = fixture(t), dir = join(f.root, 'config');
const file = writeJson(join(dir, 'businesses/acme.json'), f.doc);
const settings = instanceSettings({ dir, rolesDir: f.rolesDir, system: systemFor(f.root) });
settings.set('acme', { revision: settings.read('acme').revision, instance: 'coder-two', op: 'add', definition: 'coder',
tracker: { bot: 'bot-acme-coder-two', botId: 40 } }, 'jason');
const entry = JSON.parse(readFileSync(file, 'utf8')).roles['coder-two'];
assert.deepEqual(Object.keys(entry.credentials).sort(), ['gitea', 'vikunja']);
assert.equal(Object.hasOwn(entry, 'github'), false);
});
+1 -1
View File
@@ -206,7 +206,7 @@ test("credentials: Vikunja scopes are a group-to-verbs map from the grantable li
});
test("credentials: services", () => {
refuses(() => check({ credentials: [{ service: "github", scopes: [] }] }), /unsupported.*scopes/);
refuses(() => check({ credentials: [{ service: "github", scopes: [] }] }), /service must be one of/);
refuses(() => check({ credentials: [{ service: "gitea", scopes: ["read:issue"] }, { service: "gitea", scopes: ["read:user"] }] }), /twice/);
refuses(() => check({ credentials: [{ service: "gitea", scopes: ["read:issue"], token: "x" }] }), /unsupported role credentials\[0\] key/);
assert.deepEqual(check({ credentials: [] }).credentials, []);
-6
View File
@@ -115,9 +115,6 @@ function accessFor(system, env, warn) {
return { file, context };
}
export const consoleOwners = (sso) =>
sso.providers.flatMap((p) => p.subjects.map((s) => ({ provider: p.id, iss: p.issuer, sub: s.sub })));
// The `{op: 'boot'}` config for packages/bus/src/process.mjs, for one
// business. The host holds a reader capability for the notifier and binds
// launches later through bindLaunch, so `launches` starts empty.
@@ -138,9 +135,6 @@ export function bootConfig({ system, businessId, env = process.env, warn = () =>
if (tracker) config.trackers = { [b.id]: tracker };
const access = accessFor(system, env, warn);
if (access) config.access = access;
// Row 252 (#1753, Webby N1): every console.sso subject is an owner, by provider id, issuer and sub.
// The loader checked console.sso; no new config field names owners.
if (system.console?.sso) config.consoleOwners = consoleOwners(system.console.sso);
return config;
});
}
-43
View File
@@ -1,43 +0,0 @@
// Row297 CLI shapes. Only the broker parses/uses the RSA private key.
import { CliError } from './errors.mjs';
const USAGE = 'mosaic grants status|sweep github [--json] [--tester] | revoke AGENT github --reason R | admin set github --url HTTPS_ORIGIN --app-id ID --installation-id ID --account-id ID --rotate-by DATE | admin rotate github --rotate-by DATE | admin remove github';
const SHAPES = {
status: { n: 1, flags: ['--json', '--tester'], need: [] },
sweep: { n: 1, flags: ['--tester'], need: [] },
revoke: { n: 2, flags: ['--tester'], need: ['--reason'] },
'admin set': { n: 1, flags: [], need: ['--url', '--app-id', '--installation-id', '--account-id', '--rotate-by'] },
'admin rotate': { n: 1, flags: [], need: ['--rotate-by'] },
'admin remove': { n: 1, flags: [], need: [] },
};
const usage = () => { throw new CliError(USAGE, 4); };
async function stdinKey(stdin) {
let text = '';
for await (const chunk of stdin) { text += chunk; if (text.length > 16384) throw new CliError('refused: App key on stdin is too long', 2); }
if (!text.trim()) throw new CliError('refused: no App key on stdin', 4);
return text;
}
const positive = (text) => {
if (typeof text !== 'string' || !/^[1-9][0-9]*$/.test(text) || !Number.isSafeInteger(Number(text))) usage();
return Number(text);
};
function format(status) {
const lines = [status.admin ? `admin: App ${status.admin.appId}, installation ${status.admin.installationId}, account ${status.admin.accountId} on ${status.admin.url}, rotate by ${status.admin.rotateBy}` : 'admin: none'];
for (const g of status.grants) lines.push(`grant\t${g.agent}\t${g.projects.join(',')}\texpires ${g.expiresAt}\trotate by ${g.rotateBy}\t${g.decision}`);
for (const g of status.serviceLeft) lines.push(`left\t${g.agent}\t${g.generation}\tuntil ${g.until}`);
return lines.join('\n') + '\n';
}
export async function githubGrantsCommand(parsed, verb, rest, io, callFor) {
const shape = Object.hasOwn(SHAPES, verb) ? SHAPES[verb] : null;
const v = parsed.values;
if (!shape || rest.length !== shape.n || [...parsed.flags].some((f) => !shape.flags.includes(f)) ||
Object.keys(v).some((k) => k !== '--business' && !shape.need.includes(k)) || shape.need.some((k) => !Object.hasOwn(v, k))) usage();
let args = { service: 'github' };
if (verb === 'revoke') args = { ...args, agent: rest[0], reason: v['--reason'] };
if (verb === 'admin set') args = { ...args, url: v['--url'], appId: positive(v['--app-id']), installationId: positive(v['--installation-id']), accountId: positive(v['--account-id']), rotateBy: v['--rotate-by'] };
if (verb === 'admin rotate') args.rotateBy = v['--rotate-by'];
if (verb === 'admin set' || verb === 'admin rotate') args.token = await stdinKey(io.stdin);
try {
const result = await callFor(parsed)('grants.' + verb.replace(' ', '.'), args);
io.stdout.write(verb === 'status' && !parsed.flags.has('--json') ? format(result) : JSON.stringify(result, null, 2) + '\n');
} catch (e) { if (e instanceof CliError) throw e; throw new CliError(e.message, 2); }
}
+2 -5
View File
@@ -4,16 +4,14 @@
// and sweep the human's or, with --tester, the tester's. admin set reads the admin token on stdin, never
// argv, and nothing here prints a token: the answers carry ids and names only.
import { CliError } from "./errors.mjs";
import { githubGrantsCommand } from "./github-grants.mjs";
export const GRANTS_USAGE =
"mosaic grants status gitea [--json] [--tester] [--business ID] | run DECISION [--business ID] | " +
"revoke AGENT gitea --reason R [--tester] [--business ID] | sweep gitea [--tester] [--business ID] | " +
"admin set gitea --url URL --account NAME --rotate-by DATE [--business ID] | " +
"admin rotate gitea --rotate-by DATE | admin rotate gitea --finish | admin remove gitea | " +
"status|sweep github | revoke AGENT github --reason R | admin set github --url URL --app-id ID --installation-id ID --account-id ID --rotate-by DATE | admin rotate github --rotate-by DATE | admin remove github";
"admin rotate gitea --rotate-by DATE | admin rotate gitea --finish | admin remove gitea";
const MAX_TOKEN = 16384;
const VALUES = ["--business", "--reason", "--url", "--account", "--rotate-by", "--app-id", "--installation-id", "--account-id"];
const VALUES = ["--business", "--reason", "--url", "--account", "--rotate-by"];
function parse(args) {
const out = { positional: [], flags: new Set(), values: {} };
@@ -74,7 +72,6 @@ export async function grantsCommand(args, io, callFor) {
verb = `admin ${rest[0] ?? ""}`;
rest = rest.slice(1);
}
if (verb !== "run" && rest.at(-1) === "github") return githubGrantsCommand(parsed, verb, rest, io, callFor);
// Own keys only, so `constructor` is a usage error, not a verb.
const shape = Object.hasOwn(SHAPES, verb ?? "") ? SHAPES[verb] : null;
if (
+1 -1
View File
@@ -14,7 +14,7 @@ import { accessContextFor } from "../src/access.mjs";
import { bootConfig, loadSystem } from "../src/config.mjs";
import { fixture, tmp } from "./helpers.mjs";
const SSO = { sso: { providers: [{ id: "authentik", issuer: "https://idp.test", subjects: [{ sub: "jason-sub" }] }] } };
const SSO = { sso: { providers: [{ id: "authentik", subjects: [{ sub: "jason-sub" }] }] } };
const FEDERATION = { self: "hub", peers: [{ id: "factory", origin: "https://factory.example.test", label: "Software factory" }] };
const DOC = {
accessVersion: 1,
-16
View File
@@ -69,19 +69,3 @@ test("an unknown business and a broken system config refuse with exit 3", (t) =>
assert.throws(() => bootConfig({ system: loadSystem({ env: f.env }), businessId: "nope", env: f.env }), (e) => e.exitCode === 3);
assert.throws(() => loadSystem({ env: { ...f.env, MOSAIC_CONFIG: join(f.dataRoot, "missing.json") } }), (e) => e.exitCode === 3);
});
test("Row 252 (Webby N1): every console.sso subject reaches the boot as a console owner, and no sso means no consoleOwners key", (t) => {
const f = fixture(tmp(t));
const system = loadSystem({ env: f.env });
assert.equal("consoleOwners" in bootConfig({ system, businessId: "acme", env: f.env }), false);
const sso = { providers: [
{ id: "authentik", issuer: "https://auth.example/app/", subjects: [{ sub: "jason-sub" }, { sub: "second-sub" }] },
{ id: "other", issuer: "https://idp.example", subjects: [{ sub: "jason-sub" }] },
] };
const boot = bootConfig({ system: { ...system, console: { sso } }, businessId: "acme", env: f.env });
assert.deepEqual(boot.consoleOwners, [
{ provider: "authentik", iss: "https://auth.example/app/", sub: "jason-sub" },
{ provider: "authentik", iss: "https://auth.example/app/", sub: "second-sub" },
{ provider: "other", iss: "https://idp.example", sub: "jason-sub" },
]);
});
-42
View File
@@ -1,42 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { main } from '../src/cli.mjs';
import { io } from './helpers.mjs';
const setup = () => {
const calls = [];
const transport = () => async (verb, args) => { calls.push({ verb, args }); return { grants: [], serviceLeft: [], admin: null }; };
return { calls, deps: { system: { dataRoot: '/nonexistent' }, transport, testerTransport: transport } };
};
test('GitHub admin set uses separate identity flags and bounded stdin', async () => {
const f = setup(), x = io(); const fakeKey = '-----BEGIN PRIVATE KEY-----\nfake-only-not-a-real-key\n-----END PRIVATE KEY-----\n';
x.stdin.end(fakeKey);
await main(['grants', 'admin', 'set', 'github', '--business', 'demo', '--url', 'https://api.github.com',
'--app-id', '10', '--installation-id', '20', '--account-id', '30', '--rotate-by', '2099-01-01'], x, f.deps);
assert.equal(f.calls.length, 1);
assert.deepEqual(f.calls[0], { verb: 'grants.admin.set', args: { service: 'github', url: 'https://api.github.com',
appId: 10, installationId: 20, accountId: 30, rotateBy: '2099-01-01', token: fakeKey } });
assert.ok(!x.out.text.includes(fakeKey) && !x.err.text.includes(fakeKey));
});
test('GitHub rotate replaces the human-supplied key rather than Gitea token minting', async () => {
const f = setup(), x = io(); x.stdin.end('fake-only-key-value');
await main(['grants', 'admin', 'rotate', 'github', '--business', 'demo', '--rotate-by', '2099-01-01'], x, f.deps);
assert.equal(f.calls[0].verb, 'grants.admin.rotate');
assert.equal(f.calls[0].args.service, 'github');
assert.equal(f.calls[0].args.rotateBy, '2099-01-01');
});
test('GitHub management dispatch and malformed flag shapes', async () => {
const f = setup();
await main(['grants', 'status', 'github', '--business', 'demo', '--json'], io(), f.deps);
await main(['grants', 'revoke', 'coder', 'github', '--business', 'demo', '--reason', 'test'], io(), f.deps);
await main(['grants', 'sweep', 'github', '--business', 'demo'], io(), f.deps);
assert.deepEqual(f.calls.map((c) => c.verb), ['grants.status', 'grants.revoke', 'grants.sweep']);
for (const args of [
['admin', 'set', 'github', '--account', '20'], ['admin', 'rotate', 'github', '--finish'],
['status', 'github', '--app-id', '10'], ['admin', 'remove', 'github', '--tester'],
['admin', 'set', 'github', '--url', 'https://api.github.com', '--app-id', 'NaN', '--installation-id', '20', '--account-id', '30', '--rotate-by', '2099-01-01'],
]) await assert.rejects(main(['grants', ...args, '--business', 'demo'], io(), f.deps), (e) => e.exitCode === 4);
assert.equal(f.calls.length, 3);
});
+1 -1
View File
@@ -142,7 +142,7 @@ test('real pinned install loads bus and Console; same-copy mosaic talk passes pr
host.stdout.on('data',c=>hostOut+=c);host.stderr.on('data',c=>hostErr+=c);
try{await until(()=>hostOut.includes('bus host up')||host.exitCode!==null);assert.match(hostOut,/bus host up/,hostErr);}
finally{if(host.exitCode===null&&host.signalCode===null){const done=once(host,'close');host.kill('SIGTERM');await done;}}
const web=spawn(process.execPath,[join(root,'packages/webui/src/cli.mjs'),'serve','--port','0','--business','acme'],{env,stdio:['ignore','pipe','pipe']});let webOut='',webErr='';
const web=spawn(process.execPath,[join(root,'packages/webui/src/cli.mjs'),'serve','--port','0','--business','acme','--board','http://127.0.0.1:9'],{env,stdio:['ignore','pipe','pipe']});let webOut='',webErr='';
web.stdout.on('data',c=>webOut+=c);web.stderr.on('data',c=>webErr+=c);
try{await until(()=>/Mosaic Console: http:/.test(webOut)||web.exitCode!==null);const port=webOut.match(/127\.0\.0\.1:(\d+)/)?.[1];assert.ok(port,webErr);assert.equal((await fetch(`http://127.0.0.1:${port}/healthz`)).status,200);}
finally{if(web.exitCode===null&&web.signalCode===null){const done=once(web,'close');web.kill();await done;}}
-58
View File
@@ -851,64 +851,6 @@ test('T44 row 180 item 2: --plan lists tester-port-invalid and tester-port-targe
});
});
// Row 198 (design item 8, B2; sage-2834 ruling a; sage-2837): --board is dropped for every op, because
// webui-service.sh install refuses the flag. The reason follows the target's cli.mjs: a target that
// parses --board is pre-198 (7331 retired); a target that names it only in its refusal runs the board
// in the Console. The plan names each drop. setCli rewrites a shared copy's cli.mjs and restores it after.
const BOARD='--board http://127.0.0.1:9';
const REFUSES_BOARD=`// fixture Console, never run\n// serve accepts '--tester-port'\nif (flag === '--board') throw new Error('--board retired: the board runs in the Console (row 198)');\n`;
const PARSES_BOARD=`// fixture Console, never run\n// serve accepts '--tester-port'\nelse if (flag === '--board') board = value;\n`;
const setCli=(t,id,text)=>{const f=join(COPIES,id,'packages/webui/src/cli.mjs'),orig=readFileSync(f,'utf8');chmodSync(f,0o644);writeFileSync(f,text);t.after(()=>{chmodSync(f,0o644);writeFileSync(f,orig);chmodSync(f,0o444)})};
const withBoard=(e)=>{const q=pointer(e);q.console=[...q.console,...BOARD.split(' ')];writeFileSync(e.pointer,JSON.stringify(q)+'\n')};
test('row 198 item 8 (M12): a re-pin plan drops a --board the target Console does not parse, and its Console line has none',async(t)=>{
const e=await mk(t);await seed(e);setCli(t,C1,REFUSES_BOARD);withBoard(e);
const before=fp(e),n=calls(e).length,logs=logLines(e).length;
const r=await run(e,'activate',C1,'acme','--plan');
assert.equal(r.code,0,r.out+r.err);assert.equal(r.err,'');
assert.ok(r.out.split('\n').includes(`drop ${BOARD}; the target Console does not parse it, so the board runs in the Console`),r.out);
assert.doesNotMatch(r.out.split('\n').find((l)=>l.startsWith('Console: ')),/--board/);
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
});
test('row 198 item 8 (M13, activate): an activate to a pre-198 copy that parses --board drops the unit\'s --board, and the install and unit have none',async(t)=>{
// Sage sage-2837: a target that parses --board is pre-198, and webui-service.sh install still refuses the flag.
// The unit carries the flag before, as a pre-198 Console does. The plan is checked first, then a real run.
const e=await mk(t);await seed(e);setCli(t,C1,PARSES_BOARD);
edit(join(e.units,CON),'--business acme',`--business acme ${BOARD}`);withBoard(e);
const before=fp(e),n=calls(e).length,logs=logLines(e).length;
let r=await run(e,'activate',C1,'acme','--plan');
assert.equal(r.code,0,r.out+r.err);assert.equal(r.err,'');
const lines=r.out.split('\n');
assert.ok(lines.includes(`drop ${BOARD}; the 7331 board is retired, so this Console runs without the board`),r.out);
assert.doesNotMatch(lines.find((l)=>l.startsWith('Console: ')),/--board/);
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
r=await run(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
assert.ok(r.out.split('\n').includes(`drop ${BOARD}; the 7331 board is retired, so this Console runs without the board`),r.out);
assert.equal(pointer(e).copy,C1);assert.equal(pointer(e).console.includes('--board'),false);
assert.doesNotMatch(consoleText(e),/--board/);
});
test('row 198 item 8 (M13, rollback): a rollback drops the unit\'s --board even for a copy that parses it, and the install and unit have none',async(t)=>{
// Sage sage-2834 ruling (a), sage-2837: webui-service.sh install refuses --board, so a rollback drops it and
// names the drop with the retired-board reason. The activation runs without --board; the unit then carries it,
// and the rollback target B parses it, which must not keep the flag. The plan is checked first, then a real run.
const e=await mk(t);setCli(t,B,PARSES_BOARD);
await seed(e);
let r=await run(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
edit(join(e.units,CON),'--business acme',`--business acme ${BOARD}`);withBoard(e);
const before=fp(e);
r=await run(e,'rollback','acme','--plan');assert.equal(r.code,0,r.out+r.err);
const lines=r.out.split('\n');
assert.ok(lines.includes(`drop ${BOARD}; the 7331 board is retired, so this Console runs without the board`),r.out);
assert.doesNotMatch(lines.find((l)=>l.startsWith('Console: ')),/--board/);
assert.deepEqual(fp(e),before);
r=await run(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);
assert.equal(pointer(e).copy,B);assert.equal(pointer(e).console.includes('--board'),false);
assert.doesNotMatch(consoleText(e),/--board/);
assert.match(stepText(e),/drop --board http:\/\/127\.0\.0\.1:9; the 7331 board is retired/);
});
// Row 181 (#1679, P19 390): COUNTED and STORE_JS as the script under test has them. The fixture
// tables carry only seq and at, which is all the count reads.
const srText=readFileSync(SR,'utf8');
+12 -17
View File
@@ -100,22 +100,14 @@ service transitions or authorize replacing a running board.
```
node src/cli.mjs scan [--config PATH] [--repo PATH] [--fleet PATH|none] [--liveness tmux|assume-alive] [--print]
node src/cli.mjs serve [same flags] [--port N] [--host 127.0.0.1]
```
`scan` runs once and writes the status files.
`serve` is retired (row 198). It exits 2 with a pointer to the Console. The
board now runs in-process inside the Console (`packages/webui`), behind the
Console's own sign-in. Start it with:
```
node packages/webui/src/cli.mjs serve
```
Then open `http://127.0.0.1:7330/` in a browser. The board's routes are
`/api/board` and the two writes `/api/seen` and `/api/reply`, which need
`--send` on the public name. The page fetches `/api/board` every 10 seconds;
each fetch re-runs the scan, so the page is never staler than that timer.
`scan` runs once and writes the status files. `serve` starts a small local
web server: open `http://127.0.0.1:7331/` in a browser. The page fetches
`/api/board` every 10 seconds; each fetch re-runs the scan, so the page is
never staler than that timer. There is no login, so the server refuses to
bind to anything but a loopback address.
- `--config PATH` — path to the system config file. Defaults to
`~/.config/mosaic-dev/config.json`. This file must exist and name an
@@ -132,8 +124,11 @@ each fetch re-runs the scan, so the page is never staler than that timer.
first column is `*` for a row waiting on you, `s` for a seen row, or blank
otherwise. The final summary line reads
`board: <dir> (N sessions, N waiting on you, N seen)`.
Routes (row 198: served by the Console, `packages/webui`, not by a board port;
the bodies and refusals below are unchanged):
- `--port N` — (`serve`) port to listen on. Default `7331`; `0` picks a free port.
- `--host ADDR` — (`serve`) loopback address to bind. Default `127.0.0.1`.
Any non-loopback address is refused.
Routes served:
- `GET /` — the page.
- `GET /api/board` — rescan, returns `index.json`.
@@ -175,7 +170,7 @@ so the seat's reaction shows without waiting for the next tick.
## Exit codes
- `0` — scan completed and status files were written.
- `0` — scan completed and status files were written, or the server stopped cleanly.
- `2` — refused: bad config, missing/invalid `dataRoot`, or bad arguments.
The message on stderr says why.
-78
View File
@@ -1,78 +0,0 @@
// The control board's routes as plain async functions (row 198). The Console
// (packages/webui) calls these in-process behind its own auth. Nothing here
// opens a socket or binds a port: the 7331 writer is retired.
//
// Every method returns a promise and uses the async child-process paths (B1), so
// a slow tmux call never blocks the Console's event loop.
//
// Write counter (decision 10, Webby N1). A scan must not serve a pre-write view.
// index() joins an in-flight scan only when that scan started at the current
// counter value. markSeen() and reply() bump the counter and never join a scan.
import { scanAsync, markSeen as writeMark, loadRegistrations, runAsync } from "./scan.mjs";
import { replyToRowAsync, conversationResponse, DEFAULT_AGENT_SEND } from "./serve.mjs";
import { createReader, rootsFromSpecs } from "../../conversation/src/reader.mjs";
// onEvent(name) reports "write", "bump", "send" and "scan" in order. Production
// passes nothing; the order test (board.test.mjs) reads it to pin the sequence.
export function createBoard({ specs, boardDir, seatsDir = null, discordDataRoot = null, isAlive, isPidAlive, now = () => new Date(), agentSend = DEFAULT_AGENT_SEND, exec = runAsync, conversationReader = null, host, onEvent = () => {} }) {
let writes = 0;
let inflight = null;
const scanFresh = () => {
onEvent("scan");
return scanAsync(specs, { isAlive, isPidAlive, exec, boardDir, now, seatsDir, discordDataRoot });
};
const reader = conversationReader ?? createReader({ roots: () => rootsFromSpecs(specs, loadRegistrations(seatsDir).registrations) });
// Joins a scan only when it started after the last write and is still running
// (see header). A settled scan, fulfilled or rejected, is cleared so the next
// index() starts a fresh one; the clear is on both branches, so a rejection is
// not left unhandled.
function index() {
if (inflight && inflight.generation === writes) return inflight.promise;
const generation = writes;
const promise = scanFresh();
const current = { generation, promise };
inflight = current;
const clear = () => { if (inflight === current) inflight = null; };
promise.then(clear, clear);
return promise;
}
// Pinned order (Webby, Mos mos-1052): write, then bump the counter, then scan.
// The scan sees the mark, and no scan started before the bump can be joined.
async function markSeenRoute(body) {
writeMark(boardDir, { project: body.project, agent: body.agent, lastActivity: body.lastActivity, seen: body.seen ?? true });
onEvent("write");
writes++;
onEvent("bump");
return scanFresh();
}
// Pinned order: send, then bump the counter, then scan. The planning scan
// before the send is a read and is not part of the pinned sequence. A refusal
// runs no send, so it writes nothing: no bump and no post-send scan. Only a
// send that reached the exec runs the rest. The post-send scan rewrites the
// derived board files, so a reply's effects reach them the same way a mark does.
async function reply(body) {
const planned = await scanFresh();
let sent = false;
const sendExec = (...args) => {
sent = true;
return exec(...args);
};
const out = await replyToRowAsync({ index: planned, key: body.agent, text: body.text, agentSend, exec: sendExec, now, host });
if (!sent) return out;
onEvent("send");
writes++;
onEvent("bump");
await scanFresh();
return out;
}
function conversation(url) {
return conversationResponse(reader, url);
}
return { index, markSeen: markSeenRoute, reply, conversation };
}
+18 -6
View File
@@ -1,18 +1,17 @@
#!/usr/bin/env node
// Usage:
// node packages/control-board/src/cli.mjs scan [--config PATH] [--repo PATH] [--fleet PATH|none] [--liveness tmux|assume-alive] [--print]
// `serve` is retired (row 198): the board runs in-process inside the Console.
// node packages/control-board/src/cli.mjs serve [same flags] [--port N] [--host 127.0.0.1]
// Exit 0 on success, 2 on a config refusal or bad usage.
import { join } from "node:path";
import { homedir } from "node:os";
import { loadConfig, defaultConfigPath, discoverRepoAgents, discoverFleetAgents, scan, tmuxInspect, ConfigError } from "./scan.mjs";
import { startServer } from "./serve.mjs";
const USAGE = "usage: mosaic-board scan [--config PATH] [--repo PATH] [--fleet PATH|none] [--liveness tmux|assume-alive] [--print]";
const RETIRED = "the control board no longer serves a port (retired on 7331, row 198). Open the board in the Console (webui, port 7332) instead.";
const USAGE = "usage: mosaic-board scan|serve [--config PATH] [--repo PATH] [--fleet PATH|none] [--liveness tmux|assume-alive] [--print] [--port N] [--host 127.0.0.1]";
function parseArgs(argv) {
if (argv[0] === "serve") throw new ConfigError(RETIRED);
const opts = { command: argv[0], config: defaultConfigPath(), repo: process.cwd(), fleet: join(homedir(), ".mosaic", "fleet", "agents"), liveness: "tmux", print: false };
const opts = { command: argv[0], config: defaultConfigPath(), repo: process.cwd(), fleet: join(homedir(), ".mosaic", "fleet", "agents"), liveness: "tmux", print: false, port: 7331, host: "127.0.0.1" };
for (let i = 1; i < argv.length; i++) {
const a = argv[i];
const next = () => {
@@ -24,9 +23,13 @@ function parseArgs(argv) {
else if (a === "--fleet") opts.fleet = next();
else if (a === "--liveness") opts.liveness = next();
else if (a === "--print") opts.print = true;
else if (a === "--port") {
opts.port = Number(next());
if (!Number.isInteger(opts.port) || opts.port < 0 || opts.port > 65535) throw new ConfigError("--port must be an integer 0..65535");
} else if (a === "--host") opts.host = next();
else throw new ConfigError(`unknown argument: ${a}`);
}
if (opts.command !== "scan") throw new ConfigError(USAGE);
if (opts.command !== "scan" && opts.command !== "serve") throw new ConfigError(USAGE);
if (!["tmux", "assume-alive"].includes(opts.liveness)) throw new ConfigError(`unknown liveness mode: ${opts.liveness}`);
return opts;
}
@@ -38,6 +41,15 @@ async function main() {
const isAlive = opts.liveness === "tmux" ? tmuxInspect : () => true;
const boardDir = join(dataRoot, "board");
const seatsDir = join(dataRoot, "seats");
if (opts.command === "serve") {
const server = await startServer({ host: opts.host, port: opts.port, specs, boardDir, isAlive, seatsDir, discordDataRoot: dataRoot });
const addr = server.address();
process.stdout.write(`control board: http://${opts.host}:${addr.port}/ (${specs.length} agents; board files in ${boardDir}; Ctrl-C to stop)\n`);
const stop = () => server.close(() => process.exit(0));
process.on("SIGINT", stop);
process.on("SIGTERM", stop);
return;
}
const index = scan(specs, { boardDir, isAlive, seatsDir, discordDataRoot: dataRoot });
for (const line of index.discoveryErrors) process.stderr.write(`${line}\n`);
for (const line of index.registrationErrors) process.stderr.write(`registration skipped: ${line}\n`);
+605
View File
@@ -0,0 +1,605 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Control board</title>
<style>
:root{
--canvas:#f4f6f8;--surface:#fff;--raised:#e9edf1;--text:#16202a;--muted:#5a6774;
--line:#e2e7ec;--action:#1f5f8b;--onAction:#fff;--accent:#c96a2b;
--success:#2e7d4f;--warning:#b26a00;--danger:#b3261e;
--font:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
--mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace;
--radius:8px;--radius-lg:14px;
}
@media (prefers-color-scheme: dark){
:root{
--canvas:#11161c;--surface:#182029;--raised:#1f2833;--text:#e7edf3;--muted:#94a3b0;
--line:#2a343f;--action:#6ba6d6;--onAction:#0b1116;--accent:#e08a4c;
--success:#57b586;--warning:#e0a63c;--danger:#e58077;
}
}
*,*::before,*::after{box-sizing:border-box}
html{font-family:var(--font);background:var(--canvas);color:var(--text);line-height:1.45}
body{margin:0 auto;max-width:1100px;padding:16px}
h1,h2,h3{margin:0 0 .4em;line-height:1.2;font-weight:600}
h1{font-size:1.5rem}h2{font-size:1.1rem;margin-top:1.4em}h3{font-size:.95rem}
:focus-visible{outline:3px solid var(--action);outline-offset:2px;border-radius:4px}
.page-head{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:8px 16px}
.head-controls{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.status-line{margin:0;color:var(--muted);font-size:.9rem}
.btn{font:inherit;font-weight:600;font-size:.85rem;min-height:34px;padding:6px 12px;border-radius:var(--radius);
border:1px solid var(--line);background:var(--surface);color:var(--text);cursor:pointer}
.btn:hover{background:var(--raised)}
.error-banner{margin:12px 0;padding:10px 14px;border-radius:var(--radius);border:1px solid var(--danger);
background:color-mix(in srgb,var(--danger) 10%,var(--surface));color:var(--danger);font-size:.9rem}
.empty{color:var(--muted);font-style:italic}
#seenDetails summary{cursor:pointer;list-style:none;display:flex;align-items:center;gap:8px}
#seenDetails summary::-webkit-details-marker{display:none}
#seenDetails summary::before{content:"\25B8";color:var(--muted);font-size:.9em}
#seenDetails[open] summary::before{content:"\25BE"}
#seenDetails summary h2{display:inline;margin:0}
.table-wrap{overflow-x:auto;border:1px solid var(--line);border-radius:var(--radius-lg);background:var(--surface)}
table{width:100%;border-collapse:collapse;font-size:.88rem}
th,td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--line);vertical-align:top}
th{font-size:.72rem;text-transform:uppercase;letter-spacing:.03em;color:var(--muted);font-weight:600;white-space:nowrap}
tbody tr:last-child td{border-bottom:none}
tr.is-offline{opacity:.55}
.badge{display:inline-flex;align-items:center;gap:5px;font-size:.76rem;font-weight:600;padding:2px 9px;
border-radius:99px;border:1px solid transparent;white-space:nowrap}
.badge::before{content:"";width:6px;height:6px;border-radius:50%;background:currentColor}
.badge-ok{color:var(--success);background:color-mix(in srgb,var(--success) 12%,var(--surface))}
.badge-warn{color:var(--warning);background:color-mix(in srgb,var(--warning) 12%,var(--surface))}
.badge-danger{color:var(--danger);background:color-mix(in srgb,var(--danger) 12%,var(--surface))}
.badge-muted{color:var(--muted);background:var(--raised)}
.badge-accent{color:var(--accent);background:color-mix(in srgb,var(--accent) 12%,var(--surface))}
.row-toggle{font:inherit;font-weight:600;background:none;border:0;padding:2px 0;color:var(--action);
cursor:pointer;text-align:left}
.row-toggle:hover{text-decoration:underline}
.seen-toggle{font:inherit;font-size:.76rem;background:none;border:1px solid var(--border);border-radius:6px;
padding:1px 8px;color:var(--action);cursor:pointer;margin-left:6px;vertical-align:middle}
.seen-toggle:hover{border-color:var(--action)}
.seen-tag{font-size:.72rem;color:var(--muted);margin-left:6px;vertical-align:middle}
.msg-error{color:var(--danger)}
.msg-text,.msg-error{display:block;max-width:36ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.relaunch-notice{display:block;max-width:36ch;overflow-wrap:anywhere;white-space:normal}
.model{display:block;color:var(--muted);font-size:.75em;white-space:nowrap}
.task-text{display:block;max-width:28ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.unknown{color:var(--muted);font-style:italic}
.reply-form{display:flex;gap:.4em;align-items:center;max-width:60ch}
.reply-form input{flex:1;min-width:12ch;font:inherit;padding:.2em .4em}
.reply-receipt{display:block;margin-top:.3em;font-size:.85em}
.reply-ok{color:var(--muted)}
.source{color:var(--muted);font-size:.75em;margin-left:.35em;white-space:nowrap}
.set-by{color:var(--muted);font-size:.75em;margin-left:.35em;white-space:nowrap}
.detail-row td{background:var(--raised)}
.detail-list{display:grid;grid-template-columns:auto 1fr;gap:4px 14px;margin:0;font-size:.85rem;font-family:var(--mono)}
.detail-list dt{color:var(--muted);font-family:var(--font)}
.detail-list dd{margin:0;overflow-wrap:anywhere}
.project-group{margin-bottom:20px}
.project-group-head{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:8px}
.group-toggles{display:flex;flex-wrap:wrap;gap:14px}
.project-group-head label{font-size:.85rem;color:var(--muted);display:flex;align-items:center;gap:6px}
.offline-note{margin:6px 0 0;font-size:.82rem;color:var(--muted)}
.page-footer{margin-top:24px;padding-top:12px;border-top:1px solid var(--line);color:var(--muted);font-size:.85rem}
</style>
</head>
<body>
<header class="page-head">
<h1>Control board</h1>
<div class="head-controls">
<p id="headerStatus" class="status-line" aria-live="polite">Updated never · next refresh in 10s</p>
<button id="refreshBtn" class="btn" type="button">Refresh</button>
<button id="pauseBtn" class="btn" type="button" aria-pressed="false">Pause</button>
</div>
</header>
<div id="errorBanner" class="error-banner" role="alert" hidden></div>
<main id="main">
<section aria-labelledby="waiting-h">
<h2 id="waiting-h">Waiting on you</h2>
<div id="waitingBody"></div>
</section>
<section aria-labelledby="seen-h">
<details id="seenDetails">
<summary><h2 id="seen-h">Seen</h2></summary>
<div id="seenBody"></div>
</details>
</section>
<section aria-labelledby="projects-h">
<h2 id="projects-h">By project</h2>
<div id="projectsBody"></div>
</section>
</main>
<footer id="footer" class="page-footer"></footer>
<script>
(function () {
"use strict";
var STATES = ["working", "waiting", "error", "offline", "idle", "unknown"];
var BADGE_CLASS = { waiting: "badge-warn", error: "badge-danger", working: "badge-ok", offline: "badge-muted", idle: "badge-muted", unknown: "badge-accent" };
var REFRESH_MS = 10000;
var headerStatus = document.getElementById("headerStatus");
var refreshBtn = document.getElementById("refreshBtn");
var pauseBtn = document.getElementById("pauseBtn");
var errorBanner = document.getElementById("errorBanner");
var waitingBody = document.getElementById("waitingBody");
var seenBody = document.getElementById("seenBody");
var seenHeading = document.getElementById("seen-h");
var projectsBody = document.getElementById("projectsBody");
var footer = document.getElementById("footer");
var main = document.getElementById("main");
var lastData = null;
var paused = false;
var fetching = false;
var timerId = null;
var secondsLeft = REFRESH_MS / 1000;
var hideOfflineState = {};
var hideSeenState = {};
// Open detail panels survive a refresh. Keyed by section, project and agent
// because the same agent can appear in both the waiting list and its group.
var openDetails = {};
var rowIdx = 0;
function esc(v) {
if (v === null || v === undefined) return "";
return String(v).replace(/[&<>"']/g, function (c) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
});
}
function humanAge(s) {
if (s === null || s === undefined) return "—";
if (s < 60) return s + "s";
var m = Math.floor(s / 60);
if (m < 60) return m + "m";
var h = Math.floor(m / 60);
if (h < 24) return h + "h";
return Math.floor(h / 24) + "d";
}
function timeAgo(iso) {
if (!iso) return "never";
var ms = Date.now() - Date.parse(iso);
if (Number.isNaN(ms)) return "never";
var s = Math.floor(ms / 1000);
if (s < 5) return "just now";
if (s < 60) return s + "s ago";
var m = Math.floor(s / 60);
if (m < 60) return m + "m ago";
var h = Math.floor(m / 60);
if (h < 24) return h + "h ago";
return Math.floor(h / 24) + "d ago";
}
function cap(s) { return s.charAt(0).toUpperCase() + s.slice(1); }
function badge(state) {
var cls = BADGE_CLASS[state] || "badge-muted";
return '<span class="badge ' + cls + '">' + esc(state) + "</span>";
}
// A "Seen" button on rows that need you; a "seen" tag plus "Unsee" on rows
// you have already marked. The mark clears itself when the agent writes again.
function seenControl(rec) {
var needsYou = rec.state === "waiting" || rec.state === "error";
if (!needsYou || !rec.lastActivity) return "";
var attrs = ' data-project="' + esc(rec.project) + '" data-agent="' + esc(rec.agent) + '" data-last="' + esc(rec.lastActivity) + '"';
if (rec.seen) {
return '<span class="seen-tag">seen</span><button type="button" class="seen-toggle" data-seen="false"' + attrs + ' title="Put this row back under Waiting on you">Unsee</button>';
}
return '<button type="button" class="seen-toggle" data-seen="true"' + attrs + ' title="I have read this; hide it from Waiting on you until the agent writes again">Seen</button>';
}
var SOURCE_LABEL = { "registration": "registered", "first-user-message": "first message", "tmux-pane": "tmux pane", "session-cwd": "session cwd", "workspace-git-root": "git root" };
function sourceLabel(source) { return SOURCE_LABEL[source] || source; }
function sourceTag(source) { return source ? '<span class="source" title="source: ' + esc(source) + '">' + esc(sourceLabel(source)) + "</span>" : ""; }
function fromSource(source) { return source ? " (from " + esc(sourceLabel(source)) + ")" : ""; }
// Who set a registered task (#1511): shown only when the task on the row is
// the registered one (the scan gives null otherwise). It is what the
// caller claimed when it ran `mosaic seat task`, not a verified identity,
// and it never changes what the row lets you do.
function setByTag(rec) { return rec.taskSetBy ? '<span class="set-by" title="set by (as claimed by the caller, not verified): ' + esc(rec.taskSetBy) + '">set by ' + esc(rec.taskSetBy) + "</span>" : ""; }
function setByText(rec) { return rec.taskSetBy ? esc(rec.taskSetBy) + " (as claimed by the caller, not verified)" : "—"; }
// The model a seat is running, e.g. "gpt-6-astra" with the provider in the
// hover title. modelText() already escapes, so the title takes it as is.
// Empty when the log has not named one yet.
function modelTag(rec) { return rec.model ? '<span class="model" title="' + modelText(rec) + '">' + esc(rec.model) + "</span>" : ""; }
function modelText(rec) { return rec.model ? (rec.provider ? esc(rec.provider) + "/" : "") + esc(rec.model) : "unknown"; }
// Reply-from-board (#1505): a box and Send only where the board has an
// address, i.e. a live registration naming a tmux session. Receipts and
// unsent drafts are kept per row id so a refresh does not lose them.
var replyReceipts = {};
var replyDrafts = {};
function canReply(rec) {
if (rec.connector) return false;
var reg = rec.registered;
return !!(reg && reg.alive !== false && reg.tmux && reg.tmux.session);
}
function replyReceipt(id) {
var r = replyReceipts[id];
if (!r) return "";
if (r.delivered) return '<span class="reply-receipt reply-ok">delivered ' + esc(r.sentAt) + " to tmux " + esc(r.session) + "</span>";
return '<span class="reply-receipt msg-error" style="white-space:normal;max-width:none">failed' + (r.exitCode === null || r.exitCode === undefined ? "" : " (exit " + esc(r.exitCode) + ")") + ": " + esc(r.stderr || r.error || "no output") + "</span>";
}
function replyControl(rec) {
var id = [rec.project, rec.agent].join("/");
if (rec.connector) return '<span class="unknown">Board replies disabled for Discord connectors</span>';
if (!canReply(rec)) return '<span class="unknown">reply needs a registered seat</span>' + replyReceipt(id);
return '<form class="reply-form" data-id="' + esc(id) + '">' +
'<input type="text" class="reply-text" maxlength="2000" autocomplete="off" aria-label="Reply to ' + esc(rec.agent) + '" placeholder="Reply to ' + esc(rec.agent) + '" value="' + esc(replyDrafts[id] || "") + '">' +
'<button type="submit">Send</button></form>' + replyReceipt(id);
}
function registeredText(reg) {
if (!reg) return "no (not started through mosaic launch)";
var parts = ["started " + esc(reg.startedAt || "—")];
if (reg.updatedAt) parts.push("task updated " + esc(reg.updatedAt));
if (reg.harness) parts.push("harness " + esc(reg.harness));
if (reg.pid) parts.push("pid " + esc(reg.pid));
if (reg.tmux && reg.tmux.session) parts.push("tmux " + esc(reg.tmux.session) + (reg.tmux.socket ? " (socket " + esc(reg.tmux.socket) + ")" : ""));
if (reg.layout) parts.push(esc(reg.layout) + " layout");
if (reg.alive === false) parts.push("stale: pid " + esc(reg.pid) + " is gone, so the derived values are shown");
return parts.join("; ");
}
function relaunchNotice(rec) { return rec.relaunchedAt ? 'relaunched at ' + esc(rec.relaunchedAt) + ', no messages since' : ''; }
function buildRowPair(rec, showProject) {
var idx = rowIdx++;
var key = (showProject ? "waiting:" : "group:") + [rec.project, rec.agent].join("/");
var open = !!openDetails[key];
var cls = "session-row" + (rec.state === "offline" ? " is-offline" : "");
var notice = relaunchNotice(rec);
var msg = notice ? '<span class="relaunch-notice">' + notice + '</span>' : rec.state === "error" && rec.lastError
? '<span class="msg-error" title="' + esc(rec.lastError) + '">' + esc(rec.lastError) + "</span>"
: '<span class="msg-text" title="' + esc(rec.lastAssistantText || "") + '">' + esc(rec.lastAssistantText || "—") + "</span>";
var projectCell = showProject ? "<td>" + esc(rec.project) + "</td>" : "";
// Gate A fields: from the seat's registration (mosaic launch), else derived
// from the log and tmux, else "unknown". Never guessed. The source tag on
// the row says which one is shown.
var task = rec.task
? '<span class="task-text" title="' + esc(rec.task) + '">' + esc(rec.task) + "</span>" + sourceTag(rec.taskSource) + setByTag(rec)
: '<span class="unknown">unknown</span>';
var activeProject = rec.activeProject
? '<span title="' + esc(rec.workspace || "") + '">' + esc(rec.activeProject) + "</span>" + sourceTag(rec.activeProjectSource)
: '<span class="unknown" title="' + esc(rec.workspace || "") + '">unknown</span>';
var main =
'<tr class="' + cls + '">' +
projectCell +
'<td><button type="button" class="row-toggle" data-idx="' + idx + '" data-key="' + esc(key) + '" aria-expanded="' + (open ? "true" : "false") + '" aria-controls="detail-' + idx + '">' + esc(rec.agent) + "</button>" + modelTag(rec) + "</td>" +
"<td>" + badge(rec.state) + (rec.connector ? '<span class="unknown"> · ' + (rec.connector.braked === true ? 'braked (STOP)' : rec.connector.braked === false ? 'not braked' : 'brake unknown') + ' · owner ' + esc(rec.connector.ownerState) + '</span>' : '') + seenControl(rec) + "</td>" +
"<td>" + (notice ? '<span class="relaunch-notice">' + notice + '</span>' : esc(humanAge(rec.ageSeconds))) + "</td>" +
"<td>" + task + "</td>" +
"<td>" + activeProject + "</td>" +
"<td>" + msg + "</td>" +
"</tr>";
var span = showProject ? 7 : 6;
var tmux = rec.tmux && rec.tmux.session
? esc(rec.tmux.session) + (rec.tmux.socket ? " (socket " + esc(rec.tmux.socket) + ")" : "")
: "—";
var detail =
'<tr class="detail-row" id="detail-' + idx + '"' + (open ? "" : " hidden") + '><td colspan="' + span + '">' +
'<dl class="detail-list">' +
"<dt>Session ID</dt><dd>" + esc(rec.sessionId || "—") + "</dd>" +
"<dt>Session file</dt><dd>" + esc(rec.sessionFile || "—") + "</dd>" +
"<dt>Task</dt><dd>" + (rec.task ? esc(rec.task) : "unknown") + fromSource(rec.taskSource) + "</dd>" +
"<dt>Task set by</dt><dd>" + setByText(rec) + "</dd>" +
"<dt>Active project</dt><dd>" + (rec.activeProject ? esc(rec.activeProject) : "unknown") + fromSource(rec.activeProjectSource) + "</dd>" +
"<dt>Workspace</dt><dd>" + (rec.workspace ? esc(rec.workspace) : "unknown") + fromSource(rec.workspaceSource) + "</dd>" +
"<dt>Model</dt><dd>" + modelText(rec) + "</dd>" +
"<dt>Registered</dt><dd>" + registeredText(rec.registered) + "</dd>" +
"<dt>Reply</dt><dd>" + replyControl(rec) + "</dd>" +
"<dt>Session cwd</dt><dd>" + esc(rec.cwd || "—") + "</dd>" +
"<dt>Tmux session</dt><dd>" + tmux + "</dd>" +
(notice ? "<dt>Current activity</dt><dd>" + notice + "</dd>" : "") +
"<dt>" + (notice ? "Historical last activity" : "Last activity") + "</dt><dd>" + esc(rec.lastActivity || "—") + "</dd>" +
"<dt>Scanned at</dt><dd>" + esc(rec.scannedAt || "—") + "</dd>" +
"<dt>Skipped lines</dt><dd>" + esc(rec.skippedLines) + "</dd>" +
"<dt>" + (notice ? "Historical last message" : "Last message") + "</dt><dd>" + esc(rec.lastAssistantText || "—") + "</dd>" +
"<dt>" + (notice ? "Historical last error" : "Last error") + "</dt><dd>" + esc(rec.lastError || "—") + "</dd>" +
"</dl></td></tr>";
return main + detail;
}
// Rows you marked Seen, in one place, so they can be found and put back.
// Collapsed by default; the open/closed choice survives a refresh.
function renderSeen(data) {
var list = (data.sessions || []).filter(function (r) { return r.seen; });
list.sort(function (a, b) {
var aa = a.ageSeconds === null || a.ageSeconds === undefined ? Infinity : a.ageSeconds;
var bb = b.ageSeconds === null || b.ageSeconds === undefined ? Infinity : b.ageSeconds;
return aa - bb;
});
seenHeading.textContent = "Seen (" + list.length + ")";
if (list.length === 0) {
seenBody.innerHTML = '<p class="empty">Nothing is marked seen. Rows you mark come back here, and return to "Waiting on you" on their own when the agent writes again.</p>';
return;
}
var rows = list.map(function (r) { return buildRowPair(r, true); }).join("");
seenBody.innerHTML =
'<div class="table-wrap"><table><thead><tr>' +
"<th scope=\"col\">Project</th><th scope=\"col\">Agent</th><th scope=\"col\">State</th><th scope=\"col\">Age</th><th scope=\"col\">Task</th><th scope=\"col\">Active project</th><th scope=\"col\">Last message</th>" +
"</tr></thead><tbody>" + rows + "</tbody></table></div>";
}
function renderWaiting(data) {
var list = (data.sessions || []).filter(function (r) { return r.waitingOnYou; });
list.sort(function (a, b) {
var ae = a.state === "error" ? 0 : 1, be = b.state === "error" ? 0 : 1;
if (ae !== be) return ae - be;
var aa = a.ageSeconds === null || a.ageSeconds === undefined ? Infinity : a.ageSeconds;
var bb = b.ageSeconds === null || b.ageSeconds === undefined ? Infinity : b.ageSeconds;
return aa - bb;
});
if (list.length === 0) {
waitingBody.innerHTML = '<p class="empty">Nothing is waiting on you.</p>';
return;
}
var rows = list.map(function (r) { return buildRowPair(r, true); }).join("");
waitingBody.innerHTML =
'<div class="table-wrap"><table><thead><tr>' +
"<th scope=\"col\">Project</th><th scope=\"col\">Agent</th><th scope=\"col\">State</th><th scope=\"col\">Age</th><th scope=\"col\">Task</th><th scope=\"col\">Active project</th><th scope=\"col\">Last message</th>" +
"</tr></thead><tbody>" + rows + "</tbody></table></div>";
}
function projectOrder(a, b) {
if (a === "fleet" && b === "fleet") return 0;
if (a === "fleet") return 1;
if (b === "fleet") return -1;
return a.localeCompare(b);
}
function renderProjects(data) {
var sessions = data.sessions || [];
var byProject = {};
sessions.forEach(function (r) {
(byProject[r.project] = byProject[r.project] || []).push(r);
});
var projects = Object.keys(byProject).sort(projectOrder);
if (projects.length === 0) {
projectsBody.innerHTML = '<p class="empty">No agents are configured.</p>';
return;
}
projectsBody.innerHTML = projects.map(function (project) {
var group = byProject[project].slice().sort(function (a, b) { return a.agent.localeCompare(b.agent); });
if (!(project in hideOfflineState)) hideOfflineState[project] = true;
if (!(project in hideSeenState)) hideSeenState[project] = true;
var hideOffline = hideOfflineState[project];
var hideSeen = hideSeenState[project];
var offlineHidden = 0, seenHidden = 0;
var visible = group.filter(function (r) {
if (hideOffline && r.state === "offline") { offlineHidden += 1; return false; }
if (hideSeen && r.seen) { seenHidden += 1; return false; }
return true;
});
var rows = visible.map(function (r) { return buildRowPair(r, false); }).join("");
var noteParts = [];
if (offlineHidden > 0) noteParts.push(offlineHidden + " offline hidden");
if (seenHidden > 0) noteParts.push(seenHidden + " seen hidden");
var note = noteParts.length > 0 ? '<p class="offline-note">' + noteParts.join(" \u00b7 ") + "</p>" : "";
var headCount = visible.length < group.length ? visible.length + " of " + group.length : String(group.length);
return (
'<div class="project-group">' +
'<div class="project-group-head"><h3>' + esc(project) + ' <span>(' + headCount + ")</span></h3>" +
'<div class="group-toggles">' +
'<label><input type="checkbox" class="hide-offline-toggle" data-project="' + esc(project) + '" ' + (hideOffline ? "checked" : "") + "> Hide offline</label>" +
'<label><input type="checkbox" class="hide-seen-toggle" data-project="' + esc(project) + '" ' + (hideSeen ? "checked" : "") + "> Hide seen</label>" +
"</div></div>" +
'<div class="table-wrap"><table><thead><tr>' +
"<th scope=\"col\">Agent</th><th scope=\"col\">State</th><th scope=\"col\">Age</th><th scope=\"col\">Task</th><th scope=\"col\">Active project</th><th scope=\"col\">Last message</th>" +
"</tr></thead><tbody>" + (rows || '<tr><td colspan="6" class="empty">No agents.</td></tr>') + "</tbody></table></div>" +
note + "</div>"
);
}).join("");
}
function renderFooter(data) {
var counts = data.counts || {};
var parts = STATES.map(function (s) { return cap(s) + " " + (counts[s] || 0); }).join(" · ");
parts += " · Seen " + ((data.seen || []).length);
footer.innerHTML = "<p>" + esc(parts) + " — generated " + esc(timeAgo(data.generatedAt)) + " (" + esc(data.generatedAt || "") + ")</p>";
}
// A refresh rebuilds the tables. If Jason is typing a reply, put the
// caret back where it was so the periodic refresh does not steal it.
function withReplyFocus(render) {
var active = document.activeElement;
var focus = active && active.classList && active.classList.contains("reply-text")
? { id: active.form.dataset.id, pos: active.selectionStart }
: null;
render();
if (!focus) return;
var forms = main.querySelectorAll('.reply-form[data-id="' + focus.id.replace(/"/g, '\\"') + '"]');
var input = forms.length ? forms[0].querySelector(".reply-text") : null;
if (!input) return;
input.focus();
try { input.setSelectionRange(focus.pos, focus.pos); } catch (e) {}
}
function renderAll() {
withReplyFocus(function () {
rowIdx = 0;
renderWaiting(lastData);
renderSeen(lastData);
renderProjects(lastData);
renderFooter(lastData);
});
}
function renderHeaderStatus() {
var updated = lastData ? timeAgo(lastData.generatedAt) : "never";
var refresh = paused ? "auto-refresh paused" : "next refresh in " + secondsLeft + "s";
headerStatus.textContent = "Updated " + updated + " · " + refresh;
}
function showError(msg) {
errorBanner.hidden = false;
errorBanner.textContent = "Could not refresh: " + msg + ". Showing the last known data.";
}
function hideError() {
errorBanner.hidden = true;
errorBanner.textContent = "";
}
function scheduleNextFetch() {
clearTimeout(timerId);
if (paused) return;
secondsLeft = REFRESH_MS / 1000;
timerId = setTimeout(runFetch, REFRESH_MS);
}
function runFetch() {
if (fetching) return;
fetching = true;
fetch("/api/board", { cache: "no-store" })
.then(function (res) {
return res.json().catch(function () {
throw new Error("the server sent a response that was not valid JSON");
}).then(function (body) {
if (!res.ok) throw new Error((body && body.error) || "HTTP " + res.status);
return body;
});
})
.then(function (body) {
lastData = body;
hideError();
renderAll();
})
.catch(function (err) {
console.error("control board refresh failed", err);
showError(err && err.message ? err.message : "unknown error");
})
.then(function () {
fetching = false;
renderHeaderStatus();
scheduleNextFetch();
});
}
function setPaused(p) {
paused = p;
pauseBtn.textContent = paused ? "Resume" : "Pause";
pauseBtn.setAttribute("aria-pressed", String(paused));
if (paused) clearTimeout(timerId);
else scheduleNextFetch();
renderHeaderStatus();
}
function postSeen(btn) {
if (fetching) return;
fetching = true;
btn.disabled = true;
fetch("/api/seen", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ project: btn.dataset.project, agent: btn.dataset.agent, lastActivity: btn.dataset.last, seen: btn.dataset.seen === "true" })
})
.then(function (res) {
return res.json().catch(function () {
throw new Error("the server sent a response that was not valid JSON");
}).then(function (body) {
if (!res.ok) throw new Error((body && body.error) || "HTTP " + res.status);
return body;
});
})
.then(function (body) {
lastData = body;
hideError();
renderAll();
})
.catch(function (err) {
console.error("marking seen failed", err);
showError(err && err.message ? err.message : "unknown error");
btn.disabled = false;
})
.then(function () {
fetching = false;
renderHeaderStatus();
scheduleNextFetch();
});
}
function postReply(form) {
var id = form.dataset.id;
var input = form.querySelector(".reply-text");
var text = input.value;
if (!text.trim()) return;
var sendBtn = form.querySelector("button");
sendBtn.disabled = true;
fetch("/api/reply", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ agent: id, text: text })
})
.then(function (res) {
return res.json().catch(function () {
throw new Error("the server sent a response that was not valid JSON");
}).then(function (body) {
if (!res.ok) throw new Error((body && body.error) || "HTTP " + res.status);
return body;
});
})
.then(function (body) {
replyReceipts[id] = body;
if (body.delivered) delete replyDrafts[id];
renderAll();
// The seat's log shows the effect; fetch now instead of waiting a tick.
if (body.delivered && !paused) runFetch();
})
.catch(function (err) {
console.error("reply failed", err);
replyReceipts[id] = { delivered: false, exitCode: null, error: err && err.message ? err.message : "unknown error" };
renderAll();
});
}
main.addEventListener("submit", function (e) {
var form = e.target.closest(".reply-form");
if (!form) return;
e.preventDefault();
postReply(form);
});
main.addEventListener("input", function (e) {
var input = e.target.closest(".reply-text");
if (!input) return;
replyDrafts[input.form.dataset.id] = input.value;
});
main.addEventListener("click", function (e) {
var seenBtn = e.target.closest(".seen-toggle");
if (seenBtn) return postSeen(seenBtn);
var btn = e.target.closest(".row-toggle");
if (!btn) return;
var detail = document.getElementById("detail-" + btn.dataset.idx);
var expanded = btn.getAttribute("aria-expanded") === "true";
btn.setAttribute("aria-expanded", String(!expanded));
detail.hidden = expanded;
if (expanded) delete openDetails[btn.dataset.key];
else openDetails[btn.dataset.key] = true;
});
main.addEventListener("change", function (e) {
var cb = e.target.closest(".hide-offline-toggle, .hide-seen-toggle");
if (!cb) return;
var stateMap = cb.classList.contains("hide-seen-toggle") ? hideSeenState : hideOfflineState;
stateMap[cb.dataset.project] = cb.checked;
renderProjects(lastData);
});
refreshBtn.addEventListener("click", runFetch);
pauseBtn.addEventListener("click", function () { setPaused(!paused); });
setInterval(function () {
if (!paused && secondsLeft > 0) secondsLeft -= 1;
renderHeaderStatus();
}, 1000);
renderHeaderStatus();
runFetch();
})();
</script>
</body>
</html>
+3 -45
View File
@@ -16,7 +16,7 @@
import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, writeFileSync, renameSync } from "node:fs";
import { join, basename, isAbsolute, resolve } from "node:path";
import { homedir } from "node:os";
import { spawnSync, execFile } from "node:child_process";
import { spawnSync } from "node:child_process";
import { readRegistration, samePath, SeatError, LAYOUTS } from "../../seat/src/seat.mjs";
import { discoverDiscordAgents, inspectDiscord, newestDiscordSession } from './discord.mjs';
@@ -193,46 +193,17 @@ export function parsePanes(listPanesOutput) {
// "unknown", never assumed alive).
// workspace the current path of the first pane running pi, else null.
// `exec` is injectable for tests.
export function tmuxInspect(tmux, { exec = spawnSync } = {}) {
return inspectFromResult(exec("tmux", tmuxArgs(tmux), { encoding: "utf8", timeout: TMUX_TIMEOUT_MS }));
}
const TMUX_TIMEOUT_MS = 5000;
function tmuxArgs({ socket, session }) {
export function tmuxInspect({ socket, session }, { exec = spawnSync } = {}) {
const args = [];
if (socket) args.push("-L", socket);
args.push("list-panes", "-s", "-t", `=${session}`, "-F", "#{pane_current_command}\t#{pane_current_path}");
return args;
}
// Shared by the sync and async paths, so they cannot drift.
function inspectFromResult(r) {
const r = exec("tmux", args, { encoding: "utf8", timeout: 5000 });
if (r.error) return { alive: null, workspace: null };
if (r.status !== 0) return { alive: false, workspace: null };
const pane = parsePanes(r.stdout ?? "").find((p) => PI_COMMANDS.includes(p.command));
return { alive: Boolean(pane), workspace: pane?.path ?? null };
}
// Async child run for the Console's in-process board (B1). Same result shape as
// spawnSync: { status, signal, stdout, stderr, error }. A timeout or a failure to
// start is an error, never a status. The Console's event loop must not block here.
export function runAsync(file, args, { encoding = "utf8", timeout, env } = {}) {
return new Promise((resolvePromise) => {
execFile(file, args, { encoding, timeout, env, maxBuffer: 1 << 20 }, (err, stdout, stderr) => {
if (!err) return resolvePromise({ status: 0, signal: null, stdout, stderr, error: undefined });
if (err.killed) return resolvePromise({ status: null, signal: err.signal ?? null, stdout, stderr, error: Object.assign(new Error(`timed out after ${timeout} ms`), { code: "ETIMEDOUT" }) });
if (typeof err.code === "number") return resolvePromise({ status: err.code, signal: null, stdout, stderr, error: undefined });
return resolvePromise({ status: null, signal: null, stdout, stderr, error: err });
});
});
}
// Async twin of tmuxInspect. `exec` is injectable for tests.
export async function tmuxInspectAsync(tmux, { exec = runAsync } = {}) {
return inspectFromResult(await exec("tmux", tmuxArgs(tmux), { encoding: "utf8", timeout: TMUX_TIMEOUT_MS }));
}
// Liveness only, for callers that do not need the pane path.
export function tmuxIsAlive(tmux, opts) {
return tmuxInspect(tmux, opts).alive;
@@ -491,16 +462,3 @@ export function scan(specs, { boardDir, isAlive, now, seatsDir = null, isPidAliv
writeAtomic(join(boardDir, "index.json"), index);
return index;
}
// Async scan for the Console's in-process board (B1). With no isAlive, every tmux
// check runs through the async exec first, in parallel, and the sync scan body
// then reads those answers. A caller's sync isAlive (the CLI, tests) is used as is.
// One scan body serves both paths, so the records cannot drift.
export async function scanAsync(specs, { isAlive, exec = runAsync, ...rest } = {}) {
if (isAlive) return scan(specs, { ...rest, isAlive });
const checks = new Map();
const keyOf = (tmux) => JSON.stringify([tmux.socket ?? null, tmux.session]);
const pending = [...new Map(specs.filter((s) => !s.connector).map((s) => [keyOf(s.tmux), s.tmux])).values()];
await Promise.all(pending.map(async (tmux) => checks.set(keyOf(tmux), await tmuxInspectAsync(tmux, { exec }))));
return scan(specs, { ...rest, isAlive: (tmux) => checks.get(keyOf(tmux)) ?? { alive: null, workspace: null } });
}
+191 -49
View File
@@ -1,30 +1,55 @@
// Control board: the pure board logic the Console calls in-process (row 198).
// No listener. The writer on 7331 is retired; the Console serves the board
// routes behind its own auth (packages/webui/src/serve.mjs). The board opens no
// socket and never binds a port.
// Control board step 2: a tiny local web server. No dependencies, no auth.
// It only ever binds to a loopback address (fail closed otherwise).
//
// Route logic lives in src/board.mjs. This module keeps the pure helpers the
// Console and the tests share: reply planning and shaping, the Host/Origin check
// (foreignRequest), and the conversation query and refusal map.
// GET / the page (src/page.html)
// GET /api/board re-runs the scanner and returns index.json as JSON
// POST /api/seen {project, agent, lastActivity, seen?} marks a row as seen
// (or clears the mark with seen:false); rescans, returns index
// POST /api/reply {agent: "<project>/<agent>", text} delivers text to the
// seat's tmux pane through tools/tmux/agent-send.sh (#1505);
// answers {delivered, exitCode, stdout, stderr, ...}
// GET /healthz {"ok":true}
// GET /api/conversations
// read-only Pi conversation catalogue (#1507, CHAT-02)
// GET /api/conversation?id=<conversation>[&branch=<branch>][&cursor=<cursor>]
// one CHAT-01 history page: the first page of a branch (the
// default one without branch), or the next page (or a
// follow) for a cursor. A cursor call repeats the page's
// branch; without it the answer is 400. Refusals carry
// {error, refusal: {code, reconcile}}.
//
// POST requires Content-Type: application/json. A plain form post from another
// site in the browser cannot set that header without a CORS preflight, and this
// server answers no preflight, so a stray page cannot flip marks.
//
// Every route first checks Host and Origin (#1507). Binding to loopback does
// not stop DNS rebinding: a page whose name now resolves to 127.0.0.1 reaches
// a Console on this host as its own origin, with its own name as Host, and could
// read the board or post /api/reply into a live pane. A Host that is not a
// loopback name on the listening port, or any Origin other than the server's
// own, gets 403 before anything else runs. The Console applies the same check.
// this server as its own origin, with its own name as Host, and could read
// /api/board or post /api/reply into a live pane. A Host that is not a loopback
// name on this server's port, or any Origin other than this server's own, gets
// 403 before anything else runs. Same check as packages/webui/src/serve.mjs.
// No CORS headers are ever sent.
//
// Every /api/board request rescans, so the page is never staler than its
// refresh timer. The scan rewrites the derived board files as a side effect.
//
// The conversation routes read only: packages/conversation lists the repository
// specs' Pi session roots (never fleet or connector ones), opens files
// O_NOFOLLOW and writes nothing. Registrations are hints there too. The
// conversation id is opaque and no path comes from the request.
// conversation id is opaque and no path comes from the request. Cursors live
// in this server's memory, bound to the one actor this unauthenticated
// loopback route has, local-operator.
import { createServer as createHttpServer } from "node:http";
import { readFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { isIP } from "node:net";
import { hostname } from "node:os";
import { spawnSync } from "node:child_process";
import { resolve } from "node:path";
import { seenKey, runAsync } from "./scan.mjs";
import { scan, markSeen, seenKey, ConfigError, loadRegistrations } from "./scan.mjs";
import { createReader, rootsFromSpecs } from "../../conversation/src/reader.mjs";
const MAX_BODY = 4096;
// Reply-from-board (#1505). The board owns no transport: a reply is handed to
// the repository's own inter-agent channel, tools/tmux/agent-send.sh, which
@@ -46,48 +71,27 @@ const REPLY_TIMEOUT_MS = 15000;
// Refusals (4xx) happen before the tool runs and carry { error }. Once the
// tool has run the answer is 200 with delivered true/false, the exit code and
// both output streams verbatim, whatever the code was.
export function replyToRow(opts) {
return replyWith(opts, (exec, args, env, agentSend) => exec(agentSend, args, { encoding: "utf8", timeout: REPLY_TIMEOUT_MS, env }), { exec: spawnSync });
}
// Async twin of replyToRow for the in-process board (B1). `exec` is async.
export async function replyToRowAsync(opts) {
return replyWith(opts, async (exec, args, env, agentSend) => exec(agentSend, args, { encoding: "utf8", timeout: REPLY_TIMEOUT_MS, env }), { exec: runAsync, async: true });
}
function replyWith({ index, key, text, agentSend = DEFAULT_AGENT_SEND, exec, now = () => new Date(), host = hostname().split(".")[0] }, send, { exec: defaultExec, async = false }) {
const plan = planReply({ index, key, text, host });
if (plan.refusal) return async ? Promise.resolve(plan.refusal) : plan.refusal;
// The registration says which socket the seat is on. A launcher-exported
// MOSAIC_TMUX_SOCKET in this server's own environment must not override it.
const env = { ...process.env };
delete env.MOSAIC_TMUX_SOCKET;
const finish = (r) => replyBody({ r, key, agentSend, session: plan.session, socket: plan.socket, now });
const out = send(exec ?? defaultExec, plan.args, env, agentSend);
return async ? Promise.resolve(out).then(finish) : finish(out);
}
function planReply({ index, key, text, host }) {
const refuse = (status, error) => ({ refusal: { status, body: { error } } });
if (typeof key !== "string" || !key) return refuse(400, "agent must be the row id <project>/<agent>");
if (typeof text !== "string" || !text.trim()) return refuse(400, "text must be a non-empty string");
if (text.length > REPLY_LIMIT) return refuse(400, `text is longer than ${REPLY_LIMIT} characters`);
export function replyToRow({ index, key, text, agentSend = DEFAULT_AGENT_SEND, exec = spawnSync, now = () => new Date(), host = hostname().split(".")[0] }) {
if (typeof key !== "string" || !key) return { status: 400, body: { error: "agent must be the row id <project>/<agent>" } };
if (typeof text !== "string" || !text.trim()) return { status: 400, body: { error: "text must be a non-empty string" } };
if (text.length > REPLY_LIMIT) return { status: 400, body: { error: `text is longer than ${REPLY_LIMIT} characters` } };
const rec = index.sessions.find((r) => seenKey(r) === key);
if (!rec) return refuse(404, `unknown row: ${key}`);
if (rec.connector || / \(discord: [a-z0-9][a-z0-9._-]{0,63}\)$/.test(rec.agent)) return refuse(409, "board replies are disabled for Discord connectors");
if (!rec) return { status: 404, body: { error: `unknown row: ${key}` } };
if (rec.connector || / \(discord: [a-z0-9][a-z0-9._-]{0,63}\)$/.test(rec.agent)) return { status: 409, body: { error: "board replies are disabled for Discord connectors" } };
const reg = rec.registered;
if (!reg) return refuse(409, "reply needs a registered seat (start it through scripts/mosaic launch)");
if (reg.alive === false) return refuse(409, `registration is stale: pid ${reg.pid} is gone`);
if (!reg.tmux || !reg.tmux.session) return refuse(409, "registration has no tmux session to address");
if (!reg) return { status: 409, body: { error: "reply needs a registered seat (start it through scripts/mosaic launch)" } };
if (reg.alive === false) return { status: 409, body: { error: `registration is stale: pid ${reg.pid} is gone` } };
if (!reg.tmux || !reg.tmux.session) return { status: 409, body: { error: "registration has no tmux session to address" } };
const session = reg.tmux.session;
const socket = reg.tmux.socket || null;
const args = ["-s", session, "-S", `${host}:${REPLY_SENDER}`];
if (socket) args.push("-L", socket);
args.push("-m", `${text}\n${REPLY_TRAILER}`);
return { args, session, socket };
}
function replyBody({ r, key, agentSend, session, socket, now }) {
// The registration says which socket the seat is on. A launcher-exported
// MOSAIC_TMUX_SOCKET in this server's own environment must not override it.
const env = { ...process.env };
delete env.MOSAIC_TMUX_SOCKET;
const r = exec(agentSend, args, { encoding: "utf8", timeout: REPLY_TIMEOUT_MS, env });
if (r.error) return { status: 500, body: { error: `could not run ${agentSend}: ${r.error.message}` } };
const exitCode = r.status;
return {
@@ -106,6 +110,39 @@ function replyBody({ r, key, agentSend, session, socket, now }) {
};
}
function sendJson(res, status, body) {
res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
res.end(JSON.stringify(body) + "\n");
}
function readJsonBody(req) {
return new Promise((resolvePromise, reject) => {
const type = String(req.headers["content-type"] || "").split(";")[0].trim().toLowerCase();
if (type !== "application/json") return reject(new Error("Content-Type must be application/json"));
const chunks = [];
let size = 0;
req.on("data", (c) => {
size += c.length;
if (size > MAX_BODY) {
req.destroy();
reject(new Error(`body larger than ${MAX_BODY} bytes`));
return;
}
chunks.push(c);
});
req.on("end", () => {
try {
const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("body must be a JSON object");
resolvePromise(parsed);
} catch (err) {
reject(new Error(`invalid JSON body: ${err.message}`));
}
});
req.on("error", reject);
});
}
const LOOPBACK = new Set(["127.0.0.1", "::1", "localhost"]);
export function isLoopbackHost(host) {
@@ -150,6 +187,11 @@ export const REFUSAL_STATUS = {
};
const QUERY_VALUE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
function sendConversationJson(res, status, body) {
res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store", "x-content-type-options": "nosniff" });
res.end(JSON.stringify(body) + "\n");
}
// GET /api/conversation: id is required; branch and cursor are optional; any
// other, repeated or malformed parameter is a 400.
function conversationQuery(url) {
@@ -178,3 +220,103 @@ export function conversationResponse(reader, url) {
if (out.ok) return { status: 200, body: out };
return { status: REFUSAL_STATUS[out.refusal.code] ?? 422, body: { error: out.refusal.message, refusal: { code: out.refusal.code, reconcile: out.refusal.reconcile } } };
}
export function loadPage(path = join(import.meta.dirname, "page.html")) {
return readFileSync(path, "utf8");
}
// specs: agent specs to scan on each request. boardDir: where scan writes.
export function createServer({ specs, boardDir, isAlive, now, seatsDir = null, discordDataRoot = null, page = loadPage(), isPidAlive, agentSend = DEFAULT_AGENT_SEND, exec = spawnSync, conversationReader = null }) {
const rescan = () => scan(specs, { boardDir, isAlive, now, seatsDir, isPidAlive, discordDataRoot });
const reader = conversationReader ?? createReader({ roots: () => rootsFromSpecs(specs, loadRegistrations(seatsDir).registrations) });
return createHttpServer((req, res) => {
const refused = foreignRequest(req);
if (refused) {
req.resume();
return sendJson(res, 403, { error: refused });
}
const url = new URL(req.url, "http://localhost");
if (req.method === "POST" && url.pathname === "/api/reply") {
return readJsonBody(req)
.then((body) => {
let index;
try {
index = rescan();
} catch (err) {
return sendJson(res, 500, { error: err.message });
}
const out = replyToRow({ index, key: body.agent, text: body.text, agentSend, exec, now });
sendJson(res, out.status, out.body);
})
.catch((err) => sendJson(res, 400, { error: err.message }));
}
if (req.method === "POST" && url.pathname === "/api/seen") {
return readJsonBody(req)
.then((body) => {
try {
markSeen(boardDir, { project: body.project, agent: body.agent, lastActivity: body.lastActivity, seen: body.seen ?? true });
} catch (err) {
return sendJson(res, 400, { error: err.message });
}
try {
sendJson(res, 200, rescan());
} catch (err) {
sendJson(res, 500, { error: err.message });
}
})
.catch((err) => sendJson(res, 400, { error: err.message }));
}
if (req.method !== "GET" && req.method !== "HEAD") {
res.writeHead(405, { "content-type": "text/plain" });
return res.end("method not allowed\n");
}
if (url.pathname === "/" || url.pathname === "/index.html") {
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
return res.end(page);
}
if (url.pathname === "/api/board") {
let index;
try {
index = rescan();
} catch (err) {
res.writeHead(500, { "content-type": "application/json", "cache-control": "no-store" });
return res.end(JSON.stringify({ error: err.message }) + "\n");
}
res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
return res.end(JSON.stringify(index) + "\n");
}
if (url.pathname === "/api/conversations" || url.pathname === "/api/conversation") {
let out;
try {
out = conversationResponse(reader, url);
} catch (err) {
process.stderr.write(`conversation read failed: ${err.message}\n`);
out = { status: 500, body: { error: "conversation read failed" } };
}
return sendConversationJson(res, out.status, out.body);
}
if (url.pathname === "/favicon.ico") {
res.writeHead(204);
return res.end();
}
if (url.pathname === "/healthz") {
res.writeHead(200, { "content-type": "application/json" });
return res.end('{"ok":true}\n');
}
res.writeHead(404, { "content-type": "text/plain" });
res.end("not found\n");
});
}
// Resolves to the listening server. Refuses any non-loopback host.
export async function startServer({ host = "127.0.0.1", port = 7331, ...rest }) {
if (!isLoopbackHost(host)) throw new ConfigError(`refusing to bind to non-loopback host: ${host} (no auth in the MVP)`);
const server = createServer(rest);
return new Promise((resolvePromise, reject) => {
server.once("error", reject);
server.listen(port, host, () => {
server.off("error", reject);
resolvePromise(server);
});
});
}
-129
View File
@@ -1,129 +0,0 @@
import { test, after } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, appendFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { spawnSync } from "node:child_process";
import { createBoard } from "../src/board.mjs";
const pkgRoot = resolve(import.meta.dirname, "..");
const cli = join(pkgRoot, "src", "cli.mjs");
// Track every tmpdir we create so a stray failure never leaves fixtures behind.
const roots = [];
function makeRoot() {
const root = mkdtempSync(join(tmpdir(), "control-board-board-test-"));
roots.push(root);
return root;
}
after(() => {
for (const root of roots) rmSync(root, { recursive: true, force: true });
});
// One agent whose last message is an assistant reply (waiting on the user), so
// a mark on it is visible in the index. No registration; liveness stubbed so no tmux runs.
function fixture() {
const root = makeRoot();
const sessionsDir = join(root, "sessions");
mkdirSync(sessionsDir, { recursive: true });
writeFileSync(
join(sessionsDir, "session.jsonl"),
[
JSON.stringify({ type: "session", id: "s1", timestamp: "2026-10-20T18:59:00Z", cwd: root }),
JSON.stringify({ type: "message", timestamp: "2026-10-20T19:00:00Z", message: { role: "assistant", stopReason: "stop", content: [{ type: "text", text: "Input needed: which branch should I use?" }] } }),
].join("\n") + "\n",
);
const boardDir = join(root, "board");
const specs = [{ agent: "agent1", project: "proj", sessionsDir, tmux: {} }];
const isAlive = () => ({ alive: true, workspace: null });
return { root, boardDir, specs, isAlive };
}
// Pinned order (Webby, Mos mos-1052): markSeen writes, bumps the counter, then scans.
test("markSeen runs write, bump, scan in that order", async () => {
const { boardDir, specs, isAlive } = fixture();
const events = [];
const board = createBoard({ specs, boardDir, isAlive, onEvent: (name) => events.push(name) });
await board.markSeen({ project: "proj", agent: "agent1", lastActivity: "2026-10-20T19:00:00Z" });
assert.deepEqual(events, ["write", "bump", "scan"]);
});
// Mutant target: a scan run before the write, or a join of a pre-write scan,
// would let the answer omit the mark it was asked to show.
test("markSeen answer includes the mark it just wrote", async () => {
const { boardDir, specs, isAlive } = fixture();
const board = createBoard({ specs, boardDir, isAlive });
const before = await board.index();
assert.deepEqual(before.seen, []);
const rec = before.sessions[0];
const index = await board.markSeen({ project: "proj", agent: "agent1", lastActivity: rec.lastActivity });
assert.deepEqual(index.seen, ["proj/agent1"]);
});
// A reply to a row that is not registered is refused before any send, so the
// send and bump events must not appear.
test("reply refuses an unregistered row and never sends", async () => {
const { boardDir, specs, isAlive } = fixture();
const events = [];
const board = createBoard({ specs, boardDir, isAlive, onEvent: (name) => events.push(name), exec: async () => assert.fail("no send on refusal") });
const out = await board.reply({ agent: "proj/agent1", text: "hello" });
assert.equal(out.status, 409);
assert.equal(events.includes("send"), false);
assert.equal(events.includes("bump"), false);
});
// The retired writer refuses to start. Exit 2 with a pointer to the Console.
test("cli serve refuses and points to the Console", () => {
const r = spawnSync(process.execPath, [cli, "serve"], { encoding: "utf8" });
assert.equal(r.status, 2);
assert.match(r.stderr, /retired on 7331/);
assert.match(r.stderr, /webui/);
});
// M15 (Webby N1): index() must not join a scan that started before the last write.
// A scan already answered before the mark would hide the mark; the join is what
// this kills, so the event log must show a fresh scan after the write.
test("index after a write starts a fresh scan and sees the mark (M15)", async () => {
const { boardDir, specs, isAlive } = fixture();
const events = [];
const board = createBoard({ specs, boardDir, isAlive, onEvent: (name) => events.push(name) });
await board.index();
const before = events.length;
await board.markSeen({ project: "proj", agent: "agent1", lastActivity: "2026-10-20T19:00:00Z" });
const after = await board.index();
assert.deepEqual(events.slice(before), ["write", "bump", "scan", "scan"]);
assert.deepEqual(after.seen, ["proj/agent1"]);
});
// B4 (Webby r3): a settled scan is not in flight. With no write between two index()
// calls, the second must start a fresh scan and show activity appended in between.
// The mutant: drop the clear on settle in index(); the second call then joins the
// settled promise, logs one scan, and shows the first view.
test("index after a settled scan starts a fresh scan and shows new activity (B4)", async () => {
const { boardDir, specs, isAlive } = fixture();
const events = [];
const board = createBoard({ specs, boardDir, isAlive, onEvent: (name) => events.push(name) });
await board.index();
appendFileSync(join(specs[0].sessionsDir, "session.jsonl"), JSON.stringify({ type: "message", timestamp: "2026-10-20T19:05:00Z", message: { role: "user", content: [{ type: "text", text: "new activity" }] } }) + "\n");
const second = await board.index();
assert.deepEqual(events, ["scan", "scan"]);
assert.equal(second.sessions[0].lastActivity, "2026-10-20T19:05:00Z");
});
// B4 (Webby r3): a scan that rejects is cleared too. The next index() runs fresh
// and answers, rather than replaying the rejection on every poll.
test("index after a rejected scan starts a fresh scan (B4)", async () => {
const { boardDir, specs, isAlive } = fixture();
const events = [];
let fail = true;
const flaky = (tmux) => {
if (fail) throw new Error("boom");
return isAlive(tmux);
};
const board = createBoard({ specs, boardDir, isAlive: flaky, onEvent: (name) => events.push(name) });
await assert.rejects(board.index(), /boom/);
fail = false;
const answer = await board.index();
assert.deepEqual(events, ["scan", "scan"]);
assert.equal(answer.sessions[0].agent, "agent1");
});
+12 -12
View File
@@ -6,8 +6,7 @@ import { spawnSync } from 'node:child_process';
import { join } from 'node:path';
import { discoverDiscordAgents, inspectDiscord } from '../src/discord.mjs';
import { scan, scanAgent } from '../src/scan.mjs';
import { replyToRow } from '../src/serve.mjs';
import { createBoard } from '../src/board.mjs';
import { replyToRow, startServer } from '../src/serve.mjs';
import { identityOf } from '../../discord/src/journal.mjs';
import { envelope } from '../../discord/src/context.mjs';
import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs';
@@ -116,20 +115,21 @@ test('connector reply refusal precedes forged live tmux registration; ordinary a
r.agent = 'ordinary'; assert.equal(replyToRow({ index: { sessions: [r] }, key: 'fleet/ordinary', text: 'hello', exec }).status, 200); assert.equal(calls, 1);
});
test('board rescans connector discovery and refuses reply without transport', async () => {
const f = fixture();
test('server rescans connector discovery and refuses HTTP reply without transport', async () => {
const f = fixture(); let server;
try {
const sessionsDir = join(f.root, 'sessions', 'discord-demo'), seatsDir = join(f.root, 'seats');
writeRegistration(seatsDir, makeRegistration({ resolved: { seat: 'forged', project: 'fleet', sessionsDir, seatDir: sessionsDir, launchScript: join(f.root, 'unused.sh'), layout: 'repo', defaultWorkspace: f.root }, task: 'forged', tmux: { session: 'forged', socket: null }, pid: process.pid }));
const board = createBoard({ specs: [], seatsDir, boardDir: join(f.root, 'board'), discordDataRoot: f.root, exec: () => { throw Error('transport forbidden'); } });
const payload = await board.index();
assert.equal(payload.sessions[0].state, 'idle'); assert.equal(payload.sessions[0].connector.alive, true);
assert.equal(payload.sessions[0].registered, null); assert.notEqual(payload.sessions[0].task, 'forged');
const { status } = await board.reply({ agent: 'fleet/researcher (discord: demo)', text: 'hello' });
assert.equal(status, 409);
server = await startServer({ port: 0, specs: [], seatsDir, boardDir: join(f.root, 'board'), discordDataRoot: f.root, exec: () => { throw Error('transport forbidden'); } });
const base = `http://127.0.0.1:${server.address().port}`;
let response = await fetch(base + '/api/board'); const board = await response.json();
assert.equal(board.sessions[0].state, 'idle'); assert.equal(board.sessions[0].connector.alive, true);
assert.equal(board.sessions[0].registered, null); assert.notEqual(board.sessions[0].task, 'forged');
response = await fetch(base + '/api/reply', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ agent: 'fleet/researcher (discord: demo)', text: 'hello' }) });
assert.equal(response.status, 409);
rmSync(join(f.root, 'discord', 'demo.json'));
assert.equal((await board.index()).sessions.length, 0);
} finally { f.cleanup(); }
assert.equal((await (await fetch(base + '/api/board')).json()).sessions.length, 0);
} finally { if (server) await new Promise(r => server.close(r)); f.cleanup(); }
});
test('connector session links and linked directories are not read', () => {
@@ -1,44 +0,0 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { existsSync, readFileSync, readdirSync } from "node:fs";
import { join, resolve } from "node:path";
// Row 198: the 7331 writer is retired and the board runs in the Console. These
// are source and doc checks; each names the design mutant it kills (M1, M2, M9).
const repo = resolve(import.meta.dirname, "..", "..", "..");
const read = (path) => readFileSync(join(repo, path), "utf8");
const srcDir = join(repo, "packages", "control-board", "src");
const srcFiles = readdirSync(srcDir).filter((f) => f.endsWith(".mjs"));
// M1: cli.mjs starts no server, not even on the retired port.
test("retired: cli.mjs starts no server (M1)", () => {
assert.doesNotMatch(read("packages/control-board/src/cli.mjs"), /startServer|createServer|\.listen\(/);
});
// M2: nothing in the board's source opens a socket or exports a server.
test("retired: no board source imports http or exports a server (M2)", () => {
for (const file of srcFiles) {
const text = read(join("packages", "control-board", "src", file));
assert.doesNotMatch(text, /from ["']node:http["']|createServer\(|\.listen\(/, file);
assert.doesNotMatch(text, /export (async )?function (startServer|createServer)\b/, file);
}
});
// M9: a live doc or script must not tell anyone to run the retired serve or open 7331.
// A line that says it is retired is allowed. Append-only logs and plans are not scanned.
test("retired: live docs and scripts do not tell anyone to run control-board serve (M9)", () => {
const liveDocs = ["README.md", "AGENTS.md"];
for (const pkg of readdirSync(join(repo, "packages"))) {
if (existsSync(join(repo, "packages", pkg, "README.md"))) liveDocs.push(join("packages", pkg, "README.md"));
}
for (const file of readdirSync(join(repo, "scripts"))) {
if (file.endsWith(".sh")) liveDocs.push(join("scripts", file));
}
const retiredUse = /control-board\/src\/cli\.mjs serve|control-board serve\b|127\.0\.0\.1:7331/;
const retiredNote = /retired|no longer|exits 2/i;
for (const file of liveDocs) {
read(file).split("\n").forEach((line, i) => {
if (retiredUse.test(line) && !retiredNote.test(line)) assert.fail(`${file}:${i + 1} tells people to run the retired board: ${line.trim()}`);
});
}
});
File diff suppressed because it is too large. Load diff
+4 -6
View File
@@ -1,6 +1,6 @@
{
"prd": "docs/prd/mosaic-stack.md",
"version": "1.17",
"version": "1.15",
"requirements": [
{ "id": "REQ-ROLE-1", "title": "Each role is a reviewed roles/<name>.json: contract, authority map, credentials" },
{ "id": "REQ-ROLE-2", "title": "One holder per role instance, one per assistant session; the chief of staff may end a run, others need a decision" },
@@ -77,15 +77,13 @@
{ "id": "REQ-SESS-1", "title": "Each session shows its lifetime count of completed compactions on pi and Claude Code; an unproven count shows unknown" },
{ "id": "REQ-SESS-2", "title": "Clear context retires a session after a safe stop and starts a fresh successor with none of the old context" },
{ "id": "REQ-SESS-3", "title": "Every session start passes the launch checks: authority, route, revocation, kill switch, limits and admission" },
{ "id": "REQ-LIB-1", "title": "A human-set library maps the user's repository onto universes, books, types, playgrounds, supporting and archive folders" },
{ "id": "REQ-LIB-2", "title": "Scope comes from the path or a DOCX's source, lookups default to canon, and only canon files are ever cited as canon" },
{ "id": "REQ-LIB-1", "title": "A library maps the user's repository onto universes, books, types, playgrounds and supporting folders, set by a human" },
{ "id": "REQ-LIB-2", "title": "Scope comes from the path, lookups default to canon, and a playground or supporting file is never cited as canon" },
{ "id": "REQ-LIB-3", "title": "Universes stay separate; a crossover is a hybrid universe only a human creates, reading its sources' canon" },
{ "id": "REQ-LIB-4", "title": "Git holds every change. The bus fast-forwards a read-only mirror every 60 seconds, the one place agents read Markdown" },
{ "id": "REQ-LIB-4", "title": "Git holds every change; the bus fast-forwards a read-only mirror every 60 seconds and agents read only that" },
{ "id": "REQ-LIB-5", "title": "Agents check the library through typed lookup tools that name the commit and show links out and in" },
{ "id": "REQ-LIB-6", "title": "Agents never write story, only docs in supporting folders a human marks. Chat is free text, and any citation is checked" },
{ "id": "REQ-LIB-7", "title": "One chat room with several creative agents, addressed by leading @tags; an unknown tag sends nothing" },
{ "id": "REQ-LIB-8", "title": "Case 2 story files become DOCX by a human-only one-way import or in the editor; Markdown stays for notes and docs" },
{ "id": "REQ-LIB-9", "title": "The Console's DOCX editor: only a human saves, each save is one commit, and the editor code is vendored and pinned" },
{ "id": "REQ-INST-1", "title": "The installer offers optional components: pinned, health-checked, removable, switched on in config; first is SearXNG" },
{ "id": "REQ-INST-2", "title": "With no OIDC provider to configure, the installer deploys a bundled Pocket ID, Q203 (post-v1)" },
{ "id": "REQ-INST-3", "title": "One-command install of a named instance from a profile, and one-command upgrade with backup and rollback" }
+3 -3
View File
@@ -18,10 +18,10 @@ test("the list holds the PRD's requirement ids, in its order, at its version", (
const ids = [...prd.matchAll(/^- \*\*(REQ-[A-Z]+-[0-9]+)\.\*\*/gm)].map((m) => m[1]);
// Any other mention of a bold id would be a bullet this pattern missed.
assert.equal(ids.length, [...prd.matchAll(/\*\*REQ-/g)].length);
assert.equal(ids.length, 87);
assert.equal(ids.length, 85);
assert.deepEqual(list.requirements.map((r) => r.id), ids);
assert.match(prd, new RegExp(`^- Status: approved, version ${list.version.replace('.', '\\.')}\\.`, 'm'));
assert.deepEqual([list.prd, list.version], ['docs/prd/mosaic-stack.md', '1.17']);
assert.deepEqual([list.prd, list.version], ['docs/prd/mosaic-stack.md', '1.15']);
});
test('the committed list loads frozen', () => {
@@ -74,7 +74,7 @@ test('the schema refuses any other shape', () => {
const twice = raw();
twice.requirements.push(structuredClone(twice.requirements[0]));
writeFileSync(join(dir, 'twice.json'), JSON.stringify(twice));
assert.throws(() => loadRequirements(join(dir, 'twice.json')), listError('requirements[87].id: REQ-ROLE-1 twice'));
assert.throws(() => loadRequirements(join(dir, 'twice.json')), listError('requirements[85].id: REQ-ROLE-1 twice'));
} finally {
rmSync(dir, { recursive: true, force: true });
}
+10 -29
View File
@@ -4,22 +4,22 @@ Piece 4, #1507. The control board is the first and only WebUI screen.
## Run
The board runs inside the Console (row 198), so there is one server to start:
Start the existing board in one terminal, then the WebUI in another:
```sh
node packages/control-board/src/cli.mjs serve
node packages/webui/src/cli.mjs serve
```
Open http://127.0.0.1:7330/. Optional `--port N` selects another port. There
is no separate board server (`control-board serve` is retired and exits 2) and
no `--board` flag (it is refused).
Open http://127.0.0.1:7330/. Optional `--port N` and
`--board http://127.0.0.1:7331` select another port or loopback board origin.
`--business ID` picks the business the bus views read; without it they read
the running bus host's business (`mosaic bus start <business>`).
Port 0 picks a free port. Ctrl-C stops each foreground server. No daemon,
installation, account, login or deployment is added. The preview mode below
checks one header a sign-in proxy sets; it signs nobody in itself.
The WebUI binds to IPv4 loopback
Only HTTP loopback board origins are accepted. The WebUI binds to IPv4 loopback
by default, rejects a repeated Host (400), nonlocal Host and cross-origin
requests, sends no CORS
headers, refuses redirects and accepts only JSON object POST bodies up to 4096
@@ -60,11 +60,9 @@ In preview mode:
`Host: NAME` would otherwise skip the sign-in check.
- Every method other than GET and HEAD answers 403 `read-only`, whatever the
Host. `/api/seen` and `/api/reply` never reach the board, so Seen and Reply
fail in the page with that refusal. The exceptions are POST `/api/bus/message`
when the Console was started with `--send` (next section), and POST
`/api/seen` and `/api/reply` when the Console runs with `--send` on the
public name (row 198: the board runs in-process, so these writes reach it
directly; without `--send` they answer 403 `read-only`).
fail in the page with that refusal. The one exception is POST
`/api/bus/message` when the Console was started with `--send` (next
section).
- The GET routes are unchanged: the bus views, the Queue, Business and
Settings reads, the board panel and the assets. If the board is down, its
panel shows the existing error and the bus views still answer.
@@ -337,28 +335,13 @@ note is `agents/dewey/work/wui/SLICE1-VIEWS.md`.
same functions, so both show the same broker data.
- Nothing writes but the "Message a role" box, the Settings update, the Assistant
section's three buttons and the spec and docs editor's Save, all under `--send` (above,
"Assistant settings" and "Spec and docs editor"), and the decision Approve and Deny below.
A decision shows `mosaic decide <id> <key>` for each choice,
"Assistant settings" and "Spec and docs editor"). A decision shows `mosaic decide <id> <key>` for each choice,
with Copy, which only puts the command on the clipboard. It is answered in a
terminal (REQ-DEC-3, Q4). Seen is not set from the browser either. The
command has no `--business`, so `mosaic decide` uses the running bus host's
business, as the Console does by default. From a Console started with
`--business` for another business the copied command refuses (no bus host,
or no such decision) rather than answering elsewhere.
- Row 252 (#1753, PRD 1.9 REQ-WEB-2): a grant request (`credential.mint` routed
to the human, raised with a grant and two options) can also be approved or
denied on its decision page, only when the Console runs with both `--sso` and
`--send` and the page is signed in as a `console.sso` subject. The first click
POSTs `/api/decisions/<id>/confirmation` and shows what the answer does; only
Confirm POSTs `/resolve` with that one-use confirmation (two minutes, bound to
the session, decision and choice). Each step spawns
`packages/bus/src/console-cli.mjs`, which the bus verifies as the Console's
child holding the signed-in subject, and the bus records the answer `via
console` with a `human.input` naming that subject. Nothing retries; an unknown
outcome says to refresh. Without `--sso` the page has no buttons and says why;
under `--sso` without `--send` it says the Console is read-only for decisions.
The tester listener has no decision routes. Every other decision stays
terminal-only.
- What no Q1 verb returns is labelled where it would show, for example
"Bot names: not in the Q1 module" (lead decision 63). Nothing is guessed.
- A task's list row can only say its current state came from a Vikunja read,
@@ -953,9 +936,7 @@ completion Seen and find it again in Seen. Record any reason to open the board's
own page or a repo seat terminal in DEFERRED.md. Pass is Jason's end-of-day
say-so, not a test result. Keep #1507 open pending that ruling.
Rollback requires only stopping the WebUI. The board runs inside it, so there is no separate board to keep running.
A move to a pre-198 copy, by rollback or by activate, has no board until the next re-pin. The plan drops a recorded `--board` (the 7331 board is retired and the install refuses the flag), and that Console runs without a board until a copy that has the in-process board is activated.
Rollback requires only stopping the WebUI and using the unchanged board at 7331.
Revert the scoped WebUI commit to remove it; there is no data migration to undo.
Seen and reply actions already taken belong to the board and are not rolled back.
+11 -33
View File
@@ -14,7 +14,6 @@ import { fileURLToPath } from 'node:url';
import { HUMAN_CLI } from '../../cli/src/transport.mjs';
import { readHostState } from '../../cli/src/host.mjs';
import { views } from '../../bus/src/views.mjs';
import { CONSOLE_VERBS } from '../../bus/src/broker.mjs';
export class BusReadError extends Error {
constructor(code, message = code) { super(message); this.code = code; }
@@ -28,13 +27,10 @@ export const subjectOk = s => typeof s === 'string' && s.length > 0 && s.length
// request on stdin, JSON on stdout, or a bus error code as the last
// `^[a-z-]{1,64}$` line of stderr.
export function humanCall({ socket, business, env = process.env, cli = HUMAN_CLI, timeoutMs = 30000, maxBytes = 8 * 1024 * 1024 }) {
return (verb, args = {}) => transport({ cli, socket, env: cli === HUMAN_CLI ? { ...env, MOSAIC_BUS_CLI_NONCE: randomBytes(32).toString('hex') } : env, input: { business, verb, args }, timeoutMs, maxBytes });
}
function transport({ cli, socket, env, input, timeoutMs, maxBytes }) {
return new Promise((resolve, reject) => {
return (verb, args = {}) => new Promise((resolve, reject) => {
let child;
try {
child = spawn(process.execPath, [cli, socket], { env, stdio: ['pipe', 'pipe', 'pipe'] });
child = spawn(process.execPath, [cli, socket], { env: cli === HUMAN_CLI ? { ...env, MOSAIC_BUS_CLI_NONCE: randomBytes(32).toString('hex') } : env, stdio: ['pipe', 'pipe', 'pipe'] });
} catch { return reject(new BusReadError('outcome-unknown', 'the bus transport did not start')); }
const out = [], err = [];
let size = 0, settled = false;
@@ -57,42 +53,24 @@ function transport({ cli, socket, env, input, timeoutMs, maxBytes }) {
catch { done(reject, new BusReadError('invalid-response')); }
});
child.stdin.on('error', () => {}); // A child that exits early reports through 'close'.
child.stdin.end(`${JSON.stringify(input)}\n`);
child.stdin.end(`${JSON.stringify({ business, verb, args })}\n`);
});
}
// Row 252 (#1753, design section 2): the Console owner's answer to a grant request. Each call spawns
// a fresh console-cli with a fresh nonce, and the business and the signed-in (provider, iss, sub) go
// in its spawn environment, where the bus reads them back. Inherited console keys are dropped first,
// so only this call's values reach the wrapper. Only the two console verbs pass (N4). A resolve pins
// the business its confirmation was issued for.
export const CONSOLE_CLI = fileURLToPath(new URL('../../bus/src/console-cli.mjs', import.meta.url));
export function consoleCall({ dataRoot, socket, business = null, env = process.env, cli = CONSOLE_CLI, timeoutMs = 30000 }) {
return async (verb, args, { subject, business: pinned = null }) => {
if (!CONSOLE_VERBS.includes(verb)) throw new Error(`the Console does not call ${verb}`);
const id = pinned ?? (business || hostBusiness(dataRoot));
const own = Object.fromEntries(Object.entries(env).filter(([k]) => !k.startsWith('MOSAIC_BUS_CONSOLE_')));
return transport({
cli, socket, timeoutMs, maxBytes: 1024 * 1024, input: { verb, args },
env: { ...own, MOSAIC_BUS_CONSOLE_NONCE: randomBytes(32).toString('hex'), MOSAIC_BUS_CONSOLE_BUSINESS: id,
MOSAIC_BUS_CONSOLE_PROVIDER: subject.provider, MOSAIC_BUS_CONSOLE_ISS: subject.iss, MOSAIC_BUS_CONSOLE_SUB: subject.sub },
});
};
}
// The business is the --business flag, or else the live bus host's, read
// on every request so a host started after the Console is picked up.
export function busCaller({ dataRoot, socket, business = null, env = process.env, cli = HUMAN_CLI, timeoutMs = 30000 }) {
return async (verb, args) => {
return humanCall({ socket, business: business || hostBusiness(dataRoot), env, cli, timeoutMs })(verb, args);
let id = business;
if (!id) {
let state;
try { state = readHostState(dataRoot); } catch { throw new BusReadError('no-bus-host', 'the bus host state file is unreadable'); }
if (!state?.live) throw new BusReadError('no-bus-host', 'no bus host is running and no --business was given');
id = state.business;
}
return humanCall({ socket, business: id, env, cli, timeoutMs })(verb, args);
};
}
function hostBusiness(dataRoot) {
let state;
try { state = readHostState(dataRoot); } catch { throw new BusReadError('no-bus-host', 'the bus host state file is unreadable'); }
if (!state?.live) throw new BusReadError('no-bus-host', 'no bus host is running and no --business was given');
return state.business;
}
export const busReader = options => views({ call: busCaller(options) });
// FED R6 (#1800): a granted user's bus transport, R3's user-cli.mjs (#414, design 3.6). Same
+11 -26
View File
@@ -1,14 +1,12 @@
#!/usr/bin/env node
// MOSAIC_TESTER_SOCKET_V1: private tester ingress, no tester TCP.
import { createChat } from './chat.mjs';
import { startServer, previewOptions, proofOptions } from './serve.mjs';
import { busCaller, busBusiness, userCaller, consoleCall } from './bus.mjs';
import { startServer, previewOptions, proofOptions, DEFAULT_BOARD } from './serve.mjs';
import { busCaller, busBusiness, userCaller } from './bus.mjs';
import { consoleBus, userOptionsFor, agentCaller, TESTER_AGENT_VERBS } from './console-bus.mjs';
import { consoleAccess } from './access.mjs';
import { accessContextFor } from '../../cli/src/access.mjs';
import { accessFilePath, loadAccess } from '../../business/src/access.mjs';
import { createBoard } from '../../control-board/src/board.mjs';
import { discoverRepoAgents, discoverFleetAgents } from '../../control-board/src/scan.mjs';
import { startTesterSocket } from './tester-socket.mjs';
import { TESTER_CLI } from '../../cli/src/transport.mjs';
import { views } from '../../bus/src/views.mjs';
@@ -17,15 +15,14 @@ import { configDir } from '../../business/src/business.mjs';
import { consoleReads, redactor } from './reads.mjs';
import { createSso, readSlot } from './sso.mjs';
import { statSync } from 'node:fs';
import { homedir } from 'node:os';
import { isAbsolute, join } from 'node:path';
import { isAbsolute } from 'node:path';
import { ID_PATTERN } from '../../business/src/vocabulary.mjs';
const args = process.argv.slice(2);
const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--tester-socket] [--checkout PATH] [--business ID] [--sso [--send]] [--public-host NAME --auth-header HEADER --allow-user ID [--send] [--proof-header NAME --proof-file PATH]]';
const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--tester-socket] [--checkout PATH] [--board http://127.0.0.1:7331] [--business ID] [--sso [--send]] [--public-host NAME --auth-header HEADER --allow-user ID [--send] [--proof-header NAME --proof-file PATH]]';
try {
if (args.length === 1 && ['--help', '-h'].includes(args[0])) { console.log(usage); process.exit(0); }
if (args.shift() !== 'serve') throw new Error(usage);
const options = { port: 7330 }, preview = {}, proofFlags = {};
const options = { board: DEFAULT_BOARD, port: 7330 }, preview = {}, proofFlags = {};
let sendOn = false, ssoOn = false, testerSocket = false;
while (args.length) {
const flag = args.shift();
@@ -35,14 +32,12 @@ try {
if (flag === '--sso') { ssoOn = true; continue; }
if (flag === '--tester-socket') { testerSocket = true; continue; }
if (flag === '--tester-port') throw new Error('--tester-port retired; activate explicitly with --tester-socket');
// Row 198: the board runs inside the Console; there is no board origin to point at.
if (flag === '--board') throw new Error('--board retired: the board runs in the Console (row 198)');
const value = args.shift();
if (!value) throw new Error(`missing value for ${flag}`);
if (flag === '--port') {
if (!/^\d+$/.test(value) || Number(value) > 65535) throw new Error('port must be 0..65535');
options.port = Number(value);
}
} else if (flag === '--board') options.board = value;
// Row 77 (#1571): the canonical checkout the queue read runs from, when
// serve runs from a pinned copy; everything else loads from the copy.
else if (flag === '--checkout') {
@@ -73,7 +68,7 @@ try {
// the system config, Business can't resolve authority and Settings has
// no notifier. A refusal printed here names no path (redactor).
// --send without a bus refuses to start rather than serve a composer that can't send.
let bus = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, libraryFile = null, libraryStatus = null, librarySync = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, decisionWrite = null, busLine, system = null;
let bus = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, libraryFile = null, libraryStatus = null, librarySync = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, busLine, system = null;
let processing = null, ownerCall = null;
try {
system = loadSystem();
@@ -81,9 +76,6 @@ try {
const call = ownerCall = busCaller({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null });
// FED R6 (#1800): the bus-backed options, shared with each granted user's session (console-bus.mjs).
({ processing, chat, bus, projectFiles, projectFile, libraryFile, libraryStatus, send, librarySync, settingsWrite, projectSave, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite } = consoleBus(call, { send: sendOn }));
// Row 252 (#1753): Approve and Deny on grant requests, through the console wrapper, only with
// --sso and --send.
if (ssoOn && sendOn) decisionWrite = consoleCall({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null });
busLine = `Bus: ${options.business ?? 'the running bus host\'s business'}`;
} catch (err) {
const why = redactor({ configDir: configDir() })(err.message);
@@ -92,13 +84,6 @@ try {
if (testerSocket) throw new Error(`--tester-socket needs the bus configured (${why})`);
busLine = `Bus: not configured (${why})`;
}
// Row 198: the board runs in-process over the system config's data root and reads the same repository
// agents as before, plus the fleet's read-only specs (the old board's default). Without the system
// config there is no data root, so the board routes answer not-configured.
const board = system ? createBoard({
specs: [...discoverRepoAgents(options.checkout ?? REPO), ...discoverFleetAgents(join(homedir(), '.mosaic', 'fleet', 'agents'))],
boardDir: join(system.dataRoot, 'board'), seatsDir: join(system.dataRoot, 'seats'), discordDataRoot: system.dataRoot,
}) : null;
const reads = consoleReads({ root: REPO, queueRoot: options.checkout ?? REPO, system, configDir: configDir(), rolesDir: rolesDir(), business: options.business ?? null });
// The sign-in config is checked again here, then each slot file, before anything listens. A
// provider that can't be reached starts degraded; one whose discovery is refused stops the start.
@@ -119,14 +104,14 @@ try {
}
let server;
try {
server = await startServer({ board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, libraryFile, libraryStatus, librarySync, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, decisionWrite, userOptions, federation: system?.federation ?? null });
server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, libraryFile, libraryStatus, librarySync, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, userOptions, federation: system?.federation ?? null });
} catch (err) { sso?.close(); throw err; }
let testerServer = null;
if (testerSocket) {
const call = busCaller({ dataRoot: system.dataRoot, socket: socketPath(system.dataRoot), business: options.business ?? null, cli: TESTER_CLI });
const testerBus = { ...views({ call }), names: () => call('names'), instances: () => call('instances'), routes: () => call('routes') };
try {
testerServer = await startTesterSocket({ dataRoot: system.dataRoot, board,
testerServer = await startTesterSocket({ dataRoot: system.dataRoot, board: options.board,
tester: true, testerProofHeader: proof?.header ?? null, testerAuthHeader: options.preview?.header ?? null, bus: testerBus, reads,
processing: { read: () => call('processing.status') },
projectFiles: args => call('project.files', args), projectFile: args => call('project.file', args),
@@ -143,9 +128,9 @@ try {
const proofLine = `\nProxy: proxy proof ${proof ? 'on' : 'off'}`;
// One line per provider: id, issuer, subject count and state. No client id, secret or sub.
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 ssoLines = sso ? `\nSign-in: ${sso.publicOrigin}/ (--sso), ${send ? 'reads and human writes (--send)' : 'read-only'}` + sso.summary().map(p => `\nSign-in provider: ${p.id} issuer ${p.issuer} subjects ${p.subjects} (${health[p.id]})`).join('') : '';
const queueLine = options.checkout ? '\nQueue: read from the --checkout directory' : '';
console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${board ? 'in the Console (no port)' : 'not configured (no system config)'}\n${busLine}${queueLine}${previewLine}${ssoLines}${proofLine}${testerServer ? `\nTester Console: unix:${testerServer.address()} (binds the tester)` : ''}\nCtrl-C stops this server.`);
console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${options.board}\n${busLine}${queueLine}${previewLine}${ssoLines}${proofLine}${testerServer ? `\nTester Console: unix:${testerServer.address()} (binds the tester)` : ''}\nCtrl-C stops this server.`);
for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => { sso?.close(); server.close(); testerServer?.close(); });
} catch (err) {
console.error(`refused: ${err.message}`);
-148
View File
@@ -1,148 +0,0 @@
// Row 252 (#1753, Gander's design approved by Webby at 5d8f0f4b5): Approve and Deny for a grant
// request, from the signed-in Console only. Two POSTs. /confirmation asks the bus for the decision's
// two answers and digest and returns a one-use confirmation bound to this session, business,
// decision, choice and digest. /resolve takes only that choice and confirmation, consumes the
// confirmation before it awaits the bus, and asks the bus to resolve with the digest. The bus
// rechecks everything in the resolve transaction. Nothing here retries.
import { createHash, randomBytes } from 'node:crypto';
import { BusReadError, subjectOk } from './bus.mjs';
export const DECISION_ROUTE = /^\/api\/decisions\/([^/]*)\/(confirmation|resolve)$/;
// The endpoint contract, design section 4. Each refusal is one of these, never a 200.
export const DECISION_REFUSALS = Object.freeze({
'sign-in-required': [401, 'Sign in again before answering.'],
'read-only': [403, 'Console decision answers are not enabled. Use mosaic decide.'],
'cross-origin': [403, 'Answer from the signed-in Console page.'],
'not-found': [404, 'Not found.'],
'human-required': [403, 'The bus refused this human identity.'],
'peercred-required': [403, 'The bus could not verify this connection.'],
'method-not-allowed': [405, 'Use POST.'],
'invalid-request': [400, 'Invalid decision answer.'],
'request-too-large': [413, 'Decision answer is too large.'],
'decision-not-found': [404, 'This decision is unavailable.'],
'decision-not-eligible': [403, 'Answer this decision with mosaic decide.'],
'decision-closed': [409, 'This decision is no longer open. Refresh the inbox.'],
'decision-stale': [409, 'The choices changed. Review the decision again.'],
'confirmation-required': [409, 'Review and confirm this answer again.'],
'confirmation-unavailable': [503, 'Confirmation is unavailable. Try again later.'],
'outcome-unknown': [503, 'The answer may have reached the bus. Refresh before trying again.'],
});
// Bus codes by name. A bus that refuses this caller is human-required, whatever its code. Any
// other code, or no answer, is confirmation-unavailable at the preview and outcome-unknown at the
// resolve, which the page reads as "refresh first".
const BUS_CODES = {
'human-required': 'human-required', unauthenticated: 'human-required', 'unknown-business': 'human-required',
'access-refused': 'human-required', 'access-revoked': 'human-required', 'agent-required': 'human-required',
'peercred-required': 'peercred-required',
'decision-not-found': 'decision-not-found', 'decision-not-eligible': 'decision-not-eligible',
'decision-closed': 'decision-closed', 'decision-stale': 'decision-stale', 'invalid-choice': 'decision-stale',
'invalid-request': 'invalid-request',
};
const BODY_MAX = 4096;
const TOKEN = /^[a-f0-9]{64}$/;
const DIGEST = /^[a-f0-9]{64}$/;
const one = (req, name) => (req.headersDistinct[name]?.length === 1 ? req.headersDistinct[name][0] : null);
const hash = (token) => createHash('sha256').update(token).digest('hex');
export function decisionRefuse(res, code) {
const [status, message] = DECISION_REFUSALS[code];
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
res.end(JSON.stringify({ error: code, message }));
}
// The confirmations, in this process's memory only and stored by hash. 120 seconds, at most 1024
// live. A full store refuses a new one rather than drop a live one. An expired one is swept.
export function createConfirmations({ ttlMs = 120000, max = 1024, now = Date.now } = {}) {
const live = new Map();
const sweep = () => { const t = now(); for (const [k, v] of live) if (v.expires <= t) live.delete(k); };
return {
issue(binding) {
sweep();
if (live.size >= max) return null;
const token = randomBytes(32).toString('hex');
live.set(hash(token), { ...binding, expires: now() + ttlMs });
return token;
},
// One use: a token found is gone, whether or not it matches.
consume(token, { session, decision, choice }) {
sweep();
if (typeof token !== 'string' || !TOKEN.test(token)) return null;
const k = hash(token), v = live.get(k);
if (!v) return null;
live.delete(k);
return v.session === session && v.decision === decision && v.choice === choice ? v : null;
},
get size() { sweep(); return live.size; },
};
}
async function readBody(req) {
if (one(req, 'content-type')?.split(';')[0].trim().toLowerCase() !== 'application/json') return { code: 'invalid-request' };
const chunks = [];
let size = 0;
for await (const chunk of req) {
size += chunk.length;
if (size > BODY_MAX) return { code: 'request-too-large' };
chunks.push(chunk);
}
try {
const value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
return value && typeof value === 'object' && !Array.isArray(value) ? { value } : { code: 'invalid-request' };
} catch { return { code: 'invalid-request' }; }
}
const preview = (v, id) =>
v && typeof v === 'object' && v.id === id && typeof v.business === 'string' && DIGEST.test(v.digest ?? '') &&
['approve', 'deny'].every((k) => typeof v[k]?.key === 'string' && typeof v[k]?.text === 'string') &&
v.approve.key !== v.deny.key && v.grant && typeof v.grant.method === 'string';
// `session` is the live sign-in session object this request passed, and `live()` reads it again
// right before each spawn. `call(verb, args, { subject, business })` is the console transport.
export async function decisionHTTP(req, res, { path, decide, session, live, confirmations, log }) {
const [, raw, step] = DECISION_ROUTE.exec(path);
let id = null;
try { id = decodeURIComponent(raw); } catch { /* refused below */ }
if (req.method !== 'POST') return decisionRefuse(res, 'method-not-allowed');
if (!decide) return decisionRefuse(res, 'read-only');
if (!session) return decisionRefuse(res, 'sign-in-required');
if (one(req, 'origin') !== decide.origin || one(req, 'sec-fetch-site') !== 'same-origin') return decisionRefuse(res, 'cross-origin');
const line = (outcome) => log(`${new Date().toISOString()} decide user=${JSON.stringify(String(session.name ?? '').slice(0, 64))} decision=${JSON.stringify(String(id ?? '').slice(0, 64))} step=${step} ${outcome}`);
const refuse = (code) => { line(`error=${code}`); return decisionRefuse(res, code); };
const { value, code } = await readBody(req);
if (code) return refuse(code);
if (!subjectOk(id)) return refuse('decision-not-found');
const subject = { provider: session.provider, iss: session.iss, sub: session.sub };
const busCode = (err, open) => {
const c = err instanceof BusReadError ? BUS_CODES[err.code] : undefined;
return c ?? open;
};
if (step === 'confirmation') {
if (Object.keys(value).length !== 1 || typeof value.choice !== 'string') return refuse('invalid-request');
if (live() !== session) return refuse('sign-in-required');
let v;
try { v = await decide.call('decision.preview.console', { id }, { subject }); } catch (err) { return refuse(busCode(err, 'confirmation-unavailable')); }
if (!preview(v, id)) return refuse('confirmation-unavailable');
const action = value.choice === v.approve.key ? 'approve' : value.choice === v.deny.key ? 'deny' : null;
if (!action) return refuse('decision-stale');
if (live() !== session) return refuse('sign-in-required');
const confirmation = confirmations.issue({ session, business: v.business, decision: id, choice: value.choice, digest: v.digest });
if (!confirmation) return refuse('confirmation-unavailable');
line(`action=${action}`);
return json(res, 200, { id, question: typeof v.question === 'string' ? v.question : '', action, choice: value.choice, text: v[action].text, grant: v.grant, confirmation });
}
const keys = Object.keys(value);
if (keys.length !== 2 || !keys.includes('choice') || !keys.includes('confirmation') || typeof value.choice !== 'string') return refuse('invalid-request');
// Consumed before any await on the bus, so a second click finds nothing (J12).
const held = confirmations.consume(value.confirmation, { session, decision: id, choice: value.choice });
if (!held) return refuse('confirmation-required');
if (live() !== session) return refuse('sign-in-required');
let r;
try {
r = await decide.call('decision.resolve.console', { id, choice: held.choice, digest: held.digest }, { subject, business: held.business });
} catch (err) { return refuse(busCode(err, 'outcome-unknown')); }
if (r?.id !== id || r.op !== 'resolved' || r.choice !== held.choice) return refuse('outcome-unknown');
line(`choice=${JSON.stringify(held.choice.slice(0, 64))}${r.grant ? ` grant=${r.grant}` : ''}`);
return json(res, 200, { id, op: 'resolved', choice: r.choice, ...(r.grant === 'started' ? { grant: 'started' } : {}) });
}
function json(res, status, body) {
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
res.end(JSON.stringify(body));
}
+5 -8
View File
@@ -7,7 +7,7 @@
// Delivered replies with no newer answer yet, by row: the board's sentAt. Page memory only.
const awaiting = new Map();
let data = null, project = null, selected = null, returnFocus = null, paused = false, busy = false, writing = false, reading = null, timer;
let board = 'the Console board', palette = 'harbor', mode = 'system', failed = false;
let board = 'the configured board', palette = 'harbor', mode = 'system', failed = false;
// Show machine sessions (#1589): off by default, kept with the page's settings. While it is off the
// page doesn't read /api/board at all; the held roles above come from the bus (bus.js).
let machine = false;
@@ -20,8 +20,6 @@
// `sign-in required`; that signs the page out too, and the line links to the Console's sign-in.
// Any other 401 stays an error. `sso` comes from /api/config.
let sso = false;
// Row 198 (mos-1512): the reply form and the Seen button draw only when /api/config says send; otherwise a line says so.
let canSend = false;
const signInRequired = (res, body) => res.status === 401 && body?.error === 'sign-in required';
const signedOut = () => Object.assign(new Error('the sign-in proxy redirected it, so it never reached the Console'), { signedOut: true });
const labels = { registration: 'registered', 'first-user-message': 'first message', 'tmux-pane': 'tmux pane', 'session-cwd': 'session cwd', 'workspace-git-root': 'git root' };
@@ -48,7 +46,7 @@
const s = Date.parse(sent), a = Date.parse(r.lastActivity);
return Number.isFinite(s) && Number.isFinite(a) && a < s;
}
const canReply = r => canSend && !r.connector && r.registered && r.registered.alive !== false && r.registered.tmux?.session;
const canReply = r => !r.connector && r.registered && r.registered.alive !== false && r.registered.tmux?.session;
const row = id => data?.sessions.find(r => key(r) === id);
const announce = text => { $('announce').textContent = text; };
// Colours come from tokens.css by data-palette and data-mode. System removes data-mode, so the OS setting picks light or dark.
@@ -68,7 +66,6 @@
$('mode').onchange = e => { mode = e.target.value; theme(); };
function seenButton(r) {
if (!['waiting', 'error'].includes(r.state) || !r.lastActivity) return '';
if (!canSend) return '<span class="small muted">Seen is off: this Console is read-only.</span>';
return `<button type="button" class="btn" data-seen="${esc(key(r))}" aria-label="${r.seen ? 'Unsee' : 'Mark seen'} ${esc(r.agent)}">${r.seen ? 'Unsee' : 'Seen'}</button>`;
}
const historyButton = r => `<button type="button" class="btn conv-open" data-history="${esc(key(r))}" aria-label="History for ${esc(r.agent)}">History</button>`;
@@ -97,7 +94,7 @@
if (r.relaunchedAt) fields.unshift(['Current activity', relaunchNotice(r)]);
if (r.connector) fields.push(['Connector', `${r.connector.braked === true ? 'braked (STOP)' : r.connector.braked === false ? 'not braked' : 'brake unknown'}; owner ${r.connector.ownerState}`]);
const receipt = receipts.get(selected);
$('inspection').innerHTML = `<dl class="kv">${fields.map(([k, v]) => `<dt>${k}</dt><dd>${esc(v)}</dd>`).join('')}${pending(r) ? `<dt>Reply</dt><dd>Waiting for a reply to your message sent ${esc(awaiting.get(selected))}. The page checks every 10 seconds${paused ? ' once you resume' : ''}.</dd><dt>${r.relaunchedAt ? 'Historical assistant text' : 'Previous assistant text'}, before your message</dt>` : `<dt>${r.relaunchedAt ? 'Historical last assistant text' : 'Last assistant text'}</dt>`}<dd><pre class="last-text">${esc(r.lastAssistantText || 'No assistant text yet.')}</pre></dd>${r.lastError ? `<dt>${r.relaunchedAt ? 'Historical last error' : 'Last error'}</dt><dd>${esc(r.lastError)}</dd>` : ''}</dl>${historyButton(r)}${seenButton(r)}${canReply(r) ? `<form class="reply-form"><label for="reply">Reply to ${esc(r.agent)}</label><textarea id="reply" maxlength="2000" rows="3" placeholder="Message this seat">${esc(drafts.get(selected) || '')}</textarea><button class="btn primary" type="submit" ${sending.has(selected) ? 'disabled' : ''}>${sending.has(selected) ? 'Sending…' : 'Send'}</button></form>` : `<p class="muted">${r.connector ? 'Board replies disabled for Discord connectors' : !canSend ? 'Replies are off: this Console is read-only.' : 'reply needs a registered seat'}</p>`}${receipt ? `<p class="receipt ${receipt.delivered ? '' : 'failed'}" role="status">${esc(receipt.delivered ? `delivered ${receipt.sentAt} to tmux ${receipt.session}` : `failed${receipt.exitCode == null ? '' : ' (exit ' + receipt.exitCode + ')'}: ${receipt.stderr || receipt.error || 'no output'}`)}</p>` : ''}`;
$('inspection').innerHTML = `<dl class="kv">${fields.map(([k, v]) => `<dt>${k}</dt><dd>${esc(v)}</dd>`).join('')}${pending(r) ? `<dt>Reply</dt><dd>Waiting for a reply to your message sent ${esc(awaiting.get(selected))}. The page checks every 10 seconds${paused ? ' once you resume' : ''}.</dd><dt>${r.relaunchedAt ? 'Historical assistant text' : 'Previous assistant text'}, before your message</dt>` : `<dt>${r.relaunchedAt ? 'Historical last assistant text' : 'Last assistant text'}</dt>`}<dd><pre class="last-text">${esc(r.lastAssistantText || 'No assistant text yet.')}</pre></dd>${r.lastError ? `<dt>${r.relaunchedAt ? 'Historical last error' : 'Last error'}</dt><dd>${esc(r.lastError)}</dd>` : ''}</dl>${historyButton(r)}${seenButton(r)}${canReply(r) ? `<form class="reply-form"><label for="reply">Reply to ${esc(r.agent)}</label><textarea id="reply" maxlength="2000" rows="3" placeholder="Message this seat">${esc(drafts.get(selected) || '')}</textarea><button class="btn primary" type="submit" ${sending.has(selected) ? 'disabled' : ''}>${sending.has(selected) ? 'Sending…' : 'Send'}</button></form>` : `<p class="muted">${r.connector ? 'Board replies disabled for Discord connectors' : 'reply needs a registered seat'}</p>`}${receipt ? `<p class="receipt ${receipt.delivered ? '' : 'failed'}" role="status">${esc(receipt.delivered ? `delivered ${receipt.sentAt} to tmux ${receipt.session}` : `failed${receipt.exitCode == null ? '' : ' (exit ' + receipt.exitCode + ')'}: ${receipt.stderr || receipt.error || 'no output'}`)}</p>` : ''}`;
}
// Conversation view (#1507, CHAT-02): the full read-only history from the board's
// /api/conversations and /api/conversation routes. Session content is set with
@@ -197,7 +194,7 @@
if (!conv) return;
const r = row(conv.row), can = !!r && !!canReply(r), receipt = receipts.get(conv.row);
$('conv-form').hidden = !can; $('conv-no-reply').hidden = can;
$('conv-no-reply').textContent = !r ? 'This session is no longer in the board scan.' : r.connector ? 'Board replies disabled for Discord connectors' : !canSend ? 'Replies are off: this Console is read-only.' : 'reply needs a registered seat';
$('conv-no-reply').textContent = !r ? 'This session is no longer in the board scan.' : r.connector ? 'Board replies disabled for Discord connectors' : 'reply needs a registered seat';
if (r) $('conv-reply-label').textContent = `Reply to ${r.agent}`;
$('conv-send').disabled = sending.has(conv.row); $('conv-send').textContent = sending.has(conv.row) ? 'Sending…' : 'Send';
$('conv-receipt').hidden = !receipt;
@@ -479,5 +476,5 @@
setTimeout(() => { const a = document.activeElement; if (!a || a === document.body || a.disabled || !a.getClientRects().length) line.focus(); });
});
// Sign out (sage-1347, F7): a plain same-origin form POST to /auth/logout, shown only under --sso.
api('/api/config').then(config => { canSend = config.send === true; board = config.board || 'the Console board'; $('board-url').textContent = `Board: ${board}`; window.MosaicFederation?.render(config.federation, $('federation')); if (config.sso === true) { sso = true; $('sign-out').hidden = out(); } }).catch(err => { if (machine) error(err); }).finally(refresh);
api('/api/config').then(config => { board = config.board; $('board-url').textContent = `Board: ${board}`; window.MosaicFederation?.render(config.federation, $('federation')); if (config.sso === true) { sso = true; $('sign-out').hidden = out(); } }).catch(err => { if (machine) error(err); }).finally(refresh);
})();
+9 -123
View File
@@ -190,9 +190,8 @@
out.kind = 'decision.raised'; out.decision = r.id; out.actor = { role: r.raised_by_role, run: r.raised_by_run };
out.text = `Raised a ${r.class} decision for ${r.route_to}: ${String(r.question).split('\n')[0]}`;
} else if (r.table === 'decision_events') {
// The human answers from the CLI (via cli) or, since row 252 (#1753), a grant request from the
// signed-in Console (via console); every other row is the broker's.
out.kind = `decision.${r.op}`; out.decision = r.decision; out.by = r.via === 'cli' ? 'human' : r.via === 'console' ? 'human-console' : 'broker';
// The human answers from the CLI (via cli); every other row is the broker's.
out.kind = `decision.${r.op}`; out.decision = r.decision; out.by = r.via === 'cli' ? 'human' : 'broker';
out.text = r.op === 'resolved' ? `${r.by} chose “${r.choice}” via ${r.via}` : r.op === 'seen' ? `${r.by} saw it${r.via ? ` via ${r.via}` : ''}` : `${r.op[0].toUpperCase()}${r.op.slice(1)} by ${r.by}${r.note ? `: ${r.note}` : ''}`;
} else if (r.table === 'task_snapshots') {
out.kind = `snapshot.${r.source}`;
@@ -228,9 +227,8 @@
// An empty state says what would appear and where it comes from.
const empty = (what, from) => `<div class="empty"><b>${what}</b>${from ? `<p>${from}</p>` : ''}</div>`;
const taskLink = ref => ref ? `<a href="#/tasks/${enc(ref)}">#${esc(String(ref).split('/')[1])}</a>` : '';
// Rows without an actor role come from the human (CLI outside any run, or the signed-in Console)
// or from the broker itself.
const actor = (a, by) => `<span class="source">${a ? `${txt(a.role)} · ${txt(a.run)}` : by === 'human' ? 'human, CLI outside any run' : by === 'human-console' ? 'human, signed-in Console' : 'broker'}</span>`;
// Rows without an actor role come from the human (CLI, outside any run) or from the broker itself.
const actor = (a, by) => `<span class="source">${a ? `${txt(a.role)} · ${txt(a.run)}` : by === 'human' ? 'human, CLI outside any run' : 'broker'}</span>`;
const PRIORITY = ['unset', 'low', 'medium', 'high', 'urgent', 'do now'];
const head = (title, sub) => `<div class="page-head"><div><h1 tabindex="-1">${title}</h1>${sub ? `<p class="small muted" id="bus-status">${sub}</p>` : ''}</div></div>`;
let readAt = null;
@@ -258,7 +256,7 @@
// Inbox
function decisionCard(d) {
return `<li class="wait-item s1-dec${d.blocking ? ' is-attn' : ''}"><div class="wait-head"><a href="#/inbox/${enc(d.id)}" class="s1-dec-open">${txt(first(d.question))}</a></div>
<p class="s1-badges">${d.blocking ? blockChip('blocking') : ''}${chip(d.class, d.action)}${canDecide && d.console ? ` ${badge('Approve or Deny here', 'ok')}` : ''}</p>
<p class="s1-badges">${d.blocking ? blockChip('blocking') : ''}${chip(d.class, d.action)}</p>
<p class="small s1-auth">${approves(d.authorization)}</p>
<p class="small muted">Raised by ${txt(d.raised_by_role)} ${when(d.at)}${d.task_ref ? ` · task ${taskLink(d.task_ref)}` : ''}${d.requirement_ref ? ` · ${txt(d.requirement_ref)}` : ''}</p></li>`;
}
@@ -316,101 +314,12 @@
el.outerHTML = composerHtml();
restore(k);
}
// Row 252 (#1753, design section 5): Approve and Deny for an open grant request, drawn only when
// the server says this Console answers decisions (`decide` in /api/config: signed in through SSO
// and started with --send) and the bus marked the inbox row eligible (its `console` field). The
// first click asks the Console for a one-use confirmation that names what the answer does. Only
// the second click answers. Nothing here retries: a refusal drops the confirmation, and an
// unknown outcome says to refresh first. The state lives in `answer`, not the DOM, so the 10 s
// refresh redraws it. The server and the bus check everything again; this is presentation only.
let canDecide = false, ssoOn = false, answer = null;
const ACTIONS = { approve: 'Approve', deny: 'Deny' };
const grantText = g => [`method ${g.method}`, g.agent && `agent ${g.agent}`, g.host && `host ${g.host}`, Array.isArray(g.projects) && g.projects.length && `projects ${g.projects.join(', ')}`].filter(Boolean).map(s => txt(String(s))).join(' · ');
function decideHtml() {
const a = answer;
if (!a) return '';
const off = a.pending ? ' disabled' : '';
let body;
if (a.result) {
const r = a.result;
body = `<p class="receipt" id="decide-status" tabindex="-1">${r.action === 'approve' ? 'Approved' : 'Denied'}: you chose “${txt(r.choice)}”, ${txt(r.text)}. ${r.grant === 'started' ? 'The grant executor started.' : r.action === 'approve' ? 'The bus started no grant for it.' : 'Nothing starts for a denial.'} History below shows it once the bus is read again.</p>`;
} else if (a.confirm) {
const c = a.confirm, label = `Confirm ${c.action}`;
body = `<div class="s1-confirm" role="group" aria-labelledby="b-confirm"><h3 id="b-confirm" class="small">${label}</h3>
<dl class="kv"><dt>Id</dt><dd><code>${txt(c.id)}</code></dd><dt>Question</dt><dd class="s1-q">${txt(c.question)}</dd><dt>Answer</dt><dd><b class="s1-key">${txt(c.choice)}</b> ${txt(c.text)}</dd><dt>Grant</dt><dd>${grantText(c.grant)}</dd></dl>
<p class="small muted">${c.action === 'approve' ? 'Approving starts this grant.' : 'Denying starts nothing.'} This confirmation works once and lasts two minutes.</p>
<div class="s1-cmd"><button type="button" class="btn primary" data-decide="confirm"${off}>${a.pending ? 'Answering…' : label}</button><button type="button" class="btn" data-decide="cancel"${off}>Cancel</button></div></div>`;
} else {
body = `<ul class="s1-plain">${Object.entries(ACTIONS).map(([k, l]) => `<li><b>${l}</b> chooses <b class="s1-key">${txt(a.console[k].key)}</b> ${txt(a.console[k].text)}</li>`).join('')}</ul>
<p class="small muted">Grant: ${grantText(a.console.grant)}. Either button opens a confirmation first. Nothing is answered until you confirm.</p>
<div class="s1-cmd">${Object.entries(ACTIONS).map(([k, l]) => `<button type="button" class="btn${k === 'approve' ? ' primary' : ''}" data-decide="${k}"${off}>${a.pending === k ? 'Checking…' : l}</button>`).join('')}</div>`;
}
const e = a.error;
const said = e ? `<p class="receipt failed" id="decide-status" tabindex="-1">Not answered here: ${txt(e.message)} (<code>${txt(e.code)}</code>).${e.code === 'outcome-unknown' ? ' Nothing was sent again.' : ''}</p>` : '';
return `<section class="panel s1-decide" id="decide-section" aria-labelledby="b-decide"><h2 id="b-decide">Answer in Console</h2>${body}${said}</section>`;
}
// Redraw only the answer panel. `focus` names what takes focus after it.
function paintDecide(focus) {
const el = $('decide-section');
if (!el) return;
el.outerHTML = decideHtml();
(focus ? $('decide-section')?.querySelector(focus) : null)?.focus();
}
async function decidePost(url, body) {
let res = null, json = null;
try {
res = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), cache: 'no-store', redirect: 'manual' });
try { json = await res.json(); } catch { json = null; }
} catch { res = null; }
return { res, json };
}
// A refusal the Console wrote carries its code and message (design section 4). Anything else, a
// 408 or no answer included, gets the fallback: at the resolve step that is outcome-unknown.
const DECIDE_FALLBACK = {
'confirmation-unavailable': 'Confirmation is unavailable. Try again later.',
'outcome-unknown': 'The answer may have reached the bus. Refresh before trying again.',
};
const decideRefusal = (res, json, fallback) => res && !res.ok && res.status !== 408 && typeof json?.error === 'string' && typeof json?.message === 'string'
? { code: json.error, message: json.message } : { code: fallback, message: DECIDE_FALLBACK[fallback] };
async function decide(step) {
const a = answer;
if (!a || a.pending || a.result) return;
if (step === 'cancel') { a.confirm = null; a.error = null; paintDecide('[data-decide="approve"]'); announce('Cancelled. Nothing was answered.'); return; }
if (out()) { a.confirm = null; a.error = { code: 'sign-in-required', message: 'Sign in again before answering.' }; paintDecide('#decide-status'); return; }
if (step === 'approve' || step === 'deny') {
const choice = a.console[step].key;
a.pending = step; a.error = null; a.confirm = null; paintDecide();
const { res, json } = await decidePost(`/api/decisions/${enc(a.id)}/confirmation`, { choice });
a.pending = null;
// The page moved to another decision meanwhile: its confirmation is dropped, never shown.
if (answer !== a) { if (res?.type === 'opaqueredirect') signOut(); return; }
if (res?.type === 'opaqueredirect') { a.error = { code: 'sign-in-required', message: 'Sign in again before answering.' }; paintDecide('#decide-status'); signOut(); return; }
if (res?.ok && json?.id === a.id && json.action === step && json.choice === choice && typeof json.confirmation === 'string' && json.grant && typeof json.grant.method === 'string') {
a.confirm = json; paintDecide('[data-decide="confirm"]'); announce(`Confirm ${step}: ${json.text}`);
} else { a.error = decideRefusal(res, json, 'confirmation-unavailable'); paintDecide('#decide-status'); announce(`Not answered: ${a.error.message}`); }
return;
}
if (step !== 'confirm' || !a.confirm) return;
const c = a.confirm;
a.pending = 'confirm'; a.error = null; paintDecide();
const { res, json } = await decidePost(`/api/decisions/${enc(a.id)}/resolve`, { choice: c.choice, confirmation: c.confirmation });
// One use, whatever came back.
a.pending = null; a.confirm = null;
if (answer !== a) { if (res?.type === 'opaqueredirect') signOut(); else render(false); return; }
if (res?.type === 'opaqueredirect') { a.error = { code: 'sign-in-required', message: 'Sign in again before answering. The sign-in proxy redirected the answer, so it never reached the Console.' }; paintDecide('#decide-status'); signOut(); return; }
if (res?.ok && json?.id === a.id && json.op === 'resolved' && json.choice === c.choice) {
a.result = { action: c.action, choice: c.choice, text: c.text, grant: json.grant ?? null };
paintDecide('#decide-status'); announce(c.action === 'approve' ? 'Approved' : 'Denied'); render(false);
} else { a.error = decideRefusal(res, json, 'outcome-unknown'); paintDecide('#decide-status'); announce(`Not answered: ${a.error.message}`); }
}
async function inboxView(get) {
// The broker orders gated first, then oldest; lift blocking ones to the top.
const rows = (await get('inbox')).slice().sort((a, b) => (b.blocking - a.blocking) || String(a.at).localeCompare(String(b.at)));
countInbox(rows);
const body = rows.length ? `<ul class="waiting s1-decs">${rows.map(decisionCard).join('')}</ul>` : empty('Nothing is waiting on you.', 'A decision shows here when an agent raises one routed to you through the bus.');
const answered = canDecide ? 'A grant request can be approved or denied here, after a confirmation. Every other decision is answered in a terminal with <code>mosaic decide</code>.'
: 'Decisions are answered in a terminal with <code>mosaic decide</code>; Console only shows them.';
return `${head('Inbox', `${readNote()} ${answered}`)}
return `${head('Inbox', `${readNote()} Decisions are answered in a terminal with <code>mosaic decide</code>; Console only shows them.`)}
${await composer(get)}
<h2 id="b-for-you">For you <span class="count">${rows.length}</span></h2>${body}
<p class="small muted">Not shown here yet: ${gap('Decisions routed to roles')} ${gap('Closed decisions')} ${gap('Seen and DM state per row')} Open a decision for its seen, DM and closing history.</p>`;
@@ -434,13 +343,6 @@
// A command is offered only for an open decision routed to the human, and only when
// the id and key pass the broker's identifier rule, so the copied text is safe to paste.
const answerable = !c && d.routeTo === 'human';
// Row 252: the answer panel follows this decision. A refresh keeps a confirmation open; a
// decision no longer eligible (closed elsewhere, say) drops it, unless an answer is in flight
// or this page just answered it.
const eligible = canDecide && answerable && open?.console ? open.console : null;
if (answer?.id !== d.id) answer = eligible ? { id: d.id, console: eligible, pending: null, confirm: null, error: null, result: null } : null;
else if (eligible && !answer.pending && !answer.confirm) answer.console = eligible;
else if (!eligible && !answer.pending && !answer.result) answer = null;
const options = `<ol class="s1-opts">${d.options.map(o => {
const cmd = `mosaic decide ${d.id} ${o.key}`, chosen = c?.choice === o.key, approving = d.authorization?.approvalChoice === o.key;
const copy = !answerable ? '' : SAFE.test(String(d.id)) && SAFE.test(String(o.key))
@@ -450,18 +352,11 @@
}).join('')}</ol>`;
const earlier = await chain(get, d);
const history = [...d.seen.map(s => `<li>Seen by ${txt(s.by)}${s.via ? ` via ${txt(s.via)}` : ''} ${when(s.at)}</li>`), ...(c ? [`<li>${esc(c.op)} by ${txt(c.by)}${c.via ? ` via ${txt(c.via)}` : ''} ${when(c.at)}</li>`] : [])];
// Without SSO, or under SSO without --send, the Console says why it has no buttons and never
// draws a disabled one (design section 5).
const why = !answerable || isTester || canDecide ? ''
: ssoOn ? ' This Console is read-only for decisions: it was started without writes enabled (--send), so answers go through a terminal.'
: ' Answering a grant request from the Console needs sign-in (--sso) and writes enabled (--send).';
const help = eligible ? '<p class="small muted">Approve or Deny in the panel above, or run one of these in a terminal. Copy only puts the command on your clipboard.</p>'
: answerable ? `<p class="small muted">To answer, run one of these in a terminal. Copy only puts the command on your clipboard. Console sends nothing.${why}</p>`
const help = answerable ? '<p class="small muted">To answer, run one of these in a terminal. Copy only puts the command on your clipboard. Console sends nothing.</p>'
: !c ? `<p class="small muted">This decision is routed to ${txt(d.routeTo)}, not to you, so Console offers no command for it.</p>` : '';
return `${head(`Decision ${txt(short(d.id))}`, status)}
<section aria-labelledby="b-q" class="panel"><h2 id="b-q">Question</h2><p class="s1-q">${txt(d.question)}</p>
<dl class="kv"><dt>Class</dt><dd>${chip(d.class, d.action)}${d.blocking ? ` ${blockChip('blocking')}` : ''}</dd><dt>Approval</dt><dd class="s1-auth">${approves(d.authorization)}</dd><dt>Routed to</dt><dd>${txt(d.routeTo)}</dd><dt>Raised by</dt><dd>${txt(d.raisedBy.role)} <span class="source">${txt(d.raisedBy.run)}</span></dd><dt>Raised</dt><dd>${when(d.at)}</dd>${d.taskRef ? `<dt>Task</dt><dd>${taskLink(d.taskRef)} <span class="source">${txt(d.taskRef)}</span></dd>` : ''}${d.requirementRef ? `<dt>Requirement</dt><dd>${txt(d.requirementRef)}</dd>` : ''}<dt>DM</dt><dd>${d.dm ? `sent ${when(d.dm.at)}` : d.blocking ? 'not sent' : 'not needed (not blocking)'}</dd><dt>Id</dt><dd><code>${txt(d.id)}</code></dd></dl></section>
${answer?.id === d.id ? decideHtml() : ''}
<section aria-labelledby="b-o"><h2 id="b-o">Options</h2>${help}${options}<p class="small muted" id="copy-status"></p></section>
<section aria-labelledby="b-c"><h2 id="b-c">Earlier versions</h2>${earlier.length ? `<ul class="s1-plain">${earlier.map(x => `<li><a href="#/inbox/${enc(x.id)}">${txt(short(x.id))}</a> ${badge(x.closed ? x.closed.op : 'open')} ${txt(first(x.question))}${x.closed?.note ? `<span class="source">${txt(x.closed.note)}</span>` : ''}</li>`).join('')}</ul>` : '<p class="muted">None.</p>'}<p class="small muted">${gap('A later version that replaces this one')}</p></section>
<section aria-labelledby="b-h"><h2 id="b-h">History</h2>${history.length ? `<ul class="s1-plain">${history.join('')}</ul>` : '<p class="muted">Not seen yet.</p>'}<p><a href="#/trail/decision/${enc(d.id)}">Full trail for this decision</a></p></section>`;
@@ -1203,7 +1098,6 @@
return {
href: view.contains(a) ? a.getAttribute('href') : null, copy: view.contains(a) ? a.dataset?.copy : null, retry: view.contains(a) && a.hasAttribute?.('data-retry'), mirror: view.contains(a) ? a.dataset?.mirror : null, h1: view.contains(a) && a.tagName === 'H1',
field: view.contains(a) ? a.dataset?.field : null, caret: view.contains(a) && a.tagName === 'TEXTAREA' ? [a.selectionStart, a.selectionEnd, a.scrollTop] : null, send: view.contains(a) && a.hasAttribute?.('data-send'),
decide: view.contains(a) ? a.dataset?.decide ?? (a.id === 'decide-status' ? 'status' : null) : null,
open: [...view.querySelectorAll('details[id]')].filter(d => d.open).map(d => d.id), status: $('copy-status')?.textContent || '',
};
}
@@ -1211,10 +1105,7 @@
for (const id of k.open) { const d = $(id); if (d) d.open = true; }
if ($('copy-status')) $('copy-status').textContent = k.status;
const el = [...$('bus-view').querySelectorAll('a[href],[data-copy],[data-retry],[data-mirror],[data-field],[data-send]')].find(e => k.href ? e.getAttribute('href') === k.href : k.copy ? e.dataset.copy === k.copy : k.mirror ? e.dataset.mirror === k.mirror : k.field ? e.dataset.field === k.field : k.send ? e.hasAttribute('data-send') : k.retry && e.hasAttribute('data-retry'));
// Row 252: the answer panel's buttons and its status line.
const dec = k.decide ? $('bus-view').querySelector(k.decide === 'status' ? '#decide-status' : `[data-decide="${k.decide}"]`) : null;
if (dec) dec.focus({ preventScroll: true });
else if (el && (k.href || k.copy || k.retry || k.mirror || k.field || k.send)) {
if (el && (k.href || k.copy || k.retry || k.mirror || k.field || k.send)) {
el.focus({ preventScroll: true });
if (k.caret && el.tagName === 'TEXTAREA') { el.setSelectionRange(k.caret[0], k.caret[1]); el.scrollTop = k.caret[2]; }
}
@@ -1306,9 +1197,6 @@
}
return;
}
// Row 252: Approve, Deny, Confirm and Cancel. One click is one step; none repeats itself.
const dec = e.target.closest('#decide-section [data-decide]');
if (dec) { if (!dec.disabled) decide(dec.dataset.decide); return; }
if (e.target.closest('#toast [data-dismiss]')) { toast(null); return; }
const item = e.target.closest('#cmdk-list [data-href]');
if (item) { pkGo(item.dataset.href); return; }
@@ -1430,14 +1318,12 @@
isTester = c?.tester === true;
canEdit = !isTester && c?.edit === true;
canSend = !isTester && c?.send === true;
ssoOn = c?.sso === true;
canDecide = !isTester && c?.decide === true;
if (isTester) {
const banner = document.createElement('div'); banner.id = 'tester-banner'; banner.className = 'banner ref';
banner.setAttribute('role', 'status'); banner.textContent = 'Tester Console. Changes are recorded as tester, never as the human.';
$('main').prepend(banner);
}
if (!canSend && !isTester && !ssoOn) return;
if (!canSend && !isTester) return;
const path = routeOf().split('?')[0];
if (path.startsWith('/chat')||path.startsWith('/inbox')||path==='/settings'||path.startsWith('/docs/')) render(false); else if (path === '/' || path === '') paintHeld(heldErr);
}).catch(() => {});
+42 -55
View File
@@ -11,11 +11,20 @@ import { ReadError, readStatus, fieldTable } from './reads.mjs';
import { THREAD_DEFINITIONS } from '../../business/src/vocabulary.mjs';
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']],
@@ -181,20 +190,6 @@ const one = (req, name) => (req.headersDistinct[name]?.length === 1 ? req.header
// so it carries `sso: true` and the public origin; the preview writer still checks its header.
const signedIn = (req, p) => p.sso === true || one(req, p.header) === p.user;
const ownOrigin = p => p.origin ?? `https://${p.host}`;
// Row 198: the board's writes, in-process. A body the board refuses is a 400; the reply's own answer
// (its status and receipt) comes back as the board gave it, and a failed scan is a 500 with no detail.
async function boardSend(req, res, { board, path }) {
let value;
try { value = JSON.parse((await body(req)).toString('utf8')); } catch (err) { return json(res, 400, { error: err.message }); }
try {
if (path === '/api/seen') return json(res, 200, await board.markSeen(value));
const out = await board.reply(value);
return json(res, out.status, out.body);
} catch (err) {
if (path === '/api/seen') return json(res, 400, { error: err.message });
return json(res, 500, { error: 'board read failed' });
}
}
async function busSend(req, res, { send, preview, log }) {
if (!signedIn(req, preview)) return json(res, 401, { error: 'sign-in required' });
let to = null;
@@ -605,8 +600,7 @@ 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.
// `board` is createBoard() from packages/control-board (row 198), run in-process; null answers the board routes 503.
export async function startServer({ host = '127.0.0.1', port = 7330, board = null, timeout = 20000, bus = null, reads = null, preview = null, sso = null, tester = false, testerSocket = null, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, processing = null, decisionWrite = null, userOptions = null, federation = null, log = line => console.log(line), fieldsFile = undefined } = {}) {
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, sso = null, tester = false, testerSocket = null, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, processing = 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');
@@ -617,13 +611,11 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = nul
if (tester && [send, settingsWrite, projectSave, assistantStop].some(value => value !== null)) throw new Error('tester listener permits only instance, route, name and chat writes');
if (tester && !testerSocket) throw new Error('tester listener requires private socket');
if (!tester && testerSocket) throw new Error('tester socket requires tester listener');
// Row 252 (#1753, design section 4): Console decision answers need --sso and --send, never the tester.
if (decisionWrite !== null && (tester || sso === null || send === null || typeof decisionWrite !== 'function')) throw new Error('Console decision answers need --sso and --send on the main listener');
const confirmations = createConfirmations();
const upstream = boardURL(board);
// `userOptions` (FED R6, #1800) is `session => options`, the bus-backed options over that session
// user's own transport (console-bus.mjs). Only a signed-in listener has user sessions.
if (userOptions !== null && (sso === null || typeof userOptions !== 'function')) throw new Error('user sessions need --sso');
const owner = { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, files };
const owner = { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, upstream, files };
const hub = federationView(federation);
// An object with none of the three is refused too: asking for the preview never serves without it.
if (preview !== null && !(preview = previewOptions({ publicHost: preview.host, authHeader: preview.header, allowUser: preview.user }))) {
@@ -631,10 +623,9 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = nul
}
if (proof !== null && (preview === null || tester)) throw new Error('proxy proof needs preview mode');
if (send !== null && ((preview === null && sso === null) || typeof send !== 'function')) throw new Error('--send needs the preview mode (--public-host, --auth-header and --allow-user) or --sso');
const boardLabel = board ? 'in the Console' : null;
const server = createServer(async (req, res) => {
// A granted user's request rebinds these below; every other request keeps the owner's.
let { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, files } = owner, granted = false;
let { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, upstream, files } = owner, granted = false;
res.setHeader('x-content-type-options', 'nosniff');
res.setHeader('referrer-policy', 'no-referrer');
// Under --sso the Sign out form's POST is redirected to the provider's end-session endpoint, and
@@ -678,30 +669,25 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = nul
// alone stays open on a loopback Host for the status and release gates (Q-C).
if (sso !== null && !named && !(path === '/healthz' && req.method === 'GET')) return json(res, 403, { error: 'public Host required' });
// No CORS. JSON content type and same-origin checks keep browser forms out.
const decisionPath = DECISION_ROUTE.test(path);
if (req.headers.origin && req.headers.origin !== (named ? (sso ? sso.publicOrigin : `https://${preview.host}`) : `http://${req.headers.host}`))
return decisionPath ? decisionRefuse(res, 'cross-origin') : json(res, 403, { error: 'cross-origin request refused' });
let signer = preview, ssoSession = null;
if (req.headers.origin && req.headers.origin !== (named ? (sso ? sso.publicOrigin : `https://${preview.host}`) : `http://${req.headers.host}`)) return json(res, 403, { error: 'cross-origin request refused' });
let signer = preview;
if (sso !== null && named) {
if (path.startsWith('/auth/')) return sso.handle(req, res, path, search);
// Only a page load or a write keeps a session alive; the page's background reads don't (Q-D).
const session = path === '/healthz' ? null : sso.session(req, { touch: req.method === 'POST' || (req.method === 'GET' && !path.startsWith('/api/')) });
if (!session && path !== '/healthz') {
if (decisionPath) return decisionRefuse(res, 'sign-in-required');
if (path.startsWith('/api/') || req.method !== 'GET') return json(res, 401, { error: 'sign-in required' });
res.writeHead(303, { location: `/auth/login?return=${encodeURIComponent(path + search)}`, 'cache-control': 'no-store' });
return res.end();
}
if (session) signer = { sso: true, user: session.name, host: sso.publicHost, origin: sso.publicOrigin };
// Row 252: only the owner's session answers a decision. A granted user stops at userRoute below.
if (session && session.user === undefined) ssoSession = session;
// FED R6 (#1800): a granted user. The Console's gate first; behind it no human-transport
// object is left, so a route past the gate reaches the broker only as this user.
if (session?.user !== undefined) {
if (!userRoute(path, req.method)) return json(res, 403, { error: 'owner-only', message: 'this part of the Console is the business owner\'s' });
if (userOptions === null) return json(res, 503, { error: 'not-configured', message: 'the Console has no user transport' });
({ bus, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing } = userOptions(session));
reads = null; files = USER_FILES; granted = true;
reads = null; upstream = null; files = USER_FILES; granted = true;
}
}
if (named && preview !== null) {
@@ -710,13 +696,6 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = nul
if (!values || values.length !== 1 || values[0] !== preview.user) return json(res, 401, { error: 'sign-in required' });
}
const writer = tester ? { tester: true, user: 'tester', origin: TESTER_ORIGIN } : signer;
// Row 252: Approve and Deny. The tester listener never offers them. Anywhere else without a signed-in
// session on the public name under --sso and --send, they are read-only.
if (decisionPath) {
if (tester) return decisionRefuse(res, 'not-found');
const decide = decisionWrite && send && named && sso ? { call: decisionWrite, origin: sso.publicOrigin } : null;
return decisionHTTP(req, res, { path, decide, session: ssoSession, live: () => sso.session(req), confirmations, log });
}
// Row 194 (#1692, D4.1): the tester also launches and terminates.
if (tester && !['GET', 'HEAD'].includes(req.method) && !['/api/instances', '/api/routes', '/api/name', '/api/agent/launch', '/api/agent/terminate'].includes(path) && !TESTER_CHAT_WRITE.test(path))
return json(res, 403, { error: 'read-only', sent: false });
@@ -784,15 +763,31 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = nul
if (path === '/api/chat' || path.startsWith('/api/chat/')) {
return chatHTTP(req, res, { path, search, chat, named, send, preview: writer, log });
}
// Row 198: the board's two writes run in-process, under --send and the public name only.
if (path === '/api/seen' || path === '/api/reply') {
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
if (!send || !named) return json(res, 403, { error: 'read-only', sent: false });
if (!board) return json(res, 503, { error: 'not-configured', message: 'the Console has no board configured' });
return boardSend(req, res, { board, path });
}
// Nothing else but GET and HEAD gets past here in preview mode, whatever the Host.
if ((preview !== null || sso !== null) && req.method !== 'GET' && req.method !== 'HEAD') return json(res, 403, { error: 'read-only', message: 'the Console preview is read-only' });
// The conversation routes (#1507, CHAT-02) keep their query; the board validates it.
const conversation = path === '/api/conversations' || path === '/api/conversation';
const allowed = path === '/api/board' || conversation ? 'GET' : ['/api/seen', '/api/reply'].includes(path) ? 'POST' : null;
if (allowed) {
if (req.method !== allowed) return json(res, 405, { error: 'method not allowed' });
if (upstream === null) return json(res, 503, { error: 'not-configured', message: 'the Console has no board for this session' });
let bytes;
if (allowed === 'POST') {
try { bytes = await body(req); } catch (err) { return json(res, 400, { error: err.message }); }
}
try {
const response = await fetch(upstream + path + (conversation ? search : ''), {
method: allowed, headers: bytes ? { 'content-type': 'application/json' } : {}, body: bytes,
redirect: 'error', signal: AbortSignal.timeout(timeout),
});
const text = await response.text();
JSON.parse(text); // Never serve upstream HTML or scripts as API data.
res.writeHead(response.status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
return res.end(text);
} catch {
return json(res, 502, { error: `Board unreachable or invalid response at ${upstream}. Check the board server. No automatic action retry.`, board: upstream });
}
}
if (path.startsWith('/api/bus/')) {
const verb = path.slice('/api/bus/'.length);
if (!BUS_VERBS.includes(verb)) return json(res, 404, { error: 'not found' });
@@ -812,7 +807,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = nul
if (path === '/api/queue' || path.startsWith('/api/queue/') || path === '/api/business' || path === '/api/settings' || path === '/api/bus-status') {
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' });
const a = server.address(), address = `http://${a.family === 'IPv6' ? `[${a.address}]` : a.address}:${a.port}/`;
if (path === '/api/settings') return consoleRead(res, async () => ({ ...(reads ? await reads.settings() : { release: null, business: null, notifier: { refused: 'not-configured', message: 'the Console has no reads configured' } }), board: boardLabel, address }));
if (path === '/api/settings') return consoleRead(res, async () => ({ ...(reads ? await reads.settings() : { release: null, business: null, notifier: { refused: 'not-configured', message: 'the Console has no reads configured' } }), board: upstream, address }));
if (!reads) return json(res, 503, { error: 'not-configured', message: 'the Console has no reads configured' });
if (path === '/api/business') return consoleRead(res, () => reads.business());
if (path === '/api/bus-status') return consoleRead(res, () => reads.busStatus());
@@ -822,18 +817,10 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = nul
return consoleRead(res, () => reads.row(Number(id)));
}
if (req.method !== 'GET' && req.method !== 'HEAD') return json(res, 405, { error: 'method not allowed' });
// Row 198: the board's reads, in-process. The sign-in gate above has already run for each one.
if (path === '/api/board' || path === '/api/conversations' || path === '/api/conversation') {
if (!board) return json(res, 503, { error: 'not-configured', message: 'the Console has no board configured' });
if (path === '/api/board') return consoleRead(res, () => board.index());
let out;
try { out = board.conversation(new URL(req.url, 'http://localhost')); } catch { return json(res, 500, { error: 'conversation read failed' }); }
return json(res, out.status, out.body);
}
// `send`: the page shows the composer only where a send can pass (the public name, --send on).
// `edit`: the editor shows Save only where a save can pass, the same test as the route's.
// `user` and `federation` (FED R6) only when there is one, so the owner's config is unchanged.
if (path === '/api/config') return json(res, 200, { board: granted ? null : boardLabel, ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), ...(granted ? { user: true } : {}), ...(hub ? { federation: hub } : {}), send: !tester && Boolean(send) && named, decide: !tester && !granted && Boolean(send) && Boolean(decisionWrite) && named && sso !== null, edit: !tester && !granted && Boolean(send) && Boolean(projectSave) && named });
if (path === '/api/config') return json(res, 200, { board: upstream, ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), ...(granted ? { user: true } : {}), ...(hub ? { federation: hub } : {}), send: !tester && Boolean(send) && named, edit: !tester && !granted && Boolean(send) && Boolean(projectSave) && named });
if (path === '/healthz') return json(res, 200, { ok: true, ...(sso ? { sso: sso.health() } : {}) });
if (path === '/favicon.ico') { res.writeHead(204); return res.end(); }
const file = files.get(path);
+2 -2
View File
@@ -17,7 +17,7 @@ const view={business:'acme',revision:REV,launch:true,noHire:['reviewer'],retired
test('row 194 T16 Settings Agents: unavailable read-only, launch follows its request, confirms, reason and refusal',{timeout:90000},async()=>{
const bus={messages:async()=>({messages:[],unread:0}),usage:async()=>{throw Error('no usage');},instances:async()=>view};
const web=await startServer({port:0,board:null,bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})}}),b=await browser();
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})}}),b=await browser();
try{
// POSTs answer from `replies`, request reads from `polls`; both record what was asked.
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`window.posts=[];window.replies=[];window.polls=[];window.reads=[];window.enabled=new URL(location.href).searchParams.has('send');const original=window.fetch;window.fetch=async(url,options={})=>{if(url==='/api/config')return new Response(JSON.stringify({send:window.enabled}));if(options.method==='POST'&&/^\\/api\\/(agent|veto|instances)/.test(url)){posts.push([url,JSON.parse(options.body)]);const [status,body]=replies.shift();return new Response(JSON.stringify(body),{status});}if(String(url).startsWith('/api/agent/request')){reads.push(url);const [status,body]=polls.shift()??[200,{request:'${ID}',action:'launch',instance:'coder',state:'open',done:null,cancelled:null}];return new Response(JSON.stringify(body),{status});}return original(url,options);};`});
@@ -131,7 +131,7 @@ test('row 194 T16 Settings Agents: unavailable read-only, launch follows its req
test('row 256 T7 T8 the human page offers Delete on a creative instance, the tester page never does',{timeout:90000},async()=>{
const human={...view,instances:{...view.instances,bean:{...view.instances.bean,endProtected:false}}};
const bus={messages:async()=>({messages:[],unread:0}),usage:async()=>{throw Error('no usage');},instances:async()=>human};
const web=await startServer({port:0,board:null,bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})}}),b=await browser();
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})}}),b=await browser();
try{
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`const q=new URL(location.href).searchParams;const original=window.fetch;window.fetch=async(url,options={})=>{if(url==='/api/config')return new Response(JSON.stringify(q.has('tester')?{tester:true}:{send:true}));return original(url,options);};`});
const port=web.address().port;
+2 -4
View File
@@ -22,7 +22,6 @@ import { views } from '../../bus/src/views.mjs';
import { startServer } from '../src/serve.mjs';
import { browser } from './browser.mjs';
import { fixture, close } from './fixture.mjs';
import { routeWrites } from './board-route.mjs';
const businesses = {
demo: {
@@ -73,8 +72,7 @@ async function setup(machine, fast = false) {
const reader = b0.bindReader({ business: 'demo' });
const bus = views({ call: async (verb, args = {}) => structuredClone(b0.request(reader, { verb, args })) });
const f = await fixture(), b = await browser({ machine });
const web = await startServer({ port: 0, board: f.board, bus }), base = `http://127.0.0.1:${web.address().port}/`;
const stopRoute = await routeWrites(b, base, f.board);
const web = await startServer({ port: 0, board: f.boardURL, bus }), base = `http://127.0.0.1:${web.address().port}/`;
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: stub(fast) });
await b.viewport(1280);
const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out: '+${JSON.stringify(expr)})})()`);
@@ -83,7 +81,7 @@ async function setup(machine, fast = false) {
const reqs = () => b.evaluate('window.__reqs');
const polls = () => b.evaluate('window.__polls');
const settle = () => new Promise(r => setTimeout(r, 600));
const done = async () => { await stopRoute(); await b.close(); await close(web); await f.close(); store.close(); rmSync(root, { recursive: true, force: true }); };
const done = async () => { await b.close(); await close(web); await f.close(); store.close(); rmSync(root, { recursive: true, force: true }); };
return { b, base, wait, text, shown, reqs, polls, settle, done };
}
@@ -1,144 +0,0 @@
// Row 198: the board runs in-process in the Console. These are the Console-side killers:
// M3 (no proxy fetch to a board URL), M5 (no seen write without --send), M6 and M7 (the
// preview gate runs before any board route), M10 (positive control: the write branches
// reach the board under --send) and M14 (a pending board read never blocks /healthz).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { request } from 'node:http';
import { startServer } from '../src/serve.mjs';
import { createBoard } from '../../control-board/src/board.mjs';
import { seenPath } from '../../control-board/src/scan.mjs';
import { close, fixture } from './fixture.mjs';
const NAME = 'mosaic-preview.example.com', HEADER = 'remote-user', ID = 'jason';
const preview = { host: NAME, header: HEADER, user: ID };
const ok = { [HEADER]: ID, origin: `https://${NAME}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' };
const without = name => Object.fromEntries(Object.entries(ok).filter(([k]) => k !== name));
const BODY = '{"agent":"proj/agent1","text":"hi"}';
const BOARD_ROUTES = [['GET', '/api/board'], ['GET', '/api/conversations'], ['POST', '/api/seen'], ['POST', '/api/reply']];
// node:http, not fetch: fetch can't set Host.
function call(port, { method = 'POST', path, headers = ok, host = NAME, body = BODY } = {}) {
return new Promise((resolve, reject) => {
const length = method === 'GET' ? {} : { 'content-length': Buffer.byteLength(body) };
const req = request({ host: '127.0.0.1', port, method, path, headers: { host, ...length, ...headers } }, res => {
let text = '';
res.on('data', c => (text += c));
res.on('end', () => resolve({ status: res.statusCode, text }));
});
req.on('error', reject);
req.end(method === 'GET' ? undefined : body);
});
}
// A board that counts every call that reaches it.
function countingBoard(hits, index = async () => ({ sessions: [], seen: [] })) {
return {
index: async () => { hits.push('index'); return index(); },
markSeen: async () => { hits.push('markSeen'); return { seen: [] }; },
reply: async () => { hits.push('reply'); return { status: 200, body: { delivered: true } }; },
conversation: async () => { hits.push('conversation'); return { status: 200, body: {} }; },
};
}
function stop(web) {
return new Promise(resolve => { web.closeAllConnections(); web.close(resolve); });
}
// M6, M7: under the preview, every board route refuses a request without the proxy header
// (401) and a request with a foreign Host (403), and the board never runs.
test('preview: every board route refuses without the proxy header (401) or with a foreign Host (403) before the board runs (M6, M7)', async t => {
const hits = [];
const web = await startServer({ port: 0, board: countingBoard(hits), preview });
t.after(() => stop(web));
const port = web.address().port;
for (const [method, path] of BOARD_ROUTES) {
const bare = await call(port, { method, path, headers: without(HEADER) });
assert.equal(bare.status, 401, `${method} ${path} without the header`);
const foreign = await call(port, { method, path, host: 'evil.example' });
assert.equal(foreign.status, 403, `${method} ${path} with a foreign Host`);
}
assert.deepEqual(hits, [], 'the board ran for a refused request');
});
// M5: without --send the seen write refuses and writes no mark file.
test('seen without --send refuses and writes no mark (M5)', async t => {
const f = await fixture();
t.after(() => f.close());
const web = await startServer({ port: 0, board: f.board, preview });
t.after(() => stop(web));
const res = await call(web.address().port, { path: '/api/seen', body: JSON.stringify({ project: 'proj', agent: 'agent1', lastActivity: 'x', seen: true }) });
assert.equal(res.status, 403);
assert.equal(JSON.parse(res.text).sent, false);
assert.equal(existsSync(seenPath(join(f.root, 'board'))), false, 'a mark was written without --send');
});
// M10: positive control. With --send on the public name, both writes reach the in-process
// board. A branch moved below the read-only refusal would answer 403 here and fail.
test('with --send on the public name, seen and reply reach the in-process board (M10)', async t => {
const f = await fixture();
t.after(() => f.close());
const web = await startServer({ port: 0, board: f.board, preview, send: async () => ({ id: 'msg-0001' }) });
t.after(() => stop(web));
const port = web.address().port;
const rec = (await f.board.index()).sessions.find(r => r.agent === 'agent1');
const seen = await call(port, { path: '/api/seen', body: JSON.stringify({ project: rec.project, agent: rec.agent, lastActivity: rec.lastActivity, seen: true }) });
assert.equal(seen.status, 200, seen.text);
assert.equal(existsSync(seenPath(join(f.root, 'board'))), true, 'the seen write did not reach the board');
const reply = await call(port, { path: '/api/reply' });
assert.equal(reply.status, 200, reply.text);
assert.equal(JSON.parse(reply.text).delivered, true);
assert.equal(f.captures.length, 1);
assert.match(f.captures[0].args.at(-1), /^hi\n/);
});
// M3: the Console reads its board in-process. No request leaves the Console for a board URL.
test('a board read makes no request to a board URL (M3)', async t => {
const hits = [];
const web = await startServer({ port: 0, board: countingBoard(hits) });
t.after(() => stop(web));
const base = `http://127.0.0.1:${web.address().port}`;
const urls = [];
const realFetch = globalThis.fetch;
globalThis.fetch = (url, opts) => { urls.push(String(url)); return realFetch(url, opts); };
t.after(() => { globalThis.fetch = realFetch; });
assert.equal((await globalThis.fetch(base + '/api/board')).status, 200);
assert.deepEqual(hits, ['index'], 'the board ran once, in-process');
assert.equal(urls.length, 1, 'the spy saw the test request');
assert.ok(urls.every(u => u.startsWith(base)), `a request left the Console: ${urls.join(', ')}`);
});
// M14: a board read never blocks the Console's loop. The board is a real createBoard, and its tmux
// check runs through a shim on PATH that sleeps 1 s. A 10 ms timer records the longest stall of the
// loop while /api/board is pending; a spawnSync in the read path stalls the loop for the shim's full
// second. /healthz is timed by elapsed time from the same moment.
test('a board read never blocks the loop: the timer gap and /healthz stay short while /api/board is pending (M14)', async t => {
const root = mkdtempSync(join(tmpdir(), 'webui-m14-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const shim = join(root, 'shim'), sessionsDir = join(root, 'sessions'), seatsDir = join(root, 'seats');
for (const d of [shim, sessionsDir, seatsDir]) mkdirSync(d);
writeFileSync(join(shim, 'tmux'), '#!/bin/sh\nsleep 1\nexit 1\n');
chmodSync(join(shim, 'tmux'), 0o755);
const PATH = process.env.PATH;
process.env.PATH = `${shim}:${PATH}`;
t.after(() => { process.env.PATH = PATH; });
const board = createBoard({ specs: [{ agent: 'agent1', project: 'proj', sessionsDir, tmux: { socket: null, session: 'agent1' } }], boardDir: join(root, 'board'), seatsDir, discordDataRoot: root, host: 'm14' });
const web = await startServer({ port: 0, board });
t.after(() => stop(web));
const base = `http://127.0.0.1:${web.address().port}`;
let last = Date.now(), worst = 0;
const tick = setInterval(() => { const now = Date.now(); worst = Math.max(worst, now - last); last = now; }, 10);
t.after(() => clearInterval(tick));
const pending = fetch(base + '/api/board');
const t0 = Date.now();
const health = await fetch(base + '/healthz');
const healthMs = Date.now() - t0;
assert.equal(health.status, 200);
assert.ok(healthMs < 500, `/healthz took ${healthMs} ms while the board read was pending`);
assert.equal((await pending).status, 200);
clearInterval(tick);
assert.ok(worst < 500, `the loop stalled for ${worst} ms during the board read`);
});
-148
View File
@@ -1,148 +0,0 @@
// Row 198 r4 (sage-3618, mos-1506): the board refusal. The in-process board never answers 403 (consoleRead
// has no 403 code), so the browser stubs the answer: while the board is refused, Fetch fulfils /api/board
// and /api/conversation(s) with 403 board-refused, as the old fake board server did. The assertions are the
// refusal block that 4d8123b0a deleted from shell.test.mjs, ported one for one (BUILD.md, r4 mapping).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { browser } from './browser.mjs';
import { close } from './fixture.mjs';
import { startServer } from '../src/serve.mjs';
import { ReadError } from '../src/reads.mjs';
import { sendConfig } from './board-route.mjs';
// The old fake board's data: agent1 waiting on you in proj, plus agent2 and agent3, one conversation.
function board() {
const state = { refused: false, down: false, mode: 'ok' };
let index = { sessions: [], counts: {}, generatedAt: new Date().toISOString() };
const one = { agent: 'agent1', project: 'proj', state: 'waiting', waitingOnYou: true, task: 'Fixture task', lastActivity: '2026-10-10T00:00:00Z', lastAssistantText: 'Input needed: fixture', registered: null };
const catalogue = { conversations: [{ project: 'proj', seat: 'agent1', conversation: 'c1', availability: 'available', title: 'Fixture', conversationCreatedAt: '2026-10-10T00:00:00Z', lastActivityAt: '2026-10-10T00:00:00Z' }] };
const page = { page: { branch: 'b1', entries: [{ message: 'm1', role: 'assistant', createdAt: '2026-10-10T00:00:00Z', content: [{ block: 0, type: 'text', text: 'Fixture history line' }] }] }, view: { incomplete: false, defaultBranch: 'b1', branch: 'b1' }, cursor: null, follow: null };
const set = (sessions, counts) => { index = { sessions, counts, generatedAt: new Date().toISOString() }; state.mode = 'ok'; };
return {
state,
board: {
index: () => {
if (state.down) throw new ReadError('read-failed', 'board unavailable');
if (state.mode === 'fail') throw new Error('board scan failed');
return index;
},
conversation: url => {
if (state.mode === 'fail') throw new Error('catalogue read failed');
return { status: 200, body: url.pathname === '/api/conversations' ? catalogue : page };
},
markSeen: () => ({ status: 200, body: { ok: true } }),
reply: () => ({ status: 200, body: { delivered: true } }),
},
one: () => { state.refused = false; state.down = false; set([one, { ...one, agent: 'agent2', state: 'working', waitingOnYou: false }, { ...one, agent: 'agent3', state: 'idle', waitingOnYou: false, seen: true }], { waiting: 1, working: 1 }); },
registered: () => { state.refused = false; state.down = false; set([{ ...one, registered: { alive: true, tmux: { session: 'fixture' } } }], { waiting: 1 }); },
fail: () => { state.refused = false; state.mode = 'fail'; },
unavailable: () => { state.refused = false; state.down = true; },
refuse: () => { state.refused = true; },
};
}
test('a board refusal fails closed: the page hides, the banner says refused, the conversation closes and clears', { timeout: 180000 }, async () => {
const brd = board(); brd.one();
const web = await startServer({ port: 0, board: brd.board });
const base = `http://127.0.0.1:${web.address().port}/`, b = await browser();
const out = process.env.WEBUI_EVIDENCE;
if (out) mkdirSync(out, { recursive: true });
const shot = async name => { if (out) await b.screenshot(join(out, `shell-${name}.png`)); };
const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out: '+${JSON.stringify(expr)})})()`);
const text = sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.textContent ?? null`);
const count = sel => b.evaluate(`document.querySelectorAll(${JSON.stringify(sel)}).length`);
const flat = async () => assert.equal(await b.evaluate('document.documentElement.scrollWidth<=document.documentElement.clientWidth'), true, 'sideways scroll at 400px');
const refresh = async () => { await b.evaluate('document.querySelector("#refresh").click()'); await wait('!document.querySelector("#refresh").disabled'); };
// The stub: while refused, the board's reads get the 403 the old fake server gave; otherwise they go on to the Console.
// /api/config answers with send on (mos-1524: config only), so the conversation's reply form is drawn.
await b.call('Fetch.enable', { patterns: [{ urlPattern: '*/api/board*', requestStage: 'Request' }, { urlPattern: '*/api/conversation*', requestStage: 'Request' }, { urlPattern: '*/api/config', requestStage: 'Request' }] });
b.on('Fetch.requestPaused', async ({ requestId, request: req }) => {
if (new URL(req.url).pathname === '/api/config') {
await b.call('Fetch.fulfillRequest', { requestId, responseCode: 200, responseHeaders: [{ name: 'content-type', value: 'application/json' }], body: Buffer.from(await sendConfig(base)).toString('base64') }).catch(() => {});
return;
}
const answer = brd.state.refused
? b.call('Fetch.fulfillRequest', { requestId, responseCode: 403, responseHeaders: [{ name: 'content-type', value: 'application/json' }], body: Buffer.from(JSON.stringify({ error: 'board-refused' })).toString('base64') })
: b.call('Fetch.continueRequest', { requestId });
answer.catch(() => {});
});
try {
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: 'window.errors=[];addEventListener("error",e=>errors.push(e.message));addEventListener("unhandledrejection",e=>errors.push(String(e.reason)));' });
await b.viewport(400, 900);
await b.navigate(base); await wait('!document.querySelector("#sessions [aria-busy]")');
await refresh(); await wait('document.querySelectorAll("table.sessions tbody tr").length===2');
// Board, a refusal: fails closed. The kept rows are hidden, and so is everything drawn from
// them: the open inspector, the project filter, the project tree's counts and the footer.
await b.evaluate('document.querySelector("#projects [data-project=proj]").click()');
assert.equal(await b.evaluate('document.querySelector("#projects [data-project=proj]").getAttribute("aria-pressed")'), 'true');
await b.evaluate('document.querySelector("table.sessions [data-open]").click()'); await wait('!document.querySelector("#inspector").hidden');
assert.equal(await text('#footer'), 'waiting 1 · working 1');
assert.equal(await b.evaluate('document.querySelectorAll("#projects .count").length'), 2);
assert.equal(await count('#seen .wait-item'), 1);
brd.refuse(); await refresh();
assert.equal(await b.evaluate('document.querySelector("#error").className'), 'banner ref');
assert.match(await text('#error'), /The board refused the read\. refused: board-refused/);
assert.equal(await b.evaluate('getComputedStyle(document.querySelector("#board-view")).display'), 'none');
assert.equal(await b.evaluate('document.querySelector("#board-refused").hidden'), false);
assert.equal(await b.evaluate('document.querySelector("#inspector").hidden'), true);
assert.equal(await b.evaluate('document.body.classList.contains("has-inspector")'), false);
assert.equal(await b.evaluate('document.querySelectorAll("#projects .count, #projects [data-project]").length'), 0);
assert.equal(await text('#footer'), 'Board not read: refused');
assert.equal(await text('#fresh-board'), 'Board not read: refused');
assert.doesNotMatch(await text('#status'), /Scanned/);
for (const id of ['#waiting-count', '#seen-count', '#session-count']) assert.equal(await text(id), '–', id);
await flat(); await shot('board-refused-400');
// A failed read after the refusal shows the board again, empty: no row, card or count from
// the refused scan, and the footer says what the banner says. With nothing focused, focus stays put.
await b.evaluate('document.activeElement.blur()');
brd.unavailable(); await refresh();
assert.equal(await b.evaluate('document.activeElement === document.body'), true, 'a failure with nothing focused leaves focus alone');
assert.equal(await b.evaluate('document.querySelector("#error").className'), 'banner err');
assert.match(await text('#error'), /board unavailable.*No board data loaded\./);
assert.notEqual(await b.evaluate('getComputedStyle(document.querySelector("#board-view")).display'), 'none');
assert.equal(await count('#sessions tr, #sessions [data-open], #waiting .wait-item, #seen .wait-item, #board-view [data-history]'), 0);
for (const id of ['#waiting-count', '#seen-count', '#session-count']) assert.equal(await text(id), '–', id);
assert.equal(await text('#footer'), 'Board not read: the read failed');
assert.equal(await text('#fresh-board'), 'Board not read: the read failed');
assert.equal(await text('#status'), 'Not read: the read failed');
assert.equal(await text('#projects'), 'Not read: the read failed.');
await flat(); await shot('board-refused-then-failed-400');
brd.one(); await refresh();
assert.equal(await b.evaluate('document.querySelector("#error").hidden'), true);
assert.equal(await b.evaluate('document.querySelector("#projects [data-project=\'\']").getAttribute("aria-pressed")'), 'true');
assert.notEqual(await b.evaluate('getComputedStyle(document.querySelector("#board-view")).display'), 'none');
assert.equal(await text('#footer'), 'waiting 1 · working 1');
// A good read after a failed one drops the stale words.
brd.fail(); await refresh(); assert.match(await text('#fresh-board'), /stale/);
brd.one(); await refresh(); assert.match(await text('#fresh-board'), /^Board scanned \d\d:\d\d:\d\d UTC/); assert.doesNotMatch(await text('#fresh-board'), /stale/);
// A refusal with a conversation open closes it and clears its history and reply form: the
// session is in no scan the page holds.
brd.registered(); await refresh(); await wait('document.querySelectorAll("table.sessions tbody tr").length===1');
await b.evaluate('document.querySelector("table.sessions [data-history]").click()');
await wait('document.querySelector("#conv-log").textContent.includes("Fixture history line")');
assert.equal(await b.evaluate('document.querySelector("#conv-form").hidden'), false);
// Fix to HEAD behaviour: a failed catalogue read leaves no session from the last one in the picker.
assert.equal(await count('#conv-pick option'), 1);
brd.fail(); await b.evaluate('document.querySelector("table.sessions [data-history]").click()');
await wait('document.querySelector("#conv-status").textContent.startsWith("History unavailable")');
assert.equal(await count('#conv-pick option'), 0, 'no stale session in the picker');
brd.registered(); await b.evaluate('document.querySelector("table.sessions [data-history]").click()');
await wait('document.querySelector("#conv-log").textContent.includes("Fixture history line")');
brd.refuse(); await refresh();
assert.equal(await b.evaluate('document.querySelector("#conversation").hidden'), true);
assert.equal(await b.evaluate('document.body.classList.contains("has-conversation")'), false);
assert.equal(await count('#conv-log > *'), 0);
assert.equal(await b.evaluate('document.querySelector("#conv-form").checkVisibility()'), false);
assert.equal(await b.evaluate('getComputedStyle(document.querySelector("#board-view")).display'), 'none');
assert.equal(await b.evaluate('document.querySelector("#board-refused").hidden'), false);
// Fix to HEAD behaviour: focus went back to History, which the refusal removed; it lands on the page, not <body>.
assert.equal(await b.evaluate('document.activeElement.id'), 'main', 'focus after a refusal closes the conversation');
brd.one(); await refresh(); await wait('document.querySelectorAll("table.sessions tbody tr").length===2');
assert.deepEqual(await b.evaluate('window.errors'), []);
} finally { await b.close(); await close(web); }
});
@@ -1,32 +0,0 @@
// Row 198 (mos-1513): the write mapping the browser tests reach through board-route.mjs, pinned in node
// against the named send listener itself. The Console's own gate, parse and status mapping answer here;
// the browser tests only get back what this listener returns.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { close, fixture } from './fixture.mjs';
import { forward, namedListener } from './board-route.mjs';
test('named send listener maps the board writes: seen is 200, a delivered reply is 200 delivered, an exit-4 reply is 200 not delivered', { timeout: 60000 }, async t => {
const f = await fixture();
const named = await namedListener(f.board);
t.after(async () => { await close(named); await f.close(); });
const port = named.address().port;
const rec = (await f.board.index()).sessions.find(r => r.agent === 'agent1');
const row = `${rec.project}/${rec.agent}`;
const seen = await forward(port, '/api/seen', JSON.stringify({ project: rec.project, agent: rec.agent, lastActivity: rec.lastActivity, seen: true }));
assert.equal(seen.status, 200, seen.text);
const delivered = await forward(port, '/api/reply', JSON.stringify({ agent: row, text: 'hi' }));
assert.equal(delivered.status, 200, delivered.text);
assert.equal(JSON.parse(delivered.text).delivered, true);
assert.equal(f.captures.length, 1);
f.failReply();
const refused = await forward(port, '/api/reply', JSON.stringify({ agent: row, text: 'hi' }));
assert.equal(refused.status, 200, refused.text);
const body = JSON.parse(refused.text);
assert.equal(body.delivered, false);
assert.equal(body.exitCode, 4);
assert.equal(f.captures.length, 2);
});
-75
View File
@@ -1,75 +0,0 @@
// Row 198 (mos-1513): the browser tests reach the real board routes. Fetch intercepts the page's
// /api/config (send on) and its /api/reply and /api/seen writes. Node forwards each write to a named
// send listener on the same board, with the preview Host, the https Origin and the signed user header,
// so the server's own gate, parse and status mapping answer and the page gets that answer back.
// Nothing here re-implements boardSend; the node route tests pin what the listener returns.
import { request } from 'node:http';
import { startServer } from '../src/serve.mjs';
import { close } from './fixture.mjs';
export const ROUTE = { host: 'board-route.example', header: 'X-authentik-uid', user: 'board-route-user' };
const JSON_HEADER = [{ name: 'content-type', value: 'application/json' }];
// The named send listener on the board: the preview Host, so the server's own gate and status mapping answer.
export function namedListener(board) {
return startServer({ port: 0, board, preview: ROUTE, send: async () => ({ id: 'board-route' }) });
}
// One POST to the named listener, as the browser's forwarded write would arrive. Resolves { status, text }.
export function forward(port, path, body) {
return new Promise((resolve, reject) => {
const headers = { host: ROUTE.host, origin: `https://${ROUTE.host}`, [ROUTE.header]: ROUTE.user, 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) };
const req = request({ host: '127.0.0.1', port, method: 'POST', path, headers }, res => {
let text = '';
res.setEncoding('utf8');
res.on('data', chunk => { text += chunk; });
res.on('end', () => resolve({ status: res.statusCode, text }));
});
req.on('error', reject);
req.end(body);
});
}
// The page's /api/config body with send on and everything else as the Console answers it. The base URL may end in a slash.
export async function sendConfig(base) {
return JSON.stringify({ ...(await (await fetch(new URL('/api/config', base))).json()), send: true });
}
// Config only (mos-1524): Fetch answers the page's /api/config with send on and leaves every write alone, so the
// writes go to the page's own listener on its existing path. The Fetch domain stays on until the browser closes.
export async function sendOnConfig(b, base) {
await b.call('Fetch.enable', { patterns: [{ urlPattern: '*/api/config', requestStage: 'Request' }] });
b.on('Fetch.requestPaused', async ({ requestId }) => {
try {
await b.call('Fetch.fulfillRequest', { requestId, responseCode: 200, responseHeaders: JSON_HEADER, body: Buffer.from(await sendConfig(base)).toString('base64') });
} catch {
await b.call('Fetch.failRequest', { requestId, errorReason: 'Failed' }).catch(() => {});
}
});
}
// Returns a function that closes the named listener. Call it in the test's finally, before the browser closes.
export async function routeWrites(b, base, board) {
const listener = await namedListener(board);
const port = listener.address().port;
await b.call('Fetch.enable', { patterns: [
{ urlPattern: '*/api/config', requestStage: 'Request' },
{ urlPattern: '*/api/reply', requestStage: 'Request' },
{ urlPattern: '*/api/seen', requestStage: 'Request' },
] });
b.on('Fetch.requestPaused', async ({ requestId, request: req }) => {
try {
const path = new URL(req.url).pathname;
let answer;
if (path === '/api/config') {
answer = { status: 200, text: await sendConfig(base) };
} else {
answer = await forward(port, path, req.postData ?? '');
}
await b.call('Fetch.fulfillRequest', { requestId, responseCode: answer.status, responseHeaders: JSON_HEADER, body: Buffer.from(answer.text).toString('base64') });
} catch {
await b.call('Fetch.failRequest', { requestId, errorReason: 'Failed' }).catch(() => {});
}
});
return () => close(listener);
}
+13 -18
View File
@@ -1,31 +1,26 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { browser } from './browser.mjs';
import { close } from './fixture.mjs';
import { routeWrites } from './board-route.mjs';
import { startServer } from '../src/serve.mjs';
test('browser edge states: loading, empty, malformed, stale, hostile/long values, in-flight reply and appearance fallback', { timeout: 120000 }, async () => {
let index = { sessions: [], counts: {}, generatedAt: 'fixture time' }, held = null, sends = 0, holdBoard = true, releaseBoard;
let index = { sessions: [], counts: {}, generatedAt: 'fixture time' }, held = null, sends = 0, holdBoard = true, boardResponse;
let resolveBoardRequest;
const boardRequested = new Promise(resolve => { resolveBoardRequest = resolve; });
// The board runs in-process (row 198): index() is the page's /api/board read, reply() its reply.
const board = {
index: () => {
if (!holdBoard) return index;
resolveBoardRequest();
return new Promise(resolve => { releaseBoard = resolve; });
},
reply: () => { sends++; return new Promise(resolve => { held = resolve; }); },
};
const web = await startServer({ port: 0, board });
const server = createServer(async (req, res) => {
for await (const chunk of req) { void chunk; }
res.setHeader('content-type', 'application/json');
if (req.url === '/api/board') { if (holdBoard) { boardResponse = res; resolveBoardRequest(); return; } return res.end(JSON.stringify(index)); }
sends++; held = res;
});
await new Promise(r => server.listen(0, '127.0.0.1', r));
const web = await startServer({ port: 0, board: `http://127.0.0.1:${server.address().port}` });
const b = await browser();
const base = `http://127.0.0.1:${web.address().port}`;
let stopRoute;
const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out')})()`);
try {
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: 'Object.defineProperty(window,"localStorage",{get(){throw new Error("storage denied")}});window.errors=[];addEventListener("error",e=>errors.push(e.message));' });
stopRoute = await routeWrites(b, base, board);
await b.viewport(320); await b.navigate(`http://127.0.0.1:${web.address().port}`);
// Row 95: with storage denied the toggle starts off and still turns on; the page throws nothing.
assert.match(await b.evaluate('document.querySelector("#status").textContent'), /Machine sessions hidden/);
@@ -35,7 +30,7 @@ test('browser edge states: loading, empty, malformed, stale, hostile/long values
let timer;
try { await Promise.race([boardRequested, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('Show machine sessions made no /api/board request in 5 s')), 5000); })]); }
finally { clearTimeout(timer); }
holdBoard = false; releaseBoard(index);
holdBoard = false; boardResponse.end(JSON.stringify(index));
await wait('document.querySelector("#sessions").textContent.includes("No sessions")');
assert.equal(await b.evaluate('document.querySelector("#waiting-count").textContent'), '0');
assert.equal(await b.evaluate('document.documentElement.dataset.palette'), 'harbor');
@@ -57,7 +52,7 @@ test('browser edge states: loading, empty, malformed, stale, hostile/long values
await b.evaluate('document.querySelector("#refresh").click()'); await wait('!document.querySelector("#refresh").disabled');
assert.equal(await b.evaluate('document.querySelector(".reply-form button").disabled'), true);
await b.evaluate('document.querySelector("#reply").focus()'); await b.call('Input.insertText', { text: 'newer ' });
held({ status: 200, body: { delivered: true, sentAt: 'fixture', session: agent } });
held.end(JSON.stringify({ delivered: true, sentAt: 'fixture', session: agent }));
await wait('document.querySelector(".receipt")?.textContent.startsWith("delivered")');
assert.equal(sends, 1); assert.match(await b.evaluate('document.querySelector("#reply").value'), /newer/);
// A stale registration removes the form, but does not destroy its draft.
@@ -97,5 +92,5 @@ test('browser edge states: loading, empty, malformed, stale, hostile/long values
assert.match(await b.evaluate('document.querySelector("#error").textContent'), /Invalid board session data/);
assert.equal(await b.evaluate('document.querySelectorAll("table.sessions [data-open]").length'), 1);
assert.deepEqual(await b.evaluate('window.errors'), []);
} finally { releaseBoard?.(index); held?.({ status: 200, body: {} }); await stopRoute?.(); await b.close(); await close(web); }
} finally { boardResponse?.end(); held?.end(); await b.close(); await close(web); await close(server); }
});
+4 -7
View File
@@ -4,17 +4,14 @@ 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);
@@ -112,15 +109,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.
// 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);
// Stop only the fixture board. The page keeps the last data and reports its URL.
await new Promise(r => f.board.close(r));
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('in the Console')); assert.match(error, /Showing last known data/);
assert.ok(error.includes(f.boardURL)); 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 stopRoute?.(); await b.close(); await f.close(); }
} finally { await b.close(); await f.close(); }
});
+1 -1
View File
@@ -98,7 +98,7 @@ test('S5 pages in a browser: real broker rows, inert text, Copy, routes, failure
assert.ok(comments.label.includes('\u001b'), 'the label excerpt keeps the ESC byte');
assert.ok(comments.label.includes('\u202e'), 'the label excerpt keeps the bidi character');
writeFileSync(fieldsFile, JSON.stringify(hostile));
const web = await startServer({ port: 0, board: f.board, bus: s.bus, fieldsFile }), base = `http://127.0.0.1:${web.address().port}/`;
const web = await startServer({ port: 0, board: f.boardURL, bus: s.bus, fieldsFile }), base = `http://127.0.0.1:${web.address().port}/`;
const out = process.env.WEBUI_EVIDENCE;
if (out) mkdirSync(out, { recursive: true });
const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out: '+${JSON.stringify(expr)})})()`);
+8 -8
View File
@@ -48,7 +48,7 @@ function seeded(t) {
test('bus routes serve the four reads from the reader view, unescaped JSON for the page to escape', async t => {
const { decision, bus, calls } = seeded(t);
const web = await startServer({ port: 0, board: null, bus });
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', 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: null, bus });
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', 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: null, bus });
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', 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: null, bus });
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', 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: null, bus });
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', 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: null, bus });
const none = await startServer({ port: 0, board: null });
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' });
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: null, bus: views({ call: (verb) => call(verb, { mode: 'slow' }) }) });
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus: views({ call: (verb) => call(verb, { mode: 'slow' }) }) });
t.after(() => close(web));
const base = `http://127.0.0.1:${web.address().port}`;
const started = Date.now();
+1 -1
View File
@@ -47,7 +47,7 @@ const stub = `(() => {
})();`;
async function setup(t, suffix = '') {
const f = await fixture(); t.after(() => f.close());
const server = await startServer({ port: 0, board: f.board }); t.after(() => close(server));
const server = await startServer({ port: 0, board: f.boardURL }); t.after(() => close(server));
const b = await browser(); t.after(() => b.close());
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: stub });
await b.navigate(`http://127.0.0.1:${server.address().port}/${suffix}#/chat`);
+1 -1
View File
@@ -53,7 +53,7 @@ test('C2-09a metadata uses the human listener bus, projects labels only and refu
test('C2-09a metadata uses the private tester listener own bus and no human transport', async t => {
const root = mkdtempSync(join(tmpdir(), 'chat219-')); t.after(() => rmSync(root, { recursive: true, force: true }));
let reads = 0;
const server = await startTesterSocket({ dataRoot: root, board: null,
const server = await startTesterSocket({ dataRoot: root, board: 'http://127.0.0.1:9',
bus: { instances: async () => { reads++; return { instances: { quill: { definition: 'proofreader', vars: { 'persona.name': 'Quill' } } } }; } } });
t.after(() => close(server));
const answer = await get(server);
+1 -1
View File
@@ -294,7 +294,7 @@ test('configured Console process injects chat through its existing human transpo
const { fileURLToPath } = await import('node:url');
const root = tmp(t, 'webui-chat-cli-'), { env } = fixture(root);
const cli = fileURLToPath(new URL('../src/cli.mjs', import.meta.url));
const child = spawn(process.execPath, [cli, 'serve', '--port', '0',
const child = spawn(process.execPath, [cli, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9',
'--public-host', preview.host, '--auth-header', preview.header, '--allow-user', preview.user, '--send'],
{ env, stdio: ['ignore', 'pipe', 'pipe'] });
t.after(async () => {
@@ -1,250 +0,0 @@
// Row 252 (#1753, design section 5, J16 and J17): Approve and Deny on a decision page, in a browser.
// A loopback page has no sign-in, so a script added before the page loads sets `sso` and `decide`
// in /api/config from the page URL, and answers each POST /api/decisions/... from a queue, recording
// what the page sent. Server-side checks are decision-http.test.mjs's and the bus side is
// packages/bus/tests/console-decide.test.mjs's. The decisions come from an in-process broker
// through its reader session. Refresh timers fire after 150 ms.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Store } from '../../bus/src/store.mjs';
import { Broker } from '../../bus/src/broker.mjs';
import { views } from '../../bus/src/views.mjs';
import { grantDecision } from '../../bus/tests/host-fixtures.mjs';
import { startServer } from '../src/serve.mjs';
import { browser } from './browser.mjs';
import { fixture, close } from './fixture.mjs';
const HOSTILE = '<script>window.injected=1</script><img src=x onerror="window.injected=2">';
const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'pm', delivery: 'pm' }, roles: { chief: { authority: { withinRole: ['message.send'], crossRole: [] } }, pm: { authority: { withinRole: ['message.send'], crossRole: [] } } } } };
// Answers in order; `hold` waits for window.__release(), `reject` is a network failure, `redirect`
// what fetch gives for the sign-in proxy's redirect under redirect: 'manual'.
const STUB = `(() => {
const real = window.fetch.bind(window);
window.__posts = []; window.__answers = [];
const timeout = window.setTimeout.bind(window);
window.setTimeout = (f, ms, ...rest) => timeout(f, ms === 10000 ? 150 : ms, ...rest);
const q = new URLSearchParams(location.search);
const answer = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
window.fetch = async (url, init = {}) => {
const u = String(url);
if (u === '/api/config') {
const c = await (await real(url, init)).json();
return answer(200, { ...c, ...(q.has('sso') ? { sso: true } : {}), ...(q.has('send') ? { send: true } : {}), ...(q.has('decide') ? { send: true, decide: true } : {}), ...(q.has('tester') ? { tester: true } : {}) });
}
if (!u.startsWith('/api/decisions/')) return real(url, init);
window.__posts.push({ url: u, method: init.method, headers: init.headers, body: init.body, cache: init.cache, redirect: init.redirect });
const a = window.__answers.shift();
if (!a) throw new TypeError('no answer queued');
if (a.hold) await new Promise(r => { window.__release = r; });
if (a.reject) throw new TypeError('Failed to fetch');
if (a.redirect) return { type: 'opaqueredirect', ok: false, status: 0, json: async () => { throw new SyntaxError('no body'); } };
return answer(a.status, a.body);
};
})();`;
// The open grant request, as host-fixtures' grantDecision writes it but with the hostile question.
// decisions is append-only (migration 3b), so the question goes in with the row, not by an UPDATE.
function hostileGrant(store, grant) {
const id = randomUUID(), at = '2026-10-21T00:00:00.000Z';
store.run(
'INSERT INTO decisions(id,at,business,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation,blocking) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)',
id, at, 'demo', 'chief', 'chief-run', 'gated', 'credential.mint', 'human', `Enroll web1? ${HOSTILE}`,
JSON.stringify([{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }]), 'yes', 0,
);
store.run(
"INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,?,'action.allowed',?,?,?,?)",
randomUUID(), at, 'demo', 'chief', 'chief-run', id,
JSON.stringify({ decision: id, action: 'credential.mint', operation: 'decision.raise', approvalChoice: 'yes', grant }),
);
return id;
}
async function setup() {
const root = mkdtempSync(join(tmpdir(), 'webui-decide-browser-'));
const store = new Store(root), b0 = new Broker({ store, businesses });
const grant = { method: 'host-enroll', host: 'web1', address: '192.0.2.10', port: 22, user: 'jarvis', sudo: true, fingerprints: [] };
const open = hostileGrant(store, grant);
const deploy = grantDecision(store, { action: 'deploy', grant, close: null });
const bare = grantDecision(store, { close: null });
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.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)})})()`);
const text = sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.textContent ?? null`);
const has = sel => b.evaluate(`!!document.querySelector(${JSON.stringify(sel)})`);
const posts = () => b.evaluate('window.__posts');
const queue = a => b.evaluate(`window.__answers.push(${JSON.stringify(a)}), true`);
const click = step => b.evaluate(`document.querySelector('#decide-section [data-decide="${step}"]').click(), true`);
const page = async (query, id) => {
// The same URL again is a same-document hash change, not a load, so load about:blank first (as editor-browser does).
await b.navigate('about:blank');
await b.navigate(`${base}?${query}#/inbox/${id}`);
await wait('document.querySelector("#bus-view h1")?.textContent.startsWith("Decision") && !document.querySelector("#bus-view [aria-busy]")');
// The config read redraws once it lands; a refresh tick after it settles the page.
await new Promise(r => setTimeout(r, 400));
};
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 }); };
return { b, store, base, open, deploy, bare, wait, text, has, posts, queue, click, page, settle, done };
}
const CONFIRM = (s, action, choice, text) => ({ status: 200, body: { id: s.open, question: `Enroll web1? ${HOSTILE}`, action, choice, text, grant: { method: 'host-enroll', host: 'web1' }, confirmation: 'c'.repeat(64) } });
test('J16: no controls without sso and decide, read-only reasons, terminal lines kept, and only an eligible grant gets the panel', { timeout: 120000 }, async () => {
const s = await setup();
try {
// Without SSO: no panel, no button, and the reason names both flags.
await s.page('x', s.open);
assert.equal(await s.has('#decide-section'), false);
assert.equal(await s.has('[data-decide]'), false);
assert.match(await s.text('#bus-view'), /needs sign-in \(--sso\) and writes enabled \(--send\)/);
assert.equal(await s.b.evaluate('document.querySelectorAll("#bus-view [data-copy]").length'), 2);
// SSO without --send: read-only, still the terminal lines.
await s.page('sso', s.open);
assert.equal(await s.has('#decide-section'), false);
assert.match(await s.text('#bus-view'), /read-only for decisions: it was started without writes enabled \(--send\)/);
// send alone, without decide, still draws nothing.
await s.page('sso&send', s.open);
assert.equal(await s.has('#decide-section'), false);
// A tester Console never answers, whatever the config says.
await s.page('sso&decide&tester', s.open);
assert.equal(await s.has('#decide-section'), false);
// Eligible: the panel, both buttons, the escaped question and the terminal lines too.
await s.page('sso&decide', s.open);
assert.equal(await s.text('#decide-section h2'), 'Answer in Console');
assert.deepEqual(await s.b.evaluate('[...document.querySelectorAll("#decide-section [data-decide]")].map(e => [e.dataset.decide, e.textContent, e.disabled])'), [['approve', 'Approve', false], ['deny', 'Deny', false]]);
assert.match(await s.text('#decide-section'), /Grant: method host-enroll · host web1\./);
assert.equal(await s.b.evaluate('document.querySelectorAll("#bus-view [data-copy]").length'), 2);
assert.equal(await s.b.evaluate('window.injected ?? null'), null);
assert.ok((await s.text('#bus-view .s1-q')).includes(HOSTILE));
// A grant raise with another action, and a credential.mint with no grant, keep the terminal only.
for (const id of [s.deploy, s.bare]) {
await s.page('sso&decide', id);
assert.equal(await s.has('#decide-section'), false);
assert.equal(await s.has('[data-decide]'), false);
}
// The Inbox says so, and marks only the eligible row.
await s.b.navigate(`${s.base}?sso&decide#/inbox`);
await s.wait('document.querySelector("#bus-view h1")?.textContent.startsWith("Inbox") && document.querySelectorAll("#bus-view .s1-decs > li").length === 3');
await s.settle();
assert.match(await s.text('#bus-view'), /A grant request can be approved or denied here, after a confirmation\./);
assert.equal(await s.b.evaluate('[...document.querySelectorAll("#bus-view .s1-decs > li")].filter(li => li.textContent.includes("Approve or Deny here")).length'), 1);
assert.deepEqual(await s.posts(), []);
} finally { await s.done(); }
});
test('J17: one POST per click, confirm then answer, cancel, refusals and an unknown outcome never retry', { timeout: 120000 }, async () => {
const s = await setup();
const enc = encodeURIComponent;
try {
await s.page('sso&decide', s.open);
// Approve asks for a confirmation and answers nothing. The button holds while the request is out.
await s.queue({ ...CONFIRM(s, 'approve', 'yes', 'Yes'), hold: true });
await s.click('approve');
await s.wait('document.querySelector("#decide-section [data-decide=approve]")?.textContent === "Checking…"');
assert.equal(await s.b.evaluate('[...document.querySelectorAll("#decide-section [data-decide]")].every(e => e.disabled)'), true);
await s.click('approve');
await s.b.evaluate('window.__release(), true');
await s.wait('document.querySelector("#decide-section [data-decide=confirm]")');
let sent = await s.posts();
assert.equal(sent.length, 1);
assert.deepEqual(sent[0], { url: `/api/decisions/${enc(s.open)}/confirmation`, method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ choice: 'yes' }), cache: 'no-store', redirect: 'manual' });
assert.equal(await s.text('#decide-section [data-decide=confirm]'), 'Confirm approve');
assert.match(await s.text('#decide-section .s1-confirm'), /Approving starts this grant\./);
assert.equal(await s.b.evaluate('document.activeElement?.dataset.decide ?? null'), 'confirm');
assert.equal(await s.b.evaluate('window.injected ?? null'), null);
// A refresh keeps the confirmation open.
await s.settle();
assert.equal(await s.has('#decide-section [data-decide=confirm]'), true);
// Cancel drops it without a request.
await s.click('cancel');
await s.wait('document.querySelector("#decide-section [data-decide=approve]")');
assert.equal((await s.posts()).length, 1);
// A refusal the Console wrote shows its code and message; a 408 or a lost answer is unavailable.
await s.queue({ status: 409, body: { error: 'decision-stale', message: 'The choices changed. Review the decision again.' } });
await s.click('deny');
await s.wait('document.querySelector("#decide-status")');
assert.equal(await s.text('#decide-status'), 'Not answered here: The choices changed. Review the decision again. (decision-stale).');
await s.queue({ status: 408, body: { error: 'x', message: 'y' } });
await s.click('deny');
await s.wait('document.querySelector("#decide-status code")?.textContent === "confirmation-unavailable"');
await s.queue({ reject: true });
await s.click('deny');
await s.wait('document.querySelectorAll("#decide-section [data-decide]:not([disabled])").length === 2');
assert.equal(await s.b.evaluate('document.querySelector("#decide-status code")?.textContent'), 'confirmation-unavailable');
assert.equal((await s.posts()).length, 4);
// A reply that doesn't match what was asked is not a confirmation.
await s.queue({ status: 200, body: { ...CONFIRM(s, 'approve', 'yes', 'Yes').body } });
await s.click('deny');
await s.wait('document.querySelector("#decide-status code")?.textContent === "confirmation-unavailable" && document.querySelectorAll("#decide-section [data-decide]:not([disabled])").length === 2');
assert.equal(await s.has('#decide-section [data-decide=confirm]'), false);
// An unknown outcome at the answer says to refresh, drops the confirmation and sends nothing again.
await s.queue(CONFIRM(s, 'deny', 'no', 'No'));
await s.click('deny');
await s.wait('document.querySelector("#decide-section [data-decide=confirm]")');
assert.match(await s.text('#decide-section .s1-confirm'), /Denying starts nothing\./);
await s.queue({ status: 503, body: { error: 'outcome-unknown', message: 'The answer may have reached the bus. Refresh before trying again.' } });
await s.click('confirm');
await s.wait('document.querySelector("#decide-status code")?.textContent === "outcome-unknown"');
assert.match(await s.text('#decide-status'), /Nothing was sent again\.$/);
sent = await s.posts();
assert.equal(sent.length, 7);
assert.deepEqual(sent[6], { url: `/api/decisions/${enc(s.open)}/resolve`, method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ choice: 'no', confirmation: 'c'.repeat(64) }), cache: 'no-store', redirect: 'manual' });
assert.equal(await s.has('#decide-section [data-decide=confirm]'), false);
await s.settle();
await s.settle();
assert.equal((await s.posts()).length, 7, 'the page sent again by itself');
// A confirmed approve: one answer, a receipt, and no buttons after it, across refreshes.
await s.queue(CONFIRM(s, 'approve', 'yes', 'Yes'));
await s.click('approve');
await s.wait('document.querySelector("#decide-section [data-decide=confirm]")');
await s.queue({ status: 200, body: { id: s.open, op: 'resolved', choice: 'yes', grant: 'started' } });
await s.click('confirm');
await s.wait('document.querySelector("#decide-status")?.textContent.startsWith("Approved")');
assert.equal(await s.text('#decide-status'), 'Approved: you chose “yes”, Yes. The grant executor started. History below shows it once the bus is read again.');
await s.settle();
assert.equal(await s.has('[data-decide]'), false);
assert.equal((await s.posts()).length, 9);
// Closed elsewhere: the next read drops the panel and shows the recorded answer with its provenance.
s.store.run('INSERT INTO decision_events(decision,at,op,by,choice,via) VALUES(?,?,?,?,?,?)', s.open, '2026-10-22T00:00:00.000Z', 'resolved', 'jason', 'yes', 'console');
await s.page('sso&decide', s.open);
assert.equal(await s.has('#decide-section'), false);
assert.match(await s.text('#bus-view'), /Resolved: jason chose “yes” via console/);
assert.equal(await s.b.evaluate('document.querySelectorAll("#bus-view [data-copy]").length'), 0);
} finally { await s.done(); }
});
test('a sign-in redirect at either step signs the page out and answers nothing', { timeout: 120000 }, async () => {
const s = await setup();
try {
await s.page('sso&decide', s.open);
await s.queue({ redirect: true });
await s.click('approve');
await s.wait('document.body.classList.contains("signed-out")');
assert.equal((await s.posts()).length, 1);
await s.page('sso&decide', s.open);
await s.queue(CONFIRM(s, 'approve', 'yes', 'Yes'));
await s.click('approve');
await s.wait('document.querySelector("#decide-section [data-decide=confirm]")');
await s.queue({ redirect: true });
await s.click('confirm');
await s.wait('document.body.classList.contains("signed-out")');
assert.equal((await s.posts()).length, 2);
} finally { await s.done(); }
});
-192
View File
@@ -1,192 +0,0 @@
// Row 252 (#1753, design sections 4 and 5): the Console decision routes on the human listener, signed
// in through the fake OP in helpers/fake-oidc.mjs on loopback. The bus transport is a recording stub,
// so this covers the listener's gates and the session it passes; decision-http.test.mjs covers the
// endpoint's own table and console-decide.test.mjs the bus. The public name is console.example.test,
// sent only as a Host header to the test's own listener. sso.test.mjs pins its own log lines, so the
// decide lines are checked here.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { request } from 'node:http';
import { readFileSync } from 'node:fs';
import { startServer } from '../src/serve.mjs';
import { createSso, SESSION_COOKIE } from '../src/sso.mjs';
import { DECISION_REFUSALS } from '../src/decision-http.mjs';
import { consoleCall } from '../src/bus.mjs';
import { consoleAccess } from '../src/access.mjs';
import { userOptionsFor } from '../src/console-bus.mjs';
import { close } from './fixture.mjs';
import { fakeOidc } from './helpers/fake-oidc.mjs';
const HOST = 'console.example.test', ORIGIN = `https://${HOST}`;
const DIGEST = 'b'.repeat(64);
const PREVIEW = { id: 'd1', business: 'demo', question: 'Enroll web1?', digest: DIGEST, approve: { key: 'yes', text: 'Yes' }, deny: { key: 'no', text: 'No' }, grant: { method: 'host-enroll', host: 'web1' } };
// node:http, not fetch: fetch can't set Host, Origin or Sec-Fetch-Site.
function call(port, { method = 'GET', path = '/', headers = {}, body, host = HOST } = {}) {
return new Promise((resolve, reject) => {
const length = body === undefined ? {} : { '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, headers: res.headers, body: text && (res.headers['content-type'] ?? '').startsWith('application/json') ? JSON.parse(text) : text }));
});
req.on('error', reject);
req.end(body);
});
}
const writeHeaders = cookie => ({ ...(cookie ? { cookie } : {}), origin: ORIGIN, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' });
// The fake OP, createSso over it, and the human listener. `server` overrides startServer options.
// The decision transport records each call and answers a preview or a resolve. `access`, when given,
// is the access document: createSso gets the users hook and the listener a user transport, as
// granted-user.test.mjs sets them up.
async function setup(t, server = {}, access = null) {
const op = await fakeOidc();
t.after(() => op.close());
const iss = op.issuer('a');
const config = { publicOrigin: ORIGIN, providers: [{ id: 'a', issuer: iss.issuer, clientId: iss.clientId, clientSecret: { slot: 'a' }, algs: ['RS256'], subjects: [{ sub: 'user-a', name: 'person-a' }] }] };
const lines = [];
const users = access && { users: consoleAccess({ load: () => access, business: () => 'demo' }) };
const sso = await createSso(config, { secrets: new Map([['a', iss.clientSecret]]), fetch: op.fetch, log: l => lines.push(l), ...users });
t.after(() => sso.close());
const calls = [];
const decisionWrite = async (verb, args, ctx) => {
calls.push({ verb, args, ctx });
return verb === 'decision.preview.console' ? { ...PREVIEW } : { id: args.id, op: 'resolved', choice: args.choice };
};
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: 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) => {
const start = await call(port, { path: '/auth/login' });
assert.equal(start.status, 303);
const cookie = [start.headers['set-cookie']].flat()[0].split(';')[0];
const g = op.grant(iss, start.headers.location, { sub });
const q = new URLSearchParams({ code: g.code, state: new URL(start.headers.location).searchParams.get('state') });
const r = await call(port, { path: `/auth/callback?${q}`, headers: { cookie } });
return [r.headers['set-cookie'] ?? []].flat().find(c => c.startsWith(`${SESSION_COOKIE}=`) && !c.includes('Max-Age=0')).split(';')[0];
};
const post = (cookie, path, body, headers = {}) => call(port, { method: 'POST', path, headers: { ...writeHeaders(cookie), ...headers }, body: JSON.stringify(body) });
return { port, iss, calls, lines, signIn, post };
}
const refused = (r, code) => {
assert.equal(r.status, DECISION_REFUSALS[code][0], `${code}: ${r.status} ${JSON.stringify(r.body)}`);
assert.deepEqual(r.body, { error: code, message: DECISION_REFUSALS[code][1] });
};
test('a signed-in owner confirms and answers, with the session\'s provider, issuer and sub as the subject', async t => {
const env = await setup(t);
const session = await env.signIn();
const config = await call(env.port, { path: '/api/config', headers: { cookie: session } });
assert.equal(config.body.decide, true);
assert.equal(config.body.sso, true);
const c = await env.post(session, '/api/decisions/d1/confirmation', { choice: 'yes' });
assert.equal(c.status, 200, JSON.stringify(c.body));
const r = await env.post(session, '/api/decisions/d1/resolve', { choice: 'yes', confirmation: c.body.confirmation });
assert.equal(r.status, 200, JSON.stringify(r.body));
assert.deepEqual(r.body, { id: 'd1', op: 'resolved', choice: 'yes' });
const subject = { provider: 'a', iss: env.iss.issuer, sub: 'user-a' };
assert.deepEqual(env.calls, [
{ verb: 'decision.preview.console', args: { id: 'd1' }, ctx: { subject } },
{ verb: 'decision.resolve.console', args: { id: 'd1', choice: 'yes', digest: DIGEST }, ctx: { subject, business: 'demo' } },
]);
assert.match(env.lines.at(-1), / decide user="person-a" decision="d1" step=resolve choice="yes"$/);
assert.equal(env.lines.some(l => l.includes(c.body.confirmation) || l.includes(session.split('=')[1])), false);
});
test('the listener gates: no session, a cross-origin page, a loopback Host, a GET, and a sign-out between the steps', async t => {
const env = await setup(t);
refused(await env.post(null, '/api/decisions/d1/confirmation', { choice: 'yes' }), 'sign-in-required');
const session = await env.signIn();
refused(await env.post(session, '/api/decisions/d1/confirmation', { choice: 'yes' }, { origin: 'https://evil.test' }), 'cross-origin');
refused(await env.post(session, '/api/decisions/d1/confirmation', { choice: 'yes' }, { 'sec-fetch-site': 'cross-site' }), 'cross-origin');
const loop = await call(env.port, { method: 'POST', path: '/api/decisions/d1/confirmation', host: `127.0.0.1:${env.port}`, headers: { cookie: session, 'content-type': 'application/json' }, body: '{"choice":"yes"}' });
assert.equal(loop.status, 403);
refused(await call(env.port, { path: '/api/decisions/d1/confirmation', headers: { cookie: session } }), 'method-not-allowed');
assert.equal(env.calls.length, 0);
const c = await env.post(session, '/api/decisions/d1/confirmation', { choice: 'yes' });
assert.equal(c.status, 200);
const out = await call(env.port, { method: 'POST', path: '/auth/logout', headers: { cookie: session, origin: ORIGIN, 'sec-fetch-site': 'same-origin' } });
assert.equal(out.status, 303);
refused(await env.post(session, '/api/decisions/d1/resolve', { choice: 'yes', confirmation: c.body.confirmation }), 'sign-in-required');
assert.equal(env.calls.length, 1);
// A new session can't use the old session's confirmation.
const again = await env.signIn();
refused(await env.post(again, '/api/decisions/d1/resolve', { choice: 'yes', confirmation: c.body.confirmation }), 'confirmation-required');
assert.equal(env.calls.length, 1);
});
test('J16: without a decision transport the routes are read-only and /api/config says no; startServer refuses decide without sso or send', async t => {
// A decision transport without --send never starts (serve.mjs refuses it). The configs that start
// without send have no transport either, so they are checked with the missing transport.
await assert.rejects(setup(t, { send: null }), /need --sso and --send/);
for (const server of [{ decisionWrite: null }, { decisionWrite: null, send: null }]) {
const env = await setup(t, server);
const session = await env.signIn();
const config = await call(env.port, { path: '/api/config', headers: { cookie: session } });
assert.equal(config.body.decide, false);
assert.equal(config.body.sso, true);
refused(await env.post(session, '/api/decisions/d1/confirmation', { choice: 'yes' }), 'read-only');
assert.equal(env.calls.length, 0);
}
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: 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"}' });
refused(r, 'read-only');
const config = await call(port, { path: '/api/config', host });
assert.equal(config.body.decide, false);
assert.equal('sso' in config.body, false);
});
test('J19: a granted user\'s session gets decide false and the user page config, and a decision POST stops at owner-only', async t => {
const access = {
accessVersion: 1, revision: 1,
users: [{ id: 'alice', home: 'local', subjects: [{ provider: 'a', sub: 'alice-sub' }] }],
grants: [{ id: 'g-1', user: 'alice', business: 'demo', projects: ['*'], instances: ['assistant'], level: 'collaborator' }],
};
const env = await setup(t, {}, access);
// The listener has a decision transport and --send, and the owner's session decides.
const owner = await env.signIn();
assert.equal((await call(env.port, { path: '/api/config', headers: { cookie: owner } })).body.decide, true);
// The granted user's config says send (its own transport) but never decide.
const alice = await env.signIn('alice-sub');
const config = await call(env.port, { path: '/api/config', headers: { cookie: alice } });
assert.equal(config.status, 200);
assert.equal(config.body.user, true);
assert.equal(config.body.send, true);
assert.equal(config.body.decide, false);
// userRoute refuses the decision routes before decisionHTTP, so no session reaches the transport.
for (const step of ['confirmation', 'resolve']) {
const r = await env.post(alice, `/api/decisions/d1/${step}`, { choice: 'yes', confirmation: 'c'.repeat(64) });
assert.equal(r.status, 403);
assert.equal(r.body.error, 'owner-only');
}
assert.equal(env.calls.length, 0);
});
test('N4: decision.resolve has no Console route, the --send caller names no decision verb, and consoleCall takes only the two console verbs', async t => {
const env = await setup(t);
const session = await env.signIn();
for (const verb of ['decision.resolve', 'decision.resolve.console', 'decision.preview.console']) {
for (const method of ['GET', 'POST']) {
const r = await call(env.port, { method, path: `/api/bus/${verb}`, headers: writeHeaders(session), ...(method === 'POST' ? { body: '{}' } : {}) });
// A GET finds no such bus read. A POST stops earlier, at the preview's read-only refusal for
// every write that has no route of its own (row 74).
if (method === 'GET') assert.equal(r.status, 404, `${method} ${verb}`);
else assert.deepEqual([r.status, r.body.error], [403, 'read-only'], `${method} ${verb}`);
}
}
assert.equal(env.calls.length, 0);
const cli = readFileSync(new URL('../src/cli.mjs', import.meta.url), 'utf8');
assert.doesNotMatch(cli, /['"]decision\./);
const c = consoleCall({ dataRoot: '/nonexistent', socket: '/nonexistent', business: 'demo', cli: '/nonexistent/console-cli.mjs', timeoutMs: 1000 });
for (const verb of ['decision.resolve', 'decision.seen', 'message.send', 'inbox']) {
await assert.rejects(c(verb, { id: 'd1', choice: 'yes' }, { subject: { provider: 'a', iss: 'i', sub: 's' } }), /the Console does not call/);
}
});
-205
View File
@@ -1,205 +0,0 @@
// Row 252 (#1753): the Console decision endpoint, driven with a fake request, response and bus
// transport. The real bus side is in packages/bus/tests/console-decide.test.mjs and the page side
// in the browser tests.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { BusReadError } from '../src/bus.mjs';
import { DECISION_REFUSALS, createConfirmations, decisionHTTP } from '../src/decision-http.mjs';
const ORIGIN = 'https://console.test';
const DIGEST = 'a'.repeat(64);
const PREVIEW = {
id: 'd1', business: 'demo', question: 'Enroll host?', digest: DIGEST,
approve: { key: 'yes', text: 'Yes, enroll it' }, deny: { key: 'no', text: 'No' },
grant: { method: 'host-enroll', host: 'h1' },
};
function request({ method = 'POST', body = {}, headers = {}, raw } = {}) {
const h = { origin: [ORIGIN], 'sec-fetch-site': ['same-origin'], 'content-type': ['application/json'], ...headers };
for (const k of Object.keys(h)) if (h[k] === null) delete h[k];
const data = Buffer.from(raw ?? JSON.stringify(body));
return { method, headersDistinct: h, async *[Symbol.asyncIterator]() { yield data; } };
}
function response() {
const r = { status: null, headers: null, text: '' };
r.writeHead = (s, h) => { r.status = s; r.headers = h; };
r.end = (t = '') => { r.text += t; };
r.json = () => JSON.parse(r.text);
return r;
}
function setup({ answer = () => ({ ...PREVIEW }), resolve = (a) => ({ id: a.id, op: 'resolved', choice: a.choice, grant: 'started' }), now } = {}) {
const session = { name: 'jason', provider: 'fake-oidc', iss: 'https://idp.test', sub: 'owner-1' };
const calls = [], lines = [];
const s = {
session, calls, lines, current: session,
confirmations: createConfirmations(now ? { now } : {}),
decide: {
origin: ORIGIN,
call: async (verb, args, ctx) => {
calls.push({ verb, args, ctx });
return verb === 'decision.preview.console' ? answer(args) : resolve(args);
},
},
};
s.run = async (path, req) => {
const res = response();
await decisionHTTP(req, res, { path, decide: s.decide, session: s.session, live: () => s.current, confirmations: s.confirmations, log: (l) => lines.push(l) });
return res;
};
s.confirm = (choice = 'yes', id = 'd1') => s.run(`/api/decisions/${id}/confirmation`, request({ body: { choice } }));
s.resolve = (body, id = 'd1') => s.run(`/api/decisions/${id}/resolve`, request({ body }));
return s;
}
const refused = (res, code) => {
assert.equal(res.status, DECISION_REFUSALS[code][0], `${code}: ${res.text}`);
assert.deepEqual(res.json(), { error: code, message: DECISION_REFUSALS[code][1] });
assert.equal(res.headers['cache-control'], 'no-store');
};
test('a confirmation then a resolve answers once with the held choice and digest, and a second resolve refuses', async () => {
const s = setup();
const c = await s.confirm('yes');
assert.equal(c.status, 200);
const body = c.json();
assert.match(body.confirmation, /^[a-f0-9]{64}$/);
assert.deepEqual({ ...body, confirmation: 'x' }, { id: 'd1', question: 'Enroll host?', action: 'approve', choice: 'yes', text: 'Yes, enroll it', grant: PREVIEW.grant, confirmation: 'x' });
assert.deepEqual(s.calls[0], { verb: 'decision.preview.console', args: { id: 'd1' }, ctx: { subject: { provider: 'fake-oidc', iss: 'https://idp.test', sub: 'owner-1' } } });
const r = await s.resolve({ choice: 'yes', confirmation: body.confirmation });
assert.equal(r.status, 200);
assert.deepEqual(r.json(), { id: 'd1', op: 'resolved', choice: 'yes', grant: 'started' });
assert.deepEqual(s.calls[1], { verb: 'decision.resolve.console', args: { id: 'd1', choice: 'yes', digest: DIGEST }, ctx: { subject: { provider: 'fake-oidc', iss: 'https://idp.test', sub: 'owner-1' }, business: 'demo' } });
// J12: the confirmation is spent, so a repeat never reaches the bus.
refused(await s.resolve({ choice: 'yes', confirmation: body.confirmation }), 'confirmation-required');
assert.equal(s.calls.length, 2);
assert.equal(s.lines.length, 3);
assert.match(s.lines[0], / decide user="jason" decision="d1" step=confirmation action=approve$/);
assert.match(s.lines[1], / step=resolve choice="yes" grant=started$/);
assert.doesNotMatch(s.lines.join('\n'), new RegExp(body.confirmation));
});
test('deny resolves without a grant field', async () => {
const s = setup({ resolve: (a) => ({ id: a.id, op: 'resolved', choice: a.choice }) });
const c = (await s.confirm('no')).json();
assert.equal(c.action, 'deny');
assert.equal(c.text, 'No');
const r = await s.resolve({ choice: 'no', confirmation: c.confirmation });
assert.deepEqual(r.json(), { id: 'd1', op: 'resolved', choice: 'no' });
});
test('the gates before the body: method, read-only, sign-in and same origin', async () => {
const s = setup();
refused(await s.run('/api/decisions/d1/confirmation', request({ method: 'GET' })), 'method-not-allowed');
const res = response();
await decisionHTTP(request({ body: { choice: 'yes' } }), res, { path: '/api/decisions/d1/confirmation', decide: null, session: s.session, live: () => s.session, confirmations: s.confirmations, log: () => {} });
refused(res, 'read-only');
const out = response();
await decisionHTTP(request({ body: { choice: 'yes' } }), out, { path: '/api/decisions/d1/confirmation', decide: s.decide, session: null, live: () => null, confirmations: s.confirmations, log: () => {} });
refused(out, 'sign-in-required');
for (const headers of [{ origin: ['https://evil.test'] }, { origin: null }, { origin: [ORIGIN, ORIGIN] }, { 'sec-fetch-site': ['same-site'] }, { 'sec-fetch-site': null }]) {
refused(await s.run('/api/decisions/d1/confirmation', request({ body: { choice: 'yes' }, headers })), 'cross-origin');
}
assert.equal(s.calls.length, 0);
});
test('a bad body, id or content type refuses before the bus', async () => {
const s = setup();
const path = '/api/decisions/d1/confirmation';
refused(await s.run(path, request({ body: { choice: 'yes' }, headers: { 'content-type': ['text/plain'] } })), 'invalid-request');
refused(await s.run(path, request({ raw: '{' })), 'invalid-request');
refused(await s.run(path, request({ raw: '[]' })), 'invalid-request');
refused(await s.run(path, request({ body: { choice: 'yes', extra: 1 } })), 'invalid-request');
refused(await s.run(path, request({ body: { choice: 1 } })), 'invalid-request');
refused(await s.run(path, request({ raw: JSON.stringify({ choice: 'y'.repeat(5000) }) })), 'request-too-large');
refused(await s.run('/api/decisions/..%2Fx/confirmation', request({ body: { choice: 'yes' } })), 'decision-not-found');
refused(await s.run('/api/decisions/%E0%A4%A/confirmation', request({ body: { choice: 'yes' } })), 'decision-not-found');
refused(await s.resolve({ choice: 'yes' }), 'invalid-request');
refused(await s.resolve({ choice: 'yes', confirmation: 'a'.repeat(64), more: 1 }), 'invalid-request');
assert.equal(s.calls.length, 0);
});
test('a choice that is neither key is decision-stale, and a malformed preview is confirmation-unavailable', async () => {
refused(await setup().confirm('maybe'), 'decision-stale');
for (const bad of [{ ...PREVIEW, id: 'd2' }, { ...PREVIEW, digest: 'short' }, { ...PREVIEW, deny: { key: 'yes', text: 'No' } }, { ...PREVIEW, grant: null }, null]) {
const s = setup({ answer: () => bad });
refused(await s.confirm('yes'), 'confirmation-unavailable');
assert.equal(s.confirmations.size, 0);
}
});
test('bus refusals map by code, and an unknown or missing code is unavailable at preview and outcome-unknown at resolve', async () => {
const cases = [
['human-required', 'human-required'], ['agent-required', 'human-required'], ['access-revoked', 'human-required'],
['peercred-required', 'peercred-required'], ['decision-not-eligible', 'decision-not-eligible'],
['decision-closed', 'decision-closed'], ['decision-stale', 'decision-stale'], ['invalid-choice', 'decision-stale'],
['decision-not-found', 'decision-not-found'], ['outcome-unknown', 'confirmation-unavailable'], ['weird', 'confirmation-unavailable'],
];
for (const [bus, code] of cases) {
const s = setup({ answer: () => { throw new BusReadError(bus); } });
refused(await s.confirm('yes'), code);
}
refused(await setup({ answer: () => { throw new Error('boom'); } }).confirm('yes'), 'confirmation-unavailable');
for (const [bus, code] of [['decision-closed', 'decision-closed'], ['outcome-unknown', 'outcome-unknown'], ['weird', 'outcome-unknown']]) {
const s = setup({ resolve: () => { throw new BusReadError(bus); } });
const c = (await s.confirm('yes')).json();
refused(await s.resolve({ choice: 'yes', confirmation: c.confirmation }), code);
// Nothing retries, and the confirmation stays spent.
assert.equal(s.calls.length, 2);
refused(await s.resolve({ choice: 'yes', confirmation: c.confirmation }), 'confirmation-required');
}
for (const reply of [null, { id: 'd2', op: 'resolved', choice: 'yes' }, { id: 'd1', op: 'refused', choice: 'yes' }, { id: 'd1', op: 'resolved', choice: 'no' }]) {
const s = setup({ resolve: () => reply });
const c = (await s.confirm('yes')).json();
refused(await s.resolve({ choice: 'yes', confirmation: c.confirmation }), 'outcome-unknown');
}
});
test('a confirmation binds the session, decision and choice, and any mismatch spends it', async () => {
const s = setup();
let c = (await s.confirm('yes')).json();
refused(await s.resolve({ choice: 'no', confirmation: c.confirmation }), 'confirmation-required');
refused(await s.resolve({ choice: 'yes', confirmation: c.confirmation }), 'confirmation-required');
c = (await s.confirm('yes')).json();
refused(await s.resolve({ choice: 'yes', confirmation: c.confirmation }, 'd2'), 'confirmation-required');
c = (await s.confirm('yes')).json();
const other = setup();
other.confirmations = s.confirmations;
other.session = { ...s.session };
other.current = other.session;
refused(await other.resolve({ choice: 'yes', confirmation: c.confirmation }), 'confirmation-required');
refused(await s.resolve({ choice: 'yes', confirmation: 'not-a-token' }), 'confirmation-required');
assert.equal(s.calls.filter((x) => x.verb === 'decision.resolve.console').length, 0);
assert.equal(other.calls.length, 0);
});
test('a session that ends between the steps refuses before the bus', async () => {
const s = setup();
s.current = null;
refused(await s.confirm('yes'), 'sign-in-required');
assert.equal(s.calls.length, 0);
s.current = s.session;
const c = (await s.confirm('yes')).json();
s.current = { ...s.session };
refused(await s.resolve({ choice: 'yes', confirmation: c.confirmation }), 'sign-in-required');
assert.equal(s.calls.length, 1);
// A sign-out during the preview also refuses, and no confirmation is issued.
const t = setup({ answer: () => { t.current = null; return { ...PREVIEW }; } });
refused(await t.confirm('yes'), 'sign-in-required');
assert.equal(t.confirmations.size, 0);
});
test('confirmations expire after the TTL, cap at the limit without dropping a live one, and are one use', () => {
let t = 0;
const store = createConfirmations({ ttlMs: 1000, max: 2, now: () => t });
const bind = { session: 's', business: 'demo', decision: 'd1', choice: 'yes', digest: DIGEST };
const a = store.issue(bind), b = store.issue(bind);
assert.equal(store.issue(bind), null);
assert.equal(store.size, 2);
assert.equal(store.consume(a, bind).digest, DIGEST);
assert.equal(store.consume(a, bind), null);
t = 1000;
assert.equal(store.consume(b, bind), null);
assert.equal(store.size, 0);
assert.match(store.issue(bind), /^[a-f0-9]{64}$/);
assert.equal(store.consume(undefined, bind), null);
assert.equal(store.consume('A'.repeat(64), bind), null);
});
+8 -10
View File
@@ -3,16 +3,16 @@ import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { startServer as startBoard } from '../../control-board/src/serve.mjs';
import { identityOf } from '../../discord/src/journal.mjs';
import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs';
import { startServer } from '../src/serve.mjs';
import { browser } from './browser.mjs';
import { close, inProcessBoard } from './fixture.mjs';
import { forward, namedListener } from './board-route.mjs';
import { close } from './fixture.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 web, b, named;
let board, web, b;
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,21 +23,19 @@ 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' });
const board = inProcessBoard({ root, specs: [], seatsDir, exec: () => { throw Error('no transport'); } });
web = await startServer({ port: 0, board });
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 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);
// 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' }));
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' }) });
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(base);
await b.navigate(`http://127.0.0.1:${board.address().port}`);
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/);
@@ -60,5 +58,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 (named) await close(named); if (web) await close(web); rmSync(root, { recursive: true, force: true }); }
} finally { if (b) await b.close(); if (web) await close(web); if (board) await close(board); rmSync(root, { recursive: true, force: true }); }
});
+1 -1
View File
@@ -48,7 +48,7 @@ test('the editor: the list, hostile markdown that runs nothing, the draft, and e
const f = await fixture(), b = await browser();
const reads = { files: [], file: [] };
const web = await startServer({
port: 0, board: f.board,
port: 0, board: f.boardURL,
reads: { business: async () => ({ business: { id: 'demo', human: 'jason', projects: ['stack'] } }) },
projectFiles: async args => { reads.files.push(args); return { head: C1, files: Object.keys(files).map(path => ({ path })) }; },
projectFile: async args => { reads.file.push(args); return { path: args.path, head: C1, commit: C1, content: files[args.path], author: 'jason', at: '2026-10-16T00:00:00Z' }; },
+1 -1
View File
@@ -51,7 +51,7 @@ async function session(run) {
const reads = [];
const f = await fixture(), b = await browser();
const web = await startServer({
port: 0, board: f.board,
port: 0, board: f.boardURL,
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' }; },
+7 -13
View File
@@ -1,19 +1,12 @@
// Same session-line fixture shape as control-board/tests/serve.test.mjs.
// Run the real board scanner in-process (row 198) against temporary data only.
// Run the real board scanner/server against temporary data only.
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createBoard } from '../../control-board/src/board.mjs';
import { startServer as startBoard } from '../../control-board/src/serve.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 = [];
@@ -36,11 +29,12 @@ export async function fixture() {
updateTask(seatsDir, agent, 'Registered fixture task', { layout: 'repo', setBy: 'fixture-setter' });
}
}
const board = inProcessBoard({ root, specs, seatsDir, isAlive: tmux => tmux.session !== 'agent4',
const board = await startBoard({ port: 0, specs, seatsDir, boardDir: join(root, 'board'), isAlive: tmux => tmux.session !== 'agent4', isPidAlive: () => true,
exec: (file, args) => { captures.push({ file, args }); return { status: exitCode, stdout: 'fixture transport', stderr: exitCode ? 'fixture refusal <unsafe>' : '' }; },
});
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 }); },
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 }); },
};
}
+3 -33
View File
@@ -51,7 +51,7 @@ const ALICE = { id: 'alice', home: 'local', subjects: [{ provider: 'a', sub: 'al
// The fake OP, an in-process broker for business `demo`, createSso with the access hook, and the
// human listener. The owner's options are counting stubs; each user session's options are
// consoleBus over that user's transport. `counts` records every owner stub and user transport call.
async function setup(t, { users = [ALICE], grants = [grant()], board = null, send = false } = {}) {
async function setup(t, { users = [ALICE], grants = [grant()] } = {}) {
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()], board = null, sen
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, send, sso, log, ...owner, userOptions, federation: FEDERATION });
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', 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,36 +246,6 @@ 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 });
@@ -366,7 +336,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.board, federation: FEDERATION }); t.after(() => close(owner));
const owner = await startServer({ port: 0, board: f.boardURL, 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 -4
View File
@@ -5,9 +5,10 @@
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, inProcessBoard } from './fixture.mjs';
import { close } from './fixture.mjs';
export const at = s => new Date(Date.now() + s * 1000).toISOString();
@@ -35,11 +36,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 = inProcessBoard({ root, specs: [{ agent: seat, project, sessionsDir, tmux: { session: seat } }], seatsDir, isAlive: () => true,
const board = await startBoard({ port: 0, specs: [{ agent: seat, project, sessionsDir, tmux: { session: seat } }], boardDir: join(root, 'board'), seatsDir, isAlive: () => true, isPidAlive: () => true,
exec: (f, args) => { captures.push(args); return { status: 0, stdout: '', stderr: '' }; } });
const web = await startServer({ port: 0, board });
const web = await startServer({ port: 0, board: `http://127.0.0.1:${board.address().port}` });
return { root, projectRoot, sessionsDir, board, web, base: `http://127.0.0.1:${web.address().port}`, captures,
async close() { await close(web); rmSync(root, { recursive: true, force: true }); },
async close() { await close(web); await close(board); rmSync(root, { recursive: true, force: true }); },
};
}
@@ -8,7 +8,6 @@ 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';
@@ -17,7 +16,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, stopRoute;
let b;
try {
const file = join(f.sessionsDir, '2026-09-26T10-00-00_s1.jsonl');
const log = session(file, f.projectRoot);
@@ -25,7 +24,6 @@ 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.
@@ -97,5 +95,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 (stopRoute) await stopRoute(); if (b) await b.close(); await f.close(); }
} finally { 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:null,bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})}}),b=await browser();
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})}}),b=await browser();
try{
// 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);};`});
+1 -1
View File
@@ -14,7 +14,7 @@ test('C2-04 CLI: both listener callback pairs dispatch library verbs with their
// The loader supplies a fixture bus and captures callbacks instead of
// opening an HTTP listener. Production imports and routes are unchanged.
const serveSource = "export const DEFAULT_BOARD='http://127.0.0.1:9';export const previewOptions=()=>null;export const proofOptions=()=>null;export async function startServer(o){if(typeof o.libraryFile!=='function'||typeof o.libraryStatus!=='function')throw Error('missing library callbacks');await o.libraryFile({project:'books',path:'Book One/A.md'});await o.libraryStatus({project:'books'});return {address:()=>({port:0}),close:()=>{}};}";
const busSource = `import {appendFileSync} from 'node:fs';export class BusReadError extends Error{};export const busCaller=o=>async(verb,args)=>{appendFileSync(${JSON.stringify(log)},JSON.stringify({verb,args,cli:o.cli??${JSON.stringify(HUMAN_CLI)}})+'\\n');return {};};export const busBusiness=()=>{throw Error('busBusiness outside --sso')};export const userCaller=()=>{throw Error('userCaller outside --sso')};export const consoleCall=()=>{throw Error('consoleCall outside --sso')};`;
const busSource = `import {appendFileSync} from 'node:fs';export class BusReadError extends Error{};export const busCaller=o=>async(verb,args)=>{appendFileSync(${JSON.stringify(log)},JSON.stringify({verb,args,cli:o.cli??${JSON.stringify(HUMAN_CLI)}})+'\\n');return {};};export const busBusiness=()=>{throw Error('busBusiness outside --sso')};export const userCaller=()=>{throw Error('userCaller outside --sso')};`;
writeFileSync(hook, `import {registerHooks} from 'node:module';
registerHooks({load(url,context,next){
if(url.endsWith('/webui/src/serve.mjs'))return {format:'module',shortCircuit:true,source:${JSON.stringify(serveSource)}};
+2 -2
View File
@@ -21,7 +21,7 @@ const TOKENS=[
test('REQ-LIMIT-3 REQ-LIMIT-4 Settings shows token limits counted, not enforced and check failed; Reset posts one path; a save keeps the token limits',{timeout:90000},async()=>{
let answer={revision:launch.revision,limits:launch.limits,tokens:TOKENS};
const bus={messages:async()=>({messages:[],unread:0}),usage:async()=>{throw Error('no usage');},limits:async()=>answer};
const web=await startServer({port:0,board:null,bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{},launch})}}),b=await browser();
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();
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:null,bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{},launch:withAssistant})}}),b=await browser();
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();
try{
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`window.posts=[];window.fixture=${JSON.stringify(withAssistant)};const original=window.fetch;window.fetch=async(url,options={})=>{if(url==='/api/config')return new Response(JSON.stringify({send:true}));if(url==='/api/settings'&&options.method==='POST'){posts.push(JSON.parse(options.body));return new Response(JSON.stringify({...fixture,revision:'bc'[posts.length-1].repeat(64)}));}return original(url,options);};`});
const q=sel=>`document.querySelector(${JSON.stringify(sel)})`;
+3 -3
View File
@@ -15,7 +15,7 @@ function seed(t){
}
test('messages HTTP read pages and preserves metadata; bad arguments refuse before a read; other reads unchanged',async t=>{
const s=seed(t),a=s.send('first'),b=s.send('second');
const web=await startServer({port:0,board:null,bus:s.bus});t.after(()=>close(web));
const web=await startServer({port:0,board:'http://127.0.0.1:9',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:null,bus:s.bus});
const web=await startServer({port:0,board:'http://127.0.0.1:9',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:null,bus,reads});
const web=await startServer({port:0,board:'http://127.0.0.1:9',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')`;
+1 -5
View File
@@ -225,7 +225,7 @@ test('a copy that meets an empty target directory replaces it by rename, not by
// serve from a copy: /healthz, the config check, roles and RELEASE from the
// copy, and the queue read from --checkout or, without it, from the copy.
async function serve(t, cli, args, env) {
const child = spawn(process.execPath, [cli, 'serve', '--port', '0', ...args], { env, stdio: ['ignore', 'pipe', 'pipe'] });
const child = spawn(process.execPath, [cli, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', ...args], { env, stdio: ['ignore', 'pipe', 'pipe'] });
t.after(() => child.kill());
let out = '', err = '';
child.stderr.on('data', (c) => { err += c; });
@@ -252,10 +252,6 @@ 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');
Loaded 100 of 129 files, more files were not shown because too many files have changed in this diff. Show more