Files
stack/packages/webui/README.md
T
jason.woltjeandClaude Opus 5.5 351464ce42 feat(webui): signed-in read-only Console preview behind the edge proxy (#1568, row 74)
serve takes --public-host NAME --auth-header HEADER --allow-user ID, all
three or none. With them, a request whose Host is NAME must carry HEADER
equal to ID or gets 403; a request that carries HEADER with any other Host
gets 403, so a proxy that rewrites Host can't reach the loopback path with
a signed-in request; every POST is refused read-only. A repeated Host gets
400 in both modes. Host and header names compare case-insensitively
(Traefik sends X-Authentik-Uid). The README states the proxy contract:
the edge forwards Host unchanged and strips client copies of HEADER.

Author: Dewey. Round 2 candidate manifest
17b6d7f62d106a23d99441eefa46e34ab2e9bd1442a1d02e219f126a82a8668d (base
17ea0dbc, no drift on the 4 paths through 60c8dcf9). Approved by Filbert
(comment 27485) and Darkwing (comment 27486) on #1568. Their wording
notes on the README (first signed-in request; the tunnel sentence) go to
row 76, which owns the README next.

Sage gate rerun on a worktree of a55915f0 with the patch: manifest OK;
webui 61/61, control-board 124/124, cli 113/113; test-auth 15, conductor
17, config 24, extension-package 18, foundation 44, queue 27, release 14,
task 98, all 0 failed. test-discord failed one timing test (engine: a
timed-out run outlives the grace, 280 ms at load 30) on the sequential
run and passed 66/66 on a solo rerun of the same worktree; the patch
touches no discord file.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 18:32:51 -05:00

405 lines
24 KiB
Markdown

# Mosaic Console
Piece 4, #1507. The control board is the first and only WebUI screen.
## Run
Start the existing board in one terminal, then the WebUI in another:
```sh
node packages/control-board/src/cli.mjs serve
node packages/webui/src/cli.mjs serve
```
Open http://127.0.0.1:7330/. Optional `--port N` and
`--board http://127.0.0.1:7331` select another port or loopback board origin.
`--business ID` picks the business the bus views read; without it they read
the running bus host's business (`mosaic bus start <business>`).
Port 0 picks a free port. Ctrl-C stops each foreground server. No daemon,
installation, account, login or deployment is added. The preview mode below
checks one header a sign-in proxy sets; it signs nobody in itself.
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
only in the preview mode below, with a proxy that meets its contract.
## Preview behind a sign-in proxy (row 74, #1568)
```sh
node packages/webui/src/cli.mjs serve --port 7332 \
--public-host NAME --auth-header HEADER --allow-user ID
```
The three flags go together; one or two refuse at start, before any config
is read. NAME is a DNS name, no scheme or port. HEADER is a header name other
than Host or Origin; Node lowercases incoming header names, so it matches
without regard to case (`X-Example-Uid` on the flag finds `x-example-uid`).
ID is the one user id, 1 to 256 visible ASCII characters without a comma,
and the value compare is exact. The Console still binds to loopback only. It
names no identity provider and has no login code: the proxy signs the user
in and limits sign-in to the right group.
In preview mode:
- A request whose Host is exactly NAME must carry HEADER once, with a value
equal to ID. Missing, different or repeated answers 401 `sign-in required`
before any route runs, so no bus read or board call is made. An Origin,
when sent, must be `https://NAME`.
- A request whose Host is loopback on the Console's port (`127.x`,
`localhost` or `[::1]`) is checked as before and needs no HEADER, so the
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
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.
- 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.
What the proxy must do. The Console trusts HEADER only because the proxy
controls it:
- It terminates TLS for NAME and is the only thing that reaches the port
from off the host.
- It strips HEADER from every client request and sets it only after the
identity provider signs the user in.
- It forwards only requests whose Host is NAME, and forwards that Host
unchanged. nginx rewrites Host to the upstream by default; set
`proxy_set_header Host $host;` (or the literal NAME). Caddy and Traefik
keep the client's Host by default.
- Any loopback Host on the Console's port, `localhost` included, is the
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.
- Every hop on this host that reaches the loopback port is bound by this
contract too, not only the edge.
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.
## Use
- Waiting on you uses the board's `waitingOnYou` flag across all projects.
- Select a project on the left to filter the session table. Counts show visible
rows out of the total when Hide offline or Hide seen hides anything.
- Select an agent to open its inspector. Arrow keys move between table agent
buttons, Enter opens, and Close or Escape in the inspector returns focus.
- Seen clears the row from Waiting on you until the board detects new activity.
The collapsed Seen section keeps those rows available; Unsee returns them.
- Reply appears only where the board's registration permits it. A failure shows
the board's stderr and keeps the draft. Transport success is the board's
`delivered` receipt, not proof that the seat processed the message.
- Refresh runs the existing board scan. Automatic refresh uses the board page's
ten-second interval; Pause stops it. Failed refreshes keep the last snapshot
with a warning. There are no automatic action retries.
- Palette and appearance come from `tokens.css` (see "Shell" below) and persist
in this browser when local storage is available. Appearance defaults to
System. Settings (row 54) shows the same control and nothing else that
changes.
- History opens a read-only conversation view for a seat, from its Waiting
card, its table row or its inspector (#1507, CHAT-02). It shows the whole
branch with nothing clipped. Tool calls, tool results and thinking start
collapsed. Session text is always shown as text: Markdown stays as source,
and terminal controls, bidi controls and marks show as visible symbols. Reply in the view
uses the same board reply path as the inspector.
- The view checks for new entries on the same ten-second refresh, and Pause
stops it. It never switches on its own. A fork, a newer session for the seat
or a rewritten file each shows a marker with a button to open the other
history. Reloading after a rewrite keeps the branch the view was on; if that
branch is gone, the view opens the latest one and says so. Session lists
older sessions for the seat, and is empty while the list is read or when
that read fails. Only repository Pi seats
have history; other harnesses say so.
Drafts and receipts stay in page memory, including across refresh, inspector
changes and a stale registration. Reloading or closing the page loses them.
A pending send stays disabled across refresh; new text typed during a send is
not cleared by the earlier send's success. If the proxy loses the response,
delivery may be unknown: inspect the seat before sending again.
## Inbox, tasks, agents and trails (slice 1 S5, #1522)
The Console sidebar adds Inbox, Tasks and Agents next to the control board,
which stays at `#/`. Routes: `#/inbox`, `#/inbox/<decision>`, `#/tasks`,
`#/tasks/<vikunja:project/task>`, `#/agents`, `#/trail/task/<ref>` and
`#/trail/decision/<id>`, 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 <id> <key>` 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
business, as the Console does by default. From a Console started with
`--business` for another business the copied command refuses (no bus host,
or no such decision) rather than answering elsewhere.
- What no Q1 verb returns is labelled where it would show, for example
"Bot names: not in the Q1 module" (lead decision 63). Nothing is guessed.
- A task's list row can only say its current state came from a Vikunja read,
since the list has no trail. The task page reads the trail and tells a task
the stack never created from one changed in Vikunja after the stack wrote it.
A stale poll read is shown in the snapshots and never as current.
- All bus- and agent-authored text is rendered inert: controls show as control
pictures or `[U+XXXX]`, and Markdown stays as source.
- Views refresh on the board's ten-second interval and stop on Pause. Focus,
open sections and the copy status survive a refresh.
- A failed read keeps the last good answer for that read and says it is old,
with the time it was read. A refusal (403, `not-configured`, `no-bus-host`,
or row 54's `queue-refused`) never shows kept rows (row 53), and it drops
every kept read: the Inbox count and the palette's decisions, tasks and
queue rows go too, until a read succeeds.
A page never read, or refused, shows the failure instead,
titled "Bus refused the read" (403: the bus refused the Console's read, for
example `human-required` from a Console started inside an agent run), "No bus to read" (no system
config, or no bus host and no `--business`) or "The read failed".
## Shell (row 53, #1542)
The Console is restyled to `docs/design` (`IMPLEMENTING.md`). It is a restyle
only: no new data, route or write.
- `src/public/tokens.css` and `src/public/icons.svg` are byte copies of
`docs/design/tokens.css` and `docs/design/icons.svg`; `tests/shell.test.mjs`
fails if either differs. Edit `docs/design`, rebuild there, then copy.
- Colours come only from `tokens.css`, selected by `data-palette` and
`data-mode` on `<html>`. The page sets no colour inline. Appearance System
removes `data-mode`, so the OS light or dark setting applies; it is the
default, with Harbor. The choice is kept under `mosaic-console-appearance`.
- `shell.css` loads last and holds the restyle: radii `--r` 6px and `--r-lg`
10px, the type scale, tabular numbers, the command bar, section list, dense
table, inspector, status marks, class chips, toast, command palette and
not-found page. The earlier CSS files are unchanged.
- JetBrains Mono 400, 500 and 700 come from the official JetBrains release
(v2.304); `assets/fonts/jetbrains-mono-sources.txt` has the URL and sha256
of the archive and each file, and `jetbrains-mono-OFL.txt` the licence.
Manrope has no 800 file here, so 800 headings render at 700.
- Ctrl+K (or the search field) opens the palette over views, inbox decisions,
tasks and (row 54) queue rows from the last queue read; arrows move, Enter opens, Esc closes and returns focus. Arrow
keys also move between task links in the bus tables.
- Copy reports in a toast. If the clipboard is unavailable, the toast stays
until dismissed and the command is selected for Ctrl+C. When no toast shows,
the toast is an empty live region, visually hidden but still in the
accessibility tree, so the first copy is announced.
- The command bar shows, in UTC, when the board was scanned and the bus read,
and says when the last attempt failed. A board refusal drops the last scan
and everything drawn from it: the cards, the table, the inspector, the
project filter and tree, the counts, the footer and the scan line all say
the board was not read. A failed read with no scan held, after a refusal or
before the first scan, shows the board empty and says the read failed; it
never brings back rows from a refused scan. A refusal also closes an open
conversation and clears its history. If that removes what had focus, focus
moves to the page.
- From 760px the footer is a one-line status bar and the side column fits
between it and the command bar. Below 760px the section list is one strip
that scrolls inside itself.
## Queue, Business and Settings (row 54, #1543)
Three read-only views, in the section list and the palette. Each has the
same five states as the bus views: loading (the skeleton names the source),
normal, empty, a failed read over kept data (labelled stale, with its time),
and a refusal that shows nothing kept. The command bar names the source:
"Queue read …", "Business not read: refused". A refusal shows the code in
mono and the module's own message, redacted by the server.
- `#/queue` lists every row with its state, owner, reviewers, brief and last
update, filterable by state. `#/queue/<id>` adds the gate, issues, claim,
`after`, note and review rounds. Rows move only with
`scripts/mosaic queue move` in a terminal; the row page shows that command
with a Copy button and has no button that moves a row. The brief is shown
as its path and anchor, because Console serves no repository files.
- `#/business` shows the business named by `--business`, else the running
bus host's business: the human, the arbiters as instance and kind ("pm
(delivery), cto (technical)"), projects, launch rules, the vars
on an allowlist, each role instance with its holder and harness, the
authority of each instance for each action as the business package
classifies it (`resolveInstance`, `classify`), and credentials as
metadata only: service, account, role, expiry or rotate-by date, and a
state worked out from the date alone. A missing or invalid business file
is a `not-configured` refusal with the business module's own text.
- `#/settings` shows appearance (the row 53 control, kept in this browser),
the notifier binding by name only, and about: the Console's loopback
address, `RELEASE`, the business and the board URL. A Settings refusal
still shows appearance, because it needs no read.
Console never reads a token, a token file or a credential variable, and no
response, page or log carries one, a file path or a Discord channel, guild or
user id. The server redacts every message and string field before it answers
(`redactor` in `src/reads.mjs`): the config directory becomes `<config>`, the
data root `<dataRoot>`, any other absolute or `~/` path `<path>`, a 17 to 20
digit run `<id>`, and a JSON parse error's quote of the file is dropped. The
startup log goes through the same redactor. A path starts at the beginning
of the text or after a space, an opening bracket, a quote, `=`, `,`, `<` or
`:`, so `file:/x` and `file:///x` are redacted and `https://host/x` is not.
Known gap: a path stops at the first space, so a path containing a space is
redacted only up to that space and the rest shows. A bare `~` or `~user/`
is not treated as a path, and neither is a relative path.
## Data and boundaries
GET `/api/board`, `/api/conversations` and `/api/conversation`, and POST
`/api/seen` and `/api/reply` proxy only those board paths. Only the two
conversation routes carry their query string; the board validates it. POST
bytes and upstream status/JSON are preserved. GET `/api/config` returns the
configured board URL for the page's error message. No scanner, registration,
session reader or transport is implemented here. The session reader is
`packages/conversation`, served by the board.
GET `/api/bus/inbox`, `/api/bus/tasks`, `/api/bus/agents`,
`/api/bus/trail?subject=<id>` and `/api/bus/decisions?list=roles|recent` are
the bus reads (`src/bus.mjs`). Any other method is 405. The subject must match
the broker's identifier rule, and the list must be `roles` or `recent`, else
400. `decisions` (row 57, #1546) is for the Inbox tabs "Routed to roles" and
"Recent"; "Human and reader paths" in `packages/bus/README.md` has what each
list holds.
Each read runs the S4 human transport (`packages/bus/src/human-cli.mjs`) as
a child process, so a slow read doesn't stall the server. The bus checks
that child's ancestry, which ends at the Console process; it never sees the
browser or any other client of the port. So while the Console runs,
anything that can connect to its port (7330 by default) reads what a reader
capability reads ("Human and reader paths" in `packages/bus/README.md`):
the inbox, tasks, agents, trails and the two decision lists. 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
(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
(`~/.config/mosaic-dev/config.json`); without it the board still serves and
the bus routes answer `not-configured`.
GET `/api/queue`, `/api/queue/<id>`, `/api/business` and `/api/settings` are
row 54's reads (`src/reads.mjs`), under the same Host, Origin and CSP rules.
Any other method is 405, and a queue id that is not a positive integer is
400. The queue is read in a child process (`src/queue-read.mjs`) through
`rows()` from `packages/queue/src/store.mjs` (lead decision 83): the same
read as `queue list`, with no write and no network. Like `list`, it takes
the queue lock only to recheck a disagreement before reporting it. The child
imports the store inside its `try`, so a store that fails to load reports
"the queue read failed". Only the store's exit 2 (invalid data or refused)
forwards the child's text, as `queue-refused`; any other exit is the fixed
`read-failed` message "the queue read failed (exit N)", because that stderr
may be Node's own, with a `file://` path and a stack. The business
read uses `packages/business` in process. No read writes, calls out or adds a
dependency. Answers carry `at`; failures are `{ error, message }` with 503
for `not-configured`, `no-bus-host`, `queue-refused` and `read-failed`, 404
for a row that isn't there, 400 for a bad address and 502 otherwise. Settings
answers 200 even without a system config, so appearance always works; its
notifier part then says `not-configured`.
Console's shared CSS, Console CSS, brand.js and local Manrope fonts were copied
unchanged from `agents/dewey/work/wui/`. Font license and source URLs accompany
the files under `src/public/assets/fonts/`. `live.css` contains the live-page
adaptations; original mockups and unrelated pending design work stay untouched.
Unsupported mockup fields and controls are listed in `docs/plans/DEFERRED.md`.
The source tree is a project filter; no invented workspace registration tree
or mockup session hierarchy is presented as live data.
## Verify
```sh
node --test packages/webui/tests/
node --test packages/control-board/tests/ packages/seat/tests/ packages/ledger/tests/ packages/mosaic/tests/
WEBUI_EVIDENCE=/tmp/webui-evidence node --test packages/webui/tests/browser.test.mjs
WEBUI_EVIDENCE=/tmp/webui-evidence node --test packages/webui/tests/conversation.test.mjs
WEBUI_EVIDENCE=/tmp/webui-evidence node --test packages/webui/tests/bus-browser.test.mjs
WEBUI_EVIDENCE=/tmp/webui-evidence node --test packages/webui/tests/shell.test.mjs
node --test packages/webui/tests/reads.test.mjs packages/webui/tests/views.test.mjs
```
Node's test runner and installed `/usr/bin/chromium` are required. Set `CHROMIUM`
to another installed Chromium path. No npm download is needed. The copied CDP
helper starts a separate temporary browser profile and removes it on exit.
Tests use temporary board session/registration files and stub the board's
transport. They never send to real seats or read real session logs.
Browser tests exercise the rendered real board fixture, exact counts, project
filtering, keyboard return focus, Seen/Unsee, success/failure receipts, draft and
caret preservation, pending-send exclusion, storage denial, stale registration,
loading/empty/malformed/unreachable states and hostile text. Contrast is measured
on 330 rendered samples across ten palettes and three modes. Layouts are checked
at 320, 390, 768, 1440 and 2560px. Horizontal scrolling is intentional within the
dense table; the page itself must not overflow.
Conversation tests run the real board routes over temporary repository-layout
session files. They cover full-length answers, collapsed tools and thinking,
hostile content rendered inert, malformed-line and reconcile markers, forks, and
the return flow: a send from the view, a tool call and a delayed result while a
draft is typed, a peer message, a 4.5-million-character answer split into
continuation parts, and a relaunch mid-turn.
Bus tests (`bus.test.mjs`, `bus-browser.test.mjs`) run the routes and the
views against an in-process broker read through its reader session, so every
row has the broker's own shape. `humanCall` runs against a stub CLI script.
They cover each view, the gap labels, inert hostile text, Copy, the stale
fallback and each failure title, and that no route writes. They never start
the human CLI against a live bus.
Shell tests (`shell.test.mjs`) check the copies, the font hashes and the kept
strings, and in the browser at 400px: System mode under an emulated dark and
light OS, the empty, stale and refused states of the board and the bus views
(a failed first read drawn empty, a board refusal with the inspector open and a project picked, then a failed
read, a failed session list, and a refusal with a conversation open and where
focus lands after it), not found, the palette, the toast,
keyboard and visible focus, the section strip at 360 and 400px, no sideways
scroll, no script error and no request other than GET. A second test walks
the Board, Inbox, Tasks, Agents and a decision trail through loading (a held
read), normal, a failed read over kept rows, and a refusal by the bus
(`human-required`) and for want of one (`no-bus-host`). It then checks that a
refusal empties the palette and the Inbox count, from a view, from the
palette's own reads and from the board page, and that palette labels are text.
Row 54's tests share the seeded fixture in `reads-fixture.mjs`: a scratch
queue whose row note names an id and token paths shaped like real notes
(absolute, `~/` and after `file:`; the store refuses `<` in a note, so the
`<…>` case is in the redactor test), and a business,
notifier config and Discord binding that hold token files, an environment
variable name, an agent `model` var holding a path, and guild, channel, bot
and user ids. Row 9's After entry has the `{id, when}` shape the real queue
stores. `reads.test.mjs` checks each
route's body and status, the redactor, the credential state, methods and
Origin, and that no body carries any seeded value or the temporary directory;
that a `store.mjs` with a syntax error, or none, gives the fixed message with
no path; and that the child's timeout, output cap and non-2 exits hold, over a
fake child script.
`views.test.mjs` walks Queue, a queue row, Business and Settings through the
five states over a stub read, with each refusal code the view can meet, at
400px; checks that a refusal leaves no queue row in the palette, that no view
has a control but Copy and Read again, and that the row page shows the
`queue move` command, that the 10 s refresh keeps focus on a Settings mirror
select, and that the palette keeps its queue rows after its own inbox and tasks
reads answer; then, over the seeded fixture, that the Arbiters line reads the
business file's object, that neither the page nor
any response it read carries a seeded value, that a missing or invalid
business file shows the module's text, and that the startup log names no
config path.
No root CI workflow is configured for this package. These local tests are not a
claim of CI, deployment, live-seat delivery or user acceptance.
## User test and rollback
Gate E belongs to Jason on Monday 2026-09-14. Use Console to find who is waiting,
open a registered repo seat, reply and see its next answer after a scan. Mark a
completion Seen and find it again in Seen. Record any reason to open the board's
own page or a repo seat terminal in DEFERRED.md. Pass is Jason's end-of-day
say-so, not a test result. Keep #1507 open pending that ruling.
Rollback requires only stopping the WebUI and using the unchanged board at 7331.
Revert the scoped WebUI commit to remove it; there is no data migration to undo.
Seen and reply actions already taken belong to the board and are not rolled back.