Source-candidate: poe d9a564e6d20f5f0fd75ce45b12cbbf8bb4c9d568\nSource-candidate: magica38ba5762b06716db1dee00d0afec162656488f5c\nValidated321whole-file phase plus whole bus1349/model-proxy100/webui490; final gate tooling checks pending, not landed.
21 KiB
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.keyor 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/EasyCLIProxyAPIand 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 changesclaude-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.
packages/model-proxy/, a new package with its owntests/, so the gate's package loop picks it up.- PIN and the fetch.
packages/model-proxy/PINnames 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.
- 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.jsononce, exclusively (wx, the same rule ascreateExclusiveinscripts/mosaic-config.mjs:398), with{ "port": N, "binary": "<path>" }. - The loader (
scripts/mosaic-config.mjsvalidate:293 andload:362) reads it when present, checks it, and returns it asproxyin 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.portandproxy.binary". P4 readsproxy.portfrom it and nothing else.
- Invariant 2 says config.json is created only by bootstrap and never overwritten. So init doesn't touch
config.json. It creates
- 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. - The config template sets these values:
host: 127.0.0.1andallow-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.
- 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 v0DELETE ?value=(D14). It sends the management key only in theAuthorizationheader 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. - The unit. A user unit
mosaic-proxy.service(named instance:mosaic-proxy-NAME.service), from a template besidepackages/cli/systemd/mosaic-bus.service.in.scripts/instance.shgets aPROXY_UNITname next toCONSOLE_UNIT(:84). The unit's start step refuses a binary whose sha256 doesn't match PIN. The bus unit getsWants=andAfter=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. - 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/modelswith 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 underContent-Security-Policy: sandbox allow-scripts allow-formswith noallow-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.
- The route.
/proxy/and below on the human listener is forwarded to127.0.0.1:<proxy.port>(P1 item 3's contract), and only when the validated config hasproxy. Otherwise it answers 404not-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.
- The route is owner-only. It is refused for a granted user (before
- Requests.
- Strip the Console session cookie, every
x-authentik-*header, the preview header, the proof header andx-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.
- Strip the Console session cookie, every
- Responses.
- Every forwarded response gets
Content-Security-Policy: sandbox allow-scripts allow-formswith noallow-same-origin. It also gets the Console'snosniff,no-referrerandframe-ancestors 'none'. - Upstream
Set-Cookieheaders 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.
- Every forwarded response gets
- 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.
- 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;
Authorizationpassed through and absent from logs;- the CSP sandbox and the dropped
Set-Cookieon every response; Origin: nullaccepted 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.
- 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.modelreference and no change toroles/*.json(D13, mos-1600 ruling 4). - Key format
msk-<instanceTag>-<32 random bytes, base64url>(D11).
- 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 refusesproxy-key-issue-failed. - Rotate.
rotateBydefaults 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 afterproxy.rotateGraceMinutes(default 120), whichever is first. A session still running then is stopped. The rotation period is human-only (Q222, D20). - Revoke.
revoke()inpackages/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 withcredential-revoke-failed, as for the other kinds. It never uses the v0 DELETE. - 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.
- Preflight (D17). Before each launch in proxy mode, the bus sends one
GET /v1/modelswith the agent's key. A 401 refusesproxy-key-refused, and a connection failure refusesproxy-down. A session with no output during the one initialproxy.firstOutputSecondsbudget (default 120), armed by successful first actual adapter-child spawn, is stopped and logged asproxy-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. - 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.
- The bus passes the harness module
{ baseUrl: "http://127.0.0.1:<port>", key }with today'senvanswer. - Dropped variables. In proxy mode
sessionEnv(packages/seat/src/session.mjs:256,ENV_ALLOW:242) stops passingCLAUDE_CODE_OAUTH_TOKEN,ANTHROPIC_*,OPENAI_API_KEY,OPENROUTER_API_KEYandZAI_API_KEYfrom the instance. Without a proxy nothing changes. - Claude Code (
packages/plugins/harness/claude-code/index.mjs,env:119-126) setsANTHROPIC_BASE_URLandANTHROPIC_AUTH_TOKEN=<key>. - pi (
packages/plugins/harness/pi/index.mjs,env:63-70) writes<run>/home/.pi/agent/models.jsonwith the provider'sbaseUrl(no/v1) and setsANTHROPIC_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-credentialwhen it has neither a proxy key nor its own credential. - Nobody maps
CLAUDE_CODE_OAUTH_TOKENinto a pi variable. Whether this closes P19 #383 is Jason's call, and the row doesn't close it.
- Business settings.
launch.models(packages/business/src/launch.mjs:12-24) accepts providerproxy, andlaunch.account(:81-89) can name the proxy. A business withoutproxyentries is unchanged. Record the exact key names in BUILD.md. - No harness reads the host's
~/.claude,~/.pior~/.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.jsoncontent and mode, rewritten on every launch; - the pi preflight refusal;
- the
proxyprovider inlaunch.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.
- Per-key quotas.
launch.limits.tokens(packages/business/src/launch.mjs:60-75) accepts an agent's key id as akeysname, 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). - The plug-in. A
usageplug-in kind with one optional module,cliproxy-queue. It drainsGET /v8/management/observability/usage/queue?count=100through the bus's management client everyusage.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.
- 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.