270 lines
22 KiB
Markdown
270 lines
22 KiB
Markdown
# 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.
|