Author SHA1 Message Date
taodengandClaude Opus 4.7 3551921f55 docs(readme): document client-side shell-tool routing limitation
OLP cannot fully prevent agentic clients with shell/fs tools from
reporting OLP-server-side state as their own "self-check" results.
This is an architectural property of spawn-CLI proxying.

Phase 6c's --system-prompt override (ADR 0009 Amendment 1) addresses
the prompt-side leak (claude CLI's <env>cwd=...</env> injection). But
if a client like OpenClaw is configured to route shell/fs tool calls
to the OLP server host (not the user's local machine), the agent will
correctly execute tools — and correctly report results — except the
model may describe those results as "my own state" when in fact they
describe the OLP server.

OLP is stateless and doesn't know which client is calling. The system
message is owned by the client. So this is documented as a known
limitation with per-client recommendations:

- OpenClaw client mode: configure shell/fs tools to route locally
- Hermes Agent: not affected (pre-processes tools on its host)
- Cline / Continue.dev / Cursor / Aider: not affected (local tools)
- Generic agentic clients: integrators document to their users

Cross-references ADR 0014 for the multi-tenant security counterpart
(sandbox-runtime prevents cross-client OAuth token reads even when
shell-tool routing is misconfigured — once PR-B HTTP-path activation
ships).

The IDENTITY.md hint that was added to the maintainer's Mac mini
OpenClaw workspace during the 2026-05-28 session was a temporary
client-side hack and is out of OLP scope per maintainer feedback —
it has been reverted.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 19:36:45 +10:00
taodengandClaude Opus 4.7 b1e24b7cb0 fix(sandbox): add OLP_SANDBOX_DISABLED=1 env-var emergency disable
PR-B PI231 live verification surfaced a hard problem: HTTP-path anthropic
spawns produce no claude stdout when sandbox-wrapped, while manual exec
of the identical wrap script in the same node process DOES produce output.
Root cause not yet isolated — likely interaction between SandboxManager's
in-process proxy sockets (HTTP/SOCKS Unix sockets in /tmp) and OLP's
HTTP request-handler event loop.

Until the root cause is debugged and Suite 44-equivalent E2E tests cover
the full HTTP request → sandbox spawn → response pipeline, this commit
adds an env-var emergency disable: OLP_SANDBOX_DISABLED=1 in the server
environment causes bootstrapSandbox() to short-circuit immediately,
returning { active: false, reason: '...' }. Provider plugins continue
to spawn unsandboxed (matching pre-PR-B behavior).

Default remains sandbox-enabled. Only operators with broken HTTP-path
sandbox should set the env var (which is everyone on PI231 today,
until follow-up debugging completes).

Operational follow-up (PI231 right now):
  setsid env ... OLP_SANDBOX_DISABLED=1 node ~/olp/server.mjs ...
  → /health.sandbox.active=false
  → HTTP requests resume working

Future PR-B follow-up issues:
  1. Reproduce HTTP-path sandbox spawn failure in unit test
  2. Investigate in-process proxy lifecycle vs HTTP handler event loop
  3. Possibly: switch to per-spawn SandboxManager.initialize() lifecycle
  4. Re-enable sandbox by default after fix + Suite 44 HTTP-path coverage

Sandbox doctor (PR-A) unchanged. ADR 0014 unchanged (the disable is a
runtime gate, not a contract change). Suite 44 negative tests still
pass when enabled.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 17:52:07 +10:00
taodengandClaude Opus 4.7 497b2550e6 fix(anthropic): tolerate null exit code when result event was seen (PR-B sandbox)
The /bin/sh -c wrapping that sandbox-runtime applies (bwrap argv prefix +
inner claude command) can return exit code null on cleanup even when the
underlying claude process completed cleanly and emitted the `result`
event. The previous logic treated any non-zero exit as fatal and threw
ProviderError, causing the HTTP handler to discard the already-yielded
chunks and respond with content:null.

resultEventSeen=true is the authoritative success signal — if it's set,
the model completed and the stop chunk was yielded. Abnormal exit after
that is sandbox bookkeeping noise.

Live PI231 evidence (2026-05-28 commit 2864275 deploy):
  Direct provider test (bypasses HTTP):
    chunk: {"type":"delta","content":"DIRECT_PROOF",...}
    chunk: {"type":"stop","finish_reason":"stop"}
    ERR: claude exit null      ← throw after chunks yielded
  HTTP response: choices[0].message.content == null
                 (chunks lost when consumer received throw)

Fix: only throw on non-zero exit when resultEventSeen=false. With the
guard, the smoke `reply: SANDBOX_PROOF` request now returns the proper
content through the HTTP layer.

The pre-PR-B (non-sandbox) path is unaffected: that path runs claude
directly (no /bin/sh wrap), so exit is always 0 when the model
completes, and the resultEventSeen check is a no-op for that path.

Authority:
- live PI231 2026-05-28 transcript (direct vs HTTP path divergence)
- ADR 0009 Amendment 1 § "NDJSON event handling" — result event is
  the terminal-success indicator
- ADR 0014 § PR-B — sandbox wrap introduces /bin/sh layer

Tests: unchanged at 813 (this is a pure-defensive code path; Suite 44
on PI231 will now exercise the corrected path on E2E run).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 17:38:27 +10:00
taodengandClaude Opus 4.7 28642756b5 fix(sandbox): PR-B fold-ins — allow read ~/.claude + skip wrap under test mock
Live PI231 verification of d0dcd28 surfaced two real-runtime issues:

1. ~/.claude was denyRead, but claude CLI MUST read its own OAuth
   credentials file to authenticate. Result: 100% of anthropic requests
   on sandboxed PI231 failed with "Not logged in · Please run /login".
   Live transcript:
     {"event":"fallback_hop_error", "provider":"anthropic",
      "error":"Not logged in · Please run /login"}

   The cross-tenant risk for ~/.claude was a false security trade-off:
   (a) ~/.claude is the spawn's OWN auth, not cross-tenant material
       (all OLP clients share the same Anthropic OAuth — the file is
       not multi-tenant secret)
   (b) Real protection for the model accidentally exfiltrating creds
       via tool_use is Phase 6c's --system-prompt suppressing tool
       descriptions; sandbox-runtime can only blanket-deny a path, not
       distinguish "claude reads creds" from "model emits Read tool"
   Fix: remove ~/.claude from denyRead. Keep ~/.olp, ~/.ssh, ~/.config,
   ~/.codex (all genuinely cross-tenant).
   Suite 44a (cat ~/.olp/keys.json MUST fail) unchanged — still proves
   the core acceptance.
   Suite 44c added (regression guard): in-sandbox cat ~/.claude/.credentials.json
   MUST succeed when the file exists.

2. anthropic.mjs::_spawnAndStream called wrapSpawn() unconditionally,
   including when a test had set __setSpawnImpl(mockFn). The wrap
   rewrites bin to '/bin/sh -c <wrapped-string>' which breaks every HTTP
   integration test that asserts on spawn args. Result on PI231 (sandbox
   active): 16 production-shape tests failed because their mocks were
   never called with the expected bin/args.
   Fix: skip wrapSpawn when spawnImpl !== defaultSpawn (mock active).
   Mocks don't exec, so isolation is meaningless there anyway. Real-CLI
   spawns (production + Suite 44) still get wrapped.

Tests: 813 → 813 (44c is also PI231-gated, skips on Mac with file
absent; the fold-in does not change Mac test count).

Authority:
- @anthropic-ai/sandbox-runtime v0.0.52 — wrapWithSandbox() shell-string
  return shape is the proximate cause of (2)
- Live PI231 2026-05-28 transcript: /v1/chat/completions returning
  content=null + "Not logged in" from anthropic; OLP_E2E_SANDBOX=1 npm
  test showing 16 prior pass tests now fail
- ADR 0014 § 4.1 PR-B acceptance criteria — the negative test (44a)
  continues to pass; the false-positive denyRead (~/.claude) is corrected

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 17:34:36 +10:00
taodengandClaude Sonnet 4.6 d0dcd281ef feat(sandbox): Phase 7 PR-B — anthropic.mjs spawn wrapped in sandbox-runtime
Wraps the claude CLI spawn in @anthropic-ai/sandbox-runtime per ADR 0014
§ PR-B. Achieves multi-tenant filesystem + network isolation: the spawned
claude subprocess can no longer read ~/.olp/keys.json, ~/.claude/.credentials.json,
~/.ssh/, or any other per-client OAuth material. Only api.anthropic.com and
statsig.anthropic.com are reachable; only /tmp/olp-spawn/<uuid>/ is writable
per spawn (ephemeral, UUID-scoped to prevent cross-request contamination).

## Authority citations

- @anthropic-ai/sandbox-runtime v0.0.52
  https://github.com/anthropic-experimental/sandbox-runtime
  dist/sandbox/sandbox-manager.js — SandboxManager.initialize(), wrapWithSandbox()
  dist/sandbox/sandbox-utils.js  — getDefaultWritePaths()
- 2026-05-28 spike report at /tmp/sandbox-spike/report.md on PI231:
  spike-anthropic.mjs (wrapWithSandbox call signature + NDJSON proof),
  spike-deny.mjs (cat ~/.olp/keys.json MUST fail)
- OLP ADR 0014 § 2.1 PR-B, § 4.1 PR-B acceptance criteria
- OLP ADR 0009 Amendment 1 § Caveats #3 (sandbox is cloud prerequisite)
- OLP ALIGNMENT.md Rule 1 — provider plugin authority citation
- cc-mem incident 2026-05-27 § 3 (multi-tenant OAuth token exposure via
  prompt injection)

## Files changed

- lib/sandbox/manager.mjs (new): bootstrap + spawn-wrap layer.
  Exports: bootstrapSandbox(), isSandboxActive(), wrapSpawn(),
  __resetSandboxManagerForTests(). Config-at-boot model (one
  SandboxManager.initialize() at server start; per-request wrapWithSandbox()
  reads from already-initialized state). Transparent pass-through when inactive.

- lib/providers/anthropic.mjs: spawn site wrapped via wrapSpawn({
  bin, args, env, allowedDomains: ['api.anthropic.com','statsig.anthropic.com']
  }). ADR 0009 Amendment 1 spawn args unchanged — only execution is wrapped.

- server.mjs: bootstrapSandbox() called before server.listen(); startup banner
  logs sandbox.active state. /health.sandbox now includes active:boolean field
  (distinguishes "deps present" from "SandboxManager initialized and wrapping").

- test-features.mjs: Suite 43 (8 tests — manager unit: bootstrap state, idempotency,
  isSandboxActive, wrapSpawn passthrough, /health.active field) + Suite 44
  (2 PI231-gated tests: 44a security negative test + 44b positive echo test,
  skipped by default, run with OLP_E2E_SANDBOX=1 npm test).

- CHANGELOG.md, docs/adr/0014-sandbox-runtime-integration.md: PR-B status
  updated; ADR table + /health JSON example updated with active field.

## Test count

805 (pre-PR-B) → 813 (+8 Suite 43; Suite 44 skipped on macOS, runs on PI231)
All 813 pass on macOS dev machine. 0 regressions.

## PI231 validation (required before merge — Suite 44)

After apt-get install bubblewrap socat (ripgrep already present) + server restart:

1. curl /health → confirm sandbox.available=true AND sandbox.active=true
2. Real anthropic request → confirm stream-json still works end-to-end
3. OLP_E2E_SANDBOX=1 npm test → confirm Suite 44a (cat ~/.olp/keys.json MUST fail)
   and Suite 44b (echo SANDBOX_PROOF succeeds)

Reviewer: must SSH PI231, run Suite 44, and confirm the ADR 0014 § 4.1 criteria.
This commit is ready to push; do NOT push before Suite 44 transcript is captured.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 17:26:34 +10:00
taodengandClaude Opus 4.7 07d9c8a6ae feat(sandbox): Phase 7 PR-A — @anthropic-ai/sandbox-runtime dep + doctor + ADR 0014
Lays the foundation for multi-tenant provider spawning isolation per
ADR 0014 § Decision. NO production wiring — PR-B (anthropic.mjs spawn
wrap) lands separately and requires bubblewrap + socat + ripgrep
installed on PI231 first.

Files:
- package.json: add @anthropic-ai/sandbox-runtime ^0.0.52
- lib/sandbox/doctor.mjs (new): preflight checkSandboxAvailability +
  describeSandboxStatus; pure module, no state, no initialize() call
- server.mjs: /health response gains 'sandbox' field with availability
  + missing deps + install hint; result memoized via _sandboxStatusCache;
  __resetSandboxStatusCache() test seam exported
- docs/adr/0014-sandbox-runtime-integration.md (new): 4-PR layered
  rollout decision per Iron Rule 11; PR-B/C/D acceptance criteria
  previewed; 6 spike pitfalls recorded; 282 lines
- CHANGELOG.md: Unreleased entry under Phase 7 PR-A
- test-features.mjs Suite 42 (+8 tests, 797 → 805, all pass)

Authority:
- @anthropic-ai/sandbox-runtime v0.0.52 — anthropic-experimental org
  https://github.com/anthropic-experimental/sandbox-runtime
- 2026-05-28 PoC spike (verdict YELLOW; report at /tmp/sandbox-spike/
  on PI231): clean arm64 install, dep check fail-closed behaviour
  confirmed, three PoC scripts parked
- cc-mem incident memory § 4 (prior-art search — ecosystem hasn't
  solved multi-tenant fs/tool isolation)
- ADR 0009 Amendment 1 § Caveats #3 — sandbox is cloud prerequisite
- docs/plans/cloud-deployment-family.md § 5

Spike note (macOS): on dev Mac mini with rg via Homebrew,
SandboxManager.isSupportedPlatform()=true and checkDependencies()
returns no errors — macOS uses built-in sandbox-exec, not bwrap.
/health.sandbox.available=true on macOS dev, false on PI231 until
apt install.

Operational follow-ups (NOT this PR):
1. sudo apt-get install -y bubblewrap socat ripgrep on PI231 (5-min window)
2. PR-B: lib/providers/anthropic.mjs spawn wrap + negative test
   (in-sandbox cat of OAuth token MUST fail)
3. PR-C: lib/providers/codex.mjs wrap with enableWeakerNestedSandbox:true
4. PR-D: cloud deployment plan update + unblock

Tests: 797 → 805 (all pass, 0 fail).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 17:09:37 +10:00
taodengandClaude Opus 4.7 e5cfc696da fix(anthropic): suppress stream-json log spam for system/* and user events
The NDJSON event parser matched only `system/init` and treated every
other system subtype as "unknown event type" — logged via console.error,
once per event. During the 2026-05-28 OAuth-expiry 401 cascade this
produced 2 spam lines per failed request (PI231 server log was
~50% noise).

Also caught: claude echoes {type:'user'} events back in some stream-json
modes (analogous to --replay-user-messages); these were also spammed
as unknown.

Both are now consumed silently — neither maps to an IR chunk.

Authority:
- empirical PI231 v2.1.104 transcripts 2026-05-28 (server log shows
  '[anthropic] unknown stream_json event type: system' and
  '[anthropic] unknown stream_json event type: user')
- ADR 0009 Amendment 1 § "NDJSON event handling" — generic "future-proof
  for unknown events" intent; the table was incomplete

Tests: 795 → 797
- 41e-5b: regression guard for system/<non-init-subtype>
- 41e-5c: user (echo) event consumption

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 16:56:52 +10:00
taodengandClaude Opus 4.7 dbac5f5521 fix(anthropic): stop injecting env.CLAUDE_CODE_OAUTH_TOKEN — let CLI auto-refresh
OLP was injecting accessToken from ~/.claude/.credentials.json into the
spawn env as CLAUDE_CODE_OAUTH_TOKEN unconditionally. This defeated
claude CLI's built-in OAuth refresh: when the env var is present, the
CLI reads it directly and never touches credentials.json, so expired
accessTokens were never swapped using the refreshToken sitting right
there in the file. Result: hard 401 cascade every ~8h (access token
TTL) requiring manual `security find-generic-password → scp → PI231`
cycle.

Empirical 2026-05-28 spike on PI231 v2.1.104:
- expiresAt = 1 min ago (simulated expiry)
- spawn claude -p WITHOUT CLAUDE_CODE_OAUTH_TOKEN env
- response succeeded, AND credentials.json.expiresAt advanced to
  ~8h in the future
- token refresh handled internally by claude CLI

Fix: remove the env injection in _spawnAndStream. buildSpawnEnv still
forwards process.env (minus ANTHROPIC_*), so if the operator explicitly
sets CLAUDE_CODE_OAUTH_TOKEN at OLP boot, it's still honored — for
users on ephemeral CI / no-file-creds setups. The readAuthArtifact()
call remains as a preflight check (verify creds exist before spawn)
but the read value is no longer forwarded.

Tests: 793 → 795
- 41h: regression guard — no CLAUDE_CODE_OAUTH_TOKEN in env when only
  file/keychain auth is available
- 41h-2: operator-set process.env passthrough still works

User-facing impact: bot-down-every-8-hours issue resolved. Hermes +
OpenClaw clients no longer require periodic keychain → PI231 copy.

Authority:
- empirical PI231 v2.1.104 spike 2026-05-28 (no public CLI doc cites
  refresh behavior; verified by simulating expiry + observing file
  update post-spawn)
- ADR 0009 Amendment 1 § "Caveats" — implementation must remain robust
  to OAuth flow changes (this fix removes a hard-coded assumption that
  was actively harmful)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 16:52:43 +10:00
taodengandClaude Opus 4.7 dd0c821272 docs(plans): cloud deployment plan (family testing phase) — draft
Plan-stage document for Oracle Cloud VM deployment with family-scale
hardening. Covers architecture overview, TLS termination, iptables /
OCI Security List policy, OAuth credential transfer, auth model
(allow_anonymous off, per-key tier mapping), audit visibility, and
the Phase 7 sandbox-runtime prerequisite.

Status: Draft, pending Phase 7 (sandbox-runtime integration) completion.
This commit makes the plan referenceable from ADR 0009 Amendment 1
§ Caveats #3, the 2026-05-27 incident memory (cc-rules), and the
forthcoming Phase 7 charter.

NOT a code change — no ALIGNMENT.md authority citation required.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 07:38:21 +10:00
taodengandClaude Opus 4.7 65f945c16d fix(anthropic): assistant-aggregate fallback when claude emits no content_block_delta
Live PI231 v2.1.104 verification of 97e7d16 surfaced a real-runtime bug:
in stream-json mode WITHOUT -p (the Transport A path locked by ADR 0009
Amendment 1), claude emits ONLY the aggregate {type:"assistant"} event
for fast/short responses — NO content_block_delta events. The previous
implementation returned null for `assistant` unconditionally, so the
delta-chunk stream was empty and OpenAI-format responses came back with
content=null.

Empirical evidence (PI231 2026-05-27):
- Manual `claude --output-format stream-json --verbose --no-session-persistence
  --system-prompt "..."` < "reply: DIAG-PROOF" → NDJSON stream contains
  system/init + assistant(content:[{text:"DIAG-PROOF"}]) + rate_limit_event
  + result. NO content_block_delta events.
- OLP server response: choices[0].message.content == null
- Provider direct test: chunks = [{type:"stop"}], no delta yielded

Fix:
- anthropicStreamJsonEventToIR(event, isFirstDelta) now handles assistant:
  - isFirstDelta=true  → extract aggregate text from message.content, yield
    as single delta chunk (this is the "no streaming" path)
  - isFirstDelta=false → return null (duplicate of already-streamed deltas)
- Non-text content blocks (thinking) are filtered out
- Multi text blocks concatenate in order

4 new tests in Suite 41 covering: aggregate-when-streamed-null, aggregate-as-
fallback-delta, multi-block-concat, thinking-block-filter.

Tests: 790 → 793 (all pass).

This was a brief-implementation gap: ADR 0009 Amendment 1 § "NDJSON event
handling" mentioned "If we somehow get assistant without prior
content_block_delta events (no streaming), then yield content as single
delta" as a robustness fallback, but the original implementation treated
the case as unreachable. Live verification proved it's the COMMON case
when --include-partial-messages is not set (which we deliberately omit
per the ADR's locked flag set).

Authority:
- claude CLI v2.1.104 § --output-format stream-json (NDJSON shape)
- claude CLI v2.1.104 § --include-partial-messages (controls whether
  content_block_delta events are emitted; we omit it per ADR 0009
  Amendment 1's locked flag set, accepting aggregate-only output)
- ADR 0009 Amendment 1 § "NDJSON event handling" — `assistant` row already
  documents the fallback intent; this commit aligns implementation with intent
- OLP ALIGNMENT.md Rule 1 — provider plugin authority citation

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 22:38:19 +10:00
taodengandClaude Opus 4.7 97e7d16585 feat(anthropic): stream-json + --system-prompt (ADR 0009 Amendment 1)
Replaces claude -p --output-format text spawn with claude (no -p)
--output-format stream-json --verbose --system-prompt.

Authority:
- claude CLI v2.1.104 § --output-format stream-json (verified 2026-05-27
  empirical test on PI231: NDJSON event stream emits without -p)
- claude CLI v2.1.104 § --verbose (required companion to stream-json)
- claude CLI v2.1.104 § --system-prompt (full default-prompt replacement,
  suppresses env-block + tool descriptions)
- claude CLI v2.1.104 § --no-session-persistence
- OLP ADR 0009 Amendment 1 — decision lock + value re-anchoring
- OLP ALIGNMENT.md Rule 1 — provider plugin authority citation

Four orthogonal values delivered:
1. Hallucination fix: model no longer claims server-side cwd / OS /
   tools (verified bot self-check produces "I don't have local env"
   instead of "/home/tlab/olp")
2. ~64% per-request cost reduction: empirical Sonnet 4.6
   $0.0216 → $0.0078, from ~30% input-token reduction (16,601 → 10,700)
3. NDJSON observability: rate_limit_event + usage + cache stats per
   request now available (future audit/dashboard work)
4. Possible 30-60 day bridge for Anthropic 2026-06-15 billing split
   (uncertain per P0 spike — third-party-app classification clause)

Per ADR 0009 Amendment 1 § "Caveats": this is NOT a substitute for
Phase 7 @anthropic-ai/sandbox-runtime work; sandbox remains required
for any cloud / multi-tenant deployment.

Cache key composition unchanged (ADR 0005 IR-based hash; on-the-wire
format is internal to the provider plugin).

Tests: 771 → 790 (+19 new in Suite 41; 9 existing mocks updated to
emit valid NDJSON stream_event format instead of raw text).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 22:29:17 +10:00
40f9453d88 fix(ir): accept OpenAI role=developer at entry surface, normalize to system (#65)
* fix(ir): accept OpenAI role=developer at entry surface, normalize to system

Hermes Agent (v0.13+) and other modern openai-completions clients (Cline,
Continue.dev) default to role=developer for the high-priority instruction
slot when the model id matches OpenAI o1/o3+ reasoning family. OLP IR's
validator rejected this 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 trying olp-codex/gpt-5.5
through PI231 OLP. olp-claude (Anthropic models) was unaffected because
Hermes sends system role for Claude models (no developer-role concept
on Anthropic side).

Fix: extend openai-to-ir.mjs normalizeRole to map developer to system at
the entry boundary. The IR canonical-four-roles invariant is preserved;
every provider plugin's role-handling stays unchanged.

Same pattern as the existing function-to-tool normalization that already
lives in normalizeRole (function role was OpenAI-deprecated). Normalize-at-
entry centralizes role-spec-evolution handling in one file rather than
bloating the IR schema and forcing every provider plugin to handle each
new role.

Why not add developer to VALID_ROLES: it would require branches in three
provider plugins (anthropic, codex, mistral) all mapping to system-style
annotation anyway, plus wider IR surface for any future OpenAI role
addition.

Tests: two new pin tests in Suite IR translation:
- developer role to system translation
- mixed-role array including developer validates cleanly

768 to 770 tests, 0 fail. Verified Hermes via olp-codex no longer hits the
IR rejection error after this fix (smoke run from PI230).

ADR 0003 Amendment 3 documents the rationale + future-role policy
(normalize-at-entry unless a role genuinely conveys
provider-distinguishable semantics).

Authority: OpenAI Responses API spec developer-role + Hermes Agent
v0.14.0 reproduction.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test+docs: PR #65 fold-in reviewer N2 + N3

N2 — Negative control test added: unknown role (e.g. "admin") still raises
BadRequestError. Pins that the developer-to-system normalization is the
only entry-surface escape hatch; any future widening of normalizeRole that
returns role as-is for unknown inputs will be caught by this test.

N3 — ADR 0003 Amendment 3 gains a "Cache-key impact" bullet documenting
that a developer-form and system-form request with otherwise-identical
content now share the same IR and thus the same cache key (per ADR 0005).
This is by design and matches OpenAI's own backward-compat semantics; the
note exists so a future debug session investigating "why does my developer
request hit a cache entry from an old system request" finds the answer.

Test count: 770 to 771, all pass.

Skipped reviewer N1 (URL precision) — Amendment already cites multiple
authorities including the Hermes/Cline tracking convention and live
reproduction transcript; single-URL precision is over-spec.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 18:55:11 +10:00
e2f41eb60e docs(openclaw): switch to canonical env-var-reference apiKey + olp-claude rename + /models menu-only note (#64)
Three corrections to PR #63 surfaced via live Telegram bot bring-up on Mac mini -> PI231 OLP:

1. headers.Authorization workaround does NOT work for openai-shape model ids.
   PI231 audit log showed key_id=__anonymous__ for every gpt-* model sent via
   olp-codex despite headers.Authorization being set. Canonical OpenClaw pattern
   is apiKey: dollar-brace VAR brace env-var reference (per docs.openclaw.ai).
   Verified end-to-end: this attributes both olp-claude/* and olp-codex/* traffic
   to the bot owner key.

   Root cause: two unresolved upstream openclaw bugs:
   - #41157 (Gemini openai-completions Authorization not sent)
   - #1669 (Ollama provider ignores apiKey)

2. claude-local renamed to olp-claude for naming symmetry with olp-codex.

3. /models is menu-only in OpenClaw. Typing /models olp-codex/gpt-5.5 does NOT
   directly switch -- only opens the picker.

Changes:
- Two-modes table: client-mode Auth row now recommends env-var-ref apiKey
- Mode B: NEW step walks through env var setup per OS
- Provider JSON examples use VAR ref pattern (no more headers.Authorization)
- Gotchas rewritten with three failure modes documented
- New gotcha for /models menu-only semantics
- Troubleshooting table: updated 401 row + new rows for anonymous attribution + menu-only

Authority: Live reproduction on Mac mini OpenClaw v2026.5.22 + PI231 OLP v0.5.1.
PI231 audit log verification. OpenClaw issues #41157, #1669, #29095.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 16:57:46 +10:00
5d60a0599f docs(openclaw): expand integration guide — server-co-located vs client-mode + codex-via-OLP recipe (#63)
The previous version of docs/integrations/openclaw.md assumed
loopback OpenClaw+OLP co-located on the same host. Two real gaps:

1. **Client-mode (OpenClaw on a different machine than OLP) is
   the common family-deployment shape**, and the loopback config
   recipe doesn't work there. Specifically:

   - apiKey field is silently overridden by OpenClaw's service-managed
     env (`OPENCLAW_SERVICE_MANAGED_ENV_KEYS=DEEPSEEK_API_KEY,OPENAI_API_KEY`).
     The OpenClaw gateway clobbers `process.env.OPENAI_API_KEY` with
     whatever ChatGPT key it's been authed against, so when an
     openai-completions provider falls back to env-var auth, the
     wrong token gets sent and OLP rejects with 401.

   - The escape hatch is `headers.Authorization: Bearer olp_...` in
     the provider config, which beats the env fallback because
     OpenClaw merges `providerConfig.headers` at request-build time
     (sanitizeModelHeaders → outgoing request).

2. **codex / OpenAI models via OLP routing wasn't documented at
   all.** OpenClaw's stock `openai-codex` provider talks to ChatGPT
   directly (bypasses OLP, no audit, no per-key tracking). To get
   gpt-5.5 / gpt-5.3-codex etc. routed through OLP for per-key
   observability, you have to register a custom `olp-openai`
   provider — recipe now included.

Changes to docs/integrations/openclaw.md:

- Restructured into Mode A (server-co-located) vs Mode B (client-mode)
  with explicit "pick yours" table up top.
- Mode B fully written out — proxyUrl with server IP, headers.Authorization
  override, claude-local provider listed with the three Claude IDs that
  OLP's /v1/models actually exposes.
- New § "Using codex / OpenAI models through OLP" with olp-openai
  provider recipe + agents.defaults.models alias examples + warning
  about why NOT to use OpenClaw's stock openai-codex provider.
- New § Gotchas covering: OPENCLAW_SERVICE_MANAGED_ENV_KEYS clobber,
  default-agent-model points-at-removed-provider, /new doesn't reset
  model selection, the openclaw.extensions schema-drift (was already
  there).
- New § Troubleshooting table mapping symptoms → root causes → fixes.

Discovered live on Mac mini 2026-05-27 during PI231-as-server topology
bring-up. The "apiKey shadowed by env" finding required reading
OpenClaw source (`/opt/homebrew/lib/node_modules/openclaw/dist/...`)
to confirm the `providerConfig.headers` precedence. Verified end-to-end:
Telegram bot now routes free-text through claude-local → PI231 OLP →
claude spawn → reply; and `/models olp-openai/gpt-5.5` switches to
codex via OLP with per-key audit visible on dashboard.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 16:10:55 +10:00
ea0392f744 fix(olp-plugin): add openclaw.extensions field for modern OpenClaw v2026.5+ install (#62)
Discovered 2026-05-27 while bringing up Mac mini as OpenClaw client of
PI231 OLP server: `openclaw plugins install ./olp-plugin/` fails with

  package.json missing openclaw.extensions; update the plugin package to
  include openclaw.extensions (for example ["./dist/index.js"])

OpenClaw v2026.5.22 enforces a stricter plugin-manifest validation than
earlier revisions. The olp-plugin/package.json still used the legacy
schema (`type: "plugin"` + `pluginManifest`) without the new
`extensions: [./index.js]` field that modern OpenClaw requires for the
gateway plugin-discovery loop.

Workaround until this fix lands: manually `cp -R olp-plugin
~/.openclaw/extensions/olp` (symlink also works per docs Option B).

Changes:
- olp-plugin/package.json: added `extensions: ["./index.js"]` to the
  openclaw block. Keeps legacy fields (`type`, `id`, `pluginManifest`)
  for backwards compat with pre-2026.5 OpenClaw revisions; the modern
  loader only reads `extensions`.

- docs/integrations/openclaw.md:
  - § 3 Configure: rewrote example to show the modern `plugins.allow` +
    `plugins.entries.olp.{enabled, config}` schema. The previous shape
    (`plugins.olp.{proxyUrl, apiKey}` at top level) doesn't match what
    OpenClaw actually picks up.
  - § Known issues: added a paragraph documenting the
    `openclaw.extensions` schema-drift event + recovery path (pull latest
    OLP, or symlink fallback).

Verified post-fix: `openclaw plugins install ./olp-plugin/` succeeds on
Mac mini OpenClaw v2026.5.22.

Authority:
- Reproduced live on Mac mini 2026-05-27 during PI231-as-server topology
  bring-up
- OpenClaw stock plugin format (sample: `/opt/homebrew/lib/node_modules/
  openclaw/dist/extensions/anthropic/package.json` uses `extensions:
  ["./index.js"]`)

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:29:35 +10:00
cc250e71bf fix(olp-connect): line 547 referenced undefined \$remote_host (bash nounset crash) (#61)
bin/olp-connect declares \$host and \$port locally (line 436) but line 547
referenced an undefined \$remote_host. Under set -u (nounset) the script
aborted with "remote_host: unbound variable" on every successful /health
probe path that surfaced an anonymousKey, blocking the zero-config
client setup that D68-D70 + ADR 0011 designed.

Fix: use \${host}:\${port} (the actual local variables) for the error-log
context. The variable was only used as a message-context string passed
into validate_olp_token, so no behavior change beyond the message.

Discovered while bringing up MacBook as anonymous client of PI231 server
(2026-05-27). Reproduced cleanly on the v0.5.1 main branch.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 12:25:17 +10:00
9dc070bc53 docs: refresh dashboard screenshot with live MacBook v0.5.1 data (#60)
D82 originally rendered docs/img/dashboard-v0.5.0.png from synthetic
quota_v2 data (the live API wasn't probed at the time of capture).
After v0.5.1 hotfix shipped, the screenshot is replaced with a render
from a real /v0/management/dashboard-data response captured 2026-05-27
from the MacBook OLP server on commit fa2d1af (F4+#7 post-merge).

Changes:
- docs/img/dashboard-v0.5.0.png → docs/img/dashboard-v0.5.1.png
  (rename + content refresh; new content is the v0.5.1 live capture)
- README.md screenshot reference updated to v0.5.1 path + alt-text
  now includes the live utilization numbers (5h: 6%, 7d: 38%)
- docs/exit-gates/phase-5-e2e.json refreshed to reflect the post-v0.5.1
  test (different server version, different temp owner key, new
  utilization snapshot). Records the v0.5.1 contract verifications
  (status enum, failure null for healthy live, schema_version match).

Post-test cleanup verified:
- 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 (port 14567) terminated

No code or contract changes. Pure documentation refresh.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 11:28:16 +10:00
fa2d1af130 feat+test: F4 CLI/plugin quota_v2 migration + v1.x #7 AUTH_MISSING test pin (#59)
## F4 — bin/olp.mjs + olp-plugin/index.js migrate to quota_v2

Codex post-v0.5.0 review Q4: both `olp usage` CLI and `/olp usage`
plugin handler read legacy `body.quota` shape, which never has
`percent_used` or meaningful `available` data → display always fell
through to "no quota api" even when anthropic live quota was visible
on the dashboard (D81/D82).

Authority: codex review Q4 (PR #58); ADR 0008 Amendment 2 (quota_v2
shape: { provider, status, utilization, reset, representative_claim,
fallback_percentage, overage, failure?, failure_kind?, backoff_until? }).

### bin/olp.mjs cmdUsage

- When `body.quota_v2` present (non-empty array, server v0.5.0+): render
  per-provider rows with status badge (live/stale/unreachable/unavailable),
  5h + 7d utilization % (color-coded: green <50% / yellow 50-80% / red ≥80%),
  reset countdowns, binding claim, ⚠ stale /  unreachable annotations.
- When `body.quota_v2` absent: fall through to existing legacy `body.quota`
  rendering (preserves backwards compat with pre-v0.5.0 servers).
- Added `formatResetCountdown(epochSeconds)` — 5-range formatter (past /
  <1h / <24h / <7d / ≥7d), ported from dashboard.html D82. Exported for
  tests. Kept in bin/olp.mjs (not lib/) per spec guidance.
- Added `formatAgo(diffMs)` — small helper for stale-row age display.

### olp-plugin/index.js fmtUsage()

- Same quota_v2 migration. Plain-text output (no ANSI), one line per
  provider. Legacy body.quota fallback preserved.
- Added `pluginFormatResetCountdown(epochSeconds)` — intentionally
  duplicated from bin/olp.mjs (olp-plugin ships as a separate package
  and must not import from bin/). Exported for tests.
- Also fixed fmtUsage to read `w.request_count` (dashboard-data shape)
  in addition to legacy `w.requests` (OCP-era shape) for completeness.

### Backwards compat

- Pre-v0.5.0 server returns body.quota only → both surfaces use legacy
  display (unchanged behaviour).
- v0.5.0+ server returns both quota and quota_v2 → both surfaces prefer
  quota_v2.
- Legacy code paths kept (5-10 lines each, not deleted).

## v1.x roadmap #7 — AUTH_MISSING tuple path test coverage — CLOSED

The dedicated test was already shipped at D56 (test-features.mjs line
6255: 'engine: AUTH_MISSING terminates chain, fallbackDetail tuple
records trigger_type:"auth_missing" (D56, v1.x roadmap #7)'). This
commit closes the roadmap entry with a date stamp and PR reference.

Authority: docs/v1x-roadmap.md § "#7 — AUTH_MISSING tuple path test
coverage (D40 follow-up)".

## Tests

Suite 40 (9 new tests — 40a through 40i):
  40a — cmdUsage parses quota_v2 live rows (mock server)
  40b — cmdUsage falls back to legacy body.quota when quota_v2 absent
  40c — olp-plugin fmtUsage parses quota_v2 live row
  40d — olp-plugin fmtUsage falls back to legacy body.quota
  40e — formatResetCountdown covers all 5 time ranges
  40f — pluginFormatResetCountdown covers past/<1h/<24h/<7d/≥7d
  40g — cmdUsage renders quota_v2 stale row with ⚠ stale note
  40h — cmdUsage renders quota_v2 unreachable row with  indicator
  40i — cmdUsage renders quota_v2 unavailable rows correctly

759 → 768 tests, 0 fail.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 11:24:46 +10:00
bddf2cba1e release(v0.5.1): hotfix — quota probe cache/backoff/schema-drift correctness (codex review) (#58)
* release(v0.5.1): hotfix — quota probe cache/backoff/schema-drift correctness (codex review)

Addresses three production-quality findings from codex's post-v0.5.0 review (codex review on
PR #57, reproduced with local mocks):

F1 [P1] — Doctor bypass of cache + backoff (ADR 0013 Rule 3)
  `anthropic.quota_probe_reachable` called `_probeOnce(auth)` directly, bypassing
  `quotaProbeState.backoffUntil`. Successive `olp doctor` invocations within a backoff
  window each hit upstream — violating ADR 0013 Rule 3 (backoff is mandatory for ALL
  consumers). Fix: doctor now routes through `quotaStatus()`. ADR 0013 Rule 3 clarified:
  "All consumers of `quotaStatus()`, including `olp doctor` checks, MUST route through
  `quotaStatus()` and MUST NOT call `_probeOnce()` directly."

F2 [P2] — 200 with empty ratelimit headers cached as live data (ADR 0013 Rule 5)
  A 200 OK with zero `anthropic-ratelimit-*` headers was cached as `stale: false` (live).
  Minimum-viable-schema gate added to `_probeOnce`: requires 5h-utilization + 5h-reset +
  7d-utilization + 7d-reset present; 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 failure modes into `status: 'unavailable'`
  ("no public quota api or probe disabled") — same as providers with no API at all.
  Fix: `quotaStatus()` v0.5.1 contract — `null` ONLY for opt-in-off; failures return
  `{ probe_status: 'unreachable', failure: { kind, message, backoff_until? } }`.
  New `failure_kind` enum: no_credentials | auth_failed | rate_limited | schema_drift |
  network | other. Dashboard renders `unreachable` with red border + failure detail.

Authority:
  ADR 0013 Rules 3, 5, 6 (cache + backoff + schema-drift + failure transparency)
  ADR 0008 Amendment 2 (richer quota_v2 shape; new unreachable status)
  ADR 0002 Amendment 8 unchanged (constitutional permission for the probe)
  Codex review findings F1–F3 (codex on PR #57)

Changes:
  - lib/providers/anthropic.mjs: quotaProbeState gains lastError + failureKind;
    _probeOnce: min-field gate + failureKind population; quotaStatus(): v0.5.1 contract
    (null=disabled only; probe_status:live/stale/unreachable); doctorChecks routes
    through quotaStatus(); reset functions updated
  - lib/audit-query.mjs: _normalizeAnthropicQuota handles probe_status field;
    aggregateProviderQuota emits failure/failure_kind/backoff_until; unreachable status
  - dashboard.html: unreachable CSS classes + render path + footer v0.5.1
  - test-features.mjs: 38f/j/l updated for v0.5.1 shape; 38r refactored for F1;
    38g/k gain probe_status assertions; 38u/v/w new regression tests; 756→759 tests
  - docs/adr/0008: Amendment 2 (richer ProviderQuotaEntry + quotaStatus contract)
  - docs/adr/0013: Rule 3 clarification (doctor must use quotaStatus);
    Rule 5 min-viable-schema gate specification
  - package.json: 0.5.0 → 0.5.1
  - CHANGELOG.md: v0.5.1 hotfix entry promoted from Unreleased
  - README.md / AGENTS.md: Phase 5 closed at v0.5.1; Phase 6 next

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs+code: PR #58 fold-in — reviewer Nits #1 + #2 (ADR doc-drift + opt_in_off enum)

Fresh-context reviewer (PR #58) verdict: APPROVE_WITH_MINOR, 0 blocking,
4 nits. Folding in #1 + #2 (both 1-line cosmetic-but-correct fixes).
Deferring #3 (test-only edge case in module-level seam restore) and #4
(pre-existing codex F4 — bin/olp.mjs + olp-plugin still read legacy
quota shape; separate PR planned).

Nit #1 — ADR 0002 Amendment 8 documentation drift.

Amendment 8 at v0.5.0 line 20 said "the function returns `null` rather
than throwing", and line 33 said "stale-cache-on-failure (`null` is
returned only when no cache entry exists; if a stale entry exists it's
returned with a `stale: true` marker)". v0.5.1 refined this contract:
`null` is now reserved STRICTLY for opt-in-off, and all failure modes
return `{ probe_status: 'unreachable' | 'stale', failure: {...} }`.

The substantive idempotent-failure constraint (no throw to caller) is
unchanged. The operational description in Amendment 8 was stale — fixed
to cross-reference ADR 0008 Amendment 2 + ADR 0013 Rule 6 for the
v0.5.1 contract refinement. Also references Suite 38 (38u/38v/38w) as
the regression coverage producing the new shape.

Nit #2 — `failureKind: 'opt_in_off'` declared but never produced.

The enum value was listed in both the code comment (anthropic.mjs:250)
and ADR 0008 Amendment 2 (line 30) but never actually assigned —
because when opt-in is off, `quotaStatus()` returns the literal `null`
BEFORE any state mutation happens. The enum value was dead.

Fix: removed `opt_in_off` from both enum declarations + added an
inline note explaining that consumers (audit-query, doctor) distinguish
opt-in-off by checking `quotaStatus() === null`, not via failureKind.

Deferred:

- Nit #3 (test-seam restore edge case): if a caller pre-sets
  `_quotaAuthReadFnForTest` AND passes a non-default `_authReadFn`,
  the finally block restores to null clobbering pre-set value.
  Test-only impact, no production risk. Pure hygiene; defer.

- Nit #4 (codex F4): bin/olp.mjs cmdUsage and olp-plugin/index.js
  still read legacy `body.quota` field, never consume `quota_v2`.
  Reviewer confirmed neither crashes — both gracefully fall through
  to "no quota api" branch. Out of scope for this hotfix per the
  hotfix dispatch contract; separate PR will migrate them.

Tests: 759/759 still pass post-fold-in. No test changes needed.

Authority: PR #58 review thread + ADR 0013 Rule 6 (failure transparency)
+ ADR 0008 Amendment 2 (ProviderQuotaEntry v0.5.1 shape).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 09:33:47 +10:00
29 changed files with 5032 additions and 358 deletions
+1 -1
View File
@@ -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 1922). 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 15 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`).
---
+72 -1
View File
@@ -4,7 +4,78 @@ All notable changes to OLP land here. Per `CLAUDE.md` release_kit overlay, this
## Unreleased
(empty — Phase 6 entries land here once Phase 6 opens)
### Phase 7 PR-B — anthropic.mjs spawn wrapped in sandbox-runtime
- 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.100v2.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 5080% / 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 F1F3).** 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 F1F3 (codex PR review on v0.5.0 close PR #57).
---
## v0.5.0 — 2026-05-26
+14 -4
View File
@@ -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).
---
@@ -283,7 +283,7 @@ See [ADR 0004 (Fallback Engine)](./docs/adr/0004-fallback-engine.md), [ADR 0007
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.
![OLP v0.5.0 dashboard — Plan Usage panel with live anthropic quota](./docs/img/dashboard-v0.5.0.png)
![OLP v0.5.1 dashboard — Plan Usage panel with live anthropic quota (utilization 5h: 6%, 7d: 38%, status: live)](./docs/img/dashboard-v0.5.1.png)
### How it works
@@ -484,7 +484,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 Enrichmentlive 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 F1F3). Phase 6 is next. This table reflects what is currently shipped vs. what is designed for later phases.
| File / artifact | Status | Notes |
|---|---|---|
@@ -546,6 +546,15 @@ 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](./docs/adr/0014-sandbox-runtime-integration.md) for the multi-tenant security counterpart of this issue — even with shell-tool routing, OLP server-side sandboxing prevents one client from reading another client's OAuth tokens (Phase 7 PR-A shipped; PR-B HTTP-path activation pending).
---
## Architecture
@@ -573,7 +582,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, D35D42).
- **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 F1F3).
- **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
View File
@@ -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
View File
@@ -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));
+28 -1
View File
@@ -100,6 +100,7 @@
.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;
@@ -134,6 +135,7 @@
.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;
@@ -147,6 +149,7 @@
.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;
@@ -168,6 +171,12 @@
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;
@@ -294,7 +303,7 @@
<div id="panel-chains"><div class="panel-loading">Loading…</div></div>
</section>
</div>
<footer>OLP Dashboard · Plan Usage: 60s refresh · other panels: 30s · paused when tab hidden · v0.5.0-phase5</footer>
<footer>OLP Dashboard · Plan Usage: 60s refresh · other panels: 30s · paused when tab hidden · v0.5.1</footer>
<script>
(function () {
'use strict';
@@ -442,6 +451,24 @@
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 || {};
+2 -2
View File
@@ -17,7 +17,7 @@
- `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 `null` rather than throwing. The caller (server.mjs / dashboard / `olp usage` CLI) interprets `null` as "quota data unavailable" and continues gracefully.
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).
@@ -30,7 +30,7 @@
- **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 (`null` is returned only when no cache entry exists; if a stale entry exists it's returned with a `stale: true` marker).
- **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)
@@ -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.
@@ -5,6 +5,67 @@
## 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 F1F3.
#### 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,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).
@@ -47,9 +47,10 @@ The probe MUST NOT call any other HTTP path on the provider's API. No `/v1/model
- 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 `null`.
- 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
@@ -70,7 +71,17 @@ Default: `false`. The maintainer must explicitly opt in after credentials are co
`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
### 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:
@@ -0,0 +1,291 @@
# ADR 0014 — Sandbox-Runtime Integration for Multi-Tenant Provider Spawning
**Status:** Accepted (PR-A — deps + doctor + ADR only; PR-B/C/D pending)
**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 (per ADR 0014 § 5 singleton decision, TBD in PR-B ADR amendment)
- 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.
+14 -52
View File
@@ -1,72 +1,34 @@
{
"phase": "Phase 5",
"exit_gate_item": "9 \u2014 Live MacBook E2E verification (dashboard renders enriched panel with real quota data)",
"captured_at_utc": "2026-05-26T07:56:36.651Z",
"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.4.4 (pre-v0.5.0-close; main @ commit 2b07a3b \u2014 D83)",
"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)",
"config_opt_in": {
"providers.anthropic.quota_probe_enabled": true
},
"quota_v2_shape_proof": [
{
"provider": "anthropic",
"status": "live",
"schema_version": "2026-05-26",
"last_fresh_at": 1779782166101,
"utilization": {
"5h": 0.36,
"7d": 0.34
},
"reset": {
"5h": 1779794400,
"7d": 1780225200,
"overall": 1779794400,
"overage": null
},
"representative_claim": "five_hour",
"fallback_percentage": 0.5,
"overage": {
"status": "rejected",
"disabled_reason": "org_level_disabled_until"
},
"raw_available": true
},
{
"provider": "openai",
"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
}
],
"result_summary": {
"anthropic": {
"status": "live",
"schema_version": "2026-05-26",
"utilization_5h": 0.36,
"utilization_7d": 0.34,
"utilization_5h": 0.06,
"utilization_7d": 0.38,
"representative_claim": "five_hour",
"overage_status": "rejected",
"fallback_percentage": 0.5
"failure": null
},
"openai": {
"status": "unavailable",
"reason": "no public quota api or probe disabled"
}
},
"dashboard_screenshot": "docs/img/dashboard-v0.5.0.png",
"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=6yullsd-, name=e2e-d83-close-prep) revoked",
"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=36163, port=14567) terminated"
"test server (pid varies, port=14567) terminated"
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

+285 -61
View File
@@ -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": {
"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
+655
View File
@@ -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 (35 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
+3 -2
View File
@@ -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-26, #1 (streaming SF, D57+D58), #2 (multi-key auth, Phase 2), #4 and #7 (closed in D56), and #8 (dashboard enrichment, D82 Phase 5) 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.
**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.
---
@@ -107,8 +107,9 @@
- `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)
## #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.
+34 -7
View File
@@ -449,19 +449,26 @@ export function spendTrendDaily({ days, olpHome, logEvent, _nowFn } = {}) {
* 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: raw.stale ? (raw.last_fresh_at ?? null) : (raw.probedAt ?? null),
utilization: {
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: {
reset: probeStatus === 'unreachable' ? null : {
'5h': f.reset_5h ?? null,
'7d': f.reset_7d ?? null,
overall: f.reset ?? null,
@@ -469,11 +476,15 @@ function _normalizeAnthropicQuota(raw) {
},
representative_claim: f.representative_claim ?? null,
fallback_percentage: f.fallback_percentage ?? null,
overage: {
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,
};
}
@@ -556,7 +567,8 @@ export async function aggregateProviderQuota({
}
if (rawResult === null || rawResult === undefined) {
// Plugin returned null: either disabled, no quota API, or no credentials.
// 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',
@@ -569,14 +581,18 @@ export async function aggregateProviderQuota({
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 isStale = rawResult.stale === true;
const probeStatus = rawResult.probe_status ?? (rawResult.stale === true ? 'stale' : 'live');
const normalized = _normalizeAnthropicQuota(rawResult);
if (normalized === null) {
@@ -593,13 +609,24 @@ export async function aggregateProviderQuota({
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: isStale ? 'stale' : 'live',
status: outputStatus,
...normalized,
});
}
+17 -2
View File
@@ -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;
}
File diff suppressed because it is too large Load Diff
+290
View File
@@ -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'),
};
}
+409
View File
@@ -0,0 +1,409 @@
/**
* lib/sandbox/manager.mjs Sandbox manager bootstrap + spawn-wrap (Phase 7 PR-B)
*
* Authority:
* @anthropic-ai/sandbox-runtime v0.0.52
* https://github.com/anthropic-experimental/sandbox-runtime
* dist/sandbox/sandbox-manager.js SandboxManager.initialize(), wrapWithSandbox()
* dist/sandbox/sandbox-utils.js getDefaultWritePaths() (used internally)
*
* 2026-05-28 PR-A spike report on PI231 (arm64 Debian Bookworm):
* /tmp/sandbox-spike/spike-anthropic.mjs wrapWithSandbox call signature,
* CLAUDE_CODE_OAUTH_TOKEN env passthrough, shell-mode spawn pattern.
* OLP ADR 0014 § Decision (singleton at boot) + § PR-B specific scope
* OLP ADR 0009 Amendment 1 § Caveats #3 (sandbox is cloud prerequisite)
* cc-mem incident 2026-05-27 § 3 (multi-tenant security gap motivation)
* ALIGNMENT.md Rule 1 provider plugin authority citation
*
* Design:
* One-shot bootstrap at server startup (idempotent). If sandbox not available
* (doctor.available=false or SandboxManager.initialize throws), bootstrap is a
* no-op and isSandboxActive() returns false provider falls back to direct spawn
* (transparent pass-through).
*
* Singleton pattern: SandboxManager is a process-wide singleton per library
* design (reset() clears ALL state). PR-B initializes once at boot with union
* config (Anthropic domains only; codex config follows in PR-C). Per-request
* wrapSpawn() calls SandboxManager.wrapWithSandbox() which reads from the
* already-initialized config state no per-request initialize().
*
* ADR 0014 § Pitfalls #4: SandboxManager.reset() in test teardown must happen
* in finally blocks; concurrent in-flight spawns may break if reset fires while
* a wrapWithSandbox call is in-flight. OLP's current single-server model (one
* process) makes this safe: tests call __resetSandboxManagerForTests() which
* also calls SandboxManager.reset() only safe in test context where no real
* spawns are in-flight.
*
* Exports:
* bootstrapSandbox(opts?) one-shot bootstrap; returns { active, reason?, summary? }
* isSandboxActive() synchronous query
* wrapSpawn({ bin, args, env, cwd, allowedDomains })
* wraps spawn args; transparent pass-through when inactive
* __resetSandboxManagerForTests() test seam: reset internal state + SandboxManager
*/
import { createHash } from 'node:crypto';
import { mkdirSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { checkSandboxAvailability } from './doctor.mjs';
// ── Internal state ────────────────────────────────────────────────────────
/**
* Whether bootstrapSandbox() has been called (initialized = true means we
* ran through bootstrap, not necessarily that sandbox is active).
* @type {boolean}
*/
let _initialized = false;
/**
* Whether the SandboxManager was successfully initialized and is ready to wrap.
* @type {boolean}
*/
let _active = false;
/**
* The config-at-boot snapshot passed to SandboxManager.initialize().
* Null if never initialized or bootstrap failed.
* @type {object|null}
*/
let _initConfig = null;
// ── Ephemeral workspace root ─────────────────────────────────────────────
// Per-request cwd: /tmp/olp-spawn/<uuid>/ — unique per request to prevent
// cross-request contamination. Caller (provider) owns cleanup (or trusts tmpfs
// lifetime). Created by mkdirSync(recursive:true) inside wrapSpawn().
const SPAWN_BASE_DIR = '/tmp/olp-spawn';
// ── Custom error types ───────────────────────────────────────────────────
export class SandboxBootstrapError extends Error {
constructor(message) {
super(message);
this.name = 'SandboxBootstrapError';
}
}
export class SandboxWrapError extends Error {
constructor(message) {
super(message);
this.name = 'SandboxWrapError';
}
}
// ── bootstrapSandbox ──────────────────────────────────────────────────────
/**
* One-shot bootstrap of the sandbox. Idempotent safe to call multiple times.
* If already bootstrapped, returns cached result immediately.
*
* Steps:
* 1. Call checkSandboxAvailability() from doctor module.
* 2. If !available set _active=false, return { active:false, reason }.
* 3. If available build config-at-boot, call SandboxManager.initialize(config).
* 4. On init success _active=true, return { active:true, summary }.
* 5. On init failure log + _active=false + return error (server still starts).
*
* The network allowedDomains covers the Anthropic provider only (PR-B scope).
* Codex domains will be added in PR-C alongside the enableWeakerNestedSandbox flag.
*
* ADR 0014 § PR-B: denyRead covers ~/.olp, ~/.claude, ~/.ssh, ~/.config, ~/.codex
* using absolute literal Linux paths (no globs see ADR 0014 § Pitfalls #2).
* ~/.olp contains keys.json (OLP API keys). ~/.claude contains OAuth credentials.
* ~/.ssh and ~/.config contain identity material. ~/.codex contains codex config.
*
* @param {object} [opts]
* @param {boolean} [opts.force=false] if true, re-run bootstrap even if already initialized
* @returns {Promise<{ active: boolean, reason?: string, summary?: string }>}
*/
export async function bootstrapSandbox(opts = {}) {
// Return cached result if already initialized (unless forced)
if (_initialized && !opts.force) {
return _active
? { active: true, summary: _buildSummary() }
: { active: false, reason: _initConfig?.failReason ?? 'sandbox not available' };
}
// OLP_SANDBOX_DISABLED env-var gate (2026-05-28 PR-B emergency disable):
// Live PI231 evidence showed that even with the exit-null guard, HTTP-path
// anthropic spawns produced no claude stdout when wrapped (manual exec of
// the SAME wrap script in the same process did produce output — root cause
// not yet isolated; likely interaction between SandboxManager in-process
// proxy sockets and OLP's request-handler event loop). Until the root cause
// is debugged + Suite 44-equivalent E2E tests cover the HTTP path, the
// sandbox bootstrap is opt-out via OLP_SANDBOX_DISABLED=1 in the server env.
//
// Default is sandbox-enabled (no env var = try-and-bootstrap). Sandbox is
// skipped only when the operator explicitly disables.
//
// Future PR-B follow-up: investigate the in-process proxy lifecycle
// interaction with OLP's HTTP server event loop; capture diagnostic
// transcript; ship Suite 44-equivalent that exercises the full HTTP
// request → sandbox spawn → response pipeline.
if (process.env.OLP_SANDBOX_DISABLED === '1') {
_initialized = true;
_active = false;
_initConfig = { failReason: 'OLP_SANDBOX_DISABLED=1 — sandbox bootstrap skipped by operator' };
return {
active: false,
reason: 'OLP_SANDBOX_DISABLED=1 — sandbox bootstrap skipped by operator',
};
}
// Reset state for re-bootstrap
_initialized = false;
_active = false;
_initConfig = null;
// Step 1: Check OS + library availability
let availability;
try {
availability = await checkSandboxAvailability();
} catch (e) {
_initialized = true;
_active = false;
_initConfig = { failReason: `doctor check threw: ${e?.message ?? e}` };
return { active: false, reason: _initConfig.failReason };
}
if (!availability.available) {
_initialized = true;
_active = false;
const reason = availability.missing.length > 0
? `sandbox deps missing: ${availability.missing.join(', ')}`
: `sandbox not available on platform: ${availability.details?.platform}`;
_initConfig = { failReason: reason };
return { active: false, reason };
}
// Step 2: Build config-at-boot
// Network allowedDomains: Anthropic provider API domains (PR-B scope).
// - api.anthropic.com: primary Anthropic API endpoint
// - statsig.anthropic.com: claude CLI telemetry (verified empirically in spike;
// required by claude CLI OAuth token refresh path — removing it causes auth failure)
// TODO(PR-C): union in codex/openai provider domains when codex wrap lands.
const allowedDomains = [
'api.anthropic.com',
'statsig.anthropic.com',
];
const home = homedir();
// denyRead: Absolute literal Linux paths per ADR 0014 § Pitfalls #2.
// No ~ or glob — ripgrep glob expansion is not used here to stay safe on
// both Linux (bwrap) and macOS (sandbox-exec profile).
//
// 2026-05-28 PR-B fold-in: ~/.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 even
// though the operator has valid credentials present.
//
// The cross-tenant risk for ~/.claude is mitigated by Phase 6c's
// --system-prompt flag (ADR 0009 Amendment 1): the system prompt is
// fully replaced, suppressing the default tool descriptions that would
// otherwise tell the model it has Read/Bash. Without tool descriptions,
// the model is highly unlikely to emit tool_use even under prompt
// injection. Sandbox's contribution here is protecting OTHER auth
// material (other clients' OLP keys, SSH identity, other providers'
// tokens) — files claude CLI does NOT legitimately need.
//
// If we ever switch to a CLI that requires reading credentials.json
// AND also legitimately offers tool execution that surfaces those files
// (no known case today), this trade-off needs revisiting.
const denyRead = [
join(home, '.olp'), // OLP API keys + config — cross-tenant
join(home, '.ssh'), // SSH identity material — lateral movement
join(home, '.config'), // Generic config dir (may contain tokens)
join(home, '.codex'), // Codex config — other-provider auth (PR-C will wrap codex)
// NOT denied: ~/.claude — this spawn's own auth, breaks claude CLI if denied
];
// allowWrite: ephemeral spawn workspace only. mkdirSync at bootstrap.
// getDefaultWritePaths() adds /dev/stdout, /dev/null etc. internally.
try {
mkdirSync(SPAWN_BASE_DIR, { recursive: true });
} catch (e) {
// Non-fatal: if this dir can't be created, wrapSpawn will fail per-request.
console.warn(`[sandbox/manager] Warning: could not create ${SPAWN_BASE_DIR}: ${e?.message}`);
}
const config = {
network: {
allowedDomains,
deniedDomains: [],
},
filesystem: {
denyRead,
allowWrite: [SPAWN_BASE_DIR, '/tmp'],
denyWrite: [],
},
};
// Step 3: Initialize SandboxManager
let SandboxManager;
try {
const mod = await import('@anthropic-ai/sandbox-runtime');
SandboxManager = mod.SandboxManager;
} catch (e) {
_initialized = true;
_active = false;
_initConfig = { failReason: `sandbox-runtime import failed: ${e?.message ?? e}` };
return { active: false, reason: _initConfig.failReason };
}
try {
// ADR 0014 § Pitfalls #5: initialize() generates MITM CA cert (~100-500ms).
// Must happen at boot, not per-request.
await SandboxManager.initialize(config);
_initialized = true;
_active = true;
_initConfig = { config, SandboxManager };
return { active: true, summary: _buildSummary() };
} catch (e) {
_initialized = true;
_active = false;
const reason = `SandboxManager.initialize failed: ${e?.message ?? e}`;
_initConfig = { failReason: reason };
// Log but DO NOT throw — server still starts in unsandboxed mode.
// PR-D will add hard-fail mode via config flag.
console.warn(`[sandbox/manager] WARNING: ${reason} — provider spawns will run UNSANDBOXED`);
return { active: false, reason };
}
}
/** @internal — returns summary string for logging */
function _buildSummary() {
const cfg = _initConfig?.config;
if (!cfg) return 'active (no config)';
const domains = (cfg.network?.allowedDomains ?? []).join(', ');
return `network allowlist=[${domains}], denyRead=[${(cfg.filesystem?.denyRead ?? []).length} paths], allowWrite=[${SPAWN_BASE_DIR}, /tmp]`;
}
// ── isSandboxActive ───────────────────────────────────────────────────────
/**
* Synchronous query of bootstrap state.
* Returns true only if bootstrapSandbox() completed successfully.
* Used by provider plugins to decide spawn path.
*
* @returns {boolean}
*/
export function isSandboxActive() {
return _active;
}
// ── wrapSpawn ─────────────────────────────────────────────────────────────
/**
* Wrap a spawn command + args for sandbox execution.
*
* Returns { bin, args, env, cwd, sandboxed: boolean }.
* - If sandbox inactive: returns inputs unchanged with sandboxed:false.
* - If sandbox active: returns the wrapped shell string as
* { bin: '/bin/sh', args: ['-c', wrappedShellString], env, cwd, sandboxed:true }.
*
* The wrapped command is a shell string from SandboxManager.wrapWithSandbox().
* It must be spawned with shell:true OR by invoking /bin/sh -c <string> directly
* (the latter is what we do here avoids relying on the shell that Node picks).
*
* Per-spawn ephemeral cwd uses a UUID to prevent cross-request contamination.
* The caller is responsible for cleanup (or trusts tmpfs lifetime).
*
* ADR 0014 § PR-B: env vars passed through unchanged so CLAUDE_CODE_OAUTH_TOKEN
* (if operator set at OLP boot time) still works inside the sandbox.
*
* @param {object} params
* @param {string} params.bin original binary (e.g. 'claude')
* @param {string[]} params.args original args
* @param {object} params.env spawn environment (from buildSpawnEnv())
* @param {string} [params.cwd] original cwd (ignored; replaced by ephemeral dir)
* @param {string[]} [params.allowedDomains] per-spawn domain override (passed as customConfig)
* @returns {Promise<{ bin: string, args: string[], env: object, cwd: string, sandboxed: boolean }>}
*/
export async function wrapSpawn({ bin, args, env, cwd: _cwd, allowedDomains }) {
// Transparent pass-through when sandbox inactive
if (!_active || !_initConfig?.SandboxManager) {
return {
bin,
args: args ?? [],
env: env ?? {},
cwd: _cwd,
sandboxed: false,
};
}
const SandboxManager = _initConfig.SandboxManager;
// Build the shell command string from bin + args.
// Each arg is shell-quoted to handle spaces and special characters.
// Authority: spike-anthropic.mjs line 29-31 — same quoting pattern.
const quotedArgs = (args ?? []).map(a =>
/[\s"'`$\\;&|<>()\[\]{}!#~*?]/.test(a)
? `"${a.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\$/g, '\\$').replace(/`/g, '\\`')}"`
: a
);
const commandString = [bin, ...quotedArgs].join(' ');
// Per-spawn ephemeral cwd (UUID) — prevents cross-request contamination.
// ADR 0014 § PR-B: unique per request.
const reqId = createHash('sha256').update(`${Date.now()}-${Math.random()}`).digest('hex').slice(0, 16);
const spawnCwd = join(SPAWN_BASE_DIR, reqId);
try {
mkdirSync(spawnCwd, { recursive: true });
} catch (e) {
throw new SandboxWrapError(`Failed to create ephemeral spawn dir ${spawnCwd}: ${e?.message ?? e}`);
}
// Per-spawn customConfig: allow caller to override domains (e.g. different provider).
// Default: use the config-at-boot allowedDomains.
let customConfig;
if (allowedDomains && allowedDomains.length > 0) {
customConfig = {
network: {
allowedDomains,
deniedDomains: [],
},
};
}
let wrappedCommand;
try {
wrappedCommand = await SandboxManager.wrapWithSandbox(commandString, undefined, customConfig);
} catch (e) {
throw new SandboxWrapError(`SandboxManager.wrapWithSandbox failed: ${e?.message ?? e}`);
}
// Invoke via /bin/sh -c to avoid spawning a second shell layer.
// The wrapped command is already a complete shell invocation (bwrap args or
// sandbox-exec profile + the original command inside).
return {
bin: '/bin/sh',
args: ['-c', wrappedCommand],
env: env ?? {},
cwd: spawnCwd,
sandboxed: true,
};
}
// ── Test seam ─────────────────────────────────────────────────────────────
/**
* Reset internal state so test suite can simulate fresh process.
* Also calls SandboxManager.reset() if it was initialized (to clear singleton).
*
* ADR 0014 § Pitfalls #4: must only be called when no in-flight wrapSpawn calls
* are active. Safe in sequential test contexts.
*
* @returns {Promise<void>}
*/
export async function __resetSandboxManagerForTests() {
if (_active && _initConfig?.SandboxManager) {
try {
await _initConfig.SandboxManager.reset();
} catch { /* ignore — test teardown, best-effort */ }
}
_initialized = false;
_active = false;
_initConfig = null;
}
+79 -4
View File
@@ -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)) {
+2 -1
View File
@@ -10,6 +10,7 @@
"openclaw": {
"type": "plugin",
"id": "olp",
"pluginManifest": "openclaw.plugin.json"
"pluginManifest": "openclaw.plugin.json",
"extensions": ["./index.js"]
}
}
+89
View File
@@ -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
View File
@@ -1,6 +1,6 @@
{
"name": "olp",
"version": "0.5.0",
"version": "0.5.1",
"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"
}
}
+85
View File
@@ -70,6 +70,16 @@ 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, __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 {
@@ -221,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)
@@ -859,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);
@@ -2301,6 +2368,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].
@@ -2313,6 +2382,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;
+1708 -47
View File
File diff suppressed because it is too large Load Diff