sage: briefs for rows 176 (#1674, Console tester listener on 127.0.0.1, 7332 refuses x-mosaic-tester, mos-0872/0874), 177 (#1675, tester threads, scoping) and 178 (#1676, Traefik proof for signed-in requests, Vikunja #385, mos-0874)

This commit is contained in:
2026-10-19 17:32:16 -05:00
parent 835941b7c4
commit 6ee7285c0d
3 changed files with 258 additions and 0 deletions
@@ -0,0 +1,94 @@
# Brief: the Console checks that a signed-in request came through Traefik
Vikunja #385, Gitea #1676. Owner and reviewer are set at briefing. Gate owner Sage. Not on the window path (mos-0874).
Line numbers are at origin/refactor 835941b7c.
## The hole
In preview mode, the Console treats a request as Jason's when the Host is the public name and the auth header (live:
`X-authentik-username`) holds exactly the allowed user (`packages/webui/src/serve.mjs:453`, :466-469). Traefik and
authentik set that header after sign-in and strip client copies. But the tunnel delivers every public request to
127.0.0.1:7332 (`R:0.0.0.0:7332:127.0.0.1:7332`), so the Console can't tell Traefik's requests from a local
process's. Any process on dragon-lin that can open 127.0.0.1:7332 can send `Host: <public name>` and
`X-authentik-username: jason.woltje` and get Jason's sends, saves and settings writes. An agent can too, which gets
around the human-shell guard (decisions 62 and 94).
## The design
Traefik adds a proof header with a secret value to every request it forwards to the Console. The Console refuses a
signed-in request without the right value.
### `packages/webui/src/serve.mjs` and `cli.mjs`
- New serve flags `--proof-header NAME` and `--proof-file PATH`, given together and only with the preview flags.
Either one alone refuses to start (exit 2).
- The Console reads PATH once at start. It refuses to start (exit 2) unless the file is a regular file owned by the
service's uid, has mode 0600 or 0400, and holds one line of at least 32 bytes from `[A-Za-z0-9_-]`. The value
never appears on argv, in a log line, in an error message or in `/api/config`.
- On a request with the public Host, the proof check runs before the auth header is read. It needs exactly one copy
of NAME, and the value has to match by `crypto.timingSafeEqual` over equal-length buffers. Anything else answers
401 `proxy proof required`, with no bus call and no board call. A missing value and a wrong value get the same
answer.
- A request with NAME and any other Host refuses 403, as the auth header does at :456.
- The tester listener from row 176 refuses NAME 403.
- Without the flags, the startup line says `proxy proof off`, so the live unit's state is visible in its journal.
### `scripts/webui-service.sh` and `scripts/stack-release.sh`
- Both flags join FLAGS, with the same checks. `render` refuses a PATH inside the repo or a copy, and a PATH it can't
stat. It never reads the file.
- `stack-release.sh` carries both flags through a re-pin and a rollback unchanged (`P_CONSOLE`).
- Update `packages/webui/README.md`, `docs/TOOLS.md` and the unit template's header comment.
### What this stops and what it doesn't
The proof stops any process that can't read the proof file: another uid on the host, a hostNetwork pod on the
k8s-woltje node (Cilium `AllowLocalhost=always`), and a sandboxed worker, since `run-task.sh` workers get no host
home.
It doesn't stop a process running as the Console's own uid. The Console runs as a `systemctl --user` unit under
jwoltje, and every T3 seat runs as jwoltje too. A seat can read any file the Console can read, and `ptrace_scope` 1
doesn't stop that. Closing that part means the Console runs under a uid the seats don't have, which is a host and
service change, so it's Jason's (Q173: infra). This row doesn't make that change. The brief records it, and the issue
stays open until Jason rules on it.
### Jason's steps, not this row's
- The secret's home. A file outside git, mode 0600, outside every repo and copy, written by Jason. Its path goes in
the unit through `--proof-file`. No agent writes or reads it, and it never goes in a message.
- The Traefik middleware that sets the header on the Console's route (`headers.customRequestHeaders`, which replaces
a client copy). That's k3s, so it's Jason's. The value has to match the file.
- Turning it on in the live unit. That changes who can reach the Console from outside (it narrows it), so it waits
for Jason under Q173. The order is middleware first, then unit flags, then a signed-in check. Rollback drops the
unit flags.
## Tests
1. A named request with the right proof and auth header passes, and the reads and the send path still work as before.
2. A named request with no proof, a wrong proof, a proof one byte short, a repeated proof header, or the proof in
another case of header name gets 401 with no bus call and no board call. Assert that the body never contains the
value.
3. NAME with a loopback Host or any other Host refuses 403. NAME on the row 176 tester listener refuses 403.
4. Start-up refusals: one flag without the other; the flags without preview mode; a missing file, a directory, a
symlink, mode 0644, another owner, a short value, a value with a newline in the middle or a bad character. Each
one exits 2, and stderr never shows the value.
5. The value never shows in the request log, in `/api/config`, or in `ps` output for the serve process. The test reads
`/proc/<pid>/cmdline`.
6. `webui-service.sh render` puts both flags in ExecStart, refuses a PATH inside the repo, and doesn't open the file.
`stack-release.sh` keeps both flags across a re-pin and a rollback.
7. A timing check isn't a gate test. The code uses `timingSafeEqual`, and a review grep confirms it.
Mutants, each killed by some gate test (mos-0862):
- the auth header is read before the proof check
- a wrong proof with the right length passes
- a missing proof passes when the flags are set
- the proof is accepted with a loopback Host
- mode 0644 accepted
- the value is logged on refusal
## Gate
The reviewer approves the candidate manifest. Webui and cli suites and every `scripts/test-*.sh` green on Sage's
gate. Landing changes nothing live. The live unit gets the flags only in a window Jason runs or approves, after the
middleware.
@@ -0,0 +1,126 @@
# Brief: a loopback-only Console listener for the tester
Gitea #1674. Owner Rocko, reviewer Dewey, gate owner Sage. Starts after rows 169 and 175 are done: it uses 175's
tester writes and touches `scripts/webui-service.sh`, which 169's window path reads. Line numbers are at
origin/refactor 1c90c68c5.
## Why
DECISIONS Q173 makes the dragon-lin mosaic-stack business a test system that agents test end to end, as the tester
and never as Jason (mos-0870 N3). The merged window's browser check has to run against the Console without Jason
signing in. Mos's design (mos-0872) is a second listener for the tester, because the loopback Host check on 7332
can't tell a tester call from a public one:
- `mosaic-preview-tunnel` runs chisel with `R:0.0.0.0:7332:127.0.0.1:7332`, so all public traffic reaches the
Console from 127.0.0.1 with whatever Host the client sends.
- Cilium's `AllowLocalhost=always` lets the k8s-woltje node's hostNetwork pods reach 7332 too.
The namespace `woltje-mosaic-preview` has a default-deny policy and a tunnel policy that admits 7332 only from
namespace traefik, and ops-01 confirmed it blocks other pods (Vikunja #384 comment 285, closed; mos-0874 corrects
mos-0872 on this). That doesn't change the design.
So the tester gets its own listener on 127.0.0.1 and a port the tunnel doesn't forward, and 7332 refuses anything that
asks to be the tester. Who can reach the Console from outside doesn't change: the tunnel still forwards 7332 only.
Other users on the host can reach a loopback port, as they can reach 7332's loopback path today; that is P19 333 and
this row doesn't change it. A local process that forges the preview's auth header on 7332 is Vikunja #385, row 178,
and not this row.
## The change
### `packages/webui/src/cli.mjs`
- New flag `serve --tester-port N`. N is a whole number in 1024..65535, not equal to `--port`, and not 7332 whatever
`--port` is, since the tunnel forwards 7332. Anything else refuses to start (exit 2).
- `--tester-port` needs the bus configured, as `--send` does, and refuses to start without it.
- With the flag, `serve` starts a second `startServer` in the same process on `127.0.0.1:N`, whose bus caller is
`busCaller({ ..., cli: TESTER_CLI })`. `TESTER_CLI` is exported from `packages/cli/src/transport.mjs:13`, and
`busCaller` already takes `cli` (`packages/webui/src/bus.mjs:60`), so `bus.mjs` doesn't change. `tester-cli.mjs`
sets its own nonce (`packages/bus/src/tester-cli.mjs:10-16`).
- The tester listener's writes are the tester writes from row 175 only: `instancesWrite` (`instances.set`),
`routesWrite` (`routes.set`) and `nameWrite` (`name.set`). `send`, `settingsWrite`, `projectSave` and
`assistantStop` stay null on it, and so does `chat` until row 177.
- The startup lines name the tester listener's address and say it binds the tester.
### `packages/webui/src/serve.mjs`
- `startServer` takes `tester: true` for the second listener. With it, `preview` must be null (refuse to start
otherwise), and every request needs the loopback Host on the listener's own port, which the non-named check at
:458-461 already does.
- On the tester listener, `/api/instances`, `/api/routes` and `/api/name` pass without `named`, and take the same body
checks as the human's routes plus Origin `http://127.0.0.1:N` and `Sec-Fetch-Site: same-origin`. Their log lines
say `user="tester"`.
- On the tester listener, `/api/settings` POST, `/api/project-file` POST, `/api/assistant-stop`, `/api/bus/message`
and every `/api/chat` write refuse 403 with no bus call.
- The tester listener refuses 403 any request that carries the main listener's `--auth-header` header, so a
misrouted proxy request never binds the tester.
- Every listener that isn't the tester listener, 7332 included, refuses 403 any request with an `x-mosaic-tester`
header, whatever the Host, before any other check reads the request (mos-0872).
- `/api/config` on the tester listener answers `tester: true`, `send: false`, `edit: false`, and the page shows a
tester banner and the instance, routes and name editors only.
- A broker refusal of `tester-off` or `tester-required` maps to 403 in `busStatus`, and the page says the tester is
off.
### Bus reads the pages need
The Console reads `inbox`, `tasks`, `agents`, `trail`, `decisions`, `credentials`, `replies`, `messages`, `usage`,
`limits`, `names`, `instances` and `routes` (`serve.mjs:59`), plus `project.files` and `project.file` for the docs
page. Row 175 leaves `credentials`, `routes`, `project.files` and `project.file` refusing a tester. This row adds
them as tester reads in `packages/bus/src/broker.mjs`:
- `credentials` joins `TESTER_VERBS`. It already runs for a reader (:2158), and `#secretCheck` still runs on the
result.
- `routes` passes the organization branch for a tester, as row 175's three reads do.
- `#requestProjectFile` (:1400) refuses a tester through `this.#human(s)` (:1404). Let a tester read `project.files`
and `project.file`. `project.file.save` stays
human-only (`project-spec-human-only`).
### `scripts/webui-service.sh`
- `--tester-port N` joins FLAGS, with the same checks as serve's, rendered into ExecStart. `render`, `install` and
`status` show it.
- `scripts/stack-release.sh` carries the pointer's Console flags as an array (`P_CONSOLE`, :96, :704, :708). Check it
carries `--tester-port` through a re-pin and a rollback unchanged, and add the test.
- Update the header comment, `docs/TOOLS.md` and `packages/webui/README.md`.
### Untouched
Row 164 pins `packages/bus/src/human.mjs`, `packages/bus/src/human-cli.mjs`, `packages/cli/src/transport.mjs` and
`packages/webui/src/bus.mjs` (`packages/bus/tests/channel-proof.test.mjs:11-19`). Don't touch them. If one has to
move, stop and tell Sage. Before in-review, grep the tests for each changed path and for a 64-hex pin on it.
## Tests
1. 7332's refusal: a request with `x-mosaic-tester` on the main listener refuses 403 with a loopback Host, with the
preview's public Host and auth header, and with any other Host. No bus call, no board call.
2. The tester listener binds 127.0.0.1 only. `--tester-port` equal to `--port`, equal to 7332, below 1024, above
65535 or not a number refuses to start, and so does a missing bus config.
3. The tester listener refuses a non-loopback Host, a Host with another port, and the main listener's auth header,
each with 403.
4. Every bus read on the tester listener goes through `TESTER_CLI` (assert the `cli` the caller spawns), and none
through `HUMAN_CLI`.
5. Against a real broker fixture with `bus.tester` true: an instance change, a routes change and a name set through
the tester listener land with actor `tester` (receipt) and `by = 'tester'`.
6. The tester listener refuses settings save, project-file save, assistant stop, bus message and chat writes with
403 and no bus call.
7. With `bus.tester` off, the tester listener's reads answer 403 `tester-off`.
8. A tester reads `credentials`, `routes`, `project.files` and `project.file`, and still refuses `project.file.save`.
9. `webui-service.sh render --tester-port 7333` puts it in ExecStart, and refuses 7332, the main port and bad values
(exit 4). `stack-release.sh` keeps `--tester-port 7333` across a re-pin and a rollback.
10. A browser test (the webui browser harness) opens the tester listener, sees the tester banner, changes an
instance's route, and the change lands as tester.
Mutants, each killed by some gate test (mos-0862):
- drop the `x-mosaic-tester` refusal on the main listener, or check it only for a loopback Host
- the tester listener calls through `HUMAN_CLI`
- the tester listener lets a settings save or a project-file save through
- `--tester-port 7332` accepted
- the tester listener accepts the main listener's auth header
- `credentials` dropped from the tester reads, or `project.file.save` let through for a tester
## Gate
Dewey approves the candidate manifest. Bus, cli, webui and business suites and every `scripts/test-*.sh` green on
Sage's gate. The merged window adds `--tester-port 7333` to the Console unit after a store backup, and the window's
browser check runs against http://127.0.0.1:7333/. Before it does, the window reads the tunnel's forward spec and
stops unless it still forwards 7332 only.
+38
View File
@@ -0,0 +1,38 @@
# Brief: tester threads (scoping)
Gitea #1675. Proposed owner Dewey, reviewer Filbert, gate owner Sage. Starts after row 176 is done. Line numbers are at
origin/refactor 835941b7c. This is a scoping brief. The owner writes the design into the packet, and Sage approves it
before any code.
## Why
Q173 says agents test the test system end to end as the tester (mos-0870). The Console's chat page and the window's
W6/W7 checks need a thread conversation. Every thread verb in `packages/bus/src/broker.mjs` is human-only today:
`thread.create` (:2282), `thread.list` (:2306), `thread.open` (:2320), `thread.send` (:2327) and
`thread.messages` (:2354) each start with `this.#human(s)`. They record the human as the thread's owner,
`thread_opens.by` and the message's `from_role 'human'` and `from_run 'human-cli'`.
## What the design has to settle
1. A tester thread's owner is `tester`, and a tester sees and opens only threads it owns. The human's thread list
leaves tester threads out, or labels them. Pick one and say why.
2. `thread.send` from a tester stores `from_role 'tester'`, and `from_run` is the tester CLI, never `human-cli`. Row
120's messaging lists have to allow tester to instance. Say whether that is a business-file entry or a broker
rule.
3. Replies on a tester thread route to `tester` and never to the human's inbox. The human's view shows them as tester
traffic.
4. Whether a tester gets `assistant.stop` on its own threads only. Never on the human's.
5. The `human.input` event on `thread.send` (:2351) has to say tester, or a tester thread needs its own event kind.
Check what the launcher and the runner do with the event first.
6. The Console's tester listener (row 176) gets `/api/chat` for tester threads only, and 7332's chat stays the
human's.
7. DDL: read `threads`, `thread_opens` and `messages` in the migrations for CHECKs before saying no schema change.
## Untouched
The row 164 pins (`packages/bus/tests/channel-proof.test.mjs:11-19`). Row 175's tester writes and row 176's
listener, except where point 6 adds chat.
## Gate
Set in the design. At least the bus, webui and conversation suites and every `scripts/test-*.sh`.