Built onbbd22f1b, rebased on row 60 (3d298920): the bus.mjs header comment conflict merged, serve.mjs BUS_VERBS and the README credentials paragraphs merged cleanly. 21 of 21 mutants killed, full gate green. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,346 @@
|
||||
# Row 76 (#1570): Console sends a human message to a role, round 1
|
||||
|
||||
Owner: Dewey. Reviewers: Darkwing and Filbert. Brief:
|
||||
`docs/plans/2026-10-11_dogfood-cutover.md` § "Console sends a message to a
|
||||
role", blob `f5d56554` (rev 552). Jason's Q95 answer is lead decision 95;
|
||||
the reply view question is lead decision 97, option (a). Base `3d298920`
|
||||
(row 60 landed). None of the candidate's paths changed between `3d298920`
|
||||
and `refactor` HEAD (`3d298920`) when the packet was built.
|
||||
|
||||
`build.patch` is `git diff --binary 3d298920 -- packages/webui`, the two
|
||||
new test files included. Apply it with `git apply --index` to a detached
|
||||
worktree of the base; `candidate-manifest.sha256` then checks 9/9 OK.
|
||||
|
||||
## What ships
|
||||
|
||||
`serve --public-host NAME --auth-header HEADER --allow-user ID --send`.
|
||||
`--send` is off by default; without it the Console behaves as after row 74
|
||||
(`preview.test.mjs` and `serve.test.mjs` unchanged and green).
|
||||
|
||||
**Start (`cli.mjs`, `serve.mjs`).** `--send` is the one flag without a
|
||||
value. Without all three preview flags the CLI exits 2 with `refused:
|
||||
--send needs the preview mode (...)`, and `startServer` throws the same
|
||||
for a direct caller, or for a `send` that isn't a function. With `--send`
|
||||
and no system config the start refuses (`--send needs the bus configured
|
||||
(...)`, the reason redacted as before) instead of serving a box that can't
|
||||
send; a read-only Console still serves without the config. The `Preview:`
|
||||
line says reads and human messages to roles, one line per send below.
|
||||
`busReader` is split into `busCaller` (the human transport call, the
|
||||
business resolved per request as before) and `views` over it, so the
|
||||
reads and the send use the same transport.
|
||||
|
||||
**The route.** With `--send`, `POST /api/bus/message` is the one write
|
||||
preview mode lets past its read-only check; any other method on that path
|
||||
is 405, and every other write is still 403 `read-only`. Order, each on
|
||||
every request:
|
||||
|
||||
1. The shared checks from row 74 run first: repeated Host 400, HEADER with
|
||||
a Host other than NAME 403 `proxy Host refused`, a NAME request without
|
||||
HEADER = ID once 401, a sent Origin other than `https://NAME` 403. These
|
||||
write no log line.
|
||||
2. `busSend` checks HEADER = ID once again itself (401), so the route
|
||||
never runs on a loopback Host: a loopback request without HEADER is
|
||||
401, with it 403 `proxy Host refused`. There is no loopback send.
|
||||
3. Origin sent once and exactly `https://NAME` (a missing Origin passes
|
||||
the shared check and stops here), else 403 `cross-origin request
|
||||
refused`.
|
||||
4. `Sec-Fetch-Site` sent once and exactly `same-origin`, else 403
|
||||
`cross-site request refused`.
|
||||
5. `Content-Type: application/json`, at most 16384 bytes, a JSON object
|
||||
with `to` and `body`, optionally `in_reply_to`, no other key; else 400
|
||||
`invalid-request`.
|
||||
6. `message.send` with exactly those keys. The broker judges `to` and the
|
||||
body. Its limit, 32768 characters, is past the request limit, so 16 KiB
|
||||
binds first.
|
||||
|
||||
Every answer after step 2 carries `sent`: `false` for a refusal, `"unknown"`
|
||||
when the message may exist. Success is 200 `{id, at}`.
|
||||
|
||||
**Outcomes (`bus.mjs` `sendOutcome`).** The broker writes in one
|
||||
transaction, so any code it returns means nothing was written:
|
||||
`{error: code, sent: false}` at the read routes' statuses (`busStatus`),
|
||||
with `unknown-role`, `message-not-found` and `request-too-large` added as
|
||||
400. `outcome-unknown`, `invalid-response` and `response-too-large` from
|
||||
the transport, any error that isn't a `BusReadError`, and an answer
|
||||
without a valid message id leave it open: 503 `outcome-unknown` with
|
||||
`sent: "unknown"`. Nothing retries.
|
||||
|
||||
**Log.** One stdout line per request that gets past step 2: the time,
|
||||
`send`, `user=` (JSON-quoted), `to=` (JSON-quoted, cut to 64 characters, or
|
||||
`none`), then `id=<message id>` or `error=<code>`. Never the body. A test
|
||||
shows a `to` with a newline can't forge a second line.
|
||||
|
||||
**`/api/config`** gains `send`: true only with `--send` on a NAME request,
|
||||
so the operator's loopback page never shows a box that would get 401.
|
||||
|
||||
**The page (`public/bus.js`).** With `send` true the Inbox has "Message a
|
||||
role": a picker of held roles from the agents read, a text box and Send.
|
||||
|
||||
- The draft (role and text) lives in page memory, so the ten-second
|
||||
refresh redraws the form without losing it; focus and the caret are
|
||||
kept. A reload loses it. A role no longer held stays selected, marked
|
||||
"(not held now)".
|
||||
- Send is disabled while a send is pending; a second press sends nothing.
|
||||
An empty role or text is refused in the page without a request.
|
||||
- One POST per Send, `redirect: 'manual'`. Sent: an id came back; the
|
||||
receipt shows it and links `#/trail/message/<id>` (a new trail kind: the
|
||||
message and its deliveries, back link to the Inbox, not-found for an id
|
||||
the trail doesn't hold). The text is cleared unless it was edited during
|
||||
the send.
|
||||
- Not sent: a 4xx, or the server's `sent: false` (a 503 `no-bus-host`
|
||||
too), or a redirect. Only the sign-in proxy redirects, when its session
|
||||
has expired, so the request never reached the Console; the receipt asks
|
||||
for a reload. The draft is kept.
|
||||
- Anything else, a lost answer included: outcome unknown. The receipt says
|
||||
the message may have been sent, that nothing was sent again, and that
|
||||
there is no trail to link without an id; the draft is kept.
|
||||
- The box says a reply comes back as a message the Console doesn't show
|
||||
yet, and the next `mosaic talk` prints it after sending its own message
|
||||
(decision 97).
|
||||
|
||||
**Reply in the board (`public/app.js`).** A refused Reply said "Delivery
|
||||
may be unknown; check the seat before sending again" for a 403. A 4xx now
|
||||
says "Not sent."; no answer, an unreadable one or a 5xx keeps the old
|
||||
text. Row 74 left this for this row.
|
||||
|
||||
**README.** A "Message a role" section: the command, what `--send` grants
|
||||
(anyone the proxy lets through as ID, and any local process that reaches
|
||||
the port and sets HEADER with `Host: NAME`; decisions 62 and 95), the
|
||||
request and each check, the broker's refusals and their statuses, the
|
||||
unknown outcome, the log line, the page's three receipts, and the reply
|
||||
path with `mosaic talk`'s limits (decision 97). The routes list gains the
|
||||
message trail; the "nothing writes" lines name the exception. Folded in
|
||||
from row 74 per the row note: Filbert N1 ("its first signed-in request"),
|
||||
Darkwing's tunnel sentence (anything reaching a tunnel's local end can
|
||||
send a loopback Host without HEADER, so bind it to loopback), and a
|
||||
pointer to `scripts/webui-service.sh`, which takes no `--send`.
|
||||
|
||||
## Decisions and tradeoffs
|
||||
|
||||
1. **No loopback send.** The brief asks for the header on every request,
|
||||
loopback included. A loopback request can't carry it (row 74 refuses
|
||||
HEADER with a loopback Host), so the operator's browser on `127.0.0.1`
|
||||
reads but can't send. `/api/config` hides the box there.
|
||||
2. **The route checks the header itself** even though the shared check
|
||||
already did for NAME requests. It costs one line and keeps the route
|
||||
closed if the shared check changes.
|
||||
3. **A missing Origin is refused on the send** (the shared check allows a
|
||||
missing one for reads). The brief asks for the request's own origin,
|
||||
and browsers send Origin on a POST.
|
||||
4. **`Sec-Fetch-Site` is compared exactly**, so `Same-Origin` and a
|
||||
repeated header are refused. Browsers send the lowercase value once.
|
||||
5. **Which errors mean "not sent".** The brief maps broker codes like the
|
||||
reads'. I treat every coded broker answer as definite, since the broker
|
||||
writes in one transaction, and only the transport's three lost-answer
|
||||
codes, an uncoded error or a missing id as unknown. The page adds 4xx
|
||||
and the redirect as definite. A proxy 4xx means the request wasn't
|
||||
accepted for forwarding; a proxy 5xx (502, 504) can come after the
|
||||
Console got it, so it stays unknown.
|
||||
6. **The draft is in page memory, not storage.** The brief asks for it to
|
||||
survive the periodic refresh. Keeping it out of `localStorage` keeps a
|
||||
message body off disk on a shared browser profile.
|
||||
7. **`--send` without the bus refuses to start.** The brief doesn't say.
|
||||
Serving a box that can only answer `not-configured` seemed worse than
|
||||
a refusal at start.
|
||||
|
||||
## Where the brief and the code differ (for Sage)
|
||||
|
||||
- "Replies show in the inbox as today": they don't; the inbox shows
|
||||
decisions. Decision 97 settled it: the README and the box give `mosaic
|
||||
talk`'s limits, and the reply view is row 81 (#1575).
|
||||
- "Points at the trail" on an unknown outcome: there's no id to link, so
|
||||
the receipt points at the role's side and the bus trail from a terminal.
|
||||
- The log's `error=` value is the code as answered, unquoted; for the 403s
|
||||
it has spaces (`error=cross-origin request refused`). It is always last
|
||||
on the line.
|
||||
|
||||
## Rebase on row 60
|
||||
|
||||
I built this row on `bbd22f1b`. Row 60 landed first (`1771bf98`), so I
|
||||
applied my patch to `3d298920` with `git apply --3way`. Row 60's hunks in
|
||||
this row's files, and how each merged:
|
||||
|
||||
- `packages/webui/src/serve.mjs`: row 60's `BUS_VERBS` line (adds
|
||||
`credentials`). It merged cleanly; my changes there are elsewhere in the
|
||||
file.
|
||||
- `packages/webui/README.md`: row 60's credentials read and its line in
|
||||
the exposure list. Merged cleanly, in different paragraphs from my
|
||||
"Message a role" section and the "nothing writes" lines.
|
||||
- `packages/webui/src/bus.mjs`: the header comment, the one conflict. Row
|
||||
60 named its `credentials` read and I named the one write. The merged
|
||||
comment says both:
|
||||
|
||||
```
|
||||
// Only the four verbs of the Q1 module (lead decision 56), row 57's
|
||||
// `decisions` and row 60's `credentials` reads are reachable as reads: no
|
||||
// read writes, not even seen (Q4). The one write is row 76's human
|
||||
// `message.send` (lead decision 95), built by the CLI only with --send. The
|
||||
// bus does the proof; a server started inside an agent run gets
|
||||
// `human-required` from the broker.
|
||||
```
|
||||
|
||||
Row 60's `tests/bus.test.mjs` change isn't in a file this row touches. The
|
||||
mutants, the suites and the demonstration below ran on the rebased tree.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `packages/webui/src/serve.mjs` | `busSend`, the route under `send`, `body()` limit parameter, `send`/`log` options, `/api/config` `send` |
|
||||
| `packages/webui/src/bus.mjs` | `busCaller` split out of `busReader`; `sendOutcome`; three codes added to the status map |
|
||||
| `packages/webui/src/cli.mjs` | `--send`, refused without the preview flags or the bus; the `Preview:` line |
|
||||
| `packages/webui/src/public/bus.js` | the "Message a role" box, the message trail kind, focus and caret kept across a refresh |
|
||||
| `packages/webui/src/public/app.js` | the Reply 4xx text |
|
||||
| `packages/webui/README.md` | the "Message a role" section and the folded-in row 74 notes |
|
||||
| `packages/webui/tests/send.test.mjs` | new, 13 tests |
|
||||
| `packages/webui/tests/send-browser.test.mjs` | new, 1 browser test |
|
||||
| `packages/webui/tests/shell.test.mjs` | the pinned write boundary: bus.js has exactly one write, this one |
|
||||
|
||||
## Tests
|
||||
|
||||
`send.test.mjs` (13): `startServer` refuses `send` without the preview
|
||||
mode or as a non-function; the CLI refuses `--send` without the flags and
|
||||
without a configured bus (exit 2, nothing on stdout, the config path not
|
||||
named); the CLI with `--send` and a configured bus but no bus host running
|
||||
prints the `Preview:` line, answers a send 503 `no-bus-host` with `sent:
|
||||
false` through the real human transport, and logs one line without the
|
||||
body; without `--send` the route is 403 `read-only` and
|
||||
config says no send; with `--send` every other write (POST, PUT, DELETE,
|
||||
PATCH on the board, bus and page routes) is still 403 `read-only` and
|
||||
nothing reaches the board or bus; config says send only on NAME; 401 for no
|
||||
header, another user, a suffix, two values, loopback without the header
|
||||
and `localhost` with no Origin, and 403 `proxy Host refused` for loopback
|
||||
with it, none sent or logged; each request check (no Origin, no or wrong
|
||||
or repeated `Sec-Fetch-Site`, no or wrong Content-Type, a form, 16385
|
||||
bytes, extra keys, `business`, missing `to` or `body`, not JSON, null, an
|
||||
array, a string) refused with `sent: false`, logged without the body, and
|
||||
16384 bytes accepted; wrong Origins refused by the shared check; GET and
|
||||
PUT 405; exactly `to`, `body` and `in_reply_to` passed on; a crafted `to`
|
||||
can't forge a log line; each bus refusal with its status and `sent:
|
||||
false`, each open outcome 503 `unknown`, a missing id unknown, and one
|
||||
call each (no retry); the human transport request is exactly the business,
|
||||
`message.send` and the keys; against a real broker the message is written
|
||||
once as a human send (`from_role` human, `from_run` human-cli, class INFO)
|
||||
and `unknown-role`, `invalid-request` and `message-not-found` map to 400.
|
||||
|
||||
`send-browser.test.mjs` drives the page in Chrome with an in-process
|
||||
broker for the roles and the trail and a stub for the POST (a send passes
|
||||
only with the proxy's https Origin, which a loopback page can't have): no
|
||||
box without `send`; the held roles; empty role and text refused without a
|
||||
request; the draft across a refresh; a 400 and a 503 `sent: false`
|
||||
refused with the draft kept; a redirect refused as `sign-in`; an unknown
|
||||
outcome, a network failure and a 200 without an id each unknown, with no
|
||||
trail link and no resend; one POST per Send, pinned as
|
||||
`{method, headers, body, redirect: 'manual'}`; Send disabled while
|
||||
pending and a second press ignored; text typed during a send kept; a plain
|
||||
success clears the text and keeps the role; the receipt's trail link opens
|
||||
the message's trail; an unknown message id is not found.
|
||||
|
||||
## Mutants
|
||||
|
||||
`mutants.py` makes 21 single-site edits, one at a time, in a separate
|
||||
worktree of the rebased candidate (never the build tree). Each mutant runs
|
||||
against the whole webui suite, and the file is restored from the copy read
|
||||
before the edit. The baseline passed, the worktree was unchanged afterwards,
|
||||
and **21 of 21 were killed** (2026-10-12T00:40:39Z to 01:02:24Z).
|
||||
`mutants.txt` lists each mutant with the tests that killed it.
|
||||
|
||||
- Route (`serve.mjs`): the route's own header check removed; Origin
|
||||
unchecked; `Sec-Fetch-Site` unchecked, or `same-site` let through; the
|
||||
limit raised to 64 KiB; extra keys allowed; `in_reply_to` dropped; the
|
||||
id check removed; the log's `to` unquoted and uncut; `/api/config`
|
||||
`send` true on loopback; the route open without `--send`; every write
|
||||
opened by `--send` (`send-opens-writes`, killed by the new "every other
|
||||
write is still 403" test).
|
||||
- Outcomes (`bus.mjs`): only `outcome-unknown` left open; `unknown-role`
|
||||
dropped from the status map.
|
||||
- Page (`bus.js`): no pending guard; the text cleared though edited during
|
||||
the send; a 5xx `sent: false` treated as unknown; an unknown outcome
|
||||
resent; a redirect treated as unknown; redirects followed; the message
|
||||
trail shown for any id.
|
||||
|
||||
I ran 20 of these on the `bbd22f1b` build before the rebase. The 18 that
|
||||
finished there were killed too; the run was stopped for the rebase and its
|
||||
log is not in the packet.
|
||||
|
||||
## Demonstration
|
||||
|
||||
`demo.mjs` runs the candidate's `startServer` with `--send`'s options
|
||||
against a real in-process broker (pm, cto and coder; coder claimed),
|
||||
driven over `node:http` on loopback with the headers the proxy and the
|
||||
browser send (`Host: NAME`, `x-authentik-uid: jason`, Origin,
|
||||
`Sec-Fetch-Site`). Three servers: `send`, `lost` (the broker writes, then
|
||||
the call throws `outcome-unknown`, as when the transport loses the
|
||||
answer) and `readonly` (no `--send`). `demo.txt` is its output on the gate
|
||||
worktree:
|
||||
|
||||
```text
|
||||
== a send, and a reply to the coder
|
||||
to coder 200 {"id":"8f66630d-cb38-4879-8119-fb3defd532c8","at":"2026-10-12T00:40:48.392Z"}
|
||||
to coder, in_reply_to the coder's message 200 {"id":"3a35d7b4-2020-464e-9bc3-5ee870c8b53f","at":"2026-10-12T00:40:49.041Z"}
|
||||
== refused before the bus
|
||||
no sign-in header (401, no log line) 401 {"error":"sign-in required"}
|
||||
loopback Host with the header 403 {"error":"proxy Host refused","message":"the proxy must forward Host unchanged"}
|
||||
another Origin (shared check, no log line) 403 {"error":"cross-origin request refused"}
|
||||
no Origin 403 {"error":"cross-origin request refused","message":"a send needs the Console's own Origin","sent":false}
|
||||
Sec-Fetch-Site: same-site 403 {"error":"cross-site request refused","message":"a send needs Sec-Fetch-Site: same-origin","sent":false}
|
||||
Content-Type: text/plain 400 {"error":"invalid-request","message":"Content-Type must be application/json","sent":false}
|
||||
16385 bytes 400 {"error":"invalid-request","message":"body larger than 16384 bytes","sent":false}
|
||||
an extra key 400 {"error":"invalid-request","message":"a send takes to, body and an optional in_reply_to, nothing else","sent":false}
|
||||
GET 405 {"error":"method not allowed"}
|
||||
== refused by the broker
|
||||
a role the business lacks 400 {"error":"unknown-role","message":"not sent: the bus refused it (unknown-role)","sent":false}
|
||||
an empty body 400 {"error":"invalid-request","message":"not sent: the bus refused it (invalid-request)","sent":false}
|
||||
in_reply_to a missing message 400 {"error":"message-not-found","message":"not sent: the bus refused it (message-not-found)","sent":false}
|
||||
== the answer lost after the broker wrote
|
||||
unknown, not retried 503 {"error":"outcome-unknown","message":"the message may have been sent; nothing was retried. Check the trail before sending again","sent":"unknown"}
|
||||
== without --send
|
||||
POST is read-only 403 {"error":"read-only","message":"the Console preview is read-only"}
|
||||
== what the broker holds
|
||||
8f66630d from=human/human-cli to=coder class=INFO in_reply_to=- body="Please look at row 76 before the cutover."
|
||||
3a35d7b4 from=human/human-cli to=coder class=INFO in_reply_to=0536126f-c54a-40a5-999d-c7fe1c01d9a2 body="refactor"
|
||||
pm receives 1 message(s) from the lost-answer send: ["Lost answer test"]
|
||||
== the Console log, one line per send past sign-in
|
||||
2026-10-12T00:40:48.392Z send user="jason" to="coder" id=8f66630d-cb38-4879-8119-fb3defd532c8
|
||||
2026-10-12T00:40:49.041Z send user="jason" to="coder" id=3a35d7b4-2020-464e-9bc3-5ee870c8b53f
|
||||
2026-10-12T00:40:49.057Z send user="jason" to=none error=cross-origin request refused
|
||||
2026-10-12T00:40:49.058Z send user="jason" to=none error=cross-site request refused
|
||||
2026-10-12T00:40:49.061Z send user="jason" to=none error=invalid-request
|
||||
2026-10-12T00:40:49.066Z send user="jason" to=none error=invalid-request
|
||||
2026-10-12T00:40:49.072Z send user="jason" to=none error=invalid-request
|
||||
2026-10-12T00:40:49.281Z send user="jason" to="auditor" error=unknown-role
|
||||
2026-10-12T00:40:49.535Z send user="jason" to="coder" error=invalid-request
|
||||
2026-10-12T00:40:49.802Z send user="jason" to="coder" error=message-not-found
|
||||
2026-10-12T00:40:50.173Z send user="jason" to="pm" error=outcome-unknown
|
||||
a body in the log: false
|
||||
```
|
||||
|
||||
## Gate (my run on the candidate)
|
||||
|
||||
Detached worktree of `3d298920`, `build.patch` applied with `git apply
|
||||
--index`, the manifest 9/9 OK before and after. Suites one after another,
|
||||
`TMPDIR` in my scratch, Docker on, 2026-10-12T01:02:24Z to 01:09:40Z:
|
||||
|
||||
| Suite | Result |
|
||||
|---|---|
|
||||
| `packages/webui` | 76 pass, 0 fail, 0 skipped, the browser tests included (14 new) |
|
||||
| `packages/bus` | 88 pass, 0 fail |
|
||||
| `packages/cli` | 114 pass, 0 fail |
|
||||
| `packages/control-board` | 124 pass, 0 fail |
|
||||
| business, conversation, discord, harness, ledger, mosaic, queue, runs, seat, tasks | all 0 fail (60, 182, 178, 59, 78, 69, 150, 41, 27, 71 pass) |
|
||||
| build-tokens `--check` | exit 0 |
|
||||
| test-auth, test-conductor, test-config, test-discord, test-extension-package, test-foundation | exit 0 (15, 17, 24, 66, 18, 44 passed) |
|
||||
| test-queue | 27 passed, 0 failed; its `verify`/`render --check` case skips outside the canonical checkout |
|
||||
| test-release | 14 passed, 0 failed (Docker, nothing skipped) |
|
||||
| test-task | 98 passed, 0 failed (Docker, nothing skipped) |
|
||||
|
||||
`git status` afterwards shows only the patch's 9 staged files and the
|
||||
`node_modules` link. `core.hooksPath` stayed unset.
|
||||
|
||||
## Questions for reviewers
|
||||
|
||||
1. Decision 5: is a proxy 4xx safe to show as "not sent"? I think so for
|
||||
the proxies we'd use, but a reviewer who knows one that answers 4xx
|
||||
after forwarding should say.
|
||||
2. Decision 7: refusing `--send` at start without the bus config, rather
|
||||
than serving reads and answering `not-configured` on each send.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,9 @@
|
||||
c6cf1714407803d21a5757f39333a396c76abf582da0df51aeee4294e00cff06 packages/webui/README.md
|
||||
a0661abb7de79ce2854884c99f7d8db114d8028d99048e7387a9b2f8534cd422 packages/webui/src/bus.mjs
|
||||
c8f9845c66793b0516d5f356f6be9f8ae2d46c40fdd044945aac2a606d024bee packages/webui/src/cli.mjs
|
||||
a259dd523625a83237ebc98d0361ff6de1732729a661f9e0f37492f99e3b3f60 packages/webui/src/serve.mjs
|
||||
6ec0ff037d91ba52cce3361b079131d255df8484bcc6e924956731d2b2f86de1 packages/webui/src/public/app.js
|
||||
643178391c4e5dc2ac05a9c1d2972dd374bf6f692a5f28717ec0289d74a64a91 packages/webui/src/public/bus.js
|
||||
7dfceeaf485bc9b636fb12c7bc5a0f12ca74d6b43c351feaf2d335f527f31ee9 packages/webui/tests/send.test.mjs
|
||||
fdf0d30c097ab425fe8426f940892b7422a00ab5d6b7d30cefa0c4c5feafaa35 packages/webui/tests/send-browser.test.mjs
|
||||
17cb39097414bd685d68305dd5e402fd645020695f3dee86cd6bb567dd410eff packages/webui/tests/shell.test.mjs
|
||||
@@ -0,0 +1,104 @@
|
||||
// Row 76 (#1570): `serve --send` end to end against a real broker, driven over HTTP on loopback
|
||||
// with the headers the sign-in proxy and the browser would send.
|
||||
// Run: TMPDIR=<scratch> node demo.mjs <worktree with the candidate>.
|
||||
// The broker is in this process, as in send-browser.test.mjs; the human transport between the
|
||||
// Console and a bus host is send.test.mjs's. `send` maps the broker's refusal the way humanCall does.
|
||||
import { request } from 'node:http';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
const tree = resolve(process.argv[2]);
|
||||
const load = p => import(pathToFileURL(join(tree, p)).href);
|
||||
const { Store } = await load('packages/bus/src/store.mjs');
|
||||
const { Broker } = await load('packages/bus/src/broker.mjs');
|
||||
const { views } = await load('packages/bus/src/views.mjs');
|
||||
const { startServer } = await load('packages/webui/src/serve.mjs');
|
||||
const { BusReadError } = await load('packages/webui/src/bus.mjs');
|
||||
|
||||
const NAME = 'mosaic-preview.example.com', HEADER = 'x-authentik-uid', ID = 'jason';
|
||||
const preview = { host: NAME, header: HEADER, user: ID };
|
||||
const role = { authority: { withinRole: ['message.send'], crossRole: [] } };
|
||||
const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' }, roles: { pm: role, cto: role, coder: role } } };
|
||||
const root = mkdtempSync(join(tmpdir(), 'r76-demo-'));
|
||||
const store = new Store(root), b = new Broker({ store, businesses });
|
||||
const coder = b.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'pi', address: 'coder-run' });
|
||||
b.request(coder, { verb: 'role.claim' });
|
||||
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const reader = b.bindReader({ business: 'demo' });
|
||||
const bus = views({ call: async (verb, args = {}) => structuredClone(b.request(reader, { verb, args })) });
|
||||
const toBus = fn => async args => { try { return await fn(args); } catch (e) { throw new BusReadError(e.code ?? 'outcome-unknown'); } };
|
||||
const send = toBus(async args => structuredClone(b.request(human, { verb: 'message.send', args })));
|
||||
// A transport that loses the answer after the broker wrote: the outcome is unknown.
|
||||
const lost = toBus(async args => { b.request(human, { verb: 'message.send', args }); throw Object.assign(new Error('lost'), { code: 'outcome-unknown' }); });
|
||||
const lines = [];
|
||||
const log = l => lines.push(l);
|
||||
const servers = {
|
||||
send: await startServer({ port: 0, board: 'http://127.0.0.1:9', bus, preview, send, log }),
|
||||
lost: await startServer({ port: 0, board: 'http://127.0.0.1:9', bus, preview, send: lost, log }),
|
||||
readonly: await startServer({ port: 0, board: 'http://127.0.0.1:9', bus, preview, log }),
|
||||
};
|
||||
|
||||
const signed = { [HEADER]: ID, origin: `https://${NAME}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' };
|
||||
function call(server, { method = 'POST', headers = signed, body, host = NAME } = {}) {
|
||||
return new Promise((done, fail) => {
|
||||
const length = body === undefined ? {} : { 'content-length': Buffer.byteLength(body) };
|
||||
const req = request({ host: '127.0.0.1', port: servers[server].address().port, method, path: '/api/bus/message', headers: { host, ...length, ...headers } }, res => {
|
||||
let text = '';
|
||||
res.on('data', c => (text += c));
|
||||
res.on('end', () => done(`${res.statusCode} ${text}`));
|
||||
});
|
||||
req.on('error', fail);
|
||||
req.end(body);
|
||||
});
|
||||
}
|
||||
const omit = (k, extra = {}) => ({ ...Object.fromEntries(Object.entries(signed).filter(([n]) => n !== k)), ...extra });
|
||||
const show = async (what, server, options) => console.log(`${what.padEnd(46)} ${await call(server, options)}`);
|
||||
const BODY = 'Please look at row 76 before the cutover.';
|
||||
const sent = [];
|
||||
|
||||
console.log('== a send, and a reply to the coder');
|
||||
const first = await call('send', { body: JSON.stringify({ to: 'coder', body: BODY }) });
|
||||
console.log(`${'to coder'.padEnd(46)} ${first}`);
|
||||
sent.push(JSON.parse(first.slice(5)).id);
|
||||
const asked = b.request(coder, { verb: 'message.send', args: { to: 'human', body: 'Which branch?' } });
|
||||
const reply = await call('send', { body: JSON.stringify({ to: 'coder', body: 'refactor', in_reply_to: asked.id }) });
|
||||
console.log(`${'to coder, in_reply_to the coder\'s message'.padEnd(46)} ${reply}`);
|
||||
sent.push(JSON.parse(reply.slice(5)).id);
|
||||
|
||||
console.log('== refused before the bus');
|
||||
await show('no sign-in header (401, no log line)', 'send', { headers: omit(HEADER), body: JSON.stringify({ to: 'coder', body: BODY }) });
|
||||
await show('loopback Host with the header', 'send', { host: '127.0.0.1', body: JSON.stringify({ to: 'coder', body: BODY }) });
|
||||
await show('another Origin (shared check, no log line)', 'send', { headers: omit('origin', { origin: 'https://evil.example' }), body: JSON.stringify({ to: 'coder', body: BODY }) });
|
||||
await show('no Origin', 'send', { headers: omit('origin'), body: JSON.stringify({ to: 'coder', body: BODY }) });
|
||||
await show('Sec-Fetch-Site: same-site', 'send', { headers: omit('sec-fetch-site', { 'sec-fetch-site': 'same-site' }), body: JSON.stringify({ to: 'coder', body: BODY }) });
|
||||
await show('Content-Type: text/plain', 'send', { headers: omit('content-type', { 'content-type': 'text/plain' }), body: JSON.stringify({ to: 'coder', body: BODY }) });
|
||||
await show('16385 bytes', 'send', { body: JSON.stringify({ to: 'coder', body: 'x'.repeat(16385 - 23) }) });
|
||||
await show('an extra key', 'send', { body: JSON.stringify({ to: 'coder', body: BODY, class: 'BLOCKING' }) });
|
||||
await show('GET', 'send', { method: 'GET', body: undefined });
|
||||
console.log('== refused by the broker');
|
||||
await show('a role the business lacks', 'send', { body: JSON.stringify({ to: 'auditor', body: BODY }) });
|
||||
await show('an empty body', 'send', { body: JSON.stringify({ to: 'coder', body: '' }) });
|
||||
await show('in_reply_to a missing message', 'send', { body: JSON.stringify({ to: 'coder', body: BODY, in_reply_to: 'msg-none' }) });
|
||||
console.log('== the answer lost after the broker wrote');
|
||||
await show('unknown, not retried', 'lost', { body: JSON.stringify({ to: 'pm', body: 'Lost answer test' }) });
|
||||
console.log('== without --send');
|
||||
await show('POST is read-only', 'readonly', { body: JSON.stringify({ to: 'coder', body: BODY }) });
|
||||
|
||||
console.log('== what the broker holds');
|
||||
for (const id of sent) {
|
||||
const [m] = b.request(human, { verb: 'trail', args: { subject: id } }).filter(r => r.table === 'messages' && r.id === id);
|
||||
console.log(`${id.slice(0, 8)} from=${m.from_role}/${m.from_run} to=${m.to_role} class=${m.class} in_reply_to=${m.in_reply_to ?? '-'} body=${JSON.stringify(m.body)}`);
|
||||
}
|
||||
const pm = b.bindLaunch({ business: 'demo', role: 'pm', run: 'pm-run', harness: 'pi', address: 'pm-run' });
|
||||
b.request(pm, { verb: 'role.claim' });
|
||||
const lostRows = b.request(pm, { verb: 'message.receive', args: {} });
|
||||
console.log(`pm receives ${lostRows.length} message(s) from the lost-answer send: ${JSON.stringify(lostRows.map(m => m.body))}`);
|
||||
|
||||
console.log('== the Console log, one line per send past sign-in');
|
||||
for (const l of lines) console.log(l);
|
||||
console.log(`a body in the log: ${lines.some(l => l.includes(BODY) || l.includes('refactor') || l.includes('Lost answer'))}`);
|
||||
for (const s of Object.values(servers)) await new Promise(r => s.close(r));
|
||||
store.close();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
@@ -0,0 +1,38 @@
|
||||
== a send, and a reply to the coder
|
||||
to coder 200 {"id":"8f66630d-cb38-4879-8119-fb3defd532c8","at":"2026-10-12T00:40:48.392Z"}
|
||||
to coder, in_reply_to the coder's message 200 {"id":"3a35d7b4-2020-464e-9bc3-5ee870c8b53f","at":"2026-10-12T00:40:49.041Z"}
|
||||
== refused before the bus
|
||||
no sign-in header (401, no log line) 401 {"error":"sign-in required"}
|
||||
loopback Host with the header 403 {"error":"proxy Host refused","message":"the proxy must forward Host unchanged"}
|
||||
another Origin (shared check, no log line) 403 {"error":"cross-origin request refused"}
|
||||
no Origin 403 {"error":"cross-origin request refused","message":"a send needs the Console's own Origin","sent":false}
|
||||
Sec-Fetch-Site: same-site 403 {"error":"cross-site request refused","message":"a send needs Sec-Fetch-Site: same-origin","sent":false}
|
||||
Content-Type: text/plain 400 {"error":"invalid-request","message":"Content-Type must be application/json","sent":false}
|
||||
16385 bytes 400 {"error":"invalid-request","message":"body larger than 16384 bytes","sent":false}
|
||||
an extra key 400 {"error":"invalid-request","message":"a send takes to, body and an optional in_reply_to, nothing else","sent":false}
|
||||
GET 405 {"error":"method not allowed"}
|
||||
== refused by the broker
|
||||
a role the business lacks 400 {"error":"unknown-role","message":"not sent: the bus refused it (unknown-role)","sent":false}
|
||||
an empty body 400 {"error":"invalid-request","message":"not sent: the bus refused it (invalid-request)","sent":false}
|
||||
in_reply_to a missing message 400 {"error":"message-not-found","message":"not sent: the bus refused it (message-not-found)","sent":false}
|
||||
== the answer lost after the broker wrote
|
||||
unknown, not retried 503 {"error":"outcome-unknown","message":"the message may have been sent; nothing was retried. Check the trail before sending again","sent":"unknown"}
|
||||
== without --send
|
||||
POST is read-only 403 {"error":"read-only","message":"the Console preview is read-only"}
|
||||
== what the broker holds
|
||||
8f66630d from=human/human-cli to=coder class=INFO in_reply_to=- body="Please look at row 76 before the cutover."
|
||||
3a35d7b4 from=human/human-cli to=coder class=INFO in_reply_to=0536126f-c54a-40a5-999d-c7fe1c01d9a2 body="refactor"
|
||||
pm receives 1 message(s) from the lost-answer send: ["Lost answer test"]
|
||||
== the Console log, one line per send past sign-in
|
||||
2026-10-12T00:40:48.392Z send user="jason" to="coder" id=8f66630d-cb38-4879-8119-fb3defd532c8
|
||||
2026-10-12T00:40:49.041Z send user="jason" to="coder" id=3a35d7b4-2020-464e-9bc3-5ee870c8b53f
|
||||
2026-10-12T00:40:49.057Z send user="jason" to=none error=cross-origin request refused
|
||||
2026-10-12T00:40:49.058Z send user="jason" to=none error=cross-site request refused
|
||||
2026-10-12T00:40:49.061Z send user="jason" to=none error=invalid-request
|
||||
2026-10-12T00:40:49.066Z send user="jason" to=none error=invalid-request
|
||||
2026-10-12T00:40:49.072Z send user="jason" to=none error=invalid-request
|
||||
2026-10-12T00:40:49.281Z send user="jason" to="auditor" error=unknown-role
|
||||
2026-10-12T00:40:49.535Z send user="jason" to="coder" error=invalid-request
|
||||
2026-10-12T00:40:49.802Z send user="jason" to="coder" error=message-not-found
|
||||
2026-10-12T00:40:50.173Z send user="jason" to="pm" error=outcome-unknown
|
||||
a body in the log: false
|
||||
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env python3
|
||||
# Dewey row 76 mutants: single-site edits in a mutant worktree (never the build tree), each run
|
||||
# against the whole webui suite. The candidate is uncommitted there, so each file is restored
|
||||
# from the copy read before the edit, not from git.
|
||||
# Usage: mutants.py [mutant ...]. A site that doesn't match exactly once is BAD-SITE.
|
||||
import os, subprocess, sys
|
||||
|
||||
R = os.path.expanduser('~/dewey-scratch/r76')
|
||||
T = R + '/mut'
|
||||
OUT = R + '/out/mut'
|
||||
SERVE = 'packages/webui/src/serve.mjs'
|
||||
BUS = 'packages/webui/src/bus.mjs'
|
||||
PAGE = 'packages/webui/src/public/bus.js'
|
||||
TESTS = ['node', '--test', 'packages/webui/tests/*.test.mjs']
|
||||
|
||||
MUTANTS = [
|
||||
# name, file, edits [(old, new)]
|
||||
('send-header-unchecked', SERVE, [(" if (one(req, preview.header) !== preview.user) return json(res, 401, { error: 'sign-in required' });\n", "")]),
|
||||
('send-origin-unchecked', SERVE, [(" if (one(req, 'origin') !== `https://${preview.host}`) return refuse(", " if (false) return refuse(")]),
|
||||
('send-sfs-unchecked', SERVE, [(" if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(", " if (false) return refuse(")]),
|
||||
('send-sfs-same-site', SERVE, [(" if (one(req, 'sec-fetch-site') !== 'same-origin') return", " if (!['same-origin', 'same-site'].includes(one(req, 'sec-fetch-site'))) return")]),
|
||||
('send-limit-64k', SERVE, [("await body(req, 16384)", "await body(req, 65536)")]),
|
||||
('send-extra-keys', SERVE, [(" || Object.keys(value).some(k => !SEND_KEYS.includes(k))", "")]),
|
||||
('send-drops-reply-to', SERVE, [(" if ('in_reply_to' in value) args.in_reply_to = value.in_reply_to;\n", "")]),
|
||||
('send-no-id-check', SERVE, [(" if (!subjectOk(result?.id)) return refuse(", " if (false) return refuse(")]),
|
||||
('send-log-unbounded', SERVE, [("JSON.stringify(to.slice(0, 64))", "to")]),
|
||||
('config-send-loopback', SERVE, [("send: Boolean(send) && named", "send: Boolean(send)")]),
|
||||
('route-open-without-send', SERVE, [(" if (send && path === '/api/bus/message') {", " if (path === '/api/bus/message') {")]),
|
||||
('send-opens-writes', SERVE, [(" if (preview !== null && req.method !== 'GET' && req.method !== 'HEAD') return json(res, 403, { error: 'read-only'", " if (preview !== null && !send && req.method !== 'GET' && req.method !== 'HEAD') return json(res, 403, { error: 'read-only'")]),
|
||||
('outcome-all-definite', BUS, [("const OPEN = ['outcome-unknown', 'invalid-response', 'response-too-large'];", "const OPEN = ['outcome-unknown'];")]),
|
||||
('status-unknown-role-502', BUS, [("'unknown-role': 400, ", "")]),
|
||||
('page-no-pending-guard', PAGE, [(" if (sending) return;\n", "")]),
|
||||
('page-clears-edited-text', PAGE, [(" if (draft.body === text) draft.body = '';", " draft.body = '';")]),
|
||||
('page-5xx-refusal-unknown', PAGE, [(" || body?.sent === false)", ")")]),
|
||||
('page-retries-unknown', PAGE, [(" } else receipt = { kind: 'unknown', to };", " } else { receipt = { kind: 'unknown', to }; await fetch('/api/bus/message', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ to, body: text }), cache: 'no-store', redirect: 'manual' }).catch(() => {}); }")]),
|
||||
('page-redirect-unknown', PAGE, [(" if (res?.type === 'opaqueredirect') {", " if (false) {")]),
|
||||
('page-follows-redirect', PAGE, [(", cache: 'no-store', redirect: 'manual' });", ", cache: 'no-store' });")]),
|
||||
('page-trail-any-id', PAGE, [(" if (!of(tr, 'messages').some(m => m.id === ref)) return notFound(", " if (false) return notFound(")]),
|
||||
]
|
||||
|
||||
def run(cmd):
|
||||
try:
|
||||
p = subprocess.run(cmd, cwd=T, capture_output=True, text=True, env={**os.environ, 'TMPDIR': R + '/tmp'}, timeout=600)
|
||||
except subprocess.TimeoutExpired:
|
||||
return 'TIMEOUT', 'timed out after 600 s'
|
||||
return p.returncode, p.stdout + p.stderr
|
||||
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
os.makedirs(R + '/tmp', exist_ok=True)
|
||||
before = run(['git', 'diff'])[1]
|
||||
rc, out = run(TESTS)
|
||||
open(f'{OUT}/baseline.txt', 'w').write(out)
|
||||
print('baseline', 'pass' if rc == 0 else f'FAIL rc={rc}', flush=True)
|
||||
want = sys.argv[1:]
|
||||
results = []
|
||||
for name, f, edits in MUTANTS:
|
||||
if want and name not in want:
|
||||
continue
|
||||
path = os.path.join(T, f)
|
||||
original = open(path).read()
|
||||
bad = [old for old, _ in edits if original.count(old) != 1]
|
||||
if bad:
|
||||
results.append(f'{name} BAD-SITE ({[original.count(o) for o in bad]} matches)')
|
||||
print(results[-1], flush=True)
|
||||
continue
|
||||
src = original
|
||||
for old, new in edits:
|
||||
src = src.replace(old, new)
|
||||
open(path, 'w').write(src)
|
||||
try:
|
||||
rc, out = run(TESTS)
|
||||
finally:
|
||||
open(path, 'w').write(original)
|
||||
open(f'{OUT}/mut-{name}.txt', 'w').write(out)
|
||||
failed = [l[2:].split(' (')[0] for l in out.splitlines() if l.startswith('✖ ') and 'failing tests' not in l]
|
||||
verdict = 'TIMEOUT' if rc == 'TIMEOUT' else 'killed' if rc else 'SURVIVED'
|
||||
results.append(f"{name} {verdict}" + (f": {sorted(set(failed))}" if failed else ''))
|
||||
print(results[-1], flush=True)
|
||||
print('worktree unchanged after:', run(['git', 'diff'])[1] == before)
|
||||
ran = [r for r in results if 'BAD-SITE' not in r]
|
||||
print(f'summary {sum(1 for r in ran if " killed" in r)} of {len(ran)} killed')
|
||||
@@ -0,0 +1,24 @@
|
||||
baseline pass
|
||||
send-header-unchecked killed: ['a send without the signed-in user is 401 and is neither sent nor logged, loopback included']
|
||||
send-origin-unchecked killed: ['each request check refuses before anything is sent, and logs the refusal without the body']
|
||||
send-sfs-unchecked killed: ['each request check refuses before anything is sent, and logs the refusal without the body']
|
||||
send-sfs-same-site killed: ['each request check refuses before anything is sent, and logs the refusal without the body']
|
||||
send-limit-64k killed: ['each request check refuses before anything is sent, and logs the refusal without the body']
|
||||
send-extra-keys killed: ['each request check refuses before anything is sent, and logs the refusal without the body']
|
||||
send-drops-reply-to killed: ['a send passes exactly to, body and in_reply_to, answers the id, and logs it', 'against a real broker: the message is written once as a human send, and its refusals map']
|
||||
send-no-id-check killed: ['a bus refusal is not sent, with its status; an outcome the bus could not confirm is unknown and never retried']
|
||||
send-log-unbounded killed: ['a crafted role name cannot forge a log line', 'a send passes exactly to, body and in_reply_to, answers the id, and logs it', 'the CLI with --send and a configured bus sends through the human transport and logs one line']
|
||||
config-send-loopback killed: ['config says send only on the public name with --send on']
|
||||
route-open-without-send killed: ['without --send the route is read-only like every other write, and config says no send']
|
||||
send-opens-writes killed: ['with --send every other write is still 403 read-only, and nothing reaches the board or bus']
|
||||
outcome-all-definite killed: ['a bus refusal is not sent, with its status; an outcome the bus could not confirm is unknown and never retried', 'through the human transport the request is exactly the business, message.send and the keys']
|
||||
status-unknown-role-502 killed: ['a bus refusal is not sent, with its status; an outcome the bus could not confirm is unknown and never retried', 'against a real broker: the message is written once as a human send, and its refusals map', 'through the human transport the request is exactly the business, message.send and the keys']
|
||||
page-no-pending-guard killed: ['the send box: held roles, the draft across a refresh, each receipt, one POST per Send, and the trail link']
|
||||
page-clears-edited-text killed: ['the send box: held roles, the draft across a refresh, each receipt, one POST per Send, and the trail link']
|
||||
page-5xx-refusal-unknown killed: ['the send box: held roles, the draft across a refresh, each receipt, one POST per Send, and the trail link']
|
||||
page-retries-unknown killed: ['shipped strings are unchanged, and the web keeps its write boundary', 'the send box: held roles, the draft across a refresh, each receipt, one POST per Send, and the trail link']
|
||||
page-redirect-unknown killed: ['the send box: held roles, the draft across a refresh, each receipt, one POST per Send, and the trail link']
|
||||
page-follows-redirect killed: ['shipped strings are unchanged, and the web keeps its write boundary', 'the send box: held roles, the draft across a refresh, each receipt, one POST per Send, and the trail link']
|
||||
page-trail-any-id killed: ['the send box: held roles, the draft across a refresh, each receipt, one POST per Send, and the trail link']
|
||||
worktree unchanged after: True
|
||||
summary 21 of 21 killed
|
||||
Reference in New Issue
Block a user