docs: briefs for the model proxy rows P1 to P5 (Vikunja 237, mos-1600): P1 package, pinned fetch with sha256 refusal, mosaic proxy init writing proxy.json once beside config.json (invariant 2) and the key through the human transport, unit; P4 Console /proxy/ forward, owner-only, in a CSP sandbox with a stop condition; P2 per-agent keys, reconcile, preflight; P3 harness env; P5 usage plug-in, per-key count only; doc only

This commit is contained in:
jason.woltje committed 2026-10-24 14:04:26 -05:00
1 parent 06f507dbc0
commit 7fe32d7226
1 file changed
+308
+308
View File
@@ -0,0 +1,308 @@
# Briefs: the model proxy build rows P1 to P5
Written 2026-10-24 by Sage for Mos's mos-1600, which approved the Vikunja 237 design
(`docs/plans/2026-10-24_stack-model-proxy.md`, 9a50f082f) with changes. Read the design first. These briefs
cite its decision numbers (D1 to D20) and carry Mos's changes, which win over the design where they differ.
Line numbers are at origin/refactor 06f507dbc. Check them before you edit.
Every row is a Codex gpt-6.1-sol build on its own new thread and worktree off refactor (mos-1542). A separate
Codex thread gives the first review and a Claude reviewer gives the second, because every row touches
credentials or the Console's security boundary. Sage runs the landing gate.
## Rules for every row
- **Secrets and the live proxy.** No agent reads `<configDir>/secrets/proxy-management.key` or anything under
a real instance's `<dataRoot>/proxy/` (`config.yaml`, `auth/`, `logs/`). That covers Mos, Sage, every
builder and every reviewer (mos-1600 ruling 2). Tests and acceptance runs use their own made-up config,
key and data root in a fresh temp root. The host proxy on 127.0.0.1:8317, its binary under
`~/.local/opt/EasyCLIProxyAPI` and its config are never started, stopped, read, copied or called. No real
upstream credential and no request off the machine except P1's pinned fetch.
- **No live change.** No row edits a live instance config, starts `mosaic-proxy`, restarts a bus or Console,
or changes `claude-session.env`. Cutover is P7, with Jason present.
- **Refusals.** Every new fail-closed refusal gets its own test, by its code (mos-1590). A red in an old test
goes to Sage before any rerun, including a rerun you expect to be timing (mos-1592).
- **Runs.** TMPDIR and nice as BRIEF-TEMPLATE's Gate says. All test runs are slot work: ask Sage for CLEAR.
Design notes and receipts go in `agents/<seat>/work/queue-<row>/BUILD.md`. Report to Sage's thread.
## Order
P1 and P4 start now. P4 builds against the config contract in P1 item 3 and rebases on P1 before its
gate, and P4 lands after P1. P2 starts when P1 lands. P3 and P5 start when P2 lands and run in parallel.
P6 waits for row 157 to resume. P7 waits for P1 to P4 and for Jason.
## Brief: P1 the model-proxy package, pinned fetch, proxy init and the proxy unit
**Why.** D1 to D6 and D11's sentinel need a pinned binary, a config the stack writes once, a management
client that only the bus uses, and a human-only way to set the management key. Mos dropped J1 and merged J2
into J3: a row does the fetch, and `mosaic proxy init` writes the proxy settings, so Jason never edits a
file by hand (mos-1600 rulings 1 and 7, Q126).
**What ships.**
1. **`packages/model-proxy/`**, a new package with its own `tests/`, so the gate's package loop picks it up.
2. **PIN and the fetch.** `packages/model-proxy/PIN` names the CLIProxyAPI version, the linux-amd64 release
asset, the control panel asset (`router-for-me/Cli-Proxy-API-Management-Center`) and the sha256 of each.
- Pin the newest release that the acceptance run below passes on. Record in BUILD.md which release that
is, and whether it is 8.0.27, the version row 96 tested.
- Record each hash from the release as downloaded. The reviewer downloads the same assets and measures
them independently.
- A fetch step (`mosaic proxy fetch`) downloads the pinned assets from the release URLs only and
verifies each against PIN. It installs them under `<dataRoot>/opt/cliproxyapi/<version>/`, outside
`<dataRoot>/proxy/`. A hash mismatch, a redirect off GitHub's release hosts, or an existing target
with different content refuses, and a refusal leaves nothing installed.
- The gate's tests use a local fake file server. Only the builder's and the reviewer's own measurement
runs touch GitHub.
3. **The proxy settings, written by `mosaic proxy init`.**
- Invariant 2 says config.json is created only by bootstrap and never overwritten. So init doesn't touch
config.json. It creates `<configDir>/proxy.json` once, exclusively (`wx`, the same rule as
`createExclusive` in `scripts/mosaic-config.mjs`:398), with `{ "port": N, "binary": "<path>" }`.
- The loader (`scripts/mosaic-config.mjs` `validate` :293 and `load` :362) reads it when present, checks
it, and returns it as `proxy` in the validated config. A missing file means no proxy, which is today's
behaviour. A present file with an unknown key, a missing field, a port outside 1024 to 65535, port
8317, or a binary that isn't the pinned install path refuses.
- The port is a setting with a default. Init picks a free loopback port other than 8317 unless the human
names one, and refuses a port that something already listens on.
- The binary path is the one the fetch installed. Init refuses if the fetch hasn't run or the installed
file doesn't match PIN.
- The config contract for the other rows is "the validated config has `proxy.port` and `proxy.binary`".
P4 reads `proxy.port` from it and nothing else.
4. **The management key.** Init is human-only. It prompts twice with no echo. It sends the key through the
bus's human transport with the path-bound proof (`packages/bus/src/human-cli.mjs`, #225), the same way
the human-only settings verbs work (`packages/bus/src/runtime.mjs`:158). A tester or agent caller
refuses. The bus writes `<configDir>/secrets/proxy-management.key` (0600, exclusive) and the initial
`<dataRoot>/proxy/config.yaml` (0600, directory 0700, `auth/` 0700, `logs/`), once (D5, D6). Running init
again refuses and changes nothing. No verb, log or error returns the key.
5. **The config template** sets these values:
- `host: 127.0.0.1` and `allow-remote: false`;
- `disable-auto-update-panel: true`, with the panel from the pinned install;
- `request-retry: 0`;
- D10's routing defaults (fill-first, session affinity on, rebind on quota, no alias pools);
- a client-key list holding only the sentinel (`msk-<tag>-sentinel-...`, D11);
- the management key.
6. **The management client** (`packages/model-proxy/src/client.mjs`) is used by the bus alone (D12). It
GETs and PUTs the v8 client-key list and never uses the v0 `DELETE ?value=` (D14). It sends the
management key only in the `Authorization` header and only to loopback. It logs a receipt per call
(call, status, key ids as the first 12 hex of sha256) and never a key value or a list body.
7. **The unit.** A user unit `mosaic-proxy.service` (named instance: `mosaic-proxy-NAME.service`), from a
template beside `packages/cli/systemd/mosaic-bus.service.in`. `scripts/instance.sh` gets a `PROXY_UNIT`
name next to `CONSOLE_UNIT` (:84). The unit's start step refuses a binary whose sha256 doesn't match
PIN. The bus unit gets `Wants=` and `After=` on it only when the instance has a proxy. Tests render and
check the unit in a temp root. Nothing is installed or started for real.
8. **Correction to D3.** At boot the proxy is already listening on its port, so "refuse if another process
listens" can't work as the design wrote it. Init refuses a port in use. At boot, P2's sentinel check
(D11) is what proves the listener is this instance's proxy.
**Acceptance run, recorded in BUILD.md.** This runs the pinned binary under `unshare -rn` against a
stand-in upstream, the way row 96 did (`agents/researcher/work/i1-proxy-test/`). It lives in a script
outside the gate's `scripts/test-*.sh` set, and its output goes in BUILD.md as plain text. It must answer
each of these:
- what the proxy does with an empty client-key list (D11);
- whether `GET /v1/models` with a client key is answered locally, with no upstream call (D17);
- the body and status for a revoked or wrong key, and what Claude Code does with it (row 96 point 4 and
note b), recorded only, with no shape fix;
- whether the version can restrict a client key to some models (design section 3, for P2);
- whether the pinned panel works under a `/proxy/` sub-path through a minimal loopback forward, and under
`Content-Security-Policy: sandbox allow-scripts allow-forms` with no `allow-same-origin` (needed by P4);
- what redirect or callback URL the panel's OAuth start returns for Claude and Codex, and whether that
callback can come back through `/proxy/`. If it can't, J3 gets the proxy's CLI login in Jason's own
shell as the fallback, and BUILD.md gives the exact command (mos-1600 ruling 3).
**Out of scope.** Per-agent keys (P2), harness config (P3), the Console forward (P4), usage (P5), any live
instance, and the 8317 proxy.
**Gate.** Boyd gives the first Codex review, and re-measures both hashes from the release. Dewey reviews the
landing candidate. The new package suite runs whole, `scripts/test-config.sh` and every touched test file
run whole, and every package suite but queue plus every `scripts/test-*.sh` is green on Sage's landing gate.
BUILD.md has the acceptance output.
## Brief: P4 the Console forwards /proxy/ to the instance proxy in a sandbox
**Why.** D8: Jason reaches the panel at `https://mosaic.woltje.com/proxy/` behind the Console's existing
sign-in, with no new DNS name (Q116). The Console never injects or stores the management key (mos-1600
ruling 3).
One risk the design missed: the forward serves the panel's HTML and scripts on the Console's own origin.
Those scripts would pass the Console's same-origin checks (`Origin` equal to its own and
`sec-fetch-site: same-origin`, `packages/webui/src/serve.mjs`:463-464) and could call every Console
write route as the owner. The session cookie is HttpOnly (`packages/webui/src/sso.mjs`:19), but a
same-origin fetch still carries it. A bug in the third-party panel would become a bug in the Console.
So the forward puts the panel in a CSP sandbox with an opaque origin, and the panel never runs as the
Console.
**What ships.**
1. **The route.** `/proxy/` and below on the human listener is forwarded to `127.0.0.1:<proxy.port>` (P1
item 3's contract), and only when the validated config has `proxy`. Otherwise it answers 404
`not-configured`.
- The route is owner-only. It is refused for a granted user (before `userRoute`, `serve.mjs`:745), on the
tester listener and on a loopback Host.
- It needs the same sign-in as other owner pages: a session under `--sso`, or the preview header equal
to the user.
- The path mapping (prefix strip or base path) follows P1's acceptance result for the pinned panel.
Until P1 has it, use a fake upstream and record which mapping you built.
2. **Requests.**
- Strip the Console session cookie, every `x-authentik-*` header, the preview header, the proof header
and `x-mosaic-*` before forwarding.
- Pass `Authorization`, the management key the panel sends, through unchanged. Never log it, store it or
add one.
- Only the panel's own paths and its management API paths go through. Anything else under `/proxy/`
answers 404.
3. **Responses.**
- Every forwarded response gets `Content-Security-Policy: sandbox allow-scripts allow-forms` with no
`allow-same-origin`. It also gets the Console's `nosniff`, `no-referrer` and `frame-ancestors 'none'`.
- Upstream `Set-Cookie` headers are dropped.
- The panel then runs with an opaque origin, so its calls arrive with `Origin: null`. The forward
accepts that only on `/proxy/` management paths. Every Console route keeps refusing it, and a test
proves that.
4. **Stop condition.** First check on the stand-in whether the pinned panel can sign in and list keys while
sandboxed and while the sign-in cookies stay SameSite=Lax. If it can't (for example it needs
localStorage, or its API calls lose the sign-in), stop and report to Sage with the evidence. Don't ship
an unsandboxed forward. A subdomain is Jason's call (Q116), and a forward without the sandbox is a
security boundary change that needs Mos.
5. **Logs.** Each forwarded call logs method, path, status and user, and never headers or bodies.
**Tests.** Cover each of these:
- owner allowed, and a granted user, the tester listener, a loopback Host and a signed-out request each
refused;
- not configured;
- headers stripped;
- `Authorization` passed through and absent from logs;
- the CSP sandbox and the dropped `Set-Cookie` on every response;
- `Origin: null` accepted on `/proxy/` and refused on a Console write route;
- paths outside the allowed set refused;
- an upstream that is down answering 502 with no retry.
The fake upstream is a local test server. P4 doesn't need the binary. Before its gate, P4 reruns P1's panel
acceptance step against the real forward and records the result.
**Out of scope.** Traefik, DNS, Authentik settings, the .54 Console's provider (Q218), and the management key.
**Gate.** Gladstone gives the first Codex review, Filbert reviews the landing candidate. P4 rebases on P1's
landing. The webui suite runs whole with `MOSAIC_REQUIRE_EDITOR_BUILD=1`, and every package suite but queue
plus every `scripts/test-*.sh` is green on Sage's landing gate.
## Brief: P2 per-agent model keys, reconcile and the launch preflight
**Why.** D11 to D14 and D17: each agent gets its own client key from the stack, revoked before a fire
delists the agent, and no session starts on a key the proxy refuses.
**What ships.**
1. **The store kind.** A row 199 store kind `model` (`packages/bus/src/credentials.mjs`: `issue` :291,
`revoke` :383, `remove` :400, `status` :474) at `<configDir>/secrets/agents/<business>/<agent>/model`
(0600).
- Its status holds the key id, the issue time and the state, never the value.
- The kind is implicit for every agent of an instance with a proxy, with no `credentials.model`
reference and no change to `roles/*.json` (D13, mos-1600 ruling 4).
- Key format `msk-<instanceTag>-<32 random bytes, base64url>` (D11).
2. **Issue.** At hire (`packages/business/src/instance-settings.mjs`:354) and at the first launch of an
agent with no key, the bus GETs the list, PUTs it with the new key, and GETs again to confirm the key is
there. Only then does it write the file. Any failure writes nothing and refuses `proxy-key-issue-failed`.
3. **Rotate.** `rotateBy` defaults to 30 days for model keys. The bus PUTs the new key beside the old one,
writes the new file and launches new sessions on it. The old key goes once the runs started on it have
ended, or after `proxy.rotateGraceMinutes` (default 120), whichever is first. A session still running
then is stopped. The rotation period is human-only (Q222, D20).
4. **Revoke.** `revoke()` in `packages/bus/src/runtime.mjs`:166-197 gains the model kind: GET, PUT without
the key, then GET to confirm, and only then remove the file. A failure refuses the fire with
`credential-revoke-failed`, as for the other kinds. It never uses the v0 DELETE.
5. **Reconcile at boot.** The bus GETs the list once.
- A key in the record but missing from the proxy is PUT back.
- A key with this instance's prefix that is in the proxy but not in the record is removed.
- Keys without the prefix are left alone.
- A list with no sentinel refuses every launch with `proxy-open`. That check is also what proves the
listener is this instance's proxy (P1 item 8).
- The reconcile writes a receipt with key ids only.
- One broker runs per data root, so one bus holds the management client per instance. Say in BUILD.md
how the code relies on that.
6. **Preflight (D17).** Before each launch in proxy mode, the bus sends one `GET /v1/models` with the agent's
key. A 401 refuses `proxy-key-refused`, and a connection failure refuses `proxy-down`. A session with no
output for `proxy.firstOutputSeconds` (default 120) is stopped and logged as `proxy-stall`.
7. If P1 found that a client key can be limited to some models, narrow each agent's key to its role's
models. If it can't, say so in BUILD.md. The bundle's model choice stays the guard.
**Tests.** Use a fake management server for every step above, every refusal code by its own test, and a
receipt check that no key value appears in any log, error or status. Add a fire test showing the key is gone
from the proxy before the agent is delisted.
**Out of scope.** Harness env and files (P3), usage (P5), the Console.
**Gate.** Boyd gives the first Codex review, Dewey reviews the landing candidate (credentials). Every touched
test file runs whole, the named mutants below are killed, and every package suite but queue plus every
`scripts/test-*.sh` is green on Sage's landing gate. Mutants: revoke skips the confirming GET; reconcile
removes a key without the prefix; the preflight treats a 401 as success.
## Brief: P3 harness config from the proxy
**Why.** D16: in proxy mode each session gets only its own key and the proxy's address, written fresh on every
launch, and none of the instance-wide provider variables (mos-1600 ruling 6).
**What ships.**
1. The bus passes the harness module `{ baseUrl: "http://127.0.0.1:<port>", key }` with today's `env`
answer.
2. **Dropped variables.** In proxy mode `sessionEnv` (`packages/seat/src/session.mjs`:256, `ENV_ALLOW` :242)
stops passing `CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_*`, `OPENAI_API_KEY`, `OPENROUTER_API_KEY` and
`ZAI_API_KEY` from the instance. Without a proxy nothing changes.
3. **Claude Code** (`packages/plugins/harness/claude-code/index.mjs`, `env` :119-126) sets
`ANTHROPIC_BASE_URL` and `ANTHROPIC_AUTH_TOKEN=<key>`.
4. **pi** (`packages/plugins/harness/pi/index.mjs`, `env` :63-70) writes `<run>/home/.pi/agent/models.json`
with the provider's `baseUrl` (no `/v1`) and sets `ANTHROPIC_API_KEY=<key>`.
- A non-Anthropic route names that provider's base URL in the same file.
- pi gets a preflight that refuses `no-session-credential` when it has neither a proxy key nor its own
credential.
- Nobody maps `CLAUDE_CODE_OAUTH_TOKEN` into a pi variable. Whether this closes P19 #383 is Jason's call,
and the row doesn't close it.
5. **Business settings.** `launch.models` (`packages/business/src/launch.mjs`:12-24) accepts provider
`proxy`, and `launch.account` (:81-89) can name the proxy. A business without `proxy` entries is
unchanged. Record the exact key names in BUILD.md.
6. No harness reads the host's `~/.claude`, `~/.pi` or `~/.codex`. The Codex harness config waits for a
Codex harness row and isn't here.
**Tests.** Cover each of these:
- proxy mode drops each named variable, and non-proxy mode keeps today's env exactly;
- the Claude Code env;
- pi's `models.json` content and mode, rewritten on every launch;
- the pi preflight refusal;
- the `proxy` provider in `launch.models`;
- a session env that never holds another agent's key.
**Out of scope.** Key issue and revoke (P2), the proxy itself (P1), and live sessions.
**Gate.** Gladstone gives the first Codex review, Dewey reviews the landing candidate. Every touched test
file runs whole, and every package suite but queue plus every `scripts/test-*.sh` is green on Sage's
landing gate. Mutant: proxy mode still passes `CLAUDE_CODE_OAUTH_TOKEN`.
## Brief: P5 usage plug-in and per-key limits
**Why.** D18 and D19 (Q153, Q154, Q222).
**Correction to D18 (mos-1600).** D18 said the stack counts per account, but D9 says the stack never sees
which account served a request. The stack's own count is per agent key. Per-account numbers come only from
the `cliproxy-queue` plug-in or from the panel.
**What ships.**
1. **Per-key quotas.** `launch.limits.tokens` (`packages/business/src/launch.mjs`:60-75) accepts an agent's
key id as a `keys` name, so a daily quota per agent key is a business setting. It is unset by default,
which means no quota, and it is human-only (Q222).
2. **The plug-in.** A `usage` plug-in kind with one optional module, `cliproxy-queue`. It drains
`GET /v8/management/observability/usage/queue?count=100` through the bus's management client every
`usage.cliproxyQueue.pollSeconds` (default 15, under the proxy's 60 s retention).
- It maps each event's client key to a key id and drops the value.
- It maps the upstream account field to an account id the same way. Row 96 saw that field carry an
upstream key in plain text (`REPORT.md`:161), and the stack never stores an upstream credential (D7).
- It records upstream failures per key id. It is off by default and turned on by `plugins.set`.
- It never enforces a quota alone.
- A `msk-` key that isn't in the record goes to the reconcile log.
3. **The Console view.** Usage shows the stack's per-key count. When the plug-in is on, it also shows the
plug-in's per-account numbers, labelled as the proxy's.
**Tests.** Use a fake queue that returns events with made-up client keys and upstream values, and check that
no value of either kind appears in any store, log or view. Also test the poll interval setting, plug-in off
by default, a quota per key id, and the unknown-key reconcile entry.
**Out of scope.** Quota enforcement from proxy numbers, and the panel.
**Gate.** Boyd gives the first Codex review, Dewey reviews the landing candidate. Every touched test file runs
whole, and every package suite but queue plus every `scripts/test-*.sh` is green on Sage's landing gate.
Mutant: the reader stores the upstream account value instead of its id.