digital-roster 0.3.2

Rent the intelligence, own the governance — a control plane for workers: software colleagues whose every action passes through a gateway you control (default-deny egress, injected credentials, budgets, approval gates, audit).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
# Plan: configurable box agent — pi | claude | codex

Status: WP0 spike complete (green, 2026-07-17) — implementation not started
Scope: let a worker's one-shot task runs execute under Claude Code (`claude`) or
OpenAI Codex (`codex`) instead of pi, selected per worker in config. Warm
conversation sessions stay pi.

## Goal

```
agent = "claude"        # worker.toml — this worker's task runs use Claude Code
```

with an org-wide default:

```
[engine]
agent = "codex"         # org.toml — default for workers that don't say
```

Everything else — the governed door, sentinel credentials, action gates, task
attestation — behaves identically regardless of agent.

## Non-goals

- Warm sessions (`run_session`, `src/run/boxed.rs:387`) for non-pi agents. The
  stdin RPC protocol, `agent_end` turn tracking, and `assistant_text()` are
  pi-specific and conversations weren't asked for. Sessions always run pi.
- Per-task (as opposed to per-worker) agent selection. Can be layered on later;
  the plumbing below keys off `RunSpec`, so it wouldn't be invasive.
- Arbitrary/bring-your-own agents. The value is an enum, not a command string —
  a free-form command would bypass everything provisioning guarantees.

## What couples the box to pi today

| Coupling | Where | Severity for one-shot runs |
|---|---|---|
| Spawn command | `pi_prefix()` `src/run/boxed.rs:607`; call sites `:310` (one-shot), `:445` (session) | must generalize |
| CLI flags (`--mode`, `--session-dir`, `--session-id`, `--append-system-prompt`) | `boxed.rs:310-323, 607-632, 662` | per-agent mapping |
| Sentinel auth shape (`/pihome/agent/auth.json`) | `prepare_pihome()` `boxed.rs:1254` | per-agent writer |
| Env wiring (`PI_CODING_AGENT_DIR`) | `boxed.rs:869-873`; `RESERVED_ENV` `src/config.rs:661` | add claude/codex homes |
| Tool surface (pi extensions → gateway action host) | `box/extensions/actions.ts`, `web.ts` | port to MCP (WP5) |
| Credential availability probe | `model_credentials_available()` `boxed.rs:224` | per-agent |
| Image contents (only pi baked) | `box/Dockerfile:56-64` | bake CLIs |
| Output protocol parsing | sessions only (`assistant_text()`); one-shot stdout is streamed unparsed to `stdout.jsonl` (`boxed.rs:339-357`) | none |

What is already agent-agnostic — verified, not assumed:

- **Credential injection.** The gateway *overwrites* auth headers in transit
  (`src/gateway/proxy.rs:695-715` drops the box's header, applies the rendered
  inject). The box's sentinel value never has to be acceptable to the API —
  only to the CLI's own "am I logged in" check.
- **The vault credentials are already the right kind.** The `anthropic`
  registry entry (`src/credential/providers.default.json`) is the Claude Code
  OAuth client (scope includes `user:sessions:claude_code`); `openai-codex` is
  the codex CLI OAuth client. `roster connection add anthropic` mints exactly
  the token claude needs; ditto codex.
- **Egress grants.** Model connections compile to grants on `model_hosts`
  (`api.anthropic.com`, `chatgpt.com`) — the hosts claude/codex actually call.
- **Task attestation.** `finalize()` (`src/work/dispatch.rs:258`) keys on host
  evidence (exit code, ceiling, pending gates, gateway refusal count) plus the
  `/outcome` report POSTed to the action host — none of it parses pi output.
  A clean exit with no report and no refusals attests completed
  (`dispatch.rs:307`), which makes a tool-less milestone shippable.
- **Briefing text.** `src/worker/context.rs` names tools (`task_complete`,
  `message_user`, `set_tasks`, …) but not the agent — identical MCP tool names
  keep every compiled prompt working unchanged.

## Design decisions

1. **Selection is per-worker** (`agent = "…"` in worker.toml, top level), with
   `[engine] agent` as the org default and `"pi"` as the built-in default.
   Tasks belong to workers; heartbeat runs are queued tasks, so they follow the
   worker's agent too (see WP5 note).
2. **Enum, fail-closed.** `pi | claude | codex`. Unknown value = validation
   error naming the allowed set (style of `config.rs:333`).
