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]>
347 lines
20 KiB
Markdown
347 lines
20 KiB
Markdown
# 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.
|