review(dewey): row 76 round 1 packet, Console sends a human message to a role, on 3d298920 (#1570)

Built on bbd22f1b, 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:
2026-10-11 20:10:35 -05:00
co-authored by Claude Opus 5.5
parent 146043eb53
commit 1deb614344
7 changed files with 1686 additions and 0 deletions
+346
View File
@@ -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
+104
View File
@@ -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 });
+38
View File
@@ -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
+81
View File
@@ -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')
+24
View File
@@ -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