3. **Sessions pin pi.** A worker with `[channels]` and a non-pi agent gets a
   *warning* (sessions run pi), not an error.
4. **One image, all agents baked.** Same philosophy as the toolbelt
   (`box/Dockerfile:2`): binaries add no egress power on the NAT-less network.
   CLIs are npm-pinned in `package.json` so versions ride the lockfile, like pi.
5. **`run-claude` / `run-codex` wrapper scripts in the image**, mirroring
   `run-pi`: the wrapper owns agent-specific fixed flags (output format, MCP
   registration, permission bypass), the host stays image-agnostic.
6. **Same sentinel scheme.** Write believable-but-worthless credentials in each
   CLI's native format; far-future expiry so the CLI never attempts a refresh;
   gateway injects the real credential in transit. No secret enters the box.
7. **One tool surface via MCP.** The pi extensions become thin wrappers over a
   shared tool module also served by a stdio MCP server. Same names, same
   `submit()` envelope to `https://actions.roster.internal/*`, same governance.
8. **Codex system prompt: prepend to the input prompt.** Codex has no
   `--append-system-prompt`; writing AGENTS.md into the cwd would dirty code
   worktrees that `propose_changes` later commits. Prepending with a clear
   delimiter is safe and reversible; revisit if codex stabilizes an
   instructions flag.

---

## WP0 — Spike: prove sentinel auth end-to-end (do this first)

No roster code changes. Build a scratch image with both CLIs, start a box with
the exact provisioning roster uses (`[engine] image` + a manual `docker run`
copying the args from a real run), hand-write sentinel files, run one prompt
per agent through the gateway.

Answers we need out of it:

- Exact on-disk credential shape per pinned CLI version:
  claude `$CLAUDE_CONFIG_DIR/.credentials.json` (`claudeAiOauth: {accessToken,
  refreshToken, expiresAt, scopes, …}` — confirm) and codex
  `$CODEX_HOME/auth.json` (`{OPENAI_API_KEY, tokens: {access_token,
  refresh_token, account_id, …}, last_refresh}` — confirm).
- Does `vault::render_injection` render both anthropic inject headers when the
  vault credential is OAuth-only (no `{key}`)? The claude CLI sends
  `authorization: Bearer …` for OAuth — confirm the injected request
  authenticates.
- Which extra hosts each CLI phones (statsig/sentry for claude, telemetry for
  codex) and that env suppression (`DISABLE_TELEMETRY=1`,
  `DISABLE_ERROR_REPORTING=1`, `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1`,
  `DISABLE_AUTOUPDATER=1`; codex config equivalents) silences them — every
  unsuppressed call is a judge denial that counts toward `take_refusals`.
- Proxy + CA compliance: claude is Node (`HTTPS_PROXY` + `NODE_EXTRA_CA_CERTS`
  — should just work); codex is Rust/reqwest (`HTTPS_PROXY` + `SSL_CERT_FILE`
  — verify it honors both).

Acceptance:
- **Given** a box on the lockdown network with only sentinel files, **when**
  `claude -p "reply DONE" --output-format stream-json
  --dangerously-skip-permissions` runs, **then** a completion returns, the
  ledger shows the injected call to `api.anthropic.com`, and no real token
  exists anywhere in the run dir.
- **Given** the same box, **when** `codex exec --json "reply DONE"` runs with a
  sentinel `auth.json`, **then** same result via `chatgpt.com`.
- **Given** either CLI with telemetry suppression env set, **when** the run
  ends, **then** the gateway ledger shows zero denied requests.

Exit criterion: if either CLI refuses the sentinel (e.g. insists on refreshing
against `platform.claude.com` / `auth.openai.com`), capture the exact requests
and decide between (a) far-future expiry adjustments, (b) allowing the refresh
endpoint as a special no-inject grant, or (c) gateway-side refresh. Do not
proceed to WP3+ until green.

### WP0 results (run 2026-07-17 — GREEN, with one design change)

Both CLIs completed a governed one-shot run end-to-end on sentinel
credentials with correct header injection and zero secrets in the box.
Versions tested: claude-code 2.1.211, codex (npm latest same day), baked over
`ghcr.io/manasgarg/roster-box:latest`.

