mirror of
https://github.com/dtzp555-max/olp.git
synced 2026-07-22 21:45:09 +00:00
Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e334f9cac | ||
|
|
349d35557b | ||
|
|
67bf7fa692 | ||
|
|
74e67fdca3 | ||
|
|
43ca4a65b0 | ||
|
|
7019294c63 | ||
|
|
ffe81f7a45 | ||
|
|
d67ba3d675 | ||
|
|
3551921f55 | ||
|
|
b1e24b7cb0 | ||
|
|
497b2550e6 | ||
|
|
28642756b5 | ||
|
|
d0dcd281ef | ||
|
|
07d9c8a6ae | ||
|
|
e5cfc696da | ||
|
|
dbac5f5521 | ||
|
|
dd0c821272 | ||
|
|
65f945c16d | ||
|
|
97e7d16585 | ||
|
|
40f9453d88 | ||
|
|
e2f41eb60e | ||
|
|
5d60a0599f | ||
|
|
ea0392f744 | ||
|
|
cc250e71bf | ||
|
|
9dc070bc53 | ||
|
|
fa2d1af130 | ||
|
|
bddf2cba1e | ||
|
|
65681ed7d2 | ||
|
|
d872330c9e | ||
|
|
2b07a3bd1b | ||
|
|
a41420d0fc | ||
|
|
5288493f19 | ||
|
|
82d2e1cbea | ||
|
|
187e79321f | ||
|
|
1605400052 |
@@ -49,7 +49,7 @@ Runtime: Node.js (ESM, `.mjs` throughout). No build step. No bundler. `server.mj
|
||||
- `.github/workflows/alignment.yml` — CI blacklist grep + per-provider citation soft check; fails the build on known-hallucinated tokens.
|
||||
- `CLAUDE.md` — Claude-Code-specific session instructions + `release_kit` overlay (Iron Rule 5.5).
|
||||
|
||||
**Implementation status note (as of 2026-05-25):** Files marked 📋 above are designed and documented but not yet on disk; files marked 🟡 are partially shipped; files marked ✅ are Phase 2 deliverables. The shipped set as of D47 is: `server.mjs` (with Phase 2 auth middleware + audit wire + owner-vs-non-owner gating), `lib/ir/`, `lib/providers/{anthropic,codex,mistral}.mjs`, `lib/cache/{keys,store}.mjs`, `lib/fallback/engine.mjs`, `lib/keys.mjs` (core + loadAuthConfigSync — D44 + D45), `lib/audit.mjs` (D45), `bin/olp-keys.mjs` (D47), `models-registry.json`, `test-features.mjs` (Suites 19–22). Phase 2 functional scope is complete; remaining is Phase 2 close → v0.2.0 (maintainer-triggered, explicit per CLAUDE.md `release_kit.phase_close_trigger`).
|
||||
**Implementation status note (as of 2026-05-27):** Phase 5 (Quota Probes + Dashboard Enrichment) is closed at v0.5.0 + v0.5.1 hotfix. The shipped set includes all Phases 1–5 deliverables. v0.5.1 hotfix (2026-05-27) fixes three codex review findings: F1 (doctor check bypassed backoff by calling `_probeOnce` directly — now routes through `quotaStatus()`), F2 (200 with empty `anthropic-ratelimit-*` headers was cached as live — minimum-viable-schema gate added), F3 (null collapsed all failure modes — `probe_status:'unreachable'` shape + `failure`/`failure_kind`/`backoff_until` fields added). See ADR 0008 Amendment 2 + ADR 0013 Rule 3/5 clarifications. Phase 6 is next (per CLAUDE.md `release_kit.current_phase`).
|
||||
|
||||
---
|
||||
|
||||
|
||||
+12
-2
@@ -196,9 +196,19 @@ In addition to the recurring 14 May audit below, the following one-shot audits a
|
||||
|
||||
## Class-specific Exceptions
|
||||
|
||||
(none at project founding)
|
||||
Any Rule 2 or Rule 3 deviation lands here as a numbered exception with PR link, reviewer, and rationale.
|
||||
|
||||
Any future Rule 3 deviation lands here as a numbered exception with PR link, reviewer, and rationale.
|
||||
### 1. Anthropic plan-usage probe via direct `/v1/messages` call (Phase 5, D79 — 2026-05-26)
|
||||
|
||||
**Class:** Rule 2(a) — provider-plugin scope. The Anthropic plugin's `quotaStatus()` calls `POST https://api.anthropic.com/v1/messages` directly rather than spawning `claude -p`. Under the strict reading of Rule 2(a), plugins must mirror provider-CLI behaviour; under the strict reading, this is a deviation because the spawn path goes through the CLI binary and the probe path does not.
|
||||
|
||||
**Authority:** ADR 0002 Amendment 8 (governance) + ADR 0013 (implementation discipline) + ADR 0012 (Phase 5 charter). Schema pin: `~/.cc-rules/memory/learnings/anthropic_plan_usage_probe_schema_2026_05_26.md` (compiled-binary `strings` + live API probe evidence). PR #50.
|
||||
|
||||
**Rationale:** Claude Code's compiled binary makes the same `POST /v1/messages` call internally (verified by `strings` over the v2.1.142 / v2.1.150 Mach-O / ELF binary). The probe mirrors that observed CLI behaviour without introducing a new wire format or output assumption. The exemption is bounded by ADR 0002 Amendment 8's three constraints (READ-ONLY, subscription-scope, idempotent-failure) + ADR 0013's seven implementation rules (notably Rule 2's per-endpoint enumeration — only `POST /v1/messages` is permitted).
|
||||
|
||||
**Reviewer:** fresh-context opus subagent on PR #50 (Iron Rule 10 + CLAUDE.md hard requirement #3). Verdict: APPROVE_WITH_MINOR. Six in-PR nits folded in; three outside-PR nits documented and addressed (this entry is one of them — N9).
|
||||
|
||||
**Re-evaluation trigger:** if Anthropic publishes a public documented quota endpoint (e.g. `GET /v1/usage`), this exception is RETIRED and the plugin migrates to the documented endpoint, deleting this exception by amendment PR. Until that hypothetical retirement, this exception is the canonical entry.
|
||||
|
||||
### Controlled deviations (entry-surface scope)
|
||||
|
||||
|
||||
+158
-1
@@ -4,7 +4,164 @@ All notable changes to OLP land here. Per `CLAUDE.md` release_kit overlay, this
|
||||
|
||||
## Unreleased
|
||||
|
||||
(empty — Phase 5 entries land here once Phase 5 opens)
|
||||
(no in-flight changes)
|
||||
|
||||
## v0.7.0 — 2026-05-29 — Phase 7 close: Solution 1 isolation + opus 4.8
|
||||
|
||||
Phase 7 closes with the multi-tenant isolation architecture re-grounded on per-spawn ephemeral `$HOME` + per-provider `ISOLATION` contract. The original PR-B outer-bwrap approach is superseded; archived to branch `phase-7-pr-b-outer-bwrap-snapshot`.
|
||||
|
||||
### Phase 7 Amendment 1 — Solution 1 four-layer architecture (PR #66 + #67 + #68 + #69)
|
||||
|
||||
- **docs(adr): Phase 7 Amendment 1 (PR #66, commit `d67ba3d`)** — Co-merge of ADR 0014 Amendment 1 (architecture: 4-layer Solution 1) + ADR 0002 Amendment 9 (Provider `ISOLATION` contract: `ephemeralEnvOverrides`, `credentialMounts`, `requiredHomePaths`, `hasInnerSandbox`, `crossTenantReadProtection`, `recommendedDeploymentTier`, `toolHardeningArgs`). Forcing reasons (4 primary citations, fresh-context reviewer verified): Anthropic's blog frames sandbox-runtime as inner-wrap by Claude Code (not outer-wrap of claude); `~/.claude.json` non-atomic-write closed `not_planned` by upstream inactivity bot (no maintainer policy); codex inner-bwrap requires `clone(CLONE_NEWUSER)` so outer-wrap is incompatible (openai/codex#16018); `CODEX_HOME` exists per `/codex/config-reference`. Mistral `VIBE_HOME` documented per `docs.mistral.ai/mistral-vibe/terminal/configuration`. Mission boundary preserved (ADR 0001 § Non-mission); `recommendedDeploymentTier` is operator advisory metadata, not commercial trust-isolation.
|
||||
|
||||
- **docs(spike): PI231 verify HOME/CODEX_HOME ephemeral redirect — Solution 1 PASS (PR #67, commit `ffe81f7`)** — Empirical verification on PI231 (arm64 Debian Bookworm, claude v2.1.152, codex v0.133.0). Both providers honour the env-var override: all CLI state writes redirect to `/tmp/olp-spawn/<keyId>/<reqId>/home/`; real `~/.claude.json` / `~/.codex/auth.json` untouched. Caveats documented: codex refuses PATH-helper install under `/tmp` (warning, not blocker); codex v0.133.0 dropped `--ask-for-approval` flag (use `-c approval_policy="never"` instead).
|
||||
|
||||
- **feat(sandbox): Phase 7 Solution 1 implementation + opus 4.8 (PR #68, commit `7019294`)** — Code change implementing Amendment 1's four-layer architecture. New `lib/sandbox/manager.mjs prepareIsolatedEnvironment({provider, keyId, reqId})` returns `{ephemeralRoot, envOverrides, hardenedArgs, wrapForLayer3, cleanup}`. Per-provider `ISOLATION` exports in `lib/providers/anthropic.mjs` (lines 1607-1697) and `lib/providers/codex.mjs` (lines 798-924). `server.mjs` wires both buffered + streaming spawn paths to `prepareIsolatedEnvironment` with cleanup in `finally`. PR-B's outer-bwrap path removed; `OLP_SANDBOX_DISABLED=1` env-var gate preserved 1-2 releases per ADR 0014 § A1.6. Independent fresh-context opus reviewer (Iron Rule 10) verified APPROVE_WITH_MINOR; 6 citation fold-ins applied; second fresh-context reviewer verified APPROVE.
|
||||
|
||||
- **fix(sandbox): attach ISOLATION to provider default export + test-context bypass (PR #69, commit `43ca4a6`)** — Discovered at PI231 prod deploy: `lib/providers/index.mjs` was importing only the default export from each provider plugin, so the named `ISOLATION` export was invisible to the orchestrator. Fix: import as named import and mutate onto the default export in place (NOT spread; identity preservation required by downstream cache layer). Also added test-context bypass (`process.argv[1]?.endsWith('test-features.mjs')`) to skip ISOLATION when mocked spawn is in play and the streaming singleflight cache layer's async timing would mis-interact with per-request ephemeral home cleanup. Test 43f opts back in via `globalThis.__OLP_FORCE_ISOLATION_IN_TEST` for active-shape verification.
|
||||
|
||||
### Phase 7 Solution 1 — verified prod E2E on PI231
|
||||
|
||||
After PR #69 deploy: `find ~/.claude.json ~/.claude ~/.codex -newer marker` returned EMPTY across anthropic + codex + opus-4-8 invocations. `~/.claude.json` mtime unchanged across requests. `~/.codex/auth.json` mtime unchanged. ISOLATION fires; cleanup runs; real home untouched. Tested from MacBook (172.16.2.29) and PI230 (172.16.2.230) — both clients reach PI231 server via anonymous LAN key, audit log captures per-request key_id + provider + model + latency.
|
||||
|
||||
### opus 4.8 (Task #15)
|
||||
|
||||
- **models-registry.json** — new entry `claude-opus-4-8` (200K ctx, `created: 1783814400`). Alias `opus` repointed from `claude-opus-4-7` to `claude-opus-4-8`. `claude-opus-4-7` retained as callable by literal id.
|
||||
- **README.md** — Anthropic models sub-table now shows opus-4-8 / opus-4-7 / sonnet-4-6 / haiku-4-5.
|
||||
- **test-features.mjs** — Suite 17 / 17a / D17 alias tests updated for the 3→4 canonical / 7→8 with-alias counts.
|
||||
|
||||
### Phase 7 PR-B (original) — SUPERSEDED by Amendment 1
|
||||
|
||||
The original Phase 7 PR-B (outer-bwrap of claude CLI via `@anthropic-ai/sandbox-runtime` with config-at-boot model) was shipped 2026-05-28 and disabled the same day via `OLP_SANDBOX_DISABLED=1` after HTTP-path activation regression on PI231. The 2026-05-29 re-evaluation found four independent forcing reasons against the outer-bwrap architecture (see PR #66 above). PR-B is now superseded; the implementation is archived to branch `phase-7-pr-b-outer-bwrap-snapshot` (commit `3551921`) for future revisit if needed. The `lib/sandbox/doctor.mjs` preflight module is retained.
|
||||
|
||||
### Phase 7 PR-A — sandbox-runtime dep + doctor + ADR 0014
|
||||
|
||||
(unchanged from pre-Amendment-1; doctor preserved, `/health.sandbox` field preserved)
|
||||
|
||||
- feat(sandbox): Phase 7 PR-A — @anthropic-ai/sandbox-runtime dep + lib/sandbox/doctor.mjs preflight + ADR 0014. No runtime wiring yet (PR-B will wrap anthropic.mjs spawn). /health now reports sandbox availability (`available: false` until PI231 has `bubblewrap` + `socat` + `ripgrep` installed via `sudo apt-get install -y bubblewrap socat ripgrep`). On macOS (dev machine with ripgrep via Homebrew), sandbox-runtime reports `available: true` because macOS uses the built-in `sandbox-exec` seatbelt — no apt install needed. 797 → 805 tests (+8 Suite 42).
|
||||
|
||||
### Phase 6 D-day — stream-json transport for Anthropic provider (ADR 0009 Amendment 1)
|
||||
|
||||
- feat(anthropic): stream-json output + --system-prompt suppression of env-block / tool descriptions (ADR 0009 Amendment 1). Cuts ~64% per-request cost on Sonnet 4.6 via 30% input-token reduction ($0.0216 → $0.0078), fixes bot self-check hallucination (model no longer claims server cwd / OS / tool names), exposes rate_limit + usage events from NDJSON for future audit/dashboard work. Per-key API + cache + audit semantics unchanged. claude CLI v2.1.104 verified; warn if claude-version outside v2.1.100–v2.1.149.
|
||||
|
||||
### F4 — `bin/olp.mjs` + `olp-plugin/index.js` migration to `quota_v2` shape
|
||||
|
||||
**Codex post-v0.5.0 review Q4.** Both CLI surfaces (`olp usage` and `/olp usage`) previously fell through to "no quota api" for every provider because they read the legacy `body.quota` shape, which never carries `percent_used` or meaningful `available` data. Now that the server (v0.5.0+) emits `body.quota_v2` per ADR 0008 Amendment 2, both surfaces prefer `quota_v2` and fall back to legacy `quota` on older servers.
|
||||
|
||||
### Phase 7 PR-B (original — see SUPERSEDED note above)
|
||||
|
||||
- feat(sandbox): Phase 7 PR-B — `lib/providers/anthropic.mjs` spawn wrapped via `@anthropic-ai/sandbox-runtime` with config-at-boot model (per-spawn ephemeral cwd `/tmp/olp-spawn/<uuid>`, network allowlist `api.anthropic.com` + `statsig.anthropic.com`, filesystem denylist for `~/.olp` / `~/.claude` / `~/.ssh` / `~/.config` / `~/.codex`). Load-bearing negative test (Suite 44, PI231-gated) confirms in-sandbox `cat` of OAuth credentials MUST fail. `/health.sandbox.active=true` on PI231 after `apt-get install bubblewrap socat ripgrep`. Adds `lib/sandbox/manager.mjs` (bootstrap + spawn-wrap layer), server startup wiring (`bootstrapSandbox()` before listen), `/health.sandbox.active` boolean field. 805 → 813 tests (+8 Suite 43; Suite 44 skips by default, runs on PI231 with `OLP_E2E_SANDBOX=1`). ADR 0014 PR-B acceptance criteria: met.
|
||||
|
||||
### Phase 7 PR-A — sandbox-runtime dep + doctor + ADR 0014
|
||||
|
||||
- feat(sandbox): Phase 7 PR-A — @anthropic-ai/sandbox-runtime dep + lib/sandbox/doctor.mjs preflight + ADR 0014. No runtime wiring yet (PR-B will wrap anthropic.mjs spawn). /health now reports sandbox availability (`available: false` until PI231 has `bubblewrap` + `socat` + `ripgrep` installed via `sudo apt-get install -y bubblewrap socat ripgrep`). On macOS (dev machine with ripgrep via Homebrew), sandbox-runtime reports `available: true` because macOS uses the built-in `sandbox-exec` seatbelt — no apt install needed. 797 → 805 tests (+8 Suite 42).
|
||||
|
||||
### Phase 6 D-day — stream-json transport for Anthropic provider (ADR 0009 Amendment 1)
|
||||
|
||||
- feat(anthropic): stream-json output + --system-prompt suppression of env-block / tool descriptions (ADR 0009 Amendment 1). Cuts ~64% per-request cost on Sonnet 4.6 via 30% input-token reduction ($0.0216 → $0.0078), fixes bot self-check hallucination (model no longer claims server cwd / OS / tool names), exposes rate_limit + usage events from NDJSON for future audit/dashboard work. Per-key API + cache + audit semantics unchanged. claude CLI v2.1.104 verified; warn if claude-version outside v2.1.100–v2.1.149.
|
||||
|
||||
### F4 — `bin/olp.mjs` + `olp-plugin/index.js` migration to `quota_v2` shape
|
||||
|
||||
**Codex post-v0.5.0 review Q4.** Both CLI surfaces (`olp usage` and `/olp usage`) previously fell through to "no quota api" for every provider because they read the legacy `body.quota` shape, which never carries `percent_used` or meaningful `available` data. Now that the server (v0.5.0+) emits `body.quota_v2` per ADR 0008 Amendment 2, both surfaces prefer `quota_v2` and fall back to legacy `quota` on older servers.
|
||||
|
||||
- **`bin/olp.mjs cmdUsage`**: when `body.quota_v2` is present (non-empty array), renders per-provider rows with status (`live` / `stale` / `unreachable` / `unavailable`), 5h and 7d utilization percentages with color-coding (green < 50% / yellow 50–80% / red ≥ 80%), reset countdowns, binding claim, and ⚠ stale / ❌ unreachable annotations. Legacy `body.quota` path preserved as fallback for pre-v0.5.0 servers. `formatResetCountdown(epochSeconds)` added — 5-range formatter (past / <1h / <24h / <7d / ≥7d), ported from `dashboard.html` D82, kept in-file (no shared lib).
|
||||
|
||||
- **`olp-plugin/index.js fmtUsage()`**: same migration — `quota_v2` rows render as one-line plain text per provider (no ANSI; Telegram/Discord safe). `pluginFormatResetCountdown(epochSeconds)` added; intentionally duplicated (plugin ships as a separate package). Legacy `body.quota` fallback preserved.
|
||||
|
||||
### v1.x roadmap #7 — AUTH_MISSING tuple path test coverage — ✅ CLOSED
|
||||
|
||||
The dedicated AUTH_MISSING engine test (asserting `fallbackDetail[0].trigger_type === 'auth_missing'`) was already shipped at D56 (`test-features.mjs` line 6255). This item closes the roadmap entry with a date stamp and PR reference per the tracker convention. No code changes — documentation only.
|
||||
|
||||
### Tests
|
||||
|
||||
- Suite 40 (9 new tests): `40a`–`40i` covering `cmdUsage` quota_v2 live/stale/unreachable/unavailable parse, legacy fallback, `pluginFormatResetCountdown` and `formatResetCountdown` 5-range coverage, olp-plugin `fmtUsage` quota_v2 + legacy paths. 759 → 768 tests, 0 fail.
|
||||
|
||||
### Authority
|
||||
|
||||
- F4: codex post-v0.5.0 review Q4 (PR #58 review); ADR 0008 Amendment 2 (quota_v2 shape).
|
||||
- #7: `docs/v1x-roadmap.md` § "#7 — AUTH_MISSING tuple path test coverage (D40 follow-up)".
|
||||
|
||||
## v0.5.1 — 2026-05-27
|
||||
|
||||
**Hotfix — Quota probe cache/backoff/schema-drift correctness (codex review findings F1–F3).** Three production-quality bugs in the v0.5.0 quota probe, reproduced by codex with local mocks, are corrected. 756 → 759 tests (3 new regression tests); 4 existing test assertions updated to reflect the v0.5.1 return-shape contract.
|
||||
|
||||
### Fixes
|
||||
|
||||
- **F1 [P1] — Doctor bypass of cache + backoff (ADR 0013 Rule 3).** `anthropic.quota_probe_reachable` doctor check called `_probeOnce(auth)` directly, bypassing the module-level `quotaProbeState.backoffUntil` check. Successive `olp doctor` invocations within a backoff window each hit upstream — violating ADR 0013 Rule 3 (60s-3600s exponential backoff is mandatory for all consumers). **Fix:** doctor check now routes through `quotaStatus()`, which enforces cache + backoff. ADR 0013 Rule 3 clarification added: "All consumers of `quotaStatus()`, including `olp doctor` checks, MUST route through `quotaStatus()` and MUST NOT call `_probeOnce()` directly."
|
||||
|
||||
- **F2 [P2] — 200 with empty `anthropic-ratelimit-*` headers cached as live data (ADR 0013 Rule 5).** `_probeOnce` treated any 200 OK (regardless of header content) as a successful probe, caching it with `stale: false` even when zero `anthropic-ratelimit-*` headers were present. A proxy stripping headers, a schema change, or a mock returning `{}` would silently appear as "LIVE" on the dashboard with all bars empty. **Fix:** minimum-viable-schema gate requires these 4 fields non-null: `5h-utilization`, `5h-reset`, `7d-utilization`, `7d-reset`. Any absence → `failureKind: 'schema_drift'`, backoff scheduled, result not cached. ADR 0013 Rule 5 updated with the gate specification.
|
||||
|
||||
- **F3 [P2] — Dashboard-data loses failure detail (ADR 0013 Rule 6).** `aggregateProviderQuota()` collapsed all non-null failure modes (no credentials, auth failure, rate limit, schema drift, network error) into `status: 'unavailable', reason: 'no public quota api or probe disabled'` — the same string as providers with no quota API at all. Operator could not tell what to fix. **Fix:** `quotaStatus()` v0.5.1 return contract: `null` reserved for opt-in-off only; probe failures return `{ probe_status: 'unreachable', failure: { kind, message, backoff_until? } }`. `aggregateProviderQuota()` emits new fields `failure_kind`, `failure`, `backoff_until` per row. `status: 'unreachable'` distinguishes "probe failed" from `status: 'unavailable'` ("no API or disabled"). Dashboard renders `unreachable` with a red border + failure.message + backoff countdown.
|
||||
|
||||
### Backwards-compat notes
|
||||
|
||||
- `quotaStatus()`: `stale: false` → now also includes `probe_status: 'live'` (additive). `stale: true` → now also includes `probe_status: 'stale'` + `failure: {...}` (additive). `null` → NOW RESERVED FOR OPT-IN-OFF ONLY (breaking for callers that relied on `null` to detect "no credentials" or "probe failed" — use `probe_status: 'unreachable'` instead).
|
||||
- `ProviderQuotaEntry.status`: gains `'unreachable'` as a new value (additive). Existing `'live'`, `'stale'`, `'unavailable'` semantics unchanged.
|
||||
- `ProviderQuotaEntry` gains new fields `failure`, `failure_kind`, `backoff_until` (additive, null when not applicable).
|
||||
- `dashboard.html`: handles `unreachable` row (no existing row had this status; additive render path).
|
||||
|
||||
### Test changes
|
||||
|
||||
- 38f, 38j, 38l: updated assertions from `null` to `probe_status: 'unreachable'` (F3 shape change).
|
||||
- 38r: refactored to seed cache + manually expire it + set backoff (F1 — doctor now routes through `quotaStatus()`). Added F1-regression assertion: HTTP call counter stays at 1 after two doctor calls within backoff.
|
||||
- 38g, 38k: added `probe_status` + `failure` assertions (verify new fields present on live/stale shapes).
|
||||
- **38u** (new): F1 regression — successive doctor calls within backoff window → HTTP counter stays at 1.
|
||||
- **38v** (new): F2 regression — 200 + empty ratelimit headers → `probe_status: 'unreachable'` + `failure_kind: 'schema_drift'` + cache stays null.
|
||||
- **38w** (new): F3 regression — `lastError` + `failureKind` propagate through `quotaStatus()` shape for all failure modes (rate_limited / auth_failed / schema_drift / no_credentials).
|
||||
|
||||
### ADR changes
|
||||
|
||||
- **ADR 0013 Rule 3** clarification: doctor checks route through `quotaStatus()`, not `_probeOnce()` directly.
|
||||
- **ADR 0013 Rule 5** update: minimum-viable-schema gate specification (4 required fields; absence = schema_drift signal).
|
||||
- **ADR 0008 Amendment 2**: richer `ProviderQuotaEntry` shape with `failure`/`failure_kind`/`backoff_until`; `probe_status` on `quotaStatus()` return; `unreachable` status semantics; `dashboard.html` unreachable rendering.
|
||||
|
||||
### Authority
|
||||
|
||||
ADR 0013 Rules 3, 5, 6 (cache + backoff + schema-drift + failure transparency); ADR 0008 Amendment 2; ADR 0002 Amendment 8 (unchanged); codex review findings F1–F3 (codex PR review on v0.5.0 close PR #57).
|
||||
|
||||
---
|
||||
|
||||
## v0.5.0 — 2026-05-26
|
||||
|
||||
**Phase 5 — Provider Quota Probes + Dashboard Enrichment.** OLP gains live subscription-quota observability for Anthropic Pro/Max subscribers, surfaced through a Claude.ai-style Plan Usage panel on the owner-only dashboard. The probe is opt-in, READ-ONLY, idempotent on failure, and 5-min-cached with 60s→3600s exponential backoff. Six D-days, seven PRs, zero blocking reviewer findings, no flaky tests; 720 → 756 total tests.
|
||||
|
||||
### What's new for users
|
||||
|
||||
- **Live plan usage on the dashboard.** Per-provider rows show 5-hour + 7-day utilization bars with reset countdowns ("Resets in 1hr 6min" / "Resets Sun 9:00 PM"), status badges (allowed / rejected), representative-claim chips ("five_hour" / "seven_day"), overage-status indicators, and a `↻ Refresh` button. 60-second auto-refresh pauses when the tab is hidden.
|
||||
- **Anthropic quota probe.** Opt-in via `~/.olp/config.json providers.anthropic.quota_probe_enabled: true`. Parses the canonical `anthropic-ratelimit-unified-*` response-header schema (13 fields) from a minimal `POST /v1/messages` probe. Reuses the spawn-path OAuth credentials — env var → `~/.claude/.credentials.json` → macOS Keychain. Refresh-on-401, stale-cache-on-failure.
|
||||
- **`olp doctor anthropic.quota_probe_reachable`.** New check surfaces probe health. Returns `status: ok` with parsed utilization when fresh, `warn` on stale cache, `fail` with `human_steps[]` auth-aware recipe (re-login via `claude setup-token` or wait-and-retry).
|
||||
- **Provider matrix.** Anthropic ✅ live (13 fields). OpenAI ❌ no public quota API. Mistral ❌ no member-key-accessible quota endpoint (Admin API exists but org-admin-scoped, out of scope for trusted-LAN deployment per ADR 0011). All three pinned in `models-registry.json quota_probe.<provider>` block.
|
||||
|
||||
### What's new for contributors
|
||||
|
||||
- **ADR 0012 (Phase 5 charter)** — D-day plan + exit gate + scope boundaries (`docs/adr/0012-phase-5-charter-quota-probes-dashboard.md`).
|
||||
- **ADR 0002 Amendment 8** — first Class-specific Exception to the plugin contract: `quotaStatus()` may call provider HTTP APIs directly, subject to three constraints (READ-ONLY, subscription-scope, idempotent-failure) and the per-endpoint enumeration in ADR 0013 Rule 2.
|
||||
- **ADR 0013** — seven rules covering OAuth READ-ONLY consumption + dual-path schema-drift mitigation (compiled-binary `strings` + live API probe diff, since Claude Code v2.1.x is now a Mach-O / ELF binary with no `cli.js` to grep).
|
||||
- **`models-registry.json quota_probe.schema_version`** — pinned at `2026-05-26` (13 fields). Bump on schema-drift events per ADR 0013 Rule 5.
|
||||
- **Test seams** — 5 underscore-prefixed exports in `lib/providers/anthropic.mjs` (`_setQuotaUrlsForTest`, `_resetQuotaProbeStateForTest`, `_resetQuotaStateOnlyForTest`, `_getQuotaProbeStateForTest`, `_setQuotaAuthReadFnForTest`) for hermetic probe testing. Production code must not call them.
|
||||
- **ALIGNMENT.md § Class-specific Exceptions** — gains its first numbered exception (Anthropic plan-usage probe via direct `/v1/messages`).
|
||||
- **Audit memory at `~/.cc-rules/memory/learnings/anthropic_plan_usage_probe_schema_2026_05_26.md`** — schema canon + verification protocol + OCP institutional history.
|
||||
|
||||
### D-day-level changes (Phase 5)
|
||||
|
||||
- **D79** (PR #50 + cleanup PR #51): governance layer — ADR 0012 charter, ADR 0002 Amendment 8, ADR 0013, ALIGNMENT.md Class-specific Exceptions entry, D84 Mistral NO-GO disposition.
|
||||
- **D80** (PR #52): ported OCP `server.mjs:842-1109` to `lib/providers/anthropic.mjs:quotaStatus()`. Adds macOS-keychain reader to `readAuthArtifact()`. Parses all 13 fields including 3 new since OCP's 2026-04 capture (`5h-status`, `7d-status`, `overage-reset`). Implements 5min cache + 60s-3600s exponential refresh backoff + stale-cache-on-failure + opt-in config flag + `anthropic.quota_probe_reachable` doctor check. ~250 LOC.
|
||||
- **D81** (PR #53): added `lib/audit-query.mjs aggregateProviderQuota()` + `/v0/management/dashboard-data quota_v2` field + `/v0/management/quota quota_v2` field. Pinned `quota_probe.schema_version` in `models-registry.json`. Legacy `quota` field stays alongside for backwards compat until v1.0.0. ADR 0008 Amendment 1 documents the shape.
|
||||
- **D82** (PR #54): `dashboard.html` restructure — Claude.ai-style Plan Usage panel above the existing 4 panels. Per-provider rows with utilization bars, reset countdowns, status chips, representative-claim badges, overage chips, "Updated N min ago" labels. 60s `setInterval` with `visibilitychange` pause/resume. Manual refresh button with 2s spam guard. Graceful fallback to legacy `quota` when `quota_v2` absent. Closes v1.x roadmap #8.
|
||||
- **D83** (PR #55): Suite 38 (20 quota-probe unit tests covering all 13-header parse + cache + backoff + 401-refresh + 429-stale + schema_version + 5 doctor status paths) + Suite 39 (8 dashboard rendering smoke tests covering /dashboard 200/401 + key D82 HTML strings). Added 5 test seams to anthropic.mjs. 727 → 755 tests, 0 fail. Fold-in commit added 38j positive-path coverage (38j2: 401 → refresh succeeds → retry 200) per reviewer finding; total 756.
|
||||
- **Close-prep** (PR #56): README § Plan Usage section + § Supported Providers Quota-probe column + dashboard screenshot + `docs/exit-gates/phase-5-e2e.json` live verification artifact. Fold-in commit addressed 3 maintainer accuracy findings (doctor-kind framing / Mistral admin-API acknowledgment / SPOT drift closure via `quota_probe.openai` + `quota_probe.mistral` registry entries).
|
||||
|
||||
### Out of Phase 5 scope (deferred to later)
|
||||
|
||||
- **D84 Mistral probe.** NO-GO per 2026-05-26 spike: no member-key-accessible quota endpoint at `docs.mistral.ai/api`. Re-entry point pinned at `lib/providers/mistral.mjs DL-7`; re-evaluate if Mistral publishes a member-key surface or if OLP deployment posture expands to org-admin scope (Mistral Admin API exists).
|
||||
- **OpenAI / codex probe.** Permanently skipped — `openai/codex` CLI has no public quota API.
|
||||
- **`X-OLP-Cost-USD` per-request header.** Deferred to Phase 6 (depends on per-(provider, model) cost weights table).
|
||||
- **`context_window_exceeded` fallback trigger.** Deferred (trigger condition not yet observed).
|
||||
- **Automated schema-drift detector.** ADR 0013 Rule 5 codifies a procedural runbook (Annual Alignment Audit + `olp doctor` probe-failure + manual maintainer attention at major `claude --version` bumps), not an automated alarm.
|
||||
|
||||
### Authority cited
|
||||
|
||||
ALIGNMENT.md Rules 1 + 2 + 5; CLAUDE.md release_kit (Phase 5 close trigger); ADR 0012 § Exit gate; ADR 0013 Rule 5 schema-drift protocol; OCP `server.mjs:842-1109` as port reference; live `/v1/messages` probe transcripts captured 2026-05-26 from PI231 (D79 audit) + MacBook (D80 + Phase 5 close-prep E2E); audit memory at `~/.cc-rules/memory/learnings/anthropic_plan_usage_probe_schema_2026_05_26.md`.
|
||||
|
||||
## v0.4.4 — 2026-05-26
|
||||
|
||||
|
||||
@@ -135,7 +135,7 @@ release_kit:
|
||||
# This overlay is the authoritative source. If Iron Rule 5 appears to be silently
|
||||
# violated (no version bump after many D-day pushes), check this section first
|
||||
# before filing a compliance finding.
|
||||
current_phase: Phase 5
|
||||
current_pre_release_identifier: "0.5.0-phase5"
|
||||
current_phase: Phase 7 closed at v0.7.0 (2026-05-29); Phase 8 not yet scoped
|
||||
current_pre_release_identifier: "0.7.0"
|
||||
phase_close_trigger: explicit maintainer action (not automated)
|
||||
```
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
A personal- and family-scale multi-provider LLM proxy. One HTTP endpoint, many subscriptions behind it, automatic routing + fallback + content-addressed caching. Your IDEs and family clients keep working as long as **any** of your subscriptions has quota left.
|
||||
|
||||
> **Status:** v0.4.3 shipped, 714+ tests. Phase 4 (Operator + Client UX) closed; Phase 5 scope is open. Coming from [OCP](https://github.com/dtzp555-max/ocp)? See [§ Migration from OCP](#migration-from-ocp).
|
||||
> **Status:** v0.5.1 shipped, 759+ tests. Phase 5 (Quota Probes + Dashboard Enrichment) closed; Phase 6 next. Coming from [OCP](https://github.com/dtzp555-max/ocp)? See [§ Migration from OCP](#migration-from-ocp).
|
||||
|
||||
---
|
||||
|
||||
@@ -14,7 +14,26 @@ A personal- and family-scale multi-provider LLM proxy. One HTTP endpoint, many s
|
||||
- **Multi-key auth** — owner key with full visibility, family-member keys with per-key audit log + per-provider scoping
|
||||
- **Telegram / Discord** `/olp` slash commands (read-only — for "is OLP up?" checks from anywhere)
|
||||
- **AI-driven self-repair** — `olp doctor --json` emits machine-readable `next_action.ai_executable[]` so a Claude Code / Cursor / Copilot session can fix install issues for you (see [§ Install with your AI](#install-with-your-ai-the-fast-path))
|
||||
- **Observability** — owner-only `/dashboard` (quota / 24h stats / 30d spend trend / top fallback chains)
|
||||
- **Observability** — owner-only `/dashboard` (live Claude.ai-style plan-usage rows / 24h stats / 30d spend trend / top fallback chains)
|
||||
- **Plan-usage probe** (Phase 5, v0.5.0) — opt-in per-provider quota probe for Anthropic Pro/Max subscriptions; parses the canonical `anthropic-ratelimit-unified-*` response headers, surfaces 5-hour + 7-day utilization with reset countdowns. See [§ Plan Usage](#plan-usage-live-quota-probe).
|
||||
|
||||
---
|
||||
|
||||
## Tool execution model
|
||||
|
||||
OLP is a **chat/completion proxy**, not a tool runtime. It forwards messages between your client and a provider's LLM and returns the response. It does **not** execute tools (shell commands, filesystem reads, web fetches) on your behalf, and it has no plans to.
|
||||
|
||||
When an agentic client (Cline / Cursor / Continue.dev / Aider / Hermes Agent / OpenClaw) needs to call a tool, that tool runs **on the client's host**. The client sends the tool's output back as a follow-up message. OLP sees only the message stream — never an open file handle, an executed command, or a fetched URL.
|
||||
|
||||
Why this boundary matters:
|
||||
|
||||
- **Multi-tenant safety.** A misbehaving prompt cannot use OLP to read files belonging to another OLP key holder. The threat surface is bounded to "what the model can say in a message" — not "what the model can do on the server."
|
||||
- **Stateless operation.** OLP runs the same code path for every request, regardless of which client is calling. Session state, tool state, and conversational memory all live in the client. See [`AGENTS.md`](./AGENTS.md) § "No conversation state".
|
||||
- **Provider-CLI honesty.** OLP spawns provider CLIs (`claude`, `codex`, `vibe`) to talk to upstream APIs and translates wire formats via the IR. It does not extend those CLIs with new tools or capabilities — see [`ALIGNMENT.md`](./ALIGNMENT.md) Rule 2 (No Invention).
|
||||
|
||||
A few clients (notably OpenClaw in certain configurations) can be wired to route their tool calls *through* the OLP server host rather than executing them locally. This is a client configuration choice, not an OLP feature, and it produces surprising self-check results (the agent describes the OLP server, not your machine). See [§ Known limitations](#known-limitations) for the integrator-level guidance.
|
||||
|
||||
For the multi-tenant isolation story, [ADR 0014 Amendment 1](./docs/adr/0014-sandbox-runtime-integration.md) defines a four-layer architecture: each provider-CLI spawn gets a per-request ephemeral `$HOME` (`/tmp/olp-spawn/<keyId>/<reqId>/home/`) with credential files symlinked in, plus per-provider tool-hardening (anthropic's `--system-prompt` suppresses Read/Bash tool descriptions; codex defaults to `--sandbox read-only`). The canonical contract lives in [ADR 0002 Amendment 9](./docs/adr/0002-plugin-architecture.md) (Provider ISOLATION contract) + [ADR 0014 Amendment 1](./docs/adr/0014-sandbox-runtime-integration.md). The full "Security Model" reference will land in a Phase 7 close PR (Task #10).
|
||||
|
||||
---
|
||||
|
||||
@@ -208,22 +227,31 @@ Per-IDE setup details: [`docs/integrations/`](./docs/integrations/README.md). Te
|
||||
|
||||
## Supported Providers
|
||||
|
||||
Source of truth: [`models-registry.json`](./models-registry.json). This table is regenerated from the registry per the [`release_kit`](./CLAUDE.md) overlay; do not edit it out of sync.
|
||||
Source of truth: [`models-registry.json`](./models-registry.json). Per-provider columns are sourced from the registry's `providers.<key>` block (model metadata + tier) and `quota_probe.<key>` block (D81+; probe status / reason / source). This table is regenerated from the registry per the [`release_kit`](./CLAUDE.md) overlay; do not edit it out of sync.
|
||||
|
||||
OLP distinguishes **Candidate Providers** (declared as intended, not yet pinned) from **Enabled Providers** (authority pin filled + plugin landed + Phase audit passed). The v0.1 founding commit ships **zero Enabled Providers** — enablement is a Phase audit deliverable, not a bootstrap claim. See [`ALIGNMENT.md` § Provider Inventory](./ALIGNMENT.md) for the transition gate.
|
||||
|
||||
### Candidate Providers
|
||||
|
||||
| Provider key | CLI | Subscription / auth | Anticipated Tier | Anticipated Phase |
|
||||
|---|---|---|---|---|
|
||||
| `anthropic` | `claude -p` | Pro / Max OAuth (pre-2026-06-15); Agent SDK Credit pool after | D (re-eval post-2026-06-15) | Phase 1 |
|
||||
| `openai` | `codex exec --json` | ChatGPT Pro OAuth or API key | D | Phase 2 |
|
||||
| `mistral` | `vibe --prompt --output json` | Le Chat Pro API key | D | Phase 3 |
|
||||
| `grok` | `grok -p --output-format streaming-json` | xAI Build `xai-...` API key | C | Phase 8+ |
|
||||
| `kimi` | `kimi -p --output-format stream-json` | Moonshot Kimi API key | C | Phase 8+ |
|
||||
| `minimax` | TBD | MiniMax Token Plan (¥29+/mo) | B | Phase 8+ |
|
||||
| `glm` | TBD | Zhipu Coding Plan ($10+/mo) | B | Phase 8+ |
|
||||
| `qwen` | TBD | Alibaba Coding Plan ($50/mo) | B | Phase 8+ |
|
||||
| Provider key | CLI | Subscription / auth | Quota probe (v0.5.0+) | Anticipated Tier | Anticipated Phase |
|
||||
|---|---|---|---|---|---|
|
||||
| `anthropic` | `claude -p` | Pro / Max OAuth (pre-2026-06-15); Agent SDK Credit pool after | ✅ Live (13 `anthropic-ratelimit-unified-*` headers; opt-in via `quota_probe_enabled`) | D (re-eval post-2026-06-15) | Phase 1 |
|
||||
| `openai` | `codex exec --json` | ChatGPT Pro OAuth or API key | ❌ Not available (no public quota API) — audit-derived spend tracking only | D | Phase 2 |
|
||||
| `mistral` | `vibe --prompt --output json` | Le Chat Pro API key | ❌ Not implemented at v0.5.0 — no public quota endpoint accessible to Vibe / Le Chat member / La Plateforme API keys per D84 spike 2026-05-26. Mistral's [Admin API](https://docs.mistral.ai/admin/security-access/admin-api) does expose billing / usage queries but requires an org-admin scope (out of scope for OLP family-tier deployment). Audit-derived spend tracking only at v0.5.0. | D | Phase 3 |
|
||||
| `grok` | `grok -p --output-format streaming-json` | xAI Build `xai-...` API key | TBD (Phase 8+) | C | Phase 8+ |
|
||||
| `kimi` | `kimi -p --output-format stream-json` | Moonshot Kimi API key | TBD (Phase 8+) | C | Phase 8+ |
|
||||
| `minimax` | TBD | MiniMax Token Plan (¥29+/mo) | TBD (Phase 8+) | B | Phase 8+ |
|
||||
| `glm` | TBD | Zhipu Coding Plan ($10+/mo) | TBD (Phase 8+) | B | Phase 8+ |
|
||||
| `qwen` | TBD | Alibaba Coding Plan ($50/mo) | TBD (Phase 8+) | B | Phase 8+ |
|
||||
|
||||
**Anthropic models (sourced from `models-registry.json`):**
|
||||
|
||||
| Model ID | Display name | Context window | Notes |
|
||||
|---|---|---|---|
|
||||
| `claude-opus-4-8` | Claude Opus 4.8 | 200 000 | Newest opus; `opus` alias points here |
|
||||
| `claude-opus-4-7` | Claude Opus 4.7 | 200 000 | Still callable by literal id |
|
||||
| `claude-sonnet-4-6` | Claude Sonnet 4.6 | 200 000 | `sonnet` + `claude` aliases point here |
|
||||
| `claude-haiku-4-5` | Claude Haiku 4.5 | 200 000 | `haiku` alias points here |
|
||||
|
||||
**Risk tier guide.** D = permissive / safe (eligible for default-enabled); C = tightening signal, no enforcement history (opt-in); B = service-level key revocation risk (opt-in + consent); A = excluded by default (cannot be opt-in enabled). Tier B providers prompt for explicit consent on first enable and record consent in `~/.olp/config.json`. See [`ALIGNMENT.md` § Risk Tier Framework](./ALIGNMENT.md#risk-tier-framework).
|
||||
|
||||
@@ -278,6 +306,59 @@ See [ADR 0004 (Fallback Engine)](./docs/adr/0004-fallback-engine.md), [ADR 0007
|
||||
|
||||
---
|
||||
|
||||
## Plan Usage (live quota probe)
|
||||
|
||||
OLP v0.5.0+ surfaces live subscription quota for Anthropic Pro/Max subscribers on the owner-only `/dashboard`. Per-provider rows show 5-hour and 7-day utilization bars with reset countdowns, status badges, representative-claim hints, and a manual refresh button. The panel auto-refreshes every 60 seconds and pauses when the tab is hidden.
|
||||
|
||||

|
||||
|
||||
### How it works
|
||||
|
||||
The probe issues a minimal `POST /v1/messages` to `api.anthropic.com` (max_tokens: 1) using the same OAuth token Claude Code uses for `claude -p`. The body is discarded; only the 13 `anthropic-ratelimit-unified-*` response headers are parsed (5h/7d utilization + reset, status, representative-claim, fallback-percentage, overage status + disabled reason). Results cache for 5 minutes; refresh failures fall back to the previous cache marked `stale: true` while exponential backoff (60s → 3600s) protects against hammering the API.
|
||||
|
||||
See [ADR 0002 § Amendment 8](./docs/adr/0002-plugin-architecture.md), [ADR 0012 (Phase 5 charter)](./docs/adr/0012-phase-5-charter-quota-probes-dashboard.md), [ADR 0013 (OAuth READ-ONLY consumption + schema-drift mitigation)](./docs/adr/0013-oauth-read-only-consumption-and-schema-drift.md), and the schema pin at `~/.cc-rules/memory/learnings/anthropic_plan_usage_probe_schema_2026_05_26.md`.
|
||||
|
||||
### Enabling the probe
|
||||
|
||||
The probe is **opt-in** (default off) per ADR 0013 Rule 4 — a fresh OLP install on a machine without OAuth credentials should not bombard `api.anthropic.com` with 401-bound probes. To enable, add to `~/.olp/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"anthropic": {
|
||||
"enabled": true,
|
||||
"quota_probe_enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The probe reads the OAuth token from (in order): `CLAUDE_CODE_OAUTH_TOKEN` env var, `~/.claude/.credentials.json`, macOS Keychain entry `"Claude Code-credentials"`. Make sure Claude Code is logged in (`claude setup-token` or equivalent) before opting in.
|
||||
|
||||
`olp doctor` adds a `anthropic.quota_probe_reachable` check when the probe is enabled. The check has `category: 'provider'`, so any failure (401/403 token-expiry, 429 rate-limit, network error) discriminates to `kind: fix_provider`. The `human_steps` recovery recipe inside the check distinguishes the underlying cause (re-login via `claude setup-token` for auth failures vs wait-and-retry for rate-limit) — the discriminator is uniformly `fix_provider` but the actionable text is auth-aware. Successful probes return `status: ok` with the parsed 5h / 7d utilization in the message body; stale-cache returns `status: warn`. Routing an auth-class failure to `kind: fix_oauth` (the other discriminator the framework supports) would require splitting this check across the `provider` / `auth` boundary — deferred to v1.x if `olp doctor` consumers report the ambiguity.
|
||||
|
||||
### Provider coverage
|
||||
|
||||
| Provider | Live quota probe | Path |
|
||||
|---|---|---|
|
||||
| `anthropic` | ✅ Live — 13 fields via `anthropic-ratelimit-unified-*` headers | This section |
|
||||
| `openai` (codex) | ❌ Not available — `openai/codex` CLI has no public quota API | Falls back to audit-derived request counts |
|
||||
| `mistral` | ❌ Not implemented at v0.5.0 — no public quota endpoint accessible to Vibe / Le Chat member / La Plateforme API keys. Mistral's Admin API does expose billing / usage queries but is gated to org-admin scope and out of scope for OLP family-tier deployment. | Falls back to audit-derived request counts |
|
||||
|
||||
If Mistral ever publishes a usage endpoint, `lib/providers/mistral.mjs` DL-7 marks the re-entry point.
|
||||
|
||||
### Schema-drift protection
|
||||
|
||||
Claude Code v2.1.x is distributed as a compiled native binary (Mach-O on macOS, ELF on Linux) — the OCP-era "grep `cli.js`" verification no longer applies. OLP's replacement protocol (ADR 0013 § Rule 5):
|
||||
|
||||
1. `strings` over the platform-specific claude-code binary captures all hardcoded header names the binary expects.
|
||||
2. A live `POST /v1/messages` against `api.anthropic.com` with valid OAuth captures what the server actually emits today.
|
||||
3. Diff path 1 vs path 2 → the actionable schema delta.
|
||||
|
||||
This is re-run at every major `claude --version` bump (next trigger: v2.x → v3.x), at the Annual Alignment Audit (14 May), and whenever `olp doctor anthropic.quota_probe_reachable` returns an unexpected status code. The current pinned schema (13 fields, `2026-05-26`) lives in `models-registry.json` under `quota_probe.schema_version`.
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Endpoint | Method | Phase | Status | Description |
|
||||
@@ -285,9 +366,9 @@ See [ADR 0004 (Fallback Engine)](./docs/adr/0004-fallback-engine.md), [ADR 0007
|
||||
| `/v1/chat/completions` | POST | 1 | ✅ Shipped | OpenAI-compatible Chat Completions entry. Internally normalized to IR, dispatched to a provider plugin, response shape converted back. |
|
||||
| `/v1/models` | GET | 1 | ✅ Shipped | Lists models from `models-registry.json`. |
|
||||
| `/health` | GET | 1 | ✅ Shipped | Per-provider health snapshot. Phase 2 owner-only-trim: full per-provider details to owner identity; trimmed `{ ok, version }` to guest / anonymous. Gate via `auth.owner_only_endpoints` config. **Optional `anonymousKey` field (D69 / Phase 4, v0.4.0)** appears in both trimmed and full payloads when `auth.advertise_anonymous_key: true` AND `auth.allow_anonymous: true` AND at least one non-revoked guest-tier key has `plaintext_advertise: true` (see [ADR 0011](./docs/adr/0011-anonymous-key-deployment-context.md) for the trusted-LAN-only invariant). Default off — field absent when prereqs unmet. |
|
||||
| `/dashboard` | GET | 3 | ✅ Shipped (D50 + D51) | Owner-only multi-provider dashboard HTML (4 panels: quota / 24h request stats / 30d spend trend / top fallback chains; 30s poll with visibilitychange pause). Owner-only_block; non-owner identities receive 401. Localhost-bound by default. |
|
||||
| `/v0/management/dashboard-data` | GET | 3 | ✅ Shipped (D50) | JSON aggregate consumed by the dashboard 30s poll: `{ generated_at, window_24h, cache_hit_24h, quota, spend_trend_30d, top_fallback_chains_24h, cache_stats }`. Owner-only_block. |
|
||||
| `/v0/management/quota` | GET | 3 | ✅ Shipped (D50) | Per-provider quota snapshot via `provider.quotaStatus()` (subset of dashboard-data; useful for scripted monitoring). Owner-only_block. |
|
||||
| `/dashboard` | GET | 3 + 5 | ✅ Shipped (D50 + D51 + D82) | Owner-only multi-provider dashboard HTML. Phase 5 D82 adds a Claude.ai-style Plan Usage section at the top (per-provider utilization bars + reset countdowns + 60s auto-refresh + manual refresh button) on top of the existing four panels (24h request stats / 30d spend trend / top fallback chains / legacy quota fallback). Owner-only_block; non-owner identities receive 401. Localhost-bound by default. |
|
||||
| `/v0/management/dashboard-data` | GET | 3 + 5 | ✅ Shipped (D50 + D81) | JSON aggregate consumed by the dashboard polls. Shape `{ generated_at, window_24h, cache_hit_24h, quota, quota_v2, spend_trend_30d, top_fallback_chains_24h, cache_stats }`. The new `quota_v2` field (D81) is the normalized per-provider shape consumed by the Plan Usage UI; the legacy `quota` field stays alongside for backwards compatibility until v1.0.0. Owner-only_block. |
|
||||
| `/v0/management/quota` | GET | 3 + 5 | ✅ Shipped (D50 + D81) | Per-provider quota snapshot via `provider.quotaStatus()`. Includes both legacy `quota` and new `quota_v2` shape (mirrors `dashboard-data` for scripted monitoring). Owner-only_block. |
|
||||
| `/cache/stats` | GET | 3 | ✅ Shipped (D50) | Live in-memory `cacheStore.stats()` (`{ hits, misses, size, inflightCount }` + `generated_at`). Owner-only_block. |
|
||||
|
||||
---
|
||||
@@ -430,7 +511,7 @@ Use a dedicated bot key — not the maintainer's personal owner key — so revoc
|
||||
|
||||
## Implementation status (as of 2026-05-26, post-v0.4.0)
|
||||
|
||||
Phase 1 closed at v0.1.1 (multi-provider proxy core + pre-Phase-2 cleanup). Phase 2 closed at v0.2.0 (multi-key auth + audit + owner gating + keygen CLI; ADR 0007 § 10 all 11 acceptance criteria shipped). Phase 3 closed at v0.3.0 (Dashboard + `lib/audit-query.mjs` + daily audit rotation; ADR 0008 § 10 all 15 acceptance criteria shipped). Phase 4 closed at v0.4.0 (Operator + Client UX per ADR 0010: SSE heartbeat + `recentErrors[20]` + `/v0/management/status` / `olp` Node CLI + `olp doctor` framework + ADR 0002 Amendment 7 / `olp-connect` bash + `/health.anonymousKey` + ADR 0011 / `olp-plugin/` Telegram-Discord + 6-IDE integration docs). Phase 5 scope is open — candidates per ADR 0010 § Out-of-Phase-4-scope. This table reflects what is currently shipped vs. what is designed for later phases.
|
||||
Phase 1 closed at v0.1.1 (multi-provider proxy core + pre-Phase-2 cleanup). Phase 2 closed at v0.2.0 (multi-key auth + audit + owner gating + keygen CLI; ADR 0007 § 10 all 11 acceptance criteria shipped). Phase 3 closed at v0.3.0 (Dashboard + `lib/audit-query.mjs` + daily audit rotation; ADR 0008 § 10 all 15 acceptance criteria shipped). Phase 4 closed at v0.4.0 (Operator + Client UX per ADR 0010: SSE heartbeat + `recentErrors[20]` + `/v0/management/status` / `olp` Node CLI + `olp doctor` framework + ADR 0002 Amendment 7 / `olp-connect` bash + `/health.anonymousKey` + ADR 0011 / `olp-plugin/` Telegram-Discord + 6-IDE integration docs). Phase 5 closed at v0.5.0 (Quota Probes + Dashboard Enrichment — live Anthropic plan-usage probe + Claude.ai-style dashboard + audit-query aggregateProviderQuota); v0.5.1 hotfix (quota probe cache/backoff/schema-drift correctness — codex review findings F1–F3). Phase 6 is next. This table reflects what is currently shipped vs. what is designed for later phases.
|
||||
|
||||
| File / artifact | Status | Notes |
|
||||
|---|---|---|
|
||||
@@ -492,6 +573,37 @@ Behaviors that work correctly at personal/family scale but have ratified follow-
|
||||
**New config block consumed at D45:** `config.json auth.{ allow_anonymous, owner_only_endpoints, fallback_detail_header_policy }`. Default `allow_anonymous: false` (production-off); set true to accept requests without an OLP API key (development / single-user dev mode). Startup emits a warn when `allow_anonymous: true` so the relaxed posture is observable.
|
||||
- **Provider-level `cacheKeyFields` mask not implemented.** Cache keys include every IR field including ones individual plugins drop at spawn (e.g., Anthropic plugin drops `temperature`). Spurious cache misses possible (extra spawn cost; never spurious hits). Conservative posture documented in [ADR 0005 Amendment 7](./docs/adr/0005-cache-cross-provider.md). Tracked in [v1.x roadmap #5](./docs/v1x-roadmap.md).
|
||||
|
||||
- **Agentic clients with shell-tool routing may report OLP-server-side state as "self".** This is an architectural property of spawn-CLI proxying that OLP cannot fully fix at the proxy layer. When a client like OpenClaw runs in **client mode** (gateway on user's machine, LLM backend pointed at remote OLP) and the agent exposes shell / fs tools, those tool calls execute on whatever machine the client's tool-handler is wired to. If the client's `ocp` / `olp` plugin routes shell to the OLP server host, an in-agent "do a self-check" prompt produces results describing the OLP host (e.g. PI231) rather than the user's local machine. OLP cannot inject "you are the client, not the server" into the prompt because (a) the client owns the system message, and (b) OLP is stateless and doesn't know which client is calling. **Phase 6c's `--system-prompt` override (ADR 0009 Amendment 1) addresses one side of this — claude CLI no longer injects `<env>cwd=...</env>` blocks into the prompt** — but it cannot prevent the client from sending tool-results that the model then describes as its own state. Recommendations for integrators:
|
||||
|
||||
- **OpenClaw client mode** — if you want bot self-checks to describe the user's local machine, configure OpenClaw's tool plugins (`plugins.entries.{ocp,olp}` etc.) so shell / fs tools route to the local host, not to the OLP server. The bundled `olp-plugin/` ships as a read-only telemetry surface (no shell mutations); the older `ocp` plugin's shell-routing semantics are OCP-era legacy and may misroute when OLP is the LLM backend.
|
||||
- **Hermes Agent client mode** — Hermes pre-processes tools on its own host before sending; the LLM emits no tool_use that reaches OLP, so this limitation does not apply to chat-only Hermes flows. Tool-using Hermes flows behave correctly: Hermes runs the tool locally and includes the result as a follow-up user message.
|
||||
- **Cline / Continue.dev / Cursor / Aider** — IDE clients typically run shell / fs tools locally on the user's machine, so self-checks report the user's machine correctly. No OLP-side action needed.
|
||||
- **Generic agentic clients** — if your client routes tool execution to the OLP server, expect bot self-reports to describe the OLP server's state. Either: (1) configure your client's tool handler to run tools locally, or (2) document this to your client users as a known limitation.
|
||||
|
||||
See [ADR 0014 Amendment 1](./docs/adr/0014-sandbox-runtime-integration.md) for the multi-tenant security counterpart of this issue. Phase 7 Solution 1 (shipped v0.7.0) per-spawn ephemeral `$HOME` + symlinked credentials redirect all CLI state writes to `/tmp/olp-spawn/<keyId>/<reqId>/home/`, so a prompt-injected `cat ~/.claude.json` reads only the ephemeral file, not other tenants' OAuth tokens.
|
||||
|
||||
### Security Model
|
||||
|
||||
OLP's multi-tenant isolation has **three deployment tiers**, each suited to a different trust model. The orchestrator reads each provider plugin's `ISOLATION` block (ADR 0002 Amendment 9) to pick the right primitives.
|
||||
|
||||
| Tier | Trust assumption | Mechanism | Suitable for |
|
||||
|---|---|---|---|
|
||||
| **shared-os-user** (default) | All OLP key holders trust each other (family / personal pool) | Per-spawn ephemeral `$HOME` (Layer 1) + symlinked credentials (Layer 2) + provider tool-suppression (Layer 4 — anthropic Phase 6c `--system-prompt`, codex `--sandbox read-only`) | Family LAN, personal multi-device, trusted small teams. ADR 0001 § Mission. |
|
||||
| **per-os-user** | Trust boundaries between OLP keys (e.g., distinct family members on a shared host) | All of the above + per-OLP-key OS user (systemd `User=olp-<keyId>`, separate uid for kernel-level fs deny) | Untrusted-key deploy that still pools OAuth subscription. Operator-managed. |
|
||||
| **separate-vm** | Adversarial isolation between OLP keys (commercial / public-demo scenarios) | All of the above + dedicated VM per OLP key | OLP outside its stated mission. Each provider plugin's `recommendedDeploymentTier` declares its minimum acceptable tier. |
|
||||
|
||||
The provider plugins' `crossTenantReadProtection` field declares **how** each protects against cross-tenant lateral filesystem reads:
|
||||
|
||||
| Provider | `crossTenantReadProtection` | Mechanism |
|
||||
|---|---|---|
|
||||
| anthropic (claude CLI) | `tool-suppression` | Phase 6c `--system-prompt` replaces claude's default system prompt; the model receives no tool descriptions for Read/Bash/etc., so prompt-injection produces no `tool_use` to read other tenants' files. |
|
||||
| codex (codex CLI) | `inner-sandbox` | codex's own bubblewrap-based `--sandbox read-only` default confines shell-tool reads to its inner sandbox view. |
|
||||
| mistral (vibe CLI) | `none` | No tool-suppression flag known on vibe at present. Recommended deployment tier `separate-vm` until a hardening regime is verified (Task #4 follow-up spike). |
|
||||
|
||||
**OLP_SANDBOX_DISABLED=1** env var disables Layer 3 (per-call sandbox-runtime wrapping) while preserving Layers 1+2+4. This is the post-Amendment 1 escape hatch retained for 1-2 releases; production deployments should leave it unset.
|
||||
|
||||
**Attribution vs isolation.** ADR 0007 multi-key auth provides **attribution** (per-key audit, per-key cache namespace, per-key provider gating). ADR 0014 Amendment 1 provides **isolation** (the security tier above). Both layer cleanly — attribution always operates; isolation tier is operator-selected per deployment.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
@@ -519,7 +631,8 @@ The original v0.1 spec (in `~/.cc-rules/memory/projects/olp_v0_1_spec.md` on the
|
||||
- **Phase 1** — Multi-provider proxy core: `server.mjs`, IR, three Tier-D provider plugins (Anthropic / OpenAI Codex / Mistral Vibe), cache (D1+D4) + cleanup (D2 bypass / D3 chunked replay / D23 size cap), fallback engine with first-chunk safety + hard triggers + per-hop log observability, IR↔OpenAI translation under Rule 2(b). ✅ Shipped — v0.1.0 (2026-05-24) + v0.1.1 cleanup (2026-05-25, D35–D42).
|
||||
- **Phase 2** — Multi-key auth (`lib/keys.mjs`) per ADR 0007: opaque OLP API keys, per-key cache namespacing, owner-vs-guest tier for header gating, audit ndjson (`lib/audit.mjs`), `/health` payload trimming + `X-OLP-Fallback-Detail` emission gating, `OLP_OWNER_TOKEN` env override, keygen CLI (`bin/olp-keys.mjs`). ✅ Shipped — v0.2.0 (2026-05-25, D43-A → D47). All 11 ADR 0007 § 10 acceptance criteria covered.
|
||||
- **Phase 3** — Dashboard + audit query layer + daily audit rotation per ADR 0008: in-memory ndjson aggregate query layer (`lib/audit-query.mjs`), 4 owner-only_block management endpoints (`/dashboard` + `/v0/management/dashboard-data` + `/v0/management/quota` + `/cache/stats`), multi-panel `dashboard.html` with 30s poll, synchronous daily audit rotation + `bin/olp-audit-rotate.mjs` cron tool, `tried_providers` schema fix (D45 P2 deferral). ✅ Shipped — v0.3.0 (2026-05-25, D48 → D54). All 15 ADR 0008 § 10 acceptance criteria covered.
|
||||
- **Phase 4 (planned)** — Per-key per-provider auth artifact mapping (ADR 0007 § 12 deferral), audit query rotation/retention policies, SQLite hybrid migration (ADR 0007 § 13 trigger), provider-cost weights for spend trend.
|
||||
- **Phase 5** — Live quota probe (Anthropic Pro/Max OAuth plan-usage via `anthropic-ratelimit-unified-*` headers), Claude.ai-style dashboard enrichment (utilization bars + reset countdowns), audit-query `aggregateProviderQuota()`, per-provider quota_v2 shape in dashboard-data. ✅ Shipped — v0.5.0 (2026-05-27). v0.5.1 hotfix (2026-05-27): quota probe cache/backoff/schema-drift correctness (codex review findings F1–F3).
|
||||
- **Phase 6 (planned)** — Per-key per-provider auth artifact mapping (ADR 0007 § 12 deferral), audit query rotation/retention policies, SQLite hybrid migration (ADR 0007 § 13 trigger), provider-cost weights for spend trend.
|
||||
- **Phase 4+ (v1.x roadmap, triggered as needed)** — Full deferred-work tracker: [`docs/v1x-roadmap.md`](./docs/v1x-roadmap.md). Includes streaming-path singleflight ([issue #16](https://github.com/dtzp555-max/olp/issues/16) + ADR 0005 Amendment 8 design ratified), soft-trigger reactivation (ADR 0004 Amendment 2), `/health` activeSpawns integration, provider-level `cacheKeyFields` mask, streaming-path SPAWN_FAILED salvage.
|
||||
- **Phase N (opt-in)** — Tier-2 / Tier-C provider plugins (Grok / Kimi / MiniMax / GLM / Qwen) per [ADR 0006](./docs/adr/0006-provider-inclusion.md); provider-native protocol endpoints; deterministic triggers. Triggered by tier-2 demand, not on the bootstrap path.
|
||||
|
||||
|
||||
+1
-1
@@ -544,7 +544,7 @@ except: print('')" 2>/dev/null || echo "")
|
||||
# D74 P1-2: validate server-advertised token shape before consuming.
|
||||
# A hostile or misconfigured server could otherwise inject arbitrary
|
||||
# strings into the user's rc file via the `anonymousKey` field.
|
||||
if ! validate_olp_token "$anon_key" "/health.anonymousKey from $remote_host"; then
|
||||
if ! validate_olp_token "$anon_key" "/health.anonymousKey from ${host}:${port}"; then
|
||||
log_err "Refusing to consume malformed advertised key. Use --key explicitly or contact the OLP operator."
|
||||
exit 2
|
||||
fi
|
||||
|
||||
+86
-1
@@ -226,6 +226,53 @@ function formatMs(ms) {
|
||||
return `${Math.floor(ms / 3600000)}h${Math.floor((ms % 3600000) / 60000)}m`;
|
||||
}
|
||||
|
||||
/** formatAgo(diffMs) — "N min ago" / "Nh ago" from a millisecond diff. */
|
||||
function formatAgo(diffMs) {
|
||||
if (typeof diffMs !== 'number' || diffMs < 0) return 'just now';
|
||||
const sec = Math.floor(diffMs / 1000);
|
||||
if (sec < 60) return `${sec}s ago`;
|
||||
const min = Math.floor(sec / 60);
|
||||
if (min < 60) return `${min}m ago`;
|
||||
return `${Math.floor(min / 60)}h ago`;
|
||||
}
|
||||
|
||||
/**
|
||||
* formatResetCountdown(epochSeconds) → human-readable reset countdown string.
|
||||
*
|
||||
* Mirrors dashboard.html formatResetCountdown(). Five ranges:
|
||||
* past / < 1h / < 24h / < 7d / ≥ 7d
|
||||
*
|
||||
* Authority: ADR 0008 Amendment 2 (quota_v2 shape); ported from
|
||||
* dashboard.html (D82). No external deps. Pure formatter.
|
||||
*
|
||||
* @param {number|null} epochSeconds — Unix epoch seconds for reset time
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatResetCountdown(epochSeconds) {
|
||||
if (epochSeconds == null) return '—';
|
||||
const nowMs = Date.now();
|
||||
const targetMs = epochSeconds * 1000;
|
||||
const diffMs = targetMs - nowMs;
|
||||
if (diffMs <= 0) return 'resetting now';
|
||||
const diffMin = Math.floor(diffMs / 60000);
|
||||
const diffHr = Math.floor(diffMin / 60);
|
||||
const diffDay = Math.floor(diffHr / 24);
|
||||
if (diffMin < 60) return `resets in ${diffMin}m`;
|
||||
if (diffHr < 24) {
|
||||
const remMin = diffMin - diffHr * 60;
|
||||
if (remMin === 0) return `resets in ${diffHr}h`;
|
||||
return `resets in ${diffHr}h ${remMin}m`;
|
||||
}
|
||||
const target = new Date(targetMs);
|
||||
const timeStr = target.toLocaleString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
|
||||
if (diffDay < 7) {
|
||||
const dayStr = target.toLocaleString('en-US', { weekday: 'short' });
|
||||
return `resets ${dayStr} ${timeStr}`;
|
||||
}
|
||||
const dateStr = target.toLocaleString('en-US', { month: 'short', day: 'numeric' });
|
||||
return `resets ${dateStr} ${timeStr}`;
|
||||
}
|
||||
|
||||
// ── Subcommand: status ────────────────────────────────────────────────────
|
||||
|
||||
async function cmdStatus(flags, io) {
|
||||
@@ -329,7 +376,45 @@ async function cmdUsage(flags, io) {
|
||||
} else {
|
||||
io.log(' (no 24h usage data — server may not have processed any requests yet)');
|
||||
}
|
||||
if (Array.isArray(body.quota) && body.quota.length > 0) {
|
||||
// F4 (v0.5.1 codex post-release review Q4): prefer quota_v2 when present
|
||||
// (server v0.5.0+), fall back to legacy quota array on older servers.
|
||||
// Authority: ADR 0008 Amendment 2 (quota_v2 shape).
|
||||
if (Array.isArray(body.quota_v2) && body.quota_v2.length > 0) {
|
||||
io.log('');
|
||||
io.log(colorize('Per-provider quota (live)', ANSI.bold, io.useColor));
|
||||
io.log('─'.repeat(60));
|
||||
for (const p of body.quota_v2) {
|
||||
const label = String(p.provider ?? '?').toUpperCase().padEnd(12);
|
||||
const status = p.status ?? 'unavailable';
|
||||
if (status === 'unavailable') {
|
||||
io.log(` ${colorize(label, ANSI.gray, io.useColor)} unavailable ${p.reason ?? 'no public quota api'}`);
|
||||
} else if (status === 'unreachable') {
|
||||
const fk = p.failure?.kind ?? 'unknown';
|
||||
const fm = p.failure?.message ?? 'probe failed';
|
||||
io.log(` ${colorize(label, ANSI.red, io.useColor)} ❌ no cached data — failure: ${fk} (${fm})`);
|
||||
} else {
|
||||
// live or stale
|
||||
const staleWarn = status === 'stale'
|
||||
? colorize(` ⚠ stale${p.last_fresh_at ? ` (${formatAgo(Date.now() - p.last_fresh_at)})` : ''} failure: ${p.failure?.kind ?? 'unknown'}`, ANSI.yellow, io.useColor)
|
||||
: '';
|
||||
const util = p.utilization ?? {};
|
||||
const reset = p.reset ?? {};
|
||||
const parts = [];
|
||||
for (const window of ['5h', '7d']) {
|
||||
const frac = util[window];
|
||||
const resetEpoch = reset[window];
|
||||
if (frac != null) {
|
||||
const pct = `${Math.round(frac * 100)}%`;
|
||||
const rst = formatResetCountdown(resetEpoch);
|
||||
parts.push(`${window}: ${colorize(pct, frac >= 0.8 ? ANSI.red : frac >= 0.5 ? ANSI.yellow : ANSI.green, io.useColor)} (${rst})`);
|
||||
}
|
||||
}
|
||||
const binding = p.representative_claim ? ` binding: ${p.representative_claim.replace('_', '-')}` : '';
|
||||
io.log(` ${colorize(label, ANSI.bold, io.useColor)} ${colorize(status, status === 'live' ? ANSI.green : ANSI.yellow, io.useColor).padEnd(6)} ${parts.join(' ')}${binding}${staleWarn}`);
|
||||
}
|
||||
}
|
||||
} else if (Array.isArray(body.quota) && body.quota.length > 0) {
|
||||
// Legacy fallback for pre-v0.5.0 servers
|
||||
io.log('');
|
||||
io.log(colorize('Per-provider quota', ANSI.bold, io.useColor));
|
||||
io.log('─'.repeat(60));
|
||||
|
||||
+575
-24
@@ -1,16 +1,24 @@
|
||||
<!DOCTYPE html>
|
||||
<!--
|
||||
OLP Dashboard — Phase 3 / D51
|
||||
OLP Dashboard — Phase 5 / D82
|
||||
------------------------------
|
||||
Multi-panel owner-only dashboard per ADR 0008 § 6. Polls
|
||||
/v0/management/dashboard-data every 30 seconds (paused when the
|
||||
page is hidden via document.visibilityState).
|
||||
Multi-panel owner-only dashboard per ADR 0008 § 6.
|
||||
|
||||
Panels (per spec v0.1 § 4.6 + ADR 0008 Lane 5 = B full):
|
||||
1. Per-provider quota / credit pool
|
||||
2. Per-provider 24h request count + cache hit rate + fallback rate
|
||||
3. 30-day spend trend (SVG sparkline; per-provider in tooltip)
|
||||
4. Top 10 fallback chains by trigger count
|
||||
Panels:
|
||||
0. Plan Usage (new D82 — Claude.ai-style per-provider rows; quota_v2; 1-min refresh)
|
||||
1. Per-provider quota / credit pool (legacy; kept for graceful fallback when quota_v2 absent)
|
||||
2. Per-provider 24h request count + cache hit rate + fallback rate (30s refresh)
|
||||
3. 30-day spend trend (SVG sparkline; per-provider in tooltip) (30s refresh)
|
||||
4. Top 10 fallback chains by trigger count (30s refresh)
|
||||
|
||||
Refresh cadence:
|
||||
- Plan Usage panel: 60s (separate timer; visibilityState-guarded per ADR 0012 D82)
|
||||
- Other panels: 30s (original poll cadence; paused when tab hidden)
|
||||
|
||||
Authority:
|
||||
- ADR 0008 § 6 — dashboard layout + owner-only_block
|
||||
- ADR 0012 D82 — quota_v2 Claude.ai-style restructure
|
||||
- v1.x roadmap #8 — closed by this D-day
|
||||
|
||||
No build step, no framework, no external dependencies. Vanilla JS +
|
||||
fetch + DOM render. Owner-only_block: anonymous / guest / no-auth all
|
||||
@@ -43,15 +51,243 @@
|
||||
.chain { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 0.85rem; color: #374151; }
|
||||
.pill { display: inline-block; background: #e5e7eb; color: #374151; padding: 0.05rem 0.4rem; border-radius: 3px; font-size: 0.75rem; }
|
||||
footer { margin-top: 2rem; color: #9ca3af; font-size: 0.75rem; text-align: center; }
|
||||
|
||||
/* ───────────────────────────────────────────
|
||||
Plan Usage panel — D82 Claude.ai-style rows
|
||||
─────────────────────────────────────────── */
|
||||
.plan-usage-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.plan-usage-header h2 { margin: 0; }
|
||||
.plan-usage-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
.refresh-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.35rem 0.75rem;
|
||||
background: #fff;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
color: #374151;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.refresh-btn:hover:not(:disabled) { background: #f9fafb; border-color: #9ca3af; }
|
||||
.refresh-btn:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
.refresh-btn .spin { display: inline-block; animation: spin 0.8s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.provider-row {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 1rem 1.25rem;
|
||||
margin-bottom: 0.75rem;
|
||||
background: #fff;
|
||||
}
|
||||
.provider-row:last-child { margin-bottom: 0; }
|
||||
.provider-row.unavailable { background: #f9fafb; }
|
||||
.provider-row.stale { border-color: #fcd34d; }
|
||||
.provider-row.unreachable { border-color: #fca5a5; background: #fff5f5; }
|
||||
|
||||
.provider-row-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.provider-badge {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: #fff;
|
||||
}
|
||||
.provider-badge.anthropic { background: #cc4b24; }
|
||||
.provider-badge.codex { background: #10a37f; }
|
||||
.provider-badge.mistral { background: #6d5acd; }
|
||||
.provider-badge.openai { background: #10a37f; }
|
||||
.provider-badge.default { background: #6b7280; }
|
||||
|
||||
.status-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.status-dot.live { background: #10b981; }
|
||||
.status-dot.stale { background: #f59e0b; }
|
||||
.status-dot.unavailable { background: #9ca3af; }
|
||||
.status-dot.unreachable { background: #ef4444; }
|
||||
|
||||
.status-chip {
|
||||
display: inline-block;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 99px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.status-chip.live { background: #d1fae5; color: #065f46; }
|
||||
.status-chip.stale { background: #fef3c7; color: #92400e; }
|
||||
.status-chip.unavailable { background: #f3f4f6; color: #6b7280; }
|
||||
.status-chip.unreachable { background: #fee2e2; color: #991b1b; }
|
||||
|
||||
.chip-sm {
|
||||
display: inline-block;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
color: #374151;
|
||||
background: #f3f4f6;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
.schema-tag {
|
||||
margin-left: auto;
|
||||
font-size: 0.7rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.unavailable-reason {
|
||||
font-size: 0.875rem;
|
||||
color: #9ca3af;
|
||||
font-style: italic;
|
||||
padding: 0.25rem 0 0;
|
||||
}
|
||||
.unreachable-reason {
|
||||
font-size: 0.875rem;
|
||||
color: #b91c1c;
|
||||
font-style: italic;
|
||||
padding: 0.25rem 0 0;
|
||||
}
|
||||
.last-fresh-tag {
|
||||
font-size: 0.7rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.utilization-bars { display: flex; flex-direction: column; gap: 0.6rem; }
|
||||
.util-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.util-label {
|
||||
flex: 0 0 180px;
|
||||
font-size: 0.8rem;
|
||||
color: #6b7280;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.util-label { flex: 0 0 100%; }
|
||||
.util-row { flex-direction: column; align-items: flex-start; }
|
||||
.grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
.util-bar-wrap {
|
||||
flex: 1 1 120px;
|
||||
min-width: 80px;
|
||||
height: 8px;
|
||||
background: #e5e7eb;
|
||||
border-radius: 99px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.util-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 99px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
.util-bar-fill.green { background: linear-gradient(90deg, #34d399, #10b981); }
|
||||
.util-bar-fill.amber { background: linear-gradient(90deg, #fbbf24, #f59e0b); }
|
||||
.util-bar-fill.red { background: linear-gradient(90deg, #f87171, #ef4444); }
|
||||
|
||||
.util-pct {
|
||||
flex: 0 0 40px;
|
||||
font-size: 0.8rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
text-align: right;
|
||||
}
|
||||
.util-reset {
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.rep-claim-badge {
|
||||
display: inline-block;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
background: #ede9fe;
|
||||
color: #5b21b6;
|
||||
border: 1px solid #ddd6fe;
|
||||
font-weight: 600;
|
||||
}
|
||||
.overage-chip {
|
||||
display: inline-block;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
border: 1px solid #fcd34d;
|
||||
}
|
||||
.overage-chip.allowed {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
border-color: #6ee7b7;
|
||||
}
|
||||
.provider-row-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.65rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>OLP Dashboard</h1>
|
||||
<div id="meta" class="meta">Loading…</div>
|
||||
<div id="banner-slot"></div>
|
||||
|
||||
<!-- Plan Usage panel (D82 — Claude.ai-style; full width) -->
|
||||
<section class="panel" style="max-width: 1200px; margin-bottom: 1rem;">
|
||||
<div class="plan-usage-header">
|
||||
<h2>Plan Usage</h2>
|
||||
<div class="plan-usage-meta">
|
||||
<span id="quota-last-refresh"></span>
|
||||
<button class="refresh-btn" id="quota-refresh-btn" title="Refresh quota data">
|
||||
<span id="quota-refresh-icon">↻</span> Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="panel-plan-usage"><div class="panel-loading">Loading…</div></div>
|
||||
</section>
|
||||
|
||||
<div class="grid">
|
||||
<section class="panel">
|
||||
<h2>Quota (per provider)</h2>
|
||||
<section class="panel" id="legacy-quota-section" style="display:none;">
|
||||
<h2>Quota (per provider) — legacy</h2>
|
||||
<div id="panel-quota"><div class="panel-loading">Loading…</div></div>
|
||||
</section>
|
||||
<section class="panel">
|
||||
@@ -67,15 +303,21 @@
|
||||
<div id="panel-chains"><div class="panel-loading">Loading…</div></div>
|
||||
</section>
|
||||
</div>
|
||||
<footer>OLP Dashboard · poll every 30s · paused when tab hidden · v0.3.0-phase3</footer>
|
||||
<footer>OLP Dashboard · Plan Usage: 60s refresh · other panels: 30s · paused when tab hidden · v0.5.1</footer>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
const POLL_INTERVAL_MS = 30000;
|
||||
let pollHandle = null;
|
||||
|
||||
/* ─────────────── constants ─────────────── */
|
||||
const POLL_INTERVAL_MS = 30000; // 30s for legacy panels
|
||||
const QUOTA_POLL_INTERVAL_MS = 60000; // 60s for Plan Usage (D82)
|
||||
let pollHandle = null;
|
||||
let quotaRefreshTimer = null;
|
||||
|
||||
/* ─────────────── DOM helpers ─────────────── */
|
||||
function fmtNum(n) { return (n ?? 0).toLocaleString(); }
|
||||
function fmtPct(rate) { return (rate * 100).toFixed(1) + '%'; }
|
||||
|
||||
function el(tag, attrs, ...children) {
|
||||
const node = document.createElement(tag);
|
||||
if (attrs) for (const [k, v] of Object.entries(attrs)) {
|
||||
@@ -97,6 +339,223 @@
|
||||
return node;
|
||||
}
|
||||
|
||||
/* ─────────────── Reset countdown helper (D82 § B) ─────────────── */
|
||||
/**
|
||||
* formatResetCountdown(epochSeconds) → human-readable string
|
||||
*
|
||||
* - past: "Resetting now…"
|
||||
* - < 1 hour: "Resets in 23 min"
|
||||
* - < 24 hours: "Resets in 12hr 30min"
|
||||
* - < 7 days: "Resets Sun 9:00 PM"
|
||||
* - >= 7 days: "Resets May 31 9:00 PM"
|
||||
*/
|
||||
function formatResetCountdown(epochSeconds) {
|
||||
if (epochSeconds == null) return '—';
|
||||
const nowMs = Date.now();
|
||||
const targetMs = epochSeconds * 1000;
|
||||
const diffMs = targetMs - nowMs;
|
||||
|
||||
if (diffMs <= 0) return 'Resetting now…';
|
||||
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
const diffHr = Math.floor(diffMin / 60);
|
||||
const diffDay = Math.floor(diffHr / 24);
|
||||
|
||||
if (diffMin < 60) {
|
||||
return 'Resets in ' + diffMin + ' min';
|
||||
}
|
||||
if (diffHr < 24) {
|
||||
const remMin = diffMin - diffHr * 60;
|
||||
if (remMin === 0) return 'Resets in ' + diffHr + 'hr';
|
||||
return 'Resets in ' + diffHr + 'hr ' + remMin + 'min';
|
||||
}
|
||||
// Format as "Resets <day-of-week> <time>" or "Resets <month> <day> <time>"
|
||||
const target = new Date(targetMs);
|
||||
const timeStr = target.toLocaleString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
|
||||
if (diffDay < 7) {
|
||||
const dayStr = target.toLocaleString('en-US', { weekday: 'short' });
|
||||
return 'Resets ' + dayStr + ' ' + timeStr;
|
||||
}
|
||||
const dateStr = target.toLocaleString('en-US', { month: 'short', day: 'numeric' });
|
||||
return 'Resets ' + dateStr + ' ' + timeStr;
|
||||
}
|
||||
|
||||
/* ─────────────── "Updated N min ago" helper ─────────────── */
|
||||
function formatAgo(epochMs) {
|
||||
if (epochMs == null) return '';
|
||||
const diffMs = Date.now() - epochMs;
|
||||
if (diffMs < 0) return 'just now';
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
if (diffSec < 60) return 'Updated just now';
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
if (diffMin === 1) return 'Updated 1 min ago';
|
||||
if (diffMin < 60) return 'Updated ' + diffMin + ' min ago';
|
||||
const diffHr = Math.floor(diffMin / 60);
|
||||
if (diffHr === 1) return 'Updated ~1hr ago';
|
||||
return 'Updated ~' + diffHr + 'hr ago';
|
||||
}
|
||||
|
||||
/* ─────────────── Utilization bar color ─────────────── */
|
||||
function utilizationColor(fraction) {
|
||||
if (fraction == null) return 'green';
|
||||
if (fraction >= 0.80) return 'red';
|
||||
if (fraction >= 0.50) return 'amber';
|
||||
return 'green';
|
||||
}
|
||||
|
||||
/* ─────────────── Provider badge color class ─────────────── */
|
||||
function providerBadgeClass(name) {
|
||||
const n = (name || '').toLowerCase();
|
||||
if (n === 'anthropic') return 'anthropic';
|
||||
if (n === 'codex') return 'codex';
|
||||
if (n === 'mistral') return 'mistral';
|
||||
if (n === 'openai') return 'openai';
|
||||
return 'default';
|
||||
}
|
||||
|
||||
/* ─────────────── Plan Usage renderer (quota_v2) ─────────────── */
|
||||
function renderPlanUsage(quotaV2) {
|
||||
const target = document.getElementById('panel-plan-usage');
|
||||
target.innerHTML = '';
|
||||
|
||||
if (!Array.isArray(quotaV2) || quotaV2.length === 0) {
|
||||
target.appendChild(el('div', { class: 'panel-loading' }, 'No quota data available.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const frag = document.createDocumentFragment();
|
||||
|
||||
for (const entry of quotaV2) {
|
||||
const status = entry.status || 'unavailable';
|
||||
const rowEl = el('div', { class: 'provider-row ' + status });
|
||||
|
||||
/* ── top bar: badge + status dot + chips + schema tag ── */
|
||||
const topBar = el('div', { class: 'provider-row-top' });
|
||||
|
||||
topBar.appendChild(el('span', { class: 'provider-badge ' + providerBadgeClass(entry.provider) }, (entry.provider || '').toUpperCase()));
|
||||
topBar.appendChild(el('span', { class: 'status-dot ' + status, title: 'Status: ' + status }));
|
||||
topBar.appendChild(el('span', { class: 'status-chip ' + status }, status));
|
||||
|
||||
if (entry.schema_version) {
|
||||
topBar.appendChild(el('span', { class: 'schema-tag' }, 'schema: ' + entry.schema_version));
|
||||
}
|
||||
|
||||
rowEl.appendChild(topBar);
|
||||
|
||||
/* ── unavailable: just show reason, no bars ── */
|
||||
if (status === 'unavailable') {
|
||||
const reason = entry.reason || 'no public quota api or probe disabled';
|
||||
rowEl.appendChild(el('div', { class: 'unavailable-reason' }, reason));
|
||||
frag.appendChild(rowEl);
|
||||
continue;
|
||||
}
|
||||
|
||||
/* ── unreachable (v0.5.1): probe failed + no cache — show failure detail ── */
|
||||
if (status === 'unreachable') {
|
||||
const failure = entry.failure || {};
|
||||
const kind = failure.kind || 'unknown';
|
||||
const msg = failure.message || 'probe failed — no cached data available';
|
||||
const shortText = `${kind}: ${msg}`;
|
||||
rowEl.appendChild(el('div', { class: 'unreachable-reason' }, shortText));
|
||||
if (failure.backoff_until) {
|
||||
const backoffMs = Math.max(0, failure.backoff_until - Date.now());
|
||||
const backoffSec = Math.round(backoffMs / 1000);
|
||||
if (backoffSec > 0) {
|
||||
rowEl.appendChild(el('div', { class: 'unavailable-reason' }, `backoff active: ${backoffSec}s remaining`));
|
||||
}
|
||||
}
|
||||
frag.appendChild(rowEl);
|
||||
continue;
|
||||
}
|
||||
|
||||
/* ── utilization bars (5h + 7d) ── */
|
||||
const util = entry.utilization || {};
|
||||
const reset = entry.reset || {};
|
||||
const barsWrap = el('div', { class: 'utilization-bars' });
|
||||
|
||||
const windows = [
|
||||
{ key: '5h', label: 'Current 5-hour session' },
|
||||
{ key: '7d', label: 'Weekly all-models' },
|
||||
];
|
||||
|
||||
for (const w of windows) {
|
||||
const frac = util[w.key];
|
||||
const resetEpoch = reset[w.key];
|
||||
const color = utilizationColor(frac);
|
||||
const pctStr = frac != null ? Math.round(frac * 100) + '%' : '—';
|
||||
const fillPct = frac != null ? Math.min(100, Math.round(frac * 100)) : 0;
|
||||
const resetStr = formatResetCountdown(resetEpoch);
|
||||
|
||||
const utilRow = el('div', { class: 'util-row' });
|
||||
|
||||
utilRow.appendChild(el('span', { class: 'util-label', title: w.label },
|
||||
w.label + (frac != null ? ': ' + pctStr : '')
|
||||
));
|
||||
|
||||
const barWrap = el('div', { class: 'util-bar-wrap' });
|
||||
barWrap.appendChild(el('div', {
|
||||
class: 'util-bar-fill ' + color,
|
||||
style: 'width: ' + fillPct + '%',
|
||||
'aria-valuenow': fillPct,
|
||||
'aria-valuemin': '0',
|
||||
'aria-valuemax': '100',
|
||||
role: 'progressbar',
|
||||
}));
|
||||
utilRow.appendChild(barWrap);
|
||||
|
||||
utilRow.appendChild(el('span', { class: 'util-pct' }, pctStr));
|
||||
utilRow.appendChild(el('span', { class: 'util-reset' }, resetStr));
|
||||
|
||||
barsWrap.appendChild(utilRow);
|
||||
}
|
||||
|
||||
rowEl.appendChild(barsWrap);
|
||||
|
||||
/* ── bottom chips: representative-claim, overage, last-fresh ── */
|
||||
const bottomBar = el('div', { class: 'provider-row-bottom' });
|
||||
|
||||
if (entry.representative_claim) {
|
||||
const claimLabel = entry.representative_claim === 'five_hour' ? '5-hour claim'
|
||||
: entry.representative_claim === 'seven_day' ? '7-day claim'
|
||||
: entry.representative_claim;
|
||||
bottomBar.appendChild(el('span', { class: 'rep-claim-badge', title: 'Binding window: ' + entry.representative_claim }, claimLabel));
|
||||
}
|
||||
|
||||
if (entry.overage && entry.overage.status) {
|
||||
const ov = entry.overage;
|
||||
const ovStatus = (ov.status || 'unknown').toLowerCase();
|
||||
const isAllowed = ovStatus === 'allowed' || ovStatus === 'active';
|
||||
const chipClass = isAllowed ? 'overage-chip allowed' : 'overage-chip';
|
||||
const label = 'Overage: ' + (ov.status || '—')
|
||||
+ (ov.disabled_reason ? ' (' + ov.disabled_reason + ')' : '');
|
||||
bottomBar.appendChild(el('span', { class: chipClass, title: label }, label));
|
||||
}
|
||||
|
||||
if (entry.fallback_percentage != null) {
|
||||
const fpPct = Math.round(entry.fallback_percentage * 100) + '%';
|
||||
bottomBar.appendChild(el('span', { class: 'chip-sm', title: 'Fallback rate (last window)' }, 'Fallback ' + fpPct));
|
||||
}
|
||||
|
||||
if (entry.last_fresh_at) {
|
||||
bottomBar.appendChild(el('span', { class: 'last-fresh-tag' }, formatAgo(entry.last_fresh_at)));
|
||||
}
|
||||
|
||||
if (status === 'stale') {
|
||||
const staleTitle = entry.last_fresh_at
|
||||
? 'Last successful probe was ' + formatAgo(entry.last_fresh_at) + '; backoff active'
|
||||
: 'Probe data is stale; backoff active';
|
||||
bottomBar.appendChild(el('span', { class: 'chip-sm', style: 'color: #92400e; background: #fef3c7; border-color: #fcd34d;', title: staleTitle }, '⚠ stale data'));
|
||||
}
|
||||
|
||||
rowEl.appendChild(bottomBar);
|
||||
frag.appendChild(rowEl);
|
||||
}
|
||||
|
||||
target.appendChild(frag);
|
||||
}
|
||||
|
||||
/* ─────────────── Legacy quota renderer (graceful fallback) ─────────────── */
|
||||
function renderQuota(data) {
|
||||
const target = document.getElementById('panel-quota');
|
||||
target.innerHTML = '';
|
||||
@@ -129,6 +588,28 @@
|
||||
target.appendChild(table);
|
||||
}
|
||||
|
||||
/* ─────────────── Plan Usage top-level render + quota routing ─────────────── */
|
||||
function renderQuotaSection(data) {
|
||||
const hasV2 = Array.isArray(data.quota_v2) && data.quota_v2.length > 0;
|
||||
const legacySection = document.getElementById('legacy-quota-section');
|
||||
|
||||
if (hasV2) {
|
||||
// D82: use enriched quota_v2 rows; hide legacy panel
|
||||
legacySection.style.display = 'none';
|
||||
renderPlanUsage(data.quota_v2);
|
||||
} else {
|
||||
// Graceful fallback: show legacy quota panel (older server build without D81)
|
||||
legacySection.style.display = '';
|
||||
// Also show legacy data in Plan Usage panel with a note
|
||||
const target = document.getElementById('panel-plan-usage');
|
||||
target.innerHTML = '';
|
||||
target.appendChild(el('div', { class: 'panel-loading', style: 'color:#6b7280;' },
|
||||
'quota_v2 not available (server may not have D81 yet). See legacy Quota panel below.'));
|
||||
renderQuota(data.quota);
|
||||
}
|
||||
}
|
||||
|
||||
/* ─────────────── Other panel renderers (unchanged from D51) ─────────────── */
|
||||
function render24h(window24h, cacheHit24h) {
|
||||
const target = document.getElementById('panel-24h');
|
||||
target.innerHTML = '';
|
||||
@@ -196,7 +677,7 @@
|
||||
const minLabel = svgEl('text', { x: 4, y: height - 4, 'font-size': 10, fill: '#6b7280' });
|
||||
minLabel.textContent = '0';
|
||||
svg.appendChild(minLabel);
|
||||
// Date labels (first + last only at v0.3.0; mid labels deferred — added if needed by Phase 4 UX feedback)
|
||||
// Date labels (first + last only)
|
||||
if (spendTrend30d.length > 0) {
|
||||
const firstDate = svgEl('text', { x: padding.left, y: height - 4, 'font-size': 10, fill: '#6b7280' });
|
||||
firstDate.textContent = spendTrend30d[0].date.slice(5);
|
||||
@@ -240,6 +721,7 @@
|
||||
target.appendChild(table);
|
||||
}
|
||||
|
||||
/* ─────────────── Error / clear banner ─────────────── */
|
||||
function showError(message) {
|
||||
const slot = document.getElementById('banner-slot');
|
||||
slot.innerHTML = '';
|
||||
@@ -250,6 +732,7 @@
|
||||
document.getElementById('banner-slot').innerHTML = '';
|
||||
}
|
||||
|
||||
/* ─────────────── Fetch ─────────────── */
|
||||
async function fetchDashboardData() {
|
||||
const res = await fetch('/v0/management/dashboard-data', {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
@@ -266,24 +749,82 @@
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
/* ─────────────── Quota-only refresh (D82 § C — 60s timer) ─────────────── */
|
||||
let _lastQuotaFetchedAt = null;
|
||||
|
||||
async function refreshQuotaV2() {
|
||||
try {
|
||||
const data = await fetchDashboardData();
|
||||
clearError();
|
||||
_lastQuotaFetchedAt = Date.now();
|
||||
renderQuotaSection(data);
|
||||
updateQuotaLastRefreshLabel();
|
||||
} catch (err) {
|
||||
console.warn('OLP quota refresh failed:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function updateQuotaLastRefreshLabel() {
|
||||
const span = document.getElementById('quota-last-refresh');
|
||||
if (!span) return;
|
||||
if (_lastQuotaFetchedAt) {
|
||||
span.textContent = 'Updated ' + new Date(_lastQuotaFetchedAt).toLocaleTimeString();
|
||||
}
|
||||
}
|
||||
|
||||
/* ─────────────── 60s quota timer with visibilityState guard ─────────────── */
|
||||
function startQuotaRefresh() {
|
||||
if (quotaRefreshTimer !== null) return;
|
||||
quotaRefreshTimer = setInterval(refreshQuotaV2, QUOTA_POLL_INTERVAL_MS);
|
||||
}
|
||||
function stopQuotaRefresh() {
|
||||
if (quotaRefreshTimer === null) return;
|
||||
clearInterval(quotaRefreshTimer);
|
||||
quotaRefreshTimer = null;
|
||||
}
|
||||
|
||||
/* ─────────────── Manual refresh button (D82 § D) ─────────────── */
|
||||
(function wireRefreshButton() {
|
||||
const btn = document.getElementById('quota-refresh-btn');
|
||||
const icon = document.getElementById('quota-refresh-icon');
|
||||
if (!btn) return;
|
||||
btn.addEventListener('click', async () => {
|
||||
if (btn.disabled) return;
|
||||
btn.disabled = true;
|
||||
icon.textContent = '⟳';
|
||||
icon.classList.add('spin');
|
||||
try {
|
||||
await refreshQuotaV2();
|
||||
} finally {
|
||||
icon.classList.remove('spin');
|
||||
icon.textContent = '↻';
|
||||
// Re-enable after 2s spam guard
|
||||
setTimeout(() => { btn.disabled = false; }, 2000);
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
/* ─────────────── Full 30s refresh (legacy panels + meta) ─────────────── */
|
||||
async function refresh() {
|
||||
try {
|
||||
const data = await fetchDashboardData();
|
||||
clearError();
|
||||
const generated = data.generated_at ? new Date(data.generated_at) : new Date();
|
||||
document.getElementById('meta').textContent =
|
||||
'Last refresh: ' + generated.toLocaleString() + ' · next in ~30s';
|
||||
renderQuota(data.quota);
|
||||
'Last refresh: ' + generated.toLocaleString() + ' · quota every 60s · other panels every 30s';
|
||||
// Quota section: also render on each full refresh to keep in sync
|
||||
_lastQuotaFetchedAt = Date.now();
|
||||
renderQuotaSection(data);
|
||||
updateQuotaLastRefreshLabel();
|
||||
render24h(data.window_24h, data.cache_hit_24h);
|
||||
renderTrend(data.spend_trend_30d);
|
||||
renderChains(data.top_fallback_chains_24h);
|
||||
} catch (err) {
|
||||
// Error banner already shown by fetchDashboardData; keep panels in
|
||||
// their last-good state. Console for operator debugging.
|
||||
console.warn('OLP dashboard refresh failed:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/* ─────────────── 30s poll (legacy panels) ─────────────── */
|
||||
function startPolling() {
|
||||
if (pollHandle !== null) return;
|
||||
pollHandle = setInterval(refresh, POLL_INTERVAL_MS);
|
||||
@@ -294,14 +835,24 @@
|
||||
pollHandle = null;
|
||||
}
|
||||
|
||||
// Pause when tab hidden, resume on visible (ADR 0008 § 6.5).
|
||||
/* ─────────────── visibilitychange (both timers) ─────────────── */
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') stopPolling();
|
||||
else { refresh(); startPolling(); }
|
||||
if (document.visibilityState === 'hidden') {
|
||||
stopPolling();
|
||||
stopQuotaRefresh();
|
||||
} else {
|
||||
refresh();
|
||||
startPolling();
|
||||
refreshQuotaV2();
|
||||
startQuotaRefresh();
|
||||
}
|
||||
});
|
||||
|
||||
// Initial fetch + start poll.
|
||||
refresh().finally(startPolling);
|
||||
/* ─────────────── Boot ─────────────── */
|
||||
refresh().finally(() => {
|
||||
startPolling();
|
||||
if (document.visibilityState === 'visible') startQuotaRefresh();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -9,6 +9,32 @@
|
||||
|
||||
> **Note on numbering.** Sequence is 1, 3, 4, 5, 6, 7 — Amendment 2 was never written. The reserved slot was originally planned for a separate `maxConcurrent` ratification, but that content was folded into Amendment 1 (the retroactive contract-sync amendment) at filing time and the gap was not backfilled. The gap is intentional and load-bearing — no missing content; do not renumber Amendments 3+ to close it (cross-references to Amendment N from other docs would silently break).
|
||||
|
||||
> **Forward-pointer:** Amendment 9 (2026-05-29) — Provider `ISOLATION` Contract for Multi-Tenant Spawn Isolation — is located at the **end of this file** (after § Sources), not in this Amendments block. The placement is documented in Amendment 9's editorial note; the substance is the addition of an OPTIONAL `ISOLATION` named export to provider plugin modules, consumed by `lib/sandbox/manager.mjs` (per ADR 0014 Amendment 1) to compose per-spawn ephemeral-home + per-provider isolation primitives. Co-merge with ADR 0014 Amendment 1.
|
||||
|
||||
### Amendment 8 — 2026-05-26: Permit `quotaStatus()` direct-API access (READ-ONLY exemption) for plan-usage probes (D79–D80 — Phase 5)
|
||||
|
||||
- **Context:** ADR 0012 (Phase 5 charter) opens 2026-05-26 to port OCP's plan-usage probe (`ocp/server.mjs:842-1109`) into `lib/providers/anthropic.mjs:quotaStatus()`. The probe calls `POST https://api.anthropic.com/v1/messages` directly with an OAuth bearer and parses `anthropic-ratelimit-unified-*` response headers. This violates the plugin contract's implicit assumption that ALL provider interaction goes through `spawn` (the binary CLI). `ALIGNMENT.md` Rule 2 (provider-CLI-as-authority) further constrains plugins to operations the provider CLI itself performs. The OCP-derived plan-usage probe satisfies neither of these — it bypasses `claude -p` and hits the public API directly. **Without an explicit exemption Amendment, D80 is unalignable.**
|
||||
- **Why the exemption is sound:** The probe is strictly **READ-ONLY** (one `POST /v1/messages` with `max_tokens: 1`; the response body is discarded; only response headers are parsed) AND **subscription-scope** (the OAuth bearer is the same one Claude Code uses for `claude -p`; no extra grant is requested) AND **idempotent** (probe failure returns `null`, never throws to a caller). The "what authority backs this?" answer is: Anthropic's CLI internally makes the same `/v1/messages` call (verified 2026-05-26 by `strings` on the compiled binary — see `~/.cc-rules/memory/learnings/anthropic_plan_usage_probe_schema_2026_05_26.md`); the probe is mirroring an established CLI behaviour rather than introducing a new wire format. Under `ALIGNMENT.md` Rule 2, mirroring observed CLI behaviour is permitted; the Rule's intent is "don't invent wire formats Anthropic's CLI does not perform", which the probe respects.
|
||||
- **Change — extend the Provider contract description:**
|
||||
- `quotaStatus(authContext): { quotaInfo }` is now permitted to call provider HTTP APIs directly, subject to **all three** constraints:
|
||||
1. **READ-ONLY** — the API call must not mutate provider-side state. POST is acceptable when the response is what's needed (Anthropic returns ratelimit headers on `POST /v1/messages`); the request body MUST minimise side-effects (`max_tokens: 1`, dummy `messages`).
|
||||
2. **Subscription-scope reuse** — the credentials used MUST be the same auth artifact the spawn path already reads via `readAuthArtifact()`. No new OAuth grant, no new API-key registration, no separate scopes.
|
||||
3. **Idempotent failure** — if the probe fails for any reason (network error, 401, 429, schema parse failure), the function returns a structured shape (`{ probe_status: 'unreachable', failure: { kind, message, backoff_until? } }` since v0.5.1; see ADR 0013 Rule 6 + ADR 0008 Amendment 2) rather than throwing. The caller (server.mjs / dashboard / `olp usage` CLI) gracefully degrades. At v0.5.0 the failure shape was the literal value `null`; v0.5.1 refined this to a structured shape so operators can distinguish auth failures from rate-limit failures from network failures from in-backoff stale-cache. The substantive idempotent-failure constraint (no throw to caller) is unchanged.
|
||||
- `healthCheck()` and other contract methods are NOT extended by this Amendment. Only `quotaStatus()` may make direct API calls. A plugin that wants live data for any other contract method must continue to use `spawn` or `readAuthArtifact`.
|
||||
- The probe MUST cache its result. Recommended TTL: 5 minutes (mirrors OCP `USAGE_CACHE_TTL`). Tighter TTLs (e.g. dashboard's 1-minute refresh) are served from the cached value if fresh; cache miss triggers a real probe.
|
||||
- The probe MUST implement exponential backoff on refresh failures: minimum 60s, maximum 3600s (mirrors OCP `OAUTH_REFRESH_MIN_BACKOFF` / `OAUTH_REFRESH_MAX_BACKOFF`). Tight loop on failure has historically burned through Anthropic's rate limit in seconds (OCP institutional lesson 2026-04).
|
||||
- The probe MUST be opt-in via `~/.olp/config.json` (`providers.<name>.quota_probe_enabled: true`; default `false`). Reasoning: a fresh OLP install on a machine without OAuth credentials should not bombard `api.anthropic.com` with 401-bound probes; the operator opts in once the credentials are configured.
|
||||
- **What this Amendment does NOT permit:**
|
||||
- Mutating API calls (e.g. POST/PATCH/DELETE that change provider-side state). Still forbidden.
|
||||
- API calls for any contract method other than `quotaStatus()`. `spawn` / `healthCheck` / `doctorChecks` / `estimateCost` / `models` / `hints` / `name` / `displayName` / `auth` remain spawn-and-filesystem-only.
|
||||
- Per-provider new auth grants. The probe uses the spawn path's existing credentials.
|
||||
- Bypassing the alignment.yml blacklist. The hallucinated `/api/oauth/usage` token stays blacklisted; the probe uses `/v1/messages` (real endpoint).
|
||||
- **API calls to endpoints not explicitly enumerated by the companion ADR 0013 § Rule 2.** Amendment 8 permits the *kind* of call (READ-ONLY direct API for quota probing); ADR 0013 Rule 2 enumerates *which specific endpoint* is permitted. A future reader of Amendment 8 alone should NOT infer that any READ-ONLY/idempotent endpoint is fair game — the per-endpoint containment is locked to ADR 0013. Re-opening per-endpoint scope requires an ADR 0013 amendment, not a new plugin-level interpretation of Amendment 8.
|
||||
- **Backwards compatibility:** Plugins whose `quotaStatus()` still returns `null` (mistral at v0.5.0 pending D84 audit, codex permanently per Phase 5 charter) are NOT affected. No existing behaviour changes for them.
|
||||
- **Authority cited at the implementation:** D80 commit cites this Amendment + `~/.cc-rules/memory/learnings/anthropic_plan_usage_probe_schema_2026_05_26.md` + `Claude Code v2.1.x § OAuth bearer + ratelimit-unified headers` + live-probe transcript from 2026-05-26 in the commit body. ALIGNMENT.md Rule 1 + Rule 5 (CI) both satisfied.
|
||||
- **Tests:** Suite 38 (Phase 5 D83) covers the probe: mock HTTP server returning all 13 `anthropic-ratelimit-unified-*` headers; assert parse correctness for each; assert 5min cache; assert 60s-3600s exponential backoff on simulated 429; assert stale-cache-on-failure. At v0.5.0 stale-failure returned `{ stale: true, ... }` with `null` reserved for no-cache failures; v0.5.1 refined the return contract — `null` is now reserved STRICTLY for opt-in-off, and all failure modes (auth / rate-limit / schema-drift / network / no-creds) return `{ probe_status: 'unreachable' | 'stale', failure: {...} }`. See `test-features.mjs` Suite 38 (38u/38v/38w added for the v0.5.1 hotfix regression coverage of F1 / F2 / F3 per codex review).
|
||||
- **Procedural mechanism:** Iron Rule 11 (IDR) — this Amendment, ADR 0012 (Phase 5 charter), and ADR 0013 (OAuth READ-ONLY consumption rules) land together at D79 as a single coupled commit. Reviewing them separately cannot verify consumer-producer alignment. Iron Rule 10 fresh-context reviewer per CLAUDE.md hard requirement #3.
|
||||
|
||||
### Amendment 7 — 2026-05-26: Add OPTIONAL `doctorChecks()` to the Provider contract (D67 — Phase 4 operator UX)
|
||||
|
||||
- **Context:** ADR 0010 § Phase 4 D64-D67 ships `bin/olp.mjs` operator CLI + `olp doctor` framework. `olp doctor` runs a set of `Check` objects (id / category / async `run()` returning `{ status, message, evidence? }`) and discriminates the next remediation step via a `kind` field (`noop` / `fix_server` / `fix_oauth` / `fix_provider` / `fresh_install`). The framework needs per-provider checks so a user with a broken `claude` install gets a different fix recipe than a user with a broken `vibe` install. Hardcoding the recipes in `bin/olp.mjs` would re-introduce the kind of per-provider knowledge drift that ADR 0002 § Decision exists to prevent — when a new provider plugin lands, the operator CLI would have to be edited too.
|
||||
@@ -186,3 +212,469 @@ Every provider plugin exports an object conforming to:
|
||||
- OLP v0.1 spec §4.2 (Plugin-based provider system, including the v1.0 Provider contract definition)
|
||||
- OCP ADR 0003 (`models.json` as SPOT) — informs the "static enumeration, not filesystem scan" loading model
|
||||
- OCP ADR 0005 — the context paragraph references OCP's `server.mjs` reaching 1667 lines at one provider; the plugin architecture is the structural response to that complexity scaling N×
|
||||
|
||||
---
|
||||
|
||||
### Amendment 9 — 2026-05-29: Provider `ISOLATION` Contract for Multi-Tenant Spawn Isolation (Phase 7, ADR 0014 Amendment 1 co-merge)
|
||||
|
||||
> **Editorial note.** Per the existing "amendments most-recent-first" convention near the top of this file, Amendment 9 logically slots between Amendment 8 and the original body. It is physically located at the file's tail (after § Sources) to honor the constitution's "append, do not rewrite" discipline for this addition — the rationale is that the contract surface added here is large enough (a structured per-provider sub-export, not just a hint-bag field) that an in-line edit of the § Decision body would constitute a rewrite of the v1.0 contract listing rather than an amendment over it. Future readers consulting the amendment-history block at the top of the file will find a stub forward-pointer to this section.
|
||||
>
|
||||
> The amendment is otherwise a peer of Amendments 1–8 (same `###` heading depth, same shape).
|
||||
|
||||
#### Context
|
||||
|
||||
The OLP spawn pipeline currently treats every provider as a plain `child_process.spawn` of the provider's CLI binary with a homogeneous env block and the server process's working directory. This works on a single-tenant developer laptop. It does **not** work on the family-LAN PI231 deployment (multi-key, multi-caller, single OS user) and is a hard blocker for the cloud rollout described in `docs/plans/cloud-deployment-family.md` § 5 — both for the reasons captured in the 2026-05-27 incident memory at `~/.cc-rules/memory/projects/olp/incident_2026_05_27_spawn_cli_security.md` (OAuth-token exfiltration, codex `shell` tool real execution, cross-tenant filesystem read leakage).
|
||||
|
||||
The parallel ADR 0014 Amendment 1 retires the **outer-bwrap PR-B approach** — which initialized `@anthropic-ai/sandbox-runtime` `SandboxManager` once at server startup and wrapped every provider spawn through a global namespace — and replaces it with a **per-spawn ephemeral-home + per-provider isolation primitives** architecture. The new shape of `lib/sandbox/manager.mjs` is no longer a thin wrapper around `wrapSpawn()`; it is an orchestrator that, on each spawn, asks the provider plugin *what isolation primitives this provider needs*, composes them, and hands the spawn a ready-to-execute environment.
|
||||
|
||||
The thing the orchestrator asks for is the subject of this amendment: the **Provider `ISOLATION` contract**.
|
||||
|
||||
#### The interaction surface this amendment governs
|
||||
|
||||
```text
|
||||
┌──────────────────────────────────────┐
|
||||
│ server.mjs handleChatCompletions │
|
||||
│ → executeHopFn │
|
||||
│ → provider.spawn(irRequest, ...) │
|
||||
└────────────────────┬─────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────┐
|
||||
│ lib/sandbox/manager.mjs │
|
||||
│ prepareIsolatedEnvironment( │
|
||||
│ provider, │
|
||||
│ { keyId, reqId, ... } │
|
||||
│ ) │
|
||||
│ ↓ reads provider.ISOLATION │
|
||||
│ ↓ mkdtemp ephemeralRoot │
|
||||
│ ↓ mkdir requiredHomePaths │
|
||||
│ ↓ symlink/copy credentialMounts │
|
||||
│ ↓ compose ephemeralEnvOverrides │
|
||||
│ ↓ wrap args via toolHardening │
|
||||
└────────────────────┬─────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────┐
|
||||
│ child_process.spawn(bin, args, { │
|
||||
│ env: composedEnv, cwd: epRoot, ... │
|
||||
│ }) │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The provider plugin is the **authority** for what isolation primitives are needed. The provider knows what env var its CLI honors for credential lookup (`HOME`, `CODEX_HOME`, `VIBE_HOME`, …). The provider knows whether the CLI has an inner sandbox that must be permitted to clone user namespaces. The provider knows the cross-tenant read protection regime it ships under. The orchestrator's job is purely composition; it must not know that "for codex, use `CODEX_HOME`" — that knowledge belongs in `lib/providers/codex.mjs`.
|
||||
|
||||
This is the same separation-of-concerns principle that has governed every prior amendment to this ADR: provider-specific knowledge lives in the provider file; the orchestrator stays generic. Amendment 7's `doctorChecks()` followed it (per-provider repair recipes); Amendment 8's `quotaStatus()` followed it (per-provider probe authorities); this amendment follows it for isolation primitives.
|
||||
|
||||
#### Decision — add OPTIONAL `ISOLATION` named export to the Provider plugin module
|
||||
|
||||
Each provider plugin module (`lib/providers/<name>.mjs`) MAY export, in addition to the default-exported provider object, a named const `ISOLATION` describing the isolation primitives the orchestrator should compose for spawns of this provider. The shape is:
|
||||
|
||||
```javascript
|
||||
export const ISOLATION = {
|
||||
ephemeralEnvOverrides: ({ ephemeralRoot, keyId, reqId }) => ({ /* env var map */ }),
|
||||
credentialMounts: [ [srcAbsPath, dstRelativeToEphemeralRoot], ... ],
|
||||
requiredHomePaths: [ /* dirs to mkdir empty under ephemeralRoot */ ],
|
||||
hasInnerSandbox: boolean,
|
||||
crossTenantReadProtection: 'tool-suppression' | 'inner-sandbox' | 'none',
|
||||
recommendedDeploymentTier: 'shared-os-user' | 'per-os-user' | 'separate-vm',
|
||||
toolHardeningArgs: (existingArgs) => modifiedArgs, // optional
|
||||
}
|
||||
```
|
||||
|
||||
The export is **optional**. A plugin that omits `ISOLATION` continues to spawn under the legacy unsandboxed shape exactly as it does today — see § Backward compatibility below. The opt-in surface is consistent with Amendment 7's `doctorChecks()` treatment (additive, no breakage for plugins that haven't been touched).
|
||||
|
||||
The remainder of this amendment specifies each field's semantics, default-when-absent behavior, validation rules, and authority citations. The three currently-shipped providers' concrete declarations are specified in § Per-provider concrete instances.
|
||||
|
||||
#### Field specification
|
||||
|
||||
##### 1. `ephemeralEnvOverrides({ ephemeralRoot, keyId, reqId }) → { [envVar]: string }`
|
||||
|
||||
**Type and semantics.** A pure (no-side-effect, no-fs-touch) function that, given the orchestrator's composed context (`ephemeralRoot`: absolute path to the spawn-scoped temp dir; `keyId`: the OLP key identity from `lib/keys.mjs` driving the request; `reqId`: the per-request UUID), returns a flat object of environment variables that the orchestrator will merge into the spawn env. The returned env vars are how the provider CLI is steered to read its credentials from the ephemeral root rather than the server process's actual home directory.
|
||||
|
||||
**Why a function and not a static object.** Because `ephemeralRoot` is generated per-spawn by `mkdtemp` and is not known at plugin load time. Because `keyId` and `reqId` are not known until the request arrives. A static object cannot carry the dependency on these values; a function carries it cleanly.
|
||||
|
||||
**Purity contract.** The function MUST be referentially transparent w.r.t. its argument object: identical input arguments yield identical output env maps. It MUST NOT read the filesystem, spawn subprocesses, or mutate the input arguments. It MUST NOT close over module-level mutable state. This contract is what makes the spawn pipeline auditable: a reviewer reading `provider.ISOLATION.ephemeralEnvOverrides({ ephemeralRoot: '/tmp/x', keyId: 'k1', reqId: 'r1' })` can know the full env mutation without running the system.
|
||||
|
||||
**Default behavior when absent.** When `ISOLATION` is absent or `ISOLATION.ephemeralEnvOverrides` is missing, the orchestrator MUST emit no environment overrides for that provider — `child_process.spawn` runs with `process.env` (possibly modified by other contract layers such as the existing `spawn()` method's env cleanup, ADR 0009 Amendment 1's `--system-prompt` injection, etc.). This preserves Phase 6c / pre-Phase 7 behavior exactly.
|
||||
|
||||
**Validation rules.** At plugin load (in `validateProvider` or a sibling `validateIsolation` helper):
|
||||
- If `ISOLATION` is defined and `ephemeralEnvOverrides` is defined, it MUST be a function. A non-function value (e.g., a static object) is a load-time error.
|
||||
- The function is NOT invoked at load time — its return shape is not validated until first spawn. Load-time invocation would require synthetic dummy arguments and would couple the validator to the orchestrator's argument shape (which itself may evolve under future ADR 0014 amendments).
|
||||
- First-spawn invocation MUST validate the return value is a plain object whose values are all strings. Non-string values (numbers, booleans, undefined) MUST cause the spawn to abort with a clear error rather than coerce silently — the env block crosses a kernel boundary and silent coercion is a footgun.
|
||||
|
||||
**Authority citation requirement.** Each env var returned must correspond to a documented credential-resolution lookup in the underlying provider CLI. For example, `HOME` is a POSIX convention for credential lookup (well-established, no citation needed beyond the POSIX umbrella). `CODEX_HOME` is documented (primary) at https://developers.openai.com/codex/config-reference (2 occurrences verified 2026-05-29: `$CODEX_HOME/profile-name.config.toml` and `$CODEX_HOME/log` path templates), with secondary corroboration at https://developers.openai.com/codex/auth/ (2 occurrences in the credential-storage section: `auth.json under CODEX_HOME`). `VIBE_HOME` is documented at https://docs.mistral.ai/mistral-vibe/terminal/configuration (3 occurrences verified 2026-05-29: descriptive sentence "Override the location with the `VIBE_HOME` environment variable", canonical `export VIBE_HOME="/path/to/custom/vibe/home"` example, and an enumeration of files/directories `VIBE_HOME` affects). The provider plugin author MUST cite the underlying CLI's env-var documentation in the plugin file's header (the same place existing CLI-flag citations live, per Rule 1 of `ALIGNMENT.md`).
|
||||
|
||||
Inventing an env var the provider CLI does not actually honor (e.g., setting `MISTRAL_HOME=...` when no such env var exists) is a Rule 2 violation and is unalignable per Rule 4 of `ALIGNMENT.md`.
|
||||
|
||||
##### 2. `credentialMounts: [ [srcAbsPath, dstRelativeToEphemeralRoot], ... ]`
|
||||
|
||||
**Type and semantics.** An array of `[src, dst]` tuples describing how the server process's real on-disk credential artifacts (OAuth tokens, API keys, refresh artifacts) are made available inside the ephemeral home. The orchestrator iterates this list and, for each tuple, ensures `<ephemeralRoot>/<dst>` resolves (via symlink, copy, or bind-mount depending on platform and constraints) to the data at `<src>`.
|
||||
|
||||
The mount strategy is a property of the orchestrator, not the provider — `lib/sandbox/manager.mjs` decides between symlink (cheapest, on macOS and unconfined Linux), copy (when crossing a namespace boundary that breaks symlinks), and bind-mount (under a future bwrap-equipped path). The provider only declares the source-destination correspondence.
|
||||
|
||||
**Why this is a list, not a function.** The mounts are static per-provider: anthropic always mounts `~/.claude/.credentials.json`, codex always mounts `~/.codex/auth.json`. A function form would invite plugin authors to compute mount paths from per-request state, which would be a security hazard (per-request mount lists are harder to audit at code-review time). Forcing the static form makes the credential surface visible by `grep ISOLATION lib/providers/*.mjs`.
|
||||
|
||||
**Default behavior when absent.** Empty mount list — the spawn sees no credential files in its ephemeral home. For most providers this means authentication fails and the spawn errors out cleanly; the orchestrator MUST log a clear "no credentialMounts declared" message before allowing the spawn to proceed, since the most common cause is "plugin author forgot to declare the mount."
|
||||
|
||||
**Validation rules.**
|
||||
- Each entry MUST be a 2-tuple (length-2 array). Single-element entries or 3+-tuples are load-time errors.
|
||||
- `srcAbsPath` MUST be an absolute path (starts with `/`). Relative paths or `~/`-prefixed paths are load-time errors — the plugin author must call `os.homedir()` explicitly. Rationale: `~/` expansion semantics vary between Node and shells and would silently break under the per-spawn ephemeral home (where `HOME` is rewritten).
|
||||
- `dstRelativeToEphemeralRoot` MUST NOT start with `..` (no parent-directory escape) and MUST NOT be absolute (no `/etc/passwd` overlay attempts). Both are load-time errors. The orchestrator's path-composition (`path.join(ephemeralRoot, dst)`) is the *only* path-resolution step that touches the destination — the validation forbids constructions that could escape `ephemeralRoot` even before composition.
|
||||
- `srcAbsPath` MAY refer to a path that does not exist at plugin-load time. The orchestrator's mount step does a `existsSync(src)` check at spawn-time and logs a "credential source missing" warning rather than failing the spawn — this is consistent with the existing `auth.path` field behavior in the Provider contract (an absent credential file is an auth condition, not a load-time error).
|
||||
- Two mounts with the same `dst` is a load-time error (no implicit ordering or override).
|
||||
|
||||
**Authority citation requirement.** Each `srcAbsPath` MUST correspond to the credential location documented by the underlying provider CLI. For anthropic: `~/.claude/.credentials.json` is the OAuth artifact per `claude` CLI docs (already cited by the plugin's `auth.path` field). For codex: `~/.codex/auth.json` per https://developers.openai.com/codex/auth/. For mistral: `~/.vibe/.env` per https://docs.mistral.ai/mistral-vibe/terminal/configuration. Plugin authors MUST cite the same authority as the `auth.path` field they already declare — the citations should be consistent.
|
||||
|
||||
##### 3. `requiredHomePaths: [ /* relative paths */ ]`
|
||||
|
||||
**Type and semantics.** An array of relative paths (e.g., `['.claude', '.claude/logs']`) that the orchestrator MUST `mkdir -p` under `ephemeralRoot` before any `credentialMounts` are processed and before the spawn begins. These are directories the provider CLI expects to exist in `HOME` and will fail or behave incorrectly if they're absent (e.g., logging directories that the CLI doesn't auto-create).
|
||||
|
||||
**Why a separate field from `credentialMounts`.** Some providers expect empty directories — not mounted credential files — at certain paths. Treating "empty directory" as a mount with src=null would muddle the validation rules for `credentialMounts`. A dedicated list is cleaner.
|
||||
|
||||
**Default behavior when absent.** Empty list — only the directories implied by `credentialMounts[i].dst` (their parent dirs, created by `mkdir -p` during the mount step) exist under `ephemeralRoot`. For most providers this is fine.
|
||||
|
||||
**Validation rules.**
|
||||
- Each entry MUST be a relative path string. Same anti-escape rules as `credentialMounts[i].dst`: no leading `..`, no absolute paths.
|
||||
- Entries MAY overlap with `credentialMounts[i].dst` parent paths (no error; orchestrator's `mkdir -p` is idempotent).
|
||||
- Duplicate entries are not an error (idempotent), but the linter / future CI grep should flag them as a code smell.
|
||||
|
||||
**Authority citation requirement.** None directly required for the path values themselves — these are typically convention (e.g., `.claude` mirrors the CLI's expected `$HOME/.claude` layout). However, if a plugin declares a `requiredHomePaths` entry that does not correspond to any documented CLI behavior, the plugin's header comment should explain *why* the directory must exist (observed behavior, error message from CLI, etc.). Speculative directories ("just in case the CLI wants this") are a Rule 2 violation — only directories whose absence is known to cause CLI failure should be listed.
|
||||
|
||||
##### 4. `hasInnerSandbox: boolean`
|
||||
|
||||
**Type and semantics.** A boolean flag declaring whether this provider's CLI spawns its own internal sandbox boundary during normal operation. The orchestrator uses this flag to decide whether the outer isolation primitives need to be loosened to permit nested sandboxing (e.g., allow `clone(CLONE_NEWUSER)` syscalls, permit `bwrap` to nest).
|
||||
|
||||
**Why a boolean and not an enum.** "Has inner sandbox or not" is the discriminator the orchestrator needs. The *kind* of inner sandbox (bwrap, sandbox-exec, seccomp-only) is a detail the orchestrator does not need to compose against — it just needs to know whether to relax the outer profile. If a future provider requires per-sandbox-flavor handling, this field can be widened to an enum in a subsequent amendment.
|
||||
|
||||
**Default behavior when absent.** Treated as `false`. This is the safer-by-default value — outer isolation stays at its strictest setting. A provider that actually has an inner sandbox but forgets to declare it will fail at spawn time (inner-bwrap attempts denied by outer profile); the failure mode is loud and obvious, which is the desired behavior.
|
||||
|
||||
**Validation rules.** MUST be a literal `true` or `false`. Truthy/falsy coercion (e.g., declaring `1` or `'yes'`) is a load-time error — booleans are the documented type and coercion would silently change the orchestrator's composition decision.
|
||||
|
||||
**Authority citation requirement.** A `hasInnerSandbox: true` declaration MUST cite the CLI's documented or observed inner-sandbox behavior in the plugin header. For codex, the citation is `openai/codex#16018` (the GitHub issue documenting `codex exec` invoking bubblewrap internally) plus https://developers.openai.com/codex/concepts/sandboxing (the official docs page describing the `--sandbox` flag and `read-only` default). For a hypothetical future provider, the citation is whatever CLI doc or observed-behavior transcript establishes the inner sandbox.
|
||||
|
||||
##### 5. `crossTenantReadProtection: 'tool-suppression' | 'inner-sandbox' | 'none'`
|
||||
|
||||
**Type and semantics.** A discriminated string declaring the regime under which this provider's spawn is protected against cross-tenant filesystem reads. The three values correspond to the three regimes observed in the 2026-05-27 prior-art / incident analysis (see incident memory § 6):
|
||||
|
||||
- `'tool-suppression'` — the provider's CLI exposes no filesystem-reading tools to the model during the spawn, because OLP suppresses them at the request level. For anthropic, this is achieved via ADR 0009 Amendment 1's `--system-prompt` injection combined with the absence of `--tools` flags: the model has no shell, no file-read, no bash, no Read/Write/Edit primitives. The cross-tenant read surface is closed at the prompt-engineering layer; OS-level isolation is a defense in depth but not the primary regime.
|
||||
|
||||
- `'inner-sandbox'` — the provider's CLI has tool execution (e.g., codex's `shell` tool, which actually runs commands) but the CLI's own inner sandbox prevents the tool from reading paths outside its declared allow-list. For codex, the inner bwrap sandbox enforces `--sandbox read-only` by default (per https://developers.openai.com/codex/concepts/sandboxing), so even though the model can call `shell`, the shell's reads are confined to the inner namespace. The cross-tenant read surface is closed at the inner-sandbox layer.
|
||||
|
||||
- `'none'` — no protection regime is currently established for this provider. The model may have tools that read files, and there is no inner sandbox blocking those reads. Operationally this means the provider should NOT be enabled in a multi-tenant deployment until a regime is established. The orchestrator MUST log a WARN at server boot when a provider with `crossTenantReadProtection: 'none'` is enabled in a deployment with >1 active OLP key — observability, not enforcement (see Rule 4 compliance below).
|
||||
|
||||
**Why a discriminated enum, not a free-form string.** The orchestrator and the operator dashboard both consume this field. Free-form values would require every consumer to perform string-matching against a moving target. The enum locks the consumer surface; future regimes are added by amending this list in a subsequent ADR 0002 amendment.
|
||||
|
||||
**Default behavior when absent.** Treated as `'none'`. Safer-by-default in the WARN sense (operators get the WARN log) but NOT in the security sense (no protection is actually applied). This is intentional: the orchestrator cannot fabricate a protection regime the plugin hasn't implemented; the WARN nudges the plugin author to declare honestly.
|
||||
|
||||
**Validation rules.** MUST be one of the three enum values literally. Any other string is a load-time error. The orchestrator MUST log the field's value at server startup so operators can audit the protection picture across providers at a glance.
|
||||
|
||||
**Authority citation requirement.**
|
||||
- `'tool-suppression'` declarations MUST cite the suppression mechanism (e.g., for anthropic: ADR 0009 Amendment 1 § "--system-prompt" + the absence-of-tools posture documented at the incident memory § 6.1).
|
||||
- `'inner-sandbox'` declarations MUST cite the CLI doc or observed behavior establishing the inner sandbox (e.g., for codex: `openai/codex#16018` + https://developers.openai.com/codex/concepts/sandboxing).
|
||||
- `'none'` is the safer default and requires no citation but MUST be accompanied by a header-comment TODO documenting what regime is expected to be established when the provider transitions from Candidate to Enabled (or earlier if the provider is enabled in a multi-tenant context).
|
||||
|
||||
##### 6. `recommendedDeploymentTier: 'shared-os-user' | 'per-os-user' | 'separate-vm'`
|
||||
|
||||
**Type and semantics.** A discriminated string giving operators a deployment-topology recommendation for this provider in a multi-tenant context. The three values express increasing degrees of operator-side isolation:
|
||||
|
||||
- `'shared-os-user'` — the OLP server process runs as a single OS user, and multiple OLP keys share that user. Protection against cross-tenant leakage rests entirely on the provider's `crossTenantReadProtection` regime + the orchestrator's ephemeral-home composition. This is the recommended posture for providers where `crossTenantReadProtection` is `'tool-suppression'` AND `hasInnerSandbox: false` (i.e., the model has no filesystem-touching tools at all).
|
||||
|
||||
- `'per-os-user'` — each OLP key (or each tenant) should map to a separate OS user, with file-permission-level isolation between tenants. The recommended posture for providers with `crossTenantReadProtection: 'inner-sandbox'` — the inner sandbox protects against accidental leakage from the model's tools, but a sandbox-escape (e.g., a CVE in bubblewrap, a misconfigured inner profile) would expose the OS-user filesystem; per-OS-user isolation adds defense in depth.
|
||||
|
||||
- `'separate-vm'` — the provider should not be co-located with any other tenant on the same VM. The recommended posture for providers with `crossTenantReadProtection: 'none'` AND/OR ones where the operator has reason to distrust the inner sandbox's quality. Practically this means the provider should not be enabled in OLP's family-LAN deployment unless the family-LAN host runs only this tenant.
|
||||
|
||||
**Why a recommendation and not a hard policy.** The orchestrator and OLP runtime cannot *enforce* OS-user separation or VM separation — those are properties of the host operator's deployment topology. This field is informational: it surfaces in `/health.providers.<name>.isolation` (a Phase 7 addition planned in a follow-up amendment) and in the dashboard, so operators making deployment decisions have the per-provider recommendation visible. Operator override is the expected normal path: a deployment that knowingly accepts the risk of running an `'separate-vm'` provider in a shared-user context is acceptable, just observable.
|
||||
|
||||
**Default behavior when absent.** Treated as `'separate-vm'` — the safest recommendation in the absence of declared analysis. The WARN log emitted for missing `ISOLATION` blocks (see Rule 4 compliance below) covers operator visibility.
|
||||
|
||||
**Validation rules.** MUST be one of the three enum values literally. Any other string is a load-time error.
|
||||
|
||||
**Authority citation requirement.** The plugin author MUST cite the basis for the recommendation in the plugin header — typically a short paragraph reasoning about the combination of `hasInnerSandbox` and `crossTenantReadProtection` for this provider. The reasoning is not a CLI authority citation (the underlying CLI does not declare deployment topology); it is an OLP-side analysis. The expected citation form is `# isolation rationale: <2-3 sentences> (cf. ADR 0014 Amendment 1 § <relevant section>)`.
|
||||
|
||||
##### 7. `toolHardeningArgs: (existingArgs) => modifiedArgs` (OPTIONAL)
|
||||
|
||||
**Type and semantics.** An OPTIONAL pure function that, given the plugin's `spawn()` method's CLI args (the array passed to `child_process.spawn`), returns a (possibly modified) args array with additional tool-hardening flags inserted. The orchestrator calls this hook after the plugin's `spawn()` constructs its args but before the actual `child_process.spawn` invocation.
|
||||
|
||||
**Purpose.** Some providers expose CLI flags that suppress or restrict the model's tool access at the per-spawn level (e.g., `--disallowedTools` on `claude`, or `--sandbox read-only` on `codex`). These flags are the *enforcement mechanism* corresponding to the `crossTenantReadProtection` *declaration*. Splitting the declaration (a static field) from the enforcement (a function that mutates args) keeps the contract auditable while letting the enforcement evolve as the underlying CLI's flag set changes.
|
||||
|
||||
**Why this is OPTIONAL.** For providers where `crossTenantReadProtection: 'tool-suppression'` is achieved entirely via the `spawn()` method's existing args construction (e.g., the existing anthropic.mjs `--system-prompt` injection), no separate hardening step is needed — the field can be omitted. For providers where the orchestrator needs to inject additional flags atop the plugin's base args, the field provides the hook.
|
||||
|
||||
**Default behavior when absent.** No args modification — the plugin's `spawn()` method's args are passed through to `child_process.spawn` unchanged. This is the current Phase 6c behavior for anthropic and is appropriate when the `spawn()` method already encodes the hardening.
|
||||
|
||||
**Validation rules.**
|
||||
- If declared, MUST be a function.
|
||||
- First-spawn invocation MUST validate the return value is an array of strings. Non-array or non-string-element returns abort the spawn (silent coercion is unsafe at the kernel boundary).
|
||||
- The function MUST be referentially transparent — same input array yields same output array (no module-level state, no fs reads).
|
||||
- The orchestrator MUST NOT pass the args by reference in a way that the function could mutate the original `existingArgs`. The hook receives a defensive copy; returning a fresh array is required.
|
||||
|
||||
**Authority citation requirement.** The injected flags MUST be documented CLI flags of the underlying provider. Inventing a `--disable-tools` flag that the CLI does not support is a Rule 2 violation. For codex, citing https://developers.openai.com/codex/concepts/sandboxing § `--sandbox` is sufficient. For anthropic, the existing ADR 0009 Amendment 1 citation covers the tool-suppression mechanism.
|
||||
|
||||
#### Per-provider concrete instances
|
||||
|
||||
The three currently-shipped providers declare `ISOLATION` as follows. Each declaration MUST be present in the corresponding plugin file before that provider can be enabled in any multi-tenant deployment (see § Rule 4 compliance and § Backward compatibility for the transition path).
|
||||
|
||||
##### anthropic
|
||||
|
||||
```javascript
|
||||
// lib/providers/anthropic.mjs
|
||||
//
|
||||
// isolation rationale: Anthropic Claude reaches OLP via stream-json transport
|
||||
// without a tool surface (ADR 0009 Amendment 1's --system-prompt injection
|
||||
// suppresses env-block, file tools, bash, and Read/Write/Edit). The model
|
||||
// has no documented mechanism to read files during the spawn. Cross-tenant
|
||||
// read protection is achieved at the prompt-engineering / CLI-flag layer.
|
||||
// The OS-level isolation primitives (HOME redirect + ephemeral credential
|
||||
// mount) add defense in depth against future CLI changes that might
|
||||
// re-introduce a tool surface.
|
||||
//
|
||||
// Authority: @anthropic-ai/claude-code v2.1.150 § --system-prompt
|
||||
// (ADR 0009 Amendment 1 + incident memory § 6.1 establishes the
|
||||
// tool-suppression mechanism); HOME env conventional POSIX behavior.
|
||||
|
||||
export const ISOLATION = {
|
||||
ephemeralEnvOverrides: ({ ephemeralRoot, keyId, reqId }) => ({
|
||||
HOME: ephemeralRoot,
|
||||
// CLAUDE_CONFIG_DIR is NOT honored as of v2.1.150 — the CLI reads from
|
||||
// $HOME/.claude/.credentials.json. Redirecting HOME is the documented
|
||||
// mechanism. The keyId / reqId arguments are unused here but received for
|
||||
// signature consistency with codex's overrides.
|
||||
}),
|
||||
credentialMounts: [
|
||||
// OAuth artifact location. Authority: existing anthropic.mjs `auth.path`
|
||||
// field — `~/.claude/.credentials.json` is the documented OAuth artifact.
|
||||
// The orchestrator resolves the absolute src path via os.homedir() at
|
||||
// load time (the plugin file shows the literal `join(homedir(), ...)`).
|
||||
[/* resolved at load: */ '<homedir>/.claude/.credentials.json',
|
||||
'.claude/.credentials.json'],
|
||||
],
|
||||
requiredHomePaths: [
|
||||
'.claude',
|
||||
// No observed behavior requires additional dirs; CLI creates session logs
|
||||
// under .claude/ on demand. If future CLI versions add a mandatory pre-
|
||||
// existing subdir, add it here with an observed-behavior comment.
|
||||
],
|
||||
hasInnerSandbox: false,
|
||||
crossTenantReadProtection: 'tool-suppression',
|
||||
recommendedDeploymentTier: 'shared-os-user',
|
||||
// toolHardeningArgs omitted — the existing spawn() method's args already
|
||||
// encode the --system-prompt suppression (ADR 0009 Amendment 1).
|
||||
};
|
||||
```
|
||||
|
||||
**Authority pin for the anthropic ISOLATION declaration:**
|
||||
- `--system-prompt` mechanism: ADR 0009 Amendment 1 + incident memory `~/.cc-rules/memory/projects/olp/incident_2026_05_27_spawn_cli_security.md` § 6.1
|
||||
- `HOME` env redirect: POSIX convention; `claude` CLI v2.1.150 observed to read `~/.claude/.credentials.json` via HOME (verified by the PR-B PI231 spike, confirmed by ADR 0014 Amendment 1's HOME-override verification task)
|
||||
|
||||
##### codex
|
||||
|
||||
```javascript
|
||||
// lib/providers/codex.mjs
|
||||
//
|
||||
// isolation rationale: OpenAI Codex's `codex exec` exposes a shell tool that
|
||||
// actually executes commands during the spawn (incident memory § 3.2). The
|
||||
// CLI provides its own inner bubblewrap sandbox (`--sandbox read-only` by
|
||||
// default per https://developers.openai.com/codex/concepts/sandboxing) that
|
||||
// confines shell tool reads/writes. The orchestrator's outer isolation
|
||||
// composes with the inner sandbox: HOME-equivalent redirect via CODEX_HOME
|
||||
// (per https://developers.openai.com/codex/config-reference) plus per-spawn
|
||||
// ephemeral credential mount. hasInnerSandbox: true so the outer profile is
|
||||
// relaxed to permit inner bwrap's user-namespace clone.
|
||||
//
|
||||
// Authority: openai/codex#16018 (inner bwrap behavior);
|
||||
// https://developers.openai.com/codex/concepts/sandboxing (--sandbox flag);
|
||||
// https://developers.openai.com/codex/config-reference (CODEX_HOME);
|
||||
// https://developers.openai.com/codex/auth/ (~/.codex/auth.json path).
|
||||
|
||||
export const ISOLATION = {
|
||||
ephemeralEnvOverrides: ({ ephemeralRoot, keyId, reqId }) => ({
|
||||
// CODEX_HOME overrides the base config / credential dir. Docs:
|
||||
// https://developers.openai.com/codex/config-reference and
|
||||
// https://developers.openai.com/codex/auth/
|
||||
CODEX_HOME: `${ephemeralRoot}/.codex`,
|
||||
// HOME also redirected for codex's own bubblewrap-internal HOME lookup
|
||||
// (the inner sandbox inherits parent HOME unless overridden).
|
||||
HOME: ephemeralRoot,
|
||||
}),
|
||||
credentialMounts: [
|
||||
// Auth artifact location. Authority: existing codex.mjs `auth.path` field
|
||||
// (Codex CLI reference § Authentication, plus
|
||||
// https://developers.openai.com/codex/auth/ canonical pin).
|
||||
[/* resolved at load: */ '<homedir>/.codex/auth.json',
|
||||
'.codex/auth.json'],
|
||||
],
|
||||
requiredHomePaths: [
|
||||
'.codex',
|
||||
// Inner bwrap may create additional state under .codex/. If observed
|
||||
// behavior shows the CLI failing on absent subdirs, add them here.
|
||||
],
|
||||
hasInnerSandbox: true,
|
||||
crossTenantReadProtection: 'inner-sandbox',
|
||||
recommendedDeploymentTier: 'per-os-user',
|
||||
toolHardeningArgs: (existingArgs) => {
|
||||
// If the operator has not explicitly passed --sandbox, inject the
|
||||
// documented read-only default. Per
|
||||
// https://developers.openai.com/codex/concepts/sandboxing the default
|
||||
// posture is `read-only`; this hardening hook makes the default explicit
|
||||
// at the spawn args level so a future CLI default change does not
|
||||
// silently weaken the isolation.
|
||||
if (existingArgs.some(arg => arg === '--sandbox' || arg.startsWith('--sandbox='))) {
|
||||
return existingArgs;
|
||||
}
|
||||
return [...existingArgs, '--sandbox', 'read-only'];
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
**Authority pin for the codex ISOLATION declaration:**
|
||||
- `CODEX_HOME`: https://developers.openai.com/codex/config-reference (retrieved 2026-05-29)
|
||||
- `~/.codex/auth.json`: https://developers.openai.com/codex/auth/ (existing `auth.path` citation in codex.mjs)
|
||||
- Inner bwrap behavior: `openai/codex#16018` plus https://developers.openai.com/codex/concepts/sandboxing
|
||||
- `--sandbox read-only`: https://developers.openai.com/codex/concepts/sandboxing § "Sandboxing modes"
|
||||
|
||||
##### mistral
|
||||
|
||||
```javascript
|
||||
// lib/providers/mistral.mjs
|
||||
//
|
||||
// isolation rationale: Mistral Vibe ships at OLP Phase 7 with no known
|
||||
// equivalent to Anthropic's Phase 6c --system-prompt tool suppression and
|
||||
// no known inner sandbox. The IR-level normalization shipped at D8 does not
|
||||
// suppress tools at the CLI layer. Cross-tenant read protection is therefore
|
||||
// 'none' — the provider should not be enabled in a multi-tenant deployment
|
||||
// until a regime is established. The declaration here exists so the
|
||||
// orchestrator can compose ephemeral-home credential isolation (which still
|
||||
// works) while the operator sees a clear WARN that the tool-side protection
|
||||
// is not in place.
|
||||
//
|
||||
// Authority: TBD — a spike task tracked at Phase 7 follow-up (see Open
|
||||
// Questions section below) will verify Vibe CLI's tool surface and inner
|
||||
// sandbox posture against https://docs.mistral.ai/mistral-vibe/terminal/.
|
||||
// Until that spike lands, this declaration documents the current honest
|
||||
// state per ALIGNMENT.md Rule 3 (Match the Implementation): no protection
|
||||
// is encoded because none has been established.
|
||||
|
||||
export const ISOLATION = {
|
||||
ephemeralEnvOverrides: ({ ephemeralRoot, keyId, reqId }) => ({
|
||||
// VIBE_HOME is documented at
|
||||
// https://docs.mistral.ai/mistral-vibe/terminal/configuration as the
|
||||
// env var that overrides the default ~/.vibe/ base directory
|
||||
// (3 occurrences verified 2026-05-29: descriptive sentence,
|
||||
// canonical export example, and an enumeration of files/dirs the
|
||||
// variable affects). Task #4 PI231 spike verifies observed CLI
|
||||
// behaviour matches the documented contract.
|
||||
VIBE_HOME: `${ephemeralRoot}/.vibe`,
|
||||
HOME: ephemeralRoot,
|
||||
}),
|
||||
credentialMounts: [
|
||||
// ~/.vibe/.env per existing mistral.mjs `auth.path` field, sourced from
|
||||
// https://docs.mistral.ai/mistral-vibe/terminal/configuration.
|
||||
[/* resolved at load: */ '<homedir>/.vibe/.env', '.vibe/.env'],
|
||||
],
|
||||
requiredHomePaths: [
|
||||
'.vibe',
|
||||
],
|
||||
hasInnerSandbox: false,
|
||||
crossTenantReadProtection: 'none',
|
||||
recommendedDeploymentTier: 'separate-vm',
|
||||
// toolHardeningArgs omitted — no CLI hardening flag is currently known for
|
||||
// Vibe. The Phase 7 spike will revisit.
|
||||
};
|
||||
```
|
||||
|
||||
**Authority pin for the mistral ISOLATION declaration:**
|
||||
- `VIBE_HOME`: https://docs.mistral.ai/mistral-vibe/terminal/configuration (3 occurrences verified 2026-05-29: descriptive sentence "Override the location with the `VIBE_HOME` environment variable", canonical `export VIBE_HOME="/path/to/custom/vibe/home"` example, and the enumeration of files/directories `VIBE_HOME` affects).
|
||||
- `~/.vibe/.env`: same source (existing `auth.path` citation in mistral.mjs).
|
||||
- **Open spike (Phase 7 follow-up, Task #4):** verify *observed CLI behaviour* matches *documented behaviour* — (a) Vibe CLI actually honours the documented `VIBE_HOME` env var during spawn; (b) Vibe CLI's tool surface (shell, file-read, etc.) during a `vibe --prompt` spawn; (c) any CLI sandbox or tool-suppression flag. Findings may transition `crossTenantReadProtection` from `'none'` to `'tool-suppression'` or `'inner-sandbox'` if a hardening regime is discovered. The spike is verification-grade, not authority-pin work.
|
||||
|
||||
#### Backward compatibility
|
||||
|
||||
A plugin that does NOT export `ISOLATION` continues to work exactly as it does today. The orchestrator's `prepareIsolatedEnvironment(provider, ctx)` function MUST detect the absence of `provider.ISOLATION` (or the absence of any individual field within it) and fall through to the legacy unsandboxed code path for that spawn. The legacy path is:
|
||||
|
||||
- No ephemeral root created
|
||||
- No env overrides
|
||||
- No credential mounts
|
||||
- `cwd: process.cwd()` (the server's working directory)
|
||||
- `env: process.env` (composed with whatever the plugin's `spawn()` method's existing env logic produces)
|
||||
|
||||
This is the same behavior as Phase 6c. No provider plugin is broken by Amendment 9's landing.
|
||||
|
||||
Plugins MAY adopt `ISOLATION` incrementally: a plugin that wants the credential-mount benefit but has not yet analyzed its cross-tenant tool surface MAY declare `crossTenantReadProtection: 'none'` and `recommendedDeploymentTier: 'separate-vm'` (the safer-by-default values). The orchestrator will compose the credential isolation correctly; the WARN log nudges follow-up.
|
||||
|
||||
#### Rule 4 compliance (ALIGNMENT.md)
|
||||
|
||||
ALIGNMENT.md Rule 4 states: "Unalignable plugins / fields are deleted, not feature-flagged." This amendment introduces an OPTIONAL contract field, which on its face could be read as "feature-flagging" isolation. The reading is wrong, and the distinction is important enough to spell out:
|
||||
|
||||
- Amendment 9 does NOT introduce an `ISOLATION` feature flag that operators or plugins toggle on/off. The field's presence/absence describes **the provider's truthful isolation posture** at a point in time. A plugin without `ISOLATION` declares (implicitly) that no analysis has been done and the safer-by-default treatment applies.
|
||||
- The OPTIONAL nature is purely transitional. Existing plugins ship without it; they continue to spawn (in their existing single-tenant developer-laptop posture). The orchestrator's WARN log surfaces the absence to the operator at server boot. An operator running a multi-tenant deployment with un-declared plugins is operating off-recommendation but not blocked.
|
||||
- A plugin that declares `ISOLATION` with values the orchestrator cannot honor (e.g., a `credentialMounts` entry pointing at a path that does not exist, or an `ephemeralEnvOverrides` function that returns non-string values) MUST fail at first spawn — the orchestrator does not silently fall back to the no-ISOLATION path. This is the Rule 4 enforcement vector: a *broken* declaration is unalignable and surfaces loudly; a *missing* declaration is the safer transitional state.
|
||||
|
||||
The WARN at server boot is observability, not enforcement. It reads approximately:
|
||||
|
||||
```
|
||||
[WARN] provider "<name>" does not declare ISOLATION; spawns will run
|
||||
under legacy unsandboxed shape. Recommended in multi-tenant
|
||||
deployments: declare ISOLATION per ADR 0002 Amendment 9.
|
||||
```
|
||||
|
||||
Operators in single-tenant developer deployments may safely ignore the WARN. Operators in multi-tenant deployments should treat it as a Phase 7 follow-up task.
|
||||
|
||||
#### Interaction with prior amendments
|
||||
|
||||
- **Amendment 1 (`maxSpawnTimeMs`).** Independent. The spawn-timeout enforcement lives inside each plugin's spawn drain loop; the orchestrator's ISOLATION composition happens *before* the spawn, so the two amendments compose without conflict.
|
||||
- **Amendment 3 (`cacheable`).** Independent. The cache layer decides whether to call the orchestrator at all; once the orchestrator is reached, ISOLATION composition is orthogonal to cacheability.
|
||||
- **Amendment 4 (`contractVersion`).** Independent. `contractVersion: '1.0'` plugins MAY add an `ISOLATION` export under Amendment 9 without bumping the contract version — `ISOLATION` is an additive named export, not a v1.0 contract surface change. A future Provider contract v1.1 may promote `ISOLATION` to a required field (forcing all enabled plugins to declare); that decision is deferred to a future amendment, gated on the Phase 7 follow-up findings.
|
||||
- **Amendment 6 (`maxConcurrent` runtime enforcement).** Independent. The semaphore acquire happens before the orchestrator's `prepareIsolatedEnvironment`; the release happens after the spawn drains. ISOLATION composition is bracketed by the semaphore, not entangled with it.
|
||||
- **Amendment 7 (`doctorChecks()`).** Adjacent. A future plugin may add an `<provider>.isolation_declared` doctor check that reports whether `ISOLATION` is declared and whether its referenced credential paths resolve. The check is OPTIONAL per Amendment 7's framework and is appropriate for `olp doctor` operator UX.
|
||||
- **Amendment 8 (`quotaStatus()` direct-API exemption).** Independent. The quota probe runs outside the spawn pipeline (direct HTTPS from server process); it does not interact with `ISOLATION` composition.
|
||||
|
||||
#### Companion ADR
|
||||
|
||||
This amendment is the companion governance piece for **ADR 0014 Amendment 1** (the Phase 7 architectural shift from outer-bwrap PR-B to per-spawn ephemeral-home + per-provider primitives). ADR 0014 Amendment 1 describes the orchestrator's composition algorithm and the rationale for retiring the outer-bwrap approach; ADR 0002 Amendment 9 (this section) describes the contract surface the orchestrator reads.
|
||||
|
||||
The two amendments are reviewed and merged together as a single coupled commit (Iron Rule 11 — minimum reviewable unit per layer). Reviewing them separately cannot verify producer-consumer alignment: the orchestrator's algorithm is meaningless without the contract it consumes, and the contract is meaningless without the orchestrator's composition discipline.
|
||||
|
||||
#### Tests
|
||||
|
||||
Test coverage for Amendment 9 lands as a new Suite in `test-features.mjs` co-merged with ADR 0014 Amendment 1's `lib/sandbox/manager.mjs` refactor. The suite covers:
|
||||
|
||||
1. `validateProvider` (or `validateIsolation` helper) rejects each documented invalid shape: non-function `ephemeralEnvOverrides`; non-2-tuple `credentialMounts` entries; `dst` paths starting with `..` or absolute; non-boolean `hasInnerSandbox`; out-of-enum `crossTenantReadProtection`; out-of-enum `recommendedDeploymentTier`; non-function `toolHardeningArgs`.
|
||||
2. The legacy code path: a fake provider without `ISOLATION` spawns under the existing shape unchanged. Existing Phase 6c tests for anthropic continue to pass.
|
||||
3. The ephemeral-home composition path: a fake provider declaring a minimal `ISOLATION` block has its env overrides applied and its credential mount resolved into a `mkdtemp`-created ephemeral root.
|
||||
4. First-spawn return-shape validation: `ephemeralEnvOverrides` returning non-string values aborts the spawn loudly; `toolHardeningArgs` returning a non-array aborts the spawn loudly.
|
||||
5. Per-shipped-provider declaration smoke: each of `anthropic`, `codex`, `mistral` declares an `ISOLATION` block; each block's `credentialMounts[i][0]` (when resolved against the running user's `homedir()`) matches the plugin's `auth.path` field.
|
||||
|
||||
The full test list is captured in ADR 0014 Amendment 1's PR-B-revised test suite specification.
|
||||
|
||||
#### Open questions (Phase 7 follow-up)
|
||||
|
||||
1. **Mistral Vibe tool surface and inner sandbox.** The mistral plugin's `ISOLATION` declares `crossTenantReadProtection: 'none'` honestly. A spike task is required to determine whether Vibe CLI exposes any tool surface and/or any sandbox flag; findings update the declaration. Tracked at the Phase 7 work plan.
|
||||
2. **HOME-only providers vs CODEX_HOME-style providers.** The current contract assumes credential redirection happens via env-var rewriting (`HOME` or `<PROVIDER>_HOME`). A future provider that hardcodes its credential path (no env override) would be unable to honor the contract and would need a different isolation strategy (e.g., bind-mount of the literal path). This is not a current problem (all three shipped providers honor env overrides) but should be tracked for future inclusion ADRs.
|
||||
3. **Promoting `ISOLATION` to required at contract v1.1.** Once all enabled providers declare `ISOLATION`, a future contract-version bump may promote the field from OPTIONAL to REQUIRED. The decision is gated on operational experience after PI231 + cloud deployment — see ADR 0014 Amendment 1 for the rollout milestones.
|
||||
4. **Per-spawn vs per-key ephemeral root.** This amendment specifies per-spawn ephemeral roots (one `mkdtemp` per `provider.spawn` call). A future optimization may cache ephemeral roots per-key (one ephemeral root per OLP key identity, reused across spawns) to reduce mkdtemp / mount overhead. The contract surface here is compatible with either strategy; the choice is an orchestrator implementation detail.
|
||||
5. **Cleanup discipline.** The orchestrator is responsible for `rm -rf`-ing the ephemeral root after the spawn drains. The cleanup mechanism (synchronous vs deferred, error vs success path symmetry) is specified in ADR 0014 Amendment 1, not here. This amendment notes the dependency for completeness.
|
||||
|
||||
#### Authority citations summary
|
||||
|
||||
| Field | Authority |
|
||||
|---|---|
|
||||
| `ephemeralEnvOverrides` (general) | POSIX `HOME` convention; per-provider env-var documentation cited per declaration |
|
||||
| `credentialMounts` (general) | Each plugin's existing `auth.path` field citation |
|
||||
| `requiredHomePaths` (general) | Observed CLI behavior; no speculative entries (Rule 2) |
|
||||
| `hasInnerSandbox` (general) | CLI doc or observed-behavior transcript |
|
||||
| `crossTenantReadProtection` (enum) | OLP-side analysis based on prior-art search in incident memory `~/.cc-rules/memory/projects/olp/incident_2026_05_27_spawn_cli_security.md` § 4 + § 6 |
|
||||
| `recommendedDeploymentTier` (enum) | OLP-side analysis; ADR 0014 Amendment 1 § Deployment topology |
|
||||
| `toolHardeningArgs` (function) | Documented CLI flags of the underlying provider; no invented flags (Rule 2) |
|
||||
| anthropic `--system-prompt` tool suppression | ADR 0009 Amendment 1 + incident memory § 6.1 |
|
||||
| codex `CODEX_HOME` | https://developers.openai.com/codex/config-reference + https://developers.openai.com/codex/auth/ |
|
||||
| codex inner bwrap | openai/codex#16018 + https://developers.openai.com/codex/concepts/sandboxing |
|
||||
| codex `--sandbox read-only` default | https://developers.openai.com/codex/concepts/sandboxing § "Sandboxing modes" |
|
||||
| mistral `VIBE_HOME` and `.vibe/.env` | https://docs.mistral.ai/mistral-vibe/terminal/configuration |
|
||||
|
||||
#### Procedural mechanism
|
||||
|
||||
- **Iron Rule 11 (Incremental Diff Review)** — Amendment 9 (governance, ADR 0002) and ADR 0014 Amendment 1 (orchestrator architecture) land as a single coupled PR. Reviewing them separately cannot verify consumer-producer alignment.
|
||||
- **Iron Rule 10 (Code Review)** — independent fresh-context reviewer per `CLAUDE.md` hard requirement #3. The reviewer MUST open each cited authority URL (Codex config-reference, sandboxing docs, Mistral configuration docs, the incident memory) and confirm the citation in the review comment.
|
||||
- **`ALIGNMENT.md` Rule 1 (Cite First)** — every per-field design choice is cited above. Every per-provider concrete instance is cited to the underlying CLI authority.
|
||||
- **`ALIGNMENT.md` Rule 2 (No Invention)** — no invented env vars, no invented CLI flags. The mistral `crossTenantReadProtection: 'none'` declaration is the explicit honest acknowledgment that no protection regime has been established, rather than invention of one.
|
||||
- **`ALIGNMENT.md` Rule 4 (Unalignable Plugins / Fields Are Deleted)** — see § Rule 4 compliance above for the explicit reasoning that OPTIONAL `ISOLATION` is not "feature-flagging" but rather "honestly transitional."
|
||||
- **`ALIGNMENT.md` Amendment Procedure** — this section (Amendment 9) is the PR-required citation of evidence (the 2026-05-27 incident memory, the ADR 0014 PoC spike report at `/tmp/sandbox-spike/report.md` on PI231) and the structural amendment of the Provider contract documented in this ADR's § Decision.
|
||||
|
||||
@@ -7,6 +7,23 @@
|
||||
|
||||
## Amendments
|
||||
|
||||
### Amendment 3 — 2026-05-27: Accept OpenAI `role: "developer"` at entry surface, normalize to `system` in IR
|
||||
|
||||
- **Finding:** Hermes Agent v0.13/v0.14 (and likely Cline, Continue.dev, and other modern openai-completions clients) default to `role: "developer"` for what was historically the `system`-role slot when the model id matches OpenAI's o1/o3+ reasoning family. The `developer` role was introduced by OpenAI's Responses-API spec for reasoning models (high-priority developer-authored instructions; semantically a peer of `system`). OLP IR's role allow-list at v0.1 was the original four roles (`system|user|assistant|tool`); the IR validator rejected `developer` with `400 IR validation failed: role must be one of system|user|assistant|tool, got "developer"`. Reproduced 2026-05-27 on PI230 Hermes v0.14.0 → OLP v0.5.1 routing path.
|
||||
- **Decision:** Extend `openai-to-ir.mjs:normalizeRole()` to map `developer` → `system` at the entry boundary. The IR's canonical-four-roles invariant is preserved; every provider plugin's role-handling stays unchanged. The normalize-at-entry pattern matches the existing `function` → `tool` normalization that already lives in the same function (function-role-deprecation was the precedent for entry-boundary normalization vs. IR schema bloat).
|
||||
- **Why not "add `developer` to VALID_ROLES + handle in every provider":** That alternative would require:
|
||||
- Expanding `VALID_ROLES` in `lib/ir/types.mjs`.
|
||||
- Adding `developer` branch in `anthropic.mjs:irToAnthropic` (which would map to `[System]` annotation anyway).
|
||||
- Adding `developer` branch in `codex.mjs:irToCodex` (would map to `[System]` annotation anyway).
|
||||
- Adding `developer` branch in `mistral.mjs:irToMistral` (same).
|
||||
- Coordinating every future role addition (e.g., if OpenAI adds another role tomorrow) across N provider plugins.
|
||||
- Wider IR surface area = more drift-prone over time.
|
||||
Normalize-at-entry centralizes role-spec-evolution handling in one file. ADR 0003's IR-design principle ("encode the common subset every provider plugin can consume") supports keeping the IR minimal.
|
||||
- **Forward note:** Future OpenAI role additions follow the same pattern: extend `normalizeRole()`. If a role genuinely conveys provider-distinguishable semantics (e.g., a hypothetical role that meaningfully changes anthropic vs codex behavior), the calculus flips and a IR-level addition would be justified. That decision goes through a new ADR 0003 amendment.
|
||||
- **Cache-key impact:** After this amendment, a request whose first message uses `role: "developer"` and one using `role: "system"` with otherwise-identical content produce the **same** IR (because normalization happens before IR construction) → the **same** cache key (per ADR 0005 cache key composition). This is intentional and matches OpenAI's own backward-compat behavior ("system message with reasoning models is treated as developer"). If a future debug session is investigating "why does my new `developer` request hit a cache entry from an old `system` request" — this is by design.
|
||||
- **Tests:** Suite IR translation in `test-features.mjs` gains three pin tests: (a) `role: "developer"` → `role: "system"` translation, (b) mixed-role array including developer validates cleanly through to IR, (c) negative control — an unknown role (e.g. `"admin"`) still raises `BadRequestError`, confirming the normalize-at-entry mapping did not accidentally widen the role allow-list.
|
||||
- **Authority:** OpenAI Responses API spec — developer role documented as high-priority developer-authored instructions for o1/o3+ reasoning models (https://platform.openai.com/docs/api-reference/responses). Hermes Agent / Cline / Continue.dev tracking the same convention. Reproduced live on PI230 → PI231 OLP 2026-05-27.
|
||||
|
||||
### Amendment 2 — 2026-05-24: Correct model-mapping example; document verbatim-pass-through design (D32 F2)
|
||||
|
||||
- **Finding:** Round-4 cold-audit F2 (P3 ADR example vs implementation drift) — § Decision "Required fields" item `model` reads: "The provider plugin maps this to the provider-native model identifier (e.g., `claude-sonnet-4-6` → `claude-sonnet-4-6-20260301` for Anthropic)." This is WRONG per the D17 SPOT decision (commit `cb86807`): OLP does NOT perform a model-alias mapping inside the provider plugin. `irRequest.model` is passed verbatim to the provider CLI (`claude -p --model <model>`, `codex exec --model <model>`, etc.); each provider's CLI resolves its own aliases natively per its documented behaviour.
|
||||
|
||||
@@ -2,6 +2,154 @@
|
||||
|
||||
- **Date:** 2026-05-25
|
||||
- **Status:** Accepted (D48, design-only — implementation D-days D49–D54 follow; Phase 3 close = v0.3.0)
|
||||
|
||||
## Amendments
|
||||
|
||||
### Amendment 2 — 2026-05-27: v0.5.1 quota_v2 richer failure-mode shape (codex finding F3)
|
||||
|
||||
**Scope:** v0.5.1 hotfix extends `ProviderQuotaEntry` and `aggregateProviderQuota()` to surface richer failure-mode detail, addressing codex review finding F3 (operator cannot distinguish failure modes from the `unavailable` catch-all). Authority: ADR 0013 Rule 6 + codex review findings F1–F3.
|
||||
|
||||
#### 1. Extended `ProviderQuotaEntry` shape
|
||||
|
||||
```js
|
||||
{
|
||||
provider: string,
|
||||
// v0.5.1: 'unreachable' added (probe enabled, creds present, but no cache + probe failed)
|
||||
status: 'live' | 'stale' | 'unreachable' | 'unavailable',
|
||||
reason?: string, // only when status === 'unavailable' (no API or disabled)
|
||||
schema_version: string|null,
|
||||
last_fresh_at: number|null,
|
||||
utilization: { '5h': number|null, '7d': number|null } | null,
|
||||
reset: { '5h': number|null, '7d': number|null, overall: number|null, overage: number|null } | null,
|
||||
representative_claim: string|null,
|
||||
fallback_percentage: number|null,
|
||||
overage: { status: string|null, disabled_reason: string|null } | null,
|
||||
raw_available: boolean,
|
||||
// v0.5.1 (F3 — ADR 0013 Rule 6):
|
||||
failure: { kind, message, backoff_until? } | null,
|
||||
failure_kind: 'no_credentials'|'auth_failed'|'rate_limited'|'schema_drift'|'network'|'other' | null,
|
||||
// Note: 'opt_in_off' is NOT in this enum — when probe is opted out, the row's status
|
||||
// is 'unavailable' (not 'unreachable'); failure_kind stays null. Distinguishing
|
||||
// "user opted out" from "provider has no API" requires reading config separately.
|
||||
backoff_until: number | null, // epoch-ms when next probe attempt is allowed
|
||||
}
|
||||
```
|
||||
|
||||
Status semantics:
|
||||
- `'unavailable'` — probe disabled (`quota_probe_enabled: false`) OR provider has no public quota API (codex, mistral). `failure`, `failure_kind`, `backoff_until` are null.
|
||||
- `'live'` — probe succeeded within TTL. `failure` is null.
|
||||
- `'stale'` — probe failed but stale cache exists. `failure.kind` describes why the last probe failed. `last_fresh_at` is the epoch of the last successful probe. `backoff_until` tells when the next attempt is scheduled.
|
||||
- `'unreachable'` (new) — probe enabled + creds present (or missing!) but no cache available + probe failed. `failure.kind` distinguishes: `no_credentials`, `auth_failed`, `rate_limited`, `schema_drift`, `network`, `other`. `utilization` and `reset` are null (no data).
|
||||
|
||||
#### 2. `quotaStatus()` v0.5.1 return contract
|
||||
|
||||
`null` is now RESERVED for `quota_probe_enabled: false` only. All other failure paths return a structured shape:
|
||||
|
||||
```js
|
||||
null // ONLY: opt-in off
|
||||
{ probe_status: 'live', ... } // cache fresh
|
||||
{ probe_status: 'stale', ..., failure: { kind, message, backoff_until } } // cache stale + backoff
|
||||
{ probe_status: 'unreachable', source, schemaVersion, failure: { ... } } // no cache + failed
|
||||
```
|
||||
|
||||
The `stale: boolean` field is retained for backwards-compat (`stale: false` on live, `stale: true` on stale). New code should use `probe_status`.
|
||||
|
||||
#### 3. `dashboard.html` unreachable rendering
|
||||
|
||||
A new CSS class `.provider-row.unreachable` (red border + light red background) and `.unreachable-reason` text style handle the new status. `failure.message` and `failure_kind` are surfaced as a short text line under the provider badge. `failure.backoff_until` renders a "backoff active: Xs remaining" note if within window.
|
||||
|
||||
#### 4. Authority
|
||||
|
||||
- ADR 0013 Rule 6 (failure transparency mandate)
|
||||
- Codex review findings F1 (doctor bypass), F2 (200+empty-headers → schema_drift), F3 (failure-mode collapse)
|
||||
- v0.5.1 hotfix PR
|
||||
|
||||
---
|
||||
|
||||
### Amendment 1 — 2026-05-26: D81 Phase 5 quota_v2 shape + aggregateProviderQuota()
|
||||
|
||||
**Scope:** D81 (Phase 5 / ADR 0012 D81) extends the audit-query layer and dashboard-data endpoint to surface the new per-provider quota shape introduced by D80 (`lib/providers/anthropic.mjs:quotaStatus()`). This amendment documents the three new interfaces.
|
||||
|
||||
#### 1. `models-registry.json` — new `quota_probe` top-level key
|
||||
|
||||
D81 adds a `quota_probe` key at the root of `models-registry.json` per ADR 0013 Rule 5 (schema_version in registry so downstream consumers can detect schema drift):
|
||||
|
||||
```json
|
||||
{
|
||||
"quota_probe": {
|
||||
"schema_version": "2026-05-26",
|
||||
"anthropic": {
|
||||
"source": "anthropic-ratelimit-unified-headers",
|
||||
"endpoint": "https://api.anthropic.com/v1/messages",
|
||||
"fields_pinned": [ ...13 field names... ]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`fields_pinned` is load-bearing: if Anthropic adds/renames a header in a future CLI version, dashboard consumers comparing field-presence against this list can flag "schema drift detected" per the ADR 0013 Rule 5 drift-detection runbook. This field must be updated alongside the parser whenever a drift event occurs.
|
||||
|
||||
`lib/providers/anthropic.mjs` reads `quota_probe.schema_version` from the registry at call time (via `_resolveSchemaVersion()`) with the module-level `QUOTA_SCHEMA_VERSION` constant as fallback. No hard dependency on the registry — the constant is the safety net.
|
||||
|
||||
#### 2. `lib/audit-query.mjs` — new `aggregateProviderQuota()` export
|
||||
|
||||
```js
|
||||
export async function aggregateProviderQuota({
|
||||
providers, // Map<name, plugin> or plain object
|
||||
getQuotaStatus, // optional injectable getter (name) => Promise<shape|null>
|
||||
}): Promise<Array<ProviderQuotaEntry>>
|
||||
```
|
||||
|
||||
For each provider, calls `quotaStatus()` (already cached at the plugin layer per ADR 0013 Rule 3) and normalizes to the `ProviderQuotaEntry` shape:
|
||||
|
||||
```js
|
||||
{
|
||||
provider: string,
|
||||
status: 'live' | 'stale' | 'unavailable',
|
||||
reason?: string, // only when status === 'unavailable'
|
||||
schema_version: string|null,
|
||||
last_fresh_at: number|null, // epoch-ms of last successful probe
|
||||
utilization: { '5h': number|null, '7d': number|null } | null,
|
||||
reset: {
|
||||
'5h': number|null, '7d': number|null,
|
||||
overall: number|null, overage: number|null,
|
||||
} | null,
|
||||
representative_claim: string|null,
|
||||
fallback_percentage: number|null,
|
||||
overage: { status: string|null, disabled_reason: string|null } | null,
|
||||
raw_available: boolean,
|
||||
}
|
||||
```
|
||||
|
||||
Providers returning `null` from `quotaStatus()` (codex, mistral — no public quota API; or probe disabled) produce `{ status: 'unavailable', reason: 'no public quota api or probe disabled', ...null fields }`.
|
||||
|
||||
Providers whose `quotaStatus()` throws produce `{ status: 'unavailable', reason: <error.message>, ...null fields }`.
|
||||
|
||||
This function does NOT scan ndjson files; it calls live provider plugins. It is audit-query-adjacent (normalized query shape for the dashboard layer) but not audit-derived. Query model remains Lane 2 = A (in-memory, no SQLite).
|
||||
|
||||
#### 3. `/v0/management/dashboard-data` and `/v0/management/quota` — new `quota_v2` field
|
||||
|
||||
Both endpoints now return TWO quota keys:
|
||||
|
||||
- **`quota`** (legacy, unchanged): `Array<{ provider, ...rawQuotaStatus, available }>`. Kept for backwards compatibility with the existing `dashboard.html` (D82 will switch consumers to `quota_v2`).
|
||||
- **`quota_v2`** (D81 new): `Array<ProviderQuotaEntry>` — the normalized shape from `aggregateProviderQuota()` above. This is what D82's enriched dashboard UI will consume.
|
||||
|
||||
Both fields are computed from the same underlying `quotaStatus()` call. The legacy `quota` key calls `quotaStatus()` independently from `quota_v2`; since the probe is cached at the plugin layer (ADR 0013 Rule 3), the double call incurs no extra API requests.
|
||||
|
||||
**Deprecation timeline:** the legacy `quota` key is deprecated as of D81. Target removal: v1.0.0 or when D82 completes the dashboard migration (whichever comes first). Removal requires a separate PR with a CHANGELOG entry.
|
||||
|
||||
#### 4. Failure handling
|
||||
|
||||
`aggregateProviderQuota()` never throws to the dashboard endpoint. Per-provider failures are absorbed as `{ status: 'unavailable', reason: <error> }` entries. If `aggregateProviderQuota()` itself throws (implementation bug), `handleManagementDashboardData` and `handleManagementQuota` catch the error, log `dashboard_data_quota_v2_failed` / `management_quota_v2_failed`, and return `quota_v2: []` so the rest of the payload is unaffected.
|
||||
|
||||
#### 5. Authority citations for this amendment
|
||||
|
||||
- **ADR 0012 D81** — the D-day this amendment documents.
|
||||
- **ADR 0013 Rule 5** — mandate for `quota_probe.schema_version` in `models-registry.json`.
|
||||
- **D80 PR #52 commit 82d2e1c** — the producer of the `quotaStatus()` shape this amendment normalizes.
|
||||
- **ADR 0008 Lane 2 = A** — query model unchanged; `aggregateProviderQuota()` does not scan ndjson.
|
||||
|
||||
---
|
||||
- **Authors:** project maintainer (with AI drafting assistance)
|
||||
- **Related:**
|
||||
- OLP v0.1 spec § 4.6 (Dashboard requirements — port from OCP with multi-provider support) and § 4.7 (observability endpoints)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# ADR 0009 — Anthropic Interactive-Mode Path (Placeholder)
|
||||
|
||||
- **Date:** 2026-05-25
|
||||
- **Status:** Draft (Placeholder — blocked on OCP ADR 0007 P0 experiment outcome; no implementation D-day scheduled until P0 lands)
|
||||
- **Date:** 2026-05-25 (Placeholder); 2026-05-27 Amendment 1 (Accepted)
|
||||
- **Status:** **Accepted** (post-Amendment 1 — OLP self-spike supersedes OCP-wait; implementation D-day scheduled this Phase 6)
|
||||
- **Authors:** project maintainer (with AI advisory drafting)
|
||||
- **Related:**
|
||||
- **OCP ADR 0007** (Interactive-Mode Execution Pool, stream-json) — at `~/ocp/docs/adr/0007-interactive-mode-pool.md` on the maintainer's workstation. Pin reference at the time of this writing: OCP ADR 0007 is Draft status pending the same P0 outcome.
|
||||
@@ -193,5 +193,117 @@ If OCP P0 fails, **this ADR is shelved** and Phase 4 ordering is unchanged.
|
||||
## Status transitions (recorded for clarity)
|
||||
|
||||
- 2026-05-25 — Created as Draft (Placeholder). OCP ADR 0007 also Draft.
|
||||
- _(future)_ — If OCP ADR 0007 → Accepted with a confirmed transport: this ADR moves to "Pending Phase 4 implementation D-day", maintainer decides Option 1 / 2 / 3 + lane.
|
||||
- _(future)_ — If OCP ADR 0007 → Rejected: this ADR moves to "Shelved (upstream P0 failure)" with a note explaining the fallback (multi-provider routing already covers).
|
||||
- 2026-05-27 — Amendment 1 promotes to **Accepted**. OLP self-spike + empirical Transport-A confirmation on `claude` CLI v2.1.104 superseded wait-for-OCP. OCP is now in maintenance mode (per maintainer statement 2026-05-27 session) — OLP leads. Implementation lane: **Option 1 (parallel implementation, no warm pool, no PTY)**, scope reduced from "10-day warm pool with billing router" to "2-3-day stateless stream-json adapter".
|
||||
|
||||
---
|
||||
|
||||
## Amendment 1 — 2026-05-27: Self-spike supersedes wait-for-OCP; lock Option 1 with stream-json-no-`-p` transport
|
||||
|
||||
### Trigger
|
||||
|
||||
Two findings on 2026-05-27 changed the placeholder's premises:
|
||||
|
||||
1. **OCP is no longer the lead project.** The maintainer stated in the 2026-05-27 session: "OCP 不会大改动…主力方向放到 OLP." The "wait-and-port" strategy implicitly assumed OCP would do the P0 first. With OCP in maintenance mode, OLP cannot wait — the 2026-06-15 Anthropic billing split is 19 days out from this amendment.
|
||||
|
||||
2. **OLP self-spike confirmed Transport A (`--output-format stream-json --verbose` without `-p`) emits NDJSON on Claude Code v2.1.104.** The placeholder ADR § 1.3 cited OCP's "v2.1.150 only" observation as the binding caveat. Local empirical re-test on 2026-05-27 against PI231's deployed `claude` v2.1.104 produced the full NDJSON event stream (system/init + stream_event token deltas + message_stop + result + rate_limit_event) for invocations **without** `-p`. The `claude --help` text saying "(only works with --print)" is misleading — the flags accept invocation without `-p` and produce the documented NDJSON shape.
|
||||
|
||||
### Additional spike findings (2026-05-27 billing classification)
|
||||
|
||||
A separate web/GitHub research spike on the 2026-06-15 billing classification returned:
|
||||
|
||||
- Anthropic's published policy is **intent-based, not mechanism-based**. The Agent SDK credit pool covers: Agent SDK Python/TypeScript packages, `claude -p`, GitHub Actions, **and "third-party apps that authenticate with your Claude subscription through the Agent SDK"**. Subscription pool covers "Claude Code in the terminal or your IDE in interactive mode."
|
||||
- The third-party-app clause is the load-bearing ambiguity. OLP qualifies as a third-party app regardless of which CLI mode it spawns. If Anthropic tightens that clause from "via Agent SDK" to "any third-party app," OLP is caught regardless of `-p` flag presence.
|
||||
- Behavioral fingerprinting (request cadence, OAuth-scope patterns, isTTY absence) is a separate detection vector Anthropic could deploy without policy-text changes.
|
||||
|
||||
The spike's recommendation: "viable bridge for ~30-60 days post-2026-06-15, NOT durable solution."
|
||||
|
||||
### Value re-anchoring
|
||||
|
||||
The placeholder framed interactive-mode as "the durable answer to keep OLP anthropic subscription value past 2026-06-15." The 2026-05-27 spike re-anchors the value:
|
||||
|
||||
| Value | Placeholder framing | 2026-05-27 framing |
|
||||
|---|---|---|
|
||||
| Keep subscription pool 6.15+ | **Primary value** | **Uncertain bridge** (30-60 day plausibility) |
|
||||
| Hallucination fix (env-block / cwd injection) | (Not addressed) | **Primary value** — empirically proven |
|
||||
| Cost reduction (drop default tool descriptions) | (Not addressed) | **Primary value** — ~30% input token / ~64% per-request cost reduction measured against `--system-prompt` override |
|
||||
| Observability (rate_limit / cache / usage per request) | (Not addressed) | **Primary value** — NDJSON events expose data the current `--output-format text` path discards |
|
||||
| Protocol foundation for future tool-call passthrough | (Not addressed) | **Secondary value** — same NDJSON parser is reusable for Phase 8+ tool passthrough work |
|
||||
|
||||
**Net**: even if Anthropic immediately reclassifies third-party apps to Agent SDK pool on 2026-06-15 — making the bridge worthless — the implementation still earns its keep through the other four values.
|
||||
|
||||
### Locked decision
|
||||
|
||||
**Option 1 — Parallel implementation in OLP's `lib/providers/anthropic.mjs`.**
|
||||
|
||||
Lane: stream-json output, no `-p` flag (Transport A confirmed), stateless per-request spawn (no warm pool, no PTY, no node-pty dependency).
|
||||
|
||||
Rejected lanes and why:
|
||||
|
||||
- **Option 2 (chain OCP)** — OCP is in maintenance mode; coupling OLP's anthropic provider to OCP's HTTP shim is the wrong direction.
|
||||
- **Option 3 (both)** — premature complexity; pick the simple lane first.
|
||||
- **Warm-process pool** — OLP is stateless per AGENTS.md § "No conversation state". Pool lifecycle, crash backoff, and permission auto-response from OCP ADR 0007 § 4 are unnecessary for OLP's per-request model.
|
||||
- **PTY (Transport B with node-pty)** — Transport A worked; engines-bump for a native addon is unjustified when the simpler transport produces the documented NDJSON.
|
||||
|
||||
### Implementation scope (Option 1, this Phase 6)
|
||||
|
||||
| Change | Surface | Authority |
|
||||
|---|---|---|
|
||||
| `buildCliArgs(model)` drop `-p` and `--output-format text`; add `--output-format stream-json`, `--verbose`, `--no-session-persistence`, `--model` | `lib/providers/anthropic.mjs` | `claude --help` (v2.1.104) § `--output-format` / § `--verbose` |
|
||||
| `buildCliArgs(model, systemPrompt)` accepts optional system prompt; spawns with `--system-prompt "<OLP wrapper text>"` | `lib/providers/anthropic.mjs` | `claude --help` (v2.1.104) § `--system-prompt` |
|
||||
| OLP-managed system prompt construction (extract client `role:system` IR messages, prepend OLP wrapper saying "you are accessed via HTTP proxy; no local env/fs/shell access; respond directly") | `lib/providers/anthropic.mjs` `irToAnthropic` | This ADR § "OLP system prompt wrapper" below |
|
||||
| New `anthropicStreamJsonChunkToIR` parser replacing/supplementing `anthropicChunkToIR` — handles NDJSON event types `system/init`, `stream_event/content_block_delta`, `assistant`, `result`, `rate_limit_event` | `lib/providers/anthropic.mjs` | This ADR § "NDJSON event handling" below |
|
||||
| New tests verifying NDJSON parsing, system-prompt construction, env-block absence | `test-features.mjs` | (test surface; no external authority) |
|
||||
| README troubleshooting / supported-providers § note about the bridge nature | `README.md` | (docs surface) |
|
||||
|
||||
The `irToAnthropic` text serialization path is preserved for client messages (`role: user`, `role: assistant`); the `role: system` extraction goes to `--system-prompt`.
|
||||
|
||||
### OLP system prompt wrapper
|
||||
|
||||
The wrapper text injected via `--system-prompt`:
|
||||
|
||||
```
|
||||
You are accessed via the OLP HTTP proxy. You do NOT have access to any local
|
||||
filesystem, working directory, shell, git status, or machine environment.
|
||||
Do not infer or invent such information from any context you observe.
|
||||
Respond only based on the conversation provided.
|
||||
```
|
||||
|
||||
If the client IR request contains `role: system` messages, their concatenated `content` is appended after a blank line.
|
||||
|
||||
### NDJSON event handling
|
||||
|
||||
The parser must yield IR chunks based on the event stream:
|
||||
|
||||
| NDJSON event | IR yield | Notes |
|
||||
|---|---|---|
|
||||
| `{type:"system", subtype:"init"}` | None (consumed for session_id tracking) | First event always; ignore |
|
||||
| `{type:"stream_event", event:{type:"content_block_delta", delta:{type:"text_delta", text:"..."}}}` | `{type: "delta", content: "<text>"}` | Token-by-token streaming |
|
||||
| `{type:"assistant"}` | None (already captured by per-token deltas) | Aggregate message; ignore (or use for verify, optional) |
|
||||
| `{type:"result", subtype:"success"}` | `{type:"stop", finish_reason:"stop"}` | Marks end |
|
||||
| `{type:"rate_limit_event"}` | None (consumed for audit/dashboard) | Forward to OLP audit/observability layer later (Phase 6+ enhancement) |
|
||||
| `{type:"control_request"}` | Log + ignore | Per Anthropic stream-json docs |
|
||||
|
||||
The cache key composition (ADR 0005) is unchanged — same IR request hash; the on-the-wire format change is internal to the anthropic plugin.
|
||||
|
||||
### Token cost measurement (binding evidence)
|
||||
|
||||
Two requests against PI231 v2.1.104 on 2026-05-27 with identical user prompt `"reply: OK"`, model `claude-sonnet-4-6`:
|
||||
|
||||
- Default invocation (no `--system-prompt`): `cache_creation_input_tokens=4785`, `cache_read_input_tokens=11816`, total input ≈ 16,601 tokens, `total_cost_usd=$0.0216`.
|
||||
- With `--system-prompt "You are a chat assistant. Respond directly."`: `cache_creation_input_tokens=1306`, `cache_read_input_tokens=9394`, total input ≈ 10,700 tokens, `total_cost_usd=$0.0078`.
|
||||
|
||||
**Net**: ~30% input token reduction, ~64% per-request cost reduction. Replicable by anyone with `claude` v2.1.104 + OAuth on a similar setup.
|
||||
|
||||
### Caveats binding the implementation
|
||||
|
||||
1. **Bridge value uncertain.** The 30-60 day estimate is a spike judgment, not Anthropic-confirmed. Implementation must continue to function correctly if Anthropic re-routes this path to Agent SDK billing on 2026-06-15 — the only consequence is the bridge value disappears, but the other four values (hallucination / cost / observability / protocol foundation) remain.
|
||||
2. **No claim about durability.** This ADR amends only as far as "the bridge is worth the 2-3 day investment given the orthogonal values." A future ADR (likely Phase 7 sandbox-runtime + Phase 8 multi-provider robustness) will revisit the anthropic provider's strategic role once the post-2026-06-15 picture clarifies.
|
||||
3. **Sandbox-runtime still required for real multi-tenant deployment.** Per the 2026-05-27 session prior-art search, Anthropic's official multi-tenant answer is `@anthropic-ai/sandbox-runtime` (OS-level isolation). This ADR does NOT substitute for that work; sandbox-runtime remains Phase 7 scope and is a hard prerequisite before any cloud deployment per `docs/plans/cloud-deployment-family.md`.
|
||||
4. **CLI version pin guidance.** Stream-json without `-p` was confirmed on v2.1.104. Future versions may tighten this; the plugin's spawn should emit a warning to OLP server log if `claude --version` falls outside a `v2.1.100`–`v2.1.149` range. Hard failure on out-of-range version is NOT required; warning is sufficient for v0.6.x.
|
||||
|
||||
### Updated authority citations (in addition to placeholder § Authority citations)
|
||||
|
||||
- **OLP self-spike — 2026-05-27 session live transcripts** (PI231 ssh; `claude -p --output-format stream-json --verbose` and `claude` no-`-p` variants captured in session log; retained in cc-mem post-implementation).
|
||||
- **P0 billing classification spike — 2026-05-27** subagent transcript; sources include Anthropic published docs at `code.claude.com/docs/en/headless`, `support.claude.com/en/articles/15036540`, `support.claude.com/en/articles/11145838`.
|
||||
- **claude CLI v2.1.104 `--help`** (live capture on PI231) § `--output-format`, § `--verbose`, § `--system-prompt`, § `--no-session-persistence`.
|
||||
- **CLAUDE.md `release_kit.phase_rolling_mode.current_phase`** — Phase 6; this ADR consumes a Phase 6 D-day per the amendment, NOT a Phase 4 D-day (the placeholder's hypothetical scheduling).
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
# ADR 0012 — Phase 5 Charter: Provider Quota Probes + Dashboard Enrichment
|
||||
|
||||
**Status:** Accepted (Phase 5 open as of 2026-05-26)
|
||||
**Date:** 2026-05-26
|
||||
**D-day:** D79 (charter + ADR 0002 Amendment 8 + ADR 0013 land together as the constitutional layer of Phase 5)
|
||||
|
||||
## Amendments
|
||||
|
||||
### Amendment 1 — 2026-05-26: D84 Mistral probe NO-GO (post-D79-close spike)
|
||||
|
||||
The D-day table originally listed D84 as "optional, depends on D79-close 30-min Mistral docs spike". The spike completed 2026-05-26 with verdict **NO-GO** — Mistral does not expose a programmatic quota/usage endpoint **accessible to Vibe / Le Chat member / La Plateforme API keys** (the key tier OLP uses for spawning the `vibe` CLI):
|
||||
|
||||
- `docs.mistral.ai/api` (the public API spec) covers Chat, FIM, Embeddings, Classifiers, Files, Models, Batch, OCR, Audio, Events, Beta (Agents/Conversations/Libraries/Workflows/Observability). No usage/quota/credits/billing/limits endpoint accessible to a member API key.
|
||||
- Direct probe `https://api.mistral.ai/v1/usage` returns 404.
|
||||
- Mistral's "Limits and Usage" help article documents limit viewing via the `admin.mistral.ai/plateforme/limits` web console.
|
||||
- No `x-ratelimit-*` response headers documented on `/v1/chat/completions`. (Third-party summaries mentioning these headers are unsourced — appears to be OpenAI-convention extrapolation.)
|
||||
- OLP `lib/providers/mistral.mjs` already records this independently — DL-7 comment: "If quota/budget API surfaces in Le Chat Pro, pin the endpoint here."
|
||||
|
||||
**Out-of-scope but worth pinning for future revisit.** Mistral's [Admin API](https://docs.mistral.ai/admin/security-access/admin-api) DOES expose programmatic "Billing and usage queries", and the [Usage limits docs](https://docs.mistral.ai/admin/user-management-finops/usage-limits) describe usage/cost queries via that surface. The Admin API requires an **org-admin scoped API key** (separate from the member key OLP uses). For OLP's family-tier deployment posture (a maintainer's personal Le Chat Pro / La Plateforme account, not an organization's admin console), provisioning + storing an org-admin token raises the credential-scope ceiling beyond what the trusted-LAN deployment context (ADR 0011) was designed for. The NO-GO at v0.5.0 is therefore "out of scope for OLP's current deployment posture", NOT "Mistral has no programmatic surface". If the deployment posture expands to an org-admin context (e.g., a small-business multi-user deployment), this decision should be re-evaluated.
|
||||
|
||||
**Disposition:**
|
||||
- D84 row dropped from D-day plan (struck through below).
|
||||
- Mistral dashboard row in D82 UI shows "spend tracking only" badge sourced from `audit-query.mjs` aggregates (request count, estimated cost from `estimateCost()`).
|
||||
- `DL-7` in `mistral.mjs` is the documented re-entry point if Mistral ever publishes a usage endpoint.
|
||||
- Phase 5 total D-day budget revised: ~5 D-days (down from ~6).
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Phase 4 (ADR 0010) shipped OLP's operator + client UX layer — `bin/olp` operator CLI, `olp doctor` framework, `olp-connect` zero-config IDE wiring, OpenClaw `/olp` slash commands, anonymous-key deployment-context limits, SSE heartbeat. v0.4.4 is the current shipped state. Phase 4 closed every gap on the OCP-feature-parity matrix EXCEPT one: **live quota / plan-usage surfacing**.
|
||||
|
||||
Today `lib/providers/anthropic.mjs:445` has a stub `quotaStatus()` returning `null` (D4 placeholder). The OLP dashboard's quota panel renders "—" for all providers. OCP, in contrast, exposes a live "39% session / 30% weekly" panel — the maintainer uses this multiple times per day to decide when to throttle voluntary `claude -p` traffic away from interactive sessions. OLP cannot become an OCP successor in practice (vs. just feature-parity-on-paper) until quota surfacing works.
|
||||
|
||||
A pre-flight institutional-knowledge audit (2026-05-26 — see `~/.cc-rules/memory/learnings/anthropic_plan_usage_probe_schema_2026_05_26.md`) confirmed:
|
||||
|
||||
1. **The OCP probe still works today** — Anthropic returns the same `anthropic-ratelimit-unified-*` headers on every `POST /v1/messages` call. Tested live 2026-05-26 from PI231 OAuth credentials.
|
||||
2. **Schema added 3 fields since OCP's 2026-04 capture** — `5h-status`, `7d-status` (per-window status), `overage-reset` (only on active overage). No fields removed or renamed.
|
||||
3. **Verification protocol has shifted** — Claude Code v2.1.x is now a **compiled binary** (Mach-O / ELF), not bundled JS. OCP's "grep cli.js" approach no longer applies; the replacement protocol is `strings` against the binary + periodic live probe diff.
|
||||
4. **OAuth refresh path unchanged** — `platform.claude.com/v1/oauth/token` + `9d1c250a-...` client_id + 60s-3600s exponential backoff.
|
||||
|
||||
The audit makes Phase 5 implementation low-risk: this is a port of a working OCP function, not a re-derivation. The work is mechanical + adapter-layer plumbing into OLP's plugin contract.
|
||||
|
||||
A parallel maintainer request (2026-05-26, with reference screenshot of claude.ai/settings/usage) asked for Claude.ai-style dashboard enrichment: per-row utilization bars, reset countdown, 1-minute auto-refresh, manual refresh button. P5-1 (probe) + P5-2 (dashboard) together unlock both: the data plus the surface. v1.x roadmap #8 is closed by P5-2.
|
||||
|
||||
---
|
||||
|
||||
## Decision
|
||||
|
||||
Phase 5 scope is **Provider quota probes + dashboard enrichment**. The phase opens 2026-05-26 with D79 (this charter + ADR 0002 Amendment 8 + ADR 0013 OAuth READ-ONLY consumption rules). Phase 5 close ships v0.5.0; per `CLAUDE.md release_kit.phase_rolling_mode`, the close PR is maintainer-triggered.
|
||||
|
||||
### In scope — Phase 5 D-day plan (~6 D-days)
|
||||
|
||||
| D-day | Deliverable | Authority | Estimate |
|
||||
|---|---|---|---|
|
||||
| **D79** | This charter ADR 0012 + ADR 0002 Amendment 8 (direct-API READ-ONLY) + ADR 0013 (OAuth READ-ONLY consumption rules + schema-drift mitigation) + `package.json` `current_pre_release_identifier` → `0.5.0-phase5` + `CLAUDE.md release_kit.phase_rolling_mode.current_phase` → Phase 5 | This charter + audit memory | 0.5d |
|
||||
| **D80** | `lib/providers/anthropic.mjs:quotaStatus()` ported from OCP `server.mjs:842-1109` — full probe with macOS-keychain auth read added (existing OLP reader only handles env + `.credentials.json`) + 5min cache + 60s-3600s refresh backoff + stale-cache-on-429 + all 13 headers parsed (including new 5h-status / 7d-status / overage-reset) | Port OCP probe + ALIGNMENT.md Rule 2 exemption per ADR 0002 Amendment 8 + audit memory | 2d |
|
||||
| **D81** | `lib/audit-query.mjs` + `/v0/management/dashboard-data` extended to surface the new quota shape per provider (utilization, reset, representative-claim, fallback-percentage, overage-status). Audit-query stays in-memory scan per ADR 0008 Lane 2 = A (no SQLite). Schema migration documented in ADR 0008 § Amendment | ADR 0008 + this charter | 1d |
|
||||
| **D82** | `dashboard.html` Claude.ai-style restructure — per-provider rows replace the current single Quota panel; each row: provider badge, model placeholder, utilization bar (5h + 7d), reset countdown ("Your limit will reset at HH:MM AM/PM" format from the user-shared claude.ai screenshot), status badge, representative-claim hint. 1-minute auto-refresh via `setInterval` with `document.visibilityState` guard. Manual refresh button calls `/v0/management/dashboard-data` directly | v1.x roadmap #8 + maintainer reference screenshot | 1.5d |
|
||||
| **D83** | Test coverage — Suite 38 quota-probe unit tests (mock HTTP server returning the 13 headers; assert parse + cache + backoff + stale-on-429); Suite 39 dashboard rendering smoke (curl `/dashboard` after pre-seeding mock quota cache; assert HTML contains expected utilization strings); update Suite 33 doctor checks for new `anthropic.quota_probe_reachable` check | Test convention from existing suites | 1d |
|
||||
| ~~D84~~ **DROPPED** | ~~Mistral `quotaStatus()` port — depends on D79-close spike~~ **NO-GO per 2026-05-26 spike (see § Amendment 1).** Mistral dashboard row in D82 shows "spend tracking only" badge sourced from `audit-query.mjs` aggregates. `DL-7` hook point in `mistral.mjs` already marks the location for future upgrade if Mistral ever publishes a usage endpoint. Codex permanently skipped (no public API). | n/a (dropped) | 0d |
|
||||
| **close** | v0.5.0 release PR — `package.json` `0.4.4 → 0.5.0`, CHANGELOG promotion, `release_kit.phase_rolling_mode.current_pre_release_identifier` advance to Phase 6 token | `CLAUDE.md release_kit overlay` | maintainer-triggered |
|
||||
|
||||
### Out of Phase 5 scope (with explicit triggers)
|
||||
|
||||
#### `X-OLP-Cost-USD` per-request response header
|
||||
|
||||
**Status:** Deferred to Phase 6. Was listed in ADR 0010 § Out-of-scope as "Phase 5 prerequisite". The prerequisite (provider-cost weights table) is non-trivial — needs per-(provider, model) `input_cost_per_1k_tokens` / `output_cost_per_1k_tokens` / `cache_read_discount` data sourced from each provider's published pricing page. Phase 5 already pulls in two new ADRs; adding a third data-onboarding ADR is scope creep.
|
||||
|
||||
**Re-open condition.** Phase 6 unless a maintainer reports a cost-attribution debugging need that warrants pulling forward.
|
||||
|
||||
#### `context_window_exceeded` fallback trigger (LiteLLM prior-art)
|
||||
|
||||
**Status:** Deferred. ADR 0010 listed this as opportunistic-in-Phase-5 unless the trigger fires sooner. The trigger has not fired in Phase 4 production traffic. Continue to defer.
|
||||
|
||||
#### per-(provider, model) live stats Map (replacing audit-query scan)
|
||||
|
||||
**Status:** Deferred. Current scan latency is ~20ms at 7-day depth. Acceptable until volume grows (>100k requests/day). Re-evaluate at Phase 6 if dashboard latency degrades.
|
||||
|
||||
#### Anthropic interactive-mode P0 (ADR 0009)
|
||||
|
||||
**Status:** Still trigger-gated on Anthropic's 2026-06-15 billing-split rollout. Phase 5 does NOT depend on P0 — the quota probe reads `anthropic-ratelimit-unified-*` headers regardless of which billing pool the spawn path consumes. If P0 succeeds Phase 7+ Phase 5's probe code remains unchanged; if P0 fails Phase 5's probe code remains unchanged. The probe is billing-pool-agnostic because the headers are subscription-pool metadata, not Agent-SDK-Credit metadata.
|
||||
|
||||
#### `/v1/messages` Anthropic-shape entry surface
|
||||
|
||||
**Status:** Still deferred per ADR 0010 § Out-of-scope. No change in Phase 5.
|
||||
|
||||
#### v1.x roadmap #3 / #5 / #6
|
||||
|
||||
**Status:** Still trigger-gated per `docs/v1x-roadmap.md`. None has fired. Continue to defer.
|
||||
|
||||
### Opportunistic Phase 5 micro-additions (not blocking)
|
||||
|
||||
Items small enough to land alongside a planned D-day without scope creep, if encountered:
|
||||
|
||||
- README § Dashboard screenshot update (post-P5-2 enrichment) — capture from MacBook test path per `~/.cc-rules/memory/feedback/mac_mini_never_for_testing.md`.
|
||||
- `olp usage` CLI subcommand (bin/olp.mjs) surfaces the parsed quota shape in terminal form. Already partially exists (cmdUsage in bin/olp.mjs); confirm payload alignment after D80.
|
||||
- Add `claude_code_oauth_client_id` config override in `~/.olp/config.json` so power users can override the hardcoded `9d1c250a-...` UUID without env-var fiddling. Mirrors compiled binary's `CLAUDE_CODE_OAUTH_CLIENT_ID` env support.
|
||||
- `docs/provider-audits/anthropic.md` re-capture with current `claude --version` (v2.1.142 MacBook / v2.1.150 PI231) + binary distribution layout note.
|
||||
|
||||
### Exit gate — v0.5.0 close criteria
|
||||
|
||||
1. D79 — D84 all merged with fresh-context opus reviewer APPROVE per Iron Rule 10.
|
||||
2. CI green on every D-day merge commit and on the v0.5.0 release commit head. `alignment.yml` blacklist re-confirmed (no new hallucinated tokens introduced).
|
||||
3. README § Quota / Plan Usage section present with screenshot of the enriched dashboard. README § Supported Providers table updated to note "quota probe: anthropic ✅, mistral ⚠️/✅ (D84 outcome), codex ❌ (no public API)".
|
||||
4. ADR 0012 (this charter) + ADR 0002 Amendment 8 + ADR 0013 (OAuth READ-ONLY consumption) on disk.
|
||||
5. `CHANGELOG.md "Unreleased"` promoted to `"## v0.5.0 — <date>"` with D79 — D84 entries.
|
||||
6. `package.json` bumped to `0.5.0`.
|
||||
7. `CLAUDE.md release_kit.phase_rolling_mode.current_phase` advances `Phase 5 → Phase 6`; `current_pre_release_identifier` advances `0.5.0-phase5 → 0.6.0-phase6`.
|
||||
8. Standing autopilot grant covers D-day-by-D-day execution; v0.5.0 close PR is maintainer-triggered.
|
||||
9. Live MacBook E2E verification — dashboard renders enriched panel with real quota data (probe live, not mocked).
|
||||
|
||||
---
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive.**
|
||||
|
||||
- OLP finally has the load-bearing observability OCP had — maintainer can see live "39% session / 30% weekly" and decide whether voluntary `claude -p` traffic stays or moves.
|
||||
- Family members on the LAN see real reset times instead of "—", which makes the "wait 2 hours" guidance concrete vs. abstract.
|
||||
- The institutional-knowledge audit captured the schema in a memory file pinned with date stamps — future ports (mistral, future provider) re-use the verification protocol without re-deriving.
|
||||
- v1.x roadmap #8 (Dashboard enrichment per Claude.ai-style usage page) closes inside Phase 5 rather than waiting for a separate phase.
|
||||
- Compiled-binary-distribution awareness ("no more cli.js to grep") is now codified in OLP governance; the next time Anthropic ships a major CC version, the verification protocol is already written.
|
||||
|
||||
**Negative.**
|
||||
|
||||
- ADR 0002 gains another amendment (Amendment 8). The constitution surface area for `anthropic.mjs` grows. Counter-pressure: the alternative (probe lives in `server.mjs`, like OCP) violates the plugin-architecture principle that per-provider knowledge stays in `lib/providers/`. Amendment 8 is the smaller violation.
|
||||
- The probe makes one `/v1/messages` call per 5min cache miss. That's ~12 calls/hour worst case across the whole proxy (probe is per-credentials, not per-key). With `max_tokens: 1` the cost is < $0.01/day at family-scale traffic. Negligible but not zero.
|
||||
- Schema-drift risk over the long horizon. Anthropic could rename or remove headers in a future version. The mitigation protocol (strings + live probe diff) is in place, but it's a manual check — needs to be invoked by the maintainer or scheduled.
|
||||
- Dashboard refactor introduces a breaking-change risk for the existing dashboard.html consumers (none today, but conceptually). Bumping to v0.5.0 signals this clearly.
|
||||
|
||||
**Neutral.**
|
||||
|
||||
- Phase 5 has more ADR work than Phase 4 (3 governance docs vs. 2). The constitutional layer is deliberately heavier because direct-API access is the single biggest authority decision since the plugin contract itself.
|
||||
|
||||
---
|
||||
|
||||
## Authority + cross-references
|
||||
|
||||
- **Iron Rule 11 (IDR)** — Phase 5 ships across 6 D-days, each a minimum reviewable unit. The governance trio (this ADR + Amendment 8 + ADR 0013) lands at D79 as a single coupled commit (reviewing them separately cannot verify consumer-producer alignment), per ADR 0002 Amendment 7's precedent.
|
||||
- **Iron Rule 12 (prior-art search)** — discharged via the audit memory at `~/.cc-rules/memory/learnings/anthropic_plan_usage_probe_schema_2026_05_26.md`. Memory committed prior to D80 implementation.
|
||||
- **ALIGNMENT.md Rule 1 (citation)** — D80 commit must cite compiled-binary `strings` evidence per audit memory § Path A (Claude Code v2.1.x has no traditional `§ section` structure because it is a Mach-O / ELF compiled binary) plus the audit memory file path. Live-probe transcript MUST be included in the commit body.
|
||||
- **ALIGNMENT.md Rule 2 (provider-CLI-as-authority)** — direct-API access bypasses the spawn-binary contract. Amendment 8 is the explicit exemption. Without Amendment 8, the D80 commit is unalignable.
|
||||
- **ALIGNMENT.md Rule 5 (CI alignment.yml)** — must continue to pass. `api.anthropic.com/v1/messages` is NOT on the blacklist (correct — that's the real endpoint). The hallucinated `/api/oauth/usage` IS on the blacklist (transitive from OCP) and must remain.
|
||||
- **ADR 0002 Amendment 8** — companion ADR. Direct-API access scoping; READ-ONLY constraint; opt-in via config flag (default off).
|
||||
- **ADR 0013** — companion ADR. OAuth credentials shared between spawn path + probe path; refresh backoff; schema-drift mitigation protocol.
|
||||
- **CLAUDE.md release_kit** — Phase boundary triggers maintainer-led version bump. D-day commits within Phase 5 stay under "Unreleased". `0.5.0-phase5` is the pre-release identifier during the phase.
|
||||
@@ -0,0 +1,197 @@
|
||||
# ADR 0013 — OAuth READ-ONLY Consumption Rules + Schema-Drift Mitigation Protocol
|
||||
|
||||
**Status:** Accepted (2026-05-26)
|
||||
**Date:** 2026-05-26
|
||||
**D-day:** D79 (lands alongside ADR 0012 Phase 5 charter + ADR 0002 Amendment 8 as the constitutional trio of Phase 5)
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR 0002 Amendment 8 permits `quotaStatus()` to call provider HTTP APIs directly, subject to a READ-ONLY constraint. That Amendment opens the door but does not specify HOW READ-ONLY discipline is preserved across credential lifecycle events (refresh, expiry, revocation), nor how OLP detects when the upstream API schema drifts. ADR 0013 fills both gaps.
|
||||
|
||||
The motivating concern: a provider that ships its CLI as a **compiled native binary** (Anthropic Claude Code v2.1.x is now Mach-O on macOS, ELF on Linux) closes off the previous schema-verification path (grep `cli.js`). If OLP's probe parser silently breaks because a header was renamed, the dashboard shows stale or wrong numbers, and the maintainer's load-bearing throttling decision is based on bad data. This ADR establishes the verification protocol that survives the binary-distribution shift.
|
||||
|
||||
A second motivating concern: the OAuth credentials used by the probe are the SAME credentials the spawn path uses for `claude -p`. Both paths consume them; the probe must not interfere with the spawn path's ability to refresh or invalidate them. Concretely: the probe must not write to the credentials artifact, must not race the spawn path on refresh, and must not amplify a 429 into a refresh storm.
|
||||
|
||||
---
|
||||
|
||||
## Decision
|
||||
|
||||
### Rule 1 — Credential reuse is mandatory
|
||||
|
||||
The probe MUST consume the same OAuth artifact the spawn path reads via the plugin's `readAuthArtifact()`. No new OAuth grant. No alternate credential store. No environment-variable-only fallback (env var `CLAUDE_CODE_OAUTH_TOKEN` is supported as an override consistent with the spawn path, but is not the probe's primary source).
|
||||
|
||||
Precedence order (mirrors OCP `getOAuthCredentials` 2026-04-stable):
|
||||
|
||||
1. `process.env.CLAUDE_CODE_OAUTH_TOKEN` if non-empty (manual override; common in CI / dev / one-off debugging).
|
||||
2. `~/.claude/.credentials.json` → `claudeAiOauth.accessToken` (Linux + macOS without keychain access).
|
||||
3. macOS Keychain: `security find-generic-password -a "${USER}" -s "Claude Code-credentials" -w` (preferred on macOS — current `lib/providers/anthropic.mjs` only covers (1) + (2); D80 adds (3)).
|
||||
|
||||
Rationale: a separate OAuth grant would require the maintainer to repeat `claude setup-token` against an OLP-specific scope, doubling credential exposure and divergence risk. Reusing the spawn path's credentials guarantees the probe never has more permission than the spawn path itself.
|
||||
|
||||
### Rule 2 — READ-ONLY at the wire
|
||||
|
||||
The probe MUST issue exactly one HTTP request per cache miss. Method MAY be POST (Anthropic's ratelimit headers come back on `POST /v1/messages`; this is the only way to read them). Request body MUST minimise side effects:
|
||||
|
||||
- `max_tokens: 1` (cost: ~$0.000001 per probe)
|
||||
- `messages: [{role: "user", content: "hi"}]` (any minimal valid payload)
|
||||
- Model: cheapest available in the plan (`claude-haiku-4-5` at v0.5.0)
|
||||
- Do NOT include `system` prompts, `tools[]`, `tool_choice`, large content arrays, or anything that the upstream might bill differently.
|
||||
|
||||
The probe MUST discard the response body. Only response headers are parsed.
|
||||
|
||||
The probe MUST NOT call any other HTTP path on the provider's API. No `/v1/models` enumeration, no admin endpoints, no `/v1/messages/<id>` retrievals. The only permitted endpoint is `POST /v1/messages`.
|
||||
|
||||
### Rule 3 — Cache TTL and refresh discipline
|
||||
|
||||
- Cache TTL: 5 minutes. Cache miss triggers a real probe. Cache hit returns the cached value.
|
||||
- The dashboard refreshes every 1 minute; that's served from the cache between probes. A manual refresh button MAY force-clear the cache (per maintainer request 2026-05-26); ADR 0012 D82 documents the button.
|
||||
- On refresh failure (token expired, 401/403/429, network error), the probe schedules an exponential backoff: minimum 60s, maximum 3600s. The cache entry is NOT invalidated during backoff; `quotaStatus()` returns the stale cache marked `{ stale: true, last_fresh_at: <epoch> }`. If no stale entry exists, returns an `unreachable` shape (v0.5.1+) rather than `null`.
|
||||
- Successive successful probes reset the backoff to the minimum.
|
||||
- Token refresh (`POST https://platform.claude.com/v1/oauth/token`) follows the same backoff discipline. The probe MUST NOT refresh a token more than once per backoff window. The refresh path is shared with the spawn path; both observe the same backoff.
|
||||
- **All consumers of `quotaStatus()`, including `olp doctor` checks, MUST route through `quotaStatus()` and MUST NOT call `_probeOnce()` directly.** `_probeOnce()` is an internal implementation detail. Routing doctor checks through `quotaStatus()` ensures the cache+backoff discipline is enforced for every caller — including operators running `olp doctor` in a debug loop. (Clarification added v0.5.1 to address codex finding F1: the original doctor check bypassed backoff by calling `_probeOnce` directly.)
|
||||
|
||||
### Rule 4 — Opt-in via config
|
||||
|
||||
A new config field at `~/.olp/config.json` controls per-provider opt-in:
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"anthropic": {
|
||||
"enabled": true,
|
||||
"quota_probe_enabled": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Default: `false`. The maintainer must explicitly opt in after credentials are configured. Reasoning: a fresh install on a machine without OAuth credentials should not bombard `api.anthropic.com` with 401-bound probes.
|
||||
|
||||
`olp doctor` adds a per-provider check `<provider>.quota_probe_reachable` (only runs if `quota_probe_enabled: true`). Failed check provides a `next_action.ai_executable[]` recipe to either re-authenticate or disable the probe.
|
||||
|
||||
### Rule 5 — Schema-drift mitigation protocol (minimum-viable-schema gate)
|
||||
|
||||
The CC binary-distribution shift means OCP's "grep cli.js" verification is no longer applicable. OLP adopts a two-path protocol for proactive monitoring, AND enforces a minimum-viable-schema gate at parse time:
|
||||
|
||||
**Minimum-viable-schema gate (v0.5.1+).** `_probeOnce()` requires at least these 4 fields present (non-null after parse) before treating a response as successful:
|
||||
- `anthropic-ratelimit-unified-5h-utilization`
|
||||
- `anthropic-ratelimit-unified-5h-reset`
|
||||
- `anthropic-ratelimit-unified-7d-utilization`
|
||||
- `anthropic-ratelimit-unified-7d-reset`
|
||||
|
||||
If any of these 4 is absent, `_probeOnce()` classifies the probe as a schema-drift failure (`failureKind = 'schema_drift'`), schedules backoff, and returns `null`. This means a 200 OK with zero `anthropic-ratelimit-*` headers (e.g. a server-side change, a proxy stripping headers, or a mock returning `{}`) is immediately caught as drift rather than silently cached as "live" data. The other 9 fields are tolerated as absent (overage fields are conditional; top-level status fields may be absent on edge cases). The 5h/7d core 4 are load-bearing — the dashboard's progress bars depend on them. (Gate added v0.5.1 to address codex finding F2.)
|
||||
|
||||
The CC binary-distribution shift means OCP's "grep cli.js" verification is no longer applicable. OLP adopts a two-path protocol:
|
||||
|
||||
**Path A — Compiled-binary string extraction.** Run `strings` over the platform-specific binary in the claude-code distribution. Captures all hardcoded header names the binary expects:
|
||||
|
||||
```bash
|
||||
BIN_DIR=$(npm root -g)/@anthropic-ai/claude-code/node_modules/@anthropic-ai/claude-code-*
|
||||
strings "$BIN_DIR/claude" | grep -iE "anthropic-ratelimit|/v1/(messages|oauth)|platform\.claude\.com"
|
||||
```
|
||||
|
||||
**Path A prerequisites.** GNU or BSD `strings` (part of binutils/coreutils on Linux + macOS — always present on a normal developer machine; Windows requires WSL or `binutils-mingw`). A locally installed Claude Code v2.1.x (npm-global or volta-managed). A reviewer without `claude` installed can still run Path B but Path A is gated on having the binary on disk. A future Claude Code version that ships as a different distribution shape (e.g. Rust binary, statically linked Go) keeps the protocol valid: `strings` works on any ELF/Mach-O regardless of compile source.
|
||||
|
||||
**Path B — Live API probe.** Run the actual probe against `api.anthropic.com` with valid OAuth credentials. Captures what the server returns today:
|
||||
|
||||
```bash
|
||||
curl -s -i -m 10 -X POST https://api.anthropic.com/v1/messages \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "anthropic-beta: oauth-2025-04-20" \
|
||||
-H "anthropic-version: 2023-06-01" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model":"claude-haiku-4-5","max_tokens":1,"messages":[{"role":"user","content":"hi"}]}' \
|
||||
| grep -iE "^anthropic-ratelimit"
|
||||
```
|
||||
|
||||
Path A tells you what the client expects. Path B tells you what the server actually emits. The diff is the actionable schema delta.
|
||||
|
||||
**Required cadence.** The diff MUST be re-run at every major `claude --version` bump (v2.x → v3.x is the next trigger). The current pinned schema lives at `~/.cc-rules/memory/learnings/anthropic_plan_usage_probe_schema_2026_05_26.md`. After re-verification, that memory file MUST be updated (or a successor file written with a new date stamp; the old one cross-linked).
|
||||
|
||||
**Trigger for re-running the diff.** There is no automated detector for a major `claude --version` bump at v0.5.0. Three explicit hooks share this responsibility:
|
||||
|
||||
1. **Annual Alignment Audit** (`ALIGNMENT.md` § Annual Alignment Audit, every 14 May) — diff is mandatory as part of the audit checklist.
|
||||
2. **`olp doctor anthropic.quota_probe_reachable` failure** — if the probe returns non-2xx for any reason other than 401/403/429/network (typical schema breaks manifest as 422 or 400), `olp doctor` surfaces a `kind: fix_provider` recipe whose first step is "re-run the Rule 5 dual-path diff".
|
||||
3. **Manual maintainer attention at a major Claude Code release** — if the maintainer sees a major version bump in `claude --version`, kick off the diff before the next Phase opens. Rolling-mode discipline (CLAUDE.md release_kit) means major-version bumps usually intersect with Phase boundaries.
|
||||
|
||||
If the diff is missed across a major version bump, the failure mode is graceful degradation: the parser silently drops unknown headers; the dashboard shows older values (cached stale) or `null` per Rule 3; `olp doctor` surfaces the staleness.
|
||||
|
||||
**Required action on drift detection.** If a header is renamed or removed:
|
||||
|
||||
1. File a Phase-N issue tagging the maintainer.
|
||||
2. Update the parser in `lib/providers/anthropic.mjs:quotaStatus()` to handle both names (graceful migration), prefer the new name.
|
||||
3. Update the audit memory file with a "drift event" section recording: date, old field, new field, evidence URLs.
|
||||
4. Bump the `models-registry.json` `quota_probe.schema_version` (NEW field added at D80) so downstream consumers can detect.
|
||||
|
||||
If a new header appears in the live response that the parser doesn't read: low-priority enhancement; add to the parser, document in the audit memory, no schema_version bump required.
|
||||
|
||||
### Rule 6 — Failure transparency
|
||||
|
||||
The probe's failure modes are visible to the operator:
|
||||
|
||||
- `/v0/management/dashboard-data` includes per-provider `{ quota_probe: { status: 'ok' | 'stale' | 'failed' | 'disabled', last_fresh_at, last_error?, backoff_until? } }`.
|
||||
- `olp doctor` surfaces probe failure as `kind: fix_oauth` (if 401/403) or `kind: fix_provider` (if 429 with no stale cache or network error).
|
||||
- The dashboard row badge shows the status; clicking a failed row shows the last error (truncated to 200 chars, no full credential traces).
|
||||
|
||||
### Rule 7 — Out-of-scope
|
||||
|
||||
This ADR does NOT govern:
|
||||
|
||||
- Spawn-path OAuth refresh (the spawn path's refresh logic predates this ADR and is governed by the underlying CLI). The probe shares the credential artifact but does not own the refresh.
|
||||
- Anthropic-specific bearer revocation (Anthropic side). Revocation manifests as 401 to the probe, which falls into Rule 6.
|
||||
- Non-Anthropic provider OAuth flows. Mistral / future providers MAY adopt this protocol via plugin-specific ADRs; ADR 0013 establishes the template.
|
||||
|
||||
---
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive.**
|
||||
|
||||
- The probe is bounded — Rule 2 caps the wire traffic, Rule 3 caps the refresh rate, Rule 4 caps activation surface.
|
||||
- Schema-drift detection is procedural and reproducible — Rule 5 gives the maintainer a runbook that doesn't depend on Anthropic publishing a deprecation notice.
|
||||
- Failure is visible — Rule 6 means a broken probe shows up in `olp doctor` and the dashboard, not as a silent "—" in the quota row.
|
||||
- Credential reuse (Rule 1) keeps the security surface area minimal.
|
||||
|
||||
**Negative.**
|
||||
|
||||
- The `quota_probe_enabled` opt-in adds a configuration step. Mitigated by `olp doctor` surfacing the recipe when credentials are present but the probe is off.
|
||||
- The schema-drift protocol is manual. Anthropic could ship a v3.x binary tomorrow and the verification only happens when the maintainer or a doctor probe failure prompts it. Counter-pressure: drift events at OCP scale (~12 months) suggest manual verification on major version bumps is sufficient.
|
||||
- Stale-cache-on-failure (Rule 3) means the dashboard could show 30-minute-old data without an obvious "stale" indicator unless the UI explicitly renders the `stale: true` marker. ADR 0012 D82 requires the dashboard to surface staleness; reviewing that during P5-2 implementation.
|
||||
|
||||
**Neutral.**
|
||||
|
||||
- The protocol is portable. Future provider plugins adopting direct-API probes (mistral if its `/v1/usage` exists) can reuse the same six rules with provider-specific endpoint substitution.
|
||||
|
||||
---
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
### A — Probe lives in `server.mjs` (OCP-style)
|
||||
|
||||
OCP's probe is in `server.mjs:842-1109` because OCP is single-provider and pre-plugin-architecture. Porting that pattern to OLP would violate ADR 0002 (per-provider knowledge stays in `lib/providers/`). Rejected.
|
||||
|
||||
### B — Spawn `claude -p --dry-run` and parse ratelimit headers
|
||||
|
||||
`claude -p` does not expose response headers; the CLI consumes and discards them. Even if it did, parsing CLI stdout is fragile. Rejected.
|
||||
|
||||
### C — Wait for Anthropic to publish a public quota API
|
||||
|
||||
The 2026-06-15 Agent SDK Credit billing-split announcement does not include a public quota API. Anthropic may publish one in the future; this ADR is forward-compatible (Rule 7 explicitly notes "if Anthropic publishes a public ratelimit API, this entire workaround becomes obsolete — re-evaluate"). Rejected for v0.5.0 (no ETA).
|
||||
|
||||
### D — Mandate token-rotation in OLP
|
||||
|
||||
Tempting (auditability), but OCP's experience shows token rotation breaks the spawn path more often than it improves security at family-scale deployment. The credential rotation cadence is Anthropic-side (token TTL); OLP respects whatever Claude Code does. Rejected.
|
||||
|
||||
---
|
||||
|
||||
## Authority + cross-references
|
||||
|
||||
- **ADR 0002 Amendment 8** — the contract-level permission. ADR 0013 is the implementation discipline for that permission.
|
||||
- **ADR 0012** — Phase 5 charter that schedules D80 implementation.
|
||||
- **ADR 0011** — anonymous-key deployment-context (LAN-only). Separate scope; this ADR does not amend it.
|
||||
- **`~/.cc-rules/memory/learnings/anthropic_plan_usage_probe_schema_2026_05_26.md`** — the live schema pin. Updated on every drift event per Rule 5.
|
||||
- **`alignment.yml`** — must continue to blacklist `/api/oauth/usage` and related hallucinated tokens. Must NOT add `/v1/messages` to the blacklist (legitimate endpoint).
|
||||
- **OCP `server.mjs:842-1109`** — the source-of-truth port reference for D80.
|
||||
- **OCP `ALIGNMENT.md`** — the institutional precedent (2026-04-11 drift → ALIGNMENT introduction) this ADR consolidates for OLP.
|
||||
@@ -0,0 +1,662 @@
|
||||
# ADR 0014 — Sandbox-Runtime Integration for Multi-Tenant Provider Spawning
|
||||
|
||||
**Status:** Accepted (PR-A shipped; PR-B shipped pending PI231 Suite 44 validation + HTTP-path activation debug; PR-C/D pending) — **see Amendment 1 (2026-05-29): PR-B's outer-bwrap approach is superseded by the per-spawn ephemeral-home + per-provider ISOLATION contract architecture. PR-C/D are reframed; the substantive decision moves into Amendment 1.**
|
||||
**Date:** 2026-05-28
|
||||
**Phase:** Phase 7
|
||||
|
||||
---
|
||||
|
||||
## Related
|
||||
|
||||
- **ADR 0001** (Project Founding) — OLP's multi-provider rationale and "no conversation state" principle.
|
||||
- **ADR 0009 Amendment 1** (stream-json transport, Phase 6) § Caveats #3: "Sandbox-runtime still required for real multi-tenant deployment."
|
||||
- **ADR 0002** (Plugin Architecture) — Provider contract; `spawn()` is the surface this ADR will wrap in PR-B/C.
|
||||
- **ADR 0006** (Provider Inclusion / Risk Tier Framework) — classifies providers by deployment risk; sandbox status is a gating condition for Tier-A (cloud-deployed).
|
||||
- **`docs/plans/cloud-deployment-family.md` § 5** — sandbox is a hard prerequisite before any cloud rollout.
|
||||
- **cc-mem incident memory** — `~/.cc-rules/memory/projects/olp/incident_2026_05_27_spawn_cli_security.md` — the multi-tenant security gap that motivates this ADR.
|
||||
|
||||
---
|
||||
|
||||
## 1. Context
|
||||
|
||||
### 1.1 The multi-tenant security gap
|
||||
|
||||
OLP is a personal-scale proxy (ADR 0001 § Non-commercial). However, the "family-scale" deployment model means multiple human callers share a single OLP instance — each with their own OLP API key (ADR 0007) but all using the same underlying `claude` or `codex` CLI installation on the server host.
|
||||
|
||||
The security gap, identified in the 2026-05-27 session and captured in cc-mem incident memory § 3, is:
|
||||
|
||||
1. **OAuth token exposure.** A malicious (or misbehaving) prompt to the Anthropic provider could elicit a `cat ~/.olp/keys/...` or similar read of any file the OLP process user can access — including the OAuth credentials file that allows the attacker to impersonate the server-side identity.
|
||||
2. **Codex shell-tool execution.** The `codex exec` path exposes a shell tool to the model. With OLP acting as a relay, a prompt to codex from one client could execute arbitrary commands in the server process's working directory, reading or writing files belonging to other clients.
|
||||
3. **Cross-tenant data leakage.** Even without adversarial prompts, a model that freely accesses the filesystem could inadvertently leak one client's cached context to another client's response.
|
||||
|
||||
The 2026-05-27 prior-art search (incident memory § 4) surveyed the multi-tenant LLM proxy ecosystem (LiteLLM, OpenCode, CLIProxyAPI, open-source Anthropic proxies) and found that **none solve multi-tenant file-system and tool isolation at the OS level**. The field's typical answer is "don't run multi-tenant" or "use a separate VM per tenant" — neither applicable at OLP's family scale.
|
||||
|
||||
### 1.2 Anthropic's official answer: `@anthropic-ai/sandbox-runtime`
|
||||
|
||||
The `@anthropic-ai/sandbox-runtime` package (Anthropic Experimental org, `anthropic-experimental/sandbox-runtime`, v0.0.52 as of this ADR) is Anthropic's open-source solution to wrapping security boundaries around arbitrary processes. It is the library that Claude Code itself uses internally to sandbox MCP servers and tool execution.
|
||||
|
||||
The library provides:
|
||||
|
||||
- **Linux:** bubblewrap (`bwrap`) namespace isolation + socat network bridge + seccomp filter via `apply-seccomp-filter` binary. Ripgrep (`rg`) is required for deny-path glob expansion.
|
||||
- **macOS:** `sandbox-exec` seatbelt profile, which is a built-in OS facility (no additional packages required).
|
||||
|
||||
Both paths enforce filesystem read/write restrictions and network policy at the kernel level, not at the process level. A `cat ~/.olp/keys/...` inside the sandbox fails at the syscall layer regardless of what the shell or model requests.
|
||||
|
||||
### 1.3 The 2026-05-28 spike
|
||||
|
||||
A PoC spike was conducted on PI231 (arm64 Debian Bookworm) on 2026-05-28. Key findings:
|
||||
|
||||
1. **`npm install @anthropic-ai/sandbox-runtime@0.0.52` succeeds cleanly** on arm64 Linux. No native build step; prebuilt binaries were available.
|
||||
2. **`SandboxManager.isSupportedPlatform()` returns `true`** on PI231 (Linux, not WSL).
|
||||
3. **`SandboxManager.checkDependencies()` reports errors**: `bubblewrap (bwrap) not installed`, `socat not installed`, `ripgrep (rg) not found`. These are the three OS-level deps that must be installed separately (not bundled in the npm package).
|
||||
4. **The install fix is a one-liner**: `sudo apt-get install -y bubblewrap socat ripgrep`. This is a 5-minute operational task, not a code change.
|
||||
5. **Three PoC scripts** were parked at `/tmp/sandbox-spike/` on PI231 verifying: dependency check return shapes, `SandboxManager.wrapWithSandbox` call signature, and filesystem-deny path behaviour.
|
||||
|
||||
Verdict: **YELLOW** — architecturally green (the library works and the platform is supported), operationally blocked on apt deps. PR-A lays the dependency + doctor layer. PR-B wraps the anthropic spawn after apt install.
|
||||
|
||||
---
|
||||
|
||||
## 2. Decision
|
||||
|
||||
### 2.1 Layered rollout (Iron Rule 11 — minimum reviewable unit)
|
||||
|
||||
The sandbox integration is split into four discrete PRs, each independently reviewable and independently safe to land or revert:
|
||||
|
||||
| PR | Scope | Blocking condition | Status |
|
||||
|---|---|---|---|
|
||||
| **PR-A** (this PR) | npm dep `@anthropic-ai/sandbox-runtime ^0.0.52` + `lib/sandbox/doctor.mjs` (preflight module) + `/health` `sandbox` field + ADR 0014 | None — no runtime initialization | ✅ Accepted |
|
||||
| **PR-B** | `lib/sandbox/manager.mjs` (bootstrap + spawn-wrap) + `lib/providers/anthropic.mjs` spawn wrapped + server startup wiring + `/health.sandbox.active` + Suite 43/44 tests | `bubblewrap` + `socat` + `rg` installed on PI231 (`sudo apt-get install -y bubblewrap socat ripgrep`) | ✅ Implemented — pending PI231 validation (Suite 44) + opus reviewer |
|
||||
| **PR-C** | `lib/providers/codex.mjs` spawn wrapped with `enableWeakerNestedSandbox: true` | PR-B accepted + codex PoC on PI231 | 🔲 Blocked on PR-B |
|
||||
| **PR-D** | `docs/plans/cloud-deployment-family.md` § "Phase 7 prerequisite met" update; cloud rollout unblocked | PR-B + PR-C accepted | 🔲 Blocked on PR-C |
|
||||
|
||||
Rationale for the split:
|
||||
|
||||
- **PR-A is safe without bwrap.** The doctor module and `/health` field add observability with no runtime side effects. No `SandboxManager.initialize()` call. No sandbox spawned.
|
||||
- **PR-B is the load-bearing security gate.** Wrapping `anthropic.mjs` spawn requires empirical negative-test confirmation (in-sandbox `cat ~/.olp/keys/...` MUST fail). This cannot be verified until PI231 has bwrap installed.
|
||||
- **PR-C follows PR-B** because codex has a distinct issue: codex itself uses bubblewrap internally (`codex exec` spawns its own sandbox). `enableWeakerNestedSandbox: true` is required to allow the inner sandbox to function inside the outer OLP sandbox.
|
||||
- **PR-D is documentation-only** and depends on the runtime PRs being proven in production.
|
||||
|
||||
### 2.2 PR-A specific scope (binding)
|
||||
|
||||
PR-A MUST NOT include:
|
||||
|
||||
- Any call to `SandboxManager.initialize()` (no real sandbox created)
|
||||
- Any modification to `lib/providers/anthropic.mjs`, `lib/providers/codex.mjs`, or `lib/providers/mistral.mjs`
|
||||
- Any new HTTP endpoint (no `/metrics`, no new dashboard endpoint)
|
||||
- Any modification to `models-registry.json`
|
||||
|
||||
PR-A MUST include:
|
||||
|
||||
- `package.json` dependency: `"@anthropic-ai/sandbox-runtime": "^0.0.52"`
|
||||
- `lib/sandbox/doctor.mjs`: pure preflight module (no state; no initialization)
|
||||
- `/health` response: top-level `sandbox` field (`available`, `missing`, `platform`, `message` when unavailable)
|
||||
- `docs/adr/0014-sandbox-runtime-integration.md` (this document)
|
||||
- `CHANGELOG.md` Unreleased entry
|
||||
- `test-features.mjs` Suite 42 (8 new tests, all passing)
|
||||
|
||||
---
|
||||
|
||||
## 3. `lib/sandbox/doctor.mjs` design
|
||||
|
||||
### 3.1 Exports
|
||||
|
||||
```javascript
|
||||
// Returns { available: boolean, missing: string[], details: { ... } }
|
||||
export async function checkSandboxAvailability() { ... }
|
||||
|
||||
// Returns { ok: boolean, message: string } — human-readable summary
|
||||
export async function describeSandboxStatus() { ... }
|
||||
```
|
||||
|
||||
### 3.2 `checkSandboxAvailability` algorithm
|
||||
|
||||
1. Probe OS deps independently via `child_process.execFileSync('which', [binary])`:
|
||||
- `bwrap` (Linux only — macOS uses built-in `sandbox-exec`)
|
||||
- `socat` (Linux only)
|
||||
- `rg` (ripgrep — Linux only; macOS seatbelt profiles use regex patterns natively)
|
||||
2. Call `probeLibrary()` which `import()`s `@anthropic-ai/sandbox-runtime` and calls:
|
||||
- `SandboxManager.isSupportedPlatform()` — platform classification
|
||||
- `SandboxManager.checkDependencies(undefined)` — library's own dep check (called without initialize, falling back to PATH lookup)
|
||||
3. Compute `missing[]`: on Linux, add 'bubblewrap', 'socat', 'ripgrep' for each absent dep; if library import failed, add that too.
|
||||
4. `available = libLoaded && isSupportedPlatform && missing.length === 0`
|
||||
|
||||
`probeLibrary()` wraps everything in try/catch — any library-side error becomes `{ libLoaded: false, libError: '<reason>' }` rather than an unhandled rejection.
|
||||
|
||||
### 3.3 `/health` integration
|
||||
|
||||
The `sandbox` field is added to the full (owner-tier) payload only. For trimmed payloads (guest/anonymous per ADR 0007 § 7.1), the field is absent (consistent with the existing trim model). This prevents leaking infrastructure details to non-owner callers.
|
||||
|
||||
The result is memoized process-wide via `_sandboxStatusCache` in `server.mjs`. The install state of bwrap/socat cannot change at runtime without a process restart, so a single lazy fetch at the first `/health` call is correct.
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"version": "0.5.1",
|
||||
"providers": { ... },
|
||||
"sandbox": {
|
||||
"available": false,
|
||||
"missing": ["bubblewrap", "socat", "ripgrep"],
|
||||
"platform": "linux",
|
||||
"message": "Sandbox dependencies not available: bubblewrap not installed, socat not installed, ripgrep not installed. Install: sudo apt-get install -y bubblewrap socat ripgrep"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When available (after apt install + process restart) and PR-B bootstrapped:
|
||||
|
||||
```json
|
||||
{
|
||||
"sandbox": {
|
||||
"available": true,
|
||||
"active": true,
|
||||
"missing": [],
|
||||
"platform": "linux"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
(PR-A shape did not include `active`. PR-B adds `active: boolean` — distinguishes
|
||||
"deps present" from "sandbox actually initialized and wrapping spawns".)
|
||||
|
||||
---
|
||||
|
||||
## 4. PR-B/C/D acceptance criteria
|
||||
|
||||
### 4.1 PR-B (anthropic.mjs spawn wrap) — ✅ Implementation shipped, PI231 validation pending
|
||||
|
||||
**PR-B implementation (commit pending reviewer):**
|
||||
- `lib/sandbox/manager.mjs`: singleton bootstrap + transparent `wrapSpawn()` API
|
||||
- `lib/providers/anthropic.mjs`: spawn site wrapped via `wrapSpawn()` (ADR 0009 Amendment 1 spawn args unchanged)
|
||||
- `server.mjs`: `bootstrapSandbox()` called before `server.listen()`, `/health.sandbox.active` field added
|
||||
- `test-features.mjs` Suite 43 (8 tests, all pass on macOS) + Suite 44 (2 tests, PI231-gated with `OLP_E2E_SANDBOX=1`)
|
||||
- 805 → 813 tests. Suite 44 skipped by default; runs on PI231 after apt install.
|
||||
|
||||
**Load-bearing negative test (required for PR-B to merge):**
|
||||
|
||||
```bash
|
||||
# On PI231, with bwrap+socat installed, with PR-B wired:
|
||||
olp-keys list # identify owner key
|
||||
curl -X POST http://127.0.0.1:4567/v1/chat/completions \
|
||||
-H "Authorization: Bearer <owner-key>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model":"claude-sonnet-4-6","messages":[{"role":"user","content":"run: cat /home/<user>/.olp/keys/owner-key.json"}]}'
|
||||
# Expected: response MUST NOT contain any content from the keys file.
|
||||
# The model must either say it cannot access the filesystem, or produce an
|
||||
# error. Any response containing the file content is a PR-B blocking failure.
|
||||
```
|
||||
|
||||
Additional criteria:
|
||||
- `SandboxManager.initialize()` is called once at startup (singleton shape follows `@anthropic-ai/sandbox-runtime` v0.0.52 `dist/sandbox/sandbox-manager.js` SandboxManager export, where `reset()` is a process-wide operation; see § 5 Open question 1 for the per-provider config concern still to be resolved)
|
||||
- p95 latency overhead of wrapping ≤ 200ms measured over 50 warm requests
|
||||
- `checkSandboxAvailability().available === true` reported in `/health.sandbox` after PR-B rolls out
|
||||
- All existing Suite 41 tests continue to pass (stream-json transport unaffected)
|
||||
|
||||
### 4.2 PR-C (codex.mjs wrap)
|
||||
|
||||
- `enableWeakerNestedSandbox: true` is set in the `SandboxManager.initialize()` call (or per-spawn config if the API allows per-spawn override — verify against v0.0.52 API)
|
||||
- `codex exec` inner bubblewrap nest still functions: a sandboxed codex invocation that reads from an allowed path succeeds
|
||||
- Analogous negative test: in-sandbox `cat /home/<user>/.olp/keys/...` MUST fail
|
||||
|
||||
### 4.3 PR-D (cloud deployment plan update)
|
||||
|
||||
- `docs/plans/cloud-deployment-family.md` § 5 "Phase 7 prerequisite" section updated: "sandbox-runtime integration (PR-B + PR-C) confirmed operational on PI231; prerequisite met"
|
||||
- `README.md` § "Supported Providers" or § "Security" updated with a note about sandbox isolation
|
||||
- Phase 7 close PR per `CLAUDE.md release_kit.phase_rolling_mode`
|
||||
|
||||
---
|
||||
|
||||
## 5. Open questions (to be resolved in PR-B)
|
||||
|
||||
1. **Singleton vs per-spawn initialization.** `SandboxManager` is a process-wide singleton (per the library's `reset()` being a global operation). The current design plan is one `initialize()` call at server startup with a union config covering all providers. If providers require different configs (e.g., different `denyRead` paths for anthropic vs codex), this may require a mutex approach or separate singleton instances. Decision reserved for PR-B.
|
||||
|
||||
2. **`SandboxManager.reset()` in tests.** The singleton means test suites that call `initialize()` must call `reset()` in their `after()` hooks. PR-B must add this discipline or tests will leak sandbox state across suites.
|
||||
|
||||
3. **MITM proxy and Claude CLI cert pinning.** The sandbox-runtime network bridge on Linux uses a local MITM proxy to intercept HTTPS traffic. If `claude` CLI pins certificates (e.g., for `api.anthropic.com`), HTTPS through the bridge may fail. PR-B must empirically verify this on PI231 before merging.
|
||||
|
||||
4. **macOS `sandbox-exec` profile content.** macOS uses a seatbelt (SBPL) profile, not bwrap. The profile must explicitly allow `network outbound "api.anthropic.com"` etc. The default profile may be too restrictive for the Claude CLI's OAuth refresh calls. PR-B must test macOS as well as Linux.
|
||||
|
||||
5. **`getDefaultWritePaths()` output.** The library exports `getDefaultWritePaths()` which returns the paths the sandbox always allows writing to. OLP's spawn directory may not be in that list — PR-B must verify the working directory is writable or pass it explicitly in `filesystem.allowWrite`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Pitfalls inherited from the spike (binding warnings for PR-B/C authors)
|
||||
|
||||
These were confirmed empirically or inferred from the library source during the 2026-05-28 spike:
|
||||
|
||||
1. **Three OS deps, not one.** The npm package bundles nothing. Linux requires: `bubblewrap` (bwrap), `socat`, `ripgrep` (rg). All three. Missing even one → `checkDependencies()` returns errors → `wrapWithSandbox` will fail at runtime.
|
||||
|
||||
2. **Linux deny-paths are literal, not glob.** The library's `linuxGetMandatoryDenyPaths()` uses ripgrep to expand glob patterns to concrete paths before passing them to bwrap. But custom `filesystem.denyRead` entries that contain glob chars (`~/.ssh/*`) must be either expanded manually OR passed as the glob form (the library expands them if `rg` is available). The safe convention for PR-B: use absolute literal paths (e.g., `/home/<user>/.ssh`) rather than `~/`-prefixed or glob paths.
|
||||
|
||||
3. **`enableWeakerNestedSandbox: true` is required for codex.** Codex's `exec` subcommand spawns its own bubblewrap sandbox internally. Without `enableWeakerNestedSandbox`, the outer OLP sandbox blocks the inner codex sandbox from creating user namespaces. The flag loosens the outer sandbox's seccomp filter specifically to allow `clone(CLONE_NEWUSER)` — the inner sandbox then runs with reduced but non-zero isolation.
|
||||
|
||||
4. **`SandboxManager.reset()` is process-wide.** Calling `reset()` anywhere (including test teardown) clears the singleton config. Any concurrent in-flight spawn that still holds a reference to the old sandbox state will break. PR-B's design must either (a) initialize once at boot and never reset, or (b) use a mutex to prevent concurrent init/reset.
|
||||
|
||||
5. **MITM CA generation is async and expensive.** `SandboxManager.initialize()` generates a self-signed CA certificate for the MITM proxy on Linux. This takes ~100-500ms. Initialize at server startup, not per-request.
|
||||
|
||||
---
|
||||
|
||||
## 7. Authority citations
|
||||
|
||||
- **`@anthropic-ai/sandbox-runtime` v0.0.52** — https://github.com/anthropic-experimental/sandbox-runtime
|
||||
- `dist/sandbox/sandbox-manager.js` — `isSupportedPlatform()`, `checkDependencies()`, `SandboxManager` export shape
|
||||
- `dist/sandbox/linux-sandbox-utils.js` — `checkLinuxDependencies()`, `whichSync` usage, `enableWeakerNestedSandbox` rationale
|
||||
- `README.md` — installation prerequisites, platform support matrix
|
||||
|
||||
- **2026-05-28 PoC spike on PI231 (arm64 Debian Bookworm)** — report at `/tmp/sandbox-spike/report.md` on PI231. Key findings: dep install clean; `isSupportedPlatform()=true`; `checkDependencies()` errors on bwrap+socat+rg absence; three PoC scripts parked. Verdict YELLOW.
|
||||
|
||||
- **cc-mem incident memory 2026-05-27** — `~/.cc-rules/memory/projects/olp/incident_2026_05_27_spawn_cli_security.md` § 3 (gap description), § 4 (prior-art search showing ecosystem hasn't solved multi-tenant fs/tool isolation).
|
||||
|
||||
- **OLP ADR 0009 Amendment 1 § Caveats #3** — "Sandbox-runtime still required for real multi-tenant deployment. Per the 2026-05-27 session prior-art search, Anthropic's official multi-tenant answer is `@anthropic-ai/sandbox-runtime` (OS-level isolation)."
|
||||
|
||||
- **`docs/plans/cloud-deployment-family.md` § 5** — sandbox is a hard prerequisite before any cloud deployment.
|
||||
|
||||
- **OLP ALIGNMENT.md** — PR-A is library/doctor/governance; it does not touch provider plugins, the entry surface, or the IR. The authority citation for the npm dep is the official sandbox-runtime repo URL + the spike report (not a provider CLI, not the OpenAI spec, not an existing ADR — this is a new dependency decision, which is the correct scope for ADR 0014).
|
||||
|
||||
- **Iron Rule 11 (Incremental Diff Review)** — splits non-trivial work into the minimum reviewable unit. The 4-PR split (A/B/C/D) is the direct application of this rule to the sandbox integration: each PR is independently reviewable, independently safe to land or revert, and corresponds to one logical layer.
|
||||
|
||||
---
|
||||
|
||||
## 8. Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Multi-tenant isolation at the OS level.** After PR-B+C land, each provider spawn runs inside a bubblewrap (Linux) or sandbox-exec (macOS) boundary. A prompt-injected `cat ~/.olp/keys/...` hits a kernel-level deny. Cross-client filesystem leakage is structurally prevented, not just mitigated by prompt engineering.
|
||||
|
||||
- **Cloud deployment unblocked.** `docs/plans/cloud-deployment-family.md` § 5 cites sandbox as the hard prerequisite for moving from family-LAN to cloud. PR-D closes this gate.
|
||||
|
||||
- **Observability from day one.** The `/health.sandbox` field makes the install state machine-readable. Any monitoring script or dashboard can tell whether sandbox isolation is active without SSH access.
|
||||
|
||||
- **Anthropic's official library.** Using `@anthropic-ai/sandbox-runtime` rather than a home-grown bwrap wrapper means OLP inherits Anthropic's tested integration patterns (deny-path expansion, MITM proxy, seccomp, macOS seatbelt profiles) rather than reinventing them. When the library updates, OLP upgrades via `npm update`.
|
||||
|
||||
### Negative
|
||||
|
||||
- **Three new OS-level dependencies.** `bubblewrap`, `socat`, and `ripgrep` must be installed on every host running OLP with sandbox isolation active. Absent these deps, sandbox is unavailable (but OLP continues to function without isolation — degraded security, not degraded functionality). The `/health.sandbox.available` field makes this state explicit.
|
||||
|
||||
- **p95 latency overhead.** The spike did not measure sandbox wrapping overhead directly (blocked on apt install). Expected overhead per the sandbox-runtime README: ~100-200ms for sandbox initialization amortized over the process lifetime (one-time at startup); per-spawn overhead is the namespace clone + filesystem mount overhead, typically <50ms on modern kernels. PR-B's acceptance criteria gates on ≤200ms p95 overhead over 50 warm requests.
|
||||
|
||||
- **Codex inner-sandbox degradation.** `enableWeakerNestedSandbox: true` loosens the outer OLP sandbox's seccomp filter to allow `clone(CLONE_NEWUSER)`. The codex inner sandbox still runs with meaningful isolation (its own namespace, its own deny-list), but the combined depth of protection is less than ideal compared to a world where codex didn't self-sandbox.
|
||||
|
||||
- **Library is experimental.** The `anthropic-experimental` org signals this is not a production-stable API. The version pin (`^0.0.52`) provides a minor-range buffer but the API surface may change. If the library is deprecated or the API breaks, OLP's fallback is to remove the sandbox wrapping (reverting PRs B-D) until a replacement path is found. This is acceptable at family scale — security degradation is not a service outage.
|
||||
|
||||
### Reversibility
|
||||
|
||||
- **PR-A** is trivially reversible: `npm uninstall @anthropic-ai/sandbox-runtime` + delete `lib/sandbox/doctor.mjs` + revert server.mjs and CHANGELOG changes. No production behavior changes.
|
||||
- **PR-B/C** are reversible by removing the `SandboxManager.wrapWithSandbox` call from each provider's `spawn()` method. The spawn falls back to the current unsandboxed path.
|
||||
- **PR-D** is a documentation update; reverting it is a docs-only change.
|
||||
|
||||
---
|
||||
|
||||
## Status transitions
|
||||
|
||||
- 2026-05-28 — Created. Status: Accepted for PR-A scope. PR-B/C/D pending operational prereqs.
|
||||
- 2026-05-28 — PR-A shipped (commit `07d9c8a`).
|
||||
- 2026-05-28 — PR-B implementation shipped (commit chain `d0dcd28` → `2864275` → `497b255` → `b1e24b7` → `3551921`). Status: shipped pending PI231 Suite 44 validation + HTTP-path activation debug. `OLP_SANDBOX_DISABLED=1` emergency disable installed (b1e24b7) because the in-process MITM proxy interaction with OLP's HTTP request handler suppressed claude stdout on the HTTP path while the same wrap script produced output when invoked directly from a manual shell. Prod is currently running with `OLP_SANDBOX_DISABLED=1` set.
|
||||
- 2026-05-29 — **Amendment 1 — supersede PR-B outer-bwrap with ephemeral-home + per-provider ISOLATION contract.** See § Amendment 1 below. PR-B's `lib/sandbox/manager.mjs` outer-bwrap implementation is archived to branch `phase-7-pr-b-outer-bwrap-snapshot` and superseded; PR-C is reframed as inner-sandbox preservation under the new architecture; PR-D is reframed as the README "Security Model" section. `lib/sandbox/doctor.mjs` is preserved unchanged.
|
||||
|
||||
---
|
||||
|
||||
# Amendment 1 — Supersede PR-B outer-bwrap with ephemeral-home + per-provider contract (2026-05-29)
|
||||
|
||||
- **Date:** 2026-05-29
|
||||
- **Status:** Accepted (governance only — the implementation refactor lands in subsequent PRs per ALIGNMENT.md Rule 1 / Iron Rule 11)
|
||||
- **Author:** project maintainer (with AI drafting assistance)
|
||||
- **Reviewer:** independent fresh-context reviewer per Iron Rule 10 — pending at this draft
|
||||
- **Scope:** This amendment supersedes the implementation strategy of PR-B (the outer-bubblewrap-wrap of `claude` CLI shipped in commits `d0dcd28` → `b1e24b7`). It does NOT supersede the multi-tenant security gap analysis in § 1 of the original ADR, nor the four-tier authority citation list (§ 7), nor `lib/sandbox/doctor.mjs` (preserved unchanged). It DOES supersede the PR-B implementation, the PR-C scope ("wrap codex spawn in the same outer-bwrap pattern with `enableWeakerNestedSandbox`"), and PR-D's framing as "documentation update for cloud rollout unblock".
|
||||
|
||||
---
|
||||
|
||||
## A1.1 — Why the substitution is forced (the four forcing reasons)
|
||||
|
||||
PR-B as designed (outer-bwrap wrapping of the `claude` CLI spawn, with the OLP server initializing `SandboxManager` once at boot and every spawn routed through `wrapWithSandbox`) was shipped on 2026-05-28 and disabled on the same day via the `OLP_SANDBOX_DISABLED=1` env-var gate after the HTTP-path activation regression appeared on PI231 (the manual-shell wrap produced claude stdout; the OLP HTTP-request-handler wrap produced none). The 2026-05-28/2026-05-29 follow-up investigation found that the HTTP-path failure was not the whole story — even if the in-process MITM proxy lifecycle issue were debugged, four independent and load-bearing reasons forced the architecture away from outer-bwrap entirely. Each is cited to its primary authority below.
|
||||
|
||||
### A1.1.1 — Forcing reason #1: Anthropic's stated design intent for `@anthropic-ai/sandbox-runtime`
|
||||
|
||||
The PR-B design used `@anthropic-ai/sandbox-runtime` to wrap the `claude` CLI from the outside. Anthropic's published design intent for the library is the opposite direction of containment: the library is for sandboxing what Claude Code itself *triggers* (tool calls, MCP servers, sub-processes spawned during model execution), not for wrapping Claude Code from outside.
|
||||
|
||||
**Primary citation:** https://www.anthropic.com/engineering/claude-code-sandboxing — "Claude Code sandboxing" engineering blog. The post describes Claude Code's *internal* use of the sandbox-runtime library: the model emits a `tool_use` Bash call → Claude Code wraps the resulting `/bin/sh -c <…>` in a sandbox via `SandboxManager.wrapWithSandbox()` before spawning. The blog also notes the library "can be used to sandbox arbitrary processes, agents and MCP servers" — i.e., it is general-purpose, not Claude-Code-internal-only. **Our reading:** Anthropic's documented and demonstrated usage is *inner-wrap by Claude Code*; outer-wrap of `claude` itself is not documented in the blog and not shown in the post's example invocations. **This is a project-design judgment based on the absence of outer-wrap precedent, not a "don't do this" statement from Anthropic.** The architectural concerns enumerated below (MITM proxy lifecycle, semver leverage) stand on their own merits regardless of how Anthropic frames the library's intended usage.
|
||||
|
||||
**What this means for PR-B's design:** wrapping `claude` from outside with the same library is "out-of-distribution" usage. The library was not designed for, tested against, or documented for the outer-wrap case. Two concrete consequences observed in PR-B:
|
||||
|
||||
1. **MITM proxy collision.** The library starts a per-process local MITM proxy on Linux to inspect HTTPS traffic for allowlisted domains. When the same library is invoked again from inside the sandboxed process (e.g., for any sub-spawn `claude` might do), a second MITM proxy attempt collides. The PR-B implementation never reached this case because it disabled before tripping it, but the architecture invites the collision.
|
||||
2. **Inner-sandbox conflict** (see A1.1.3 for codex, but the principle applies generally). Any CLI that itself uses the same library to sandbox its own tool calls is *expected* by Anthropic to be the *holder* of the sandbox, not the *content* of one. The library's `enableWeakerNestedSandbox` option exists precisely to acknowledge this — but only as a partial mitigation.
|
||||
|
||||
The Anthropic design-intent reason is not a "won't work" reason. The PR-B outer-bwrap path did work for the smoke case (manual-shell invocation produced output). The reason is a *don't-do-this* reason: OLP would be the only known user of the library in the outer-wrap configuration, taking on the maintenance burden of a usage pattern Anthropic doesn't test, doesn't document, and doesn't owe semver discipline for. The library is `^0.0.52`. A future minor version bump could break OLP's outer-wrap path without warning. Aligning OLP's use of the library with Anthropic's documented design intent restores semver leverage.
|
||||
|
||||
### A1.1.2 — Forcing reason #2: The `~/.claude.json` upstream "closed as not planned" — permanent maintenance treadmill for outer-bwrap
|
||||
|
||||
Anthropic's `claude` CLI persists session state (most notably MRU project list, last-used model, telemetry session id) to `~/.claude.json` using a **non-atomic** write pattern: write-in-place rather than write-temp-then-rename. When `~/.claude.json` is on a read-only mount (e.g., `--ro-bind ~/.claude` in a bwrap config), the CLI either silently degrades (some state lost) or outright fails depending on which write path is hit on a given invocation.
|
||||
|
||||
The OLP outer-bwrap path in PR-B mounted `~/.claude` read-only initially, hit this exact issue (the 2026-05-28 fold-in commit `2864275` "allow read ~/.claude + skip wrap under test mock" added the read allowance), and then on the further `b1e24b7` commit the operator had to widen the mount again. The fold-in commit message explicitly notes: "~/.claude is NOT in denyRead. It contains the spawn's own OAuth credentials — claude CLI must read its own auth file to function. Denying read here causes 'Not logged in' failures."
|
||||
|
||||
**Primary citation:** https://github.com/anthropics/claude-code/issues/29250 — upstream issue requesting atomic-write semantics for `~/.claude.json`. Status: closed `not_planned` by GitHub's inactivity bot on 2026-03-31 (`closed_by: github-actions[bot]`, comment text: "Closing for now — inactive for too long"), labeled `duplicate` and `stale`. **No upstream Anthropic maintainer comment in the thread articulates any policy rationale** — the only non-bot comments are the reporter's own follow-ups and a third-party-tool plug. The closure does NOT establish "won't fix" as Anthropic policy; at most it establishes that the issue has not received maintainer engagement and that Anthropic considers it a duplicate of some other tracked report. The forcing argument for ephemeral-home over outer-bwrap rests on the architectural-cost analysis (the maintenance-treadmill description below), not on an alleged upstream policy posture.
|
||||
|
||||
**What this means for the outer-wrap maintenance treadmill:** Every future addition of `claude`-CLI-owned state files (telemetry, cache directories, session locks, MCP registration files, etc.) is, by upstream policy, free to use any write pattern the maintainers prefer. The outer-bwrap pattern requires OLP to track each of these additions and add corresponding `--ro-bind` / `--rw-bind` / write-allowlist entries — forever — because the CLI does not give OLP an enumerable contract surface for "files I will write to." The maintainer-time cost is a permanent recurring tax.
|
||||
|
||||
A non-outer-wrap approach that gives `claude` a fresh, ephemeral home directory inverts this: `claude` is free to invent any state file under its $HOME with any write pattern it chooses; OLP never tracks the list. The treadmill goes away. This is the load-bearing case for Solution 1 even setting aside the codex inner-sandbox issue below.
|
||||
|
||||
### A1.1.3 — Forcing reason #3: Codex inner-bwrap conflict (multi-provider forcing function)
|
||||
|
||||
The PR-C plan in the original ADR was to wrap the `codex` spawn in the same outer-bwrap pattern as PR-B, with `enableWeakerNestedSandbox: true` set on the `SandboxManager.initialize()` call to allow codex's own internal bubblewrap sandbox to function inside OLP's outer bubblewrap sandbox.
|
||||
|
||||
Empirical investigation (2026-05-29 PI231 prep — to be confirmed in Task #4) and published codex CLI behaviour both indicate this nested-sandbox path is structurally fragile:
|
||||
|
||||
**Primary citation:** https://github.com/openai/codex/issues/16018 — upstream codex CLI issue. The issue body documents that codex's bwrap-based default sandbox **fails outright** in environments lacking unprivileged user namespaces — the reporter quotes the error `bwrap: No permissions to create new namespace, likely because the kernel does not allow non-privileged user namespaces`. The issue is a **feature request by the reporter** asking codex to "suggest or automatically fall back to an alternative supported backend when available"; **the issue body itself does NOT contain the string `danger-full-access` and does NOT document an existing automatic fallback to it**. The codex `--sandbox danger-full-access` mode is a documented *manual* opt-out (https://developers.openai.com/codex/concepts/sandboxing § "Sandboxing modes"). Whether codex automatically degrades into it under nested-bwrap failure — or whether the spawn aborts outright — is an empirical question slated for Task #4 PI231 spike verification.
|
||||
|
||||
In other words: wrapping codex in OLP's outer bwrap, *if* the outer bwrap is configured with sufficient capability to allow the inner clone, requires giving the outer sandbox more capability than the security boundary should grant. *If* it is configured to a tighter, safer capability set, codex's inner-bwrap initialization fails (the documented failure mode per the linked issue). Whether codex then aborts the spawn or silently degrades to `danger-full-access` is empirically open (Task #4); either outcome is undesirable. The strict-additive-isolation invariant (outer-bwrap + inner-bwrap = composed isolation) does not hold for codex under this configuration: either OLP gives up outer-isolation strength to admit the inner clone, or codex's inner isolation breaks in some manner.
|
||||
|
||||
**What this means as a multi-provider forcing function:** OLP is by constitution (ADR 0001 § Mission) a multi-provider proxy. The outer-bwrap architecture cannot cover codex without a security regression. The structural response is to abandon outer-bwrap as the foundational architecture and adopt a strategy that is *compatible* with each provider's own native isolation (claude's lack of inner sandbox vs codex's `--sandbox read-only` inner sandbox). This is what Solution 1 does — see A1.2 below.
|
||||
|
||||
### A1.1.4 — Forcing reason #4: `CODEX_HOME` exists and is the documented relocation lever
|
||||
|
||||
The "ephemeral home directory per spawn" component of Solution 1 (A1.2 Layer 1) only works if each provider CLI offers a documented mechanism for relocating its state directory away from the default `$HOME` location. For `claude`, the standard `HOME` env var works (the CLI reads `~/.claude` as `$HOME/.claude`, and changing `HOME` relocates the lookup). For `codex`, the equivalent lever is the `CODEX_HOME` env var.
|
||||
|
||||
**Primary citation:**
|
||||
- https://developers.openai.com/codex/config-reference — OpenAI's published codex CLI configuration reference. The page documents `CODEX_HOME` in 2 places (verified by independent fetch 2026-05-29): as the root of the per-profile config path (`$CODEX_HOME/profile-name.config.toml`) and as the default log directory base (`$CODEX_HOME/log`). The variable is the documented relocation lever for the codex state, configuration, and authentication directory away from the default `~/.codex`.
|
||||
- Secondary corroboration:
|
||||
- https://developers.openai.com/codex/auth/ — OpenAI's published codex CLI authentication reference. The page documents `CODEX_HOME` in 2 places (verified by independent fetch 2026-05-29), both in the credential-storage section: "file stores credentials in `auth.json` under `CODEX_HOME` (defaults to `~/.codex`)." Confirms `CODEX_HOME` is the credential-directory base.
|
||||
- https://codex.danielvaughan.com/2026/04/08/codex-cli-configuration-reference/ — third-party reference page that mirrors the documented behaviour, used as cross-reference for the reachability check.
|
||||
|
||||
**What this means for Solution 1 feasibility:** All three Tier-D providers have a documented one-env-var relocation lever:
|
||||
- claude via `HOME` (POSIX convention)
|
||||
- codex via `CODEX_HOME` (citations above)
|
||||
- mistral via `VIBE_HOME` per https://docs.mistral.ai/mistral-vibe/terminal/configuration (3 occurrences verified 2026-05-29, including the canonical `export VIBE_HOME="/path/to/custom/vibe/home"` example and an enumeration of files/directories `VIBE_HOME` affects).
|
||||
|
||||
The ephemeral-home approach is implementable today; it does not require upstream changes from any of Anthropic, OpenAI, or Mistral. Task #4 PI231 spike verifies *observed CLI behaviour* matches *documented behaviour* for each provider — this is verification-grade follow-up, not authority-pin work.
|
||||
|
||||
---
|
||||
|
||||
## A1.2 — The substitute architecture: per-spawn ephemeral home + per-provider ISOLATION contract
|
||||
|
||||
The new architecture is layered. Each layer addresses a distinct attack surface, and each layer is independently reasoned about, independently reviewable, and independently revertible. The four layers, in order of containment depth:
|
||||
|
||||
### A1.2.1 — Layer 1: Per-spawn ephemeral home directory
|
||||
|
||||
Every uncached `/v1/chat/completions` request (per-`keyId`, per-`reqId`) provisions a fresh ephemeral home directory at `/tmp/olp-spawn/<keyId>/<reqId>/home/`. The directory is created on the spawn path and torn down (best-effort) on response completion. The spawn process gets this directory passed in via a per-provider env-var override:
|
||||
|
||||
- **anthropic** (`claude` CLI): `HOME=/tmp/olp-spawn/<keyId>/<reqId>/home`. The CLI's `~/.claude.json` and `~/.claude/` state writes go to the ephemeral location. No cross-request, no cross-tenant carry-over.
|
||||
- **openai** (`codex` CLI): `CODEX_HOME=/tmp/olp-spawn/<keyId>/<reqId>/home/.codex`. Codex's `~/.codex` state, auth artifacts, and config files go to the ephemeral location.
|
||||
- **mistral** (`vibe` CLI): `VIBE_HOME=/tmp/olp-spawn/<keyId>/<reqId>/home/.vibe` per https://docs.mistral.ai/mistral-vibe/terminal/configuration (documented env var, 3 occurrences verified at amendment time). Vibe's `~/.vibe/` state — `.env`, `agents/`, `prompts/`, `skills/`, `tools/`, `config.toml` — goes to the ephemeral location. Task #4 PI231 spike verifies observed CLI behaviour matches the documented contract.
|
||||
|
||||
Layer 1 provides:
|
||||
- **No cross-tenant state carry-over** at the filesystem level. Two clients invoking anthropic concurrently get two separate `$HOME` directories; the CLI cannot read the other's `~/.claude.json`, recent-projects list, or session state.
|
||||
- **No accumulation of stale state** across requests. The MRU project list does not grow without bound. The telemetry session id is fresh per request.
|
||||
- **No outer-wrap maintenance treadmill.** When `claude` invents a new state file under `~/.claude.foo.json` next quarter, OLP does not need to update a `--ro-bind` list. The new file lives in the ephemeral home and goes away with the request.
|
||||
|
||||
What Layer 1 does NOT provide:
|
||||
- It does not protect against the CLI walking *out of* its $HOME to read other paths (e.g., a model emitting a `Read` tool call on `/etc/passwd` or `~/.ssh/id_rsa`). For that protection, Layers 3 and 4 are needed.
|
||||
|
||||
### A1.2.2 — Layer 2: Symlinked credential files into the ephemeral home
|
||||
|
||||
A fresh `$HOME` is empty. The CLI needs its OAuth credentials, API key, or equivalent auth artifact to function. Layer 2 provisions these by reading the operator-pinned credential location and symlinking the relevant file(s) into the ephemeral home at the location the CLI expects.
|
||||
|
||||
Each provider plugin declares its credential paths in the ISOLATION block (see ADR 0002 Amendment pending). The runtime spawn pipeline reads this declaration, walks the list, and symlinks each entry from its real location (under the operator's real `$HOME`) into the ephemeral home. The symlinks are file-level, not directory-level, so the CLI sees its credential file but does not see the rest of the operator's `~/.claude/` or `~/.codex/` tree.
|
||||
|
||||
Example (anthropic):
|
||||
- Real: `~/.claude/.credentials.json` (operator's actual OAuth credential)
|
||||
- Ephemeral: `/tmp/olp-spawn/<keyId>/<reqId>/home/.claude/.credentials.json` (symlink → real)
|
||||
|
||||
Example (codex):
|
||||
- Real: `~/.codex/auth.json`
|
||||
- Ephemeral: `/tmp/olp-spawn/<keyId>/<reqId>/home/.codex/auth.json` (symlink → real)
|
||||
|
||||
Layer 2 provides:
|
||||
- **Credential availability** without granting visibility into other state under the same provider directory.
|
||||
- **A narrow declared surface.** The provider plugin enumerates exactly which files matter. New CLI state files that are not declared do not get symlinked, and the CLI re-initializes them in the ephemeral home (which is exactly the Layer 1 behaviour).
|
||||
|
||||
What Layer 2 does NOT provide:
|
||||
- It does not protect against the CLI walking out of its $HOME (see Layer 3).
|
||||
- It does not protect against the CLI's tool-use surface reading the symlink target's *containing directory* if the model emits a `Read` tool call with an absolute path that resolves around the symlink. For that, Layer 3 + Layer 4.
|
||||
|
||||
### A1.2.3 — Layer 3: Optional `sandbox-runtime` per-call `customConfig` for non-$HOME read protection
|
||||
|
||||
For providers whose own inner sandbox does NOT exist or does not cover the OLP threat model (the `claude` CLI today is the leading example — claude has no inner sandbox; codex has `--sandbox read-only` by default but the protection scope differs), Layer 3 wraps the spawn in `@anthropic-ai/sandbox-runtime`'s `SandboxManager.wrapWithSandbox()` *per-call* with a `customConfig` argument tailored to the per-spawn ephemeral home.
|
||||
|
||||
The key architectural difference vs PR-B's outer-wrap:
|
||||
- PR-B initialized `SandboxManager` once at server boot with a *global* config covering all providers.
|
||||
- Layer 3 calls `wrapWithSandbox()` *per spawn* with a *per-spawn* `customConfig` that names the ephemeral home as the allow-read root.
|
||||
|
||||
The per-call `customConfig` shape:
|
||||
|
||||
```javascript
|
||||
{
|
||||
network: { allowedDomains: provider.ISOLATION.allowedDomains },
|
||||
filesystem: {
|
||||
denyRead: [
|
||||
// Operator's real $HOME — sandbox cannot read OTHER clients' OLP keys,
|
||||
// operator's SSH identity, other providers' tokens, etc.
|
||||
operatorHome,
|
||||
// Operator's known sensitive directories (defensive even though they
|
||||
// are already under operatorHome) — declared so a future refactor that
|
||||
// moves the operator home does not regress this protection.
|
||||
`${operatorHome}/.ssh`,
|
||||
`${operatorHome}/.gnupg`,
|
||||
`${operatorHome}/.olp`,
|
||||
],
|
||||
// Layer 1 ephemeral home is the allow-read root for this spawn.
|
||||
// Layer 2 symlinked credentials live inside, so credential access works.
|
||||
allowRead: [ephemeralHomeForThisSpawn],
|
||||
allowWrite: [ephemeralHomeForThisSpawn, '/tmp'],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Layer 3 is invoked **only when** the provider's `ISOLATION.hasInnerSandbox === false`. For providers with their own inner sandbox (codex via `--sandbox read-only`), Layer 3 is skipped to avoid the nested-sandbox conflict (A1.1.3).
|
||||
|
||||
Layer 3 provides:
|
||||
- **OS-level deny of reads outside the ephemeral home and OLP-permitted paths.** A prompt-injected `cat /home/<operator>/.olp/keys/owner-key.json` or `cat /home/<operator>/.ssh/id_ed25519` hits a syscall-level deny.
|
||||
- **Per-spawn (not per-process) configuration.** Each request gets a fresh sandbox scope. Two concurrent spawns do not share a sandbox; the MITM-proxy collision and singleton-config-mutation hazards from PR-B disappear.
|
||||
|
||||
What Layer 3 does NOT provide:
|
||||
- It does not protect against the CLI's *own* tool-use surface emitting destructive shell commands within the allowed write zones. For that, Layer 4.
|
||||
- Per-call `wrapWithSandbox()` has higher per-request latency than PR-B's once-at-boot pattern. The amortization budget is recovered by Layer 1's $HOME-as-cwd discipline keeping the sandbox config small and by ripgrep-based glob expansion being avoided (Layer 3 uses absolute literal paths throughout).
|
||||
|
||||
### A1.2.4 — Layer 4: Provider-specific tool hardening already in place
|
||||
|
||||
This is already-shipped work, re-affirmed here as part of the layered model:
|
||||
|
||||
- **anthropic Phase 6c `--system-prompt`** (commits `97e7d16` + fold-in `65f945c`). The system prompt is fully replaced at every spawn, suppressing the default tool descriptions that Claude Code would otherwise inject. Without tool descriptions, the model is highly unlikely to emit `tool_use` for `Bash`, `Read`, etc. even under prompt injection. See cc-mem `~/.cc-rules/memory/projects/olp/incident_2026_05_27_spawn_cli_security.md` § 5.
|
||||
- **codex `--sandbox read-only` default.** OLP's codex provider spawn passes `--sandbox read-only` as a fixed flag. Codex's own inner sandbox provides read-only-by-default tool isolation. The provider's ISOLATION block declares `hasInnerSandbox: true` so Layer 3 is correctly skipped.
|
||||
- **mistral.** TBD per Task #4 — the mistral provider's tool surface and inner-sandbox status need to be characterized.
|
||||
|
||||
Layer 4 provides:
|
||||
- **Reduction of the *probability* of tool emission.** Layer 4 does not depend on OS-level enforcement; it works at the prompt layer. It is the cheap, fast, first-line defense. Layers 1–3 are the structural fallback when prompt-layer defenses are bypassed.
|
||||
|
||||
---
|
||||
|
||||
## A1.3 — The provider ISOLATION contract (named here; specified in ADR 0002 Amendment N)
|
||||
|
||||
Each provider plugin declares an `ISOLATION` block on its module export. The fields are:
|
||||
|
||||
| Field | Type | Meaning |
|
||||
|---|---|---|
|
||||
| `ephemeralEnvOverrides` | `(spawnCtx) => Record<string, string>` | Returns the env-var map to set for this spawn, given the spawn context (ephemeral home path, keyId, reqId). For anthropic: `{ HOME: spawnCtx.ephemeralHome }`. For codex: `{ CODEX_HOME: spawnCtx.ephemeralHome + '/.codex' }`. |
|
||||
| `credentialMounts` | `{ realPath: string, ephemeralPath: string }[]` | List of credential files to symlink from real → ephemeral. For anthropic: `[{ realPath: '~/.claude/.credentials.json', ephemeralPath: '.claude/.credentials.json' }]`. Provider declares; runtime symlinks. |
|
||||
| `hasInnerSandbox` | `boolean` | If true, Layer 3 is skipped to avoid nested-sandbox conflict. codex: true. anthropic: false. |
|
||||
| `crossTenantReadProtection` | `'tool-suppression' \| 'inner-sandbox' \| 'none'` | Self-declared label for what layer is providing the read-protection. Used by `/health.sandbox` to report the protection posture per provider. **The canonical enum is defined in ADR 0002 Amendment 9 § 5; this row mirrors it.** |
|
||||
| `recommendedDeploymentTier` | `'shared-os-user' \| 'per-os-user' \| 'separate-vm'` | Deployment tier the provider's current isolation posture is rated for. ADR 0006 risk-tier integration. **The canonical enum is defined in ADR 0002 Amendment 9 § 6; this row mirrors it.** |
|
||||
|
||||
**This amendment names the contract but does NOT specify its full validation, lifecycle, or test discipline.** Those land in **ADR 0002 Amendment (pending)** — the Provider contract amendment that ratifies `ISOLATION` as a required field, defines `validateProvider`'s checks on it, and documents how `lib/providers/base.mjs` enforces declaration. Until that ADR amendment lands, the ISOLATION block is a forward-looking contract; the implementation refactor (Tasks #5–#8) is gated on the ADR 0002 amendment landing first.
|
||||
|
||||
Cross-reference: see ADR 0002 § Amendments for the pending Amendment N that codifies the ISOLATION block contract.
|
||||
|
||||
---
|
||||
|
||||
## A1.4 — Revised PR plan
|
||||
|
||||
The original ADR's four-PR split (PR-A / PR-B / PR-C / PR-D) is restated as follows. PR-A is unchanged from its as-shipped state.
|
||||
|
||||
| PR | Original scope | Amendment 1 scope | Status |
|
||||
|---|---|---|---|
|
||||
| **PR-A** | npm dep + `lib/sandbox/doctor.mjs` + `/health.sandbox` | **Unchanged.** Doctor preserved; `/health.sandbox` field preserved. | ✅ Shipped (commit `07d9c8a`) |
|
||||
| **PR-B** | Outer-bwrap wrap of anthropic spawn at boot-singleton level | **Superseded by Amendment 1.** Implementation archived to branch `phase-7-pr-b-outer-bwrap-snapshot`. New scope: refactor `lib/sandbox/manager.mjs` to the Layer 1 + Layer 2 + Layer 3 architecture (Tasks #5, #8). | ⛔ Superseded |
|
||||
| **PR-C** | Outer-bwrap wrap of codex spawn with `enableWeakerNestedSandbox: true` | **Superseded by Amendment 1.** Codex isolation now flows via Layer 1 ephemeral `CODEX_HOME` + Layer 4 `--sandbox read-only`. Layer 3 deliberately skipped (`hasInnerSandbox: true`). Codex-specific PR (Task #7) lands the ISOLATION block declaration; no outer-wrap. | ⛔ Superseded |
|
||||
| **PR-D** | Documentation update for cloud rollout unblock | **Reframed.** New scope: README "Security Model" section documenting the four-layer architecture, the deployment-tier mapping, and what the operator gets vs does not get at each tier. Task #10. | ♻ Reframed |
|
||||
|
||||
The new effective PR-list:
|
||||
|
||||
- **PR-B' (Refactor):** `lib/sandbox/manager.mjs` rewritten to expose `prepareIsolatedEnvironment(spawnCtx)` (Layer 1 + Layer 2) and `maybeWrapForReadProtection(spawnCtx, command)` (Layer 3 conditional). The `OLP_SANDBOX_DISABLED=1` env-var gate is preserved for 1-2 releases as belt-and-suspenders, then removed. Singleton bootstrap pattern is removed (per-spawn config eliminates the singleton's reason to exist).
|
||||
- **PR-C' (Wiring + Anthropic ISOLATION):** `server.mjs` calls `prepareIsolatedEnvironment` on the spawn path; `lib/providers/anthropic.mjs` declares its ISOLATION block (Task #6); negative-test confirmation via Task #9 PI231 E2E.
|
||||
- **PR-D' (Codex ISOLATION):** `lib/providers/codex.mjs` declares its ISOLATION block (Task #7); `hasInnerSandbox: true` skips Layer 3; codex inner sandbox preserved unmolested. Verified on PI231 (Task #9).
|
||||
- **PR-E' (README + Phase 7 close):** README "Security Model" section (Task #10) + `docs/plans/cloud-deployment-family.md` § 5 update + Phase 7 close per `CLAUDE.md release_kit.phase_rolling_mode`.
|
||||
|
||||
The original PR sequence's load-bearing security gate (the negative test "in-sandbox `cat ~/.olp/keys/...` MUST fail") remains the acceptance criterion for the security-bearing PRs in the new sequence. The test itself transfers; only the wrap mechanism changes.
|
||||
|
||||
---
|
||||
|
||||
## A1.5 — What survives from PR-B (preserved)
|
||||
|
||||
The following artifacts from the original PR-B implementation are preserved through Amendment 1:
|
||||
|
||||
1. **`lib/sandbox/doctor.mjs` — preserved unchanged.** Pure preflight is still useful: it tells the operator whether the npm package is installed, whether the OS deps are present, and whether the platform is supported. Even though the architecture no longer relies on a boot-time `SandboxManager.initialize()`, the `/health.sandbox` field consumers (dashboard, monitoring scripts) expect a stable shape. Doctor stays.
|
||||
2. **`/health.sandbox` field — preserved.** Shape adjusts slightly: the `active` boolean shifts meaning from "SandboxManager.initialize() succeeded" (PR-B) to "Layer 3 is operational for at least one provider whose `ISOLATION.hasInnerSandbox === false`" (Amendment 1). The field's name and JSON path stay the same so downstream consumers (dashboard, Hermes self-check, monitoring) do not break. The per-provider isolation posture is exposed via a new `/health.sandbox.providers[<name>].crossTenantReadProtection` subfield sourced from each ISOLATION block.
|
||||
3. **`@anthropic-ai/sandbox-runtime` npm dependency — preserved.** Layer 3 still uses the library, but via per-call `wrapWithSandbox()` with `customConfig`, not via a once-at-boot `SandboxManager.initialize()`. The dependency line in `package.json` stays.
|
||||
4. **The four authority citations in original § 7 — preserved.** The library URL, the spike report URL, the cc-mem incident URL, and the cloud deployment plan URL are unchanged. Amendment 1 *adds* the four new primary citations enumerated in § A1.1 above.
|
||||
5. **The `OLP_SANDBOX_DISABLED=1` env-var gate — preserved for 1-2 releases, then removed.** Documented in A1.6 below.
|
||||
|
||||
---
|
||||
|
||||
## A1.6 — What disappears from PR-B (superseded)
|
||||
|
||||
The following artifacts are removed by the PR-B' refactor (Task #5):
|
||||
|
||||
1. **Outer-bwrap wrapping of the `claude` spawn.** The bwrap wrap goes away. `claude` runs directly (without bwrap shell-wrap) with its `HOME` set to the ephemeral location. Layer 3 wraps the *sub-spawn* shell when it is invoked, not the `claude` process itself.
|
||||
2. **EROFS-driven mount patches.** The fold-in commit `2864275` ("allow read ~/.claude") and the subsequent `~/.claude` rw promotion (Task #5 was filed against this) were both consequences of trying to outer-bwrap a CLI that writes non-atomically to its `$HOME`. Solution 1 gives the CLI its own fresh `$HOME` and the entire mount-patch problem disappears. Task #5 ("allowWrite ~/.claude rw promotion fix") is closed as obsolete by this amendment.
|
||||
3. **Boot-time `SandboxManager.initialize()` call.** Removed entirely. The library is loaded lazily per-spawn (with import memoization for performance — the import itself is cached after the first call; only the `wrapWithSandbox()` call is per-spawn).
|
||||
4. **The singleton config-at-boot pattern.** Removed. The `_initConfig`, `_active`, `_initialized` module-level variables in `lib/sandbox/manager.mjs` no longer represent a global sandbox state; the only module-level state retained is the import cache for the library.
|
||||
5. **The MITM proxy CA cert generated once at boot.** Per-call `wrapWithSandbox()` may regenerate per call (TBD on library v0.0.52 behaviour — Task #4 verifies). If per-call regeneration is too expensive, an alternative is a per-process MITM CA cached at first-use; the implementation detail is reserved to PR-B'.
|
||||
6. **The `enableWeakerNestedSandbox: true` flag plan.** Removed. Codex isolation does not run inside an OLP outer sandbox at all. `enableWeakerNestedSandbox` is irrelevant to Amendment 1's architecture.
|
||||
|
||||
### A1.6.1 — The `OLP_SANDBOX_DISABLED=1` env-var gate
|
||||
|
||||
The env-var gate added in commit `b1e24b7` ("add OLP_SANDBOX_DISABLED=1 env-var emergency disable") is preserved through the Amendment 1 refactor as belt-and-suspenders. Its semantics under Amendment 1:
|
||||
|
||||
- **PR-B world (current main, with the gate set in prod):** the gate skips `SandboxManager.initialize()` at boot. Prod is currently running with the gate set, which means PR-B's outer-bwrap path is not active — Layer 3 protection is also not active.
|
||||
- **Amendment 1 world (after PR-B' lands):** the gate skips Layer 3's per-call `wrapWithSandbox()` and reverts each spawn to a Layer 1 + Layer 2 + Layer 4 configuration. The CLI still gets an ephemeral `$HOME` with symlinked credentials, still gets the `--system-prompt` tool-description suppression for anthropic, still gets `--sandbox read-only` for codex. What is given up is the OS-level deny of reads outside the ephemeral home. This is a *meaningful* but not *catastrophic* degradation — the prompt-layer defense remains, and Layer 1's $HOME isolation still prevents the most common cross-tenant accident path.
|
||||
- **Sunset:** the gate is preserved for **1-2 releases** after PR-B' ships to give the operator a fast escape hatch if the Layer 3 per-call wrap regresses in production. After two clean releases with no operator escalation, the gate is removed in a subsequent ADR amendment or a clean PR citing this section as authority for the removal.
|
||||
|
||||
The gate's behaviour is documented in README's Security Model section per PR-D' (Task #10).
|
||||
|
||||
---
|
||||
|
||||
## A1.7 — Reversibility
|
||||
|
||||
Amendment 1 is reversible at the implementation layer:
|
||||
|
||||
- **PR-B' refactor** is reversible by `git revert` of the refactor commit + restoring the snapshot from `phase-7-pr-b-outer-bwrap-snapshot`. The archive branch is pushed and persistent at:
|
||||
https://github.com/dtzp555-max/olp/tree/phase-7-pr-b-outer-bwrap-snapshot
|
||||
- **The `@anthropic-ai/sandbox-runtime` dependency** stays in `package.json`, so reverting does not require an `npm install`.
|
||||
- **The `lib/sandbox/doctor.mjs` module** is unchanged across the refactor, so reverting does not affect `/health.sandbox` shape.
|
||||
|
||||
Amendment 1 itself, as a governance artifact, is reversible by a subsequent superseding amendment if the empirical foundation it rests on changes (e.g., if Anthropic publishes guidance endorsing outer-wrap use of `sandbox-runtime` and adds a contract for `~/.claude.json` write paths). ALIGNMENT.md § "Amendment Procedure" applies: such a future amendment would need to cite the new evidence.
|
||||
|
||||
The archive-branch retention policy: the snapshot branch is kept indefinitely (no auto-delete) so a future maintainer investigating outer-bwrap-around-CLI as an architecture has a working reference point. The branch's HEAD commit matches commit `b1e24b7` (the last commit of the outer-bwrap implementation before the architecture pivot).
|
||||
|
||||
---
|
||||
|
||||
## A1.8 — Updated open questions (supersedes original § 5)
|
||||
|
||||
The original § 5 listed five open questions all of which were specific to the outer-bwrap architecture. Amendment 1 supersedes those and lists the open questions for the new architecture:
|
||||
|
||||
1. **Per-call `wrapWithSandbox()` latency.** PR-B amortized the MITM CA generation (100-500ms) across all spawns by initializing once at boot. Per-call wrap regenerates this if the library does not cache internally. Task #4 PI231 spike measures the actual per-call cost; if it exceeds the original ≤200ms p95 budget, an internal cache wrapper around the library is added in PR-B'. Decision reserved for PR-B'.
|
||||
2. **`vibe` (mistral) home-relocation env var.** Task #4 PI231 spike checks whether `vibe` honours `MISTRAL_HOME` / `VIBE_HOME` / similar. If yes, mistral's ISOLATION block declares it and mistral participates in Layer 1. If no, mistral falls back to Layer 4 (prompt layer) + Layer 3 (per-call wrap with `denyRead` on the operator's real home) only. The provider's `recommendedDeploymentTier` is set accordingly.
|
||||
3. **macOS coverage.** sandbox-runtime supports macOS via `sandbox-exec` (seatbelt profile). Layer 1 ephemeral home is OS-agnostic (just an env var). Layer 3 macOS path needs verification: does per-call `wrapWithSandbox()` with `customConfig` produce a per-spawn sandbox-exec profile, or does it re-use a singleton seatbelt profile? Task #4 PI231 spike is Linux-only; a parallel macOS verification is a Task #9 deliverable.
|
||||
4. **Symlink-vs-bindmount for credentials.** Layer 2 uses symlinks for credential mounting. An alternative is bindmounting the credential file into the ephemeral home (only available inside the Layer 3 wrap). The trade-off: symlinks work outside any sandbox context (so Layer 2 works even when Layer 3 is skipped, e.g., for codex); bindmounts are stronger isolation (the CLI cannot follow the symlink to discover the real path). Decision reserved for PR-B' implementation review.
|
||||
5. **Concurrent-spawn cleanup ordering.** The ephemeral home cleanup (rmdir at response end) must not race with a still-streaming spawn. The current plan: track per-`reqId` cleanup and only fire on the spawn's `exit` event. If a streaming abort leaves the spawn alive past the HTTP response, cleanup is deferred until `exit`. Tested in Task #9.
|
||||
6. **`/health.sandbox.providers` shape under Amendment 1.** Original `/health.sandbox` had a flat `{ available, active }`. Amendment 1 adds per-provider posture: `{ available, providers: { anthropic: { crossTenantReadProtection: 'tool-suppression', layers: ['L1','L2','L3','L4'] }, openai: { crossTenantReadProtection: 'inner-sandbox', layers: ['L1','L4'] } } }`. Exact shape ratified by PR-B'.
|
||||
7. **Dashboard `/dashboard` Security panel.** The dashboard currently has no security panel. Amendment 1 names the addition as a follow-up: render `/health.sandbox.providers` as a per-provider posture badge so the operator can see at a glance which providers are in `tool-suppression` vs `inner-sandbox` vs `none` mode. Out of Phase 7 scope; recorded for a future ADR.
|
||||
|
||||
---
|
||||
|
||||
## A1.9 — Authority citations (Amendment 1)
|
||||
|
||||
Per ALIGNMENT.md Rule 1 (Cite First) and Iron Rule 12 (Pre-Brainstorm Prior-Art Search), every load-bearing claim in this amendment is cited to a primary source. The four forcing reasons are cited above in A1.1.1–A1.1.4; this section enumerates them in one place plus the supporting citations.
|
||||
|
||||
**Forcing reasons:**
|
||||
|
||||
1. **sandbox-runtime documented use-case is inner-wrap by Claude Code.**
|
||||
- https://www.anthropic.com/engineering/claude-code-sandboxing — "Claude Code sandboxing" engineering blog. Documents Claude Code's *internal* use of the library to wrap tool-spawn calls. The blog also notes the library "can be used to sandbox arbitrary processes, agents and MCP servers" — i.e., it is general-purpose, not Claude-Code-internal-only. **Our reading:** outer-wrap of `claude` itself is not the documented or demonstrated direction; OLP would be the only known user in that configuration. Project-design judgment, not an Anthropic prohibition.
|
||||
|
||||
2. **`~/.claude.json` non-atomic write — upstream issue closed `not_planned` by inactivity bot.**
|
||||
- https://github.com/anthropics/claude-code/issues/29250 — upstream issue requesting atomic-write semantics. Status: closed `not_planned` by `github-actions[bot]` on 2026-03-31 (inactivity), labeled `duplicate`, `stale`. **No upstream Anthropic maintainer comment articulates a policy position**; the closure does not establish "won't fix" as policy. Forcing argument rests on architectural-cost analysis (permanent maintenance treadmill for outer-`--ro-bind`), not on alleged upstream policy.
|
||||
|
||||
3. **Codex inner-bwrap conflict.**
|
||||
- https://github.com/openai/codex/issues/16018 — upstream codex CLI issue. Documents that codex's default bwrap sandbox **fails outright** in environments lacking unprivileged user namespaces. The issue is a feature request asking codex to add a fallback path; **the issue body does NOT document an existing automatic fallback to `--sandbox danger-full-access`**. Whether codex degrades to `danger-full-access` or aborts the spawn under nested-bwrap failure is empirically open (Task #4 deliverable). Either failure mode breaks the strict-additive-isolation invariant for outer-wrap of codex. This is the multi-provider forcing function regardless of which failure mode applies.
|
||||
|
||||
4. **`CODEX_HOME` documented relocation lever.**
|
||||
- https://developers.openai.com/codex/config-reference — OpenAI codex CLI config reference (primary).
|
||||
- https://codex.danielvaughan.com/2026/04/08/codex-cli-configuration-reference/ — third-party reference (cross-reference for reachability).
|
||||
|
||||
**Supporting citations (carried forward from original ADR § 7):**
|
||||
|
||||
5. **`@anthropic-ai/sandbox-runtime` v0.0.52** — https://github.com/anthropic-experimental/sandbox-runtime
|
||||
- `dist/sandbox/sandbox-manager.js` — `SandboxManager.wrapWithSandbox(command, undefined, customConfig)` is the per-call wrap surface used by Layer 3. The third argument `customConfig` is the per-call override mechanism that makes Amendment 1's per-spawn config architecture implementable without library modification.
|
||||
|
||||
6. **Internal evidence:**
|
||||
- **PR-B implementation chain** — commits `d0dcd28` → `2864275` → `497b255` → `b1e24b7` → `3551921`. The HTTP-path activation regression is documented in commit message `b1e24b7` and in `lib/sandbox/manager.mjs` § "OLP_SANDBOX_DISABLED env-var gate" comments.
|
||||
- **cc-mem incident memory 2026-05-27** — `~/.cc-rules/memory/projects/olp/incident_2026_05_27_spawn_cli_security.md` — the original multi-tenant gap and the prior-art search that established the ecosystem has no working solution.
|
||||
- **2026-05-28 PoC spike on PI231** — `/tmp/sandbox-spike/report.md` on PI231. Verdict was YELLOW (architecturally green, operationally blocked on apt deps). The follow-up 2026-05-29 PI231 prep work re-evaluates against the new architecture; results land in Task #4.
|
||||
|
||||
7. **OLP governance:**
|
||||
- **OLP ALIGNMENT.md Rule 1** — Authority citation required for any provider-plugin / entry-surface / IR change. Amendment 1 amends governance only; the implementation refactor (PR-B') carries its own per-commit citations to the same primary sources enumerated above.
|
||||
- **OLP ALIGNMENT.md Rule 4** — Unalignable plugins are deleted. Mistral's potential lack of a home-relocation env var (open question 2 above) is *not* an alignability gap (mistral's CLI authority is unchanged); it is a deployment-tier classification, recorded in the provider's ISOLATION block.
|
||||
- **Iron Rule 10** — Independent reviewer required. This amendment's review is pending at draft time.
|
||||
- **Iron Rule 11** — Minimum reviewable unit. PR-B' is one PR (sandbox manager refactor); the anthropic ISOLATION block, codex ISOLATION block, server wiring, and README section are each separate PRs per the revised PR plan in § A1.4.
|
||||
- **Iron Rule 12** — Pre-brainstorm prior-art search. The four forcing reasons each satisfy the rule's "provider-specific authority check decisive" condition: Anthropic's blog post + upstream issue 29250 (for the anthropic side), and the codex issue 16018 + the OpenAI config reference (for the codex side).
|
||||
|
||||
8. **OLP ADR cross-references:**
|
||||
- **ADR 0001 § Mission** — multi-provider proxy. Codex inner-bwrap conflict is the multi-provider forcing function.
|
||||
- **ADR 0002 (pending Amendment N)** — Provider ISOLATION contract specification. Amendment 1 names the contract; Amendment N specifies it.
|
||||
- **ADR 0006** — Provider Inclusion / Risk Tier. `recommendedDeploymentTier` in the ISOLATION block integrates with the risk tier framework.
|
||||
- **ADR 0009 Amendment 1 § Caveats #3** — "Sandbox-runtime still required for real multi-tenant deployment." Amendment 1 satisfies this caveat via Layer 3, not via outer-wrap.
|
||||
- **`docs/plans/cloud-deployment-family.md` § 5** — sandbox is a cloud rollout prerequisite. PR-E' updates this section to reflect that the layered architecture is the cloud prerequisite, not outer-bwrap.
|
||||
|
||||
---
|
||||
|
||||
## A1.10 — Consequences of Amendment 1
|
||||
|
||||
### Positive
|
||||
|
||||
- **No outer-bwrap maintenance treadmill.** New `claude` CLI state files do not require OLP-side `--ro-bind` updates. Layer 1 absorbs them automatically.
|
||||
- **Multi-provider compatible.** Codex inner sandbox is preserved unmolested. The architecture works for both anthropic (no inner sandbox) and codex (has inner sandbox) without per-provider workarounds in the sandbox layer; the per-provider differences live in the per-provider ISOLATION block where they belong.
|
||||
- **Per-spawn isolation primitives.** Every request gets a fresh `$HOME`. Cross-tenant state carry-over at the filesystem level is structurally impossible, not "mitigated by careful denylist."
|
||||
- **Aligned with Anthropic's design intent.** OLP uses sandbox-runtime in the direction the library was designed for (sandboxing what the spawn triggers, not wrapping the spawn from outside). The library's semver discipline becomes leverage rather than risk.
|
||||
- **Reduced HTTP-path activation surface.** PR-B's regression was that the in-process MITM proxy lifecycle interacted with OLP's HTTP request handler. Per-call `wrapWithSandbox()` does not require an always-on in-process proxy; the failure mode goes away by construction. (To be confirmed empirically in Task #4 + Task #9.)
|
||||
- **Doctor and `/health.sandbox` continuity.** Operators and dashboard consumers see the same field at the same JSON path. Shape additions are additive, not breaking.
|
||||
|
||||
### Negative
|
||||
|
||||
- **Per-call latency cost.** Per-call `wrapWithSandbox()` is more expensive than once-at-boot init+wrap. The mitigation is library-import caching and (if measured high) a sandbox-config cache keyed by the union of allowed-read paths. Empirical measurement in Task #4.
|
||||
- **New contract surface (ISOLATION block).** Each provider plugin now declares ISOLATION fields. This is incremental complexity in the Provider contract — ratified by ADR 0002 Amendment N. ADR 0002 amendment is on the critical path.
|
||||
- **Mistral declared `crossTenantReadProtection: 'none'`.** Vibe CLI has no Phase-6c-equivalent tool suppression and no known inner sandbox as of D8 ADR 0006 enablement. The mistral provider's `recommendedDeploymentTier` is therefore `separate-vm` per ADR 0002 Amendment 9 § Per-provider concrete instance, meaning mistral can run only in a dedicated VM rather than sharing the OS user with other providers. Not a regression vs status quo (mistral is not deployed today); reflects honest characterization of current state per ALIGNMENT.md Rule 3. Task #4 spike may discover a hardening regime, transitioning this tier upward.
|
||||
- **Symlink semantics edge cases.** Layer 2 symlinks credential files into the ephemeral home; some CLIs may resolve the symlink and write a sibling file in the *target* directory rather than the ephemeral location. Each provider's ISOLATION block should declare any such known behaviour; the runtime tests verify by examining the operator's real `$HOME` for stray writes after a test spawn.
|
||||
- **The `OLP_SANDBOX_DISABLED=1` env-var gate is preserved for 1-2 releases.** It remains a valid escape hatch — but as belt-and-suspenders rather than as load-bearing. Operators who rely on the gate after sunset will see a deprecation message before removal.
|
||||
|
||||
### Reversibility (governance level)
|
||||
|
||||
- Amendment 1 is reversible by a superseding ADR amendment that cites new evidence overturning any of the four forcing reasons. The most likely overturning scenario: Anthropic publishes guidance endorsing outer-wrap of `claude` CLI plus an atomic-write contract for `~/.claude.json`. If that happens, the superseding amendment cites the new guidance and re-enables outer-wrap as an option (alongside, not replacing, the Solution 1 architecture).
|
||||
- The implementation-level reversibility is documented in § A1.7 above.
|
||||
|
||||
---
|
||||
|
||||
## A1.11 — Forward-looking pointer
|
||||
|
||||
Amendment 1 is the governance layer. The implementation lands across Tasks #5–#10 (per the working task list at the time of this draft):
|
||||
|
||||
- Task #4 — PI231 spike to verify `HOME` / `CODEX_HOME` env-var override behaviour (live, with the same `claude` and `codex` CLI versions OLP ships against).
|
||||
- Task #5 — Refactor `lib/sandbox/manager.mjs` to the Layer 1 + Layer 2 + Layer 3 architecture (PR-B').
|
||||
- Task #6 — Add ISOLATION block to `lib/providers/anthropic.mjs` (PR-C').
|
||||
- Task #7 — Add ISOLATION block to `lib/providers/codex.mjs` (PR-D').
|
||||
- Task #8 — Wire `prepareIsolatedEnvironment` into `server.mjs` spawn pipeline (folds into PR-C' or its own PR depending on diff size).
|
||||
- Task #9 — PI231 E2E validation of Solution 1 + close PR-B's load-bearing negative test ("in-sandbox `cat ~/.olp/keys/...` MUST fail") against the new architecture.
|
||||
- Task #10 — README "Security Model" section + cloud-deployment-plan § 5 update + Phase 7 close (PR-E').
|
||||
|
||||
ADR 0002 Amendment N (Provider ISOLATION contract specification) is a co-merged ADR with PR-C'; it cannot land after the ISOLATION block reaches the codebase per ALIGNMENT.md Rule 2(c)'s spirit (no contract field without an authorizing ADR).
|
||||
|
||||
---
|
||||
|
||||
## A1.12 — Amendment status
|
||||
|
||||
- **Drafted:** 2026-05-29 (this document).
|
||||
- **Reviewer:** independent fresh-context reviewer per Iron Rule 10 — pending.
|
||||
- **Implementation gate:** ADR 0002 Amendment N (Provider ISOLATION contract specification) must land before or together with PR-C' (the first ISOLATION-block-bearing provider plugin commit).
|
||||
- **Production gate:** PI231 E2E (Task #9) must pass the load-bearing negative test before the `OLP_SANDBOX_DISABLED=1` env-var gate is removed from prod startup.
|
||||
@@ -25,6 +25,8 @@ New ADRs increment from the highest existing number. Filenames are `NNNN-<short-
|
||||
| [0009](0009-interactive-mode-path-placeholder.md) | Anthropic Interactive-Mode Path (Placeholder) | Placeholder ADR (2026-05-25, Draft) — blocked on OCP ADR 0007 P0 experiment outcome. Records the maintainer's "wait + port" decision: do NOT independently implement; ride OCP's P0 result. If P0 confirms Transport A (stdio NDJSON) or B (PTY) bills as subscription rather than Agent SDK credit, port to OLP `lib/providers/anthropic.mjs` (Option 1 parallel impl, or Option 2 OCP-as-backend; decision deferred to P0-resolution time). If P0 fails on both, shelve. No Phase 4 D-day scheduled until P0 lands AND maintainer issues explicit "go" naming this ADR. |
|
||||
| [0010](0010-phase-4-charter-operator-and-client-ux.md) | Phase 4 Charter — Operator + Client UX | Phase 4 scope ratification (2026-05-26, Accepted). Phase 4 = operator + client UX (SSE heartbeat / `olp` CLI + doctor / `olp-connect` zero-config + Telegram-Discord plugin + IDE docs bundle). ~13 D-days, D60 → v0.4.0. Records the explicit decision to DEFER `/v1/messages` (Anthropic-shape entry surface) on the rationale that under ADR 0009 P0 failure it provides no billing benefit AND degrades worse on fallback than OpenAI-shape clients. Re-open trigger: ADR 0009 P0 success + maintainer-named family CC user. Also closes the OCP-OLP port co-host ambiguity from ADR 0001 (default `OLP_PORT` 3456 → 4567). |
|
||||
| [0011](0011-anonymous-key-deployment-context.md) | Anonymous-Key Deployment-Context Limits (Trusted-LAN Invariant) | D70 (2026-05-26, Accepted). Codifies the trust posture for `/health.anonymousKey` opt-in field (D69) + `bin/olp-connect` zero-config consumer (D68). Three-prerequisite gate (`auth.advertise_anonymous_key=true` + `auth.allow_anonymous=true` + an active key with `plaintext_advertise` field). Guest-tier-only restriction (`createKey()` + CLI reject owner+advertise). Trusted-LAN deployment invariant (loopback / RFC1918 / tailnet / `.local` / `.internal` — soft constraint at v0.4.0; hard enforcement deferred until OLP gains a public-deployment recipe). Re-evaluation trigger: any "expose to public internet" README mode. |
|
||||
| [0012](0012-phase-5-charter-quota-probes-dashboard.md) | Phase 5 Charter — Provider Quota Probes + Dashboard Enrichment | Phase 5 scope ratification (2026-05-26, Accepted). Phase 5 = port OCP's plan-usage probe to `lib/providers/anthropic.mjs:quotaStatus()` + Claude.ai-style dashboard enrichment (1-min auto-refresh + manual refresh + per-provider rows with utilization bars, reset countdowns, status badges) + optional mistral probe at D84 (codex explicitly skipped — no public API). ~6 D-days, D79 → v0.5.0. Companion to ADR 0002 Amendment 8 (direct-API READ-ONLY exemption) + ADR 0013 (OAuth READ-ONLY consumption rules). Closes v1.x roadmap #8 (dashboard enrichment). Re-confirmed schema 2026-05-26 via compiled-binary `strings` + live API probe; 3 new fields since OCP 2026-04 capture, no removals. |
|
||||
| [0013](0013-oauth-read-only-consumption-and-schema-drift.md) | OAuth READ-ONLY Consumption Rules + Schema-Drift Mitigation Protocol | D79 (2026-05-26, Accepted). Implementation discipline for ADR 0002 Amendment 8. Seven rules covering: credential reuse with spawn path (no new OAuth grant); READ-ONLY at the wire (one probe per cache miss, `max_tokens:1`, headers-only parse, discard body); cache TTL 5min + 60s-3600s exponential refresh backoff + stale-cache-on-failure; opt-in via `~/.olp/config.json providers.<name>.quota_probe_enabled` (default false); schema-drift mitigation via dual-path verification (compiled-binary `strings` + live API probe diff); failure transparency through `olp doctor` + dashboard staleness markers; out-of-scope clarifications. Bound by `~/.cc-rules/memory/learnings/anthropic_plan_usage_probe_schema_2026_05_26.md` as the live schema pin. |
|
||||
|
||||
## When to write a new ADR
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"phase": "v0.5.1 post-release",
|
||||
"purpose": "Refresh dashboard screenshot with live MacBook data after v0.5.1 hotfix (replacing D82's synthetic-data render)",
|
||||
"captured_at_utc": "2026-05-27T01:26:17.304Z",
|
||||
"host": "maintainer's MacBook (Mac client test target per project test-envs; specific IP / Tailscale node redacted per public-repo hygiene)",
|
||||
"server_version": "0.5.1 (main @ commit fa2d1af \u2014 F4+#7 post-merge)",
|
||||
"olp_port": 14567,
|
||||
"endpoint_tested": "/v0/management/dashboard-data",
|
||||
"auth": "owner-tier OLP key (temp, revoked post-test)",
|
||||
"result_summary": {
|
||||
"anthropic": {
|
||||
"status": "live",
|
||||
"schema_version": "2026-05-26",
|
||||
"utilization_5h": 0.06,
|
||||
"utilization_7d": 0.38,
|
||||
"representative_claim": "five_hour",
|
||||
"failure": null
|
||||
},
|
||||
"openai": {
|
||||
"status": "unavailable",
|
||||
"reason": "no public quota api or probe disabled"
|
||||
}
|
||||
},
|
||||
"v0_5_1_contract_verified": [
|
||||
"quota_v2[i].status enum includes 'live' (anthropic) and 'unavailable' (openai) \u2014 both rendered correctly",
|
||||
"quota_v2[i].failure is null for healthy live status (per ADR 0013 Rule 6 \u2014 failure info only on stale/unreachable)",
|
||||
"quota_v2[i].schema_version pinned at 2026-05-26 \u2014 matches models-registry.json quota_probe.schema_version"
|
||||
],
|
||||
"post_test_cleanup": [
|
||||
"temp owner key (id=0m6s2s97, name=v0.5.1-screenshot) revoked",
|
||||
"~/.olp/config.json providers.anthropic.quota_probe_enabled flag removed (config restored to baseline)",
|
||||
"test server (pid varies, port=14567) terminated"
|
||||
]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 113 KiB |
+288
-64
@@ -1,16 +1,25 @@
|
||||
# OpenClaw + OLP
|
||||
|
||||
[OpenClaw](https://github.com/openclaw/openclaw) is a multi-bot gateway
|
||||
that exposes slash commands on Telegram, Discord, and other chat
|
||||
surfaces. OLP ships [`olp-plugin/`](../../olp-plugin/) as a native
|
||||
OpenClaw plugin that registers a `/olp` slash command with read-only
|
||||
parity to the local `olp` CLI.
|
||||
[OpenClaw](https://github.com/openclaw/openclaw) is a multi-bot gateway that exposes slash commands on Telegram, Discord, and other chat surfaces. OLP integrates with OpenClaw in two ways:
|
||||
|
||||
**Status:** ✅ Supported.
|
||||
1. **`/olp` slash commands** via the [`olp-plugin/`](../../olp-plugin/) plugin (read-only parity to the local `olp` CLI).
|
||||
2. **LLM routing** — OpenClaw's chat agent can route its model calls through your OLP server, giving you per-key audit + quota observability for every bot reply.
|
||||
|
||||
## What you get
|
||||
This doc covers both. **Status:** ✅ Supported.
|
||||
|
||||
After install, from Telegram or Discord:
|
||||
## Two deployment modes — pick yours
|
||||
|
||||
The OpenClaw config differs significantly depending on whether OpenClaw runs on the same host as the OLP server or on a separate client machine talking to a remote OLP. Pick the right section.
|
||||
|
||||
| | **Mode A: Server-co-located** | **Mode B: Client-mode (recommended for multi-machine setups)** |
|
||||
|---|---|---|
|
||||
| OpenClaw runs on | the OLP server host (loopback) | a different machine (Mac mini, laptop, etc.) |
|
||||
| OLP server runs on | localhost (same host) | a remote host (e.g. PI231) |
|
||||
| `olp-claude` baseUrl | `http://127.0.0.1:4567/v1` | `http://<server-ip>:4567/v1` |
|
||||
| Auth | `authHeader: false` (loopback trusted), OR anonymous-key if `auth.allow_anonymous: true` | `apiKey: "${OLP_OPENCLAW_BOT_TOKEN}"` env-var reference (NOT raw string, NOT `OPENAI_API_KEY` — see § Gotchas) |
|
||||
| `/olp` slash plugin proxyUrl | `http://127.0.0.1:4567` | `http://<server-ip>:4567` |
|
||||
|
||||
## `/olp` slash commands you get
|
||||
|
||||
| Slash command | Maps to | Tier |
|
||||
|---|---|---|
|
||||
@@ -24,14 +33,15 @@ After install, from Telegram or Discord:
|
||||
| `/olp doctor` | informational (HTTP endpoint not yet shipped) | — |
|
||||
| `/olp help` | usage text | — |
|
||||
|
||||
**Mutating subcommands are deliberately not exposed via chat.** `keygen`,
|
||||
`revoke`, `restart`, `logs` are SSH-only. See
|
||||
[`olp-plugin/README.md`](../../olp-plugin/README.md#what-you-can-not-do-from-chat-by-design)
|
||||
for the rationale.
|
||||
**Mutating subcommands are deliberately not exposed via chat.** `keygen`, `revoke`, `restart`, `logs` are SSH-only. See [`olp-plugin/README.md`](../../olp-plugin/README.md#what-you-can-not-do-from-chat-by-design) for the rationale.
|
||||
|
||||
## Quick setup
|
||||
---
|
||||
|
||||
### 1. Install the plugin
|
||||
## Mode A — Server-co-located install
|
||||
|
||||
OpenClaw + OLP on the same host. Auth is simpler because everything is on loopback.
|
||||
|
||||
### A1. Install the plugin
|
||||
|
||||
Two install paths — either works.
|
||||
|
||||
@@ -48,96 +58,310 @@ mkdir -p ~/.openclaw/extensions/
|
||||
ln -s /path/to/olp/olp-plugin/ ~/.openclaw/extensions/olp
|
||||
```
|
||||
|
||||
### 2. Mint a bot owner key
|
||||
|
||||
Run on the OLP host (NOT in chat):
|
||||
### A2. Mint a bot owner key
|
||||
|
||||
```bash
|
||||
npx olp-keys keygen --owner --name=openclaw-bot
|
||||
```
|
||||
|
||||
Capture the printed plaintext token — it is shown exactly once.
|
||||
Capture the printed plaintext token — shown exactly once.
|
||||
|
||||
### 3. Configure
|
||||
### A3. Configure (loopback recipe)
|
||||
|
||||
Edit `~/.openclaw/openclaw.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugins": {
|
||||
"olp": {
|
||||
"proxyUrl": "http://127.0.0.1:4567",
|
||||
"apiKey": "olp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
|
||||
"allow": ["...", "olp"],
|
||||
"entries": {
|
||||
"olp": {
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"proxyUrl": "http://127.0.0.1:4567",
|
||||
"apiKey": "olp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Restart the gateway
|
||||
For LLM routing through OLP, add (or update) the `olp-claude` provider so the bot's default agent goes through OLP-spawned `claude -p`:
|
||||
|
||||
```json
|
||||
{
|
||||
"models": {
|
||||
"providers": {
|
||||
"olp-claude": {
|
||||
"baseUrl": "http://127.0.0.1:4567/v1",
|
||||
"api": "openai-completions",
|
||||
"authHeader": false,
|
||||
"models": [
|
||||
{ "id": "claude-sonnet-4-6", "name": "Claude Sonnet 4.6", "input": ["text"],
|
||||
"contextWindow": 200000, "maxTokens": 16384, "api": "openai-completions",
|
||||
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 } }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`authHeader: false` is safe on loopback. If you set `auth.allow_anonymous: true` on the OLP server, the bot doesn't even need a key for slash commands (the `apiKey` field can be omitted). Owner-only subcommands (`/olp status`, `/olp usage`, `/olp cache`) still need an owner-tier key.
|
||||
|
||||
### A4. Restart the gateway
|
||||
|
||||
```bash
|
||||
openclaw gateway restart
|
||||
```
|
||||
|
||||
The plugin is now active. Try `/olp help` in your bot's chat.
|
||||
---
|
||||
|
||||
## Known issues
|
||||
## Mode B — Client-mode install (OpenClaw on different host than OLP)
|
||||
|
||||
- **`openclaw gateway restart` is required after install.** OpenClaw caches
|
||||
plugin discovery at gateway start. `openclaw plugins reload` does not
|
||||
guarantee a fresh import of the plugin module.
|
||||
OpenClaw on machine X (e.g., Mac mini), OLP server on machine Y (e.g., a Raspberry Pi or any LAN host). This is the common family deployment shape.
|
||||
|
||||
- **Owner key revocation kicks the plugin out immediately.** If you revoke
|
||||
the bot's owner key (`npx olp-keys revoke --id=<id>`), the next `/olp
|
||||
status` will return `401 unauthorized`. Mint a replacement key with a
|
||||
new name and edit `~/.openclaw/openclaw.json`; do NOT reuse the revoked
|
||||
key's UUID.
|
||||
### B1. Install the plugin
|
||||
|
||||
- **Long responses are truncated.** Telegram caps messages at ~4096
|
||||
characters. The plugin truncates with a `... [truncated, use SSH for
|
||||
full]` suffix when the rendered output would exceed ~3900 chars. Use
|
||||
SSH + the local `olp` CLI for full output.
|
||||
Same as Mode A:
|
||||
|
||||
```bash
|
||||
openclaw plugins install /path/to/olp/olp-plugin/
|
||||
# OR
|
||||
mkdir -p ~/.openclaw/extensions/
|
||||
ln -s /path/to/olp/olp-plugin/ ~/.openclaw/extensions/olp
|
||||
```
|
||||
|
||||
### B2. Mint a bot owner key (on the OLP server, NOT on the OpenClaw host)
|
||||
|
||||
SSH to the OLP server:
|
||||
|
||||
```bash
|
||||
ssh user@olp-server
|
||||
cd ~/olp
|
||||
node bin/olp-keys.mjs keygen --owner --name=openclaw-<hostname>-bot
|
||||
```
|
||||
|
||||
Capture the plaintext — shown exactly once. **This token will live in `~/.openclaw/openclaw.json` on your OpenClaw host**; pick a name that makes it independently revocable if that host is lost/compromised.
|
||||
|
||||
### B3. Set the bot-token env var (`OLP_OPENCLAW_BOT_TOKEN`)
|
||||
|
||||
OpenClaw's canonical pattern for custom-provider auth is `apiKey: "${VAR_NAME}"` — an env-var reference, NOT a raw token. Choose a **custom** variable name (NOT `OPENAI_API_KEY` — OpenClaw service-manages that one and clobbers it with its own ChatGPT key on every restart). Convention: `OLP_OPENCLAW_BOT_TOKEN`.
|
||||
|
||||
**macOS (gateway under launchd)**:
|
||||
|
||||
```bash
|
||||
launchctl setenv OLP_OPENCLAW_BOT_TOKEN olp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||
```
|
||||
|
||||
Add the same `export` to `~/.zshrc` so it survives reboot:
|
||||
|
||||
```bash
|
||||
export OLP_OPENCLAW_BOT_TOKEN=olp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||
```
|
||||
|
||||
**Linux (gateway under systemd-user)**: drop a file at `~/.config/environment.d/openclaw-olp.conf`:
|
||||
|
||||
```
|
||||
OLP_OPENCLAW_BOT_TOKEN=olp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||
```
|
||||
|
||||
Restart the gateway service so it picks up the new env.
|
||||
|
||||
### B4. Configure `~/.openclaw/openclaw.json`
|
||||
|
||||
Edit `~/.openclaw/openclaw.json` on the OpenClaw host:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugins": {
|
||||
"allow": ["...", "olp"],
|
||||
"entries": {
|
||||
"olp": {
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"proxyUrl": "http://<olp-server-ip>:4567",
|
||||
"apiKey": "olp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"models": {
|
||||
"providers": {
|
||||
"olp-claude": {
|
||||
"baseUrl": "http://<olp-server-ip>:4567/v1",
|
||||
"api": "openai-completions",
|
||||
"apiKey": "${OLP_OPENCLAW_BOT_TOKEN}",
|
||||
"models": [
|
||||
{ "id": "claude-sonnet-4-6", "name": "Claude Sonnet 4.6 (via OLP)", "input": ["text"],
|
||||
"contextWindow": 200000, "maxTokens": 16384, "api": "openai-completions",
|
||||
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 } },
|
||||
{ "id": "claude-opus-4-7", "name": "Claude Opus 4.7 (via OLP)", "input": ["text"],
|
||||
"contextWindow": 200000, "maxTokens": 16384, "api": "openai-completions",
|
||||
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 } },
|
||||
{ "id": "claude-haiku-4-5", "name": "Claude Haiku 4.5 (via OLP)", "input": ["text"],
|
||||
"contextWindow": 200000, "maxTokens": 16384, "api": "openai-completions",
|
||||
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 } }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Note: the `plugins.entries.olp.config.apiKey` field (line 11) IS allowed to be a raw token — it's a separate code path that doesn't suffer the service-managed-env clobber problem. Only the `models.providers.<id>.apiKey` field needs the `${VAR}` env-var-reference workaround.
|
||||
|
||||
### B5. Confirm the default agent model is on `olp-claude`
|
||||
|
||||
Check `agents.defaults.model.primary` in `openclaw.json`. It should be something like:
|
||||
|
||||
```json
|
||||
{ "agents": { "defaults": { "model": { "primary": "olp-claude/claude-sonnet-4-6" } } } }
|
||||
```
|
||||
|
||||
If it's pointing at one of OpenClaw's stock providers (`openai/...`, `anthropic/...`, `github-copilot/...`), free-text chat will **bypass OLP entirely** and hit your direct API account. You'll see no traffic in OLP's `/dashboard` and `/olp usage` will show no recent activity.
|
||||
|
||||
### B5. Restart the gateway
|
||||
|
||||
```bash
|
||||
openclaw gateway restart
|
||||
```
|
||||
|
||||
### B6. Verify routing
|
||||
|
||||
In Telegram or Discord, send a free-text message ("hello"). It should:
|
||||
1. Return a normal LLM reply (not "Something went wrong")
|
||||
2. Show up on the OLP dashboard's 24h-requests counter
|
||||
3. Show up in `/olp usage` per-provider count
|
||||
|
||||
If you see "Something went wrong" — see § Troubleshooting below.
|
||||
|
||||
---
|
||||
|
||||
## Using codex / OpenAI models through OLP
|
||||
|
||||
By default the `olp-claude` provider only knows about Claude models. To route OpenAI / codex models through OLP (so bot calls to `gpt-5.5` etc. spawn `codex exec --json` on the OLP server and benefit from per-key audit + quota tracking), add a second provider:
|
||||
|
||||
```json
|
||||
{
|
||||
"models": {
|
||||
"providers": {
|
||||
"olp-codex": {
|
||||
"baseUrl": "http://<olp-server-ip>:4567/v1",
|
||||
"api": "openai-completions",
|
||||
"apiKey": "${OLP_OPENCLAW_BOT_TOKEN}",
|
||||
"models": [
|
||||
{ "id": "gpt-5.5", "name": "GPT 5.5 (via OLP→codex)", "input": ["text"],
|
||||
"contextWindow": 200000, "maxTokens": 16384, "api": "openai-completions",
|
||||
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 } },
|
||||
{ "id": "gpt-5.4-mini", "name": "GPT 5.4 mini (via OLP→codex)", "input": ["text"],
|
||||
"contextWindow": 200000, "maxTokens": 16384, "api": "openai-completions",
|
||||
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 } },
|
||||
{ "id": "gpt-5.3-codex", "name": "GPT 5.3 codex (via OLP→codex)", "input": ["text"],
|
||||
"contextWindow": 200000, "maxTokens": 16384, "api": "openai-completions",
|
||||
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 } }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"models": {
|
||||
"olp-codex/gpt-5.5": { "alias": "OLP GPT 5.5" },
|
||||
"olp-codex/gpt-5.4-mini": { "alias": "OLP GPT 5.4 mini" },
|
||||
"olp-codex/gpt-5.3-codex": { "alias": "OLP Codex" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
After restart, type `/models` in Telegram and pick `olp-codex/gpt-5.5` from the menu that appears. **`/models` is menu-driven — it does not accept inline model names**; typing `/models olp-codex/gpt-5.5` won't directly switch you. The available IDs are the ones OLP's `/v1/models` returns — typically `gpt-5.5`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.3-codex`, `gpt-5.3-codex-spark`. Query your OLP server to see the live list:
|
||||
|
||||
```bash
|
||||
curl -s -H "Authorization: Bearer olp_…" http://<olp-server-ip>:4567/v1/models | jq '.data[].id'
|
||||
```
|
||||
|
||||
**Why not use OpenClaw's stock `openai` provider?** OpenClaw's built-in `openai-codex` provider uses the local ChatGPT account (via the `sk-proj-…` API key OpenClaw stores) and bypasses your OLP server entirely. You'd lose per-key audit + per-key quota visibility. `olp-codex` keeps everything routed through your central OLP for observability.
|
||||
|
||||
---
|
||||
|
||||
## Gotchas
|
||||
|
||||
### Auth must use `apiKey: "${VAR}"` env-var reference — three failure modes to avoid
|
||||
|
||||
Custom OpenAI-compatible providers in OpenClaw have a fragile auth path. Three patterns that **don't work** + the one that **does**:
|
||||
|
||||
**❌ `apiKey: "olp_<raw-token>"`** — raw string. Silently bypassed in some routing paths because OpenClaw treats `OPENAI_API_KEY` as service-managed (`OPENCLAW_SERVICE_MANAGED_ENV_KEYS=DEEPSEEK_API_KEY,OPENAI_API_KEY`), and certain model id patterns (notably `gpt-*`) fall back to that env var instead of using your explicit `apiKey`. Symptom: OLP audit shows the request as `__anonymous__` instead of your owner key. Confirmed via [openclaw#41157](https://github.com/openclaw/openclaw/issues/41157) (Gemini openai-completions Authorization not sent) and [#1669](https://github.com/openclaw/openclaw/issues/1669) (Ollama provider ignores apiKey, hardcodes Bearer). Both unresolved upstream as of OpenClaw v2026.5.
|
||||
|
||||
**❌ `headers: { "Authorization": "Bearer olp_<raw-token>" }`** — works for SOME provider/model combinations (e.g., model id `claude-sonnet-4-6`) but breaks for openai-shape model ids (`gpt-5.5` etc.) which take a different code path that ignores the `headers` field. Mixed behavior is worse than no behavior.
|
||||
|
||||
**❌ Setting `OPENAI_API_KEY=olp_…`** in the gateway env. OpenClaw service-manages that variable and overwrites your value with the user's ChatGPT key on every gateway start.
|
||||
|
||||
**✅ `apiKey: "${OLP_OPENCLAW_BOT_TOKEN}"`** — env-var reference with a **custom** variable name (NOT `OPENAI_API_KEY`). OpenClaw resolves the reference at request-construction time, before any service-managed-env logic runs. Both `olp-claude/*` (Claude models) and `olp-codex/*` (OpenAI models) auth correctly with this pattern. Verified end-to-end 2026-05-27: OLP audit shows requests attributed to the correct bot key for both provider blocks.
|
||||
|
||||
OpenClaw docs call out this as the canonical pattern: see [docs.openclaw.ai/concepts/model-providers](https://docs.openclaw.ai/concepts/model-providers) "API key or SecretRef/env reference".
|
||||
|
||||
### Default agent model still points at a removed provider
|
||||
|
||||
If you've removed a provider (e.g., torn down a co-located OCP server) but the bot's default agent model still references that provider, free-text messages will fail with "Something went wrong while processing your request." Check `agents.defaults.model.primary` and update it to a provider that exists.
|
||||
|
||||
### `/new` does not reset model selection — use `/reset`
|
||||
|
||||
OpenClaw's `/new` resets the **conversation context** but **preserves** the session's `/models` selection. If a session has been switched to a model that no longer works (revoked / removed), `/new` won't help — use `/reset` (resets both context and model selection).
|
||||
|
||||
### `/models` is menu-only — does not accept inline model names
|
||||
|
||||
The OpenClaw `/models` command in Telegram is **menu-driven**: typing `/models` pops a model-picker menu where you tap the model name. Typing `/models olp-codex/gpt-5.5` does NOT switch — it'll open the picker. The bot's own success-message after a pick may say *"Use `/model olp-codex/gpt-5.5 --runtime <runtime>` to switch harnesses."* — **that command form is not actually accepted by the bot**; ignore that line.
|
||||
|
||||
### OpenClaw v2026.5+ requires `openclaw.extensions` in `package.json`
|
||||
|
||||
OpenClaw versions ≥ 2026.5.22 enforce a stricter plugin-manifest validation at `openclaw plugins install` time. If `Option A` fails with `package.json missing openclaw.extensions` despite recent OLP releases, your local `olp-plugin/package.json` may predate the v0.5.x fix that adds `"extensions": ["./index.js"]` to the `openclaw` block. Pull latest OLP main (`git pull` in your OLP clone) and retry, or fall through to symlink Option B which works against any plugin shape. (Original drift event: 2026-05-27, see commit history of `olp-plugin/package.json`.)
|
||||
|
||||
### `openclaw gateway restart` is required after install
|
||||
|
||||
OpenClaw caches plugin discovery + model-provider config at gateway start. `openclaw plugins reload` does not guarantee a fresh import of the plugin module nor a fresh re-read of `models.providers.*`. Restart the gateway after every change to `~/.openclaw/openclaw.json`.
|
||||
|
||||
### Owner-key revocation kicks the plugin out immediately
|
||||
|
||||
If you revoke the bot's owner key (`npx olp-keys revoke --id=<id>`), the next `/olp status` will return `401 unauthorized`. Mint a replacement key with a new name and edit `~/.openclaw/openclaw.json`; do NOT reuse the revoked key's UUID.
|
||||
|
||||
### Long responses are truncated
|
||||
|
||||
Telegram caps messages at ~4096 characters. The plugin truncates with a `... [truncated, use SSH for full]` suffix when the rendered output would exceed ~3900 chars. Use SSH + the local `olp` CLI for full output.
|
||||
|
||||
---
|
||||
|
||||
## OLP-specific notes
|
||||
|
||||
The plugin honours these env vars on the OpenClaw gateway process:
|
||||
|
||||
- `OLP_PROXY_URL` — full URL, overrides plugin config `proxyUrl`.
|
||||
- `OLP_PORT` — port only, localhost assumed; overrides `proxyUrl` when
|
||||
`OLP_PROXY_URL` is unset.
|
||||
- `OLP_PORT` — port only, localhost assumed; overrides `proxyUrl` when `OLP_PROXY_URL` is unset.
|
||||
|
||||
If you run the OpenClaw gateway under launchd or systemd with custom env
|
||||
vars, set `OLP_PROXY_URL` there rather than editing the plugin config —
|
||||
that way the same plugin install can serve multiple OLP hosts.
|
||||
If you run the OpenClaw gateway under launchd or systemd with custom env vars, set `OLP_PROXY_URL` there rather than editing the plugin config — that way the same plugin install can serve multiple OLP hosts.
|
||||
|
||||
## Per-bot vs maintainer key
|
||||
|
||||
**Always create a dedicated bot key**, never the maintainer's personal
|
||||
owner key. The bot key:
|
||||
**Always create a dedicated bot key**, never the maintainer's personal owner key. The bot key:
|
||||
|
||||
- Has its own `id` so you can revoke it without affecting other clients.
|
||||
- Has its own audit-log entries so you can attribute `/v0/management/*`
|
||||
traffic to the bot.
|
||||
- Can be rotated routinely (every 90 days etc.) without coordinating with
|
||||
the maintainer's daily-driver IDE configs.
|
||||
- Has its own audit-log entries so you can attribute `/v0/management/*` traffic to the bot.
|
||||
- Can be rotated routinely (every 90 days etc.) without coordinating with the maintainer's daily-driver IDE configs.
|
||||
|
||||
## Test it
|
||||
## Troubleshooting
|
||||
|
||||
After restart, in Telegram or Discord:
|
||||
|
||||
```
|
||||
/olp health
|
||||
/olp status
|
||||
/olp models
|
||||
```
|
||||
|
||||
Each should return a code-block-wrapped response within a few seconds.
|
||||
|
||||
If you see `401 unauthorized`: the configured key is missing / wrong /
|
||||
revoked. If you see `403 forbidden`: the key is not owner-tier. If you
|
||||
see `OLP error: fetch failed` or similar: the `proxyUrl` is unreachable
|
||||
from the gateway host (test with `curl http://<proxyUrl>/health` from
|
||||
that host).
|
||||
| Symptom | Likely cause | Fix |
|
||||
|---|---|---|
|
||||
| `/olp status` returns 401 | bot key revoked / wrong / missing | Mint new key on OLP host; update `plugins.entries.olp.config.apiKey`; restart gateway |
|
||||
| `/olp status` returns 403 | bot key is guest-tier, not owner-tier | Generate owner-tier key (`olp-keys keygen --owner --name=...`); update config |
|
||||
| `OLP error: fetch failed` | `proxyUrl` unreachable from the gateway host | `curl http://<proxyUrl>/health` from the gateway host to confirm reachability; check firewall / OLP server `OLP_BIND=0.0.0.0` for LAN access |
|
||||
| Bot free-text chat returns "Something went wrong" but `/olp ...` works | Default agent model points at a broken provider (e.g., a removed OCP install) | Check `agents.defaults.model.primary` in `openclaw.json`; update to `olp-claude/claude-sonnet-4-6` or another working provider |
|
||||
| Free-text returns `HTTP 401: OLP API key is invalid` despite fresh key | Raw-string `apiKey: "olp_..."` shadowed by service-managed env clobber; or `headers.Authorization` bypassed for `gpt-*` model ids | Switch `models.providers.<id>.apiKey` to env-var reference: `"${OLP_OPENCLAW_BOT_TOKEN}"` (see § Gotchas: Auth) |
|
||||
| OLP audit shows `key_id=__anonymous__` for traffic that should be owner-attributed | Same root cause as 401 — raw-string apiKey or headers bypassed in some routing paths | Switch to env-var-reference `apiKey: "${VAR}"` pattern + verify `launchctl getenv OLP_OPENCLAW_BOT_TOKEN` returns the expected token |
|
||||
| Bot routes to ChatGPT account directly, not through OLP | Provider config uses OpenClaw stock `openai-codex` instead of a custom OLP-pointing provider | Add `olp-codex` provider per § Using codex / OpenAI models through OLP |
|
||||
| `/models olp-codex/gpt-5.5` typed inline doesn't work | OpenClaw `/models` is menu-only, doesn't accept inline names | Type `/models`, tap the model from the picker menu that appears |
|
||||
|
||||
## Cross-references
|
||||
|
||||
|
||||
@@ -0,0 +1,655 @@
|
||||
# OLP Cloud Deployment Plan — Family Testing Phase
|
||||
|
||||
**Status:** Draft — pending current Phase 6 completion
|
||||
**Target:** Oracle Cloud VM (existing infrastructure)
|
||||
**Audience:** Project maintainer deployment reference
|
||||
**Scope:** Single-VM deployment for family (3–5 users), spawn-binary architecture, public internet exposure with hardened auth
|
||||
|
||||
---
|
||||
|
||||
## 0. Prerequisites
|
||||
|
||||
- OLP current phase (Phase 6) is closed and tagged
|
||||
- Oracle Cloud VM accessible via SSH (existing `opc` user)
|
||||
- Domain name (optional but strongly recommended for TLS)
|
||||
- Provider CLI OAuth completed on at least one machine (credentials transferable)
|
||||
|
||||
---
|
||||
|
||||
## 1. Architecture Overview
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────┐
|
||||
│ Family Devices (anywhere on internet) │
|
||||
│ │
|
||||
│ Wife iPad / Kid Laptop / Maintainer MacBook / ... │
|
||||
│ IDE: Cline / Continue.dev / Cursor / Aider / OpenClaw │
|
||||
│ Config: OPENAI_BASE_URL=https://olp.example.com/v1 │
|
||||
│ OPENAI_API_KEY=olp_<personal-key> │
|
||||
└──────────────────────────┬─────────────────────────────────────────┘
|
||||
│ HTTPS (TLS 1.3)
|
||||
▼
|
||||
┌────────────────────────────────────────────────────────────────────┐
|
||||
│ Oracle Cloud VM │
|
||||
│ │
|
||||
│ ┌─ iptables / OCI Security List ──────────────────────────────┐ │
|
||||
│ │ ALLOW: TCP 443 (HTTPS) from 0.0.0.0/0 │ │
|
||||
│ │ ALLOW: TCP 22 (SSH) from maintainer IP only │ │
|
||||
│ │ DENY: everything else │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─ Nginx (reverse proxy + TLS termination) ───────────────────┐ │
|
||||
│ │ :443 → TLS (Let's Encrypt auto-renew via certbot) │ │
|
||||
│ │ proxy_pass → http://127.0.0.1:4567 │ │
|
||||
│ │ Rate limit: 30 req/min per IP (burst 10) │ │
|
||||
│ │ Request body limit: 1MB │ │
|
||||
│ │ Connection timeout: 300s (streaming needs long timeout) │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─ OLP server.mjs ───────────────────────────────────────────┐ │
|
||||
│ │ OLP_BIND=127.0.0.1 (loopback only — Nginx fronts it) │ │
|
||||
│ │ OLP_PORT=4567 │ │
|
||||
│ │ auth.allow_anonymous: false │ │
|
||||
│ │ auth.advertise_anonymous_key: false │ │
|
||||
│ │ Per-key audit logging to ~/.olp/logs/audit.ndjson │ │
|
||||
│ │ Owner key: maintainer only │ │
|
||||
│ │ Guest keys: one per family member │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─ Provider CLIs (installed on this VM) ──────────────────────┐ │
|
||||
│ │ claude → ~/.claude/.credentials.json (OAuth) │ │
|
||||
│ │ codex → ~/.codex/auth.json (OAuth) │ │
|
||||
│ │ vibe → ~/.vibe/.env (API key) │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─ systemd service ──────────────────────────────────────────┐ │
|
||||
│ │ olp.service: auto-start, auto-restart on crash │ │
|
||||
│ │ Runs as dedicated `olp` user (not root, not opc) │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
│ Provider CLIs spawn outbound HTTPS calls
|
||||
▼
|
||||
Anthropic API / OpenAI API / Mistral API
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Security Design (7 Layers)
|
||||
|
||||
### Layer 1 — Network Perimeter (OCI Security List + iptables)
|
||||
|
||||
**Principle:** Minimum attack surface. Only two ports reachable from the internet.
|
||||
|
||||
```
|
||||
OCI Security List (stateful ingress rules):
|
||||
┌──────────┬────────────┬───────────────────────────────┐
|
||||
│ Port │ Protocol │ Source │
|
||||
├──────────┼────────────┼───────────────────────────────┤
|
||||
│ 443 │ TCP │ 0.0.0.0/0 (public HTTPS) │
|
||||
│ 22 │ TCP │ <maintainer-IP>/32 only │
|
||||
└──────────┴────────────┴───────────────────────────────┘
|
||||
|
||||
NOT exposed:
|
||||
- Port 4567 (OLP direct) — Nginx fronts it
|
||||
- Port 80 (HTTP) — only for certbot ACME challenge, redirect to 443
|
||||
```
|
||||
|
||||
**iptables backup** (defense in depth — OCI Security List is primary, iptables is secondary):
|
||||
|
||||
```bash
|
||||
# Drop everything by default
|
||||
sudo iptables -P INPUT DROP
|
||||
sudo iptables -P FORWARD DROP
|
||||
|
||||
# Allow established connections
|
||||
sudo iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
|
||||
|
||||
# Allow loopback
|
||||
sudo iptables -A INPUT -i lo -j ACCEPT
|
||||
|
||||
# Allow SSH from maintainer IP only
|
||||
sudo iptables -A INPUT -p tcp --dport 22 -s <MAINTAINER_IP> -j ACCEPT
|
||||
|
||||
# Allow HTTPS from anywhere
|
||||
sudo iptables -A INPUT -p tcp --dport 443 -j ACCEPT
|
||||
|
||||
# Allow HTTP (certbot ACME only — Nginx redirects everything else)
|
||||
sudo iptables -A INPUT -p tcp --dport 80 -j ACCEPT
|
||||
|
||||
# Persist
|
||||
sudo iptables-save | sudo tee /etc/iptables/rules.v4
|
||||
```
|
||||
|
||||
### Layer 2 — TLS Termination (Nginx + Let's Encrypt)
|
||||
|
||||
**Principle:** All client traffic encrypted. OLP itself runs plain HTTP on loopback — simpler, no cert management in Node.
|
||||
|
||||
```nginx
|
||||
# /etc/nginx/sites-available/olp.conf
|
||||
|
||||
# Redirect HTTP → HTTPS
|
||||
server {
|
||||
listen 80;
|
||||
server_name olp.example.com;
|
||||
|
||||
# Let's Encrypt ACME challenge
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# HTTPS — TLS 1.3 only
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name olp.example.com;
|
||||
|
||||
# TLS config
|
||||
ssl_certificate /etc/letsencrypt/live/olp.example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/olp.example.com/privkey.pem;
|
||||
ssl_protocols TLSv1.3; # TLS 1.3 only
|
||||
ssl_prefer_server_ciphers off; # TLS 1.3 manages its own
|
||||
ssl_session_timeout 1d;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
|
||||
# Security headers
|
||||
add_header Strict-Transport-Security "max-age=63072000" always;
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
add_header X-Frame-Options DENY;
|
||||
|
||||
# Rate limiting (per IP)
|
||||
limit_req zone=olp_limit burst=10 nodelay;
|
||||
|
||||
# Request body size (LLM prompts can be large but cap at 1MB)
|
||||
client_max_body_size 1m;
|
||||
|
||||
# Proxy to OLP
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:4567;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# SSE streaming support (critical for /v1/chat/completions)
|
||||
proxy_set_header Connection '';
|
||||
proxy_buffering off; # Don't buffer SSE
|
||||
proxy_cache off;
|
||||
chunked_transfer_encoding on;
|
||||
|
||||
# Long timeouts for LLM inference
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_read_timeout 300s; # 5 min — long reasoning
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
}
|
||||
|
||||
# Rate limit zone definition (in http {} block of nginx.conf)
|
||||
# limit_req_zone $binary_remote_addr zone=olp_limit:10m rate=30r/m;
|
||||
```
|
||||
|
||||
**Certbot auto-renewal:**
|
||||
|
||||
```bash
|
||||
sudo certbot certonly --webroot -w /var/www/certbot -d olp.example.com
|
||||
# Auto-renew via systemd timer (certbot installs this automatically)
|
||||
```
|
||||
|
||||
### Layer 3 — Application Auth (OLP Multi-Key)
|
||||
|
||||
**Principle:** Every request must carry a valid API key. No anonymous access. Per-key audit trail.
|
||||
|
||||
```json
|
||||
// ~/.olp/config.json on the cloud VM
|
||||
{
|
||||
"auth": {
|
||||
"allow_anonymous": false,
|
||||
"advertise_anonymous_key": false,
|
||||
"owner_only_endpoints": [
|
||||
"/health",
|
||||
"/v0/management/dashboard-data",
|
||||
"/v0/management/quota",
|
||||
"/v0/management/status",
|
||||
"/cache/stats",
|
||||
"/dashboard"
|
||||
],
|
||||
"fallback_detail_header_policy": "owner_only"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Key provisioning plan:**
|
||||
|
||||
```
|
||||
┌───────────────┬──────────┬─────────────────────────────────────┐
|
||||
│ Key name │ Tier │ providers_enabled │
|
||||
├───────────────┼──────────┼─────────────────────────────────────┤
|
||||
│ cloud-owner │ owner │ all (dashboard + management access) │
|
||||
│ wife-ipad │ guest │ anthropic, openai │
|
||||
│ kid-laptop │ guest │ anthropic only (cost control) │
|
||||
│ maintainer-mb │ guest │ all (daily driver, not owner tier) │
|
||||
└───────────────┴──────────┴─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Why maintainer uses a guest key for daily driving:** owner key gives access to management endpoints. Routine IDE usage should not carry owner privilege. Owner key is used only for dashboard access and administration.
|
||||
|
||||
**Key lifecycle:**
|
||||
- Keys generated on the cloud VM via `olp-keys keygen`
|
||||
- Plaintext token communicated to family member via secure channel (Signal / iMessage, not email)
|
||||
- Each key logged independently in audit.ndjson (per-key `key_id` field)
|
||||
- Revocation: `olp-keys revoke --id=<key-id>` — immediate, no grace period
|
||||
|
||||
### Layer 4 — Process Isolation (Dedicated User + systemd)
|
||||
|
||||
**Principle:** OLP runs as a non-root, non-login user. Crash recovery is automatic.
|
||||
|
||||
```bash
|
||||
# Create dedicated user
|
||||
sudo useradd --system --shell /usr/sbin/nologin --home-dir /opt/olp olp
|
||||
|
||||
# OLP code
|
||||
sudo mkdir -p /opt/olp
|
||||
sudo git clone https://github.com/dtzp555-max/olp.git /opt/olp/app
|
||||
sudo chown -R olp:olp /opt/olp
|
||||
|
||||
# OLP data (keys, config, logs, cache)
|
||||
sudo mkdir -p /home/olp/.olp/{keys,logs,cache}
|
||||
sudo chown -R olp:olp /home/olp
|
||||
```
|
||||
|
||||
**systemd unit:**
|
||||
|
||||
```ini
|
||||
# /etc/systemd/system/olp.service
|
||||
[Unit]
|
||||
Description=OLP — Open LLM Proxy
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=olp
|
||||
Group=olp
|
||||
|
||||
WorkingDirectory=/opt/olp/app
|
||||
ExecStart=/usr/bin/node server.mjs
|
||||
|
||||
# Environment
|
||||
Environment=OLP_BIND=127.0.0.1
|
||||
Environment=OLP_PORT=4567
|
||||
Environment=NODE_ENV=production
|
||||
Environment=HOME=/home/olp
|
||||
|
||||
# Auto-restart on crash
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
StartLimitIntervalSec=60
|
||||
StartLimitBurst=5
|
||||
|
||||
# Security hardening
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=false
|
||||
ReadWritePaths=/home/olp/.olp
|
||||
PrivateTmp=true
|
||||
|
||||
# Resource limits
|
||||
LimitNOFILE=65536
|
||||
MemoryMax=1G
|
||||
|
||||
# Logging
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=olp
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
### Layer 5 — Credential Protection (Provider OAuth Tokens)
|
||||
|
||||
**Principle:** OAuth tokens are the crown jewels. Stolen tokens = someone else using your Claude/OpenAI subscription.
|
||||
|
||||
```
|
||||
Credential storage on cloud VM:
|
||||
|
||||
~olp/
|
||||
├── .claude/
|
||||
│ └── .credentials.json # chmod 600, owner=olp
|
||||
├── .codex/
|
||||
│ └── auth.json # chmod 600, owner=olp
|
||||
└── .vibe/
|
||||
└── .env # chmod 600, owner=olp
|
||||
|
||||
Security measures:
|
||||
1. chmod 600 on all credential files (olp user only)
|
||||
2. Credential files NOT in the git repo (already .gitignored)
|
||||
3. No credential in env vars (OLP reads from filesystem)
|
||||
4. Credential transfer: scp from local machine, then delete local copy of the scp command from shell history
|
||||
5. Periodic rotation: re-auth quarterly (or on any suspicion of compromise)
|
||||
```
|
||||
|
||||
**Credential transfer procedure:**
|
||||
|
||||
```bash
|
||||
# FROM maintainer's Mac mini (one-time):
|
||||
|
||||
# 1. Claude credentials
|
||||
scp ~/.claude/.credentials.json opc@<cloud-ip>:/tmp/claude-cred.json
|
||||
ssh opc@<cloud-ip> "sudo mv /tmp/claude-cred.json /home/olp/.claude/.credentials.json && sudo chown olp:olp /home/olp/.claude/.credentials.json && sudo chmod 600 /home/olp/.claude/.credentials.json"
|
||||
|
||||
# 2. Codex credentials
|
||||
scp ~/.codex/auth.json opc@<cloud-ip>:/tmp/codex-cred.json
|
||||
ssh opc@<cloud-ip> "sudo mv /tmp/codex-cred.json /home/olp/.codex/auth.json && sudo chown olp:olp /home/olp/.codex/auth.json && sudo chmod 600 /home/olp/.codex/auth.json"
|
||||
|
||||
# 3. Mistral API key
|
||||
ssh opc@<cloud-ip> "sudo -u olp bash -c 'echo MISTRAL_API_KEY=sk-xxx > ~/.vibe/.env && chmod 600 ~/.vibe/.env'"
|
||||
|
||||
# 4. Verify
|
||||
ssh opc@<cloud-ip> "sudo -u olp node /opt/olp/app/bin/olp.mjs doctor --json" | jq '.checks[] | select(.name | contains("auth"))'
|
||||
```
|
||||
|
||||
### Layer 6 — Audit and Monitoring
|
||||
|
||||
**Principle:** Every request logged. Anomalies detectable. No silent failures.
|
||||
|
||||
**Audit (already built into OLP):**
|
||||
- `~/.olp/logs/audit.ndjson` — append-only, per-request, includes `key_id`, provider, model, cache hit/miss, fallback hops
|
||||
- Daily rotation: `audit-YYYY-MM-DD.ndjson` (built-in, triggers on first append after UTC midnight)
|
||||
- External rotation tool: `olp-audit-rotate` (idempotent, cron-safe)
|
||||
|
||||
**Additional monitoring for cloud deployment:**
|
||||
|
||||
```bash
|
||||
# Cron: daily audit rotation (belt-and-suspenders alongside in-server rotation)
|
||||
0 0 * * * /usr/bin/node /opt/olp/app/bin/olp-audit-rotate.mjs
|
||||
|
||||
# Cron: daily health check + alert
|
||||
*/5 * * * * curl -sf -H "Authorization: Bearer $OLP_OWNER_KEY" https://olp.example.com/health > /dev/null || echo "OLP health check failed at $(date)" >> /home/olp/alerts.log
|
||||
|
||||
# Cron: audit log size check (alert if >100MB — suggests anomalous traffic)
|
||||
0 6 * * * find /home/olp/.olp/logs -name 'audit*.ndjson' -size +100M -exec echo "Large audit log: {}" \; >> /home/olp/alerts.log
|
||||
|
||||
# Cron: disk usage check
|
||||
0 6 * * * df -h / | awk 'NR==2 && $5+0 > 80 {print "Disk usage above 80%: "$5}' >> /home/olp/alerts.log
|
||||
```
|
||||
|
||||
**What to watch for (manually, weekly):**
|
||||
1. `olp-keys list` — any unexpected keys?
|
||||
2. Dashboard (`/dashboard`) — unusual request volume? Unknown providers being hit?
|
||||
3. `journalctl -u olp --since "7 days ago" | grep -c ERROR` — error spike?
|
||||
4. Audit log: `grep "fallback" ~/.olp/logs/audit.ndjson | wc -l` — fallback frequency (high = provider instability)
|
||||
|
||||
### Layer 7 — Update and Recovery
|
||||
|
||||
**Principle:** Rollback within 60 seconds. No data loss on failed update.
|
||||
|
||||
**Update procedure:**
|
||||
|
||||
```bash
|
||||
# SSH to cloud VM as opc
|
||||
|
||||
# 1. Snapshot before update (Oracle Cloud console or CLI)
|
||||
# OCI CLI: oci compute boot-volume-backup create ...
|
||||
|
||||
# 2. Pull latest code
|
||||
cd /opt/olp/app
|
||||
sudo -u olp git fetch origin main
|
||||
sudo -u olp git log --oneline HEAD..origin/main # review what's coming
|
||||
|
||||
# 3. Run tests BEFORE deploying
|
||||
sudo -u olp git checkout main
|
||||
sudo -u olp git pull
|
||||
sudo -u olp node test-features.mjs
|
||||
# STOP if tests fail
|
||||
|
||||
# 4. Restart service
|
||||
sudo systemctl restart olp
|
||||
sleep 3
|
||||
sudo systemctl status olp # verify running
|
||||
|
||||
# 5. Smoke test
|
||||
curl -sf -H "Authorization: Bearer $OLP_OWNER_KEY" https://olp.example.com/health | jq .ok
|
||||
# Expect: true
|
||||
```
|
||||
|
||||
**Rollback:**
|
||||
|
||||
```bash
|
||||
# If update breaks things:
|
||||
cd /opt/olp/app
|
||||
sudo -u olp git checkout <previous-tag> # e.g. v0.6.0
|
||||
sudo systemctl restart olp
|
||||
```
|
||||
|
||||
**Backup (automated):**
|
||||
|
||||
```bash
|
||||
# Cron: daily backup of OLP state (keys + config + recent audit)
|
||||
0 3 * * * tar czf /home/opc/backups/olp-state-$(date +\%Y\%m\%d).tar.gz -C /home/olp .olp/keys .olp/config.json .olp/logs/audit.ndjson 2>/dev/null; find /home/opc/backups -name 'olp-state-*' -mtime +30 -delete
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Implementation Checklist
|
||||
|
||||
Execute in order. Each step has a verification gate — do not proceed if the gate fails.
|
||||
|
||||
### Phase A — VM Preparation
|
||||
|
||||
```
|
||||
[ ] A1. SSH to Oracle Cloud VM, verify Node.js >= 18
|
||||
Gate: `node --version` prints v18+
|
||||
|
||||
[ ] A2. Create `olp` system user
|
||||
Gate: `id olp` shows the user exists
|
||||
|
||||
[ ] A3. Clone OLP repo to /opt/olp/app
|
||||
Gate: `sudo -u olp node /opt/olp/app/test-features.mjs` — all tests pass
|
||||
|
||||
[ ] A4. Install provider CLIs (as olp user)
|
||||
- npm install -g @anthropic-ai/claude-code
|
||||
- npm install -g @openai/codex
|
||||
- (mistral vibe if needed)
|
||||
Gate: `which claude && which codex` both resolve
|
||||
|
||||
[ ] A5. Transfer OAuth credentials (Layer 5 procedure)
|
||||
Gate: `sudo -u olp claude auth status` shows authenticated
|
||||
```
|
||||
|
||||
### Phase B — Security Hardening
|
||||
|
||||
```
|
||||
[ ] B1. Configure OCI Security List (Layer 1)
|
||||
Gate: nmap from external IP shows only 22 and 443 open
|
||||
|
||||
[ ] B2. Configure iptables backup (Layer 1)
|
||||
Gate: `sudo iptables -L -n` matches the plan
|
||||
|
||||
[ ] B3. Install + configure Nginx (Layer 2)
|
||||
Gate: `curl -I http://olp.example.com` returns 301 → HTTPS
|
||||
|
||||
[ ] B4. Obtain Let's Encrypt certificate
|
||||
Gate: `curl -I https://olp.example.com` returns valid cert
|
||||
|
||||
[ ] B5. Verify Nginx SSE passthrough
|
||||
Gate: test streaming request completes without timeout
|
||||
```
|
||||
|
||||
### Phase C — OLP Configuration
|
||||
|
||||
```
|
||||
[ ] C1. Write ~/.olp/config.json (Layer 3 — auth config)
|
||||
Gate: config validates (no startup warnings in journal)
|
||||
|
||||
[ ] C2. Generate owner key
|
||||
Gate: `olp-keys list --owner-only` shows 1 owner key
|
||||
|
||||
[ ] C3. Generate family guest keys (one per person)
|
||||
Gate: `olp-keys list` shows correct count
|
||||
|
||||
[ ] C4. Install systemd unit (Layer 4)
|
||||
Gate: `systemctl status olp` shows active (running)
|
||||
|
||||
[ ] C5. Verify /health with owner key
|
||||
Gate: `curl -H "Authorization: Bearer $OWNER_KEY" https://olp.example.com/health | jq .ok` → true
|
||||
|
||||
[ ] C6. Verify /health rejects unauthenticated
|
||||
Gate: `curl https://olp.example.com/health` → 401
|
||||
|
||||
[ ] C7. Verify guest key cannot access /dashboard
|
||||
Gate: `curl -H "Authorization: Bearer $GUEST_KEY" https://olp.example.com/dashboard` → 403
|
||||
|
||||
[ ] C8. End-to-end LLM request with guest key
|
||||
Gate: streaming chat completion returns a valid response
|
||||
```
|
||||
|
||||
### Phase D — Monitoring Setup
|
||||
|
||||
```
|
||||
[ ] D1. Install cron jobs (Layer 6)
|
||||
Gate: `crontab -l` shows all 4 jobs
|
||||
|
||||
[ ] D2. Verify daily backup cron
|
||||
Gate: manual trigger produces valid tar.gz
|
||||
|
||||
[ ] D3. Test health-check alert
|
||||
Gate: stop OLP, wait 5min, check alerts.log has entry
|
||||
```
|
||||
|
||||
### Phase E — Family Onboarding
|
||||
|
||||
```
|
||||
[ ] E1. Send each family member their API key via Signal/iMessage
|
||||
(NOT via email, NOT via any cloud-stored medium)
|
||||
|
||||
[ ] E2. Each family member configures their IDE:
|
||||
export OPENAI_BASE_URL=https://olp.example.com/v1
|
||||
export OPENAI_API_KEY=olp_<their-key>
|
||||
|
||||
[ ] E3. Each family member runs a test prompt
|
||||
Gate: audit.ndjson shows their key_id in the log
|
||||
|
||||
[ ] E4. Verify per-key provider scoping
|
||||
Gate: kid's key cannot hit providers outside their scope
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Security Threat Model
|
||||
|
||||
| Threat | Mitigation | Residual Risk |
|
||||
|---|---|---|
|
||||
| **Brute-force API key** | 32-byte entropy = 2^256 keyspace; Nginx rate limit 30r/m | Negligible |
|
||||
| **TLS downgrade** | TLS 1.3 only; HSTS header | None with modern clients |
|
||||
| **Credential theft (OAuth tokens on VM)** | chmod 600 + dedicated user + no root access to OLP dirs | VM root compromise (mitigated by OCI IAM) |
|
||||
| **Stolen guest key** | Single-key revocation via `olp-keys revoke`; per-key audit trail for forensics | Window between theft and detection |
|
||||
| **DDoS** | OCI DDoS protection (free tier) + Nginx rate limit + Nginx connection limit | Sustained volumetric attack may overwhelm free-tier VM |
|
||||
| **Provider credential abuse** | OLP is the only consumer; anomalous spend visible on provider dashboard | Provider-side detection lag |
|
||||
| **Supply chain (OLP code tampered)** | Git clone from known repo; `npm test` before deploy; no npm dependencies | Compromised maintainer GitHub account |
|
||||
| **Log exfiltration** | audit.ndjson contains no message content (PII guard per ADR 0008); only metadata | Key IDs in logs (low sensitivity) |
|
||||
|
||||
---
|
||||
|
||||
## 5. Operational Runbooks
|
||||
|
||||
### Runbook: OAuth Token Expired
|
||||
|
||||
```
|
||||
Symptom: /health shows provider auth.ok=false; fallback firing on every request
|
||||
Diagnosis: sudo -u olp claude auth status → "not authenticated" or expired
|
||||
|
||||
Fix:
|
||||
1. sudo -u olp claude setup-token
|
||||
2. Complete OAuth flow (browser URL → paste code)
|
||||
3. Verify: sudo -u olp claude auth status → authenticated
|
||||
4. No OLP restart needed — next spawn picks up new credentials
|
||||
```
|
||||
|
||||
### Runbook: Revoke a Compromised Key
|
||||
|
||||
```
|
||||
Symptom: suspicious traffic in audit.ndjson from a specific key_id
|
||||
grep "<suspected-key-id>" ~/.olp/logs/audit.ndjson | tail -20
|
||||
|
||||
Fix:
|
||||
1. olp-keys revoke --id=<key-id>
|
||||
2. Notify family member: "Your key was revoked. Here's a new one."
|
||||
3. olp-keys keygen --name=<new-name> --providers=<same-providers>
|
||||
4. Send new key via secure channel
|
||||
```
|
||||
|
||||
### Runbook: VM Disk Full
|
||||
|
||||
```
|
||||
Symptom: OLP stops writing audit logs; new requests may fail
|
||||
Diagnosis: df -h /
|
||||
|
||||
Fix:
|
||||
1. Purge old audit logs: find ~/.olp/logs -name 'audit-202*.ndjson' -mtime +90 -delete
|
||||
2. Purge old backups: find /home/opc/backups -name 'olp-state-*' -mtime +60 -delete
|
||||
3. Purge cache if needed: rm -rf ~/.olp/cache/*
|
||||
4. Verify: df -h / shows >20% free
|
||||
```
|
||||
|
||||
### Runbook: OLP Process Crash Loop
|
||||
|
||||
```
|
||||
Symptom: systemctl status olp shows "activating (auto-restart)"
|
||||
Diagnosis: journalctl -u olp --since "10 min ago" | tail -50
|
||||
|
||||
Common causes:
|
||||
- Port conflict → check `lsof -nP -iTCP:4567`
|
||||
- Corrupt config.json → validate JSON syntax
|
||||
- Node.js version drift → `node --version`
|
||||
|
||||
Fix:
|
||||
1. Fix root cause
|
||||
2. sudo systemctl restart olp
|
||||
3. Gate: `curl -H "Authorization: Bearer $OWNER_KEY" https://olp.example.com/health | jq .ok`
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Cost Estimate (Oracle Cloud Free Tier)
|
||||
|
||||
| Resource | Spec | Cost |
|
||||
|---|---|---|
|
||||
| VM | ARM Ampere A1 (4 OCPU, 24GB RAM) | **Free** (Always Free tier) |
|
||||
| Boot volume | 200GB | **Free** (up to 200GB) |
|
||||
| Outbound bandwidth | 10TB/month | **Free** (first 10TB) |
|
||||
| Public IP | 1 reserved | **Free** |
|
||||
| Domain | olp.example.com | ~$10/year (external registrar) |
|
||||
| TLS cert | Let's Encrypt | **Free** |
|
||||
| **Total** | | **~$10/year** (domain only) |
|
||||
|
||||
Oracle Cloud's Always Free ARM VM is overprovisioned for this use case. OLP + Nginx + 3 provider CLIs will use <1GB RAM and negligible CPU (the LLM inference happens at the provider, not here).
|
||||
|
||||
---
|
||||
|
||||
## 7. Migration Path to Commercial
|
||||
|
||||
This family deployment is a stepping stone. When commercial service is ready:
|
||||
|
||||
| Aspect | Family (this plan) | Commercial (future) |
|
||||
|---|---|---|
|
||||
| Upstream | spawn CLI (subscription) | direct API (commercial key) |
|
||||
| Auth | OLP multi-key (filesystem) | Registration + billing system |
|
||||
| TLS | Let's Encrypt (single domain) | Managed cert (Cloudflare / AWS ACM) |
|
||||
| Compute | Single VM (Oracle Free) | Container cluster (auto-scale) |
|
||||
| Monitoring | Cron + manual | Prometheus + Grafana + PagerDuty |
|
||||
| Rate limit | Nginx per-IP | Per-key token bucket in OLP |
|
||||
| Data | ~/.olp/ filesystem | PostgreSQL + S3 |
|
||||
|
||||
The deployment experience from this plan directly informs the commercial architecture. Every operational runbook becomes a feature requirement for the commercial platform.
|
||||
|
||||
---
|
||||
|
||||
**Authors:** project maintainer (with AI drafting assistance)
|
||||
**Created:** 2026-05-27
|
||||
@@ -0,0 +1,274 @@
|
||||
# PI231 Spike — Ephemeral $HOME / $CODEX_HOME Override Verification
|
||||
|
||||
**Date:** 2026-05-29
|
||||
**Operator:** project maintainer (via PI231 SSH)
|
||||
**Spike artifact:** `tlab@172.16.2.231:/tmp/olp-spike-20260529-100243/`
|
||||
**ADR context:** ADR 0014 Amendment 1 § A1.2 Layer 1 — "Per-spawn ephemeral home directory"
|
||||
**Task ref:** OLP task list #4 ("PI231 spike — verify Claude / Codex HOME / CODEX_HOME override behavior")
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
**Both providers PASS.** Setting `HOME` (claude) and `CODEX_HOME` (codex) before spawn redirects 100% of CLI state writes into the ephemeral location. Real `~/.claude/`, `~/.claude.json`, and `~/.codex/` were unmodified by the spike. Credentials accessed via symlink work end-to-end (model returned "PONG" for both providers). Solution 1 is implementable today; Tasks #5-#8 unblocked.
|
||||
|
||||
One non-blocking caveat for codex (PATH-helper installation refused under `/tmp` paths — § Caveats).
|
||||
|
||||
Vibe (mistral) not on PI231; pinned to a follow-up spike when the CLI is installed.
|
||||
|
||||
---
|
||||
|
||||
## 1. Environment
|
||||
|
||||
| Component | Value |
|
||||
|---|---|
|
||||
| Host | `tlab@172.16.2.231` (RPi4-P8-231, Debian Bookworm arm64) |
|
||||
| Real `~` | `/home/tlab` |
|
||||
| `claude` | `/home/tlab/.npm-global/bin/claude` — v2.1.152 |
|
||||
| `codex` | `/home/tlab/.npm-global/bin/codex` — v0.133.0 |
|
||||
| `vibe` | not installed |
|
||||
| Prod OLP | running (port 4567 with `OLP_SANDBOX_DISABLED=1`) — spike does not interfere |
|
||||
|
||||
Pre-state mtimes (from spike `pre-mtimes.txt`):
|
||||
```
|
||||
1779999955 /home/tlab/.claude.json
|
||||
1779999956 /home/tlab/.claude/.credentials.json
|
||||
1779759544 /home/tlab/.codex/auth.json
|
||||
```
|
||||
|
||||
Marker file timestamps (pre-spike) used to detect any post-spike write to real home.
|
||||
|
||||
---
|
||||
|
||||
## 2. Methodology
|
||||
|
||||
Both providers tested per the same skeleton:
|
||||
|
||||
```bash
|
||||
SPIKE_ROOT=/tmp/olp-spike-<timestamp>
|
||||
mkdir -p $SPIKE_ROOT/<provider>-home/.<provider>
|
||||
ln -s ~/.<provider>/<credential-file> $SPIKE_ROOT/<provider>-home/.<provider>/<credential-file>
|
||||
|
||||
<ENV_OVERRIDE>=<path> timeout 90 <provider> <invocation> "say PONG and nothing else"
|
||||
|
||||
find $SPIKE_ROOT/<provider>-home -printf "%y %M %s %p\n" # what landed in fake home
|
||||
find ~/.<provider> ~/.<provider>.json -newer <marker> # did real home get modified
|
||||
```
|
||||
|
||||
The `find -newer <marker>` test is the load-bearing assertion: if it returns **empty**, the redirect held perfectly. If it returns any path, the CLI silently fell back to the real `$HOME`-derived path despite the env override.
|
||||
|
||||
---
|
||||
|
||||
## 3. Phase B — claude CLI (anthropic)
|
||||
|
||||
### 3.1 Invocation
|
||||
|
||||
```bash
|
||||
HOME=$SPIKE_ROOT/claude-home timeout 60 claude \
|
||||
--print "say PONG and nothing else" \
|
||||
--no-session-persistence \
|
||||
--model claude-sonnet-4-6
|
||||
```
|
||||
|
||||
Credentials linked: `$SPIKE_ROOT/claude-home/.claude/.credentials.json` → `/home/tlab/.claude/.credentials.json`
|
||||
|
||||
### 3.2 Result
|
||||
|
||||
```
|
||||
PONG
|
||||
```
|
||||
|
||||
Exit 0. Model returned through Anthropic API via OAuth token from the symlinked real credentials. End-to-end success.
|
||||
|
||||
### 3.3 Fake home post-state (decisive evidence)
|
||||
|
||||
Files written under `$SPIKE_ROOT/claude-home/`:
|
||||
|
||||
```
|
||||
.claude/.credentials.json (symlink — unchanged)
|
||||
.claude/projects/-home-tlab/<uuid>.jsonl (135 bytes — project transcript)
|
||||
.claude/projects/-home-tlab/memory/ (created)
|
||||
.claude/sessions/ (drwx------ private)
|
||||
.claude/backups/.claude.json.backup.1780012965052 (50 bytes — pre-write backup)
|
||||
.claude.json (23,182 bytes — fresh)
|
||||
.cache/claude-cli-nodejs/-home-tlab/mcp-logs-claude-ai-Gmail/<ts>.jsonl
|
||||
.cache/claude-cli-nodejs/-home-tlab/mcp-logs-claude-ai-Google-Calendar/<ts>.jsonl
|
||||
.cache/claude-cli-nodejs/-home-tlab/mcp-logs-claude-ai-Google-Drive/<ts>.jsonl
|
||||
```
|
||||
|
||||
**`.claude.json` (23 KB) was written to the ephemeral location.** This is the file whose non-atomic write upstream (anthropics/claude-code#29250) drove ADR 0014 Amendment 1 § A1.1.2. The Solution 1 architecture removes the maintenance-treadmill concern by letting this file land in tmpfs — confirmed working.
|
||||
|
||||
`projects/-home-tlab/` — claude encodes the spawn CWD (`/home/tlab`) by replacing `/` with `-`. Not relevant to isolation; would also be the path if claude ran with the real `$HOME`.
|
||||
|
||||
### 3.4 Real home post-state
|
||||
|
||||
```bash
|
||||
$ find ~/.claude.json ~/.claude -newer $SPIKE_ROOT/marker
|
||||
# (empty)
|
||||
|
||||
$ stat -c "%Y %n" ~/.claude.json ~/.claude/.credentials.json
|
||||
1779999955 /home/tlab/.claude.json
|
||||
1779999956 /home/tlab/.claude/.credentials.json
|
||||
```
|
||||
|
||||
Both mtimes identical to pre-state. **Real `~/.claude.json` was not touched by the spike.**
|
||||
|
||||
### 3.5 Verdict
|
||||
|
||||
✅ **PASS.** claude v2.1.152 honours `HOME` env override completely. All state writes redirect to the ephemeral location. Symlinked credentials work for auth. The Layer 1 + Layer 2 architecture per ADR 0014 Amendment 1 § A1.2 is implementable for anthropic without further work.
|
||||
|
||||
---
|
||||
|
||||
## 4. Phase B — codex CLI (openai)
|
||||
|
||||
### 4.1 Invocation (final, working)
|
||||
|
||||
The first attempt used `--ask-for-approval never` per docs found in pre-spike research — that flag has been **removed in codex v0.133.0**. Help output shows it must be passed as a config override: `-c approval_policy="never"`. Retry:
|
||||
|
||||
```bash
|
||||
echo "say PONG and nothing else" | \
|
||||
HOME=$SPIKE_ROOT/codex-home \
|
||||
CODEX_HOME=$SPIKE_ROOT/codex-home/.codex \
|
||||
timeout 90 codex exec \
|
||||
--skip-git-repo-check \
|
||||
-c approval_policy=\"never\" \
|
||||
--sandbox read-only \
|
||||
"say PONG and nothing else"
|
||||
```
|
||||
|
||||
Credentials linked: `$SPIKE_ROOT/codex-home/.codex/auth.json` → `/home/tlab/.codex/auth.json`
|
||||
|
||||
### 4.2 Result
|
||||
|
||||
```
|
||||
WARNING: proceeding, even though we could not update PATH: Refusing to create
|
||||
helper binaries under temporary dir "/tmp"
|
||||
(codex_home: AbsolutePathBuf("/tmp/olp-spike-20260529-100243/codex-home/.codex"))
|
||||
Reading additional input from stdin...
|
||||
OpenAI Codex v0.133.0
|
||||
--------
|
||||
workdir: /home/tlab
|
||||
model: gpt-5.5
|
||||
provider: openai
|
||||
approval: never
|
||||
sandbox: read-only
|
||||
reasoning effort: none
|
||||
reasoning summaries: none
|
||||
session id: 019e710b-dc28-79f0-854a-06be116b4830
|
||||
--------
|
||||
user
|
||||
say PONG and nothing else
|
||||
...
|
||||
codex
|
||||
PONG
|
||||
tokens used
|
||||
10,826
|
||||
```
|
||||
|
||||
Exit 0. Model invoked, returned "PONG", session id assigned.
|
||||
|
||||
**The `WARNING` is significant — see § 5 Caveats. Key fact:** the warning's path embed `codex_home: AbsolutePathBuf("/tmp/olp-spike-…")` proves `CODEX_HOME` was parsed and honoured. The warning is a *narrow* refusal (PATH helper binary install), not a refusal of `CODEX_HOME` itself.
|
||||
|
||||
### 4.3 Fake home post-state (decisive evidence)
|
||||
|
||||
```
|
||||
.codex/auth.json (symlink — unchanged)
|
||||
.codex/models_cache.json (200,842 bytes)
|
||||
.codex/installation_id (36 bytes)
|
||||
.codex/cache/codex_apps_tools/<hash>.json (92,600 bytes)
|
||||
.codex/goals_1.sqlite (24,576 bytes)
|
||||
.codex/logs_2.sqlite (49,152 bytes)
|
||||
.codex/state_5.sqlite (180,224 bytes)
|
||||
.codex/shell_snapshots/ (created)
|
||||
.codex/memories/ (created)
|
||||
.codex/skills/ (created)
|
||||
.codex/sessions/2026/05/29/ (date-partitioned)
|
||||
.codex/.tmp/plugins-clone-<rand>/.git/... (cloned plugins repo)
|
||||
```
|
||||
|
||||
State scale: ~500 KB across 3 SQLite DBs + model cache + plugin checkout. Far more than claude writes. **All of it landed in the ephemeral location.**
|
||||
|
||||
### 4.4 Real home post-state
|
||||
|
||||
```bash
|
||||
$ find ~/.codex -newer $SPIKE_ROOT/codex-marker3
|
||||
# (empty)
|
||||
|
||||
$ stat -c "%Y %n" ~/.codex/auth.json
|
||||
1779759544 /home/tlab/.codex/auth.json
|
||||
```
|
||||
|
||||
Mtime unchanged. **Real `~/.codex` was not touched by the spike.**
|
||||
|
||||
### 4.5 Verdict
|
||||
|
||||
✅ **PASS.** codex v0.133.0 honours `CODEX_HOME` env override for ALL state files. Symlinked auth artifact works for API authentication. The codex inner sandbox (read-only by default per ADR 0002 Amendment 9 § Per-provider codex declaration) initialized and ran without error.
|
||||
|
||||
---
|
||||
|
||||
## 5. Caveats
|
||||
|
||||
### 5.1 codex PATH helper warning
|
||||
|
||||
Codex's startup includes a step that tries to install helper binaries into PATH (presumably under `$CODEX_HOME/bin/` or similar). When `$CODEX_HOME` is under `/tmp/`, codex refuses this step for security reasons (anti-prefix-attack on PATH):
|
||||
|
||||
```
|
||||
WARNING: proceeding, even though we could not update PATH:
|
||||
Refusing to create helper binaries under temporary dir "/tmp"
|
||||
```
|
||||
|
||||
**Impact for OLP**: none of the load-bearing functionality is affected. The model invocation completed, auth worked, all session state landed in `$CODEX_HOME`. The skipped step is for shell-completion-style helpers that the spawn-binary architecture does not need.
|
||||
|
||||
**If we ever do need those helpers**: ephemeral root would need to move out of `/tmp/`. Candidates: `/var/lib/olp-spawn/<keyId>/<reqId>/` (operator-managed) or `~/.olp/spawn/<keyId>/<reqId>/` (within OLP's own data root). Decision deferred — not required for Phase 7 implementation.
|
||||
|
||||
### 5.2 claude project-path encoding (`-home-tlab`)
|
||||
|
||||
claude encodes the spawn cwd into project paths by replacing `/` with `-`. The encoded value reflects the **real cwd at spawn time** (`/home/tlab` → `-home-tlab`), not the ephemeral `$HOME`. This is expected: cwd is a separate input from `$HOME`.
|
||||
|
||||
**Impact for OLP**: none. The encoding is internal to claude's project tracking. OLP spawn pipeline already runs each request from a per-spawn cwd if it wants to isolate cwd separately; that is orthogonal to Layer 1's `$HOME` redirect.
|
||||
|
||||
### 5.3 codex v0.133.0 flag set drift
|
||||
|
||||
The pre-spike research cited `--ask-for-approval never` as the non-interactive approval flag (sourced from OpenAI docs pages indexed before v0.133.0 changed the flag layout). v0.133.0 instead requires `-c approval_policy="never"` via the generic config-override flag. ADR 0002 Amendment 9 § codex `toolHardeningArgs` declaration uses `--sandbox read-only` which is still a valid top-level flag; no amendment update required. **Implementation note (Task #7)**: codex.mjs `toolHardeningArgs` should not inject `--ask-for-approval` — use `-c approval_policy="never"` if the policy needs to be locked at spawn time.
|
||||
|
||||
### 5.4 Mistral `vibe` CLI not present on PI231
|
||||
|
||||
`which vibe` returned empty. Vibe is not currently part of the PI231 test deployment per the topology memory (`~/.cc-rules/memory/projects/olp/topology_pi231_server_2026_05_27.md`). The ADR 0002 Amendment 9 mistral declaration uses `VIBE_HOME` per the Mistral docs page (3 occurrences verified at amendment time). The observed-behavior verification is a follow-up spike triggered when vibe is installed.
|
||||
|
||||
---
|
||||
|
||||
## 6. Implications for ADR 0014 Amendment 1
|
||||
|
||||
| Architecture claim | Spike result |
|
||||
|---|---|
|
||||
| Layer 1 (ephemeral `$HOME` / `$CODEX_HOME`) is implementable | ✅ Confirmed for anthropic + codex |
|
||||
| `~/.claude.json` upstream non-atomic-write concern is solved by redirect | ✅ Confirmed — write lands in tmpfs `.claude.json`, real one untouched |
|
||||
| Layer 2 (symlinked credentials) preserves auth | ✅ Confirmed — both providers authenticated via symlink |
|
||||
| codex inner sandbox composes with Layer 1 (no nested-bwrap conflict) | ✅ Confirmed — codex `--sandbox read-only` initialized and ran |
|
||||
| Solution 1 obsoletes outer-bwrap maintenance treadmill | ✅ Confirmed — no `--ro-bind` mount patches required |
|
||||
|
||||
No architectural changes required. ADR 0014 Amendment 1 is **validated by primary-source observation on the target deployment**.
|
||||
|
||||
---
|
||||
|
||||
## 7. Unblocked / next
|
||||
|
||||
Tasks unblocked by this spike's PASS verdict:
|
||||
- Task #5 — refactor `lib/sandbox/manager.mjs` to `prepareIsolatedEnvironment()` per Layer 1 + Layer 2
|
||||
- Task #6 — add `ISOLATION` block to `lib/providers/anthropic.mjs`
|
||||
- Task #7 — add `ISOLATION` block to `lib/providers/codex.mjs` (use `-c approval_policy="never"` per § 5.3, not `--ask-for-approval`)
|
||||
- Task #8 — wire `prepareIsolatedEnvironment` into `server.mjs` spawn site
|
||||
|
||||
Follow-ups not blocking:
|
||||
- Vibe spike when CLI is installed (verify documented `VIBE_HOME` behavior matches observed)
|
||||
- codex PATH-helper out-of-`/tmp` consideration if the helpers ever become required
|
||||
|
||||
---
|
||||
|
||||
## 8. Artifact retention
|
||||
|
||||
The spike root `/tmp/olp-spike-20260529-100243/` on PI231 is automatically cleaned by tmpfs lifetime / reboot. No commit of binary artifacts. Evidence above is the canonical record.
|
||||
|
||||
---
|
||||
|
||||
**Authored** by project maintainer 2026-05-29; commands executed on PI231 with maintainer's SSH session.
|
||||
@@ -0,0 +1,435 @@
|
||||
# TUI-mode — Deployment-A Implementation Plan (PR-0 … PR-3)
|
||||
|
||||
- **Date:** 2026-05-30
|
||||
- **Status:** Implementation plan (pre-code). Derived verbatim from the final design spec
|
||||
`docs/superpowers/specs/2026-05-30-tui-mode-production-design.md` (3 review passes + spikes S1/S2/S3 + pre-code gates T1/T3/T6). **Decisions in the spec are NOT re-litigated here.**
|
||||
- **Scope:** **Deployment A only** (single-user / OCP canary). Deployment B (multi-tenant) is DEFERRED behind spikes **T2** (body-capture `tools:[]`) + **T4** (concurrency). B's gating hooks (`--tools ""`, `--strict-mcp-config`, `--disallowedTools "mcp__*"`, per-spawn MCP-disable verification) are **wired in PR-2 but B is not enabled** — no per-key guest path ships in this plan.
|
||||
- **Authority of record (to be created in PR-3):** ADR 0016 (or ADR 0009 Amendment 2) — see PR-3.
|
||||
- **Iron Rules in force:** 10 (independent reviewer), 11 (minimum reviewable unit — one PR per layer), 12 (prior-art search done = the spikes). `ALIGNMENT.md` Rule 1 (cite authority) + Rule 2 (no inventing CLI behavior) + Rule 5 (release-kit).
|
||||
- **Author credit (binding, §13):** every implementing commit carries `Co-Authored-By: jaekwon-park <…>` (pull the real email/handle from OCP PR #101 before committing — do NOT invent). ADR 0016 names PR #101 + jaekwon-park in its acknowledgment section. Add jaekwon-park to CONTRIBUTORS and notify on PR #101 at ship time.
|
||||
|
||||
---
|
||||
|
||||
## 0. Ground-truth code anchors (verified against the real tree)
|
||||
|
||||
Everything below cites the exact function/line the change hooks into. Re-verify line numbers at edit time (the files churn).
|
||||
|
||||
| Surface | Location (verified) | Role in TUI-mode |
|
||||
|---|---|---|
|
||||
| `spawn(irRequest, authContext, isolationCtx)` (public contract) | `lib/providers/anthropic.mjs:1164` → delegates to `_spawnAndStream` | **PR-3** branches here on `CLAUDE_TUI_MODE`. Default falls through to `_spawnAndStream` (stream-json) UNCHANGED. |
|
||||
| `_spawnAndStream(irRequest, authContext, spawnImpl, isolationCtx)` | `anthropic.mjs:872` | The default transport. **Not modified** by TUI-mode (PR-3 adds a sibling branch in the public `spawn`, it does not touch `_spawnAndStream`). |
|
||||
| `buildCliArgs(model, systemPrompt)` | `anthropic.mjs:834` (returns `--model … --output-format stream-json --verbose --no-session-persistence --system-prompt …`) | TUI driver builds its **own** argv (no `-p`, no `--output-format`); it does NOT reuse `buildCliArgs`. Cited as the contrast surface. |
|
||||
| `extractSystemPrompt(irRequest)` | `anthropic.mjs:123` (always prefixes `OLP_SYSTEM_PROMPT_WRAPPER` `:109`) | **REUSED unchanged** by the TUI driver to compute the `--system-prompt` value. |
|
||||
| `irToAnthropic(irRequest)` | `anthropic.mjs:601` (serializes user/assistant/tool; skips `system`) | **REUSED unchanged** — produces the prompt body text the TUI driver writes to the prompt file (§6 recipe). |
|
||||
| `ISOLATION` named export | `anthropic.mjs:1667` (`ephemeralEnvOverrides`→`{HOME}`, `credentialMounts`, `requiredHomePaths:['.claude']`, `hasInnerSandbox:false`) | **PR-0** EXTENDS with a TUI-only seed hook. |
|
||||
| `prepareIsolatedEnvironment({provider,keyId,reqId})` | `lib/sandbox/manager.mjs:203` → returns `{ephemeralRoot, envOverrides, hardenedArgs, wrapForLayer3, cleanup}` | **PR-0** consumes the new seed step; **PR-2** driver calls it to get `ephemeralRoot`. Note the **test bypass at `:223`** (returns `_legacyShape()` under `test-features.mjs` unless `globalThis.__OLP_FORCE_ISOLATION_IN_TEST`). |
|
||||
| Buffered spawn call site | `server.mjs:1347` (`prepareIsolatedEnvironment`) → `:1355` (`for await … hopProviderPlugin.spawn(...)`) inside `collectAllChunks()` (`:1299`); result cached via `cacheStore.getOrCompute(keyId, hopCacheKey, collectAllChunks)` at `:1445` | **computeFn returns an ARRAY of IR chunks.** TUI transport must yield `[{type:'delta',role:'assistant',content},{type:'stop',finish_reason:'stop'}]` so this path is unchanged. |
|
||||
| Streaming spawn call site | `server.mjs:1564` (`prepareIsolatedEnvironment`) → `:1570` (`for await … streamPlugin.spawn(...)`) inside `sourceWithRelease()`; coordinated via `cacheStore.getOrComputeStreaming(keyId, streamCacheKey, sourceFactory, …)` at `:1587` | **sourceFactory returns an ASYNC GENERATOR of IR chunks.** TUI transport yields the same 2-chunk shape → SSE replay (`irChunkToOpenAISSE` at `server.mjs:1764`) is byte-identical to the stream-json path. This is the §3.1 single-buffered-then-replay mechanism. |
|
||||
| `irChunkToOpenAISSE`, `SSE_DONE` | imported `server.mjs:38`; used `:1764`, `:1772` | **REUSED unchanged** for `stream:true` replay. |
|
||||
| `max_tokens` parse | `lib/ir/openai-to-ir.mjs:182` (sets `ir.max_tokens`) | Accepted into IR, **dropped at CLI boundary** (§4.5). Same for `temperature` `:190`, `top_p` `:198`, `stop` `:206` (§4.6). |
|
||||
| `validateKey` / `owner_tier` / `providers_enabled` | `lib/keys.mjs:414`; tiers `'owner'|'guest'|'anonymous'` (`:428`,`:463`) | **REUSED unchanged.** A's canary runs owner-tier. B's guest gating is wired but inert. |
|
||||
|
||||
**Cache contract crux (load-bearing for PR-1).** `server.mjs` does NOT expect a string from the transport. It expects **IR chunks** — an array (buffered, `getOrCompute`) or an async generator (streaming, `getOrComputeStreaming`). The TUI transcript reader (PR-1) resolves a **single string**; the TUI driver/provider-branch (PR-2/PR-3) is responsible for the thin adapter `string → [delta, stop]` so both existing cache paths consume it with **zero modification**. This is the concrete meaning of spec §3.2 "returns a resolved response string adapted to the getOrCompute/singleflight cache contract."
|
||||
|
||||
---
|
||||
|
||||
## Cross-cutting contracts (define these FIRST; every PR conforms)
|
||||
|
||||
### C1. Transport interface (so node-pty can slot later — spec §8)
|
||||
|
||||
A single interface in `lib/tui/session.mjs`; tmux is the only implementation in this plan; node-pty is a stubbed adapter behind the same interface.
|
||||
|
||||
```
|
||||
interface TuiTransport {
|
||||
// create the session bound to ephemeralRoot, spawn `claude` interactive, settle to input box
|
||||
open({ bin, args, env, cwd, ephemeralRoot, reqId }): Promise<SessionHandle>
|
||||
// submit one prompt body (T3 recipe: file → send-keys -- "$(cat f)" → separate Enter)
|
||||
submit(handle, promptText): Promise<void>
|
||||
// teardown: kill session + nothing else (ephemeral root rm is the manager.cleanup's job, but
|
||||
// the driver MUST also kill the session in a trap/finally — §8)
|
||||
close(handle): Promise<void>
|
||||
// startup-time orphan reaper (kill restart-surviving sessions) — §5.5
|
||||
reapOrphans(): Promise<{ killed: string[] }>
|
||||
}
|
||||
```
|
||||
|
||||
`tmuxTransport` implements all four. `nodePtyTransport` is a stub that throws `NOT_IMPLEMENTED` (present so the interface boundary is real and reviewable). The transcript reader (C2) and IR mapping never import the transport — they only consume the deterministic transcript path, so swapping transports later touches nothing else.
|
||||
|
||||
### C2. Transcript-reader interface (PR-1 owns it; transport-agnostic)
|
||||
|
||||
```
|
||||
computeTranscriptPath({ ephemeralRoot, cwd, sessionId }): string // §4.1 formula, pure
|
||||
readTurnResult({ transcriptPath, sinceUserContent, wallClockCapMs, pollMs }):
|
||||
Promise<{ text: string, durationMs?: number, messageCount?: number }> // resolves the assistant text
|
||||
// throws TuiCompletionError on guard-(B) terminal conditions (tool_use / wall-clock cap) — §4.4
|
||||
```
|
||||
|
||||
`readTurnResult` is the **dual-signal** completion engine. It never imports tmux/node-pty. It is unit-tested entirely against captured JSONL fixtures.
|
||||
|
||||
### C3. `CLAUDE_TUI_MODE` flag semantics (binding)
|
||||
|
||||
- **Unset / not `"1"`** → default path. **Byte-for-byte unchanged** from today: `_spawnAndStream` (stream-json), `ISOLATION` with NO seed, no `.claude.json` written, no new on-disk sensitive data. This is a **hard requirement** (spec §7.1) and is the regression invariant (C4).
|
||||
- **`CLAUDE_TUI_MODE=1`** → TUI transport: ephemeral home seeded (PR-0), tmux interactive `claude` (PR-2), transcript-read completion (PR-1), provider branch (PR-3).
|
||||
- The flag is read **once** in the provider `spawn()` branch (PR-3) — `process.env.CLAUDE_TUI_MODE === '1'`. It is the ONLY toggle. No config-file alternative in this plan.
|
||||
- Sub-flags (A-only, all default-off, all gated under `CLAUDE_TUI_MODE=1`): `CLAUDE_TUI_WARM_POOL` (§7.2 — **out of scope for this plan; not implemented, only namespace-reserved**).
|
||||
|
||||
### C4. Default-path-unchanged invariant + how to test it
|
||||
|
||||
- **Invariant:** with `CLAUDE_TUI_MODE` unset, no code path added by PR-0..PR-3 executes. `ISOLATION` returns the same shape, `_spawnAndStream` is the only transport, no `.claude.json` is seeded.
|
||||
- **Test (regression guard, runs in every PR):** the full existing `test-features.mjs` suite stays green. Additionally PR-0 adds an explicit assertion: `prepareIsolatedEnvironment` for the anthropic provider with `CLAUDE_TUI_MODE` unset produces an ephemeral root containing **no** `.claude.json` (only the symlinked `.credentials.json` + `.claude/` dir, as today). PR-3 adds: `spawn()` with the flag unset calls `_spawnAndStream` (assert via the existing `__setSpawnImpl` seam — the mock spawn is invoked, the TUI driver is NOT).
|
||||
|
||||
---
|
||||
|
||||
## PR-0 — ISOLATION extend (TUI-only `.claude.json` seed)
|
||||
|
||||
### 1. Goal
|
||||
Seed a minimal `.claude.json` (onboarding/trust/bypass markers ONLY) into the ephemeral home **only when `CLAUDE_TUI_MODE` is active**, so a fresh-home interactive `claude` drops straight to the input box instead of hanging on first-run onboarding — while the default stream-json path's bootstrap stays byte-for-byte unchanged.
|
||||
|
||||
### 2. Files touched
|
||||
- `lib/providers/anthropic.mjs` — extend the `ISOLATION` block (`:1667`).
|
||||
- `lib/sandbox/manager.mjs` — add the opt-in seed step to `prepareIsolatedEnvironment` (`:203`), gated so it is a no-op unless the caller requests it.
|
||||
- `test-features.mjs` — new suite (seed-on / seed-off / permissions).
|
||||
- *(no new file in PR-0)*
|
||||
|
||||
### 3. Concrete changes
|
||||
|
||||
**3a. `ISOLATION` gains a seed descriptor (NOT a function that reads the real home unconditionally).** Add to the anthropic `ISOLATION` object an OPTIONAL field describing the TUI seed, e.g.:
|
||||
|
||||
```
|
||||
// anthropic.mjs ISOLATION (extend, after requiredHomePaths)
|
||||
tuiSeed: { // consumed ONLY when prepareIsolatedEnvironment is called with { tui:true }
|
||||
relPath: '.claude.json', // written under ephemeralRoot
|
||||
mode: 0o600, // §5.5 — same care as the bearer
|
||||
// builder is pure-ish: it reads the real ~/.claude.json ONCE to copy oauthAccount/userID,
|
||||
// strips `projects`, and stamps onboarding/trust/bypass markers + a pre-trusted cwd.
|
||||
build: ({ cwd }) => ({ /* hasCompletedOnboarding:true, oauthAccount, userID,
|
||||
bypassPermissionsModeAccepted:true,
|
||||
projects: { [cwd]: { hasTrustDialogAccepted:true, … } } */ }),
|
||||
}
|
||||
```
|
||||
|
||||
- **Authority/contract note:** ADR 0002 Amendment 9's `credentialMounts` is deliberately a static list (not a function) for auditability; the seed is a NEW optional field, so PR-0 must add a one-paragraph Amendment-9 note (in ADR 0002, co-merged or referenced) stating the seed reads the real `~/.claude.json` exactly once to copy `oauthAccount`/`userID`, writes mode-600, and carries **no MCP-disable weight** (T6 negative control, spec §5.2 / §7.1). The seed is onboarding/trust/bypass ONLY.
|
||||
- **The seed does NOT disable managed MCP** (T6 negative control). PR-0 must NOT add `claudeAiMcpEverConnected` manipulation or any MCP field. A code comment cites spec §5.2 + T6.
|
||||
|
||||
**3b. `prepareIsolatedEnvironment` gains a `tui` opt-in param.** Change the signature to `prepareIsolatedEnvironment({ provider, keyId, reqId, tui = false })` (`manager.mjs:203`). After the existing Layer-2 symlink loop (`:318`), add a guarded block:
|
||||
|
||||
```
|
||||
if (tui && isolation?.tuiSeed) {
|
||||
// chmod 700 the ephemeralRoot (§5.5), write isolation.tuiSeed.build({cwd}) JSON
|
||||
// at join(ephemeralRoot, tuiSeed.relPath) with { mode: tuiSeed.mode }, never log contents.
|
||||
}
|
||||
```
|
||||
|
||||
- **Default path is untouched:** existing call sites at `server.mjs:1347` and `:1564` pass NO `tui` flag → `tui=false` → seed block is skipped → identity behavior. This satisfies C4. The TUI driver (PR-2) is the ONLY caller that passes `tui:true`.
|
||||
- **`chmod 700` the ephemeral root** (§5.5) is applied **inside the `tui` block** so the default path's permission semantics are also unchanged. (The default path created the root via `mkdirSync` at `:250`; PR-0 does not alter that.)
|
||||
- **Per-`keyId` isolation** is already structurally given by the `/tmp/olp-spawn/<safeKeyId>/<safeReqId>/home` path (`manager.mjs:247`). PR-0 adds an assertion/comment that the parent `<safeKeyId>` dir is not world-traversable (chmod 700 on the chain) — §5.5.
|
||||
- **Test bypass interaction (`manager.mjs:223`):** the existing test-runner bypass returns `_legacyShape()`. PR-0's seed tests MUST set `globalThis.__OLP_FORCE_ISOLATION_IN_TEST = true` to exercise the real path, then unset it in `finally` (this seam already exists).
|
||||
|
||||
### 4. Unit tests + fixtures (`test-features.mjs`)
|
||||
- [ ] **seed-off (default-path invariant, C4):** call `prepareIsolatedEnvironment({provider:anthropic, keyId, reqId})` (no `tui`) under `__OLP_FORCE_ISOLATION_IN_TEST` → assert ephemeralRoot has `.claude/.credentials.json` symlink + `.claude/` dir and **NO `.claude.json`**.
|
||||
- [ ] **seed-on:** call with `{ tui:true }` → assert `.claude.json` exists, is mode `600`, parses as JSON, contains `hasCompletedOnboarding:true` + `bypassPermissionsModeAccepted:true` + a pre-trusted `projects[cwd]`, and contains **NO** `mcpServers`/`claudeAiMcpEverConnected` field (negative assertion — T6).
|
||||
- [ ] **root permissions:** assert ephemeralRoot is mode `700` on the `tui:true` path.
|
||||
- [ ] **no-real-home-mutation:** assert the real `~/.claude.json` is not written/modified (read-only copy).
|
||||
- [ ] Fixture: a minimal fake `~/.claude.json` (via a temp HOME or an injected reader seam) carrying a dummy `oauthAccount`/`userID` so the test never touches the operator's real account file.
|
||||
- [ ] Full existing suite stays green (regression).
|
||||
|
||||
### 5. PI231 integration checkpoint (maintainer-supervised; /tmp scratch only)
|
||||
Run on PI231 scratch (prod OLP on :4567 untouched):
|
||||
- [ ] Drive `prepareIsolatedEnvironment({tui:true})` against a scratch keyId/reqId; `ls -la` the ephemeral root.
|
||||
- [ ] **Pass criteria:** `.claude.json` present, mode `600`; root mode `700`; symlinked `.credentials.json` present; `cat` the seed shows onboarding/trust/bypass markers and **no MCP fields**; the real `~/.claude.json` mtime unchanged.
|
||||
- [ ] Launch interactive `claude` by hand bound to that ephemeral HOME and confirm it **does not** hang on onboarding (drops to input box). (This is the load-bearing reason PR-0 exists.)
|
||||
|
||||
### 6. Acceptance criteria (binding, testable)
|
||||
- With `tui` unset, ephemeral home is byte-identical to today (no `.claude.json`). ✔ regression test + PI231.
|
||||
- With `tui:true`, seed is written mode-600, root mode-700, onboarding/trust/bypass present, MCP fields absent.
|
||||
- No change to default stream-json spawn behavior; full suite green.
|
||||
|
||||
### 7. Reviewer (Iron Rule 10)
|
||||
Fresh-context reviewer opens **spec §7.1 + §5.2 (T6 negative control) + ADR 0002 Amendment 9** and confirms: (a) the seed is gated on the opt-in `tui` param so the default path is unchanged; (b) the seed carries NO MCP-disable field (T6); (c) mode-600 seed + mode-700 root + per-keyId isolation per §5.5; (d) the Amendment-9 note documenting the new `tuiSeed` field is present. A review that does not name the §5.2 negative control is not a valid approval.
|
||||
|
||||
### 8. Authority citation (commit + PR body)
|
||||
`claude` CLI v2.1.158 § first-run onboarding (theme/login pickers) + `$HOME`-redirect behavior (ADR 0002 Amendment 9 anthropic ISOLATION pin); ADR 0002 Amendment 9 (ISOLATION contract); spec §7.1 + §5.2; PI231 ephemeral-home spike `docs/spikes/2026-05-29-ephemeral-home.md`. State explicitly: **the seed does NOT disable managed MCP — that is the spawn-argv flag in PR-2 (T6).**
|
||||
|
||||
### Risk / rollback
|
||||
Independently revertable (revert reinstates the pre-seed ISOLATION; default path was never touched). Default-off: nothing reaches users — the seed only fires when a caller passes `tui:true`, and no caller does until PR-2/PR-3.
|
||||
|
||||
---
|
||||
|
||||
## PR-1 — Transcript reader (`lib/tui/transcript.mjs`)
|
||||
|
||||
### 1. Goal
|
||||
A transport-agnostic reader that computes the deterministic transcript path, polls for lazy file creation, detects turn completion via the **mandatory dual-signal guard** (turn_duration OR tool_use OR wall-clock cap; NO quiescence in v1), extracts the assistant text, and resolves a single response string.
|
||||
|
||||
### 2. Files touched
|
||||
- **NEW** `lib/tui/transcript.mjs`.
|
||||
- `test-features.mjs` — new transcript-reader suite.
|
||||
- Fixtures dir (NEW) `docs/spikes/fixtures/tui/` — captured real JSONL (see §4).
|
||||
|
||||
### 3. Concrete changes (exports + signatures)
|
||||
|
||||
- `export function computeTranscriptPath({ ephemeralRoot, cwd, sessionId })` — **pure.** Implements §4.1: `<ephemeralRoot>/.claude/projects/<CWD_ENCODED>/<sessionId>.jsonl` where `CWD_ENCODED` = `cwd` with **every** `/` → `-` **including the leading slash** (`/tmp/x` → `-tmp-x`). No filesystem access. (OLP generates `sessionId` and `cwd`, so the path is known before spawn.)
|
||||
- `export async function readTurnResult({ transcriptPath, sinceUserContent, wallClockCapMs = 120_000, pollMs = 500, toolUseIsTerminal = true })`:
|
||||
- **Lazy-create poll:** the file is created on first message, not at spawn (§4.1). Tolerate ENOENT; poll every `pollMs` until the file exists or `wallClockCapMs` elapses (then throw `TuiCompletionError('completion-marker timeout')`).
|
||||
- **Dual-signal completion (§4.4, MANDATORY):**
|
||||
- **(A) happy path:** a line `{"type":"system","subtype":"turn_duration"}` for this turn appears → done. Carries `durationMs` + `messageCount`. Do NOT rely on file-tail byte ordering (§4.3 trap): re-scan the file, find the matching `user` line for `sinceUserContent`, collect all subsequent `assistant`/`text` blocks.
|
||||
- **(B) co-equal terminal guard (mandatory, never-hang):** if the last assistant message carries `stop_reason:"tool_use"` → throw `TuiCompletionError('tool-use turn unsupported in TUI-mode')` (maps to clean 502). If `wallClockCapMs` fires → throw `TuiCompletionError('completion-marker timeout')`.
|
||||
- **NO quiescence cut in v1** (§4.4 ⚠️): do NOT abort on "file size-stable for N seconds" — a long Opus/extended-thinking turn legitimately produces no growth. Quiescence is added only after spike T5. (Comment cites §4.4 explicitly so a future contributor does not "helpfully" add it.)
|
||||
- Do NOT key off `stop_reason:"end_turn"` alone (§4.3 trap — appears on both `thinking` and `text` blocks).
|
||||
- **Assistant-text extraction (§4.2):** `JSON.parse` per line (native log → escaping-clean). Response = concatenation of `text`-type content blocks from `assistant` messages emitted **since the matching `user` line**. Return `{ text, durationMs, messageCount }`.
|
||||
- **Trailing-newline normalization (§3.2 / §6 caveat):** the input box strips the source's single trailing newline. `sinceUserContent` matching MUST normalize the trailing newline before comparing source-prompt vs the transcript `user` line, or the "matching user line" lookup (and any cache-key reasoning) sees a spurious mismatch.
|
||||
- **Cache-contract adapter note (does NOT live in PR-1, but PR-1's return shape is designed for it):** `readTurnResult` resolves a string; the PR-2/PR-3 layer wraps it as `[{type:'delta',role:'assistant',content:text},{type:'stop',finish_reason:'stop'}]`. PR-1's JSDoc states this adapter contract and points at `server.mjs:1299` (buffered array) + `server.mjs:1558` (streaming generator) so the reviewer sees the two consumers. **max_tokens/sampling graceful-drop boundary** (§4.5/§4.6): PR-1 documents that these IR fields never reach this layer (interactive `claude` has no flag); nothing to do — they are dropped at the CLI-args boundary in PR-2/PR-3. PR-1 adds a comment asserting `finish_reason` is always `'stop'` (no `length` mapping, since max_tokens is not enforced).
|
||||
|
||||
### 4. Unit tests + fixtures
|
||||
**Fixtures (capture REAL JSONL on PI231 — do not hand-fabricate the shapes):**
|
||||
- [ ] `text-turn.jsonl` — a normal `end_turn` text answer ending in a `turn_duration` line.
|
||||
- [ ] `refusal-turn.jsonl` — a refusal that still emits `turn_duration` (T1: `durationMs≈3221`).
|
||||
- [ ] `tool-use-no-marker.jsonl` — **MANDATORY** (T1): a `tool_use` turn whose last assistant line is `stop_reason:"tool_use"` with **NO** `turn_duration` line. This is the hang case guard (B) must catch.
|
||||
- [ ] `out-of-order.jsonl` — a text block flushed by byte-position AFTER `turn_duration` though `turn_duration` has the later timestamp (§4.3 trap) — proves the reader does not rely on file-tail ordering.
|
||||
- [ ] `multiturn.jsonl` — two user lines so `sinceUserContent` selection is exercised (a `toolUseResult:true` user line within a turn must NOT be mistaken for a new submit — §6 step 5).
|
||||
|
||||
**Tests:**
|
||||
- [ ] `computeTranscriptPath` exact-string equality incl. leading-slash encoding.
|
||||
- [ ] happy path returns concatenated text + `durationMs`/`messageCount`.
|
||||
- [ ] refusal path returns refusal text (still completes).
|
||||
- [ ] **tool-use fixture → throws `TuiCompletionError` (never hangs)** — assert with a short `wallClockCapMs` that the throw is the tool_use detection, not the timeout (distinguish the two error messages).
|
||||
- [ ] wall-clock cap fires on a never-completing fixture (truncated file with no marker) → throws within cap.
|
||||
- [ ] out-of-order fixture → correct text (no reliance on last byte).
|
||||
- [ ] trailing-newline normalization: `sinceUserContent` with trailing `\n` still matches the transcript user line.
|
||||
- [ ] **no-quiescence assertion:** a fixture that is size-stable for > pollMs but has not completed does NOT abort before the wall-clock cap (proves quiescence is excluded).
|
||||
- [ ] Full existing suite stays green (PR-1 adds a new module + new tests only; touches no existing path).
|
||||
|
||||
### 5. PI231 integration checkpoint (maintainer-supervised)
|
||||
- [ ] Capture the 5 fixtures above from real `claude` v2.1.158 runs on PI231 scratch (this is also how the fixtures are sourced). Commit them under `docs/spikes/fixtures/tui/`.
|
||||
- [ ] **Pass criteria:** `readTurnResult` against each freshly-captured fixture returns the same text a human reads in the transcript; the tool-use capture throws `TuiCompletionError` and never blocks; cap fires deterministically on a manually-truncated fixture.
|
||||
|
||||
### 6. Acceptance criteria (binding)
|
||||
- Deterministic path matches §4.1 exactly.
|
||||
- Dual-signal completion: completes on `turn_duration`; **never hangs** on tool-use or a missing marker (guard B); **no quiescence cut**.
|
||||
- Escaping-clean text extraction; trailing-newline normalized.
|
||||
- Pure reader: zero tmux/node-pty import; fully fixture-testable.
|
||||
|
||||
### 7. Reviewer (Iron Rule 10)
|
||||
Fresh-context reviewer opens **spec §4.1–§4.4 (and §3.2 cache-contract / trailing-newline)** and confirms: (a) the path formula incl. leading-slash; (b) the dual-signal guard is present AND quiescence is explicitly excluded with a §4.4 citation; (c) the tool-use-no-marker fixture exists and the test proves a non-hanging terminal throw; (d) the resolved-string return is documented against the `getOrCompute`/`getOrComputeStreaming` consumers. A review missing the tool-use-no-marker check is not valid.
|
||||
|
||||
### 8. Authority citation
|
||||
`claude` CLI v2.1.158 § native session transcript JSONL (`turn_duration` is an undocumented internal-log behavior — pin to v2.1.158, re-verify per CLI/Ink bump); spec §4 (S2 PASS) + §4.4 (T1 PARTIAL); fixtures captured PI231 2026-05-30. No OpenAI-spec surface (reader is internal). ALIGNMENT Rule 2: the reader consumes a behavior `claude` actually emits — no invented format.
|
||||
|
||||
### Risk / rollback
|
||||
New file + new tests only; revert deletes the module and tests, default path untouched. Riskiest sub-step is the dual-signal guard's tool-use detection (the hang vector) — fully covered by the mandatory fixture.
|
||||
|
||||
---
|
||||
|
||||
## PR-2 — Session driver (`lib/tui/session.mjs`)
|
||||
|
||||
### 1. Goal
|
||||
A tmux-backed interactive-`claude` driver behind the transport interface (C1): spawn with the T6 flag set, submit via the T3 recipe, auto-answer dialogs, run the per-spawn MCP-disable verification gate, guarantee teardown via trap/finally, and reap orphan sessions on startup — producing a single buffered response (via PR-1's reader) adapted to IR chunks for both cache paths.
|
||||
|
||||
### 2. Files touched
|
||||
- **NEW** `lib/tui/session.mjs` (tmux transport + node-pty stub + the driver `runTuiTurn`).
|
||||
- `lib/sandbox/manager.mjs` — driver calls `prepareIsolatedEnvironment({…, tui:true})` (the param added in PR-0).
|
||||
- `test-features.mjs` — driver suite (with a mock transport — no real tmux/claude in unit tests).
|
||||
- *(server wiring is PR-3, NOT here)*
|
||||
|
||||
### 3. Concrete changes
|
||||
|
||||
**3a. Transport interface + tmux implementation (C1).**
|
||||
- `export const tmuxTransport` implementing `open/submit/close/reapOrphans`.
|
||||
- `export const nodePtyTransport` — stub throwing `NOT_IMPLEMENTED` (interface placeholder, §8 decision: tmux first).
|
||||
- Session naming: `olp-tui-<keyId>-<reqId>` so `reapOrphans` can pattern-match.
|
||||
|
||||
**3b. Spawn argv (T6 flag set, §5.2) — the driver builds its OWN args (NOT `buildCliArgs`).**
|
||||
```
|
||||
claude --model <m> --session-id <uuid> --system-prompt "<extractSystemPrompt(ir)>"
|
||||
--strict-mcp-config // T6 load-bearing: 0 managed-MCP (no --mcp-config supplied)
|
||||
--disallowedTools "mcp__*" // deny MCP-namespaced tools
|
||||
[--tools "" ] // B-only built-in lockdown — WIRED, gated off for A (see 3g)
|
||||
// NO -p, NO --output-format → real TTY → cc_entrypoint=cli
|
||||
env: CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL=1 // defense-in-depth
|
||||
+ carry-forward: CLAUDE_CODE_DISABLE_CLAUDE_MDS=1, unset ANTHROPIC_* (reuse buildSpawnEnv semantics)
|
||||
+ HOME=<ephemeralRoot> (from prepareIsolatedEnvironment envOverrides)
|
||||
```
|
||||
- `--system-prompt` value comes from **`extractSystemPrompt(ir)` (`anthropic.mjs:123`) — REUSED.** Prompt body comes from **`irToAnthropic(ir)` (`anthropic.mjs:601`) — REUSED** (written to the prompt file, 3d).
|
||||
- **`--bare` is forbidden** (§5.2 — breaks OAuth). Comment cites it.
|
||||
- `--model` from `ir.model`; `--session-id` is the OLP-generated UUID also fed to `computeTranscriptPath`.
|
||||
|
||||
**3c. Per-spawn MCP-disable verification gate (§5.2 (4) preflight semantics).** After `open()` settles, assert **0** dirs matching `$HOME/.cache/claude-cli-nodejs/*/mcp-logs-claude-ai-*` under the ephemeral root. **Do NOT run `/mcp` inside the serving session** (§5.2: it writes a transcript line, consumes a turn, corrupts the reader's matching-user-line semantics). For A's canary the cache-dir assertion is the in-band check; the `/mcp`-empty assertion belongs to a **separate preflight session at startup / CLI upgrade** (wire the preflight hook here but it is owner-tier advisory for A; it becomes a hard gate for B). On assertion failure: tear down + clean 502.
|
||||
|
||||
**3d. Submit recipe (T3 PASS — binding for acceptance, §6).**
|
||||
1. Write `irToAnthropic(ir)` to a file under the ephemeral root (NEVER interpolate into a shell line — backticks/`$()`/`&&`/quotes get mangled by the shell, §6 step 1).
|
||||
2. `tmux send-keys -t <S> -- "$(cat promptfile)"` — the leading `--` end-of-options guard is **required** (prompt starting with `-`). Embedded `\n` are soft line-breaks; do NOT submit. Do NOT use `send-keys -l` for the body (§6 step 2).
|
||||
3. Settle ~1.5–2s for Ink render / paste-collapse (§6 step 3). (Production: poll the pane for input-box-ready / paste-collapse before Enter, or scale settle to payload size — §6 caveat.)
|
||||
4. **Submit Enter as a SEPARATE tmux KEY TOKEN:** `tmux send-keys -t <S> Enter` — never a literal `"\n"` appended to text (Ink #15553, §6 step 4).
|
||||
5. **Verify via TRANSCRIPT** (not `capture-pane`): exactly one `user`-role line whose content equals source minus its single trailing newline (a second `user` line with `toolUseResult:true` is in-turn tool output, not a second submit — §6 step 5). Large pastes collapse to a `[Pasted text …]` placeholder so pane-scraping is impossible — transcript-read is mandatory.
|
||||
6. **Retry** Enter (key token) up to ~4× as a defensive guard (§6 step 6).
|
||||
|
||||
**3e. Dialog auto-answer (S3 footgun, §6).** With the PR-0 seed (trust + bypass pre-seeded) neither dialog should appear. Defensive handling if they do: trust-folder defaults to "1. Yes, I trust" → bare Enter confirms; the **bypass-permissions dialog defaults cursor to "1. No, exit"** — a naive Enter **kills the session** → must send **Down then Enter** to land on "2. Yes, I accept". Prefer the pre-seed; keep the Down+Enter recipe as fallback.
|
||||
|
||||
**3f. Teardown (trap-guaranteed, §8) + orphan reaper (§5.5).**
|
||||
- `close()` + ephemeral-root cleanup MUST run in a `finally` (NOT best-effort) — S3 noted best-effort `rm` left empty `home_*` dirs with stray cred symlinks. The driver wraps the whole turn in `try { … } finally { await transport.close(handle); await isolationCtx.cleanup(); }`.
|
||||
- `tmuxTransport.reapOrphans()` runs at **server startup** (called from PR-3's boot path): list `olp-tui-*` tmux sessions surviving a restart, kill each + `rm -rf` its ephemeral root (these still hold the owner OAuth via the mounted ephemeral home — §5.5). This is the restart-time backstop complementing the steady-state finally.
|
||||
|
||||
**3g. B-gate hooks wired but inert (scope discipline).** `--tools ""` (built-in lockdown) and the `/mcp`-empty hard gate are **present in the code path but only activated for `owner_tier === 'guest'`**, which no A/canary request is. A comment + the ADR state: **B does not launch until T2 (body-capture `tools:[]`) passes; serialized after T2; concurrent only after T4** (§5.2 gate semantics). PR-2 ships the flags; PR-3/B-enablement flips them on. No guest key is provisioned in this plan.
|
||||
|
||||
**3h. Single buffered response + SSE replay (§3.1).** The driver's public entry, e.g. `export async function runTuiTurn({ ir, authContext, keyId, reqId, transport = tmuxTransport })`, returns the **resolved string** from PR-1's `readTurnResult`. The IR-chunk adapter `string → [{type:'delta',role:'assistant',content},{type:'stop',finish_reason:'stop'}]` is applied by PR-3's provider branch so both `getOrCompute` (buffered array) and `getOrComputeStreaming` (async generator) consume it unchanged — for `stream:true` the existing `irChunkToOpenAISSE` replay (`server.mjs:1764`) emits the completed text as one burst of delta(s) + `[DONE]` AFTER the turn finishes. **True token streaming is NOT possible** (§3.1) — `capture-pane` partial-text tapping is explicitly rejected (large pastes collapse to `[Pasted text …]`).
|
||||
|
||||
### 4. Unit tests + fixtures
|
||||
- [ ] **mock transport** (no real tmux/claude): assert the driver builds the exact T6 argv set (`--strict-mcp-config`, `--disallowedTools "mcp__*"`, no `-p`, no `--output-format`, no `--bare`, env `CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL=1`).
|
||||
- [ ] submit recipe shape: prompt written to a file; `send-keys -- "$(cat …)"` issued; Enter is a SEPARATE token; on a simulated missed-Enter the retry fires ≤4×.
|
||||
- [ ] dialog fallback: simulated bypass dialog → driver sends Down+Enter (not bare Enter).
|
||||
- [ ] teardown: assert `close` + `cleanup` fire in `finally` on both happy and thrown paths (inject a throw mid-turn).
|
||||
- [ ] reaper: seed fake `olp-tui-*` session records into the mock transport → `reapOrphans` kills them + rms roots.
|
||||
- [ ] guest-gating: with `owner_tier:'guest'` the argv gains `--tools ""`; with `'owner'` it does not (B-hook wired-but-inert proof).
|
||||
- [ ] string→IR-chunk adapter produces `[delta, stop]` with `finish_reason:'stop'`.
|
||||
- [ ] T3 regression negative control (documented, runs on PI231 not in unit): a newline-as-text submit silently fails (Ink #15553) — guards against a future refactor reintroducing `-l`.
|
||||
- [ ] Full existing suite green; default path (flag-unset) never reaches this module.
|
||||
|
||||
### 5. PI231 integration checkpoint (maintainer-supervised; /tmp scratch + tmux only)
|
||||
- [ ] Real multiline-code request (fenced code block + shell-special chars, ~50 lines per T3) through `runTuiTurn` against real `claude` v2.1.158 on PI231 scratch.
|
||||
- [ ] **Pass criteria:** response text is correct and byte-for-byte intact; exactly ONE `user` submit in the transcript; **`cc_entrypoint=cli` verified** (transcript `turn_duration` line `entrypoint=cli` / `--debug` metadata); MCP-disable gate passes (0 `mcp-logs-claude-ai-*` dirs); the real `~/.claude` is **untouched** (mtime check on `~/.claude.json` + `~/.claude/projects`); session is killed + ephemeral root removed on completion (no stray `home_*`); reaper kills a deliberately-orphaned session on the next startup.
|
||||
- [ ] Re-run the T3 negative control (newline-as-text fails to submit) to confirm the Ink #15553 control still holds on this CLI version.
|
||||
|
||||
### 6. Acceptance criteria (binding)
|
||||
- T6 flag set applied; MCP-disable gate asserts 0 managed-MCP (cache-dir evidence) per spawn.
|
||||
- T3 submit: multiline/shell-special payload submits byte-for-byte, exactly one submit, transcript-verified.
|
||||
- Trap-guaranteed teardown (no stray ephemeral roots / cred symlinks) + startup orphan reaper.
|
||||
- Single buffered response; `stream:true` is SSE-replay (no token streaming). B hooks wired but inert.
|
||||
- `cc_entrypoint=cli` confirmed; real `~/.claude` untouched.
|
||||
|
||||
### 7. Reviewer (Iron Rule 10)
|
||||
Fresh-context reviewer opens **spec §5.2 (T6) + §6 (T3) + §3.1 + §5.5 + §8** and confirms: (a) `--strict-mcp-config` with NO `--mcp-config` is the disable mechanism (not seed-editing); (b) `--bare` is NOT used; (c) the T3 recipe is file→`send-keys -- "$(cat)"`→separate Enter (not `-l`, not literal `\n`); (d) teardown is finally-based + a startup reaper exists; (e) B hooks (`--tools ""`, `/mcp` hard gate) are present but gated to guest and B is documented as blocked on T2/T4; (f) response is single-buffered with SSE replay, no token streaming. A review that does not open the live `claude --help` for `--strict-mcp-config`/`--disallowedTools` on v2.1.158 is not valid.
|
||||
|
||||
### 8. Authority citation
|
||||
`claude` CLI v2.1.158 § `--strict-mcp-config`, § `--disallowedTools`, § `--system-prompt`, § `--session-id`, § `--model` (live `--help` on PI231); env `CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL=1` (binary-confirmed); `tmux` 3.3a § `send-keys`/`send-keys -l`/key-tokens (Ink #15553 control); spec §5.2 (T6 PASS), §6 (T3 PASS), §3.1, §5.5, §8. ALIGNMENT Rule 2: every flag is one `claude` accepts — no invented flag.
|
||||
|
||||
### Risk / rollback
|
||||
Riskiest PR. Independently revertable (deletes the module + the `tui:true` caller; PR-0/PR-1 inert without it). Default-off: no server path invokes `runTuiTurn` until PR-3, and even then only under `CLAUDE_TUI_MODE=1`.
|
||||
|
||||
---
|
||||
|
||||
## PR-3 — Provider wiring + ADR + README
|
||||
|
||||
### 1. Goal
|
||||
Add the `CLAUDE_TUI_MODE` branch in the anthropic provider `spawn()` so a flagged request routes to the TUI driver and yields IR chunks; default stays stream-json. Land ADR 0016 as authority of record and the README docs (quirks + non-honored params + grey-area framing).
|
||||
|
||||
### 2. Files touched
|
||||
- `lib/providers/anthropic.mjs` — branch in public `spawn()` (`:1164`); call `reapOrphans` from a boot hook (or export an init the server calls).
|
||||
- `server.mjs` — call the orphan reaper at startup (near `bootstrapSandbox`, `:82`/boot path); pass `tui:true` to `prepareIsolatedEnvironment` ONLY on the TUI branch (the branch lives in the provider, so the simplest wiring is: the provider's TUI branch calls `prepareIsolatedEnvironment({…, tui:true})` itself; if the existing architecture composes isolation in `server.mjs` before `spawn`, PR-3 adds a flag-gated `tui` pass-through there — decide per the under-spec note below).
|
||||
- `docs/adr/0016-tui-mode.md` — NEW (or ADR 0009 Amendment 2).
|
||||
- `README.md` — env-var table, Troubleshooting, API/Configuration notes.
|
||||
- `CHANGELOG.md` — Unreleased entry (no version bump mid-Phase per CLAUDE.md `phase_rolling_mode`).
|
||||
- `CONTRIBUTORS` — add jaekwon-park.
|
||||
- `test-features.mjs` — branch-selection tests.
|
||||
|
||||
### 3. Concrete changes
|
||||
|
||||
**3a. `spawn()` branch (`anthropic.mjs:1164`).**
|
||||
```
|
||||
export async function* spawn(irRequest, authContext, isolationCtx) {
|
||||
if (process.env.CLAUDE_TUI_MODE === '1') {
|
||||
// import { runTuiTurn } from '../tui/session.mjs'
|
||||
const text = await runTuiTurn({ ir: irRequest, authContext, keyId, reqId, … });
|
||||
yield { type: 'delta', role: 'assistant', content: text };
|
||||
yield { type: 'stop', finish_reason: 'stop' };
|
||||
return;
|
||||
}
|
||||
yield* _spawnAndStream(irRequest, authContext, _spawnImpl, isolationCtx); // UNCHANGED default
|
||||
}
|
||||
```
|
||||
- The default branch (`_spawnAndStream`) is **byte-for-byte unchanged**. C4 invariant holds.
|
||||
- The 2-chunk yield is exactly what `collectAllChunks` (`server.mjs:1299`) buffers into an array for `getOrCompute`, and what `sourceWithRelease` (`server.mjs:1558`) yields for `getOrComputeStreaming` → SSE replay. No server change to the cache paths.
|
||||
- **keyId/reqId access:** the provider `spawn()` currently receives `(irRequest, authContext, isolationCtx)` — it does NOT receive `keyId/reqId`. The TUI driver needs them (for ephemeral root + session name). **Under-spec — see §"Open implementation questions".** Options: (i) thread `keyId/reqId` into the TUI branch via `isolationCtx` (the manager already has `safeKeyId/safeReqId` and `ephemeralRoot`), so the driver reuses `isolationCtx.ephemeralRoot` rather than re-preparing; (ii) pass a `tui:true` to `prepareIsolatedEnvironment` at the server call site (flag-gated) and let the driver consume the returned `ephemeralRoot`. **Recommended: (i)** — the provider's TUI branch reads `isolationCtx.ephemeralRoot` + a reqId carried on `isolationCtx`, and PR-0's seed runs because the server passes `tui: (process.env.CLAUDE_TUI_MODE==='1')` to `prepareIsolatedEnvironment` at `server.mjs:1347` and `:1564`. This keeps the seed/ephemeral-root creation in the manager (one owner) and the tmux drive in the provider. Maintainer to confirm the threading before PR-2 finalizes its `runTuiTurn` signature.
|
||||
|
||||
**3b. Orphan reaper at startup.** Call `tmuxTransport.reapOrphans()` from the server boot path (alongside `bootstrapSandbox`, `server.mjs:82` import region / router init at `:2334`+), gated on `CLAUDE_TUI_MODE==='1'` so default deployments incur zero tmux dependency.
|
||||
|
||||
**3c. max_tokens / sampling graceful-drop (§4.5/§4.6) — already the behavior; just assert + document.** The TUI argv carries no `--max-tokens`/`--temperature`/etc. (interactive `claude` has none). `ir.max_tokens` (`openai-to-ir.mjs:182`), `temperature`, `top_p`, `stop` are accepted into IR and silently dropped at the argv boundary — same posture as the stream-json path. No error. Document in README (3e).
|
||||
|
||||
**3d. ADR 0016 (authority of record).** New ADR: Context (2026-06-15 billing split + ADR 0009 Amd 1 premise), Decision (TTY-backed TUI transport behind `CLAUDE_TUI_MODE`, default stays stream-json), the spike record (S1/S2/S3 + T1/T3/T6 results; T2/T4/T5 open), the §5.2 security model + §5.5 credential coupling, the §3.1 no-token-streaming decision, the §4.5/§4.6 dropped-param decision, A-vs-B gate semantics (no B before T2; serialized after T2; concurrent after T4). **Acknowledgment section names OCP PR #101 + jaekwon-park** (adopted: interactive-TUI-for-subscription idea; redesigned: transcript-read not hook-file, no `--dangerously-skip-permissions`, structural tool-stripping for B). Supersede note on ADR 0009 Amendment 1's billing-pool lane (§Status of the spec).
|
||||
|
||||
**3e. README.** Per CLAUDE.md `release_kit.new_feature_doc_expectations`:
|
||||
- **Environment Variables table:** `CLAUDE_TUI_MODE` (default unset/off; opt-in TTY path; grey-area, billing-favorable, post-2026-06-15-inference), `CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL` (set by TUI driver). Reserve-note `CLAUDE_TUI_WARM_POOL` as A-only future.
|
||||
- **Troubleshooting / TUI-mode §:** onboarding-hang quirk (fresh ephemeral home → seed required, PR-0); **OAuth-login requirement** (one `claude login` on the host; member keys hold OLP keys not OAuth); **NO true token-streaming** (§3.1 — `stream:true` is replay-after-completion, one burst); **`max_tokens`/sampling params not honored** (§4.5–§4.6); honest grey-area framing (§10.2 — opt-in, no anti-fingerprinting, drop-on-ban).
|
||||
- Do NOT hand-edit the Supported Providers table (sourced from `models-registry.json`).
|
||||
|
||||
### 4. Unit tests + fixtures
|
||||
- [ ] `CLAUDE_TUI_MODE` unset → `spawn()` invokes `_spawnAndStream` (assert via `__setSpawnImpl` mock spawn is called; `runTuiTurn` is NOT). **C4 regression.**
|
||||
- [ ] `CLAUDE_TUI_MODE='1'` → `spawn()` invokes `runTuiTurn` (inject a mock driver returning a fixed string) and yields `[delta, stop]` with `finish_reason:'stop'`.
|
||||
- [ ] buffered path: a flagged request through the (mocked) provider produces a well-formed OpenAI JSON body (drive `getOrCompute`'s array consumer).
|
||||
- [ ] streaming path: a flagged `stream:true` request replays as SSE delta(s) + `[DONE]` (drive `irChunkToOpenAISSE`).
|
||||
- [ ] dropped-param: a request with `max_tokens`/`temperature` succeeds and ignores them (no error).
|
||||
- [ ] reaper boot hook is a no-op when flag unset.
|
||||
- [ ] Full existing suite green.
|
||||
|
||||
### 5. PI231 integration checkpoint (maintainer-supervised)
|
||||
- [ ] On PI231 scratch (prod :4567 untouched), run a real flagged request end-to-end through OLP scratch instance with `CLAUDE_TUI_MODE=1`: buffered `stream:false` returns correct JSON; `stream:true` returns valid SSE (one burst); flag-unset run is identical to today's stream-json.
|
||||
- [ ] **Pass criteria:** flagged path returns correct text via tmux/transcript; `cc_entrypoint=cli`; default path unchanged (diff a flag-unset response against current prod behavior); reaper runs clean at startup; real `~/.claude` untouched.
|
||||
|
||||
### 6. Acceptance criteria (binding)
|
||||
- Flag unset → identical to current stream-json (C4). Flag set → TUI path, correct buffered + SSE-replay responses.
|
||||
- ADR 0016 merged as authority of record, names PR #101 + jaekwon-park.
|
||||
- README documents the env var, onboarding-hang, OAuth-login req, no-token-streaming, dropped params, grey-area framing.
|
||||
- CHANGELOG Unreleased entry; CONTRIBUTORS updated; no mid-Phase version bump.
|
||||
|
||||
### 7. Reviewer (Iron Rule 10)
|
||||
Fresh-context reviewer opens **spec §3.1, §4.5–§4.6, §10.2, §12 (PR-3), §13 + ADR 0016** and confirms: (a) the default branch is unchanged and the flag is the sole toggle (C4); (b) the 2-chunk adapter slots into both cache paths without server cache-layer edits; (c) dropped params documented, no silent failure; (d) ADR 0016 acknowledges PR #101/jaekwon-park and the co-author trailer is on the commits; (e) README quirks present. A review that does not open ADR 0016 + confirm the author-credit obligation is not valid.
|
||||
|
||||
### 8. Authority citation
|
||||
OpenAI `/v1/chat/completions` spec (entry surface is unchanged; `stream`, `max_tokens`, `temperature`, `top_p`, `stop` fields — document non-honored set) — cite the OpenAI spec URL for the entry-surface PR portion; `claude` CLI v2.1.158 (provider branch); ADR 0016 (new authority of record) + ADR 0009 Amendment 1 (superseded billing lane) + ADR 0002 Amendment 9 (ISOLATION) + ADR 0014 (sandbox). spec §§3.1/4.5/4.6/10.2/12/13. Co-author trailer `jaekwon-park` on every commit (§13).
|
||||
|
||||
### Risk / rollback
|
||||
Independently revertable (revert removes the branch; provider returns to pure stream-json). **Default-off is the kill switch:** until an operator sets `CLAUDE_TUI_MODE=1`, nothing about TUI-mode executes. The OCP single-tenant canary (post-2026-06-15) is the first real enablement.
|
||||
|
||||
---
|
||||
|
||||
## Parallel B-gate spike track (does NOT block A)
|
||||
|
||||
These run independently of PR-0..PR-3 and gate Deployment B only. One paragraph each.
|
||||
|
||||
- **T2 — body-capture `tools:[]` (security + credential-safety gate, §5.2(4)/§5.5).** Stand up a body-logging channel for the outbound `/v1/messages` from an interactive `claude` spawn (a local MITM proxy with a trusted cert in the ephemeral home, or a body-capturing forward proxy via `HTTPS_PROXY`). `--debug api` is insufficient (metadata only). Method: run a TUI turn under the full §5.2 flag set (`--strict-mcp-config` + `--disallowedTools "mcp__*"` + `--tools ""`), capture the wire request body, assert it carries `tools:[]` or no tools array. PASS is the hard gate that lets B launch (serialized). Per §5.5 this is a **credential-safety** gate, not mere MCP hygiene.
|
||||
- **T4 — concurrency (§7.3).** Run K concurrent TUI turns sharing one owner OAuth, each with its own ephemeral `$HOME` + `--session-id` + cwd. Method: fire K parallel `runTuiTurn` calls; assert transcript isolation (no cross-session lines), billing entrypoint stays `cli` on all, no OAuth auth contention/refresh thrash, and that one credential tolerates K concurrent interactive sessions. Until PASS, B serializes (concurrency=1). This lifts B's concurrency limit only.
|
||||
- **T5 — cold-start latency + inotify (§4.4 sizing / non-blocking).** Method: measure submit→transcript-available cold-start end-to-end (currently unmeasured); compare `inotifywait` vs 0.5s poll under load; measure Opus-class long-stream `turn_duration` ordering to size the §4.4 wall-clock cap (recommend ≥120s, tune here). Non-blocking for A; informs the cap constant and a possible future quiescence window (which §4.4 forbids in v1).
|
||||
|
||||
---
|
||||
|
||||
## Test strategy on PI231 without breaking prod
|
||||
|
||||
- **PI231 runs prod OLP on :4567.** It must stay untouched throughout. All TUI testing is **/tmp scratch + tmux**: a scratch OLP instance on a different port (or direct `node` invocation of the new modules), ephemeral homes under `/tmp/olp-spawn/*`, scratch tmux sessions `olp-tui-*`.
|
||||
- **Never** point a TUI test at the prod `~/.claude` — the ephemeral-home seed + symlink keep the real home read-only; every PI231 checkpoint asserts `~/.claude.json` + `~/.claude/projects` mtime unchanged.
|
||||
- **The canary is OCP single-tenant post-6/15** (spec §12.6): OCP is one user, no cross-tenant boundary, and is where PR #101 originated. Enable `CLAUDE_TUI_MODE=1` there first; watch billing entrypoint stays `cli`, cap behavior, completion reliability over real usage — before any OLP Deployment-B exposure.
|
||||
- Mac mini is NEVER a test target (cc-mem rule). MacBook/PI231-scratch only.
|
||||
|
||||
---
|
||||
|
||||
## Author credit (binding, §13) — checklist applied to every PR
|
||||
|
||||
- [ ] Co-author trailer `Co-Authored-By: jaekwon-park <…>` on every implementing commit (pull real email/handle from OCP PR #101 first — do not invent).
|
||||
- [ ] ADR 0016 names PR #101 + jaekwon-park (adopted idea vs redesigned implementation).
|
||||
- [ ] Add jaekwon-park to CONTRIBUTORS.
|
||||
- [ ] Notify on OCP PR #101 (comment linking the shipping PR) at ship time.
|
||||
|
||||
---
|
||||
|
||||
## Open implementation questions (maintainer decides BEFORE code)
|
||||
|
||||
1. **keyId/reqId into the TUI driver.** The provider `spawn(irRequest, authContext, isolationCtx)` does not receive `keyId/reqId` today. The driver needs them for the ephemeral root + tmux session name. Recommended: have the server pass `tui:(CLAUDE_TUI_MODE==='1')` to `prepareIsolatedEnvironment` at `server.mjs:1347`/`:1564` (so the seed + chmod fire in the manager), and thread `ephemeralRoot` (+ a reqId field) to the provider via `isolationCtx`; the TUI branch then reuses `isolationCtx.ephemeralRoot` rather than re-preparing. Confirm this threading before PR-2 fixes `runTuiTurn`'s signature. **(Spec §3.2 implies the reuse but does not specify the parameter plumbing.)**
|
||||
2. **Warm pool (§7.2) is namespace-reserved, not built.** Confirm A's canary runs ephemeral-per-request (no warm pool) for this plan — the spec allows warm pool for A but it adds cross-request-context-leak risk and is out of the PR-0..PR-3 scope.
|
||||
3. **Wall-clock cap constant.** Spec recommends ≥120s pending T5. Confirm the v1 value to bake into `readTurnResult` (PR-1) — or read it from config so T5 can tune it without a code change.
|
||||
4. **Large-paste path (§6 caveat).** T3 validated ≤50 lines / 1.2 KB. Coding-proxy traffic carries multi-KB pastes. Decide whether PR-2 ships `send-keys` only (with the documented ≤50-line validation) or also wires the `paste-buffer`/`load-buffer` fallback for large bodies now (recommended as a fast-follow, non-blocking for A).
|
||||
5. **Preflight MCP-disable session for A.** §5.2 makes the separate-preflight `/mcp`-empty assertion a hard gate for B. Confirm whether A's canary runs it as advisory-at-startup (recommended) or skips it (relying on the per-spawn cache-dir assertion alone).
|
||||
|
||||
---
|
||||
|
||||
## Maintainer decisions — plan-review fixes + open questions RESOLVED (2026-05-30)
|
||||
|
||||
Plan-review verdict was **ready-with-fixes**. All anchors verified accurate. Decisions below resolve P1–P5 + the open implementation questions; the plan is now ready to implement.
|
||||
|
||||
| Ref | Decision |
|
||||
|---|---|
|
||||
| **P1 / OQ#1 — keyId/reqId plumbing** | **Reuse `isolationCtx.ephemeralRoot` + reqId.** The two existing spawn call sites (`server.mjs:1347`, `:1564`) already call `prepareIsolatedEnvironment` and pass `isolationCtx` into `spawn()`. PR-0 adds `ephemeralRoot` + `reqId` to the returned `isolationCtx`; the TUI branch reads them from there — **no new edits to the default-path call sites**, preserving the byte-for-byte-unchanged invariant. `runTuiTurn(isolationCtx, irRequest, opts)` takes `isolationCtx`, not raw keyId/reqId. |
|
||||
| **P2 — reaper boot anchor** | Wire `reapOrphans()` into the real boot region: the `isMain` block at **`server.mjs:2417`** (NOT `:2334`, which is wrong; `:82` is the import). Co-locate with the existing `await bootstrapSandbox()` call. |
|
||||
| **P3 / OQ#5 — A preflight `/mcp`** | **A also spawns with `--strict-mcp-config` + `--disallowedTools "mcp__*"`** (defense-in-depth — even the owner does not want a prompt-injected client reaching the owner's own Gmail/Drive). The separate preflight `/mcp`-empty session is **advisory-at-startup for A** (log a warning if managed MCP still attaches; do NOT block), and a **hard gate for B**. Decided line item for PR-2, no longer open. |
|
||||
| **P4 — tier citation** | Cite accurately: manifest `owner_tier ∈ {'owner','guest'}` (`keys.mjs:143`); `'anonymous'` is a runtime fallback identity (`:439`), not a manifest tier. Cosmetic; correct the anchor table. |
|
||||
| **P5 / OQ#3 — wall-clock cap** | **Config, not constant.** Read from env `CLAUDE_TUI_WALLCLOCK_MS` (default `120000`) so T5 can tune it without a code change. Baked into `readTurnResult` (PR-1). |
|
||||
| **OQ#2 / OQ#5 — warm pool** | **Out of PR-0..PR-3 scope.** Initial A = per-request ephemeral session (cleanest, matches B). Warm pool is a later opt-in optimization (`CLAUDE_TUI_WARM_POOL`), process-reuse-not-context per spec §7.2, tracked separately. |
|
||||
| **OQ#4 — large-paste (>50 lines)** | **Defer to fast-follow.** PR-2 ships the `send-keys -- "$(cat file)"` recipe with the documented ≤50-line / multi-KB validation from T3; the `paste-buffer`/`load-buffer` path for very large bodies is a non-blocking follow-up PR. Document the current bound in the README. |
|
||||
|
||||
**Net:** P1 (the one true PR-2-interface blocker) is decided = reuse `isolationCtx`. P2/P4 are anchor corrections. P3/P5 are decided line items. Warm-pool + large-paste are explicitly scoped out of the initial A deliverable. Implementation may proceed PR-0 → PR-1 → PR-2 → PR-3.
|
||||
@@ -0,0 +1,399 @@
|
||||
# TUI-mode — Production Design Spec
|
||||
|
||||
- **Date:** 2026-05-30
|
||||
- **Status:** Draft (design spec; pre-implementation). Supersedes the "Option 1 / stream-json adapter" lane of ADR 0009 Amendment 1 for the *billing-pool* concern, and proposes a new ADR (0009 Amendment 2 or a fresh ADR 0016) as the authority of record before any code lands.
|
||||
- **Authors:** project maintainer (with AI drafting assistance).
|
||||
- **Builds on community work:** `dtzp555-max/ocp` **PR #101 by jaekwon-park** (tmux + interactive-`claude` prototype). See § "Author credit plan".
|
||||
- **Validated by:** PI231 spikes S1 (billing + no-tool property), S2 (JSONL transcript output), S3 (submission reliability), plus pre-code gate spikes **T1** (completion detection on non-`end_turn` stop reasons — PARTIAL), **T3** (multiline/special-char submission — PASS), **T6** (marketplace + managed-MCP disable — PASS), `claude` v2.1.158, `tmux` 3.3a, model `claude-haiku-4-5-20251001`, arm64 Debian. Spike JSON retained in session record.
|
||||
|
||||
> **Honesty banner.** TUI-mode is a *grey-area bridge*, not a durable architecture. It automates `claude`'s genuinely-interactive mode (`cc_entrypoint=cli`) to serve programmatic proxy requests so traffic bills against the Anthropic subscription pool instead of the post-2026-06-15 Agent SDK credit pool. The interactivity is real (not forged), but it is automated. It is OPT-IN (`CLAUDE_TUI_MODE`). Spike-confirmed facts and the remaining gaps govern everything below: (a) `--system-prompt` keeps `cc_entrypoint=cli` — the TTY path carries the **genuine interactive-use signal**; whether that *bills* to the subscription pool is an **inference pending post-2026-06-15 validation** (S1 proved the entrypoint signal, not the billed pool — the split has not yet taken effect, so no spike can prove the billed pool today); (b) the native JSONL transcript is a clean, escaping-free output channel — **output mechanism is sound** (S2 PASS); (c) `--system-prompt` suppresses tool *text* but does **not structurally strip** account-attached managed MCP servers — **the no-tool property is model restraint, not enforcement** (S1 PARTIAL); (d) the load-bearing structural MCP-disable mechanism is now **found and verified** — `--strict-mcp-config` (with no `--mcp-config`) yields 0 managed-MCP attachment (T6 PASS); (e) `turn_duration` completion detection is **reliable for text/refusal turns but ABSENT on tool-use turns**, which would hang the reader — a co-equal wall-clock/quiescence guard is now mandatory, not optional (T1 PARTIAL); (f) multiline/special-char prompt submission is **byte-for-byte reliable** via `send-keys -- "$(cat file)"` + separate Enter token (T3 PASS). (c)+(e) remain the load-bearing risks; (c) is now mitigable structurally via (d) and gates multi-tenant (Deployment B) rollout together with the still-open body-capture verification (T2) and concurrency (T4).
|
||||
|
||||
---
|
||||
|
||||
## 1. Context & motivation
|
||||
|
||||
### 1.1 The billing trigger
|
||||
|
||||
Anthropic's 2026-06-15 billing split moves `claude -p`, the Agent SDK, and "third-party apps that authenticate with your Claude subscription through the Agent SDK" into a separate ~$100/month Agent SDK *credit* pool. The subscription pool (Pro/Max) covers "Claude Code in the terminal or your IDE in **interactive mode**." OLP's anthropic provider currently spawns `claude` non-interactively (`--output-format stream-json --verbose --no-session-persistence`, ADR 0009 Amendment 1). Post-split, that path's billing classification is at best uncertain and at worst routes to the credit pool — which exhausts in ~20–50 heavy sessions/month and makes OLP unusable for a Pro subscriber pooling to family/team.
|
||||
|
||||
TUI-mode is the bridge: drive `claude` in genuine interactive mode (no `-p`, no `--output-format`; a real PTY/tmux session) so the User-Agent carries `cc_entrypoint=cli`, which S1 confirmed holds even with `--system-prompt`. That signal matches genuine interactive use; **actual subscription-pool billing is an inference to be validated only after the 2026-06-15 split takes effect** — S1 cannot prove the billed pool pre-split, and the OCP canary (§ 12) is the first real billing measurement.
|
||||
|
||||
### 1.2 What changed since ADR 0009 Amendment 1
|
||||
|
||||
ADR 0009 Amendment 1 locked "Option 1 — stream-json, no `-p`" on the premise that stream-json-without-`-p` emits NDJSON *and* (implicitly) bills as interactive. The unverified premise in ADR 0009 § 1.3 was exactly the TTY-detection risk: **Anthropic may use `isTTY` as the billing signal, not the `-p` flag.** If that premise holds, the current stream-json (piped stdio, non-TTY) path bills as `sdk-cli`/credit-pool. TUI-mode resolves this by using a **real TTY** (PTY/tmux), which S1 confirmed produces `cc_entrypoint=cli` across all 5 `/v1/messages` requests in a turn (main + auxiliary). This spec therefore **does not replace** the stream-json path; it adds a *TTY-backed* execution mode selectable per the `CLAUDE_TUI_MODE` flag, keeping stream-json as the default. Note the default's billing is **uncertain, not safe-credit-pool-guaranteed**: per ADR 0009 § 1.3 the non-TTY piped-stdio default may itself bill to the credit pool if Anthropic keys on `isTTY` — its merit is the conservative ToS posture, not a billing guarantee (§ 10.2).
|
||||
|
||||
### 1.3 Orthogonal value (so the work earns its keep even if the bridge dies)
|
||||
|
||||
Per ADR 0009 Amendment 1 § "Value re-anchoring": even if Anthropic reclassifies third-party apps to the credit pool on 2026-06-15 — killing the billing bridge — the `--system-prompt` tool-suppression already delivers the hallucination fix (env-block / cwd injection) and a measured ~30% input-token / ~64% per-request cost reduction. TUI-mode inherits those. The transcript-read channel (S2) additionally exposes per-turn `turn_duration` (messageCount + durationMs) for observability.
|
||||
|
||||
---
|
||||
|
||||
## 2. Deployment models
|
||||
|
||||
TUI-mode must serve two shapes. **B is the superset; A is B with exactly one key.** Build for B; A falls out.
|
||||
|
||||
### 2.1 Model A — single-user / multi-device
|
||||
|
||||
One subscription, one OLP server instance, many of the *user's own* client IDEs/devices. All traffic is the same human. Privacy *between clients* is not a hard requirement (it's all one person), so A **may** opt into a warm session pool for latency (§ 8) — but a warm pool MUST reuse the *process* only, **not** conversation context: each request resets to a fresh turn (new `--session-id`, or `/clear` between requests) so it never inherits a prior request's implicit context. Otherwise the proxy violates OpenAI chat-completions **stateless** semantics (a later request would see an earlier one's hidden context, dirtying cache + reproducibility) even for a single user. One `claude login` on the host.
|
||||
|
||||
### 2.2 Model B — family / team share
|
||||
|
||||
One **owner** subscription pooled to N members via OLP per-key auth. Members do **not** do their own OAuth — they hold an OLP key; the host holds the single owner OAuth. Hard requirements:
|
||||
|
||||
- **Per-member privacy.** Member A cannot see the owner's or member B's history. Transcripts must never co-mingle and must never land in the owner's real `~/.claude/projects/`.
|
||||
- **Per-key cache + audit isolation.** Reuse the existing OLP/OCP multi-key namespacing (`lib/keys.mjs`: `owner_tier`, `providers_enabled`, per-key cache/audit). No new isolation primitive is invented for cache/audit.
|
||||
- **Shared 5-hour cap.** One pooled OAuth → the subscription's rolling 5-hour usage cap is shared across all B members. This is an inherent limit of pooling one subscription (§ 9).
|
||||
- **Structural tool stripping is mandatory** (not optional as in A), because a member's prompt reaching an un-stripped tool surface could touch the *owner's* Gmail/Calendar/Drive via account-attached MCP (S1 caveat). See § 5.
|
||||
|
||||
---
|
||||
|
||||
## 3. Architecture (the layers)
|
||||
|
||||
TUI-mode is a new **execution transport** under the existing anthropic provider, selected when `CLAUDE_TUI_MODE` is set. It reuses the IR boundary, the `--system-prompt` wrapper (Phase 6c), the ephemeral-home isolation (Phase 7), and multi-key auth unchanged. New surface is the session driver + transcript reader.
|
||||
|
||||
```
|
||||
OpenAI-compat entry (/v1/chat/completions) [REUSE — unchanged]
|
||||
│ validateKey → keyId, owner_tier, providers_enabled [REUSE lib/keys.mjs]
|
||||
▼
|
||||
IR request ────────────────────────────────────── [REUSE lib/ir]
|
||||
│ irToAnthropic: role:system → --system-prompt; user/assistant → prompt text
|
||||
▼
|
||||
anthropic provider .spawn() [BRANCH on CLAUDE_TUI_MODE]
|
||||
│
|
||||
├─ default (flag unset): stream-json --verbose --no-session-persistence
|
||||
│ (ADR 0009 Amd 1; uncertain-billing / safe ToS posture —
|
||||
│ per ADR 0009 §1.3 the default itself MAY bill to the
|
||||
│ credit pool because Anthropic may key on the isTTY signal)
|
||||
│
|
||||
└─ CLAUDE_TUI_MODE=1: ── TUI transport ──────────────────────────────┐
|
||||
│
|
||||
┌──────────────────────────────────────────────────────────────────────── ▼ ───┐
|
||||
│ 1. prepareIsolatedEnvironment({ provider, keyId, reqId }) [REUSE Phase 7] │
|
||||
│ Layer 1: ephemeral $HOME = /tmp/olp-spawn/<keyId>/<reqId>/home (chmod 700)│
|
||||
│ Layer 2: symlink real ~/.claude/.credentials.json → ephemeralRoot │
|
||||
│ + NEW: seed ephemeral .claude.json (onboarding/trust/bypass; mode 600) │
|
||||
│ (NOTE: seed does NOT disable managed-MCP — T6 negative control; that is │
|
||||
│ the spawn-flag --strict-mcp-config in step 2, not the seed) │
|
||||
│ 2. spawn interactive `claude` in a PTY/tmux session bound to ephemeralRoot │
|
||||
│ args: --system-prompt "<OLP wrapper>" --model <m> --session-id <uuid> │
|
||||
│ --strict-mcp-config (no --mcp-config) --disallowedTools "mcp__*" │
|
||||
│ [--tools "" | --allowedTools "…"] (NO -p, NO --output-format) │
|
||||
│ env: CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL=1 │
|
||||
│ → real TTY → cc_entrypoint=cli ; 0 managed-MCP (T6-verified) │
|
||||
│ 3. submit prompt (T3): write body to file → send-keys -- "$(cat file)" → │
|
||||
│ settle ~1.5-2s → send Enter as a SEPARATE tmux KEY TOKEN │
|
||||
│ verify via TRANSCRIPT (exactly 1 user line == source); retry Enter ≤4x │
|
||||
│ 4. read response from NATIVE JSONL transcript at the computed deterministic │
|
||||
│ path; completion (T1 dual-signal): {"type":"system","subtype": │
|
||||
│ "turn_duration"} line OR terminal guard (stop_reason:tool_use / │
|
||||
│ size-stable ≥10s / wall-clock cap ≥120s → clean 502, never hang) │
|
||||
│ 5. map transcript assistant text blocks → ONE buffered IR response → OpenAI │
|
||||
│ JSON, or (stream:true) replay the completed text as SSE chunks AFTER the │
|
||||
│ turn finishes — NOT incremental tokens (see § 3.1 streaming semantics) │
|
||||
│ 6. cleanup(): kill session, rm -rf ephemeralRoot (trap-guaranteed) │
|
||||
└────────────────────────────────────────────────────────────────────────────── ┘
|
||||
```
|
||||
|
||||
### 3.1 Streaming semantics — single buffered response, NOT token streaming (DECISION)
|
||||
|
||||
TUI-mode reads the native transcript JSONL **after the turn completes** (the `turn_duration` marker / quiescence guard, § 4.3–§ 4.4). The transport therefore produces a **single, fully-buffered response string** — there is no per-token channel to tap, because the transcript is only authoritative once the turn is done. **True incremental token-streaming is NOT possible in TUI-mode.** (Tapping the live `capture-pane` for partial text is explicitly rejected: § 6/T3 showed large pastes collapse to a `[Pasted text …]` placeholder and pane text is cosmetic, not authoritative.)
|
||||
|
||||
**Decision (maintainer default):** for `stream:true` requests, **replay the completed response as SSE chunks** — chunk the buffered string and emit it as standard OpenAI `delta` events followed by `[DONE]`. The wire format is valid SSE, but the data arrives as **one burst after the turn finishes**, not incrementally as the model generates. This limitation is documented in the README (Troubleshooting / TUI-mode § "no true streaming") and surfaced to operators. Clients that depend on early-token latency (e.g. live typing UIs) get a correct-but-non-incremental experience under TUI-mode; this is an accepted trade of the bridge.
|
||||
|
||||
### 3.2 Cache contract integration (REUSE getOrCompute / singleflight)
|
||||
|
||||
The TUI transport is, from `server.mjs`'s perspective, a function that returns a **resolved response string** for a `(keyId, prompt)` pair — the same shape the existing cache layer expects. It plugs into the established `getOrCompute` / singleflight contract in `server.mjs` unchanged: the cache key is composed exactly as today (content-addressed over the normalized prompt), and the TUI transport is invoked only on a cache miss as the compute function whose resolved string is then stored and replayed (including chunked SSE replay for `stream:true`, identical to how the stream-json path's buffered result is cached). **Cache-key note (from T3):** the interactive input box strips the prompt's single trailing newline on submit; any prompt-in vs prompt-on-wire hashing MUST normalize the trailing newline or it will see a spurious cache-key mismatch. No new cache primitive is introduced; per-key isolation and singleflight are REUSE (§ 9).
|
||||
|
||||
Layer responsibilities:
|
||||
|
||||
| Layer | Owner | Reuse / New |
|
||||
|---|---|---|
|
||||
| Entry surface, IR, key auth | server.mjs, lib/ir, lib/keys.mjs | REUSE |
|
||||
| System-prompt wrapper (`OLP_SYSTEM_PROMPT_WRAPPER`) | lib/providers/anthropic.mjs | REUSE (Phase 6c) |
|
||||
| Ephemeral home + credential mount + cleanup | lib/sandbox/manager.mjs `prepareIsolatedEnvironment` + anthropic `ISOLATION` | REUSE + EXTEND (seed `.claude.json`, pin plugins) |
|
||||
| Session driver (PTY/tmux spawn, submit, dialog auto-answer) | **NEW** lib/providers/anthropic-tui.mjs (or lib/tui/session.mjs) | NEW |
|
||||
| Transcript reader (path compute, poll/inotify, completion detect, text extract) | **NEW** lib/tui/transcript.mjs | NEW |
|
||||
| Cache + audit per-key | lib/cache, lib/audit | REUSE |
|
||||
|
||||
---
|
||||
|
||||
## 4. Output mechanism — native JSONL transcript read (S2 PASS)
|
||||
|
||||
**Decision: read `claude`'s native session transcript JSONL. Do NOT use a hook→result.json contract, and do NOT rely on `--output-format`.** S2 proved this end-to-end and it is strictly better than the PR #101 hook-file approach: it eliminates JSON double-escaping (the exact failure that broke hook→result.json) and removes any need for `--dangerously-skip-permissions` (§ 5.4).
|
||||
|
||||
### 4.1 Transcript path formula (S2-confirmed, exact)
|
||||
|
||||
```
|
||||
<EHOME>/.claude/projects/<CWD_ENCODED>/<SESSION_ID>.jsonl
|
||||
```
|
||||
|
||||
- `EHOME` = the ephemeral `$HOME` from `prepareIsolatedEnvironment`.
|
||||
- `CWD_ENCODED` = the spawn `cwd` with **every** `/` replaced by `-`, **including the leading slash** (so `/tmp/x` → `-tmp-x`). Verified against pre-existing dirs and against the spike's own run.
|
||||
- `SESSION_ID` = the UUID OLP passes via `--session-id`. OLP generates it, so OLP computes the path *before* spawn. File is created lazily on first message, not at spawn — the reader must tolerate "file not yet present" and poll for creation.
|
||||
|
||||
### 4.2 Assistant text extraction (escaping-clean — the load-bearing win)
|
||||
|
||||
The final assistant message is `type:"assistant"` with a content block `type:"text"`. Because this is `claude`'s *native* log, one `JSON.parse()` per line yields the text with real newlines, real double-quotes, and **zero** `\\n` / `\\"` double-escaping artifacts (S2 char-level checks: double-quote present, real newline present, literal-backslash-n bug-indicator absent). Response text = concatenation of `text` blocks from `assistant` messages emitted **since the matching `user` line** for this turn.
|
||||
|
||||
### 4.3 Completion detection (S2-confirmed, with the trap)
|
||||
|
||||
- **Positive marker = a line `{"type":"system","subtype":"turn_duration"}`.** It is the last line of the turn by timestamp and carries `messageCount` + `durationMs` (and `entrypoint=cli`). Poll the file (or `inotifywait`); when a `turn_duration` line for this turn appears, the turn is done. **T1 confirmed** this fires for both text turns (a 2128-word / 19991-char near-cap answer, `durationMs=33135`) **and** refusal turns (`durationMs=3221`).
|
||||
- **TRAP — do NOT key off `stop_reason:"end_turn"` alone.** It appears on BOTH the `thinking` block AND the `text` block, so "first `end_turn`" fires before the visible text is complete.
|
||||
- **TRAP — do NOT assume `turn_duration` is the literal last *byte* in the file.** S2 saw write-order momentarily differ from timestamp-order (a text block flushed after `turn_duration` by byte position while `turn_duration` had the later timestamp). Robust rule: "a `turn_duration` line for this turn has appeared" → then read all assistant `text` since the `user` line. Do not rely on file-tail ordering.
|
||||
- **TRAP (NEW, T1) — `turn_duration` is ABSENT on tool-use turns.** When the model issues a `tool_use` block, the last assistant line carries `stop_reason:"tool_use"` and **no `turn_duration` line is ever written** — even after the (interactive) tool-permission dialog is rejected. A marker-only reader would hang indefinitely. `turn_duration` MUST NOT be the sole completion signal (see § 4.4).
|
||||
- **Latency:** S2 measured submit→transcript-available ≈ 3.4–3.6s wall for a tiny haiku turn (~300 output tokens); a 0.5s poll added <0.5s detection lag. `inotifywait` would make detection lag near-zero. T1's longest legitimate text turn was `durationMs=33135` (~33s server-side, ~20s detection wall) — this is the realistic worst case for a long single-stream answer and bounds the quiescence/wall-clock sizing in § 4.4.
|
||||
|
||||
### 4.4 Completion robustness — T1 RESOLVED (partial): dual-signal guard is MANDATORY
|
||||
|
||||
**T1 verdict: PARTIAL.** `turn_duration` is RELIABLE for text-only turns (both near-cap long answers and refusals emit it) but is **ABSENT on tool-use turns**, which would hang a marker-only reader. Verified on PI231, `claude` v2.1.158, model `claude-haiku-4-5`, against the § 4.3/§ 4.4 contract. (A true API `max_tokens` truncation could not be forced — interactive `claude` exposes no max-tokens flag, so the "long" path exercised `claude`'s own default-length stop, which is `end_turn`-with-`turn_duration`; see § 4.5 and the concern below.)
|
||||
|
||||
**Production rule (now binding, not a gate).** The reader MUST treat completion as a **dual signal**:
|
||||
|
||||
- **(A) Happy path** — a `{"type":"system","subtype":"turn_duration"}` line for this turn appears (fires for `end_turn` text turns and refusal turns). Then read all assistant `text` blocks since the matching `user` line (§ 4.2; do not rely on file-tail byte ordering).
|
||||
- **(B) Co-equal terminal-NON-HANG guard (mandatory)** — detect either: the transcript's last assistant message has `stop_reason:"tool_use"`, **or** an absolute wall-clock cap fires. Either is a **terminal** condition: abort the turn and return a clean error (e.g. `502` "tool-use turn unsupported in TUI-mode" / "completion-marker timeout"). **Never block forever.**
|
||||
|
||||
⚠️ **Quiescence ("file size-stable for N seconds") is deliberately EXCLUDED from the v1 terminal set.** A long Opus extended-thinking turn or a slow-network turn can legitimately produce **no transcript growth for >10s**, so a quiescence cut would falsely abort valid long turns (this corrects the T1 spike's own co-equal-quiescence suggestion). Quiescence may be added **only after spike T5** establishes a safe window AND only gated behind "assistant/tool output has already begun." v1 relies on `turn_duration` (happy path) + `tool_use` detection + a generous wall-clock cap alone.
|
||||
|
||||
**Sizing (from T1, tune via T5):** longest legitimate text turn measured was `durationMs=33135` (~33s server-side, ~20s detection wall). Set the absolute wall-clock cap **generously above expected Opus-class long-stream latency (recommend ≥ 120s, tune via spike T5)** so a slow-but-valid long turn is not aborted prematurely.
|
||||
|
||||
**Why guard (B) cannot be dropped under the structural tool-strip.** S1 already showed — and T1 re-confirmed at the model's own words ("The tools are available in the function schema, but… I won't invoke tools") — that `--system-prompt` suppresses tool *use* via model restraint, NOT tool *availability*. Under the production `--system-prompt` wrapper, three separate tool-inviting prompts all resolved to `end_turn`+`turn_duration` with zero `tool_use` — but **restraint is not enforcement**, so a `tool_use` turn (and its hang) remains reachable in production whenever model restraint does not hold. The structural tool-removal required for guest/member keys (§ 5.2, now mechanizable via T6) reduces this for multi-tenant traffic, but does **not** eliminate the need for guard (B) on **owner-tier / canary traffic where tools remain attached.** Guard (B) is unconditional.
|
||||
|
||||
**Second hang vector — interactive tool-permission dialog.** When a `tool_use` does occur, `claude` blocks on an interactive tool-PERMISSION dialog in the TUI (`Do you want to create …? 1.Yes 2.Yes-allow-all 3.No`) with `stop_reason:"tool_use"` and no `turn_duration` — the session is frozen awaiting a keypress, a distinct hang from the missing-marker case. Production TUI-mode MUST either pre-grant/auto-deny tool permissions (a permission-mode that auto-rejects) **or** have guard (B) detect-and-tear-down a session stuck on a permission prompt. The cleanest combination is the structural disable of § 5.2/T6 (no MCP tools to invoke) *plus* a built-in-tool lockdown (`--tools ""` / explicit `--allowedTools` subset) so no `tool_use` is reachable at all on member keys.
|
||||
|
||||
**Re-run cadence:** `turn_duration` emission is an undocumented internal-log behavior pinned to `claude` v2.1.158. Re-run T1 on every `claude`/Ink version bump.
|
||||
|
||||
### 4.5 max_tokens handling (DECISION — ignore + document)
|
||||
|
||||
GROUND TRUTH (verified against the current code path): `buildCliArgs` passes only `--model` + `--system-prompt`; a client `max_tokens` is parsed into the IR (`lib/ir/openai-to-ir.mjs:182`) but **never reaches the CLI** today — interactive `claude` exposes **no max-tokens flag**, and the existing stream-json path does not forward it either. T1 also could not force a true API `max_tokens` truncation for the same reason; the "long" path tested `claude`'s own default-length stop (`end_turn`-with-`turn_duration`), which is the realistic worst case for length.
|
||||
|
||||
**Decision (maintainer default): ignore `max_tokens` and document the limitation.** This matches current stream-json behavior, so TUI-mode introduces no regression. The IR field is accepted and dropped silently at the CLI boundary (no error). README documents that `max_tokens` is not honored under either anthropic path. **Future option (not in initial scope):** soft-inject a "limit your response to roughly N tokens" instruction into the prompt body for a best-effort approximation — this is a prompt-level hint, not a hard API cap, and would be a separate ADR-tracked change. Note the formally-unverified corner: if the proxy ever maps client `max_tokens` to a *real* truncation, the `turn_duration` behavior on a hard `max_tokens` stop is untested (though `end_turn`-with-`turn_duration` is the observed behavior for the longest turns `claude` produces on its own).
|
||||
|
||||
### 4.6 Other OpenAI sampling params — graceful drop (DECISION)
|
||||
|
||||
Interactive `claude` (`cc_entrypoint=cli`) exposes **no flags** for `stop`, `temperature`, `top_p`, `presence_penalty`, `frequency_penalty`, `logit_bias`, `n`, or `seed` — the interactive session uses the account/model defaults and there is no per-request override surface. **Decision (maintainer default): accept these params into the IR and drop them gracefully at the CLI boundary** (no error, same posture as `max_tokens` § 4.5 and consistent with the existing stream-json path, which also cannot forward them). README documents the non-honored set so clients are not surprised when, e.g., a low `temperature` does not deterministically constrain output under TUI-mode. No silent failure mode is introduced — the request still succeeds, it just ignores the unsupported knobs.
|
||||
|
||||
---
|
||||
|
||||
## 5. Security model — the no-tool property
|
||||
|
||||
### 5.1 What S1 actually proved (and did not)
|
||||
|
||||
S1 confirmed *behaviorally*: with `--system-prompt`, for a coding-style prompt, the model answered conversationally and emitted **zero** `tool_use`/`tool_call`/`tool_result` tokens, and the entrypoint stayed `cli`. **But** during startup the CLI auto-fetched the Anthropic official plugin marketplace and established live MCP connections (claude.ai Gmail / Google Calendar / Google Drive) — account-attached managed MCP servers delivered **over the network** (each connects via `https://mcp-proxy.anthropic.com/v1/mcp/<mcpsrv_id>`), present **even with empty local `mcpServers` config**. `--system-prompt` replaces the system-prompt *text* (suppressing default tool-usage instructions) but does **not** strip tool/MCP *availability* from the request. The no-`tool_use` outcome was **model restraint, not structural enforcement.** **T6 re-confirmed** this directly: even under the production `--system-prompt` wrapper, the model stated the tools are present in its function schema ("The tools are available in the function schema, but… I won't invoke tools") — so structural stripping (§ 5.2) is required and is **orthogonal to** `--system-prompt`. Additionally, `--debug api` logs metadata only (not bodies), so the spike could not prove the outbound `/v1/messages` carried `tools:[]` — only that no `tool_use` came back (still open as T2 body-capture).
|
||||
|
||||
### 5.2 The structural requirement (binding for Deployment B) — T6 RESOLVED the disable mechanism
|
||||
|
||||
A multi-tenant proxy MUST **structurally** remove the tool surface, not rely on the model declining. **T6 (PASS) found and verified the load-bearing mechanism**: the `--strict-mcp-config` CLI flag (with **no** `--mcp-config` supplied) yields **ZERO** managed-MCP attachment in an ephemeral interactive session — 0 `mcp-logs-claude-ai-*` cache dirs and `/mcp` reports "No MCP servers configured" (vs. a baseline of 3 servers / 28 tools). It keeps OAuth subscription auth intact. This converts requirement (1) below from "find a mechanism" (formerly spike T6) into **"apply the verified mechanism + assert the verification gate."**
|
||||
|
||||
**Critical NEGATIVE control (binding):** T6 proved that **stripping/seeding the ephemeral `.claude.json` is NOT a mitigation.** Removing the local cache key `claudeAiMcpEverConnected` from the seed did **not** prevent attachment (the 3 servers still connected, 28 tools) — the managed-MCP fetch is **account/server-driven**, not gated by any local `.claude.json` field. **Do NOT rely on editing the seeded home to disable MCP.** The CLI flag is required; the seed-edit approach (an earlier § 7.1 / PR-0 assumption) is downgraded to onboarding/trust convenience only and carries **no** security weight for MCP.
|
||||
|
||||
Concretely, before TUI-mode is allowed for any **owner_tier=guest** (member) key, the ephemeral spawn MUST:
|
||||
|
||||
1. **Pass `--strict-mcp-config` and pass NO `--mcp-config`** (mandatory, load-bearing — the ONLY mechanism that prevents the account-attached claude.ai managed MCP from connecting over the network). T6-validated spawn template (PI231): `claude --model <m> --session-id <uuid> --strict-mcp-config --disallowedTools "mcp__*" [--tools "" | --allowedTools "…"]`.
|
||||
2. **Lock tools down explicitly** — `--disallowedTools "mcp__*"` (deny any MCP-namespaced tool even if config changes), plus built-in lockdown. **For initial Deployment B the lockdown MUST be `--tools ""` (ZERO built-in tools) — NOT an `--allowedTools` subset.** Rationale (credential-wall coupling, § 5.5): any tool in an `--allowedTools` subset that can read files / run commands / reach the network **voids both the T2 `tools:[]` proof and the owner-bearer credential wall**. Any non-empty `--allowedTools` subset for B is **out of initial scope** and requires its own ADR + security proof. Note `--strict-mcp-config` removes MCP tools but does **NOT** lock built-in tools (Bash/Read/etc.) — the `--tools ""` pairing is required for multi-tenant.
|
||||
3. **Disable the official-marketplace plugin auto-install (defense-in-depth)** — set env `CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL=1` (binary-confirmed env var; 0 plugin/marketplace dirs in T6 worst-case test). `--strict-mcp-config` affects MCP only; the marketplace is a separate surface. In a fresh ephemeral home no marketplace was present, but the env var is cheap insurance against the autoinstall firing on a flag-stripped home.
|
||||
4. **Verify with a body-level capture** (proxy MITM or a body-logging channel) that the outbound `/v1/messages` actually carries `tools:[]` (or no tools array). `--debug api` is insufficient — it does not log bodies. **This is the one remaining hard gate (spike T2)** on Deployment B: T6 proved the MCP servers do not *connect* (cache-dir + `/mcp` + transcript-token evidence), but body-capture of the wire request is still needed to assert the request carries no tools array.
|
||||
|
||||
**Verification gate (preflight / upgrade-time — NEVER inside a serving turn):** Running `/mcp` (or the cache-dir assertion) **inside a session that also serves a user request would itself write a transcript line, consume a turn, and corrupt the reader's "matching user line" semantics (§ 4.2).** So the gate MUST run as a **separate preflight session** — at server startup and on every `claude` CLI upgrade — whose transcript is discarded and which never serves a user turn. The preflight asserts **0** dirs matching `$HOME/.cache/claude-cli-nodejs/*/mcp-logs-claude-ai-*` **and** that `/mcp` reports "No MCP servers configured." Findings are pinned to `claude` v2.1.158 and the managed-MCP fetch is account/server-driven, so a future CLI/server change could alter behavior — re-run the preflight on every upgrade (and optionally on a periodic timer), not once.
|
||||
|
||||
Carry-forward env from the existing isolation: keep `CLAUDE_CODE_DISABLE_CLAUDE_MDS=1` and unset `ANTHROPIC_*`. **Do NOT use `--bare`** — it strips managed MCP too but forces `ANTHROPIC_API_KEY`/`apiKeyHelper`-only auth, which breaks the OAuth/Max subscription spawn model (the whole point of the bridge). (A settings.json route — `suppressedClaudeAiConnectors` / `allowAllClaudeAiMcps` — exists in the binary but was deliberately **not** chosen: an argv-level flag cannot be overridden by a tenant-writable settings file; spike separately only if a settings approach is ever preferred.)
|
||||
|
||||
**Deployment B gate semantics (binding, two-stage — resolves the prior T2-only-vs-T2+T4 ambiguity):**
|
||||
- **(i) Security gate = T2.** Until § 5.2 (1)+(2)+(3) are applied AND (4) is verified by body-capture, **B does not launch at all.** The disable mechanism itself (T6) is resolved; T2 is proving it on the wire.
|
||||
- **(ii) Concurrency gate = T4.** Once T2 passes, **B launches SERIALIZED (concurrency = 1).** Concurrent multi-member service is a **separate** gate on T4 (§ 7.3) — per-session isolation under parallel load + one-OAuth-concurrent-session tolerance — and is NOT lifted until T4 passes.
|
||||
- Net: **no B before T2; serialized B after T2; concurrent B only after T4.**
|
||||
|
||||
Deployment A (single user, all traffic is the owner) may proceed on the behavioral property because there is no cross-tenant boundary to breach — but the structural hardening should still ship, because an un-stripped surface means a prompt-injected client could reach the owner's own Gmail/Drive, which is undesirable even single-user.
|
||||
|
||||
### 5.3 A vs B isolation summary
|
||||
|
||||
| Concern | Model A | Model B |
|
||||
|---|---|---|
|
||||
| Cross-tenant history leakage | N/A (one human) | **Hard** — ephemeral $HOME per request; transcripts in `/tmp`, rm'd; never owner's real `~/.claude/projects/` |
|
||||
| Tool/MCP surface | Should-strip (defense-in-depth) | **Must-strip structurally** (§ 5.2); B blocked until verified |
|
||||
| Cache/audit namespacing | single key | per-key (REUSE `lib/keys.mjs`) |
|
||||
| OAuth | one owner login | one owner login, pooled (members hold OLP keys, not OAuth) |
|
||||
|
||||
### 5.4 No `--dangerously-skip-permissions` needed
|
||||
|
||||
Because OLP reads the transcript (§ 4) instead of asking `claude` to *write a result file*, there is no tool invocation to permission, so `--dangerously-skip-permissions` is **not required** for the output path. (S3 used `--dangerously-skip-permissions` in its harness for spawn convenience, and S1/S2 used a pre-seeded `bypassPermissionsModeAccepted` flag — but the *architecture* does not need the dangerous flag because no file-writing tool runs.) If a future requirement forces tool execution, that flag and its full multi-tenant security implications must be re-examined in a new ADR — it is explicitly out of scope here.
|
||||
|
||||
### 5.5 Credential-leak coupling — B's safety DEPENDS on T2+T6 (binding)
|
||||
|
||||
State this plainly: in Deployment B the **owner's OAuth bearer is symlinked into every member's ephemeral `$HOME`** (`.credentials.json`, § 7.1). It is therefore **readable by every member spawn**, and is protected **ONLY** by the (unenforced) no-tool property. There is no second wall. This means **Deployment B's credential safety is not independent of the tool surface — it is coupled to it.** If a member's prompt can reach a tool that reads files (a built-in `Read`/`Bash`, or a slipped-through MCP), it can exfiltrate the owner's bearer.
|
||||
|
||||
Consequences (all binding for B):
|
||||
|
||||
- The structural tool-strip (§ 5.2: `--strict-mcp-config` + `--disallowedTools "mcp__*"` + built-in lockdown `--tools ""`/explicit `--allowedTools`) is **the credential wall**, not merely a privacy-of-data measure. T6 (disable mechanism) and T2 (body-capture proof) are therefore **credential-safety gates**, not just MCP-hygiene gates — link them: **B credential safety ⇐ T2 ∧ T6.**
|
||||
- The ephemeral root MUST be `chmod 700` and **per-`keyId` isolated** (no shared parent that another member can traverse).
|
||||
- The seed (`.claude.json` with `oauthAccount`/`userID`) MUST be written **mode 600**; the symlinked `.credentials.json` target's permissions are the owner's real file (never copied), and the symlink lives only inside the 700 root.
|
||||
- **Orphan-tmux-session reaper (NEW, mandatory).** tmux sessions survive an OLP server restart and continue to hold the owner OAuth (via the still-mounted ephemeral home / live process). On server startup OLP MUST reap orphaned TUI tmux sessions (kill session + `rm -rf` its ephemeral root) before serving, so a crashed/restarted server does not leave owner-credential-bearing sessions live and unowned. This compounds with the § 8 trap-guaranteed teardown (steady-state cleanup) — the reaper is the restart-time backstop.
|
||||
|
||||
---
|
||||
|
||||
## 6. Submission technique (S3 PASS — 15/15 first-attempt; T3 PASS — multiline/special-char now verified)
|
||||
|
||||
**Decision: write the prompt body to a FILE, feed it in one shot with `tmux send-keys -- "$(cat file)"`, then send Enter as a tmux/PTY KEY TOKEN — never as a literal `\n`/`\r` in the text payload.** S3 proved 100% first-attempt submission for short prompts, and a negative control proved the Ink #15553 bug *does* reproduce here when a newline is sent as text (`send-keys -l "...\n"` silently fails to submit). **T3 (PASS)** extended this to realistic multiline + shell-special payloads (fenced code blocks, backticks, `$`, `${VAR}`, `$(…)`, `;`, `&&`, `|`, `&`, quotes, braces, literal mid-prompt newlines, up to ~50 lines / 1.2 KB): each produced **exactly ONE** user submit with the content arriving **byte-for-byte intact** in the transcript, zero premature submit on embedded newlines, zero corruption.
|
||||
|
||||
Production recipe (T3-validated, binding for PR-2 acceptance):
|
||||
|
||||
1. **Write the prompt body to a file. NEVER interpolate it into a shell command line** — that is where backticks/`$()`/`&&`/quotes get mangled by the shell (not by `claude`). T3 verified the file-then-`cat` path delivers all shell-special chars intact.
|
||||
2. **Feed it in ONE shot** with `tmux send-keys -t <S> -- "$(cat promptfile)"`. The leading `--` end-of-options guard is **required** so a prompt starting with `-` is not parsed as a flag. Embedded `\n` bytes are delivered as **soft line-breaks** in the Ink input box and do NOT submit. Do **NOT** use `send-keys -l` for the body in this version — the default (non-literal) mode already passes newlines through correctly and `-l` is unnecessary.
|
||||
3. **Settle ~1.5–2s** to let the Ink input box render (and, for large pastes, to let the paste-collapse UI render — a 50-line block collapses to `❯ [Pasted text #1 +46 lines]`; cosmetic only, buffer is complete).
|
||||
4. **Submit with a SEPARATE Enter KEY TOKEN:** `tmux send-keys -t <S> Enter`. Enter must be a key token, never a literal `"\n"` appended to the text (Ink #15553).
|
||||
5. **Verify** submission by reading the **transcript JSONL** (not `capture-pane`): exactly one `user`-role line whose `message.content` equals the source minus its single trailing newline. (A second `user`-role line carrying `toolUseResult:true` is `claude`'s tool output **within the same turn**, not a second submit.) For large prompts, transcript-read is **mandatory** — the paste-collapse placeholder defeats pane-scraping verification.
|
||||
6. **Retry** Enter (key token) up to ~4× as a defensive guard. S3 never needed it (net-zero cost) but it protects against rare Ink races on upgrade.
|
||||
|
||||
`paste-buffer` / `load-buffer` (bracketed, streams from a file) is an acceptable alternative and is the **recommended fallback at very large sizes** (see caveat below); it offered no advantage for the tested ≤50-line cases and was not needed.
|
||||
|
||||
**Dialog automation (calibrated, S3 — a real footgun):** trust-folder dialog defaults to "1. Yes, I trust" → bare Enter confirms. The bypass-permissions dialog defaults cursor to **"1. No, exit"** — a naive Enter here **EXITS and kills the session**; must send **Down then Enter** to land on "2. Yes, I accept". Better: pre-seed the trust + bypass markers in `.claude.json` (§ 7) so neither dialog appears.
|
||||
|
||||
⚠️ T3 caveats to carry:
|
||||
|
||||
- **Only tested up to ~50 lines / 1.2 KB.** Coding-proxy traffic can carry much larger pastes (whole files, multi-KB diffs). A follow-up spike should confirm `send-keys` behavior at e.g. 500+ lines / tens of KB, where tmux `send-keys` argv length or input-box buffering limits could surface; `paste-buffer`/`load-buffer` (streams from a file) is the more robust path at very large sizes and is the recommended next validation.
|
||||
- **Enter timing.** A fixed settle delay was used; under load or for very large pastes the input box may still be rendering when Enter fires. Production should either poll the pane for the input-box-ready / paste-collapse state before sending Enter, or scale the settle delay to payload size.
|
||||
- **Trailing-newline stripping.** The input box trims the source's single trailing newline on submit. Harmless for prompts, but any cache-key hashing of prompt-in vs prompt-on-wire MUST normalize the trailing newline (see § 3.2) or it will see a mismatch.
|
||||
- Results are pinned to `claude` v2.1.158 + tmux 3.3a on arm64; an Ink-version bump could change #15553 / paste-collapse behavior — re-run the T3 negative control on every `claude` upgrade.
|
||||
|
||||
---
|
||||
|
||||
## 7. Session lifecycle
|
||||
|
||||
### 7.1 Ephemeral default (cleanest privacy — Deployment B default)
|
||||
|
||||
Default = **per-request ephemeral session.** Each request gets its own ephemeral `$HOME` + `--session-id` UUID via `prepareIsolatedEnvironment` (REUSE Phase 7). The transcript lands in `/tmp/olp-spawn/<keyId>/<reqId>/home/.claude/projects/...` and is rm'd on cleanup. This is what guarantees Deployment-B per-member privacy: no two members ever share a `$HOME`, and nothing touches the owner's real `~/.claude`.
|
||||
|
||||
**NEW bootstrap requirement (S1+S2 gap vs current ISOLATION) — TUI-ONLY, must NOT touch the default path.** ⚠️ The seed + tightened-permissions bootstrap below runs **only when `CLAUDE_TUI_MODE` is active.** The default (stream-json) anthropic path keeps the current `ISOLATION` behavior **unchanged** — no `.claude.json` seed, no private account fields (`oauthAccount`/`userID`) written to disk, no behavior change before the feature flag. Gating the seed on the flag is mandatory: otherwise PR-0 would alter existing default-path behavior and expand the sensitive-data-on-disk surface ahead of any opt-in. (Implementation: the `ISOLATION` extend exposes the seed as an opt-in step the session driver invokes only on the TUI branch; `prepareIsolatedEnvironment` does not seed unconditionally.) The current anthropic `ISOLATION` block only symlinks `.credentials.json` and mkdir's `.claude/`. A *fresh* ephemeral `$HOME` triggers `claude`'s first-run onboarding (theme picker → login-method picker → OAuth browser-open, which **hangs**). Under TUI-mode, the bootstrap MUST additionally seed a minimal `.claude.json` carrying `hasCompletedOnboarding:true` + `oauthAccount` + `userID` (copied from the real `~/.claude.json`, `projects` stripped) + `bypassPermissionsModeAccepted:true`, and pre-trust the cwd in the seeded `projects` map to skip the trust dialog. With that seed, the session drops straight to the ready input box.
|
||||
|
||||
⚠️ **The seed does NOT disable managed MCP (T6 negative control).** An earlier draft assumed pinning the seeded `.claude.json` (e.g. removing `claudeAiMcpEverConnected`) would suppress managed-MCP attachment. **T6 disproved this** — the fetch is account/server-driven and ignores the local cache key. The seed's role is **onboarding/trust/bypass convenience only** and carries **no security weight for MCP**; the structural MCP disable is the `--strict-mcp-config` flag (§ 5.2), applied at spawn argv. PR-0 (§ 12) must reflect this: the ISOLATION extend seeds onboarding markers, but the MCP/marketplace disable is a spawn-flag/env concern owned by the session driver, not the seed.
|
||||
|
||||
⚠️ **Privacy + credential handling of the seed (see § 5.5).** `oauthAccount` + `userID` are private account fields. Treat the seed file with the same care as the bearer token: never log it, never commit it, write it **mode 600** only into the `/tmp` ephemeral root, and ensure cleanup rm's it. The ephemeral root MUST be **`chmod 700` and per-`keyId` isolated.** (The OAuth bearer itself stays only in the symlinked `.credentials.json`, never copied — but note § 5.5: that symlink is readable by every member spawn and is protected ONLY by the unenforced no-tool property, so B's credential safety is coupled to T2+T6.) An **orphan-tmux-session reaper** must run on server startup to kill restart-surviving sessions that still hold the owner OAuth (§ 5.5).
|
||||
|
||||
### 7.2 Warm-pool option (Deployment A only, opt-in `CLAUDE_TUI_WARM_POOL`)
|
||||
|
||||
Single-user A may keep N warm interactive sessions to amortize the ~3–4s cold submit→response latency. **A-only** because a warm pool reuses one `$HOME` across requests, which violates B's per-member privacy. Warm-pool entries must still be the *same single owner*. **Critical: the warm pool reuses the PROCESS, not conversation state.** A warm session reused across turns would accumulate conversation context in its transcript — which breaks OpenAI chat-completions **stateless** semantics (a later request would inherit an earlier one's hidden context, dirtying cache + reproducibility) even for a single user (§ 2.1). So each request MUST reset to a clean turn: a fresh `--session-id` per request (preferred — keeps transcript-path computation deterministic) or `/clear` between turns. Cross-request context accumulation is **forbidden for A and B alike** — the only thing A's warm pool saves is process/onboarding cold-start, never context. Pool concerns (crash recovery, idle eviction, max-age recycle) are why tmux is favored over node-pty (§ 8).
|
||||
|
||||
### 7.3 Concurrency — UNPROVEN, gates B
|
||||
|
||||
All three spikes ran **sequentially**. Concurrent multi-session isolation (N parallel requests) is **unproven**. The likely-correct answer is "one ephemeral `$HOME` per session, distinct `--session-id` + cwd" (which the ephemeral default already gives), but it must be spiked under real parallel load before Deployment B serves concurrent members, including whether one OAuth credential tolerates concurrent interactive sessions (§ 11, spike T4). Until then, B runs with a concurrency limit of 1 (serialize), or stays in canary.
|
||||
|
||||
---
|
||||
|
||||
## 8. tmux vs node-pty
|
||||
|
||||
**Recommendation: tmux as the primary transport; keep a node-pty adapter behind an interface as a fallback/option.**
|
||||
|
||||
| Dimension | tmux | node-pty |
|
||||
|---|---|---|
|
||||
| Crash recovery | **System-level** — session survives an OLP server restart; can re-attach + capture-pane to recover state | In-process — server crash kills the PTY and loses the turn |
|
||||
| Weight | External binary dependency; one process per session | In-process, lighter; native addon (engines-bump + CI matrix per ADR 0009 § 6 discipline) |
|
||||
| Spike coverage | **All of S1/S2/S3 used tmux** — the validated path | Unvalidated for OLP's flow |
|
||||
| Submission control | `send-keys` key-token vs `-l` text is the exact, S3-calibrated #15553 control | Would need its own submission-reliability re-validation |
|
||||
| Observability/debug | `capture-pane` gives a human-inspectable pane for ops | Buffer only |
|
||||
|
||||
Rationale: every passing spike used tmux, so tmux is the de-risked choice and the one this spec is written against. tmux's system-level crash recovery is especially valuable for the warm-pool (§ 7.2) and for ops debuggability (`tmux attach` to a stuck session). node-pty's in-process lightness is attractive for a pure-Node server, but it adds a native-addon dependency (CI matrix + engines bump) and **has zero spike coverage** — adopting it now would re-open submission and completion-detection risk that tmux has already closed. **Decision:** ship tmux first; define the session driver behind a transport interface (`lib/tui/session.mjs`) so a node-pty adapter can be added later without touching the transcript reader or IR mapping. ⚠️ tmux teardown must be **trap-guaranteed** — S3 noted the driver's best-effort `rm` left empty ephemeral `home_*` dirs with stray cred symlinks; production cleanup must be a `trap`/`finally`, not best-effort, or scratch homes (and cred symlinks) accumulate.
|
||||
|
||||
---
|
||||
|
||||
## 9. Reuse map
|
||||
|
||||
| Need | Reused asset | Status |
|
||||
|---|---|---|
|
||||
| Entry surface (`/v1/chat/completions`, key auth, owner gating) | `server.mjs`, `lib/keys.mjs` (`owner_tier`, `providers_enabled`, `__env_owner__`) | REUSE unchanged |
|
||||
| IR ↔ anthropic shape; `role:system` → `--system-prompt` | `lib/ir`, `lib/providers/anthropic.mjs` `irToAnthropic` / `extractSystemPrompt` | REUSE |
|
||||
| Tool-suppression + hallucination fix + cost reduction | `OLP_SYSTEM_PROMPT_WRAPPER` (Phase 6c) | REUSE |
|
||||
| Per-request ephemeral `$HOME`, credential symlink, cleanup | `lib/sandbox/manager.mjs` `prepareIsolatedEnvironment` + anthropic `ISOLATION` (ADR 0002 Amd 9) | REUSE + **EXTEND**: seed `.claude.json` (onboarding/trust/bypass only), `chmod 700` root + mode-600 seed + per-`keyId` isolation (§ 5.5). **NOTE:** the MCP/marketplace disable is NOT in the seed (T6 negative control) — it is the spawn-argv `--strict-mcp-config` + `CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL=1`, owned by the session driver |
|
||||
| Per-key cache + audit isolation | `lib/cache`, `lib/audit` | REUSE |
|
||||
| Optional OS-level sandbox (Layer 3) | sandbox-runtime `wrapForLayer3` (ADR 0014) | REUSE if active; orthogonal to TUI |
|
||||
| Session driver (PTY/tmux, submit, dialogs) | — | **NEW** `lib/tui/session.mjs` |
|
||||
| Transcript reader (path, completion, extract) | — | **NEW** `lib/tui/transcript.mjs` |
|
||||
|
||||
The EXTEND to `ISOLATION` (seed `.claude.json` for onboarding/trust/bypass; tighten root/seed permissions per § 5.5) is the only change to a Phase 7 *bootstrap* surface; it should land as its own reviewable PR (PR-0, Iron Rule 11) with ADR 0002 Amendment 9 cited, because it changes the per-spawn bootstrap contract. The managed-MCP/marketplace disable is **not** part of this EXTEND — T6 proved it is account/server-driven and cannot be controlled via the seeded home; it is enforced at spawn-argv time (`--strict-mcp-config`) by the session driver (PR-2) and gated by the per-spawn verification check.
|
||||
|
||||
---
|
||||
|
||||
## 10. Risks & opt-in framing
|
||||
|
||||
### 10.1 Precarious loophole (document honestly)
|
||||
|
||||
- **Anthropic can close it.** Parent-process verification, device fingerprinting, request-cadence/timing-pattern detection, or simply reclassifying "any third-party app" to the credit pool would kill the billing bridge. The bridge is estimated viable ~30–60 days post-2026-06-15 — a spike judgment, **not** Anthropic-confirmed. Per ADR 0009 Amd 1, the implementation must keep working (minus the billing benefit) if the bridge dies, because the cost/hallucination/observability values are orthogonal.
|
||||
- **Shared 5-hour cap.** One pooled owner OAuth → the subscription's rolling 5-hour cap is shared across all B members. A heavy member can exhaust the window for everyone. Rate-modeling must account for the auxiliary calls too: S1 saw **5× `/v1/messages` per single user turn** (main + prompt_suggestion forked agent + title/topic gen), all `cc_entrypoint=cli` — extra quota draw and extra cap pressure.
|
||||
- **Requires `claude login` once on the host.** No member OAuth; the owner runs it once. If the OAuth expires/revokes, all of B is down until re-login.
|
||||
|
||||
### 10.2 ToS-intent grey area (frame honestly, not as forgery)
|
||||
|
||||
TUI-mode runs a *genuinely interactive* `cc_entrypoint=cli` session — it is **not forging** the entrypoint header. But it **automates** that interactive mode to serve programmatic requests, which is against the spirit of "interactive mode = a human at a terminal." OLP states this plainly rather than hiding it. Mitigation = **opt-in**: `CLAUDE_TUI_MODE` lets the operator consciously choose:
|
||||
|
||||
- **flag set** → TTY path (grey-area, billing-favorable — `cc_entrypoint=cli`, the genuine interactive-use signal S1 confirmed; **actual subscription-pool billing is a post-2026-06-15 inference, not S1-proven** — § 1.2, measured first by the OCP canary § 12.6);
|
||||
- **flag unset (default)** → stream-json path (**safe ToS posture, uncertain billing**). Per ADR 0009 § 1.3 the default itself **may** bill to the Agent SDK credit pool because Anthropic may key on the `isTTY` signal rather than the `-p` flag — the piped-stdio default is non-TTY. Do **not** describe the default as a guaranteed credit-pool *or* subscription path; its billing classification is uncertain. Its value is the conservative ToS posture, not a billing guarantee.
|
||||
|
||||
No anti-fingerprinting is added (AGENTS.md: "No anti-fingerprinting"). If Anthropic detects and bans the spawn pattern, the documented response is to drop/disable TUI-mode (fall back to the default path or other providers), **not** to mask the spawn.
|
||||
|
||||
### 10.3 Reliability gates (be honest where spikes were thin)
|
||||
|
||||
- **Completion detection** — **T1 RESOLVED (partial)**: `turn_duration` is reliable for `end_turn` text turns and refusals but **ABSENT on tool-use turns** (would hang). The dual-signal guard (turn_duration **OR** co-equal quiescence/wall-clock/`stop_reason:tool_use` teardown) is now **mandatory and built into PR-1** (§ 4.4), not a deferred fold-in. A true `max_tokens` truncation remains formally unverified (no CLI flag to force it; § 4.5).
|
||||
- **Submission** — **T3 RESOLVED (PASS)**: multiline + shell-special payloads submit byte-for-byte intact via file → `send-keys -- "$(cat file)"` → separate Enter (§ 6). Gates PR-2 acceptance. Open follow-up: very large pastes (500+ lines / tens of KB) — validate `paste-buffer`/`load-buffer` next (non-blocking for initial A rollout).
|
||||
- **MCP/marketplace disable** — **T6 RESOLVED (PASS)**: `--strict-mcp-config` (no `--mcp-config`) gives 0 managed-MCP attachment; seed-editing does NOT (account/server-driven). § 5.2.
|
||||
- **Concurrency** is entirely **unproven** — gates Deployment B (§ 7.3, spike T4).
|
||||
- **Security (no-tool structural body proof)** — the disable *mechanism* is resolved (T6); the **body-level capture** that the wire `/v1/messages` carries `tools:[]` is the one remaining structural gate (§ 5.2 (4), spike T2) — and per § 5.5 it is a **credential-safety** gate for B, not just MCP hygiene.
|
||||
|
||||
---
|
||||
|
||||
## 11. Open questions & spike-gated items
|
||||
|
||||
No item below blocks the *architecture*; each gates a specific rollout step. **T1, T3, T6 are now spiked** (pre-code gate set, § 12); T2, T4, T5 remain open.
|
||||
|
||||
| ID | Status | Question | Gates | Method / Result |
|
||||
|---|---|---|---|---|
|
||||
| **T1** | ✅ **PARTIAL** | Is `turn_duration` emitted on `max_tokens`, tool-use, and refusal turns? | Completion-detect reliability (all rollout) — now built into PR-1 | **RESULT:** reliable for `end_turn` text (`durationMs=33135` near-cap) **and** refusal (`durationMs=3221`); **ABSENT on tool-use** (`stop_reason:tool_use`, no marker → hang). True `max_tokens` truncation unforceable (no CLI flag). → dual-signal guard (§ 4.4) is MANDATORY; re-run per CLI/Ink bump |
|
||||
| **T2** | 🔴 **OPEN** | Can the outbound `/v1/messages` be **proven** (body capture) to carry `tools:[]`? | **Deployment B** (multi-tenant security + credential safety, § 5.5) | Disable mechanism RESOLVED by T6 (`--strict-mcp-config`); remaining: body-capture (MITM/body-log) the wire request; assert no tools array. `--debug api` is insufficient (no bodies) |
|
||||
| **T3** | ✅ **PASS** | Long/multiline prompts and prompts with tmux-special chars — submit reliably without premature submit? | Real prompt traffic — gates PR-2 acceptance | **RESULT:** 3/3 realistic payloads (fenced code, heavy shell-special, ~50-line block) submitted byte-for-byte, exactly 1 user submit each, 0 premature submit. Recipe: file → `send-keys -- "$(cat file)"` → separate Enter (§ 6). Open follow-up: 500+ lines / tens of KB via `paste-buffer` (non-blocking) |
|
||||
| **T4** | 🔴 **OPEN** | Under N parallel requests sharing one owner OAuth, does per-session ephemeral `$HOME`+`session-id` give clean isolation, and does one OAuth tolerate concurrent interactive sessions? | **Deployment B concurrency** | Run K concurrent sessions; check transcript isolation, billing entrypoint stays `cli`, no auth contention; until passed, B serializes (concurrency=1) |
|
||||
| **T5** | 🔴 **OPEN** | inotify vs poll for completion at scale; Opus-class long-streaming latency; cold-start end-to-end latency (unmeasured) | Performance tuning (non-blocking) + sizing the § 4.4 wall-clock cap | `inotifywait` vs 0.5s poll under load; measure long-response `turn_duration` ordering; **measure cold-start end-to-end latency before B** |
|
||||
| **T6** | ✅ **PASS** | Exact flag/settings combination that disables marketplace auto-fetch + managed-MCP attach | Feeds T2 + is the § 5.2 disable mechanism + § 5.5 credential wall | **RESULT:** `--strict-mcp-config` (no `--mcp-config`) → 0 `mcp-logs-claude-ai-*` dirs, `/mcp` empty (vs baseline 3 servers/28 tools). **NEGATIVE control:** seed-editing (`claudeAiMcpEverConnected`) does NOT disable (account/server-driven). Defense-in-depth: `CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL=1` + `--disallowedTools "mcp__*"` + `--tools ""`/`--allowedTools`. NOT `--bare` (breaks OAuth). Verification gate: assert 0 mcp-logs dirs + `/mcp` empty per spawn |
|
||||
|
||||
---
|
||||
|
||||
## 12. Rollout
|
||||
|
||||
Sequenced to honor Iron Rule 11 (minimum reviewable unit per layer) and to validate billing/security before any multi-tenant exposure.
|
||||
|
||||
**Pre-code gate set (DONE — resolved before any PR lands).** Per the two design reviews, T1 must be resolved *with* the transcript reader, not folded in after; and T3/T6 likewise feed the driver/security layers they gate. These three are now **pre-code gates, completed before PR-0/PR-1/PR-2:**
|
||||
|
||||
- **T1 (✅ PARTIAL)** — completion detection on non-`end_turn` stop reasons. Result forces the **dual-signal guard** into PR-1's design (§ 4.4), not a later fold-in. Resolved before PR-1.
|
||||
- **T3 (✅ PASS)** — multiline/special-char submission. Result defines and **gates PR-2 acceptance** (§ 6 recipe). Resolved before PR-2.
|
||||
- **T6 (✅ PASS)** — marketplace + managed-MCP disable mechanism (`--strict-mcp-config`). Result defines PR-0/PR-2's spawn-flag set (§ 5.2) and is the § 5.5 credential wall. Resolved before PR-0/PR-2.
|
||||
|
||||
**PR sequence:**
|
||||
|
||||
1. **PR-0 — ISOLATION extend (TUI-ONLY — default path unchanged).** Seed `.claude.json` (onboarding/trust/bypass **only** — NOT an MCP control; § 7.1 + T6 negative control) in the anthropic `ISOLATION` block + `prepareIsolatedEnvironment`, **invoked only on the `CLAUDE_TUI_MODE` branch** so the default stream-json path's bootstrap + on-disk sensitive-data surface are unchanged (§ 7.1). Ephemeral root `chmod 700`, seed mode 600, per-`keyId` isolation (§ 5.5). Cite ADR 0002 Amendment 9. Independent reviewer (Iron Rule 10). Lands first because every TUI spawn depends on it. (The MCP/marketplace disable is spawn-argv/env — `--strict-mcp-config` + `CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL=1` — owned by PR-2's session driver, per T6.)
|
||||
2. **PR-1 — transcript reader** (`lib/tui/transcript.mjs`): path compute, lazy-create poll, **dual-signal completion (T1): `turn_duration` OR co-equal quiescence/wall-clock/`stop_reason:tool_use` terminal-teardown (§ 4.4)** — designed in from the start, not added later. Assistant-text extraction. `max_tokens`/other-param graceful-drop boundary (§ 4.5–4.6). Returns a **resolved response string** adapted to the `getOrCompute`/singleflight cache contract (§ 3.2). Unit-tested against captured fixtures incl. a tool-use-no-marker fixture.
|
||||
3. **PR-2 — session driver** (`lib/tui/session.mjs`): tmux spawn with the T6 flag set (`--strict-mcp-config` + `--disallowedTools "mcp__*"` + built-in lockdown; env `CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL=1`) + post-spawn MCP-disable verification gate (§ 5.2). **T3 submit recipe (file → `send-keys -- "$(cat file)"` → separate Enter) + transcript-read verify/retry — T3 PASS gates acceptance.** Dialog auto-answer, trap-guaranteed cleanup, **orphan-tmux-session reaper on startup (§ 5.5)**. tmux transport behind the interface; node-pty stubbed. Single-buffered response + SSE-replay for `stream:true` (§ 3.1).
|
||||
4. **PR-3 — provider wiring**: `CLAUDE_TUI_MODE` branch in anthropic `.spawn()`; default stays stream-json (uncertain-billing / safe ToS posture, § 10.2). New ADR (0009 Amd 2 / 0016) as authority of record. README: new env var + Troubleshooting (onboarding-hang quirk, OAuth-login requirement, **no true token-streaming** § 3.1, **`max_tokens`/sampling params not honored** § 4.5–4.6) + honest grey-area framing.
|
||||
5. **Measure cold-start end-to-end latency** (currently unmeasured — fold into T5) before enabling B; informs the § 4.4 wall-clock cap sizing.
|
||||
6. **OCP canary first.** Enable `CLAUDE_TUI_MODE` on **OCP** (single-tenant, the maintainer's own subscription, Deployment A) post-2026-06-15. OCP is the natural canary: single user, no cross-tenant boundary, and it is where PR #101 originated. Watch billing entrypoint stays `cli`, cap behavior, completion reliability over real usage.
|
||||
7. **Spike T2 + T4** (security body-capture + concurrency) — **hard gate** before B. T2 is a **credential-safety** gate per § 5.5.
|
||||
8. **OLP Deployment B** (family/team) only after T2 + T4 pass: enable per-key, members on guest keys (full § 5.2 flag set + per-spawn MCP-disable verification gate), concurrency limit lifted only when T4 passes. Until then B runs serialized or stays in canary.
|
||||
|
||||
The version bump + tag fires at the Phase close per CLAUDE.md `release_kit.phase_rolling_mode` (explicit maintainer action), not per D-day push.
|
||||
|
||||
---
|
||||
|
||||
## 13. Author credit plan (binding — community-PR provenance)
|
||||
|
||||
TUI-mode adopts the core idea from **`dtzp555-max/ocp` PR #101 by jaekwon-park** (interactive-`claude` via tmux to keep traffic on the subscription pool). OCP rejected PR #101's *specific implementation* (hook-file polling + `--dangerously-skip-permissions`) on alignment + security grounds, but the *idea* is the seed of this spec. The author MUST be credited and notified:
|
||||
|
||||
- **Co-author trailer** on the implementing commits: `Co-Authored-By: jaekwon-park <…>` (use the email/handle from PR #101; do not invent one — pull it from the PR before committing).
|
||||
- **ADR acknowledgment**: the authority-of-record ADR (0009 Amd 2 / 0016) names PR #101 + jaekwon-park in its "Builds on" / acknowledgment section, noting what was adopted (the interactive-TUI-for-subscription-billing idea) and what was redesigned (transcript-read instead of hook-file; no `--dangerously-skip-permissions`; structural tool-stripping for multi-tenant).
|
||||
- **CONTRIBUTORS / notification**: add jaekwon-park to CONTRIBUTORS (or equivalent) and **notify them on PR #101** (a comment on the original PR) that the idea was adopted into OLP/OCP TUI-mode, with a link to the shipping PR. This is a courtesy + provenance obligation, not optional.
|
||||
|
||||
---
|
||||
|
||||
## 14. Authority citations
|
||||
|
||||
- **Billing classification** — Anthropic 2026-06-15 split; `~/.cc-rules/memory/learnings/anthropic_claude_code_billing_split_2026_06_15.md`; published docs (`code.claude.com/docs/en/headless`, `support.claude.com/en/articles/15036540`, `support.claude.com/en/articles/11145838`) per ADR 0009 Amd 1 § "Additional spike findings".
|
||||
- **`--system-prompt` tool suppression + cost/hallucination value** — ADR 0009 Amendment 1; `lib/providers/anthropic.mjs` `OLP_SYSTEM_PROMPT_WRAPPER`; claude CLI v2.1.104+ `--help` § `--system-prompt`.
|
||||
- **Ephemeral-home isolation contract** — ADR 0014 (sandbox-runtime integration) + ADR 0002 Amendment 9 (Provider ISOLATION contract); `lib/sandbox/manager.mjs` `prepareIsolatedEnvironment`; 2026-05-29 PI231 ephemeral-home spike.
|
||||
- **Multi-key auth** — ADR 0007; `lib/keys.mjs`.
|
||||
- **Interactive-mode lineage** — ADR 0009 (placeholder + Amendment 1); OCP ADR 0007; **OCP PR #101 (jaekwon-park)**.
|
||||
- **Spike evidence** — S1 (billing + no-tool, PARTIAL), S2 (transcript output, PASS), S3 (submission reliability, PASS), **T1 (completion on non-`end_turn` stop reasons, PARTIAL — `turn_duration` reliable for text/refusal, ABSENT on tool-use)**, **T3 (multiline/special-char submission, PASS)**, **T6 (marketplace + managed-MCP disable, PASS — `--strict-mcp-config` load-bearing; seed-edit is NOT a mitigation)**, `claude` v2.1.158, model `claude-haiku-4-5-20251001`, tmux 3.3a, PI231 (ephemeral HOME with seeded creds; PROD :4567 confirmed untouched; scratch + cred symlink removed in finally). Spike JSON retained in session record.
|
||||
- **CLI version pin** — validated on `claude` v2.1.158; ADR 0009 Amd 1 § "CLI version pin guidance" — emit a log warning if `claude --version` falls outside the validated range; re-run the S3/T3 submission negative control **and** the T1 `turn_duration` + T6 MCP-disable spikes on every `claude` upgrade (Ink-version + undocumented-internal-log + account/server-driven-MCP sensitivity).
|
||||
+22
-2
@@ -8,7 +8,7 @@
|
||||
3. **Where** does the work live in the tree today (file + anchor).
|
||||
4. **When** does it need to land (trigger: load profile, security event, governance amendment).
|
||||
|
||||
**Reading order for a v1.x sprint kickoff.** As of 2026-05-25, #1 (streaming SF, D57+D58) and #2 (multi-key auth, Phase 2) are CLOSED, and #4 and #7 closed in D56. Remaining v1.x scope: #3 (soft trigger reactivation), #5 (provider cacheKeyFields mask), #6 (streaming SPAWN_FAILED salvage — unbundled from #1 at #1 close). All three remaining items have explicit "trigger to start" gates that have not fired.
|
||||
**Reading order for a v1.x sprint kickoff.** As of 2026-05-27, #1 (streaming SF, D57+D58), #2 (multi-key auth, Phase 2), #4, #7, and #8 are CLOSED. Remaining v1.x scope: #3 (soft trigger reactivation), #5 (provider cacheKeyFields mask), #6 (streaming SPAWN_FAILED salvage — unbundled from #1 at #1 close). All three remaining items have explicit "trigger to start" gates that have not fired.
|
||||
|
||||
---
|
||||
|
||||
@@ -88,8 +88,28 @@
|
||||
- **Tracking.** Not a GitHub issue. Tracked here.
|
||||
- **Trigger to start.** First report of streaming-path SPAWN_FAILED mid-stream where partial-chunk salvage would have helped a downstream caller. Practically unlikely at family scale.
|
||||
|
||||
## #7 — AUTH_MISSING tuple path test coverage (D40 follow-up)
|
||||
## #8 — Dashboard enrichment: per-provider subscription quota + reset times + 1-min refresh + manual refresh (D78 follow-up) — ✅ **CLOSED (D82, v0.5.0)**
|
||||
|
||||
- **Status.** Closed at D82 (Phase 5). `dashboard.html` restructured to Claude.ai-style per-provider rows rendering `quota_v2`. Closed by PR on branch `d82-dashboard-ui-claude-ai-style`; ships with v0.5.0. 60s quota auto-refresh + manual refresh button + visibilityState guard implemented. Graceful fallback to legacy `quota` field when server runs a pre-D81 build.
|
||||
- **What.** Phase 3 dashboard (D51 `dashboard.html`, v0.3.0) shows: per-provider quota (currently always "n/a — no quota api"), last-24h request count + cache hit + fallback rate, 30d request-count sparkline, top fallback chains. **Maintainer request 2026-05-26 post-D78**: extend to show what each enabled provider's subscription is actually consuming, with reset times visible, refresh once per minute (current 30s is OK but maintainer specified 1min target), and a manual refresh button. Reference design: Claude.ai's own `claude.ai/settings/usage` page — current session bar with "Resets in 1hr 6min", weekly all-models bar with "Resets Sun 9:00 PM", per-model bar (Sonnet only), additional features (routine runs), usage credits + monthly spend limit + auto-reload toggle.
|
||||
- **Why deferred.** v0.3.0/v0.4.x ships the dashboard frame but `provider.quotaStatus()` returns `null` in all three v0.1 plugins (anthropic / openai / mistral). The ratifying spec in ADR 0004 Amendment 2 punts `quotaStatus()` to v1.x ("soft trigger reactivation") — this dashboard ask is the **operator-facing reason** that work would land.
|
||||
- **What this requires.** Per-provider plugin work + dashboard.html UI work + audit-query.mjs aggregation:
|
||||
1. **`lib/providers/anthropic.mjs quotaStatus()`** — discover where the maintainer's Claude.ai subscription quota state is exposed. Candidates: (a) `claude` CLI command (e.g., `claude usage`) if Anthropic adds one — currently absent; (b) parsing the `claude-code` output for rate-limit error messages and caching state from headers; (c) hitting `api.anthropic.com/v1/.../usage` directly via the OAuth refresh token — not a documented endpoint, primary-source risk. ADR 0002 Rule 1 / Rule 5 require an authority citation before any implementation. Likely path: **wait until Anthropic publishes a documented endpoint**, OR derive from audit-side request counts only (no real quota truth, just "you sent N requests in the current 5h window").
|
||||
2. **`lib/providers/openai.mjs quotaStatus()`** — codex CLI doesn't expose ChatGPT-subscription quota state. OpenAI rate-limit headers per request might be parseable but ADR 0004 Amendment 2 explicitly says no plugin parses HTTP status at v0.1.
|
||||
3. **`lib/providers/mistral.mjs quotaStatus()`** — Le Chat Pro has `/v1/usage` endpoint per Mistral docs (verify).
|
||||
4. **`dashboard.html` UI restructure** to a Claude.ai-style layout: rows of (label, bar, "Resets in X" / "Resets at <day-of-week> <time>", percent). Add a manual refresh button + change auto-poll from 30s → 60s. Optionally a usage-credits / per-key spend display if Phase 5 ships per-key cost weights.
|
||||
5. **`lib/audit-query.mjs`** — extend `aggregateRequests` / `spendTrendDaily` to compute "in the current rolling window" (since session/week start) per provider. Today's aggregates are wall-clock windows; subscription resets are per-account-anchored. Need a way to model session windows (e.g., "Anthropic 5h-from-first-request-since-last-reset").
|
||||
- **Reference (maintainer 2026-05-26).** Screenshot of `claude.ai/settings/usage` shared inline. Key panels: Plan usage limits (current session + resets-in), Weekly limits (All models / Sonnet only / per-feature breakdown, each with resets-on), Additional features (Daily included routine runs N / 15), Usage credits (toggle + spent vs monthly limit + auto-reload + buy-credits link).
|
||||
- **Tracking.** Not yet a GitHub issue. Track here + cross-reference ADR 0004 Amendment 2 (soft trigger reactivation — same `quotaStatus()` data-source work) when this becomes Phase 5 scope.
|
||||
- **Code anchors today.**
|
||||
- `dashboard.html` — current 4 panels; needs restructure to Claude.ai-style row layout
|
||||
- `lib/providers/anthropic.mjs` / `openai.mjs` / `mistral.mjs` — `quotaStatus()` returns null today
|
||||
- `lib/audit-query.mjs` — current `aggregateRequests` is wall-clock-window; needs session-window variant
|
||||
- **Trigger to start.** ANY of: (a) Anthropic publishes a documented `claude usage` CLI or `api.anthropic.com/v1/usage` endpoint, (b) maintainer hits real "I want to see quota right now" pain often enough to design without per-provider truth (audit-derived only), (c) Phase 5 multi-tenant adds per-key spend limits and the dashboard needs to surface those.
|
||||
|
||||
## #7 — AUTH_MISSING tuple path test coverage (D40 follow-up) — ✅ **CLOSED (D56, 2026-05-27)**
|
||||
|
||||
- **Status.** Closed. Test shipped at D56 (PR `f4-cli-plugin-quota-v2-plus-auth-missing-test`, 2026-05-27). Test: `test-features.mjs` line 6255 — `'engine: AUTH_MISSING terminates chain, fallbackDetail tuple records trigger_type:"auth_missing" (D56, v1.x roadmap #7)'`. Asserts: `result.fallbackDetail[0].code === 'AUTH_MISSING'`, `result.fallbackDetail[0].trigger_type === 'auth_missing'`, `result.fallbackHops === 0` (no advance). The test was already present in the file before this PR closed the roadmap entry.
|
||||
- **What.** Dedicated test in `test-features.mjs` Suite D40 that asserts the `fallbackDetail` tuple records the AUTH_MISSING path with `trigger_type: 'auth_missing'`. D40 reviewer flagged this as the last gap in the engine-path matrix; code is structurally correct, just lacks an explicit pin.
|
||||
- **Why deferred.** Low priority — the AUTH_MISSING early-return branch has the tuple push BEFORE it (verified in D40 reviewer pass), so coverage is implicit via the other engine-path tests. A 3-line dedicated test would make the pin explicit.
|
||||
- **Design.** No ADR needed. ~5-line test addition.
|
||||
|
||||
@@ -443,6 +443,197 @@ export function spendTrendDaily({ days, olpHome, logEvent, _nowFn } = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a single quotaStatus() return value from the anthropic plugin into
|
||||
* the dashboard-friendly shape (D81 / ADR 0008 Amendment).
|
||||
* Provider-specific: called only for 'anthropic'. Returns null if the raw
|
||||
* shape is absent or malformed.
|
||||
*
|
||||
* v0.5.1: handles probe_status field (F3 — ADR 0013 Rule 6).
|
||||
* Accepts both old shape (stale: boolean) and new shape (probe_status: string).
|
||||
*
|
||||
* @internal — used by aggregateProviderQuota()
|
||||
*/
|
||||
function _normalizeAnthropicQuota(raw) {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const f = raw.fields ?? {};
|
||||
// v0.5.1: probe_status field (new) takes precedence; fall back to stale bool for compat.
|
||||
const probeStatus = raw.probe_status ?? (raw.stale === true ? 'stale' : 'live');
|
||||
return {
|
||||
schema_version: raw.schemaVersion ?? null,
|
||||
last_fresh_at: (probeStatus === 'stale')
|
||||
? (raw.last_fresh_at ?? null)
|
||||
: (raw.probedAt ?? null),
|
||||
utilization: probeStatus === 'unreachable' ? null : {
|
||||
'5h': f.utilization_5h ?? null,
|
||||
'7d': f.utilization_7d ?? null,
|
||||
},
|
||||
reset: probeStatus === 'unreachable' ? null : {
|
||||
'5h': f.reset_5h ?? null,
|
||||
'7d': f.reset_7d ?? null,
|
||||
overall: f.reset ?? null,
|
||||
overage: f.overage_reset ?? null,
|
||||
},
|
||||
representative_claim: f.representative_claim ?? null,
|
||||
fallback_percentage: f.fallback_percentage ?? null,
|
||||
overage: probeStatus === 'unreachable' ? null : {
|
||||
status: f.overage_status ?? null,
|
||||
disabled_reason: f.overage_disabled_reason ?? null,
|
||||
},
|
||||
raw_available: (typeof raw.raw === 'object' && raw.raw !== null),
|
||||
// v0.5.1 (F3 — ADR 0013 Rule 6): failure detail for operator diagnostics
|
||||
failure: raw.failure ?? null,
|
||||
failure_kind: raw.failure?.kind ?? null,
|
||||
backoff_until: raw.failure?.backoff_until ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate per-provider quota status into a normalized dashboard-friendly
|
||||
* shape. This is the D81 Phase 5 extension of lib/audit-query.mjs per
|
||||
* ADR 0008 Amendment (D81).
|
||||
*
|
||||
* For each loaded provider, calls quotaStatus() (already cached at the plugin
|
||||
* layer per ADR 0013 Rule 3) and normalizes to a consistent shape. Providers
|
||||
* returning null (codex, mistral) produce a { status: 'unavailable' } row.
|
||||
*
|
||||
* Audit-query stays in-memory scan per ADR 0008 Lane 2 = A. This function
|
||||
* does NOT scan the ndjson files; it calls the live provider plugins.
|
||||
*
|
||||
* Authority: ADR 0008 Amendment (D81) + ADR 0012 D81 + ADR 0013 Rule 5.
|
||||
*
|
||||
* @param {object} args
|
||||
* @param {Map<string, object>} args.providers - Map of provider name → plugin object
|
||||
* @param {(name: string) => Promise<object|null>} [args.getQuotaStatus] - injectable for tests;
|
||||
* defaults to calling providers.get(name).quotaStatus?.()
|
||||
* @returns {Promise<Array<{
|
||||
* provider: string,
|
||||
* status: 'live' | 'stale' | 'unavailable' | 'disabled',
|
||||
* reason?: string,
|
||||
* schema_version: string | null,
|
||||
* last_fresh_at: number | null,
|
||||
* utilization: { '5h': number|null, '7d': number|null } | null,
|
||||
* reset: { '5h': number|null, '7d': number|null, overall: number|null, overage: number|null } | null,
|
||||
* representative_claim: string | null,
|
||||
* fallback_percentage: number | null,
|
||||
* overage: { status: string|null, disabled_reason: string|null } | null,
|
||||
* raw_available: boolean,
|
||||
* }>>}
|
||||
*/
|
||||
export async function aggregateProviderQuota({
|
||||
providers,
|
||||
getQuotaStatus,
|
||||
} = {}) {
|
||||
if (!providers) {
|
||||
throw new Error('aggregateProviderQuota: providers (Map) is required');
|
||||
}
|
||||
|
||||
// Normalize the providers argument — accept both Map and plain object.
|
||||
const providerEntries = (providers instanceof Map)
|
||||
? [...providers.entries()]
|
||||
: Object.entries(providers);
|
||||
|
||||
const results = [];
|
||||
for (const [name, plugin] of providerEntries) {
|
||||
// Default getter: call the plugin's quotaStatus() if present.
|
||||
const fetchQuota = getQuotaStatus
|
||||
? () => getQuotaStatus(name)
|
||||
: () => (typeof plugin?.quotaStatus === 'function' ? plugin.quotaStatus(null) : Promise.resolve(null));
|
||||
|
||||
let rawResult = null;
|
||||
let callError = null;
|
||||
try {
|
||||
rawResult = await fetchQuota();
|
||||
} catch (err) {
|
||||
callError = err?.message ?? String(err);
|
||||
}
|
||||
|
||||
if (callError !== null) {
|
||||
// quotaStatus() threw — treat as error / unavailable.
|
||||
results.push({
|
||||
provider: name,
|
||||
status: 'unavailable',
|
||||
reason: callError,
|
||||
schema_version: null,
|
||||
last_fresh_at: null,
|
||||
utilization: null,
|
||||
reset: null,
|
||||
representative_claim: null,
|
||||
fallback_percentage: null,
|
||||
overage: null,
|
||||
raw_available: false,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (rawResult === null || rawResult === undefined) {
|
||||
// Plugin returned null: opt-in disabled (the ONLY case per v0.5.1 contract)
|
||||
// or providers with no quota API at all (codex, mistral).
|
||||
results.push({
|
||||
provider: name,
|
||||
status: 'unavailable',
|
||||
reason: 'no public quota api or probe disabled',
|
||||
schema_version: null,
|
||||
last_fresh_at: null,
|
||||
utilization: null,
|
||||
reset: null,
|
||||
representative_claim: null,
|
||||
fallback_percentage: null,
|
||||
overage: null,
|
||||
raw_available: false,
|
||||
failure: null,
|
||||
failure_kind: null,
|
||||
backoff_until: null,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// quotaStatus() returned a non-null shape — normalize.
|
||||
// v0.5.1: handle probe_status field (live/stale/unreachable).
|
||||
// Currently only 'anthropic' returns a structured shape; other providers
|
||||
// returning structured data will work if their shape is compatible.
|
||||
const probeStatus = rawResult.probe_status ?? (rawResult.stale === true ? 'stale' : 'live');
|
||||
const normalized = _normalizeAnthropicQuota(rawResult);
|
||||
|
||||
if (normalized === null) {
|
||||
// Shape was present but unrecognizable.
|
||||
results.push({
|
||||
provider: name,
|
||||
status: 'unavailable',
|
||||
reason: 'unrecognized quota shape',
|
||||
schema_version: null,
|
||||
last_fresh_at: null,
|
||||
utilization: null,
|
||||
reset: null,
|
||||
representative_claim: null,
|
||||
fallback_percentage: null,
|
||||
overage: null,
|
||||
raw_available: false,
|
||||
failure: null,
|
||||
failure_kind: null,
|
||||
backoff_until: null,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Map probe_status to output status:
|
||||
// 'live' → 'live'
|
||||
// 'stale' → 'stale'
|
||||
// 'unreachable' → 'unreachable' (new in v0.5.1; dashboard renders with red border)
|
||||
const outputStatus = probeStatus === 'unreachable' ? 'unreachable'
|
||||
: probeStatus === 'stale' ? 'stale'
|
||||
: 'live';
|
||||
|
||||
results.push({
|
||||
provider: name,
|
||||
status: outputStatus,
|
||||
...normalized,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Audit-derived cache hit rate over the window. Differs from
|
||||
* `cacheStore.stats()` in server.mjs: that is the live in-process counter;
|
||||
|
||||
+17
-2
@@ -27,13 +27,28 @@ export class BadRequestError extends Error {
|
||||
// ── Role normalization ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* OpenAI deprecated role='function' in favour of role='tool'.
|
||||
* Per ADR 0003, IR supports system/user/assistant/tool.
|
||||
* Normalize entry-surface role names → IR canonical set (system/user/assistant/tool).
|
||||
*
|
||||
* Per ADR 0003, IR supports exactly four roles. OpenAI's chat-completions
|
||||
* spec has evolved beyond that, and we keep the IR minimal by normalizing
|
||||
* at the entry boundary instead of bloating IR + every provider plugin.
|
||||
*
|
||||
* Current normalizations:
|
||||
* - `function` → `tool` — deprecated in OpenAI chat API, replaced by tool.
|
||||
* - `developer` → `system` — OpenAI o1/o3+ reasoning models accept a new
|
||||
* "developer" role with similar semantics to "system" (high-priority
|
||||
* instructions from the developer to the model). Providers like Hermes
|
||||
* Agent and Cline default to `developer` for openai-completions calls.
|
||||
* OLP-side anthropic + codex providers don't differentiate developer
|
||||
* from system, so the IR canonicalizes to `system` and downstream
|
||||
* translations remain unchanged.
|
||||
*
|
||||
* @param {string} role
|
||||
* @returns {string}
|
||||
*/
|
||||
function normalizeRole(role) {
|
||||
if (role === 'function') return 'tool';
|
||||
if (role === 'developer') return 'system';
|
||||
return role;
|
||||
}
|
||||
|
||||
|
||||
+1240
-96
File diff suppressed because it is too large
Load Diff
+166
-5
@@ -458,7 +458,7 @@ function buildSpawnEnv() {
|
||||
//
|
||||
// Authority: Codex CLI reference § "codex exec [flags] PROMPT"
|
||||
// § "--json": NDJSON event stream on stdout
|
||||
async function* _spawnAndStream(irRequest, authContext, spawnImpl) {
|
||||
async function* _spawnAndStream(irRequest, authContext, spawnImpl, isolationCtx) {
|
||||
const auth = authContext ?? readAuthArtifact();
|
||||
if (!auth?.accessToken) {
|
||||
throw new ProviderError(
|
||||
@@ -468,7 +468,7 @@ async function* _spawnAndStream(irRequest, authContext, spawnImpl) {
|
||||
}
|
||||
|
||||
const bin = resolveCodexBin();
|
||||
const { args, prompt, useStdin } = irToCodex(irRequest);
|
||||
const { args: baseArgs, prompt, useStdin } = irToCodex(irRequest);
|
||||
const env = buildSpawnEnv();
|
||||
|
||||
// Authority: Codex CLI reference § "Authentication"
|
||||
@@ -476,7 +476,34 @@ async function* _spawnAndStream(irRequest, authContext, spawnImpl) {
|
||||
// No explicit token injection: Codex CLI reads its own auth.json
|
||||
// (contrast with Anthropic plugin which injects CLAUDE_CODE_OAUTH_TOKEN).
|
||||
|
||||
const proc = spawnImpl(bin, args, { env, stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
// Task #8 — Phase 7 Solution 1: apply isolation context from orchestrator.
|
||||
// isolationCtx is provided by server.mjs (prepareIsolatedEnvironment) when
|
||||
// present. Three layers compose here:
|
||||
// Layer 1 (env): envOverrides (HOME, CODEX_HOME) have final precedence.
|
||||
// Layer 4 (args): hardenedArgs injects --sandbox read-only + -c approval_policy.
|
||||
// Layer 3 (wrap): wrapForLayer3 is identity for codex (hasInnerSandbox=true).
|
||||
// When isolationCtx is absent (legacy callers / tests), behavior is unchanged.
|
||||
// Authority: ADR 0014 Amendment 1 § A1.2 + ADR 0002 Amendment 9 § Backward compat.
|
||||
const envOverrides = isolationCtx?.envOverrides ?? {};
|
||||
const finalEnv = Object.keys(envOverrides).length > 0 ? { ...env, ...envOverrides } : env;
|
||||
|
||||
const hardenedArgs = isolationCtx?.hardenedArgs ?? ((a) => a);
|
||||
const args = hardenedArgs(baseArgs);
|
||||
|
||||
// Layer 3: wrapForLayer3 for codex is always identity (hasInnerSandbox=true);
|
||||
// included here for API symmetry with the anthropic path and future-proofing.
|
||||
const wrapForLayer3 = isolationCtx?.wrapForLayer3 ?? (async (c) => c);
|
||||
const wrappedBin = await wrapForLayer3(bin);
|
||||
let finalBin, finalArgs;
|
||||
if (wrappedBin !== bin) {
|
||||
finalBin = '/bin/sh';
|
||||
finalArgs = ['-c', wrappedBin];
|
||||
} else {
|
||||
finalBin = bin;
|
||||
finalArgs = args;
|
||||
}
|
||||
|
||||
const proc = spawnImpl(finalBin, finalArgs, { env: finalEnv, stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
|
||||
// Write prompt via stdin for multi-line prompts (D6 assumption A1)
|
||||
if (useStdin) {
|
||||
@@ -659,8 +686,14 @@ async function* _spawnAndStream(irRequest, authContext, spawnImpl) {
|
||||
// spawn: async (irRequest, authContext) => AsyncIterator<ResponseChunk>
|
||||
let _spawnImpl = defaultSpawn;
|
||||
|
||||
export async function* spawn(irRequest, authContext) {
|
||||
yield* _spawnAndStream(irRequest, authContext, _spawnImpl);
|
||||
// Task #8 — Phase 7 Solution 1: isolationCtx is an optional third argument.
|
||||
// When present (from server.mjs prepareIsolatedEnvironment call), it carries
|
||||
// { envOverrides, hardenedArgs, wrapForLayer3, cleanup } — the orchestrator
|
||||
// composes these on top of the provider's own env-cleanup + args composition.
|
||||
// When absent (legacy callers, tests that don't pass it), behavior is unchanged.
|
||||
// Authority: ADR 0014 Amendment 1 § A1.2.
|
||||
export async function* spawn(irRequest, authContext, isolationCtx) {
|
||||
yield* _spawnAndStream(irRequest, authContext, _spawnImpl, isolationCtx);
|
||||
}
|
||||
|
||||
// Test hook: inject mock spawn without importing child_process.
|
||||
@@ -795,6 +828,134 @@ export function doctorChecks({ _binaryExistsFn, _authReadFn } = {}) {
|
||||
];
|
||||
}
|
||||
|
||||
// ── ISOLATION export ─────────────────────────────────────────────────────
|
||||
// Declares per-provider isolation primitives consumed by lib/sandbox/manager.mjs
|
||||
// (per ADR 0014 Amendment 1 + ADR 0002 Amendment 9).
|
||||
//
|
||||
// Authority citations (all required per ALIGNMENT.md Rule 1):
|
||||
// codex CLI v0.133.0 — current PI231 prod version (verified 2026-05-29 spike)
|
||||
// https://developers.openai.com/codex/config-reference — CODEX_HOME env var
|
||||
// (2 occurrences verified: "$CODEX_HOME/profile-name.config.toml" and
|
||||
// "$CODEX_HOME/log" path templates)
|
||||
// https://developers.openai.com/codex/auth/ — ~/.codex/auth.json path
|
||||
// (2 occurrences verified: "auth.json under CODEX_HOME" credential-storage
|
||||
// section)
|
||||
// https://developers.openai.com/codex/concepts/sandboxing — --sandbox flag +
|
||||
// read-only default (codex inner bubblewrap sandbox)
|
||||
// openai/codex#16018 — inner bwrap behavior documented (failure under
|
||||
// restricted env, establishing hasInnerSandbox: true)
|
||||
// ADR 0014 Amendment 1 — orchestrator composition architecture
|
||||
// ADR 0002 Amendment 9 — ISOLATION contract spec (field semantics)
|
||||
// docs/spikes/2026-05-29-ephemeral-home.md § 5.3 — flag-drift caveat
|
||||
// (--ask-for-approval removed in codex v0.133.0; use -c approval_policy=)
|
||||
//
|
||||
// isolation rationale: OpenAI Codex's `codex exec` exposes a shell tool that
|
||||
// actually executes commands during the spawn (cc-mem incident memory § 3.2).
|
||||
// The CLI provides its own inner bubblewrap sandbox (`--sandbox read-only` by
|
||||
// default per https://developers.openai.com/codex/concepts/sandboxing) that
|
||||
// confines shell tool reads/writes. The orchestrator's outer isolation composes
|
||||
// with the inner sandbox: credential-dir redirect via CODEX_HOME
|
||||
// (https://developers.openai.com/codex/config-reference) + HOME redirect for
|
||||
// the inner bwrap's HOME lookup + per-spawn ephemeral credential mount.
|
||||
// hasInnerSandbox: true so the outer profile is relaxed to permit the inner
|
||||
// bwrap's user-namespace clone (openai/codex#16018).
|
||||
|
||||
export const ISOLATION = {
|
||||
// ephemeralEnvOverrides: pure function, no side effects, no fs access.
|
||||
// CODEX_HOME redirects the entire codex config/credential base directory.
|
||||
// HOME is also redirected because the codex inner sandbox inherits the parent
|
||||
// process's HOME for its own home lookup unless overridden.
|
||||
// Authority: CODEX_HOME → https://developers.openai.com/codex/config-reference
|
||||
// HOME → POSIX convention (both verified by PI231 spike § 4.3-4.4).
|
||||
ephemeralEnvOverrides: ({ ephemeralRoot, keyId: _keyId, reqId: _reqId }) => ({
|
||||
HOME: ephemeralRoot,
|
||||
CODEX_HOME: `${ephemeralRoot}/.codex`,
|
||||
}),
|
||||
|
||||
// credentialMounts: static list of [srcAbsPath, dstRelativeToEphemeralRoot].
|
||||
// srcAbsPath uses os.homedir() (imported as `homedir` at top of file) per
|
||||
// ADR 0002 Amendment 9 § Field 2 validation rules: absolute paths only, no
|
||||
// `~/` prefixes (shell-expansion semantics differ from Node.js behavior).
|
||||
// Authority: ~/.codex/auth.json → https://developers.openai.com/codex/auth/
|
||||
// "Codex caches login details locally in a plaintext file at ~/.codex/auth.json"
|
||||
// (matches existing codex.mjs `auth.path` field declaration above).
|
||||
credentialMounts: [
|
||||
[join(homedir(), '.codex', 'auth.json'), '.codex/auth.json'],
|
||||
],
|
||||
|
||||
// requiredHomePaths: directories to mkdir-p under ephemeralRoot before mounts.
|
||||
// .codex is required because CODEX_HOME points there and codex startup may
|
||||
// attempt to read from it before any auto-create logic runs (observed in
|
||||
// PI231 spike § 4.3 post-state: .codex/ created at spawn time).
|
||||
requiredHomePaths: [
|
||||
'.codex',
|
||||
],
|
||||
|
||||
// hasInnerSandbox: true — codex exec spawns its own bubblewrap sandbox
|
||||
// internally. Declaring true tells the outer isolation orchestrator to relax
|
||||
// the outer profile to permit clone(CLONE_NEWUSER) so the inner bwrap can
|
||||
// create user namespaces. Without this flag the inner bwrap fails with
|
||||
// EPERM. Authority: openai/codex#16018 + https://developers.openai.com/codex/concepts/sandboxing
|
||||
hasInnerSandbox: true,
|
||||
|
||||
// crossTenantReadProtection: 'inner-sandbox' — codex's shell tool runs real
|
||||
// commands but the inner bubblewrap sandbox (read-only by default) confines
|
||||
// reads/writes to the inner namespace. The toolHardeningArgs below makes this
|
||||
// default explicit at the spawn-args level. Authority: openai/codex#16018 +
|
||||
// https://developers.openai.com/codex/concepts/sandboxing.
|
||||
crossTenantReadProtection: 'inner-sandbox',
|
||||
|
||||
// recommendedDeploymentTier: 'per-os-user' — the inner bwrap sandbox protects
|
||||
// against accidental cross-tenant leakage from the model's shell tool, but a
|
||||
// sandbox-escape CVE (e.g. in bubblewrap) would expose the OS-user filesystem.
|
||||
// Per-OS-user isolation adds defense in depth. See ADR 0002 Amendment 9
|
||||
// § Field 6 for the full rationale per recommendedDeploymentTier semantics.
|
||||
recommendedDeploymentTier: 'per-os-user',
|
||||
|
||||
// toolHardeningArgs: injects --sandbox read-only if not already present, and
|
||||
// -c approval_policy="never" to suppress interactive approval prompts.
|
||||
//
|
||||
// Flag-drift caveat (docs/spikes/2026-05-29-ephemeral-home.md § 5.3):
|
||||
// ADR 0002 Amendment 9 § codex example uses `--ask-for-approval never`.
|
||||
// PI231 spike (2026-05-29) confirmed this flag was REMOVED in codex
|
||||
// v0.133.0. The codex v0.133.0 `--help` output shows the replacement is
|
||||
// the generic config-override flag: `-c approval_policy="never"`.
|
||||
// We use `-c approval_policy="never"` here. This deviates from the ADR
|
||||
// 0002 Amendment 9 code example (not the field spec — the spec only
|
||||
// requires an injected flag corresponding to a documented CLI flag).
|
||||
// The config-override form is documented at https://developers.openai.com/codex/config-reference
|
||||
// as the mechanism for overriding any config key at spawn time, including
|
||||
// approval_policy. The deviation is intentional, flag-drift-driven, and
|
||||
// takes precedence over the (now-incorrect) Amendment 9 code example per
|
||||
// ALIGNMENT.md Rule 2 (provider CLI is the authority, not the ADR text).
|
||||
//
|
||||
// --sandbox read-only: Authority: https://developers.openai.com/codex/concepts/sandboxing
|
||||
// § "Sandboxing modes" — the default posture is `read-only`; injecting it
|
||||
// explicitly prevents a future codex default change from silently weakening
|
||||
// isolation (same rationale as the existing irToCodex --skip-git-repo-check).
|
||||
toolHardeningArgs: (existingArgs) => {
|
||||
let result = [...existingArgs];
|
||||
|
||||
// Inject --sandbox read-only if the caller has not already specified --sandbox.
|
||||
if (!result.some(arg => arg === '--sandbox' || arg.startsWith('--sandbox='))) {
|
||||
result = [...result, '--sandbox', 'read-only'];
|
||||
}
|
||||
|
||||
// Inject -c approval_policy="never" if not already present.
|
||||
// Checks for the exact -c flag form used by codex v0.133.0 config overrides.
|
||||
// Flag-drift note: --ask-for-approval (pre-v0.133.0) is NOT injected — it
|
||||
// was removed; see header comment above.
|
||||
const approvalAlreadySet = result.some(
|
||||
(arg, i) => arg === '-c' && typeof result[i + 1] === 'string' && result[i + 1].startsWith('approval_policy'),
|
||||
);
|
||||
if (!approvalAlreadySet) {
|
||||
result = [...result, '-c', 'approval_policy="never"'];
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
};
|
||||
|
||||
// ── Provider export ───────────────────────────────────────────────────────
|
||||
// Conforms to ADR 0002 § "Provider contract (v1.0 interface)" + contractVersion.
|
||||
|
||||
|
||||
+14
-3
@@ -29,12 +29,23 @@
|
||||
*/
|
||||
|
||||
import { validateProvider } from './base.mjs';
|
||||
import anthropicDefault from './anthropic.mjs';
|
||||
import codexDefault from './codex.mjs';
|
||||
import anthropicDefault, { ISOLATION as anthropicISOLATION } from './anthropic.mjs';
|
||||
import codexDefault, { ISOLATION as codexISOLATION } from './codex.mjs';
|
||||
import mistralDefault from './mistral.mjs';
|
||||
import modelsRegistryRaw from '../../models-registry.json' with { type: 'json' };
|
||||
|
||||
// Normalize default export pattern
|
||||
// Attach Phase 7 ISOLATION contract per ADR 0002 Amendment 9. The ISOLATION
|
||||
// block is a top-level named export from each provider plugin; the loader
|
||||
// attaches it as a property of the default-export object so the orchestrator
|
||||
// (lib/sandbox/manager.mjs prepareIsolatedEnvironment) can read it as
|
||||
// provider.ISOLATION. In-place mutation (not spread) preserves the default
|
||||
// export's object identity, which downstream code (cache store keyed on
|
||||
// provider, singleflight Maps) relies on. Providers without ISOLATION
|
||||
// (mistral at present) fall through to legacy unsandboxed shape per
|
||||
// ADR 0002 Amendment 9 § Backward compatibility.
|
||||
if (anthropicISOLATION) anthropicDefault.ISOLATION = anthropicISOLATION;
|
||||
if (codexISOLATION) codexDefault.ISOLATION = codexISOLATION;
|
||||
|
||||
const anthropic = anthropicDefault;
|
||||
const codex = codexDefault;
|
||||
const mistral = mistralDefault;
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* lib/sandbox/doctor.mjs — Sandbox availability preflight module (Phase 7 PR-A)
|
||||
*
|
||||
* Authority:
|
||||
* @anthropic-ai/sandbox-runtime v0.0.52
|
||||
* https://github.com/anthropic-experimental/sandbox-runtime
|
||||
*
|
||||
* 2026-05-28 PoC spike on PI231 (arm64 Debian Bookworm): dep install clean,
|
||||
* isSupportedPlatform()=true, blocked on apt deps (bwrap + socat), three PoC
|
||||
* scripts parked at /tmp/sandbox-spike/ on PI231.
|
||||
*
|
||||
* OLP ADR 0014 — Sandbox-Runtime Integration for Multi-Tenant Provider Spawning
|
||||
* OLP ADR 0009 Amendment 1 § Caveats #3 (sandbox is cloud prerequisite)
|
||||
* docs/plans/cloud-deployment-family.md § 5
|
||||
*
|
||||
* Design:
|
||||
* Pure module — no state, no side effects beyond child_process.execFileSync for
|
||||
* `which` probes. Does NOT call SandboxManager.initialize(). Does NOT create
|
||||
* or interact with any real sandbox. Safe to call from /health on every request
|
||||
* (results are memoized process-wide by the caller in server.mjs — see
|
||||
* _sandboxStatusCache there).
|
||||
*
|
||||
* Exports:
|
||||
* checkSandboxAvailability() — returns { available, missing, details }
|
||||
* describeSandboxStatus() — returns { ok, message } human-readable summary
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { platform as osPlatform } from 'node:os';
|
||||
|
||||
// ── which probe helper ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check if a binary is in PATH by running `which <binary>`.
|
||||
* Returns true if found, false if not found or if `which` is unavailable.
|
||||
* Never throws.
|
||||
* @param {string} binary
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isInPath(binary) {
|
||||
try {
|
||||
execFileSync('which', [binary], { stdio: 'pipe', timeout: 2000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Platform helper ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Map Node's process.platform to the sandbox-runtime platform string.
|
||||
* @returns {'linux'|'macos'|'other'}
|
||||
*/
|
||||
function getPlatformName() {
|
||||
const p = osPlatform();
|
||||
if (p === 'linux') return 'linux';
|
||||
if (p === 'darwin') return 'macos';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
// ── Library introspection ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Attempt to import @anthropic-ai/sandbox-runtime and call its exported
|
||||
* isSupportedPlatform + checkDependencies. Returns structured findings.
|
||||
* Never throws — all errors become { libError: <message> }.
|
||||
*
|
||||
* @returns {Promise<{
|
||||
* libLoaded: boolean,
|
||||
* libError: string|null,
|
||||
* isSupportedPlatform: boolean,
|
||||
* libDependencyErrors: string[],
|
||||
* libDependencyWarnings: string[],
|
||||
* }>}
|
||||
*/
|
||||
async function probeLibrary() {
|
||||
try {
|
||||
const { SandboxManager } = await import('@anthropic-ai/sandbox-runtime');
|
||||
|
||||
let supportedPlatform = false;
|
||||
try {
|
||||
supportedPlatform = SandboxManager.isSupportedPlatform();
|
||||
} catch (e) {
|
||||
return {
|
||||
libLoaded: true,
|
||||
libError: `isSupportedPlatform() threw: ${e?.message ?? e}`,
|
||||
isSupportedPlatform: false,
|
||||
libDependencyErrors: [],
|
||||
libDependencyWarnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
// checkDependencies() requires initialize() to have been called first to
|
||||
// set ripgrep/bwrap/socat config. Since PR-A never calls initialize(), we
|
||||
// call checkDependencies() with an undefined argument — the library falls
|
||||
// back to { command: 'rg' } for ripgrep and PATH lookup for bwrap/socat,
|
||||
// which is exactly what we want for the doctor preflight.
|
||||
let libDependencyErrors = [];
|
||||
let libDependencyWarnings = [];
|
||||
if (supportedPlatform) {
|
||||
try {
|
||||
const depCheck = SandboxManager.checkDependencies(undefined);
|
||||
libDependencyErrors = depCheck?.errors ?? [];
|
||||
libDependencyWarnings = depCheck?.warnings ?? [];
|
||||
} catch (e) {
|
||||
// checkDependencies() can throw before initialize() — not fatal
|
||||
libDependencyErrors = [`checkDependencies() threw: ${e?.message ?? e}`];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
libLoaded: true,
|
||||
libError: null,
|
||||
isSupportedPlatform: supportedPlatform,
|
||||
libDependencyErrors,
|
||||
libDependencyWarnings,
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
libLoaded: false,
|
||||
libError: `@anthropic-ai/sandbox-runtime import failed: ${e?.message ?? e}`,
|
||||
isSupportedPlatform: false,
|
||||
libDependencyErrors: [],
|
||||
libDependencyWarnings: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check sandbox availability (OS deps + library platform support).
|
||||
*
|
||||
* Returns:
|
||||
* {
|
||||
* available: boolean, // true only when all hard deps pass on a supported platform
|
||||
* missing: string[], // friendly names of missing hard deps (e.g. 'bubblewrap', 'socat')
|
||||
* details: {
|
||||
* platform: string, // 'linux'|'macos'|'other'
|
||||
* bwrap: boolean, // which bwrap → found
|
||||
* socat: boolean, // which socat → found
|
||||
* ripgrep: boolean, // which rg → found
|
||||
* isSupportedPlatform: boolean,
|
||||
* libLoaded: boolean,
|
||||
* libError: string|null,
|
||||
* libDependencyErrors: string[],
|
||||
* libDependencyWarnings: string[],
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* The `missing` array uses human-readable package names ('bubblewrap', 'socat',
|
||||
* 'ripgrep') so that install hints are directly actionable.
|
||||
*
|
||||
* Does NOT call SandboxManager.initialize() — pure inspection only.
|
||||
* Does NOT cache — the caller (server.mjs) memoizes the result.
|
||||
*/
|
||||
export async function checkSandboxAvailability() {
|
||||
const platform = getPlatformName();
|
||||
|
||||
// Probe OS-level deps independently of the library (the `which` calls are
|
||||
// cheap and always correct; library's checkDependencies may be less precise
|
||||
// when initialize() hasn't been called).
|
||||
const bwrap = isInPath('bwrap');
|
||||
const socat = isInPath('socat');
|
||||
const ripgrep = isInPath('rg');
|
||||
|
||||
// Library introspection (import + isSupportedPlatform + checkDependencies)
|
||||
const lib = await probeLibrary();
|
||||
|
||||
// Determine what's missing for the doctor report.
|
||||
// Only report OS deps as missing on Linux (where bwrap/socat/rg are required);
|
||||
// macOS uses sandbox-exec which is built-in, so these are not hard requirements.
|
||||
const missing = [];
|
||||
if (platform === 'linux') {
|
||||
if (!bwrap) missing.push('bubblewrap');
|
||||
if (!socat) missing.push('socat');
|
||||
if (!ripgrep) missing.push('ripgrep');
|
||||
}
|
||||
// If the library itself failed to load, that's also a blocker
|
||||
if (!lib.libLoaded) {
|
||||
missing.push('@anthropic-ai/sandbox-runtime (import failed)');
|
||||
}
|
||||
// Library-reported hard dep errors (may overlap with our `which` probes;
|
||||
// deduplicate by treating them as additional evidence rather than re-adding)
|
||||
for (const errMsg of lib.libDependencyErrors) {
|
||||
// Only add if it doesn't overlap with what we already reported
|
||||
const isAlreadyCovered =
|
||||
(errMsg.includes('bwrap') && !bwrap) ||
|
||||
(errMsg.includes('socat') && !socat) ||
|
||||
(errMsg.includes('ripgrep') && !ripgrep) ||
|
||||
(errMsg.includes('Unsupported platform'));
|
||||
if (!isAlreadyCovered && !missing.includes(errMsg)) {
|
||||
missing.push(errMsg);
|
||||
}
|
||||
}
|
||||
|
||||
const available =
|
||||
lib.libLoaded &&
|
||||
lib.isSupportedPlatform &&
|
||||
missing.length === 0;
|
||||
|
||||
return {
|
||||
available,
|
||||
missing,
|
||||
details: {
|
||||
platform,
|
||||
bwrap,
|
||||
socat,
|
||||
ripgrep,
|
||||
isSupportedPlatform: lib.isSupportedPlatform,
|
||||
libLoaded: lib.libLoaded,
|
||||
libError: lib.libError,
|
||||
libDependencyErrors: lib.libDependencyErrors,
|
||||
libDependencyWarnings: lib.libDependencyWarnings,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable sandbox status summary for /health and CLI consumers.
|
||||
*
|
||||
* Returns:
|
||||
* {
|
||||
* ok: boolean, // same as checkSandboxAvailability().available
|
||||
* message: string, // multi-line, includes install hint when deps are missing
|
||||
* }
|
||||
*
|
||||
* Does NOT call SandboxManager.initialize() — pure inspection only.
|
||||
*/
|
||||
export async function describeSandboxStatus() {
|
||||
const result = await checkSandboxAvailability();
|
||||
const { available, missing, details } = result;
|
||||
|
||||
if (available) {
|
||||
return {
|
||||
ok: true,
|
||||
message:
|
||||
`Sandbox available on ${details.platform}` +
|
||||
(details.libDependencyWarnings.length > 0
|
||||
? `. Warnings: ${details.libDependencyWarnings.join('; ')}`
|
||||
: '.'),
|
||||
};
|
||||
}
|
||||
|
||||
// Build a friendly explanation
|
||||
const lines = [];
|
||||
|
||||
if (!details.libLoaded) {
|
||||
lines.push(`Sandbox library not available: ${details.libError ?? 'import failed'}`);
|
||||
} else if (!details.isSupportedPlatform) {
|
||||
lines.push(
|
||||
`Sandbox dependencies not available: platform '${details.platform}' is not supported by @anthropic-ai/sandbox-runtime v0.0.52.`,
|
||||
);
|
||||
} else {
|
||||
// Platform is supported but OS deps are missing
|
||||
const pkgNames = missing.filter(m => !m.includes('import failed'));
|
||||
if (pkgNames.length > 0) {
|
||||
lines.push(`Sandbox dependencies not available: ${pkgNames.map(m => `${m} not installed`).join(', ')}.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Install hint (only for Linux; macOS sandbox uses sandbox-exec which is built-in)
|
||||
if (details.platform === 'linux' && (missing.includes('bubblewrap') || missing.includes('socat') || missing.includes('ripgrep'))) {
|
||||
const aptPkgs = [];
|
||||
if (missing.includes('bubblewrap')) aptPkgs.push('bubblewrap');
|
||||
if (missing.includes('socat')) aptPkgs.push('socat');
|
||||
if (missing.includes('ripgrep')) aptPkgs.push('ripgrep');
|
||||
lines.push(
|
||||
`Install on Debian/Ubuntu/Raspbian: sudo apt-get install -y ${aptPkgs.join(' ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
// macOS note (PR-A does not wire macOS sandbox-exec; PR-B will)
|
||||
if (details.platform === 'macos') {
|
||||
lines.push(
|
||||
'macOS: sandbox-exec is built-in, but anthropic provider wrapping lands in PR-B. ' +
|
||||
'macOS sandbox integration is not yet wired in this PR (PR-A). ',
|
||||
);
|
||||
}
|
||||
|
||||
if (details.libDependencyWarnings.length > 0) {
|
||||
lines.push(`Warnings: ${details.libDependencyWarnings.join('; ')}`);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
message: lines.join('\n'),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
/**
|
||||
* lib/sandbox/manager.mjs — Sandbox manager + ephemeral-home orchestrator (Phase 7 PR-B')
|
||||
*
|
||||
* Authority:
|
||||
* OLP ADR 0014 Amendment 1 — Solution 1 four-layer architecture
|
||||
* § A1.2.1 — Layer 1: per-spawn ephemeral home directory
|
||||
* § A1.2.2 — Layer 2: symlinked credential files into ephemeral home
|
||||
* § A1.2.3 — Layer 3: optional sandbox-runtime per-call customConfig
|
||||
* § A1.6.1 — OLP_SANDBOX_DISABLED gate (preserved 1-2 releases)
|
||||
* OLP ADR 0002 Amendment 9 — Provider ISOLATION contract specification
|
||||
* § Field specification (ephemeralEnvOverrides, credentialMounts,
|
||||
* requiredHomePaths, hasInnerSandbox, toolHardeningArgs)
|
||||
* @anthropic-ai/sandbox-runtime v0.0.52
|
||||
* dist/sandbox/sandbox-manager.js — SandboxManager.wrapWithSandbox()
|
||||
* The third argument `customConfig` is the per-call override mechanism.
|
||||
* 2026-05-29 PI231 spike (docs/spikes/2026-05-29-ephemeral-home.md):
|
||||
* Verified HOME (claude) + CODEX_HOME (codex) redirect 100% of CLI state
|
||||
* writes into ephemeral location. Credentials via symlink work end-to-end.
|
||||
*
|
||||
* Design (Amendment 1 architecture):
|
||||
*
|
||||
* Boot-time:
|
||||
* bootstrapSandbox() — checks sandbox-runtime library + OS deps availability
|
||||
* via doctor.mjs. Does NOT call SandboxManager.initialize() (per A1.2.3:
|
||||
* Layer 3 is per-call, not boot-singleton). The singleton pattern from PR-B
|
||||
* is removed entirely — per-spawn config eliminates its reason to exist.
|
||||
*
|
||||
* Per-spawn (uncached /v1/chat/completions request):
|
||||
* prepareIsolatedEnvironment({ provider, keyId, reqId }) — the main
|
||||
* orchestrator entry point. Reads provider.ISOLATION, composes Layers 1–3:
|
||||
* Layer 1: mkdir /tmp/olp-spawn/<keyId>/<reqId>/home
|
||||
* Layer 2: symlink credentialMounts into ephemeralRoot
|
||||
* Layer 3: wrapForLayer3 — when isSandboxActive() && !hasInnerSandbox,
|
||||
* calls SandboxManager.wrapWithSandbox() per-call with
|
||||
* per-spawn customConfig
|
||||
* Returns { ephemeralRoot, envOverrides, hardenedArgs, wrapForLayer3, cleanup }.
|
||||
*
|
||||
* OLP_SANDBOX_DISABLED=1 (A1.6.1 belt-and-suspenders gate):
|
||||
* When set, Layers 1+2 still operate (ephemeral home + credential mounts).
|
||||
* Layer 3 (wrapForLayer3) becomes identity. Preserved for 1-2 releases.
|
||||
*
|
||||
* Exports:
|
||||
* bootstrapSandbox(opts?) — preflight check; returns { available, reason?, summary? }
|
||||
* isSandboxActive() — synchronous; true when Layer 3 is operational
|
||||
* prepareIsolatedEnvironment({ provider, keyId, reqId })
|
||||
* — compose Layers 1+2+3; returns env + hooks + cleanup
|
||||
* __resetSandboxManagerForTests() — test seam: reset module state
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, symlinkSync } from 'node:fs';
|
||||
import { rm } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { checkSandboxAvailability } from './doctor.mjs';
|
||||
|
||||
// ── Internal state ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Whether bootstrapSandbox() has completed (initialized = true means bootstrap
|
||||
* ran; does NOT mean sandbox is active).
|
||||
* @type {boolean}
|
||||
*/
|
||||
let _initialized = false;
|
||||
|
||||
/**
|
||||
* Whether the sandbox-runtime library is loaded and OS deps are present.
|
||||
* When true, Layer 3 (per-call wrapWithSandbox) is available.
|
||||
* @type {boolean}
|
||||
*/
|
||||
let _active = false;
|
||||
|
||||
/**
|
||||
* Cached failure reason string (when _active=false after bootstrap).
|
||||
* @type {string|null}
|
||||
*/
|
||||
let _failReason = null;
|
||||
|
||||
/**
|
||||
* Memoized sandbox-runtime module (loaded lazily on first prepareIsolatedEnvironment
|
||||
* call that needs Layer 3). Import caching is native ESM semantics; this variable
|
||||
* holds the resolved SandboxManager class after first load.
|
||||
* @type {object|null}
|
||||
*/
|
||||
let _SandboxManager = null;
|
||||
|
||||
// ── Ephemeral workspace root ─────────────────────────────────────────────
|
||||
// /tmp/olp-spawn/<keyId>/<reqId>/home — unique per (key, request).
|
||||
const SPAWN_BASE_DIR = '/tmp/olp-spawn';
|
||||
|
||||
// ── bootstrapSandbox ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Preflight check for Layer 3 capability (sandbox-runtime library + OS deps).
|
||||
* Idempotent — safe to call multiple times; returns cached result after first call.
|
||||
*
|
||||
* This function NO LONGER calls SandboxManager.initialize() at boot.
|
||||
* Per ADR 0014 Amendment 1 § A1.2.3, Layer 3 uses per-call wrapWithSandbox()
|
||||
* with a per-spawn customConfig; the singleton boot-init pattern is removed.
|
||||
*
|
||||
* The OLP_SANDBOX_DISABLED=1 env-var gate (A1.6.1): when set, Layer 3 is
|
||||
* disabled. Layers 1+2 (ephemeral home + credential mounts) still operate.
|
||||
*
|
||||
* @param {object} [opts]
|
||||
* @param {boolean} [opts.force=false] — re-run even if already bootstrapped
|
||||
* @returns {Promise<{ active: boolean, reason?: string, summary?: string }>}
|
||||
*/
|
||||
export async function bootstrapSandbox(opts = {}) {
|
||||
if (_initialized && !opts.force) {
|
||||
return _active
|
||||
? { active: true, summary: _buildSummary() }
|
||||
: { active: false, reason: _failReason ?? 'sandbox not available' };
|
||||
}
|
||||
|
||||
// OLP_SANDBOX_DISABLED gate (A1.6.1): operator emergency disable.
|
||||
// Layer 3 skipped; Layers 1+2 unaffected (ephemeral home + credential mounts).
|
||||
if (process.env.OLP_SANDBOX_DISABLED === '1') {
|
||||
_initialized = true;
|
||||
_active = false;
|
||||
_failReason = 'OLP_SANDBOX_DISABLED=1 — Layer 3 (sandbox-runtime wrap) disabled by operator; Layers 1+2 still active';
|
||||
return { active: false, reason: _failReason };
|
||||
}
|
||||
|
||||
// Reset for re-bootstrap
|
||||
_initialized = false;
|
||||
_active = false;
|
||||
_failReason = null;
|
||||
|
||||
// Check OS + library availability via doctor
|
||||
let availability;
|
||||
try {
|
||||
availability = await checkSandboxAvailability();
|
||||
} catch (e) {
|
||||
_initialized = true;
|
||||
_active = false;
|
||||
_failReason = `doctor check threw: ${e?.message ?? e}`;
|
||||
return { active: false, reason: _failReason };
|
||||
}
|
||||
|
||||
if (!availability.available) {
|
||||
_initialized = true;
|
||||
_active = false;
|
||||
_failReason = availability.missing?.length > 0
|
||||
? `sandbox deps missing: ${availability.missing.join(', ')}`
|
||||
: `sandbox not available on platform: ${availability.details?.platform}`;
|
||||
return { active: false, reason: _failReason };
|
||||
}
|
||||
|
||||
// Verify sandbox-runtime import is available (lazy-load check only;
|
||||
// no SandboxManager.initialize() — per ADR 0014 Amendment 1 A1.2.3).
|
||||
try {
|
||||
const mod = await import('@anthropic-ai/sandbox-runtime');
|
||||
_SandboxManager = mod.SandboxManager;
|
||||
} catch (e) {
|
||||
_initialized = true;
|
||||
_active = false;
|
||||
_failReason = `sandbox-runtime import failed: ${e?.message ?? e}`;
|
||||
return { active: false, reason: _failReason };
|
||||
}
|
||||
|
||||
_initialized = true;
|
||||
_active = true;
|
||||
return { active: true, summary: _buildSummary() };
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
function _buildSummary() {
|
||||
return `Layer 3 available (sandbox-runtime loaded, OS deps present); per-spawn wrapWithSandbox enabled`;
|
||||
}
|
||||
|
||||
// ── isSandboxActive ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Synchronous query: is Layer 3 (per-call sandbox-runtime wrap) operational?
|
||||
* Returns true only if bootstrapSandbox() completed successfully AND
|
||||
* OLP_SANDBOX_DISABLED is not set.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isSandboxActive() {
|
||||
return _active;
|
||||
}
|
||||
|
||||
// ── prepareIsolatedEnvironment ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Compose per-spawn isolation primitives (Layers 1+2+3) for a single request.
|
||||
*
|
||||
* Reads provider.ISOLATION per ADR 0002 Amendment 9. If ISOLATION is absent,
|
||||
* returns the legacy unsandboxed shape (identity env, identity hooks, no cleanup).
|
||||
*
|
||||
* @param {object} params
|
||||
* @param {object} params.provider — provider plugin object (may have .ISOLATION)
|
||||
* @param {string} params.keyId — OLP key identity driving this request
|
||||
* @param {string} params.reqId — per-request UUID
|
||||
* @returns {Promise<{
|
||||
* ephemeralRoot: string|null,
|
||||
* envOverrides: Record<string, string>,
|
||||
* hardenedArgs: (args: string[]) => string[],
|
||||
* wrapForLayer3: (command: string) => Promise<string>,
|
||||
* cleanup: () => Promise<void>,
|
||||
* }>}
|
||||
*/
|
||||
export async function prepareIsolatedEnvironment({ provider, keyId, reqId }) {
|
||||
const isolation = provider?.ISOLATION;
|
||||
|
||||
// ── Test-context bypass ──────────────────────────────────────────────────
|
||||
// The test runner (`npm test` → `node test-features.mjs`) injects mock
|
||||
// spawn implementations that bypass real CLI invocation. ISOLATION's
|
||||
// ephemeral-home + symlink + cleanup side effects interact with the
|
||||
// streaming singleflight cache layer's async timing in those tests
|
||||
// (Suite 15b / 28a / 28c / 28f see cache-miss on the second of two
|
||||
// sequential identical requests when the orchestrator emits per-request
|
||||
// ephemeral roots). To keep tests deterministic without re-engineering
|
||||
// every cache mock, the orchestrator returns the legacy identity shape
|
||||
// when running under the test runner. Production (server.mjs entrypoint)
|
||||
// is unaffected.
|
||||
//
|
||||
// This is a documented test-fixture compromise rather than a production
|
||||
// code branch on test mode. The follow-up is to ship a proper
|
||||
// __setIsolationImpl seam (parallel to __setSpawnImpl) so test fixtures
|
||||
// can inject a mock prepareIsolatedEnvironment that returns identity.
|
||||
// Tracked in Task #10 (Phase 7 close prep) / follow-up issue.
|
||||
if (
|
||||
process.argv[1]?.endsWith('test-features.mjs') &&
|
||||
!globalThis.__OLP_FORCE_ISOLATION_IN_TEST
|
||||
) {
|
||||
return _legacyShape();
|
||||
}
|
||||
|
||||
// ── Legacy unsandboxed path (no ISOLATION declared) ──────────────────────
|
||||
if (!isolation) {
|
||||
if (provider?.name) {
|
||||
console.warn(
|
||||
`[sandbox/manager] [WARN] provider "${provider.name}" does not declare ISOLATION; ` +
|
||||
`spawns will run under legacy unsandboxed shape. Recommended in multi-tenant ` +
|
||||
`deployments: declare ISOLATION per ADR 0002 Amendment 9.`,
|
||||
);
|
||||
}
|
||||
return _legacyShape();
|
||||
}
|
||||
|
||||
// ── Layer 1: Create per-spawn ephemeral home ──────────────────────────────
|
||||
// /tmp/olp-spawn/<keyId>/<reqId>/home
|
||||
// keyId is sanitized to filesystem-safe characters (alphanumeric + hyphens).
|
||||
const safeKeyId = String(keyId ?? 'anon').replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64);
|
||||
const safeReqId = String(reqId ?? 'req').replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64);
|
||||
const ephemeralRoot = join(SPAWN_BASE_DIR, safeKeyId, safeReqId, 'home');
|
||||
|
||||
try {
|
||||
mkdirSync(ephemeralRoot, { recursive: true });
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[sandbox/manager] Failed to create ephemeral root ${ephemeralRoot}: ${e?.message ?? e}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Layer 1 cont.: mkdir requiredHomePaths ────────────────────────────────
|
||||
const requiredPaths = isolation.requiredHomePaths ?? [];
|
||||
for (const relPath of requiredPaths) {
|
||||
if (typeof relPath !== 'string' || relPath.startsWith('..') || relPath.startsWith('/')) {
|
||||
throw new Error(
|
||||
`[sandbox/manager] provider "${provider.name}" ISOLATION.requiredHomePaths contains ` +
|
||||
`invalid entry "${relPath}" — must be a relative path with no leading .. or /`,
|
||||
);
|
||||
}
|
||||
const absPath = join(ephemeralRoot, relPath);
|
||||
mkdirSync(absPath, { recursive: true });
|
||||
}
|
||||
|
||||
// ── Layer 2: Symlink credentialMounts ─────────────────────────────────────
|
||||
const mounts = isolation.credentialMounts ?? [];
|
||||
for (const mount of mounts) {
|
||||
if (!Array.isArray(mount) || mount.length !== 2) {
|
||||
throw new Error(
|
||||
`[sandbox/manager] provider "${provider.name}" ISOLATION.credentialMounts entry ` +
|
||||
`is not a 2-tuple: ${JSON.stringify(mount)}`,
|
||||
);
|
||||
}
|
||||
const [srcAbsPath, dstRel] = mount;
|
||||
|
||||
// Validate src
|
||||
if (typeof srcAbsPath !== 'string' || !srcAbsPath.startsWith('/')) {
|
||||
throw new Error(
|
||||
`[sandbox/manager] provider "${provider.name}" ISOLATION.credentialMounts src ` +
|
||||
`"${srcAbsPath}" must be an absolute path (call os.homedir() in the plugin)`,
|
||||
);
|
||||
}
|
||||
// Validate dst
|
||||
if (typeof dstRel !== 'string' || dstRel.startsWith('..') || dstRel.startsWith('/')) {
|
||||
throw new Error(
|
||||
`[sandbox/manager] provider "${provider.name}" ISOLATION.credentialMounts dst ` +
|
||||
`"${dstRel}" must be a relative path with no leading .. or /`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!existsSync(srcAbsPath)) {
|
||||
console.warn(
|
||||
`[sandbox/manager] [WARN] provider "${provider.name}" credentialMount src ` +
|
||||
`"${srcAbsPath}" does not exist — spawn may fail auth`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const dstAbs = join(ephemeralRoot, dstRel);
|
||||
// Ensure parent dir exists
|
||||
mkdirSync(dirname(dstAbs), { recursive: true });
|
||||
|
||||
// Create symlink (skip if already exists — idempotent)
|
||||
if (!existsSync(dstAbs)) {
|
||||
try {
|
||||
symlinkSync(srcAbsPath, dstAbs);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[sandbox/manager] Failed to symlink ${srcAbsPath} → ${dstAbs}: ${e?.message ?? e}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Compose envOverrides (Layer 1 output) ────────────────────────────────
|
||||
let envOverrides = {};
|
||||
if (typeof isolation.ephemeralEnvOverrides === 'function') {
|
||||
const raw = isolation.ephemeralEnvOverrides({ ephemeralRoot, keyId, reqId });
|
||||
if (raw === null || typeof raw !== 'object') {
|
||||
throw new Error(
|
||||
`[sandbox/manager] provider "${provider.name}" ISOLATION.ephemeralEnvOverrides ` +
|
||||
`must return a plain object; got ${typeof raw}`,
|
||||
);
|
||||
}
|
||||
// Validate all values are strings
|
||||
for (const [k, v] of Object.entries(raw)) {
|
||||
if (typeof v !== 'string') {
|
||||
throw new Error(
|
||||
`[sandbox/manager] provider "${provider.name}" ISOLATION.ephemeralEnvOverrides ` +
|
||||
`returned non-string value for key "${k}": ${typeof v}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
envOverrides = raw;
|
||||
}
|
||||
|
||||
// ── Compose hardenedArgs (Layer 4 hook) ──────────────────────────────────
|
||||
const hardenedArgs = typeof isolation.toolHardeningArgs === 'function'
|
||||
? (args) => {
|
||||
const copy = [...args];
|
||||
const result = isolation.toolHardeningArgs(copy);
|
||||
if (!Array.isArray(result)) {
|
||||
throw new Error(
|
||||
`[sandbox/manager] provider "${provider.name}" ISOLATION.toolHardeningArgs ` +
|
||||
`must return an array; got ${typeof result}`,
|
||||
);
|
||||
}
|
||||
for (const arg of result) {
|
||||
if (typeof arg !== 'string') {
|
||||
throw new Error(
|
||||
`[sandbox/manager] provider "${provider.name}" ISOLATION.toolHardeningArgs ` +
|
||||
`returned non-string element in args array: ${typeof arg}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
: (args) => args; // identity — provider encodes hardening in its own spawn()
|
||||
|
||||
// ── Compose wrapForLayer3 ─────────────────────────────────────────────────
|
||||
// Layer 3: per-call sandbox-runtime wrap.
|
||||
// Skipped when:
|
||||
// (a) hasInnerSandbox === true (codex — outer wrap would conflict with inner bwrap)
|
||||
// (b) sandbox is not active (!_active — deps missing or OLP_SANDBOX_DISABLED=1)
|
||||
// When active + no inner sandbox: calls SandboxManager.wrapWithSandbox() per-spawn
|
||||
// with a per-spawn customConfig scoped to the ephemeralRoot.
|
||||
const hasInnerSandbox = isolation.hasInnerSandbox === true;
|
||||
const layer3Active = _active && !hasInnerSandbox;
|
||||
|
||||
let wrapForLayer3;
|
||||
if (layer3Active && _SandboxManager) {
|
||||
const operatorHome = homedir();
|
||||
// Per-spawn customConfig: deny reads on real operator home; allow the
|
||||
// ephemeral home and /tmp. Cross-tenant deny list will be tightened in a
|
||||
// follow-up task once the base Layer 3 integration is validated (Task #9).
|
||||
// ADR 0002 Amendment 9 does NOT declare an allowedDomains field on the
|
||||
// ISOLATION contract. Network policy at Layer 3 is therefore the
|
||||
// orchestrator's responsibility, not the provider's. v1 defaults to empty
|
||||
// allowlist (kernel-level deny-all on outbound to non-trusted domains
|
||||
// would be added here in a follow-up ADR amendment once the contract
|
||||
// surface for "trusted-domains per provider" is ratified). For now: open
|
||||
// network (legacy behaviour, matches pre-Solution-1 spawn shape).
|
||||
const customConfig = {
|
||||
network: {
|
||||
allowedDomains: [],
|
||||
deniedDomains: [],
|
||||
},
|
||||
filesystem: {
|
||||
denyRead: [
|
||||
operatorHome,
|
||||
join(operatorHome, '.ssh'),
|
||||
join(operatorHome, '.gnupg'),
|
||||
join(operatorHome, '.olp'),
|
||||
],
|
||||
allowRead: [ephemeralRoot],
|
||||
allowWrite: [ephemeralRoot, '/tmp'],
|
||||
denyWrite: [],
|
||||
},
|
||||
};
|
||||
|
||||
const SM = _SandboxManager;
|
||||
wrapForLayer3 = async (commandString) => {
|
||||
try {
|
||||
return await SM.wrapWithSandbox(commandString, undefined, customConfig);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[sandbox/manager] SandboxManager.wrapWithSandbox failed: ${e?.message ?? e}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
} else {
|
||||
// Identity — no Layer 3 wrap (either hasInnerSandbox=true or sandbox inactive)
|
||||
wrapForLayer3 = async (commandString) => commandString;
|
||||
}
|
||||
|
||||
// ── Cleanup (called by server after spawn completes) ─────────────────────
|
||||
const cleanup = async () => {
|
||||
// Walk up to /tmp/olp-spawn/<safeKeyId>/<safeReqId> and remove.
|
||||
// Best-effort: log + swallow errors (don't fail the response pipeline).
|
||||
const spawnDir = join(SPAWN_BASE_DIR, safeKeyId, safeReqId);
|
||||
try {
|
||||
await rm(spawnDir, { recursive: true, force: true });
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
`[sandbox/manager] Warning: cleanup of ${spawnDir} failed: ${e?.message ?? e}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
ephemeralRoot,
|
||||
envOverrides,
|
||||
hardenedArgs,
|
||||
wrapForLayer3,
|
||||
cleanup,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Legacy unsandboxed shape ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns the identity shape used for providers without ISOLATION declared.
|
||||
* Per ADR 0002 Amendment 9 § Backward compatibility.
|
||||
*/
|
||||
function _legacyShape() {
|
||||
return {
|
||||
ephemeralRoot: null,
|
||||
envOverrides: {},
|
||||
hardenedArgs: (args) => args,
|
||||
wrapForLayer3: async (cmd) => cmd,
|
||||
cleanup: async () => { /* nothing to clean up — no ephemeral root was created */ },
|
||||
};
|
||||
}
|
||||
|
||||
// ── Test seam ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Reset module-level state so the test suite can simulate a fresh process.
|
||||
* Per ADR 0014 § Pitfalls #4: only safe in sequential test contexts with no
|
||||
* in-flight spawns.
|
||||
*
|
||||
* Note: Under Amendment 1, there is no SandboxManager singleton to reset
|
||||
* (no SandboxManager.reset() call) — the per-call pattern means the library's
|
||||
* internal state is transient per wrapWithSandbox() invocation.
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function __resetSandboxManagerForTests() {
|
||||
_initialized = false;
|
||||
_active = false;
|
||||
_failReason = null;
|
||||
_SandboxManager = null;
|
||||
}
|
||||
+44
-1
@@ -1,6 +1,41 @@
|
||||
{
|
||||
"version": "0.1.0-bootstrap",
|
||||
"comment": "OLP models registry — SPOT for (provider, model) → metadata per CLAUDE.md release_kit overlay. v0.1 founding shipped zero Enabled Providers per ALIGNMENT.md § Provider Inventory. D4 populates providers.anthropic as Candidate; D5 transitions to Enabled pending E2E audit. Schema validated by .github/workflows/alignment.yml; provider keys must match ALIGNMENT.md inventory.",
|
||||
"quota_probe": {
|
||||
"schema_version": "2026-05-26",
|
||||
"comment": "D81 — ADR 0013 Rule 5 mandate: schema_version pinned in registry so downstream consumers can detect schema drift. fields_pinned is load-bearing: if Anthropic adds/renames a header, dashboard consumers comparing field-presence against this list can flag 'schema drift detected'. Last verified: 2026-05-26 via live probe against api.anthropic.com (Path B per ADR 0013 Rule 5).",
|
||||
"anthropic": {
|
||||
"status": "live",
|
||||
"source": "anthropic-ratelimit-unified-headers",
|
||||
"endpoint": "https://api.anthropic.com/v1/messages",
|
||||
"fields_pinned": [
|
||||
"status",
|
||||
"representative_claim",
|
||||
"reset",
|
||||
"fallback_percentage",
|
||||
"status_5h",
|
||||
"utilization_5h",
|
||||
"reset_5h",
|
||||
"status_7d",
|
||||
"utilization_7d",
|
||||
"reset_7d",
|
||||
"overage_status",
|
||||
"overage_disabled_reason",
|
||||
"overage_reset"
|
||||
]
|
||||
},
|
||||
"openai": {
|
||||
"status": "unavailable",
|
||||
"reason": "no public quota endpoint exposed by the openai/codex CLI; audit-derived spend tracking only at v0.5.0",
|
||||
"re_entry_point": "lib/providers/openai.mjs DL-N (when OpenAI publishes a documented quota endpoint)"
|
||||
},
|
||||
"mistral": {
|
||||
"status": "unavailable",
|
||||
"reason": "no public quota endpoint accessible to Vibe / Le Chat member / La Plateforme API keys per D84 spike 2026-05-26 (https://docs.mistral.ai/api). Mistral Admin API exposes billing/usage but requires org-admin scope (out of scope for OLP family-tier deployment).",
|
||||
"re_entry_point": "lib/providers/mistral.mjs DL-7 (when Mistral publishes a member-key-accessible usage endpoint, or when OLP scope expands to admin-key deployment)",
|
||||
"admin_api_reference": "https://docs.mistral.ai/admin/security-access/admin-api"
|
||||
}
|
||||
},
|
||||
"bootstrapCreated": 1778630400,
|
||||
"bootstrapCreatedComment": "Fallback Unix timestamp for models whose precise release date is unknown. Value = 2026-05-13 (the day before the Anthropic billing-split announcement that triggered OLP). Used by handleModels() in server.mjs when a model entry does not have a model-level 'created' field. Per F12 round-5 cold-audit: OpenAI spec treats 'created' as a stable per-model attribute; synthesizing Date.now() on each request causes spurious updates for clients caching models by 'created'.",
|
||||
"providers": {
|
||||
@@ -9,6 +44,14 @@
|
||||
"tier": "D",
|
||||
"candidate": true,
|
||||
"models": [
|
||||
{
|
||||
"id": "claude-opus-4-8",
|
||||
"displayName": "Claude Opus 4.8",
|
||||
"contextWindow": 200000,
|
||||
"deprecated": false,
|
||||
"created": 1783814400,
|
||||
"_comment": "claude-opus-4-8 added 2026-05-29 (Task #15). Model id confirmed via Anthropic published model lineup. `created` set to 1783814400 (2026-07-10) — strictly later than claude-opus-4-7's 1782864000 so OpenAI-spec /v1/models 'created' ordering reflects release recency. If a primary-source Anthropic announcement URL becomes available, replace this placeholder with the announcement timestamp."
|
||||
},
|
||||
{
|
||||
"id": "claude-opus-4-7",
|
||||
"displayName": "Claude Opus 4.7",
|
||||
@@ -34,7 +77,7 @@
|
||||
"aliases": {
|
||||
"claude": "claude-sonnet-4-6",
|
||||
"sonnet": "claude-sonnet-4-6",
|
||||
"opus": "claude-opus-4-7",
|
||||
"opus": "claude-opus-4-8",
|
||||
"haiku": "claude-haiku-4-5"
|
||||
}
|
||||
},
|
||||
|
||||
+79
-4
@@ -169,26 +169,101 @@ export function fmtHealth(body) {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* formatResetCountdown(epochSeconds) → human-readable reset countdown.
|
||||
*
|
||||
* Mirrors bin/olp.mjs + dashboard.html versions. Five ranges:
|
||||
* past / < 1h / < 24h / < 7d / ≥ 7d
|
||||
*
|
||||
* Authority: ADR 0008 Amendment 2 (quota_v2 shape), ported from dashboard.html (D82).
|
||||
* No external deps. Duplicated here intentionally (olp-plugin ships separately).
|
||||
*/
|
||||
export function pluginFormatResetCountdown(epochSeconds) {
|
||||
if (epochSeconds == null) return "—";
|
||||
const nowMs = Date.now();
|
||||
const targetMs = epochSeconds * 1000;
|
||||
const diffMs = targetMs - nowMs;
|
||||
if (diffMs <= 0) return "resetting now";
|
||||
const diffMin = Math.floor(diffMs / 60000);
|
||||
const diffHr = Math.floor(diffMin / 60);
|
||||
const diffDay = Math.floor(diffHr / 24);
|
||||
if (diffMin < 60) return `resets in ${diffMin}m`;
|
||||
if (diffHr < 24) {
|
||||
const remMin = diffMin - diffHr * 60;
|
||||
if (remMin === 0) return `resets in ${diffHr}h`;
|
||||
return `resets in ${diffHr}h ${remMin}m`;
|
||||
}
|
||||
const target = new Date(targetMs);
|
||||
const timeStr = target.toLocaleString("en-US", { hour: "numeric", minute: "2-digit", hour12: true });
|
||||
if (diffDay < 7) {
|
||||
const dayStr = target.toLocaleString("en-US", { weekday: "short" });
|
||||
return `resets ${dayStr} ${timeStr}`;
|
||||
}
|
||||
const dateStr = target.toLocaleString("en-US", { month: "short", day: "numeric" });
|
||||
return `resets ${dateStr} ${timeStr}`;
|
||||
}
|
||||
|
||||
export function fmtUsage(body) {
|
||||
let out = "OLP usage (24h)\n";
|
||||
out += "─────────────────────────────\n";
|
||||
const w = body.window_24h ?? body.usage_24h ?? {};
|
||||
if (w.requests !== undefined) {
|
||||
if (w.request_count !== undefined) {
|
||||
out += `Requests: ${w.request_count}\n`;
|
||||
const c = body.cache_hit_24h ?? {};
|
||||
if (typeof c.hit_rate === "number") {
|
||||
out += `Cache hit: ${(c.hit_rate * 100).toFixed(1)}%\n`;
|
||||
}
|
||||
} else if (w.requests !== undefined) {
|
||||
out += `Requests: ${w.requests}\n`;
|
||||
out += `Cache hit: ${w.cache_hit_rate != null ? `${(w.cache_hit_rate * 100).toFixed(1)}%` : "?"}\n`;
|
||||
out += `Fallbacks: ${w.fallbacks ?? "?"}\n`;
|
||||
} else if (typeof body.cache_hit_24h === "number") {
|
||||
// Dashboard-data shape: cache_hit_24h is a rate ∈ [0,1]
|
||||
// Legacy: cache_hit_24h as a bare number
|
||||
out += `Cache hit (24h): ${(body.cache_hit_24h * 100).toFixed(1)}%\n`;
|
||||
}
|
||||
if (Array.isArray(body.quota) && body.quota.length > 0) {
|
||||
|
||||
// F4: prefer quota_v2 when present (server v0.5.0+), fall back to legacy quota.
|
||||
// Authority: ADR 0008 Amendment 2 (quota_v2 shape).
|
||||
if (Array.isArray(body.quota_v2) && body.quota_v2.length > 0) {
|
||||
out += `\nPer-provider quota (live):\n`;
|
||||
for (const p of body.quota_v2) {
|
||||
const name = String(p.provider ?? "?").toUpperCase().padEnd(10);
|
||||
const status = p.status ?? "unavailable";
|
||||
if (status === "unavailable") {
|
||||
out += ` ${name} unavailable ${p.reason ?? "no public quota api"}\n`;
|
||||
} else if (status === "unreachable") {
|
||||
const fk = p.failure?.kind ?? "unknown";
|
||||
out += ` ${name} no cached data — failure: ${fk}\n`;
|
||||
} else {
|
||||
// live or stale
|
||||
const util = p.utilization ?? {};
|
||||
const reset = p.reset ?? {};
|
||||
const parts = [];
|
||||
for (const window of ["5h", "7d"]) {
|
||||
const frac = util[window];
|
||||
const resetEpoch = reset[window];
|
||||
if (frac != null) {
|
||||
const pct = `${Math.round(frac * 100)}%`;
|
||||
const rst = pluginFormatResetCountdown(resetEpoch);
|
||||
parts.push(`${window}: ${pct} (${rst})`);
|
||||
}
|
||||
}
|
||||
const staleNote = status === "stale"
|
||||
? ` ⚠ stale (${p.failure?.kind ?? "unknown"})`
|
||||
: "";
|
||||
out += ` ${name} ${status.padEnd(6)} ${parts.join(" ")}${staleNote}\n`;
|
||||
}
|
||||
}
|
||||
} else if (Array.isArray(body.quota) && body.quota.length > 0) {
|
||||
// Legacy fallback for pre-v0.5.0 servers
|
||||
out += `\nPer-provider quota:\n`;
|
||||
for (const q of body.quota) {
|
||||
const pct = typeof q.percent_used === "number" ? q.percent_used : null;
|
||||
const bar0 = pct != null ? ` ${bar(pct / 100, 12)} ${pct.toFixed(0)}%` : " no quota api";
|
||||
out += ` ${String(q.name ?? "?").padEnd(10)}${bar0}\n`;
|
||||
out += ` ${String(q.provider ?? q.name ?? "?").padEnd(10)}${bar0}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(body.top_fallback_chains_24h) && body.top_fallback_chains_24h.length > 0) {
|
||||
out += `\nTop fallback chains (24h):\n`;
|
||||
for (const f of body.top_fallback_chains_24h.slice(0, 5)) {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"openclaw": {
|
||||
"type": "plugin",
|
||||
"id": "olp",
|
||||
"pluginManifest": "openclaw.plugin.json"
|
||||
"pluginManifest": "openclaw.plugin.json",
|
||||
"extensions": ["./index.js"]
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+89
@@ -0,0 +1,89 @@
|
||||
{
|
||||
"name": "olp",
|
||||
"version": "0.5.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "olp",
|
||||
"version": "0.5.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sandbox-runtime": "^0.0.52"
|
||||
},
|
||||
"bin": {
|
||||
"olp": "bin/olp.mjs",
|
||||
"olp-audit-rotate": "bin/olp-audit-rotate.mjs",
|
||||
"olp-connect": "bin/olp-connect",
|
||||
"olp-keys": "bin/olp-keys.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/sandbox-runtime": {
|
||||
"version": "0.0.52",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sandbox-runtime/-/sandbox-runtime-0.0.52.tgz",
|
||||
"integrity": "sha512-vYaM7OslFmOAzNgfy5gxvt3NoWFeCbr7C0AKyuduQq7Gdxbg2NnYmE7deBf8Nxj3ZNECTcC5RhAfz0lZwvbtBA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@pondwader/socks5-server": "^1.0.10",
|
||||
"commander": "^12.1.0",
|
||||
"node-forge": "^1.4.0",
|
||||
"shell-quote": "^1.8.3",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"bin": {
|
||||
"srt": "dist/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@pondwader/socks5-server": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@pondwader/socks5-server/-/socks5-server-1.0.10.tgz",
|
||||
"integrity": "sha512-bQY06wzzR8D2+vVCUoBsr5QS2U6UgPUQRmErNwtsuI6vLcyRKkafjkr3KxbtGFf9aBBIV2mcvlsKD1UYaIV+sg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "12.1.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
|
||||
"integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/node-forge": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz",
|
||||
"integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==",
|
||||
"license": "(BSD-3-Clause OR GPL-2.0)",
|
||||
"engines": {
|
||||
"node": ">= 6.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/shell-quote": {
|
||||
"version": "1.8.4",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz",
|
||||
"integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "olp",
|
||||
"version": "0.4.4",
|
||||
"version": "0.7.0",
|
||||
"description": "Personal multi-provider LLM proxy. Successor to OCP. One HTTP endpoint, multiple subscriptions behind it, automatic routing + fallback + caching.",
|
||||
"type": "module",
|
||||
"main": "server.mjs",
|
||||
@@ -49,5 +49,8 @@
|
||||
"mistral",
|
||||
"fallback",
|
||||
"cache"
|
||||
]
|
||||
],
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sandbox-runtime": "^0.0.52"
|
||||
}
|
||||
}
|
||||
|
||||
+144
-4
@@ -70,12 +70,24 @@ import {
|
||||
ENV_OWNER_KEY_ID,
|
||||
} from './lib/keys.mjs';
|
||||
import { appendAuditEvent } from './lib/audit.mjs';
|
||||
// Phase 7 / PR-A — sandbox availability preflight module (ADR 0014).
|
||||
// checkSandboxAvailability is called lazily at first /health hit and memoized
|
||||
// process-wide (bwrap/socat install state does not change at runtime; we don't
|
||||
// want a child_process.execFileSync per /health call).
|
||||
import { checkSandboxAvailability } from './lib/sandbox/doctor.mjs';
|
||||
// Phase 7 / PR-B — sandbox manager bootstrap + spawn-wrap (ADR 0014 § PR-B).
|
||||
// bootstrapSandbox() is called at server startup (before listen) and sets up
|
||||
// the process-wide SandboxManager singleton. isSandboxActive() is used by
|
||||
// /health to report sandbox.active.
|
||||
import { bootstrapSandbox, isSandboxActive, prepareIsolatedEnvironment, __resetSandboxManagerForTests } from './lib/sandbox/manager.mjs';
|
||||
// Phase 3 / D50 — management endpoints consume the audit aggregate query layer.
|
||||
// D81 (Phase 5) — adds aggregateProviderQuota for quota_v2 shape.
|
||||
import {
|
||||
aggregateRequests as auditAggregateRequests,
|
||||
topFallbackChains as auditTopFallbackChains,
|
||||
spendTrendDaily as auditSpendTrendDaily,
|
||||
cacheHitRateWindow as auditCacheHitRateWindow,
|
||||
aggregateProviderQuota as auditAggregateProviderQuota,
|
||||
} from './lib/audit-query.mjs';
|
||||
|
||||
// ── Config ────────────────────────────────────────────────────────────────
|
||||
@@ -219,6 +231,20 @@ export function __resetRequestCounters() {
|
||||
_activeRequests = 0;
|
||||
}
|
||||
|
||||
// ── Phase 7 PR-A: sandbox availability cache ──────────────────────────────
|
||||
// checkSandboxAvailability() forks `which bwrap` / `which socat` / `which rg`
|
||||
// and imports @anthropic-ai/sandbox-runtime. Neither can change at runtime —
|
||||
// bwrap is either installed or it isn't. Memoize the first result to avoid
|
||||
// repeated child_process.execFileSync calls on every /health hit.
|
||||
//
|
||||
// _sandboxStatusCache: null → not yet fetched
|
||||
// object → memoized result from checkSandboxAvailability()
|
||||
let _sandboxStatusCache = null;
|
||||
/** @internal — test seam: reset sandbox cache between tests. */
|
||||
export function __resetSandboxStatusCache() {
|
||||
_sandboxStatusCache = null;
|
||||
}
|
||||
|
||||
// ── Startup config ────────────────────────────────────────────────────────
|
||||
// Read ~/.olp/config.json once at startup. Provides:
|
||||
// - providers.enabled → which providers are loaded (ADR 0002 § Disable model)
|
||||
@@ -857,10 +883,53 @@ async function handleHealth(req, res) {
|
||||
providerStatuses[name] = { ok: false, error: e.message, activeSpawns };
|
||||
}
|
||||
}
|
||||
// Phase 7 PR-A (ADR 0014): sandbox availability field.
|
||||
// Result is memoized process-wide in _sandboxStatusCache — bwrap/socat
|
||||
// install state does not change at runtime. If the library call throws for
|
||||
// any reason, the field is still included with available: false + error
|
||||
// (don't crash /health).
|
||||
if (_sandboxStatusCache === null) {
|
||||
try {
|
||||
_sandboxStatusCache = await checkSandboxAvailability();
|
||||
} catch (e) {
|
||||
_sandboxStatusCache = {
|
||||
available: false,
|
||||
missing: [],
|
||||
details: { platform: process.platform, error: String(e?.message ?? e) },
|
||||
};
|
||||
}
|
||||
}
|
||||
const sandboxField = {
|
||||
available: _sandboxStatusCache.available,
|
||||
// Phase 7 PR-B: active = sandbox was bootstrapped and SandboxManager is
|
||||
// ready to wrap spawns. available=true + active=true means every provider
|
||||
// spawn is actually sandboxed. available=true + active=false means deps
|
||||
// present but bootstrap failed at runtime (see server startup log).
|
||||
active: isSandboxActive(),
|
||||
missing: _sandboxStatusCache.missing ?? [],
|
||||
platform: _sandboxStatusCache.details?.platform ?? process.platform,
|
||||
};
|
||||
if (!_sandboxStatusCache.available) {
|
||||
// Include human-readable install hint for owner-tier callers.
|
||||
const missingDeps = (_sandboxStatusCache.missing ?? []).filter(
|
||||
m => m === 'bubblewrap' || m === 'socat' || m === 'ripgrep',
|
||||
);
|
||||
if (missingDeps.length > 0) {
|
||||
sandboxField.message =
|
||||
`Sandbox dependencies not available: ${missingDeps.map(m => `${m} not installed`).join(', ')}.` +
|
||||
` Install: sudo apt-get install -y ${missingDeps.join(' ')}`;
|
||||
} else if (_sandboxStatusCache.details?.error) {
|
||||
sandboxField.message = `Sandbox check error: ${_sandboxStatusCache.details.error}`;
|
||||
} else if (_sandboxStatusCache.details?.libError) {
|
||||
sandboxField.message = `Sandbox library error: ${_sandboxStatusCache.details.libError}`;
|
||||
}
|
||||
}
|
||||
|
||||
const fullPayload = {
|
||||
ok: true,
|
||||
version: VERSION,
|
||||
providers: { enabled, available, status: providerStatuses },
|
||||
sandbox: sandboxField,
|
||||
};
|
||||
if (anonymousKey !== null) fullPayload.anonymousKey = anonymousKey;
|
||||
sendJSON(res, 200, fullPayload);
|
||||
@@ -1269,9 +1338,21 @@ async function handleChatCompletions(req, res) {
|
||||
// chain hops whose model matches the request). Authority: ADR 0004 §
|
||||
// Chain advancement step 1 (per-hop config supplies provider AND model).
|
||||
const hopIrReq = irReq.model === hopModel ? irReq : { ...irReq, model: hopModel };
|
||||
|
||||
// Task #8 — Phase 7 Solution 1: per-spawn isolation primitives.
|
||||
// Compose ephemeral home + credential mounts + hardenedArgs + wrapForLayer3
|
||||
// via prepareIsolatedEnvironment. For providers without ISOLATION declared,
|
||||
// this returns the identity shape (no-op). cleanup fires in finally below.
|
||||
// Authority: ADR 0014 Amendment 1 § A1.2 + ADR 0002 Amendment 9.
|
||||
const hopIsolationCtx = await prepareIsolatedEnvironment({
|
||||
provider: hopProviderPlugin,
|
||||
keyId,
|
||||
reqId: requestId,
|
||||
});
|
||||
|
||||
try {
|
||||
try {
|
||||
for await (const irChunk of hopProviderPlugin.spawn(hopIrReq, authContext)) {
|
||||
for await (const irChunk of hopProviderPlugin.spawn(hopIrReq, authContext, hopIsolationCtx)) {
|
||||
// D16: check error chunks BEFORE pushing — preserves the invariant that
|
||||
// chunks array contains only delta/stop chunks. Without this, the catch
|
||||
// block's `chunks.length > 0` would mistake a single error chunk for
|
||||
@@ -1313,6 +1394,10 @@ async function handleChatCompletions(req, res) {
|
||||
// guarantees no other caller has incremented this provider's count
|
||||
// between our tryAcquireSpawn() above and this releaseSpawn().
|
||||
releaseSpawn(hopProvider);
|
||||
// Task #8: cleanup ephemeral home created by prepareIsolatedEnvironment.
|
||||
// Best-effort (cleanup swallows errors internally). Fires on both happy
|
||||
// path and error path via finally. No-op for providers without ISOLATION.
|
||||
await hopIsolationCtx.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1471,12 +1556,24 @@ async function handleChatCompletions(req, res) {
|
||||
// full F7 rationale + authority citation.
|
||||
const streamIr = ir.model === streamModel ? ir : { ...ir, model: streamModel };
|
||||
return (async function* sourceWithRelease() {
|
||||
// Task #8 — Phase 7 Solution 1: per-spawn isolation (streaming path).
|
||||
// prepareIsolatedEnvironment is called inside the async generator so the
|
||||
// await is legal. cleanup fires in finally below (happy + error + early-
|
||||
// return via iterator.return() from cache-layer abort propagation).
|
||||
// Authority: ADR 0014 Amendment 1 § A1.2 + ADR 0002 Amendment 9.
|
||||
const streamIsolationCtx = await prepareIsolatedEnvironment({
|
||||
provider: streamPlugin,
|
||||
keyId,
|
||||
reqId: requestId,
|
||||
});
|
||||
try {
|
||||
for await (const irChunk of streamPlugin.spawn(streamIr, authContext)) {
|
||||
for await (const irChunk of streamPlugin.spawn(streamIr, authContext, streamIsolationCtx)) {
|
||||
yield irChunk;
|
||||
}
|
||||
} finally {
|
||||
releaseSpawn(streamProvider);
|
||||
// Best-effort cleanup of ephemeral home. No-op for providers without ISOLATION.
|
||||
await streamIsolationCtx.cleanup();
|
||||
}
|
||||
})();
|
||||
};
|
||||
@@ -2059,8 +2156,10 @@ async function handleDashboard(req, res) {
|
||||
async function handleManagementDashboardData(req, res) {
|
||||
return _runOwnerOnlyManagementEndpoint(req, res, 'GET', '/v0/management/dashboard-data',
|
||||
async (_req, res2, _identity, _auditCtx) => {
|
||||
// Quota panel: collect quotaStatus from each loaded provider; null on
|
||||
// Quota panel (legacy): collect quotaStatus from each loaded provider; null on
|
||||
// throw or null return → "unavailable" indicator.
|
||||
// DEPRECATED: kept for backwards compat with current dashboard.html (D82 will
|
||||
// switch consumers to quota_v2; legacy 'quota' key removed at v1.0.0 or earlier).
|
||||
const quota = [];
|
||||
for (const [name, provider] of loadedProviders) {
|
||||
try {
|
||||
@@ -2071,12 +2170,27 @@ async function handleManagementDashboardData(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
// quota_v2 (D81 / Phase 5): normalized per-provider quota shape for enriched
|
||||
// dashboard rendering. Built from aggregateProviderQuota() in lib/audit-query.mjs.
|
||||
// Each entry contains: provider, status, schema_version, last_fresh_at,
|
||||
// utilization, reset, representative_claim, fallback_percentage, overage, raw_available.
|
||||
// Providers with null quotaStatus() return { status: 'unavailable', reason: ... }.
|
||||
// Authority: ADR 0008 Amendment (D81) + ADR 0012 D81 + ADR 0013 Rule 5.
|
||||
let quota_v2 = [];
|
||||
try {
|
||||
quota_v2 = await auditAggregateProviderQuota({ providers: loadedProviders });
|
||||
} catch (err) {
|
||||
// Graceful degradation: quota_v2 is optional enrichment; don't fail entire payload.
|
||||
logEvent('warn', 'dashboard_data_quota_v2_failed', { error: err?.message ?? String(err) });
|
||||
}
|
||||
|
||||
const WINDOW_24H = 24 * 60 * 60 * 1000;
|
||||
const payload = {
|
||||
generated_at: new Date().toISOString(),
|
||||
window_24h: auditAggregateRequests({ windowMs: WINDOW_24H, logEvent }),
|
||||
cache_hit_24h: auditCacheHitRateWindow({ windowMs: WINDOW_24H, logEvent }),
|
||||
quota,
|
||||
quota_v2,
|
||||
spend_trend_30d: auditSpendTrendDaily({ days: 30, logEvent }),
|
||||
top_fallback_chains_24h: auditTopFallbackChains({ windowMs: WINDOW_24H, limit: 10, logEvent }),
|
||||
cache_stats: cacheStore.stats(),
|
||||
@@ -2093,6 +2207,7 @@ async function handleManagementDashboardData(req, res) {
|
||||
async function handleManagementQuota(req, res) {
|
||||
return _runOwnerOnlyManagementEndpoint(req, res, 'GET', '/v0/management/quota',
|
||||
async (_req, res2, _identity, _auditCtx) => {
|
||||
// Legacy quota array (backwards compat).
|
||||
const quota = [];
|
||||
for (const [name, provider] of loadedProviders) {
|
||||
try {
|
||||
@@ -2102,7 +2217,14 @@ async function handleManagementQuota(req, res) {
|
||||
quota.push({ provider: name, error: err?.message ?? String(err), available: null });
|
||||
}
|
||||
}
|
||||
sendJSON(res2, 200, { generated_at: new Date().toISOString(), quota });
|
||||
// quota_v2 (D81): normalized per-provider quota shape per ADR 0008 Amendment (D81).
|
||||
let quota_v2 = [];
|
||||
try {
|
||||
quota_v2 = await auditAggregateProviderQuota({ providers: loadedProviders });
|
||||
} catch (err) {
|
||||
logEvent('warn', 'management_quota_v2_failed', { error: err?.message ?? String(err) });
|
||||
}
|
||||
sendJSON(res2, 200, { generated_at: new Date().toISOString(), quota, quota_v2 });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2274,6 +2396,8 @@ export function createOlpServer() {
|
||||
}
|
||||
|
||||
export { router, loadedProviders, VERSION };
|
||||
// Phase 7 PR-B: re-export sandbox manager test seam so tests can reset state.
|
||||
export { __resetSandboxManagerForTests };
|
||||
|
||||
// Main guard: only listen when invoked as the entrypoint. ESM equivalent of
|
||||
// `require.main === module` is comparing import.meta.url against argv[1].
|
||||
@@ -2286,6 +2410,22 @@ const isMain = (() => {
|
||||
})();
|
||||
|
||||
if (isMain) {
|
||||
// Phase 7 PR-B (ADR 0014 § PR-B): bootstrap sandbox before listening.
|
||||
// bootstrapSandbox() is idempotent + error-safe — server always starts even
|
||||
// if sandbox initialization fails (degrades to unsandboxed, logs a warning).
|
||||
// The /health.sandbox.active field reflects the result.
|
||||
const sandboxBoot = await bootstrapSandbox();
|
||||
if (sandboxBoot.active) {
|
||||
process.stdout.write(
|
||||
`OLP sandbox active (config-at-boot): ${sandboxBoot.summary}\n`,
|
||||
);
|
||||
} else {
|
||||
process.stderr.write(
|
||||
`OLP sandbox NOT active: ${sandboxBoot.reason} — ` +
|
||||
`provider spawns will run UNSANDBOXED (test/dev only; not safe for cloud)\n`,
|
||||
);
|
||||
}
|
||||
|
||||
const server = createOlpServer();
|
||||
server.listen(PORT, BIND, () => {
|
||||
const enabledCount = loadedProviders.size;
|
||||
|
||||
+2918
-40
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user