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:
1 parent
06f507dbc0
commit
7fe32d7226
1 file changed
+308
@@ -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.
|
||||
Reference in new issue
Block a user