- **Confirmed sentinel shapes** (golden-test these in WP4):
  - claude `$CLAUDE_CONFIG_DIR/.credentials.json`:
    `{"claudeAiOauth": {accessToken, refreshToken, expiresAt,
    refreshTokenExpiresAt, scopes[], subscriptionType, rateLimitTier}}` +
    `$HOME/.claude.json` with `{"hasCompletedOnboarding": true}`.
  - codex `$CODEX_HOME/auth.json`: `{"auth_mode": "chatgpt",
    "OPENAI_API_KEY": null, "tokens": {id_token, access_token, refresh_token,
    account_id}, "last_refresh"}` — access/id tokens must be
    structurally-valid JWTs (`sentinel_jwt()` works as-is).
  - Neither CLI attempted a token refresh (far-future expiry suffices).
- **Design change — claude needs an in-box auth shim.** `claude` is a
  Bun-compiled binary; Bun's fetch does not support userinfo credentials in
  proxy URLs (`http://token@host:port``getaddrinfo ENOTFOUND token@host`).
  pi (Node/undici) and codex (Rust/reqwest) handle userinfo fine. Fix proven
  in the spike: a ~25-line Node localhost proxy (`127.0.0.1:3128`, no auth)
  that chains CONNECT to the gateway adding `Proxy-Authorization` from
  `ROSTER_PROXY_TOKEN`; claude gets the credential-less
  `HTTPS_PROXY=http://127.0.0.1:3128`. Attribution and injection verified
  intact (`org/<worker>` in the ledger). WP3: `run-claude` starts the shim;
  WP4: pass `ROSTER_PROXY_TOKEN`/`ROSTER_GATEWAY` env (add to
  `RESERVED_ENV`).
- **Claude telemetry suppression works**: with `DISABLE_TELEMETRY=1`,
  `DISABLE_ERROR_REPORTING=1`, `DISABLE_AUTOUPDATER=1`,
  `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1` the run produced exactly one
  gateway request (`POST api.anthropic.com/v1/messages`, allow,
  `authorization` injected — the OAuth-only credential correctly skips the
  `x-api-key` template).
- **Codex telemetry — one open item**: one denied request to
  `ab.chatgpt.com/otlp/v1/metrics` (OTEL export). Find the config.toml knob
  in WP4; until then it would count one refusal per run (attestation trap for
  silent runs). Its `/backend-api/codex/analytics-events` calls ride the
  existing model-api grant (allowed).
- **Codex quirk**: `codex exec` prints "Reading additional input from
  stdin..." — the WP3 spawn should close stdin or pass `-` appropriately.
- **Security observation (independent of this plan)**: `resolve_identity`
  (`src/gateway/proxy.rs:258`) maps a *credential-less* proxy request to
  subject `org` ("trusted host-side caller") — but boxes on the locked
  network can also send credential-less requests, laundering attribution and
  escaping per-worker budgets/refusal counts while still exercising
  org-scoped grants. Consider distinguishing host-side callers by peer
  address instead. (Discovered because claude-without-shim accidentally ran
  this way and succeeded.)

## WP1 — Config: the `agent` key

Touch: `src/config.rs`, `docs/configuration.md`.

- `enum AgentKind { Pi, Claude, Codex }` with `FromStr` (lowercase names).
- `Loaded` gains `agents: HashMap<String, AgentKind>` (resolved per worker:
  worker.toml `agent``[engine] agent``Pi`).
- Validation errors: unknown value (lists allowed), wrong type.
- Warning (not error): non-pi agent on a worker with `[channels]` listeners —
  "sessions run pi; agent applies to task runs".
- Unit tests beside the existing config tests.

Acceptance:
- **Given** no `agent` key anywhere, **then** every worker resolves to `Pi` and
  behavior is unchanged.
- **Given** `agent = "claude"` in one worker.toml, **when** `roster server
  validate` runs, **then** it passes and `Loaded.agents` maps that worker to
  `Claude`.
- **Given** `agent = "gpt5"`, **then** validate fails naming `pi | claude |
  codex`.
- **Given** a claude worker with `[channels] discord = …`, **then** validate
  passes with the sessions-stay-pi warning.

## WP2 — Image: bake the CLIs

Touch: `package.json` (+ lockfile), `box/Dockerfile`.

- Add `@anthropic-ai/claude-code` and `@openai/codex` as pinned deps (versions
  ride `npm ci`, same as pi).
