Source-candidate: poe d9a564e6d20f5f0fd75ce45b12cbbf8bb4c9d568\nSource-candidate: magica38ba5762b06716db1dee00d0afec162656488f5c\nValidated321whole-file phase plus whole bus1349/model-proxy100/webui490; final gate tooling checks pending, not landed.
312 lines
21 KiB
Markdown
312 lines
21 KiB
Markdown
# 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 during the one initial `proxy.firstOutputSeconds` budget (default 120), armed by successful
|
|
first actual adapter-child spawn, is stopped and logged as `proxy-stall`. Idle before then is
|
|
unarmed; later turns do not reset or extend it; first output cancels. Invalid first-turn metadata
|
|
uses the same existing stop and value-free receipt once, unless output already cancelled it.
|
|
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.
|