docs: design for CLIProxyAPI as the stack's model credential and routing layer, one proxy per instance, per-agent keys tied to rows 199 and 194, harness config from the proxy, row 96 points placed, row 157 re-scope, cutover and Jason's steps (mos-1597, Q249, Vikunja 237); doc only

This commit is contained in:
jason.woltje committed 2026-10-24 13:45:52 -05:00
1 parent 2ce476d54c
commit 9a50f082f5
1 file changed
+360
+360
View File
@@ -0,0 +1,360 @@
# Design: CLIProxyAPI as the stack's model credential and routing layer
Written 2026-10-24 by Sage for Mos's mos-1597 (Vikunja 237). It carries out Jason's Q249 ruling that CLIProxyAPI
becomes the stack's credential and routing layer, with Q116 and Q127 still in force. This is a design document.
Mos rules it before any code. Its build rows go to new Codex gpt-6.1-sol threads (Q246), and each gets its own
brief once Mos has ruled. Line numbers are at origin/refactor 2ce476d54.
Not to be confused with queue row 237 ("one decision lands once", `2026-10-20_agent-credentials.md`). This is
Vikunja task 237.
## Inputs
- **Q116** (jason-master-restart DECISIONS.md:709, :724-725; lead-decisions.md:2578-2585). The WebUI goes under
mosaic.woltje.com behind Authentik, on a path of the existing host. A new DNS record or subdomain goes back
to Jason first. Authentik admits only jason.woltje. No agent reads the management key.
- **Q127** (:779-782). Seats talk only to a local proxy, using keys the stack issues and rotates. Upstream
tokens and keys stay in the proxy. Switching live seats to the proxy, and the subscription-terms check, go
back to Jason.
- **Q249** (:2512). The proxy serves one user's agents on that user's machine. It is not shared with other
users and not used in a commercial app. The host proxy on 127.0.0.1:8317 is not touched. Decision 108's
token file and claude-session.env are a stopgap. One proxy per user.
- **Q153/Q154** (:1088, :1096): usage sources and quota enforcement sit behind a plug-in interface. **Q222**
(:2194): limits are settings with defaults, and a setting that guards the human from agents is
human-only.
- **Row 96** (#1590), `agents/researcher/work/i1-proxy-test/REPORT.md`, tested against CLIProxyAPI 8.0.27. Issue
is 1 PUT, revoke is 1 DELETE, with no restart and effect on the next request. Keys survive a restart.
Claude Code works with `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN`. Pinned pi 0.85.1 needs a `models.json`
in the session HOME. Eight open points (:190-216) and six design items (:218-236). Row 96's queue note adds
two more. `GET api-keys` returns every client key in full. The claim that the 401 body shape causes the
Claude Code stall is unverified, so test it before any fix aimed at the shape.
- **Row 199** (landed cb5fdb064): the agent credential store, files under
`<configDir>/secrets/agents/<business>/<agent>/<kind>`, 0700 directories and 0600 files
(`agents/megavolt/work/queue-199/DESIGN.md`:63-65). Class `Credentials` is in
`packages/bus/src/credentials.mjs` (`issue` :291, `revoke` :383, `remove` :400, `useFor` :417, `status` :474).
D7 (:118-125) deferred per-agent model tokens to "a proxy ... its own row". This is that row.
- **Row 194** (landed fd6dd327c): fire stops the agent's runs, then delists it
(`packages/bus/src/broker.mjs` `#chiefLifecycle` :5038, `instance.fire` :5117). `revoke()` in
`packages/bus/src/runtime.mjs`:166-197 revokes before the write and refuses `credential-revoke-failed`.
- **How sessions get model credentials today.** The bus unit loads `claude-session.env`
(`packages/cli/systemd/mosaic-bus.service.in`:21-25). `sessionEnv` passes every `ENV_ALLOW` variable to every
session (`packages/seat/src/session.mjs`:242-262). Inside the sandbox, HOME is the run's own `home/`
(:240-241). The sandbox doesn't unshare the network (`packages/seat/src/sandbox.mjs`:70), so a session reaches
host loopback. Pi has no model credential today (P19 #383). There is no Codex harness plug-in
(`packages/plugins/harness/` holds claude-code and pi only).
## 1. Where the proxy runs
D1. **One proxy per stack instance, run by the stack.** It runs as the instance's OS user, and since every
instance has one user (invariant 2) that gives one proxy per user per machine. The default instance on
dragon-lin gets one. Melanie's .54 hub gets its own as `mosaic` under `/srv/mosaic`. Nothing is shared
between instances. The host proxy on 127.0.0.1:8317 is not started, stopped, read or configured by any of
this, and its config and data directory are not read.
D2. **Process and files.**
- A systemd user unit `mosaic-proxy.service`, written by the same scripts that write `mosaic-bus.service`
(`scripts/instance.sh`, `scripts/case2-unit.sh`). The bus unit gets `Wants=` and `After=` on it. The bus
refuses to launch a session when the proxy is configured but not answering (D17).
- The proxy's own directory, `<dataRoot>/proxy/`, mode 0700, holds `config.yaml` (0600), `auth/` (the
`oauth.auth-dir` for its OAuth logins, 0700) and `logs/`. No other component reads files in it. Run records
never copy from it.
- It listens on `127.0.0.1:<proxy.port>`. `allow-remote: false`, so management calls are accepted from
loopback only.
D3. **System config.** The instance config gets an optional `proxy` section: `{ "port": N, "binary": "<path>" }`.
With no section, the instance runs as today on claude-session.env. That is how migration works one instance
at a time (section 9). With the section, both fields are required, and a missing or bad value refuses. The
port can't be 8317, and the bus refuses at boot if another process already listens on it. Bootstrap writes
the section only for a new config, as it does for every other field. An existing config gets it by a human
edit at cutover (Jason's step J2). This changes the config schema, so Mos must rule on it.
D4. **Pinned binary.** `packages/model-proxy/PIN` records the CLIProxyAPI version, the release asset name and
its sha256, the same way pi is pinned exactly. The tested version is 8.0.27. The unit starts only a binary
whose sha256 matches PIN. The control panel asset is pinned the same way, with
`disable-auto-update-panel: true`. The proxy otherwise downloads the panel from GitHub at runtime
(`config.example.v8.0.27.yaml`:94-103), and that would be unreviewed code behind Authentik. The stack never
copies the 8317 binary. The first fetch of the release asset counts as external access, so it is Jason's
step J1 or a row Mos approves. For containerized installs the binary and panel go into the proxy image
(section 8).
D5. **Initial config, then hands off.** The stack writes `config.yaml` once, when `<dataRoot>/proxy/` is
first created, from a template in `packages/model-proxy/`, and never writes the file again. After that the
stack changes the proxy only through the management API, and Jason changes it only through the panel.
Row 96 point 2 (every management write rewrites the whole file and reorders it) then stops mattering,
because nobody edits the file by hand. The template sets the defaults in section 3 and section 7,
`request-retry: 0` so no hidden retries happen (as in row 96), the client-key list holding only the
sentinel key (D11), and the management key (D6).
## 2. Credential entry
D6. **Management key.** Jason sets the management key once, at a human-only prompt
(`mosaic proxy init`, run in his own shell, path-bound human proof as for other human verbs, #225). The
prompt doesn't echo the key. It writes the key to `<configDir>/secrets/proxy-management.key` (0600) and into
the initial `config.yaml`. The proxy hashes it in place on its first start (row 96 point 6). The stack reads
only the secrets file, never the hashed value in config. The holder is the bus process alone (D12). The
sandbox doesn't mount `<configDir>/secrets`, so no session can read the key, and no verb returns it. Jason
types the same key into the panel to sign in. An agent never runs the prompt (Q173).
D7. **Upstream credentials.** Jason adds every upstream account in the proxy's own panel: OAuth sign-ins
(Claude, Codex and the others CLIProxyAPI supports) and API keys (for example z.ai). They live in the
proxy's `auth/` directory and config and nowhere else. There is no per-seat credential file. The stack never
reads, copies or logs an upstream credential.
D8. **Panel path.** The Console forwards `https://mosaic.woltje.com/proxy/` to `127.0.0.1:<proxy.port>`, for
the panel route and the management routes only. It forwards only after its existing auth-header and
allowed-user check (decision 103, `packages/webui/src/serve.mjs`:453-469) passes. The Console never adds or
stores the management key. Jason types it in the panel, and it passes through the forward. This adds no DNS
record and no subdomain, as Q116 requires. Traefik and the tunnel already deliver mosaic.woltje.com to the
Console (7332), so no infra change is needed. The forward gives no new authority. The management key
is the barrier, and loopback processes can already reach the proxy port. Authentik is the outer layer. The
#385 hole (a loopback process forging the Host and header) applies here the same way and is no worse,
since the management key is still required.
A second Console (.54) forwards to its own instance's proxy, and its Authentik provider settings are Jason's
(Q218).
Checks for the build row, on a stand-in under `unshare -rn` as in row 96:
- the pinned panel works under the `/proxy/` sub-path (rewrite or base-path setting);
- an OAuth sign-in done from the panel through the forward completes. If the callback can't return through
the forward, the fallback is Jason running the proxy's CLI login in his own shell (`-claude-login
-no-browser` and similar), and J3 says so.
## 3. Priority routing, and how the business routing table maps onto it
D9. **Who picks what.** The business routing table picks the harness, model and thinking for a role
(`packages/business/src/vars.mjs`:139-147, resolved by `sessionModel` in `packages/harness/src/bundle.mjs`:77-86).
The proxy picks which upstream account serves that model. The two don't overlap:
- The model id in the business table is the id the session sends. The proxy routes it to an account that
serves that model.
- The business `fallback.*` vars stay the stack's. They still apply only when the primary harness's preflight
fails (`packages/cli/src/launcher.mjs`:390-397).
- Account failover inside one model is the proxy's job. The stack doesn't model accounts per session.
D10. **Priority settings.** Defaults set in the template are listed below (Q222: settings with defaults).
Jason can change all of them in the panel.
- `routing.strategy: fill-first`. A subscription account is used until it is rate-limited or out of quota, then
the next one (`config.example`:150).
- Each account gets a `priority` (larger first, `config.example`:480). Jason sets the order in the panel (J4).
- `routing.session-affinity: true`, so a session stays on one account while it works and keeps its prompt
cache (:154-171). Default `session-affinity-rebind-on: quota`, so a transient 5xx doesn't move a session.
- No model aliases or alias pools by default. A pool that maps one name to several upstream models
(:869) would let the proxy change the model under a role. Model choice stays in the business table, where
a reviewed edit can see it. If Jason adds an alias himself, that's his call.
`launch.models` (`packages/business/src/launch.mjs`:12-24) gets a provider value `proxy`, meaning "this model id
through the instance proxy". `launch.account` (:81-89) with a proxy names the proxy rather than an
environment variable set. The row that implements it settles the exact key name. A business without
`proxy` providers keeps working unchanged.
Not settled by the design, and checked in the build row: whether 8.0.27 can restrict a client key to some
models. If it can, an agent's key is narrowed to its role's models as a second guard. If not, the bundle's
model choice is the only guard, as it is today.
## 4. Per-agent keys
D11. **Key format and the sentinel.** A stack-issued client key is `msk-<instanceTag>-<32 random bytes, base64url>`.
The prefix tells stack keys apart from any key Jason adds himself. The stack manages only keys with its own
prefix and leaves every other key alone. One sentinel key, `msk-<tag>-sentinel-...`, is written by the
template and never handed out. It keeps the list non-empty, so the proxy never falls into a possible open
mode on an empty list (row 96 point 5). The build row tests the empty-list behavior on a stand-in, and the
result goes in BUILD.md either way. The bus refuses to launch (`proxy-open`) if a `GET` of the list shows
no sentinel.
D12. **Who calls the management API.** The bus process only, through a new `packages/model-proxy/` client. It
reads the management key from D6's file, holds it in memory, and sends it only in the `Authorization` header to
loopback. No CLI verb, Console route or session tool exposes it. Every management call is logged as a
receipt (call, status, key id) with no key value.
D13. **The stack's record.** The key is a row 199 store kind, `model`, one per agent:
`<configDir>/secrets/agents/<business>/<agent>/model` (0600). This replaces D7's "per stack instance only"
for instances with a proxy. The file holds the key value, so the bus can hand it to a session. Its status
record (`Credentials.status`) holds a key id (the first 12 hex of its sha256), the issue time and the state.
It never holds the value. The record is authoritative. The proxy's list is derived state, and the bus
reconciles it.
- **Boot.** The bus GETs the list once and compares it with the record. A key that is in the record but not
in the proxy is re-PUT. A stack-prefixed key that is in the proxy but not in the record, or is revoked
there, is removed. The reconcile writes a receipt.
- **No role-file change.** The model key needs no `credentials.model` reference in the business file. Every
agent of an instance with a proxy gets one implicitly. The tradeoff: a business can't opt a role out. The
other way would add `model` to every role definition, which means changing `roles/*.json`, and that is a
reviewed policy change for no benefit today.
D14. **Issue, rotate, revoke.**
- **Issue.** `instance.hire` (`packages/business/src/instance-settings.mjs`:354) and the first launch of an agent
without a key both call `Credentials.issue(model)`. The bus GETs the list, adds the key and PUTs the list.
It then GETs again and checks the key is present before it writes the store file. If any step fails, no
file is written and the bus refuses `proxy-key-issue-failed`.
- **Rotate.** `rotateBy` (row 199 D2) defaults to 30 days for model keys (Q222 setting). The bus PUTs the new
key alongside the old one, writes the new file, and launches new sessions with the new key. It removes the
old key once the agent's runs that started on it have ended, or after `proxy.rotateGraceMinutes` (default
120), whichever comes first. A session still running at that point is stopped. That's 3 calls with no
restart, and the two keys overlap for the window (row 96 rotation item).
- **Revoke.** Fire already stops the agent's runs before it delists the agent (row 194). The revoke step in
`runtime.mjs revoke()` gains the model kind: a GET, then a PUT of the list without the key, then a GET to
confirm. The store file goes only after that. If the revoke fails, the fire is refused
`credential-revoke-failed`, as for the other kinds. Revoke never uses the v0 `DELETE ?value=<key>`, because it
writes the full key into the access log (row 96 point 1). The v8 PUT doesn't.
D15. **Plaintext at rest, accepted.** The proxy keeps client keys in plaintext in `config.yaml`, and `GET api-keys`
returns them in full (row 96 point 1, queue note a). This is accepted, with these limits:
- the file and the store sit under the instance user's 0700 directories, which no session mounts;
- the management client never logs a list body, and the GET response stays in memory only;
- the usage reader (section 7) maps a key to its id at read time and stores only the id.
A key leak lets someone spend through the proxy until the key is revoked. It never exposes an upstream
credential.
## 5. Harness config written from the proxy
D16. **One source.** At launch the bus resolves the session's route and the agent's key, and passes the harness
module `{ baseUrl: "http://127.0.0.1:<port>", key }` alongside today's `env` answer. Each harness module writes
its own config from that. It's rewritten on every launch, so a port or key change reaches the next session
with nothing cached. No harness reads `~/.claude`, `~/.pi` or `~/.codex` of the host (managed-session-config.md:36-39).
When the proxy is configured, `sessionEnv` stops passing the instance-wide `CLAUDE_CODE_OAUTH_TOKEN`,
`ANTHROPIC_*`, `OPENAI_API_KEY`, `OPENROUTER_API_KEY` and `ZAI_API_KEY`. A session gets only its own key, so a
proxy-mode session can't reach an upstream with the instance's token.
- **Claude Code** (`packages/plugins/harness/claude-code/index.mjs`, `env` :119-126): `ANTHROPIC_BASE_URL` and
`ANTHROPIC_AUTH_TOKEN=<key>`. This already satisfies `SESSION_CREDENTIALS` (`session.mjs`:249-253) and
passes `ENV_ALLOW`. Row 96 ran exactly this.
- **pi** (`packages/plugins/harness/pi/index.mjs`, `env` :63-70): writes `<run>/home/.pi/agent/models.json` with
`{"providers":{"anthropic":{"baseUrl":"<baseUrl>"}}}` (no `/v1`), and sets `ANTHROPIC_API_KEY=<key>` (row 96
pi finding, :123-136). For a non-Anthropic route (z.ai through the proxy), the same file names that
provider's base URL. pi gains a preflight that refuses `no-session-credential` when neither a proxy key nor
a pi credential is present. This settles P19 #383 for proxy-mode instances: pi gets a key of its own, and
nobody maps `CLAUDE_CODE_OAUTH_TOKEN` into a pi variable. Whether that closes #383 is Jason's call.
- **Codex.** The stack has no Codex harness plug-in yet. This design fixes its proxy config for when one
exists: `CODEX_HOME=<run>/home/.codex`, with `config.toml` naming a `model_provider` with
`base_url = "<baseUrl>/v1"`, `env_key = "OPENAI_API_KEY"` and `wire_api = "responses"`, and
`OPENAI_API_KEY=<key>`. The Codex harness row writes it. The T3 Codex threads the development seats use are
not stack sessions and are out of scope.
## 6. Row 96's open points
| # | Point | Disposition |
|---|---|---|
| 1 | Plaintext keys in config, the usage queue and the v0 DELETE log | Accepted at rest (D15). Avoided in logs: no v0 DELETE (D14), and the usage reader stores ids (section 7) |
| 2 | Every management write rewrites the whole config | Solved: nobody edits the file after creation (D5) |
| 3 | No per-key accounting, 60 s queue window | Solved by the stack counting its own tokens (REQ-LIMIT-2). The queue reader is an optional plug-in that polls faster than the window (section 7) |
| 4 | Revoked or wrong key looks like a hang in Claude Code | Solved without relying on the client (D17). The 401-shape cause stays unverified and no shape fix is in scope. The build row tests it on the stand-in and records the result (queue note b) |
| 5 | Empty key list unknown | Solved by the sentinel (D11). Tested on a stand-in, result recorded |
| 6 | Management key hashed in place at startup | Solved: the stack reads only its own secrets file (D6) |
| 7 | pi ignores `ANTHROPIC_BASE_URL` | Solved: pi gets a session `models.json` (D16) |
| 8 | Stand-in limits: no real API, rate limits, token counting or routing | Accepted for the build rows, which run on stand-ins. The first real upstream traffic is Jason's cutover step (Q127), J5 |
| a | `GET api-keys` returns every key in full | Accepted, in memory only (D15) |
| b | 401 shape as the stall cause, unverified | Tested before any shape fix. None is planned (point 4) |
D17. **Key and proxy preflight.** Before a launch in proxy mode, the bus makes one request with the agent's key,
`GET /v1/models`. The build row confirms on the stand-in that this is answered locally with no upstream call. A
401 refuses the launch with `proxy-key-refused`. A connection failure refuses with `proxy-down`. Neither starts a
session, so a revoked key never reaches Claude Code's silent retry. A key that is revoked mid-session is
revoked by the stack, which stops that session first (D14). A session with no output for
`proxy.firstOutputSeconds` (default 120) is stopped and logged as `proxy-stall`.
## 7. Usage and limits
D18. **What counts.** The stack counts the tokens its own sessions use, per agent key and per account, as
REQ-LIMIT-2 says (docs/prd/mosaic-stack.md:964, `packages/tasks/requirements.json`:57). It's the default and
only built-in source. The existing `launch.limits.tokens` keys (`keys`, `daily`;
`packages/business/src/launch.mjs`:60-75) get the agent key id as a valid `keys` name, so a daily quota per agent
key is a business setting. It defaults to unset, which means no per-key quota.
D19. **The plug-in.** Under Q153/Q154, a `usage` plug-in kind takes 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 60 s retention). It maps each event's `api_key` to a key
id, then drops the value. It records upstream failures (`fail.status_code`) per key id. It is off by default,
and turning it on is a `plugins.set` change. When on, it is a second source that the Console shows next to the
stack's own count. It never enforces a quota alone. A queue event for a key with a `msk-` prefix that isn't
in the record goes in the reconcile log.
D20. **Settings and who changes them.** Every number in this design is a setting with the default shown:
`rotateBy` 30 d, `proxy.rotateGraceMinutes` 120, `proxy.firstOutputSeconds` 120, `usage.cliproxyQueue.pollSeconds`
15, routing strategy fill-first, affinity on. The per-key daily quota and the rotation period guard the human
from agents, so only a human can change them (Q222). The routing settings live in the proxy and only Jason
changes them, in the panel.
## 8. Row 157 re-scope (#1652, Vikunja 617)
Row 157 is on hold (Q248, mos-1568/1569), and this design doesn't start it. When it resumes, its brief is
amended this way, and Mos rules the amendment:
- **Egress.** The `agents` network's allowed list is the proxy container only. api.anthropic.com, api.z.ai and
api.openai.com come off the agents list (bentina/row157's draft `contracts/egress.json`) and go to a new `proxy`
list. The proxy container sits on `mosaic-agents` and `mosaic-egress` (containerized-install-design.md:786-830),
and its CONNECT calls are logged to `<dataRoot>/state/egress.jsonl` like any other.
- **The real-token run is cancelled.** `provider-hosts.sh` and `provider-runner.mjs` passed
`CLAUDE_CODE_OAUTH_TOKEN` and `ZAI_API_KEY` into a CLI child. That run is not done, and is replaced by a
stand-in run. A session container reaches the stand-in only through the proxy container, and a direct
connection to an upstream name is refused.
- Decision 108's mounted token file (`/run/mosaic/secrets/claude-code-oauth.token`) is not mounted into session
containers of a proxy-mode install. The proxy container holds the upstream credentials in its own volume.
## 9. Migration
claude-session.env (host installs) and decision 108's file (containerized) stay until each instance switches.
An instance switches only when its config gains the `proxy` section (D3). Until then nothing about it
changes. Cutover, per instance, with Jason present (Q127):
1. The build rows land and pass the landing gate. They don't change any instance yet.
2. Jason fetches and verifies the pinned binary and panel (J1). ops-01 or Sage checks the sha256 against PIN.
3. Jason adds the `proxy` section to the instance config (J2), runs `mosaic proxy init` (D6), and starts
`mosaic-proxy.service`. The bus isn't restarted yet.
4. Jason signs in to the panel and adds the accounts (J3, J4). No session uses the proxy yet.
5. A Console or bus restart (Jason or Mos, per Q173) picks up the section. The bus reconciles, issues a key
for each listed agent, and runs D17's preflight for each one. Any refusal stops the cutover, and the fix
is to remove the section and restart.
6. One test session per harness, launched by the tester actor, makes one real turn through the proxy (J5).
This is the first real upstream traffic on the new path.
7. Once both harnesses pass, the bus unit drops `EnvironmentFile=` for claude-session.env. The file stays on disk
for a 7-day rollback window, then Jason deletes it (J6). Rollback is removing the section and restoring the
`EnvironmentFile=` line.
Order: the dragon-lin default instance first, then .53, then Melanie's .54 hub once Jason has picked its
accounts (J7). The 8317 host proxy and its clients are not part of any step.
## 10. Jason's steps
- **J1.** In a browser, open the CLIProxyAPI release page for the version in PIN and download the named asset
and the panel asset into the folder the cutover note names. Sage checks the hashes. (If Mos assigns the
fetch to a row instead, J1 drops.)
- **J2.** Add `"proxy": { "port": <port from the cutover note>, "binary": "<path>" }` to the instance's
`config.json`, in an editor.
- **J3.** In a terminal of your own, run `mosaic proxy init` and type a new management key twice. Keep it in your
password manager. Then open `https://mosaic.woltje.com/proxy/` and sign in to Authentik as jason.woltje.
Paste the management key into the panel's login box.
- **J4.** In the panel's auth or OAuth page, sign in to each subscription account you want this instance to use
(Claude, Codex, and so on). On the API keys page, paste each API key you want it to have (for example z.ai).
Set each account's priority. The highest one is used first, and the next one only when the first is
limited. If an OAuth sign-in doesn't return to the panel, run the CLI login the cutover note gives, in your
own terminal.
- **J5.** Watch the Console while the two test sessions run (cutover step 6), and confirm the panel's usage page
shows their requests on the account you expect.
- **J6.** After 7 days on the proxy, delete `claude-session.env` for that instance.
- **J7.** For Melanie's .54 hub, decide which accounts it uses: hers, yours, or new ones. Then do J2 to J4 on .54
through its own Console's Authentik provider (Q218). Only accounts you entered for that hub go into its
proxy.
## Build rows (proposed, each briefed after Mos rules)
All are Codex builds on new gpt-6.1-sol threads (Q246), first review by a separate Codex thread. They touch
credentials, so a Claude reviewer (Dewey) gives the second review. Sage runs the landing gate.
| Row | Piece | Depends on |
|---|---|---|
| P1 | `packages/model-proxy/`: PIN, template, management client, `mosaic proxy init`, the `mosaic-proxy` unit, config `proxy` section, stand-in tests (empty list, `/v1/models` local, 401 shape, panel sub-path, OAuth through the forward) | Mos's ruling on D3 |
| P2 | Per-agent keys: `model` store kind, issue, rotate, revoke, reconcile, D17 preflight | P1, rows 194 and 199 |
| P3 | Harness config: Claude Code env, pi `models.json` and preflight, `sessionEnv` proxy mode | P2 |
| P4 | Console `/proxy/` forward behind the existing auth check | P1 |
| P5 | `usage` plug-in kind and the `cliproxy-queue` module; key ids in `launch.limits.tokens` | P2 |
| P6 | Row 157 brief amendment (section 8) | Row 157 resuming |
| P7 | Cutover note and per-instance window (section 9) | P1 to P4 landed; Jason present |
The Codex harness config (D16) goes with the Codex harness row, which isn't on this list.
## What Mos rules
1. D1/D3: the `proxy` section in the instance config (a schema change), and the proxy as its own user unit.
2. D6: the management key set by Jason at a human-only prompt and held only by the bus process.
3. D8: the Console forwards `/proxy/` rather than a new Traefik route.
4. D13: an implicit per-agent model key with no role-file change.
5. D15: plaintext keys at rest accepted under those limits.
6. D16: proxy mode drops the instance-wide provider variables from every session.
7. J1: Jason fetches the binary, or a row does.