- Engine layer: resolve each package's bin (same symlink dance as
  `Dockerfile:62`) → `/opt/roster/engine/claude`, `/opt/roster/engine/codex`.
  Both are native executables, not node entries (claude is Bun-compiled;
  codex ships platform binaries via optionalDependencies) — symlink and exec
  directly, no `node` prefix.
- Ship the auth shim (WP0 results) as `/opt/roster/engine/box/shim.js`.
- Generate wrappers:
  - `run-claude`: start the auth shim (`node …/box/shim.js &`, reads
    `ROSTER_PROXY_TOKEN`/`ROSTER_GATEWAY`), rewrite `HTTP(S)_PROXY` to
    `http://127.0.0.1:3128`, then `exec /opt/roster/engine/claude -p
    --output-format stream-json --verbose --dangerously-skip-permissions
    --mcp-config /opt/roster/engine/mcp.json --strict-mcp-config "$@"` (MCP
    flags arrive with WP5; until then omitted).
  - `run-codex`: `exec /opt/roster/engine/codex exec --json
    --skip-git-repo-check "$@" < /dev/null` (codex reads stdin — close it;
    approval/sandbox bypass + MCP servers land in `$CODEX_HOME/config.toml`,
    WP4 — the box is already the sandbox). No shim: reqwest parses the
    token-bearing proxy URL correctly.
- Verify multi-arch: codex's npm package delivers platform binaries via
  optionalDependencies — confirm amd64 + arm64 builds.

Acceptance:
- **Given** the built image, **when** `docker run … run-claude --version` and
  `… run-codex --version` execute, **then** both print the pinned versions,
  offline.
- **Given** a pi-only deployment pulling the new image, **then** pi runs are
  byte-identical (new layers are inert).

## WP3 — Spawn plumbing

Touch: `src/run/boxed.rs`, `src/work/dispatch.rs` (threading only).

- Thread `AgentKind` into `RunSpec` (resolved from config at dispatch;
  CLI-run entry points resolve the same way).
- `pi_prefix()``agent_prefix(engine, agent, mode, session_dir)`:
  - `Pi`: unchanged, both modes.
  - `Claude`/`Codex`: one-shot only; `mode == "rpc"` is unreachable
    (`run_session` pins `Pi` — enforce with a debug assert + comment).
  - Claude: `run-claude` + `--append-system-prompt <compiled>`; input prompt
    stays the positional arg the shared code already appends
    (`boxed.rs:318-323`). The wrapper owns the auth shim + proxy rewrite
    (WP2); the host passes the token via `ROSTER_PROXY_TOKEN` instead of the
    proxy URL for claude runs (WP0: Bun cannot parse userinfo proxy URLs).
  - Codex: `run-codex`; compiled system prompt prepended to the input prompt
    with a delimiter (decision 8) — factor a small
    `compose_prompt(agent, system, input) -> (args, prompt)` helper, unit-testable.
  - `--session-dir` / `--session-id` (cache route key): pi-only. Claude's
    `--session-id` demands a UUID; skip initially, optionally derive UUIDv5
    from `route_key` later for cache affinity.
- Transcripts: point the agent homes into the session mount so they land in
  the host-visible run dir — `CLAUDE_CONFIG_DIR=$SESSION_MOUNT/claude`,
  `CODEX_HOME=$SESSION_MOUNT/codex` (sentinels contain no secrets by design,
  so host visibility is fine and aids debugging).
- Ceiling/signal/finalize paths untouched — they act on the container.

Acceptance:
- **Given** each agent, **when** args assemble for a task run, **then**
  snapshot tests on `agent_prefix` + `compose_prompt` show the exact expected
  command lines, and the pi snapshot is unchanged from today's output.
- **Given** a claude worker task, **when** dispatched for real, **then** the
  box runs to exit and `stdout.jsonl` holds claude's stream-json transcript.

## WP4 — Auth provisioning, env, credential gating

Touch: `boxed.rs` (`prepare_pihome` → per-agent `prepare_home`,
`model_credentials_available`, provision env), `src/config.rs`
(`RESERVED_ENV`), `src/work/dispatch.rs` (queue gating).

