diff --git a/agents/dewey/work/queue-76/build.md b/agents/dewey/work/queue-76/build.md new file mode 100644 index 000000000..c15c5a851 --- /dev/null +++ b/agents/dewey/work/queue-76/build.md @@ -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=` or `error=`. 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/` (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. diff --git a/agents/dewey/work/queue-76/build.patch b/agents/dewey/work/queue-76/build.patch new file mode 100644 index 000000000..caae59bdd --- /dev/null +++ b/agents/dewey/work/queue-76/build.patch @@ -0,0 +1,1084 @@ +diff --git a/packages/webui/README.md b/packages/webui/README.md +index e2ffcb3b..6242cd34 100644 +--- a/packages/webui/README.md ++++ b/packages/webui/README.md +@@ -23,7 +23,7 @@ Only HTTP loopback board origins are accepted. The WebUI binds to IPv4 loopback + by default, rejects a repeated Host (400), nonlocal Host and cross-origin + requests, sends no CORS + headers, refuses redirects and accepts only JSON object POST bodies up to 4096 +-bytes. Do not put the board behind a public proxy. Put the Console behind one ++bytes (16 KiB for row 76's send, below). Do not put the board behind a public proxy. Put the Console behind one + only in the preview mode below, with a proxy that meets its contract. + + ## Preview behind a sign-in proxy (row 74, #1568) +@@ -53,14 +53,16 @@ In preview mode: + operator's browser on the host still works. Any other Host is 403. + - A request that carries HEADER with any Host but NAME answers 403 `proxy + Host refused`. The operator's browser never sends HEADER; a proxy that +- rewrote Host does, and it fails on its first request instead of reaching ++ rewrote Host does, and it fails on its first signed-in request instead of reaching + the loopback path unchecked. + - More than one Host header answers 400 before any other check, in this + mode or not. Node keeps the first, so a loopback Host followed by + `Host: NAME` would otherwise skip the sign-in check. + - Every method other than GET and HEAD answers 403 `read-only`, whatever the + Host. `/api/seen` and `/api/reply` never reach the board, so Seen and Reply +- fail in the page with that refusal. ++ fail in the page with that refusal. The one exception is POST ++ `/api/bus/message` when the Console was started with `--send` (next ++ section). + - The GET routes are unchanged: the bus views, the Queue, Business and + Settings reads, the board panel and the assets. If the board is down, its + panel shows the existing error and the bus views still answer. +@@ -80,7 +82,8 @@ controls it: + operator's path and skips the header check, so a proxy that forwarded a + client's `Host: 127.0.0.1:7332` would let anyone through without signing + in. A tunnel whose local end uses the same port number sends such a Host +- and reaches the Console unchecked. ++ and reaches the Console unchecked. Anything that reaches a tunnel's local ++ end can send a loopback Host without HEADER, so bind that end to loopback. + - Every hop on this host that reaches the loopback port is bound by this + contract too, not only the edge. + +@@ -88,6 +91,99 @@ The local exposure doesn't change ("Data and boundaries"): any process on + the host can still connect to the port and send `Host: NAME` with HEADER and + ID, or just a loopback Host, and read what a reader capability reads. The + user id is not a secret; the sign-in at the edge is what keeps others out. ++Under `--send` the first of those can also send (next section). ++ ++To run the Console as a systemd user unit, `scripts/webui-service.sh` ++renders and installs one with these flags (row 75; `docs/TOOLS.md` ++"Console service"). Jason starts it from his own shell (lead decision 94). ++The script takes no `--send`, so a unit it installs is read-only. ++ ++## Message a role (row 76, #1570) ++ ++```sh ++node packages/webui/src/cli.mjs serve --port 7332 \ ++ --public-host NAME --auth-header HEADER --allow-user ID --send ++``` ++ ++`--send` is off by default. It goes only with the three preview flags and ++refuses at start without them. It also needs the system config: without it ++the start refuses with `--send needs the bus configured`, where a read-only ++Console would still serve the board. With `--send` the Inbox has a "Message ++a role" box: a role picker filled from the agents read, a text box and ++Send. The message reaches the role as a message from the human, class INFO, ++through `message.send` on the human transport. It is recorded exactly like ++one sent from the CLI (lead decision 95). Decisions are still answered in a ++terminal. There are no other message classes, no corrections and no seat ++conversations here. ++ ++What `--send` grants: two kinds of client can send a human instruction to ++any role while it runs. ++ ++- Anyone the proxy lets through as ID. ++- Any local process that reaches the port and sets HEADER to ID with ++ `Host: NAME`. The user id is not a secret. This is the same class of ++ exposure as the human CLI (lead decision 62), accepted for the PoC by ++ lead decision 95. ++ ++The request is POST `/api/bus/message` with a JSON object `{to, body, ++in_reply_to?}`. Nothing is sent unless all of these hold, each checked on ++every request: ++ ++- Host is NAME and HEADER is ID, once: otherwise 401 `sign-in required`. ++ There is no loopback path. The operator's browser on `127.0.0.1` reads ++ but can't send, and a loopback Host with HEADER is 403 `proxy Host ++ refused`, as above. ++- Origin is exactly `https://NAME`, else 403 `cross-origin request refused`. ++- `Sec-Fetch-Site` is sent once and is exactly `same-origin`, else 403 ++ `cross-site request refused`. Browsers set it, and page script can't. ++- Content-Type is `application/json` and the body is at most 16384 bytes ++ and a JSON object with `to` and `body`, optionally `in_reply_to`, and no ++ other key. Anything else is 400 `invalid-request`. ++ ++The Console doesn't judge `to` or the body beyond that; the broker does, ++with `message.send`'s own rules. Its body limit, 32768 characters, is past ++the request limit, so the 16 KiB limit binds first. A broker refusal means ++nothing was written. It answers `{ error, message, sent: false }` with the ++read routes' statuses: 400 for `unknown-role`, `invalid-request`, ++`message-not-found` and `request-too-large`, 403 for `human-required` and ++`read-only`, 503 for `not-configured` and `no-bus-host`, 502 otherwise. ++ ++When the transport can't say what happened (a timeout, a child that exits ++without a code, an answer that can't be read or that has no message id), the ++message may have been written. The answer is 503 `outcome-unknown` with ++`sent: "unknown"`. Nothing retries, in the server or in the page. ++ ++Each send that gets past the sign-in check writes one line to the Console's ++stdout: the time, `send`, the user, the `to` value (JSON-quoted, at most 64 ++characters), then `id=` or `error=`. The body is never ++logged. A request refused at sign-in or by the shared Host and Origin ++checks writes no line. ++ ++On the page: ++ ++- The draft, role and text, survives the ten-second refresh, kept in page ++ memory like a Reply draft. Reloading the page loses it. Send is disabled ++ while a send is pending. ++- Sent shows the message id and links its trail, `#/trail/message/`: ++ the message and its deliveries. A reply is not in it. The text is cleared ++ unless it was edited during the send. ++- Not sent shows the broker's code and keeps the draft. A redirect counts ++ as not sent: the Console never redirects this route, so only the sign-in ++ proxy does, when its session has expired. The page asks for a reload to ++ sign in again. ++- Outcome unknown says the message may have been sent, keeps the draft, and ++ has no trail to link, since no id came back. Check the role's side, or ++ the bus trail from a terminal, before sending again. ++ ++The role's reply comes back as a message to the human. The Console doesn't ++show it yet; that view is row 81 (#1575). Jason's next `mosaic talk` in a ++terminal prints it, within talk's limits: ++ ++- talk receives only after it sends its own message; ++- `--wait 0` returns straight after the send and receives nothing; ++- otherwise each receive returns every undelivered message to the human, up ++ to 100 a call. talk prints each one and records it delivered, so the next ++ receive doesn't return it again. + + ## Use + +@@ -133,14 +229,14 @@ delivery may be unknown: inspect the seat before sending again. + + The Console sidebar adds Inbox, Tasks and Agents next to the control board, + which stays at `#/`. Routes: `#/inbox`, `#/inbox/`, `#/tasks`, +-`#/tasks/`, `#/agents`, `#/trail/task/` and +-`#/trail/decision/`, with `?kind=` filters on a trail. The design ++`#/tasks/`, `#/agents`, `#/trail/task/`, ++`#/trail/decision/` and `#/trail/message/` (row 76), with `?kind=` filters on a trail. The design + note is `agents/dewey/work/wui/SLICE1-VIEWS.md`. + + - Every view reads the four verbs of the Q1 module (`packages/bus/src/views.mjs`, + lead decision 56): `inbox`, `tasks`, `agents` and `trail`. The CLI reads the + same functions, so both show the same broker data. +-- Nothing writes. A decision shows `mosaic decide ` for each choice, ++- Nothing writes but the "Message a role" box under `--send` (above). A decision shows `mosaic decide ` for each choice, + with Copy, which only puts the command on the clipboard. It is answered in a + terminal (REQ-DEC-3, Q4). Seen is not set from the browser either. The + command has no `--business`, so `mosaic decide` uses the running bus host's +@@ -282,8 +378,8 @@ capability reads ("Human and reader paths" in `packages/bus/README.md`): + the inbox, tasks, agents, trails, the two decision lists and the credential + status. That includes + a T3 seat using `curl` and a managed S6 session, since S6 doesn't confine +-the network. No route +-writes (Q4), so the exposure is reads only. Slice 1 doesn't change this ++the network. Without `--send` no route ++writes (Q4), so the exposure is reads only; with it, see "Message a role". Slice 1 doesn't change this + (Filbert F1 on #1522). Answers are `{ rows, at }`; failures are `{ error, message }` + with 403 for a refusal, 503 for no bus or no answer, and 502 for an answer + that can't be used. The bus needs the system config +diff --git a/packages/webui/src/bus.mjs b/packages/webui/src/bus.mjs +index ca1bab89..379503f5 100644 +--- a/packages/webui/src/bus.mjs ++++ b/packages/webui/src/bus.mjs +@@ -2,9 +2,11 @@ + // transport (packages/bus/src/human-cli.mjs, lead decision 70) as a child + // with spawn, not spawnSync, so one slow read never stalls the HTTP server. + // Only the four verbs of the Q1 module (lead decision 56), row 57's +-// `decisions` and row 60's `credentials` reads are reachable: the WebUI +-// writes nothing, not even seen (Q4). The bus does the proof; a server +-// started inside an agent run gets `human-required` from the broker. ++// `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. + + import { spawn } from 'node:child_process'; + import { HUMAN_CLI } from '../../cli/src/transport.mjs'; +@@ -55,8 +57,8 @@ export function humanCall({ socket, business, env = process.env, cli = HUMAN_CLI + + // The business is the --business flag, or else the live bus host's, read + // on every request so a host started after the Console is picked up. +-export function busReader({ dataRoot, socket, business = null, env = process.env, cli = HUMAN_CLI, timeoutMs = 30000 }) { +- const call = async (verb, args) => { ++export function busCaller({ dataRoot, socket, business = null, env = process.env, cli = HUMAN_CLI, timeoutMs = 30000 }) { ++ return async (verb, args) => { + let id = business; + if (!id) { + let state; +@@ -66,7 +68,16 @@ export function busReader({ dataRoot, socket, business = null, env = process.env + } + return humanCall({ socket, business: id, env, cli, timeoutMs })(verb, args); + }; +- return views({ call }); ++} ++export const busReader = options => views({ call: busCaller(options) }); ++ ++// A send's error, sorted. The broker writes in one transaction, so any code it ++// returns means nothing was written. A transport that lost the answer, or an ++// answer that can't be read, leaves the outcome open: the message may exist. ++const OPEN = ['outcome-unknown', 'invalid-response', 'response-too-large']; ++export function sendOutcome(err) { ++ const code = err instanceof BusReadError ? err.code : 'outcome-unknown'; ++ return OPEN.includes(code) ? { code: 'outcome-unknown', sent: 'unknown' } : { code, sent: false }; + } + + // HTTP status by bus error code. 403: the bus refused this caller; 503: no +@@ -74,6 +85,6 @@ export function busReader({ dataRoot, socket, business = null, env = process.env + const STATUS = { + 'human-required': 403, unauthenticated: 403, 'unknown-business': 403, 'read-only': 403, + 'outcome-unknown': 503, 'no-bus-host': 503, 'not-configured': 503, +- 'invalid-request': 400, ++ 'invalid-request': 400, 'unknown-role': 400, 'message-not-found': 400, 'request-too-large': 400, + }; + export const busStatus = code => STATUS[code] ?? 502; +diff --git a/packages/webui/src/cli.mjs b/packages/webui/src/cli.mjs +index 5c6a5931..618a2cf1 100644 +--- a/packages/webui/src/cli.mjs ++++ b/packages/webui/src/cli.mjs +@@ -1,18 +1,23 @@ + #!/usr/bin/env node + import { startServer, previewOptions, DEFAULT_BOARD } from './serve.mjs'; +-import { busReader } from './bus.mjs'; ++import { busCaller } from './bus.mjs'; ++import { views } from '../../bus/src/views.mjs'; + import { loadSystem, socketPath, REPO, rolesDir } from '../../cli/src/config.mjs'; + import { configDir } from '../../business/src/business.mjs'; + import { consoleReads, redactor } from './reads.mjs'; + import { ID_PATTERN } from '../../business/src/vocabulary.mjs'; + const args = process.argv.slice(2); +-const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--board http://127.0.0.1:7331] [--business ID] [--public-host NAME --auth-header HEADER --allow-user ID]'; ++const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--board http://127.0.0.1:7331] [--business ID] [--public-host NAME --auth-header HEADER --allow-user ID [--send]]'; + try { + if (args.length === 1 && ['--help', '-h'].includes(args[0])) { console.log(usage); process.exit(0); } + if (args.shift() !== 'serve') throw new Error(usage); + const options = { board: DEFAULT_BOARD, port: 7330 }, preview = {}; ++ let sendOn = false; + while (args.length) { +- const flag = args.shift(), value = args.shift(); ++ const flag = args.shift(); ++ // Row 76 (#1570): the one flag without a value. ++ if (flag === '--send') { sendOn = true; continue; } ++ const value = args.shift(); + if (!value) throw new Error(`missing value for ${flag}`); + if (flag === '--port') { + if (!/^\d+$/.test(value) || Number(value) > 65535) throw new Error('port must be 0..65535'); +@@ -29,21 +34,29 @@ try { + else throw new Error(`unknown option: ${flag}`); + } + options.preview = previewOptions(preview); ++ if (sendOn && !options.preview) throw new Error('--send needs the preview mode (--public-host, --auth-header and --allow-user)'); + // The bus views (slice 1 S5) need the system config. Without it the + // board still serves and the bus routes answer not-configured. + // The Queue, Business and Settings reads (row 54) need no bus; without + // the system config, Business can't resolve authority and Settings has + // no notifier. A refusal printed here names no path (redactor). +- let bus = null, busLine, system = null; ++ // --send without a bus refuses to start rather than serve a composer that can't send. ++ let bus = null, send = null, busLine, system = null; + try { + system = loadSystem(); + const { dataRoot } = system; +- bus = busReader({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null }); ++ const call = busCaller({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null }); ++ bus = views({ call }); ++ if (sendOn) send = args => call('message.send', args); + busLine = `Bus: ${options.business ?? 'the running bus host\'s business'}`; +- } catch (err) { busLine = `Bus: not configured (${redactor({ configDir: configDir() })(err.message)})`; } ++ } catch (err) { ++ const why = redactor({ configDir: configDir() })(err.message); ++ if (sendOn) throw new Error(`--send needs the bus configured (${why})`); ++ busLine = `Bus: not configured (${why})`; ++ } + const reads = consoleReads({ root: REPO, system, configDir: configDir(), rolesDir: rolesDir(), business: options.business ?? null }); +- const server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview }); +- const previewLine = options.preview ? `\nPreview: https://${options.preview.host}/ through the proxy, signed-in user from ${options.preview.header}, read-only` : ''; ++ const server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, send }); ++ const previewLine = options.preview ? `\nPreview: https://${options.preview.host}/ through the proxy, signed-in user from ${options.preview.header}, ${send ? 'reads and human messages to roles (--send); one line per send below' : 'read-only'}` : ''; + console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${options.board}\n${busLine}${previewLine}\nCtrl-C stops this server.`); + for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => server.close()); + } catch (err) { +diff --git a/packages/webui/src/public/app.js b/packages/webui/src/public/app.js +index d321b483..2f04be1b 100644 +--- a/packages/webui/src/public/app.js ++++ b/packages/webui/src/public/app.js +@@ -372,7 +372,12 @@ + // Do not discard text typed while the request was in flight. + if (result.delivered && drafts.get(id) === text) { drafts.delete(id); if (inView && box.value === text) box.value = ''; } + if (result.delivered && !paused) await refresh(); +- } catch (err) { receipts.set(id, { error: err.message + ' Delivery may be unknown; check the seat before sending again.' }); } ++ } catch (err) { ++ // A 4xx is a refusal before anything ran (the Console's, or the board's: see replyToRow), so ++ // nothing was sent. No answer, an unreadable one or a 5xx leaves delivery unknown. ++ const refused = err.status >= 400 && err.status < 500; ++ receipts.set(id, { error: err.message + (refused ? '. Not sent.' : '. Delivery may be unknown; check the seat before sending again.') }); ++ } + finally { sending.delete(id); render(); announce(receipts.get(id)?.delivered ? 'Reply delivered' : 'Reply failed; draft kept'); } + }); + document.addEventListener('keydown', e => { +diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js +index 3be22af5..29764898 100644 +--- a/packages/webui/src/public/bus.js ++++ b/packages/webui/src/public/bus.js +@@ -1,7 +1,8 @@ + // Slice 1 S5 (#1522): inbox, tasks, agents and trail, read through /api/bus (src/bus.mjs), + // which reaches only the four reads of the Q1 module (lead decision 56). Nothing on these +-// pages writes: decisions are answered with `mosaic decide` in a terminal, and Copy only +-// puts that command on the clipboard (Q4). What no Q1 read returns is labelled where it ++// pages writes but row 76's message to a role, shown only under `serve --send` behind the ++// proxy (lead decision 95): decisions are answered with `mosaic decide` in a terminal, and ++// Copy only puts that command on the clipboard (Q4). What no Q1 read returns is labelled where it + // would show (lead decision 63). The control board (app.js) is untouched and stays at #/. + (() => { + 'use strict'; +@@ -229,7 +230,7 @@ + function sections(route) { + const items = [['/', 'Board', '', 'board'], ['/inbox', 'Inbox', inboxCount ? `${inboxCount.open}${inboxCount.blocking ? ` ${blockChip(`${inboxCount.blocking} blocking`)}` : ''}` : '', 'inbox'], ['/tasks', 'Tasks', '', 'tasks'], ['/agents', 'Agents', '', 'agents'], ['/queue', 'Queue', '', 'queue'], ['/business', 'Business', '', 'business'], ['/settings', 'Settings', '', 'settings']]; + $('sections').innerHTML = items.map(([h, l, extra, icon]) => { +- const cur = h === '/' ? route === '/' : route.startsWith(h) || (h === '/tasks' && route.startsWith('/trail/task')) || (h === '/inbox' && route.startsWith('/trail/decision')); ++ const cur = h === '/' ? route === '/' : route.startsWith(h) || (h === '/tasks' && route.startsWith('/trail/task')) || (h === '/inbox' && (route.startsWith('/trail/decision') || route.startsWith('/trail/message'))); + return `
  • ${l}${extra ? ` ${extra}` : ''}
  • `; + }).join(''); + } +@@ -242,12 +243,48 @@ +

    ${approves(d.authorization)}

    +

    Raised by ${txt(d.raised_by_role)} ${when(d.at)}${d.task_ref ? ` · task ${taskLink(d.task_ref)}` : ''}${d.requirement_ref ? ` · ${txt(d.requirement_ref)}` : ''}

    `; + } ++ // Row 76 (#1570, lead decision 95): a human message to a held role, only when the server says ++ // a send can pass here (`send` in /api/config). The draft lives in `draft`, not the DOM, so the ++ // 10 s refresh redraws the form without losing it. Nothing resends: an unknown outcome stays ++ // unknown and the draft stays put. ++ let canSend = false, sendRoles = null, sending = false, receipt = null; ++ const draft = { to: '', body: '' }; ++ function composerHtml() { ++ const roles = sendRoles ? [...new Set([...sendRoles, ...(draft.to && !sendRoles.includes(draft.to) ? [draft.to] : [])])] : null; ++ const options = roles ? roles.map(r => ``).join('') : ''; ++ const r = receipt; ++ const said = !r ? '' : r.kind === 'sent' ++ ? `

    Sent to ${txt(r.to)} ${when(r.at)}, message ${txt(short(r.id))}. Its trail.

    ` ++ : r.kind === 'refused' ++ ? `

    Not sent to ${txt(r.to)}: ${txt(r.message)} (${txt(r.code)}). The draft is kept.

    ` ++ : `

    Outcome unknown: the message to ${txt(r.to)} may have been sent. Nothing was sent again, and the draft is kept. The bus gave no message id, so there is no trail to link: before sending again, look for it on the role's side or in the bus trail from a terminal.

    `; ++ return `

    Message a role

    ++

    Goes to the role as a message from the human, recorded by the bus exactly like one sent from the CLI. A reply comes back to you as a message, which this Console does not show yet. The next mosaic talk in a terminal prints it, after sending its own message.

    ++ ${roles === null ? '

    The held roles could not be read, so there is nothing to pick. Read again to retry.

    ' : !roles.length ? '

    No role is held right now, so there is nothing to pick.

    ' : ''} ++
    ++ ++
    ${said}
    `; ++ } ++ async function composer(get) { ++ if (!canSend) return ''; ++ try { sendRoles = (await get('agents')).map(c => c.role).filter(r => typeof r === 'string' && SAFE.test(r)); } catch { sendRoles = null; } ++ return composerHtml(); ++ } ++ // Redraw only the form, keeping focus, after a send changes its state. ++ function paintSend() { ++ const el = $('send-section'); ++ if (!el) return; ++ const k = keep(); ++ el.outerHTML = composerHtml(); ++ restore(k); ++ } + async function inboxView(get) { + // The broker orders gated first, then oldest; lift blocking ones to the top. + const rows = (await get('inbox')).slice().sort((a, b) => (b.blocking - a.blocking) || String(a.at).localeCompare(String(b.at))); + countInbox(rows); + const body = rows.length ? `
      ${rows.map(decisionCard).join('')}
    ` : empty('Nothing is waiting on you.', 'A decision shows here when an agent raises one routed to you through the bus.'); + return `${head('Inbox', `${readNote()} Decisions are answered in a terminal with mosaic decide; Console only shows them.`)} ++ ${await composer(get)} +

    For you ${rows.length}

    ${body} +

    Not shown here yet: ${gap('Decisions routed to roles')} ${gap('Closed decisions')} ${gap('Seen and DM state per row')} Open a decision for its seen, DM and closing history.

    `; + } +@@ -444,6 +481,11 @@ + const row = (await get('tasks')).find(r => r.task_ref === ref); + if (!row) return notFound(`No task ${txt(ref)} in this business.`); + rows = lines(await get('trail', ref), row.seq); ++ } else if (kind === 'message') { ++ // Row 76: a sent message's trail, its request event and deliveries. Replies are not in it. ++ const tr = await get('trail', ref); ++ if (!of(tr, 'messages').some(m => m.id === ref)) return notFound(`No message with id ${txt(ref)}.`); ++ rows = lines(tr); + } else { + const d = await decision(get, ref); + if (!d) return notFound(`No decision with id ${txt(ref)}.`); +@@ -454,8 +496,8 @@ + } + const filter = GROUPS.some(([g]) => g === q.get('kind')) ? q.get('kind') : 'all'; + const base = `#/trail/${kind}/${enc(ref)}`; +- const name = kind === 'task' ? `Trail for task #${esc(String(ref).split('/')[1])}` : `Trail for decision ${txt(short(ref))}`; +- const back = kind === 'task' ? `Back to the task` : `Back to the decision`; ++ const name = kind === 'task' ? `Trail for task #${esc(String(ref).split('/')[1])}` : `Trail for ${kind} ${txt(short(ref))}`; ++ const back = kind === 'task' ? `Back to the task` : kind === 'message' ? 'Back to the Inbox' : `Back to the decision`; + return `${head(name, `${readNote()} Oldest first. ${back}`)} + + ${trailList(rows, filter)}`; +@@ -527,24 +569,30 @@ + if (a === 'settings' && n === 1) return settingsView(get); + if (a === 'trail' && n === 3 && b === 'task') return TASK.test(c) ? trailView(get, b, c, q) : notFound(`${txt(c)} is not a task reference.`); + if (a === 'trail' && n === 3 && b === 'decision') return SAFE.test(c) ? trailView(get, b, c, q) : notFound(`${txt(c)} is not a decision id.`); ++ if (a === 'trail' && n === 3 && b === 'message') return SAFE.test(c) ? trailView(get, b, c, q) : notFound(`${txt(c)} is not a message id.`); + return notFound(); + } + let gen = 0, shown = null, timer; + const paused = () => $('pause')?.getAttribute('aria-pressed') === 'true'; + function schedule() { clearTimeout(timer); if (!paused()) timer = setTimeout(() => render(false), 10000); } + // Keep focus, open
    and the copy status across a refresh of the same page. ++ // In the send form, the focused field and its caret too (its values come from `draft`). + function keep() { + const a = document.activeElement, view = $('bus-view'); + return { + href: view.contains(a) ? a.getAttribute('href') : null, copy: view.contains(a) ? a.dataset?.copy : null, retry: view.contains(a) && a.hasAttribute?.('data-retry'), mirror: view.contains(a) ? a.dataset?.mirror : null, h1: view.contains(a) && a.tagName === 'H1', ++ field: view.contains(a) ? a.dataset?.field : null, caret: view.contains(a) && a.tagName === 'TEXTAREA' ? [a.selectionStart, a.selectionEnd, a.scrollTop] : null, send: view.contains(a) && a.hasAttribute?.('data-send'), + open: [...view.querySelectorAll('details[id]')].filter(d => d.open).map(d => d.id), status: $('copy-status')?.textContent || '', + }; + } + function restore(k) { + for (const id of k.open) { const d = $(id); if (d) d.open = true; } + if ($('copy-status')) $('copy-status').textContent = k.status; +- const el = [...$('bus-view').querySelectorAll('a[href],[data-copy],[data-retry],[data-mirror]')].find(e => k.href ? e.getAttribute('href') === k.href : k.copy ? e.dataset.copy === k.copy : k.mirror ? e.dataset.mirror === k.mirror : k.retry && e.hasAttribute('data-retry')); +- if (el && (k.href || k.copy || k.retry || k.mirror)) el.focus({ preventScroll: true }); ++ const el = [...$('bus-view').querySelectorAll('a[href],[data-copy],[data-retry],[data-mirror],[data-field],[data-send]')].find(e => k.href ? e.getAttribute('href') === k.href : k.copy ? e.dataset.copy === k.copy : k.mirror ? e.dataset.mirror === k.mirror : k.field ? e.dataset.field === k.field : k.send ? e.hasAttribute('data-send') : k.retry && e.hasAttribute('data-retry')); ++ if (el && (k.href || k.copy || k.retry || k.mirror || k.field || k.send)) { ++ el.focus({ preventScroll: true }); ++ if (k.caret && el.tagName === 'TEXTAREA') { el.setSelectionRange(k.caret[0], k.caret[1]); el.scrollTop = k.caret[2]; } ++ } + else if (k.h1) $('bus-view').querySelector('h1')?.focus({ preventScroll: true }); + } + async function render(navigated) { +@@ -677,6 +725,36 @@ + if (m) { const src = $(m.dataset.mirror); src.value = m.value; src.dispatchEvent(new Event('change')); return; } + if (e.target.id === 'palette' || e.target.id === 'mode') for (const s of document.querySelectorAll(`#bus-view select[data-mirror="${e.target.id}"]`)) s.value = e.target.value; + }); ++ // The send form (row 76). Values go to `draft` as typed; Send posts once and never again by itself. ++ document.addEventListener('input', e => { const f = e.target.closest?.('#send-form [data-field]'); if (f) draft[f.dataset.field] = f.value; }); ++ document.addEventListener('submit', async e => { ++ if (e.target.id !== 'send-form') return; ++ e.preventDefault(); ++ if (sending) return; ++ const to = draft.to, text = draft.body; ++ if (!to || !text.trim()) { receipt = { kind: 'refused', to: to || 'no role', code: 'invalid-request', message: to ? 'the message is empty' : 'choose a role first' }; paintSend(); announce('Not sent: ' + receipt.message); return; } ++ sending = true; receipt = null; paintSend(); ++ let res = null, body = null; ++ try { ++ res = await fetch('/api/bus/message', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ to, body: text }), cache: 'no-store', redirect: 'manual' }); ++ try { body = await res.json(); } catch { body = null; } ++ } catch { res = null; } ++ // Sent: an id came back. Not sent: a 4xx (refused before the bus wrote), the server's ++ // sent:false, or a redirect, which only the sign-in proxy gives (an expired session), so the ++ // request never reached the Console. Anything else, no answer included, may have been sent. ++ if (res?.type === 'opaqueredirect') { ++ receipt = { kind: 'refused', to, code: 'sign-in', message: 'the sign-in proxy redirected it, so it never reached the Console; reload the page to sign in again' }; ++ } else if (res?.ok && SAFE.test(body?.id ?? '')) { ++ receipt = { kind: 'sent', to, id: body.id, at: body.at }; ++ if (draft.body === text) draft.body = ''; // keep text typed while the send was in flight ++ } else if (res && !res.ok && ((res.status >= 400 && res.status < 500) || body?.sent === false)) { ++ receipt = { kind: 'refused', to, code: typeof body?.error === 'string' ? body.error : `HTTP ${res.status}`, message: typeof body?.message === 'string' ? body.message : 'refused' }; ++ } else receipt = { kind: 'unknown', to }; ++ sending = false; paintSend(); ++ announce(receipt.kind === 'sent' ? `Sent to ${to}` : receipt.kind === 'refused' ? 'Not sent; draft kept' : 'Outcome unknown; it may have been sent; draft kept'); ++ }); ++ // Whether this server takes a send here. The form is drawn on the next Inbox render. ++ fetch('/api/config', { cache: 'no-store' }).then(r => r.json()).then(c => { if (c?.send === true) { canSend = true; if (routeOf().startsWith('/inbox')) render(false); } }).catch(() => {}); + window.addEventListener('hashchange', () => render(true)); + $('refresh').addEventListener('click', () => render(false)); + $('pause').addEventListener('click', () => setTimeout(schedule)); // after app.js flips aria-pressed +diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs +index 89327c07..6209f9b9 100644 +--- a/packages/webui/src/serve.mjs ++++ b/packages/webui/src/serve.mjs +@@ -2,7 +2,7 @@ import { createServer } from 'node:http'; + import { readFileSync } from 'node:fs'; + import { resolve } from 'node:path'; + import { isIP } from 'node:net'; +-import { BusReadError, busStatus, subjectOk } from './bus.mjs'; ++import { BusReadError, busStatus, subjectOk, sendOutcome } from './bus.mjs'; + import { ReadError, readStatus } from './reads.mjs'; + + export const DEFAULT_BOARD = 'http://127.0.0.1:7331'; +@@ -31,13 +31,13 @@ function json(res, status, body) { + res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }); + res.end(JSON.stringify(body)); + } +-async function body(req) { ++async function body(req, max = 4096) { + if ((req.headers['content-type'] || '').split(';')[0].trim().toLowerCase() !== 'application/json') throw new Error('Content-Type must be application/json'); + const chunks = []; + let size = 0; + for await (const chunk of req) { + size += chunk.length; +- if (size > 4096) throw new Error('body larger than 4096 bytes'); ++ if (size > max) throw new Error(`body larger than ${max} bytes`); + chunks.push(chunk); + } + const bytes = Buffer.concat(chunks); +@@ -70,6 +70,38 @@ async function busRead(res, bus, verb, search) { + return json(res, busStatus(code), { error: code, message: err instanceof BusReadError ? err.message : code }); + } + } ++// Row 76 (#1570, lead decision 95): with --send, the one POST preview mode allows, a human ++// message to a role. Checked here again whatever passed above: the header on this request, its own ++// Origin, Sec-Fetch-Site same-origin, JSON, 16 KiB, and only these keys. `to` and the body are the ++// broker's to refuse. Nothing retries; an outcome the bus can't confirm is reported as unknown. ++const SEND_KEYS = ['to', 'body', 'in_reply_to']; ++const one = (req, name) => (req.headersDistinct[name]?.length === 1 ? req.headersDistinct[name][0] : null); ++async function busSend(req, res, { send, preview, log }) { ++ if (one(req, preview.header) !== preview.user) return json(res, 401, { error: 'sign-in required' }); ++ let to = null; ++ // `to` is quoted and cut short so a crafted value can't forge a log line. ++ const line = outcome => log(`${new Date().toISOString()} send user=${JSON.stringify(preview.user)} to=${typeof to === 'string' ? JSON.stringify(to.slice(0, 64)) : 'none'} ${outcome}`); ++ // `sent` tells the page which it was: false, nothing was written; 'unknown', it may have been. ++ const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); }; ++ if (one(req, 'origin') !== `https://${preview.host}`) return refuse(403, 'cross-origin request refused', 'a send needs the Console\'s own Origin'); ++ if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site request refused', 'a send needs Sec-Fetch-Site: same-origin'); ++ let value; ++ try { value = JSON.parse((await body(req, 16384)).toString('utf8')); } catch (err) { return refuse(400, 'invalid-request', err.message); } ++ if (value === null || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(k => !SEND_KEYS.includes(k)) || !('to' in value) || !('body' in value)) return refuse(400, 'invalid-request', 'a send takes to, body and an optional in_reply_to, nothing else'); ++ ({ to } = value); ++ const args = { to: value.to, body: value.body }; ++ if ('in_reply_to' in value) args.in_reply_to = value.in_reply_to; ++ let result; ++ try { result = await send(args); } catch (err) { ++ const { code, sent } = sendOutcome(err); ++ return sent === false ++ ? refuse(busStatus(code), code, `not sent: the bus refused it (${code})`) ++ : refuse(503, 'outcome-unknown', 'the message may have been sent; nothing was retried. Check the trail before sending again', 'unknown'); ++ } ++ if (!subjectOk(result?.id)) return refuse(503, 'outcome-unknown', 'the bus answered without a message id; the message may have been sent. Check the trail before sending again', 'unknown'); ++ line(`id=${result.id}`); ++ return json(res, 200, { id: result.id, at: new Date().toISOString() }); ++} + // Row 54 (#1543): GET only. `reads` is consoleReads() from reads.mjs; its + // bodies and messages arrive redacted. No route here writes or calls out. + const QUEUE_ID = /^[1-9]\d{0,5}$/; +@@ -96,7 +128,9 @@ export function previewOptions({ publicHost, authHeader, allowUser } = {}) { + if (typeof allowUser !== 'string' || !USER.test(allowUser)) throw new Error('allowed user must be 1..256 visible ASCII characters without a comma'); + return { host, header, user: allowUser }; + } +-export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null } = {}) { ++// `send` is the bus write (row 76): `args => Promise<{id}>`, only with the preview mode. `log` ++// takes one line per send attempt that got past sign-in; never the body. ++export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, send = null, log = line => console.log(line) } = {}) { + if (!isLoopback(host)) throw new Error('refusing to bind to non-loopback host'); + if (host === 'localhost') host = '127.0.0.1'; + const upstream = boardURL(board); +@@ -104,6 +138,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF + if (preview !== null && !(preview = previewOptions({ publicHost: preview.host, authHeader: preview.header, allowUser: preview.user }))) { + throw new Error('preview mode needs --public-host, --auth-header and --allow-user together'); + } ++ if (send !== null && (preview === null || typeof send !== 'function')) throw new Error('--send needs the preview mode (--public-host, --auth-header and --allow-user)'); + const server = createServer(async (req, res) => { + res.setHeader('x-content-type-options', 'nosniff'); + res.setHeader('referrer-policy', 'no-referrer'); +@@ -131,7 +166,12 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF + const values = req.headersDistinct[preview.header]; + if (!values || values.length !== 1 || values[0] !== preview.user) return json(res, 401, { error: 'sign-in required' }); + } +- // Nothing but GET and HEAD gets past here in preview mode, whatever the Host. ++ // With --send, POST /api/bus/message is the one exception; it checks the header again itself. ++ if (send && path === '/api/bus/message') { ++ if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' }); ++ return busSend(req, res, { send, preview, log }); ++ } ++ // Nothing else but GET and HEAD gets past here in preview mode, whatever the Host. + if (preview !== null && req.method !== 'GET' && req.method !== 'HEAD') return json(res, 403, { error: 'read-only', message: 'the Console preview is read-only' }); + // The conversation routes (#1507, CHAT-02) keep their query; the board validates it. + const conversation = path === '/api/conversations' || path === '/api/conversation'; +@@ -173,7 +213,8 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF + return consoleRead(res, () => reads.row(Number(id))); + } + if (req.method !== 'GET' && req.method !== 'HEAD') return json(res, 405, { error: 'method not allowed' }); +- if (path === '/api/config') return json(res, 200, { board: upstream }); ++ // `send`: the page shows the composer only where a send can pass (the public name, --send on). ++ if (path === '/api/config') return json(res, 200, { board: upstream, send: Boolean(send) && named }); + if (path === '/healthz') return json(res, 200, { ok: true }); + if (path === '/favicon.ico') { res.writeHead(204); return res.end(); } + const file = files.get(path); +diff --git a/packages/webui/tests/send-browser.test.mjs b/packages/webui/tests/send-browser.test.mjs +new file mode 100644 +index 00000000..9b57c57c +--- /dev/null ++++ b/packages/webui/tests/send-browser.test.mjs +@@ -0,0 +1,164 @@ ++// Row 76 (#1570): the Inbox's "Message a role" box in a browser. A send passes only with the ++// proxy's https Origin, which a loopback page can't have, so a script added before the page ++// loads answers /api/config with send on and answers each POST /api/bus/message from a queue, ++// recording what the page sent. Server-side checks are send.test.mjs's. The roles and the ++// message trail come from an in-process broker through its reader session. ++import { test } from 'node:test'; ++import assert from 'node:assert/strict'; ++import { mkdtempSync, rmSync } from 'node:fs'; ++import { tmpdir } from 'node:os'; ++import { join } from 'node:path'; ++import { Store } from '../../bus/src/store.mjs'; ++import { Broker } from '../../bus/src/broker.mjs'; ++import { views } from '../../bus/src/views.mjs'; ++import { startServer } from '../src/serve.mjs'; ++import { browser } from './browser.mjs'; ++import { fixture, close } from './fixture.mjs'; ++ ++const businesses = { ++ demo: { ++ id: 'demo', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' }, ++ roles: { ++ pm: { authority: { withinRole: ['message.send'], crossRole: [] } }, ++ cto: { authority: { withinRole: ['message.send'], crossRole: [] } }, ++ coder: { authority: { withinRole: ['message.send'], crossRole: [] } }, ++ }, ++ }, ++}; ++ ++// Answers in order; `hold` waits for window.__release(), `reject` is a network failure, `redirect` ++// what fetch gives for the sign-in proxy's redirect under redirect: 'manual' (type opaqueredirect, status 0). ++const STUB = `(() => { ++ const real = window.fetch.bind(window); ++ window.__sends = []; window.__answers = []; ++ window.fetch = async (url, init = {}) => { ++ const u = String(url); ++ if (u === '/api/config' && !location.search.includes('nosend')) { ++ const c = await (await real(url, init)).json(); ++ return new Response(JSON.stringify({ ...c, send: true }), { headers: { 'content-type': 'application/json' } }); ++ } ++ if (u !== '/api/bus/message') return real(url, init); ++ window.__sends.push({ method: init.method, headers: init.headers, body: init.body, redirect: init.redirect }); ++ const a = window.__answers.shift(); ++ if (!a) throw new TypeError('no answer queued'); ++ if (a.hold) await new Promise(r => { window.__release = r; }); ++ if (a.reject) throw new TypeError('Failed to fetch'); ++ if (a.redirect) return { type: 'opaqueredirect', ok: false, status: 0, json: async () => { throw new SyntaxError('no body'); } }; ++ return new Response(JSON.stringify(a.body), { status: a.status, headers: { 'content-type': 'application/json' } }); ++ }; ++})();`; ++ ++test('the send box: held roles, the draft across a refresh, each receipt, one POST per Send, and the trail link', { timeout: 120000 }, async () => { ++ const root = mkdtempSync(join(tmpdir(), 'webui-send-browser-')); ++ const store = new Store(root), b0 = new Broker({ store, businesses }); ++ for (const role of ['pm', 'coder']) b0.request(b0.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi', address: `${role}-run` }), { verb: 'role.claim' }); ++ const human = b0.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); ++ const seeded = b0.request(human, { verb: 'message.send', args: { to: 'coder', body: 'Seeded message from the human' } }); ++ const reader = b0.bindReader({ business: 'demo' }); ++ const bus = views({ call: async (verb, args = {}) => structuredClone(b0.request(reader, { verb, args })) }); ++ const f = await fixture(), b = await browser(); ++ const web = await startServer({ port: 0, board: f.boardURL, bus }), base = `http://127.0.0.1:${web.address().port}/`; ++ const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out: '+${JSON.stringify(expr)})})()`); ++ const text = sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.textContent ?? null`); ++ const value = sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.value ?? null`); ++ const set = (sel, v) => b.evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(sel)});e.value=${JSON.stringify(v)};e.dispatchEvent(new Event('input',{bubbles:true}));return true})()`); ++ const answer = a => b.evaluate(`window.__answers.push(${JSON.stringify(a)}), true`); ++ const send = async () => { await b.evaluate('document.querySelector("#send-form [data-send]").click(), true'); await wait('document.querySelector("#send-section .receipt") && document.querySelector("#send-form [data-send]").getAttribute("aria-disabled") === "false"'); }; ++ const sends = () => b.evaluate('window.__sends'); ++ const receipt = () => text('#send-section .receipt'); ++ const msg = 'Please look at row 76.'; ++ try { ++ await b.call('Page.addScriptToEvaluateOnNewDocument', { source: STUB }); ++ await b.viewport(1280); ++ ++ // Without send in /api/config there is no box. ++ await b.navigate(`${base}?nosend#/inbox`); ++ await wait('document.querySelector("#bus-view h1")?.textContent.startsWith("Inbox") && !document.querySelector("#bus-view [aria-busy]")'); ++ assert.equal(await b.evaluate('!!document.querySelector("#send-section")'), false); ++ ++ await b.navigate(`${base}#/inbox`); ++ await wait('document.querySelector("#send-form")'); ++ assert.equal(await text('#send-section h2'), 'Message a role'); ++ assert.match(await text('#send-section'), /does not show yet\. The next mosaic talk in a terminal prints it, after sending its own message\./); ++ assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#send-to option")].map(o => [o.value, o.textContent])'), [['', 'Choose a held role'], ['coder', 'coder'], ['pm', 'pm']]); ++ ++ // Nothing to send is refused in the page, without a request. ++ await send(); ++ assert.match(await receipt(), /^Not sent to no role: choose a role first/); ++ await set('#send-to', 'coder'); ++ await send(); ++ assert.match(await receipt(), /^Not sent to coder: the message is empty/); ++ assert.deepEqual(await sends(), []); ++ ++ // The draft survives a refresh, which redraws the form. ++ await set('#send-body', msg); ++ await b.evaluate('document.querySelector("#send-body").dataset.before = "1", true'); ++ await b.evaluate('document.querySelector("#refresh").click(), true'); ++ await wait('!document.querySelector("#send-body")?.dataset.before && !document.querySelector("#bus-view [aria-busy]")'); ++ assert.deepEqual([await value('#send-to'), await value('#send-body')], ['coder', msg]); ++ ++ // A refusal keeps the draft and says why. ++ await answer({ status: 400, body: { error: 'unknown-role', message: 'not sent: the bus refused it (unknown-role)', sent: false } }); ++ await send(); ++ assert.equal(await receipt(), 'Not sent to coder: not sent: the bus refused it (unknown-role) (unknown-role). The draft is kept.'); ++ assert.equal(await value('#send-body'), msg); ++ const [first] = await sends(); ++ assert.deepEqual(first, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ to: 'coder', body: msg }), redirect: 'manual' }); ++ ++ // A 503 the server marked not sent is a refusal too; an unknown outcome and a lost answer are unknown. ++ await answer({ status: 503, body: { error: 'no-bus-host', message: 'not sent: the bus refused it (no-bus-host)', sent: false } }); ++ await send(); ++ assert.match(await receipt(), /^Not sent to coder: .*\(no-bus-host\)\. The draft is kept\.$/); ++ // A redirect is the sign-in proxy's (the Console never redirects this route): not sent. ++ await answer({ redirect: true }); ++ await send(); ++ assert.equal(await receipt(), 'Not sent to coder: the sign-in proxy redirected it, so it never reached the Console; reload the page to sign in again (sign-in). The draft is kept.'); ++ assert.equal(await value('#send-body'), msg); ++ for (const a of [{ status: 503, body: { error: 'outcome-unknown', message: 'the message may have been sent', sent: 'unknown' } }, { reject: true }, { status: 200, body: { at: 'x' } }]) { ++ await answer(a); ++ await send(); ++ assert.match(await receipt(), /^Outcome unknown: the message to coder may have been sent\. Nothing was sent again, and the draft is kept\./, JSON.stringify(a)); ++ assert.equal(await value('#send-body'), msg); ++ assert.equal(await b.evaluate('!!document.querySelector("#send-section .receipt a")'), false); ++ } ++ // Each Send was one POST; none was repeated after the unknown ones. ++ await new Promise(r => setTimeout(r, 500)); ++ assert.equal((await sends()).length, 6); ++ ++ // While a send is pending, Send is disabled and a second press sends nothing; text typed ++ // meanwhile is kept when the first one succeeds. ++ await answer({ hold: true, status: 200, body: { id: seeded.id, at: new Date().toISOString() } }); ++ await b.evaluate('document.querySelector("#send-form [data-send]").click(), true'); ++ await wait('document.querySelector("#send-form [data-send]").getAttribute("aria-disabled") === "true"'); ++ assert.equal(await text('#send-form [data-send]'), 'Sending…'); ++ await b.evaluate('document.querySelector("#send-form [data-send]").click(), true'); ++ await set('#send-body', 'A second thought'); ++ assert.equal((await sends()).length, 7); ++ await b.evaluate('window.__release(), true'); ++ await wait('document.querySelector("#send-section .receipt") && document.querySelector("#send-form [data-send]").getAttribute("aria-disabled") === "false"'); ++ assert.match(await receipt(), new RegExp(`^Sent to coder .*, message ${seeded.id.slice(0, 8)}\\. Its trail\\.$`)); ++ assert.equal(await value('#send-body'), 'A second thought'); ++ assert.equal((await sends()).length, 7); ++ ++ // A plain success clears the text and keeps the role. ++ await answer({ status: 200, body: { id: seeded.id, at: new Date().toISOString() } }); ++ await send(); ++ assert.deepEqual([await value('#send-to'), await value('#send-body')], ['coder', '']); ++ assert.equal(JSON.parse((await sends())[7].body).body, 'A second thought'); ++ ++ // The receipt links the message's trail, which the bus reads. ++ assert.equal(await b.evaluate('document.querySelector("#send-section .receipt a").getAttribute("href")'), `#/trail/message/${seeded.id}`); ++ await b.evaluate('document.querySelector("#send-section .receipt a").click(), true'); ++ await wait('document.querySelector("#bus-view h1")?.textContent.startsWith("Trail for message") && !document.querySelector("#bus-view [aria-busy]")'); ++ assert.equal(await text('#bus-view h1'), `Trail for message ${seeded.id.slice(0, 8)}`); ++ assert.match(await text('#bus-view'), /Seeded message from the human/); ++ assert.equal(await b.evaluate('document.querySelector("#bus-view a[href=\'#/inbox\']")?.textContent'), 'Back to the Inbox'); ++ await b.evaluate(`location.hash = '#/trail/message/no-such-message', true`); ++ await wait('document.querySelector("#bus-view h1")?.textContent === "Not found"'); ++ assert.match(await text('#bus-view'), /No message with id no-such-message\./); ++ assert.equal((await sends()).length, 8); ++ } finally { ++ await b.close(); await close(web); await f.close(); store.close(); ++ rmSync(root, { recursive: true, force: true }); ++ } ++}); +diff --git a/packages/webui/tests/send.test.mjs b/packages/webui/tests/send.test.mjs +new file mode 100644 +index 00000000..706e8f43 +--- /dev/null ++++ b/packages/webui/tests/send.test.mjs +@@ -0,0 +1,301 @@ ++// Row 76 (#1570, lead decision 95): `serve --send`, a human message to a role from the Console. ++// Only with the preview flags; POST /api/bus/message needs the header on this request, its own ++// Origin, Sec-Fetch-Site same-origin, JSON, 16 KiB and only to, body and in_reply_to. The broker ++// refuses `to` and the body. An outcome the transport can't confirm is unknown and never retried. ++// Every other write is still 403 read-only. Nothing here starts the real human transport. ++import { test } from 'node:test'; ++import assert from 'node:assert/strict'; ++import { createServer, request } from 'node:http'; ++import { spawn, spawnSync } from 'node:child_process'; ++import { writeFileSync } from 'node:fs'; ++import { join } from 'node:path'; ++import { fileURLToPath } from 'node:url'; ++import { startServer } from '../src/serve.mjs'; ++import { BusReadError, humanCall } from '../src/bus.mjs'; ++import { close } from './fixture.mjs'; ++import { broker, fixture, tmp } from '../../cli/tests/helpers.mjs'; ++ ++const NAME = 'mosaic-preview.example.com', HEADER = 'remote-user', ID = 'jason'; ++const preview = { host: NAME, header: HEADER, user: ID }; ++const CLI = fileURLToPath(new URL('../src/cli.mjs', import.meta.url)); ++const ok = { [HEADER]: ID, origin: `https://${NAME}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' }; ++const without = name => Object.fromEntries(Object.entries(ok).filter(([k]) => k !== name)); ++const msg = JSON.stringify({ to: 'coder', body: 'Please look at row 76.' }); ++ ++// node:http, not fetch: fetch can't set Host or Sec-Fetch-Site. ++function post(port, { method = 'POST', path = '/api/bus/message', headers = ok, body = msg } = {}) { ++ return new Promise((resolve, reject) => { ++ const length = body === undefined ? {} : { 'content-length': Buffer.byteLength(body) }; ++ const req = request({ host: '127.0.0.1', port, method, path, headers: { host: NAME, ...length, ...headers } }, res => { ++ let text = ''; ++ res.on('data', c => (text += c)); ++ res.on('end', () => resolve({ status: res.statusCode, body: text && (res.headers['content-type'] ?? '').startsWith('application/json') ? JSON.parse(text) : text })); ++ }); ++ req.on('error', reject); ++ req.end(body); ++ }); ++} ++ ++// A board that counts what reaches it, a bus that counts its reads, and a send that records ++// each call and answers from `answer`. ++async function setup(t, options = {}) { ++ const boardHits = []; ++ const upstream = createServer((req, res) => { boardHits.push(`${req.method} ${req.url}`); res.writeHead(200, { 'content-type': 'application/json' }); res.end('{"ok":true}'); }); ++ await new Promise(r => upstream.listen(0, '127.0.0.1', r)); ++ t.after(() => close(upstream)); ++ const busCalls = [], sends = [], lines = []; ++ const bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions'].map(v => [v, async arg => { busCalls.push([v, arg]); return [{ verb: v }]; }])); ++ const state = { answer: async () => ({ id: 'msg-0001' }) }; ++ const send = async args => { sends.push(args); return state.answer(args); }; ++ const web = await startServer({ port: 0, board: `http://127.0.0.1:${upstream.address().port}`, bus, preview, send, log: l => lines.push(l), ...options }); ++ t.after(() => close(web)); ++ return { port: web.address().port, boardHits, busCalls, sends, lines, state }; ++} ++ ++test('startServer takes send only as a function and only with the preview mode', async () => { ++ for (const options of [{ send: async () => ({}) }, { send: async () => ({}), preview: null }, { send: 'yes', preview }, { send: {}, preview }]) { ++ const result = await startServer({ port: 0, ...options }).catch(err => err); ++ if (!(result instanceof Error)) { await close(result); assert.fail(`served with ${Object.keys(options)}`); } ++ assert.match(result.message, /--send needs the preview mode/); ++ } ++}); ++ ++test('the CLI refuses --send without the preview flags, and without a configured bus', t => { ++ const root = tmp(t, 'webui-send-cli-'); ++ const env = { ...process.env, MOSAIC_CONFIG: join(root, 'no-such-config.json') }; ++ for (const [flags, why] of [ ++ [['--send'], /^refused: --send needs the preview mode/], ++ [['--send', '--public-host', NAME, '--auth-header', HEADER], /^refused: preview mode needs/], ++ [['--public-host', NAME, '--auth-header', HEADER, '--allow-user', ID, '--send'], /^refused: --send needs the bus configured \(/], ++ [['--send', '--public-host', NAME, '--auth-header', HEADER, '--allow-user', ID], /^refused: --send needs the bus configured \(/], ++ ]) { ++ const result = spawnSync(process.execPath, [CLI, 'serve', '--port', '0', ...flags], { encoding: 'utf8', timeout: 10000, env }); ++ assert.equal(result.status, 2, flags.join(' ')); ++ assert.match(result.stderr, why, flags.join(' ')); ++ assert.equal(result.stderr.includes(root), false, 'the refusal names the config path'); ++ assert.equal(result.stdout, ''); ++ } ++}); ++ ++test('the CLI with --send and a configured bus sends through the human transport and logs one line', async t => { ++ // No bus host is running, so the send is a definite refusal: no-bus-host, nothing written. ++ const root = tmp(t, 'webui-send-cli-'); ++ const { env } = fixture(root); ++ const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', '--public-host', NAME, '--auth-header', HEADER, '--allow-user', ID, '--send'], { env, stdio: ['ignore', 'pipe', 'pipe'] }); ++ t.after(() => child.kill()); ++ let out = ''; ++ const port = await new Promise((resolve, reject) => { ++ child.stdout.on('data', c => { out += c; const m = out.match(/^Mosaic Console: http:\/\/127\.0\.0\.1:(\d+)\/$/m); if (m && out.includes('Ctrl-C')) resolve(Number(m[1])); }); ++ child.on('exit', code => reject(new Error(`exited ${code}: ${out}`))); ++ }); ++ assert.match(out, new RegExp(`^Preview: https://${NAME}/ through the proxy, signed-in user from ${HEADER}, reads and human messages to roles \\(--send\\); one line per send below$`, 'm')); ++ const r = await post(port, { body: JSON.stringify({ to: 'coder', body: 'secret-body-text' }) }); ++ assert.equal(r.status, 503); ++ assert.deepEqual(r.body, { error: 'no-bus-host', message: 'not sent: the bus refused it (no-bus-host)', sent: false }); ++ const SEND = /^\d{4}-\d\d-\d\dT[\d:.]+Z send /m; ++ for (let i = 0; i < 100 && !SEND.test(out); i++) await new Promise(res => setTimeout(res, 20)); ++ const sendLines = out.split('\n').filter(l => SEND.test(l)); ++ assert.equal(sendLines.length, 1, out); ++ assert.match(sendLines[0], /^\d{4}-\d\d-\d\dT[\d:.]+Z send user="jason" to="coder" error=no-bus-host$/); ++ assert.equal(out.includes('secret-body-text'), false); ++}); ++ ++test('without --send the route is read-only like every other write, and config says no send', async t => { ++ const { port, sends, lines } = await setup(t, { send: null }); ++ const r = await post(port); ++ assert.equal(r.status, 403); assert.deepEqual(r.body, { error: 'read-only', message: 'the Console preview is read-only' }); ++ const config = await post(port, { method: 'GET', path: '/api/config', headers: { [HEADER]: ID }, body: undefined }); ++ assert.equal(config.body.send, false); ++ assert.deepEqual(sends, []); assert.deepEqual(lines, []); ++}); ++ ++test('with --send every other write is still 403 read-only, and nothing reaches the board or bus', async t => { ++ const { port, sends, lines, boardHits, busCalls } = await setup(t); ++ for (const [method, path] of [['POST', '/api/seen'], ['POST', '/api/reply'], ['POST', '/api/bus/inbox'], ['POST', '/api/bus/message/x'], ['POST', '/api/bus/messages'], ['POST', '/'], ['PUT', '/api/seen'], ['DELETE', '/api/reply'], ['PATCH', '/api/bus/inbox']]) { ++ const r = await post(port, { method, path }); ++ assert.equal(r.status, 403, `${method} ${path}`); assert.deepEqual(r.body, { error: 'read-only', message: 'the Console preview is read-only' }, `${method} ${path}`); ++ } ++ assert.deepEqual([sends, lines, boardHits, busCalls], [[], [], [], []]); ++}); ++ ++test('config says send only on the public name with --send on', async t => { ++ const { port } = await setup(t); ++ assert.equal((await post(port, { method: 'GET', path: '/api/config', headers: { [HEADER]: ID }, body: undefined })).body.send, true); ++ assert.equal((await post(port, { method: 'GET', path: '/api/config', headers: { host: `127.0.0.1:${port}` }, body: undefined })).body.send, false); ++}); ++ ++test('a send without the signed-in user is 401 and is neither sent nor logged, loopback included', async t => { ++ const { port, sends, lines } = await setup(t); ++ for (const [why, headers] of [ ++ ['no header', without(HEADER)], ++ ['another user', { ...ok, [HEADER]: 'mallory' }], ++ ['the id with a suffix', { ...ok, [HEADER]: ID + 'x' }], ++ ['two values', { ...ok, [HEADER]: [ID, ID] }], ++ ['loopback without the header', { ...without(HEADER), host: `127.0.0.1:${port}`, origin: `http://127.0.0.1:${port}` }], ++ ['localhost, no Origin, no header', { 'content-type': 'application/json', 'sec-fetch-site': 'same-origin', host: `localhost:${port}` }], ++ ]) { ++ const r = await post(port, { headers }); ++ assert.equal(r.status, 401, why); assert.deepEqual(r.body, { error: 'sign-in required' }, why); ++ } ++ // Loopback with the header is a proxy that rewrote Host: refused before the route (row 74). ++ const rewritten = await post(port, { headers: { ...ok, host: `127.0.0.1:${port}` } }); ++ assert.equal(rewritten.status, 403); assert.equal(rewritten.body.error, 'proxy Host refused'); ++ assert.deepEqual(sends, []); assert.deepEqual(lines, []); ++}); ++ ++test('each request check refuses before anything is sent, and logs the refusal without the body', async t => { ++ const { port, sends, lines } = await setup(t); ++ const cases = [ ++ ['no Origin', { headers: without('origin') }, 403, 'cross-origin request refused'], ++ ['no Sec-Fetch-Site', { headers: without('sec-fetch-site') }, 403, 'cross-site request refused'], ++ ...['cross-site', 'same-site', 'none', 'Same-Origin', 'same-origin, same-origin'].map(v => [`Sec-Fetch-Site ${v}`, { headers: { ...ok, 'sec-fetch-site': v } }, 403, 'cross-site request refused']), ++ ['two Sec-Fetch-Site', { headers: { ...ok, 'sec-fetch-site': ['same-origin', 'same-origin'] } }, 403, 'cross-site request refused'], ++ ['no Content-Type', { headers: without('content-type') }, 400, 'invalid-request'], ++ ['text/plain', { headers: { ...ok, 'content-type': 'text/plain' } }, 400, 'invalid-request'], ++ ['form', { headers: { ...ok, 'content-type': 'application/x-www-form-urlencoded' }, body: 'to=coder&body=hi' }, 400, 'invalid-request'], ++ ['oversize', { body: JSON.stringify({ to: 'coder', body: 'x'.repeat(16384) }) }, 400, 'invalid-request'], ++ ['an extra key', { body: JSON.stringify({ to: 'coder', body: 'secret-body-text', class: 'REQUEST' }) }, 400, 'invalid-request'], ++ ['the business key', { body: JSON.stringify({ to: 'coder', body: 'secret-body-text', business: 'other' }) }, 400, 'invalid-request'], ++ ['no to', { body: JSON.stringify({ body: 'secret-body-text' }) }, 400, 'invalid-request'], ++ ['no body', { body: JSON.stringify({ to: 'coder' }) }, 400, 'invalid-request'], ++ ['not JSON', { body: '{"to":' }, 400, 'invalid-request'], ++ ['null', { body: 'null' }, 400, 'invalid-request'], ++ ['an array', { body: '["coder","hi"]' }, 400, 'invalid-request'], ++ ['a string', { body: '"hi"' }, 400, 'invalid-request'], ++ ]; ++ for (const [why, req, status, error] of cases) { ++ const r = await post(port, req); ++ assert.equal(r.status, status, why); assert.equal(r.body.error, error, why); assert.equal(r.body.sent, false, why); ++ } ++ // An Origin that isn't this one is refused by the shared check, before the route. ++ for (const origin of [`http://${NAME}`, 'https://evil.example', `https://${NAME}:443`, 'null']) { ++ const r = await post(port, { headers: { ...ok, origin } }); ++ assert.equal(r.status, 403, origin); assert.deepEqual(r.body, { error: 'cross-origin request refused' }, origin); ++ } ++ assert.equal((await post(port, { method: 'GET', body: undefined })).status, 405); ++ assert.equal((await post(port, { method: 'PUT' })).status, 405); ++ assert.deepEqual(sends, []); ++ assert.equal(lines.length, cases.length); ++ for (const l of lines) assert.match(l, /^\d{4}-\d\d-\d\dT[\d:.]+Z send user="jason" to=("coder"|none) error=[a-z -]+$/, l); ++ assert.equal(lines.some(l => l.includes('secret-body-text')), false); ++ // 16 KiB fits: the body limit is the broker's beyond that. ++ const fits = JSON.stringify({ to: 'coder', body: 'x'.repeat(16384 - JSON.stringify({ to: 'coder', body: '' }).length) }); ++ assert.equal(Buffer.byteLength(fits), 16384); ++ assert.equal((await post(port, { body: fits })).status, 200); ++}); ++ ++test('a send passes exactly to, body and in_reply_to, answers the id, and logs it', async t => { ++ const { port, sends, lines, boardHits, busCalls } = await setup(t); ++ const r = await post(port, { body: JSON.stringify({ body: 'secret-body-text', to: 'coder' }) }); ++ assert.equal(r.status, 200); ++ assert.equal(r.body.id, 'msg-0001'); assert.match(r.body.at, /^\d{4}-\d\d-\d\dT/); assert.deepEqual(Object.keys(r.body), ['id', 'at']); ++ const reply = await post(port, { body: JSON.stringify({ to: 'pm', body: 'yes', in_reply_to: 'msg-0000' }) }); ++ assert.equal(reply.status, 200); ++ // Values go to the broker as given, whatever their type: it is the one that refuses them. ++ assert.equal((await post(port, { body: JSON.stringify({ to: 7, body: ['x'] }) })).status, 200); ++ assert.deepEqual(sends, [{ to: 'coder', body: 'secret-body-text' }, { to: 'pm', body: 'yes', in_reply_to: 'msg-0000' }, { to: 7, body: ['x'] }]); ++ assert.deepEqual(sends.map(Object.keys), [['to', 'body'], ['to', 'body', 'in_reply_to'], ['to', 'body']]); ++ assert.equal(lines.length, 3); ++ assert.match(lines[0], /^\d{4}-\d\d-\d\dT[\d:.]+Z send user="jason" to="coder" id=msg-0001$/); ++ assert.match(lines[2], / to=none id=msg-0001$/); ++ assert.equal(lines.some(l => l.includes('secret-body-text')), false); ++ assert.deepEqual(boardHits, []); assert.deepEqual(busCalls, []); ++}); ++ ++test('a crafted role name cannot forge a log line', async t => { ++ const { port, lines, state } = await setup(t); ++ state.answer = async () => { throw new BusReadError('unknown-role'); }; ++ const to = 'coder error=x\n2026-01-01T00:00:00.000Z send user="jason" to="pm" id=forged' + 'y'.repeat(100); ++ await post(port, { body: JSON.stringify({ to, body: 'hi' }) }); ++ assert.equal(lines.length, 1); ++ assert.equal(lines[0].includes('\n'), false); ++ assert.match(lines[0], / error=unknown-role$/); ++ assert.ok(lines[0].length < 200); ++}); ++ ++test('a bus refusal is not sent, with its status; an outcome the bus could not confirm is unknown and never retried', async t => { ++ const { port, sends, lines, state } = await setup(t); ++ for (const [answer, status, error, sent] of [ ++ [new BusReadError('unknown-role'), 400, 'unknown-role', false], ++ [new BusReadError('invalid-request'), 400, 'invalid-request', false], ++ [new BusReadError('message-not-found'), 400, 'message-not-found', false], ++ [new BusReadError('request-too-large'), 400, 'request-too-large', false], ++ [new BusReadError('human-required'), 403, 'human-required', false], ++ [new BusReadError('read-only'), 403, 'read-only', false], ++ [new BusReadError('no-bus-host'), 503, 'no-bus-host', false], ++ [new BusReadError('something-new'), 502, 'something-new', false], ++ [new BusReadError('outcome-unknown'), 503, 'outcome-unknown', 'unknown'], ++ [new BusReadError('invalid-response'), 503, 'outcome-unknown', 'unknown'], ++ [new BusReadError('response-too-large'), 503, 'outcome-unknown', 'unknown'], ++ [new Error('ENOENT: /home/someone/.mosaic-dev/bus/broker.sock'), 503, 'outcome-unknown', 'unknown'], ++ [{ id: '' }, 503, 'outcome-unknown', 'unknown'], ++ [null, 503, 'outcome-unknown', 'unknown'], ++ [{ id: '../x' }, 503, 'outcome-unknown', 'unknown'], ++ ]) { ++ const before = sends.length; ++ state.answer = async () => { if (answer instanceof Error) throw answer; return answer; }; ++ const r = await post(port); ++ const label = answer instanceof Error ? answer.code ?? answer.message : JSON.stringify(answer); ++ assert.equal(sends.length, before + 1, `${label}: sent ${sends.length - before} times`); ++ assert.equal(r.status, status, label); assert.equal(r.body.error, error, label); assert.equal(r.body.sent, sent, label); ++ assert.doesNotMatch(JSON.stringify(r.body), /home|mosaic-dev|\.sock/); ++ if (sent === 'unknown') assert.match(r.body.message, /may have been sent; nothing was retried|may have been sent/, label); ++ assert.match(lines.at(-1), new RegExp(` error=${error}$`), label); ++ } ++}); ++ ++// The same protocol as the real transport: a script standing in for human-cli.mjs. ++const FAKE = ` ++import { readFileSync, appendFileSync } from 'node:fs'; ++const request = JSON.parse(readFileSync(0, 'utf8')); ++appendFileSync(process.env.FAKE_LOG, JSON.stringify(request) + '\\n'); ++if (request.args.to === 'nobody') { process.stderr.write('unknown-role\\n'); process.exit(2); } ++if (request.args.to === 'crash') process.exit(1); ++process.stdout.write(JSON.stringify({ id: 'msg-fake-1', request: 'req-1' })); ++`; ++test('through the human transport the request is exactly the business, message.send and the keys', async t => { ++ const root = tmp(t, 'webui-send-transport-'); ++ const cli = join(root, 'fake-human-cli.mjs'), log = join(root, 'requests.jsonl'); ++ writeFileSync(cli, FAKE); ++ const call = humanCall({ socket: '/run/fake.sock', business: 'demo', cli, env: { ...process.env, FAKE_LOG: log }, timeoutMs: 5000 }); ++ const { port, lines } = await setup(t, { send: args => call('message.send', args) }); ++ const r = await post(port); ++ assert.equal(r.status, 200); assert.equal(r.body.id, 'msg-fake-1'); ++ const refused = await post(port, { body: JSON.stringify({ to: 'nobody', body: 'hi' }) }); ++ assert.deepEqual([refused.status, refused.body.error, refused.body.sent], [400, 'unknown-role', false]); ++ const crashed = await post(port, { body: JSON.stringify({ to: 'crash', body: 'hi' }) }); ++ assert.deepEqual([crashed.status, crashed.body.error, crashed.body.sent], [503, 'outcome-unknown', 'unknown']); ++ const { readFileSync } = await import('node:fs'); ++ const requests = readFileSync(log, 'utf8').trim().split('\n').map(l => JSON.parse(l)); ++ assert.deepEqual(requests, [ ++ { business: 'demo', verb: 'message.send', args: { to: 'coder', body: 'Please look at row 76.' } }, ++ { business: 'demo', verb: 'message.send', args: { to: 'nobody', body: 'hi' } }, ++ { business: 'demo', verb: 'message.send', args: { to: 'crash', body: 'hi' } }, ++ ]); ++ assert.equal(lines.length, 3); ++}); ++ ++test('against a real broker: the message is written once as a human send, and its refusals map', async t => { ++ const { b, human, transport } = broker(t); ++ const call = transport('demo'); ++ // The transport's refusal shape, as humanCall gives it. ++ const send = async args => { try { return await call('message.send', args); } catch (e) { throw new BusReadError(e.code); } }; ++ const { port } = await setup(t, { send }); ++ const r = await post(port, { body: JSON.stringify({ to: 'coder', body: 'Please look at row 76.' }) }); ++ assert.equal(r.status, 200); ++ const messages = () => b.request(human, { verb: 'trail', args: { subject: r.body.id } }).filter(row => row.table === 'messages'); ++ assert.deepEqual(messages().map(m => [m.id, m.from_role, m.from_run, m.to_role, m.class, m.body]), [[r.body.id, 'human', 'human-cli', 'coder', 'INFO', 'Please look at row 76.']]); ++ for (const [body, status, error] of [ ++ [{ to: 'no-such-role', body: 'hi' }, 400, 'unknown-role'], ++ [{ to: 'coder', body: 'x'.repeat(32769) }, 400, 'invalid-request'], ++ [{ to: 'coder', body: '' }, 400, 'invalid-request'], ++ [{ to: 7, body: 'hi' }, 400, 'unknown-role'], ++ [{ to: 'coder', body: 'hi', in_reply_to: 'msg-none' }, 400, 'message-not-found'], ++ ]) { ++ // The 32769-character body is past the Console's 16 KiB too; the Console refuses that one first. ++ const res = await post(port, { body: JSON.stringify(body) }); ++ assert.deepEqual([res.status, res.body.error, res.body.sent], [status, error, false], JSON.stringify(body).slice(0, 60)); ++ } ++ assert.equal(messages().length, 1); ++ assert.equal(b.request(human, { verb: 'trail', args: { subject: 'msg-none' } }).length, 0); ++}); +diff --git a/packages/webui/tests/shell.test.mjs b/packages/webui/tests/shell.test.mjs +index d27e0fbd..fbea206c 100644 +--- a/packages/webui/tests/shell.test.mjs ++++ b/packages/webui/tests/shell.test.mjs +@@ -50,9 +50,12 @@ test('shipped strings are unchanged, and the web keeps its write boundary', () = + ['bus.js', 'No open tasks.'], ['bus.js', 'No role is held right now.'], ['bus.js', 'Nothing of this kind in the trail.'], + ['bus.js', 'Console shows nothing rather than a guess. Nothing was written.'], + ]) assert.equal(plain[file].split(s).length - 1, n, `${file}: ${s}`); +- // No Resolve control and no bus write: the only POSTs are the board's Seen and reply. ++ // No Resolve control. The board's Seen and reply are app.js's POSTs; bus.js has one write, ++ // row 76's message to a role, and it sends only to and body. + assert.doesNotMatch(src, /]*>[^<]*(Resolve|Decide|Approve)/i); +- assert.doesNotMatch(read('bus.js').toString(), /method:\s*['"](POST|PUT|PATCH|DELETE)/); ++ const writes = read('bus.js').toString().match(/fetch\([^;]*method:\s*['"](POST|PUT|PATCH|DELETE)[^;]*;/g) ?? []; ++ assert.deepEqual(writes, ["fetch('/api/bus/message', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ to, body: text }), cache: 'no-store', redirect: 'manual' });"]); ++ assert.equal(read('bus.js').toString().split(/method:/).length - 1, 1); + assert.equal(read('icons.svg').toString().includes(' node demo.mjs . +// 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 }); diff --git a/agents/dewey/work/queue-76/demo.txt b/agents/dewey/work/queue-76/demo.txt new file mode 100644 index 000000000..b35768f13 --- /dev/null +++ b/agents/dewey/work/queue-76/demo.txt @@ -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 diff --git a/agents/dewey/work/queue-76/mutants.py b/agents/dewey/work/queue-76/mutants.py new file mode 100644 index 000000000..8bbab63ef --- /dev/null +++ b/agents/dewey/work/queue-76/mutants.py @@ -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') diff --git a/agents/dewey/work/queue-76/mutants.txt b/agents/dewey/work/queue-76/mutants.txt new file mode 100644 index 000000000..4052777a8 --- /dev/null +++ b/agents/dewey/work/queue-76/mutants.txt @@ -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