- `prepare_home(agent, …)`:
  - Pi: today's behavior, verbatim.
  - Claude: write `$CLAUDE_CONFIG_DIR/.credentials.json` in the WP0-confirmed
    shape (sentinel access/refresh tokens, `expiresAt` now+100y — reuse
    `SENTINEL`/`sentinel_jwt()` `boxed.rs:1356`), plus the minimal settings
    file that marks onboarding complete. Seed sources, in pi-parity order: a
    host claude login (`~/.claude/.credentials.json`) sentinelized, else the
    `anthropic` vault credential.
  - Codex: write `$CODEX_HOME/auth.json` (sentinel `tokens`, account id
    `roster-sentinel-account``sentinel_jwt()` already carries the
    `chatgpt_account_id` claim) and `$CODEX_HOME/config.toml`
    (`approval_policy = "never"`, `sandbox_mode = "danger-full-access"`,
    `mcp_servers.roster` per WP5, telemetry off). Seed: host `~/.codex/auth.json`
    sentinelized, else the `openai-codex` vault credential.
- Env: set `CLAUDE_CONFIG_DIR`/`CODEX_HOME` for those agents; claude runs
  additionally get `ROSTER_PROXY_TOKEN` + `ROSTER_GATEWAY` (for the shim) and
  the telemetry-suppression set from WP0. Add all of these to `RESERVED_ENV`.
  `ANTHROPIC_API_KEY` passthrough (`boxed.rs:933`) applies to claude runs too.
- Find the codex config knob that disables its OTEL export
  (`ab.chatgpt.com/otlp/v1/metrics` — the one denial left in WP0); until
  found, every codex run logs one refusal.
- `model_credentials_available()``model_credentials_available(agent)`:
  pi = today; claude = `anthropic` vault cred ∨ host claude login ∨
  `ANTHROPIC_API_KEY`; codex = `openai-codex` vault cred ∨ host codex login.
- Dispatch gating: hold only tasks whose worker's agent lacks credentials
  (today it's a single global check); provision error message names the
  agent-appropriate `roster connection add …` fix (`boxed.rs:758-764`).

Acceptance:
- **Given** only an `anthropic` vault credential, **when** the queue holds a
  claude-worker task and a codex-worker task, **then** the claude task runs
  and the codex task holds with "no openai-codex credential — run: roster
  connection add openai-codex".
- **Given** any provisioned non-pi run dir, **when** grepped for the real
  token material, **then** nothing matches (test asserts sentinels only).
- **Given** a claude run, **when** it completes, **then** the ledger shows
  zero denied telemetry calls.

## WP5 — The action surface as MCP

Touch: new `box/lib/tools.ts`, new `box/mcp/server.ts`, slim
`box/extensions/actions.ts`/`web.ts` to wrappers, `package.json`
(`@modelcontextprotocol/sdk`, pinned), `box/Dockerfile` (mcp.json for claude,
copy `box/lib` + `box/mcp`), `run-claude` MCP flags, codex `config.toml` entry.

- Extract every tool definition (`{name, description, parameters, execute}`)
  from `actions.ts` + `web.ts` into `box/lib/tools.ts`. `execute` bodies are
  already transport-pure: plain `fetch` to `https://actions.roster.internal/*`
  through `HTTP_PROXY` (built-in fetch only — undici routes through the
  gateway; native clients don't. See the header comment in `actions.ts`).
- `box/extensions/actions.ts`/`web.ts` become ~10-line adapters:
  `for (const t of tools()) api.registerTool(t)` — pi behavior unchanged.
- `box/mcp/server.ts`: stdio MCP server over the same module (Node 24 runs TS
  directly). `ROSTER_TASK_ID` gating of `task_complete`/`task_fail` carries
  over as-is (env-driven).
- Registration: claude via `--mcp-config /opt/roster/engine/mcp.json
  --strict-mcp-config` in `run-claude`; codex via `mcp_servers.roster` in the
  WP4 `config.toml`. Mounted-engine dev mode resolves the server path from the
  checkout (parallel to `box_extensions()` `boxed.rs:1385`).
- Tool naming: claude surfaces them as `mcp__roster__task_complete` etc.;
  briefings say `task_complete`. Expected to resolve fine; if runs show
  friction, add one per-agent sentence to the task-surface briefing
  (`context.rs:705`) — deliberately deferred.
- Note: until WP5 lands, a non-pi worker whose heartbeat curates tasks
  (`set_tasks`) has no way to do so — switch real workers to claude/codex only
  after WP5, or restrict pre-WP5 use to pure-exec/code tasks.

Acceptance:
- **Given** a claude task run, **when** the model calls `task_complete`,
  **then** the action host receives the identical `/outcome` POST a pi run
  sends (run/task provenance intact) and `finalize()` attests completed.
- **Given** a claude code task, **when** `propose_changes` is called after
  edits, **then** a gate files and the task attests needs-review
  (`dispatch.rs:291`).
- **Given** `web_search` via MCP, **then** the DDG request rides the same
  grant as today's pi path.
- **Given** a pi run on the refactored extensions, **then** the registered
  tool list and behaviors are unchanged (regression pass).

## WP6 — Verification pass on lifecycle & output consumers

Mostly verification, small patches where needed.

- E2E per agent (manual script or gated test): trivial task → attests
  completed; failing task (`task_fail`) → failed with reason; ceiling task →
  failed with ceiling message.
- Audit `stdout.jsonl` consumers beyond streaming (`roster server runs show`,
  any transcript pretty-printer) for pi-event assumptions; degrade gracefully
  to raw JSONL for foreign schemas.
- Confirm budget meters/ledger entries for `chatgpt.com`/`api.anthropic.com`
  calls record sanely for non-pi traffic shapes.

## WP7 — Docs & polish

- `docs/configuration.md` (the `agent` key at both scopes, credentials each
  agent needs, sessions-stay-pi), `docs/box.md` (engine section),
  `docs/architecture.md` mention.
- `roster worker show`/`ls`: display the resolved agent.
- Validate/error message review against the fail-closed style.

## Test plan

- **Unit**: config resolution matrix (default/org/worker × valid/invalid/
  channels-warning); `agent_prefix` + `compose_prompt` snapshots (pi snapshot
  frozen to current behavior); sentinel writers' golden files; per-agent
  `model_credentials_available`.
- **Regression**: full existing suite — pi paths must not drift.
- **E2E (docker + real creds, manual/gated)**: matrix {claude, codex} ×
  {plain task, code task + propose_changes, task_fail, ceiling}. Scripted as a
  repo task so it's re-runnable at CLI version bumps.

## Risks & open questions

1. **Credential-file shape drift**: both formats are CLI internals, validated
   2026-07-17 against claude-code 2.1.211 and current codex. Mitigated by
   lockfile pinning + golden tests; version bumps become deliberate,
   spike-checked events.
2. **The auth shim is a new in-box moving part** (claude runs only). Kept
   tiny (~25 lines, CONNECT-only, loopback-only) and unit-tested; its failure
   mode is loud (immediate connection errors in the transcript), not silent.
3. **Refresh attempts**: a CLI deciding to refresh the sentinel hits
   `platform.claude.com`/`auth.openai.com` — denied by the judge, run degrades.
   Far-future expiry should prevent it; WP0 confirms.
4. **Codex prompt composition**: prepending the system prompt into the input
   may weaken instruction adherence vs. a true system slot. Acceptable for
   task runs; revisit when codex exposes a stable instructions flag.
5. **Refusal-count attestation interplay**: any unsuppressed phone-home call
   turns a silent-but-successful run into an attested failure
   (`dispatch.rs:300`). WP0's suppression list is load-bearing; keep it under
   test.
6. **MCP tool-name prefixes** in briefings (claude's `mcp__roster__…`): low
   risk, deferred mitigation identified.
7. **Multi-arch codex binary** in the image build.
8. **Flag churn** in fast-moving CLIs (`--output-format stream-json`,
   `--append-system-prompt`, `codex exec --json`): pinned versions contain it;
   bumps go through the E2E matrix.

## Sequencing

1. **WP0 spike** — ✅ done 2026-07-17, green (see WP0 results above).
2. **WP1 config → WP2 image → WP3 spawn → WP4 auth** — milestone A:
   *pure-exec agents*: a claude/codex worker runs tasks end-to-end (clean exit
   ⇒ completed, `dispatch.rs:307`); code tasks work via host-side worktree,
   but no governed actions, no PR proposals, no task curation.
4. **WP5 MCP bridge** — milestone B: *full parity for task runs* (actions,
   attestation reports, memory, web, task curation — heartbeats included).
5. **WP6 verification → WP7 docs** — milestone C: documented, e2e-tested,
   safe to point real workers at.

Each WP lands independently with pi behavior frozen by tests; nothing here
touches the session path.