9 Commits
Author SHA1 Message Date
6605b7b14a feat+docs+release(v0.4.3): D76 — README install-path overhaul + OLP_BIND env + AI-install prompt + ADR 0011 amendment (#48)
10 README install gaps catalogued and fixed in one D-day after v0.4.2's
PI231 E2E session exposed that the v0.4.0-v0.4.2 README Quick Start was
fictional (npm package isn't published; olp setup/start commands don't
exist). F5 (OLP_BIND env) ships in the same patch so the documented LAN
onboarding flow actually works. AI-driven install prompt added per Phase 4
charter brainstorm Top-5 inheritance candidate #2 (D64-D67 built the
doctor framework; D76 closes the README half).

## G1-G7: README Quick Start now real

Rewrote § "Manual install" from placeholder text to the empirically-verified
sequence:
- Prerequisites (Node >= 18 + provider CLI install matrix)
- git clone + npm test verify
- olp-keys keygen --owner FIRST (allow_anonymous=false default needs a key)
- Per-provider OAuth (claude setup-token / codex login --device-auth /
  MISTRAL_API_KEY)
- ~/.olp/config.json with the minimum that actually serves traffic
- npm start
- Smoke-test via curl + olp doctor
- IDE pointing

## G8 / F5: OLP_BIND env shipped

server.mjs:
- const BIND = process.env.OLP_BIND ?? '127.0.0.1' (safe default unchanged)
- server.listen(PORT, BIND, ...) replaces hard-coded '127.0.0.1'
- New startup warn anonymous_key_advertised_with_lan_bind fires when
  OLP_BIND is non-loopback AND auth.advertise_anonymous_key: true
  (operator visibility into trust-context overlap)

Pre-D76 the server only accepted loopback connections, so the documented
olp-connect <ip> family-onboarding flow was unreachable from LAN without
SSH tunneling. F5 makes ADR 0011 operational instead of aspirational.

## G10: AI-driven install prompt

README § "Install with your AI (the fast path)" — verbatim prompt the
operator pastes into Claude Code / Cursor / Copilot / Aider. The AI
follows README + uses olp doctor --json next_action.ai_executable[] for
self-repair, stopping only when human_required[] is non-empty (provider
OAuth dances). Closes the Phase 4 brainstorm #2 inheritance candidate.

## Opening compressed

§ "Why OLP" (3 paragraphs of Anthropic 2026-06-15 billing history)
removed from the top. The OCP-trigger context moved to § "Migration
from OCP" at the bottom, condensed into a single paragraph. New users
land on value-prop + § "What you get" + install paths without needing
to digest 2026-05-14 billing history first. OCP users get a one-line
pointer at the top.

## Configuration + Env Variables sections updated

- § Configuration: placeholder replaced with full ~/.olp/config.json
  schema documentation, every field cross-referenced to its ADR
- § Environment Variables: added OLP_BIND, OLP_API_KEY, OLP_OWNER_TOKEN,
  OLP_PROXY_URL rows that were used in the manual-install flow but
  previously undocumented

## ADR 0011 § Deployment configurations amendment

Codifies the three deployment trust contexts:
- 127.0.0.1 (loopback only) — safe with any auth posture
- RFC1918 / tailnet / specific LAN IP — anonymous_key OK (the documented
  trusted-LAN zero-config family onboarding flow)
- Public IP — incompatible with advertise_anonymous_key: true

Documents the new anonymous_key_advertised_with_lan_bind startup warn
event. Closes ADR 0011's pre-D76 dangling reference to a non-existent
BIND_ADDRESS concept.

## Test count

714 (v0.4.2) -> 717 (v0.4.3). +3 D76 regression tests in Suite 36
(36s/36t/36u) pinning OLP_BIND wiring + safety warn + ADR amendment.

## Process learning

Every D-day reviewer rubric should add "open README §-Quick-Start and
verify the commands literally exist + work in the current repo" — would
have caught G1-G7 at v0.4.0 close. Combined with D74 (review-against-spec)
+ D75 (review-without-deployment), D76 (review-without-following-README)
codifies the third tier of review discipline.

## Out of D76 scope (deferred)

- F6 (doctor client-side vs server-side check separation) — needs design
  ADR for --remote mode. Phase 5.
- D75 reviewer P2-1 (ADR 0004 per-hop schema amendment) + P2-2 (defensive
  typeof hopModel === 'string') — both genuine follow-ups, neither
  blocking.
- scripts/migrate-from-ocp.mjs — Phase 7.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 13:32:02 +10:00
6edf6e0b94 fix+release(v0.4.2): D75 — codex CLI v0.133.0 schema + per-hop model override (#47)
Patch release fixing 5 bugs caught by real-machine E2E testing on PI231 +
Mac mini (2026-05-26 session). Prior D-day reviewers + the post-v0.4.0
maintainer review all missed these because they reviewed against spec text
and against the local OLP install's cached codex CLI shape, not against a
fresh `npm install -g @openai/codex` on a remote operator host getting
v0.133.0 for the first time.

F1 — codex auth.json schema pin (lib/providers/codex.mjs readAuthArtifact)
  Real codex CLI v0.133.0 nests the access token under `tokens.access_token`,
  not at top-level access_token / token / accessToken. Pre-D75 readAuthArtifact
  returned null → OLP reported "auth artifact missing" even for fully
  logged-in users. Fix: prepend creds?.tokens?.access_token to the precedence
  chain at both override + default branches. Legacy fields preserved as
  fallback. Authority: codex CLI v0.133.0 on-disk auth.json shape verified
  empirically on PI231 2026-05-26 E2E session.

F2 — codex spawn args + --skip-git-repo-check (lib/providers/codex.mjs irToCodex)
  codex CLI v0.133.0 trusted-directory sandbox refuses with "Not inside a
  trusted directory" outside git repos. OLP deploys typically outside a git
  repo. Fix: add '--skip-git-repo-check' to args before '--model'. Authority:
  codex CLI v0.133.0 reference (`codex exec --help` documents the flag).

F3 — codex NDJSON event shape pin (lib/providers/codex.mjs codexChunkToIR)
  Real v0.133.0 stream: thread.started → turn.started → item.completed
  (item.type='agent_message', item.text=<response>) → turn.completed.
  D6 defensive parser only recognised top-level content/delta/text +
  type:'stop'/done:true → every chunk silently dropped → response body had
  content: null. Fix: add three new recognisers (item.completed → delta;
  turn.completed → stop; turn.failed → error) before the legacy fallback
  chain. Legacy recognisers preserved for backward/forward compat.

F4 — `olp status` reads body.stats.cache.size, not body.cache.entries
  (bin/olp.mjs cmdStatus). Server payload nests stats under stats.cache;
  CacheStore.stats() exposes {hits, misses, size, inflightCount} — there is
  no `entries` field. D74 P2-3 fixed cmdUsage + cmdCache for the same bug
  class but missed cmdStatus.

F7 — per-hop chain `model` overrides IR model in provider.spawn()
  (server.mjs executeHopFn + streaming sourceFactory). Pre-D75 executeHopFn
  used hopModel for cache key + audit ctx but passed the original irReq
  (with irReq.model = user's request) to provider.spawn(). Chain config
  [{anthropic, claude-X}, {openai, gpt-5.5}] would always spawn BOTH plugins
  with --model claude-X — openai rejected the unknown model and the chain
  died. This broke the core OLP value prop (cross-provider fallback with
  provider-appropriate model substitution). Fix: build per-hop IR variant
  with { ...irReq, model: hopModel } and pass to spawn. Conditional skips
  clone when hopModel === irReq.model. Applied to BOTH buffered path AND
  streaming path. Authority: ADR 0004 § Chain advancement step 1 (per-hop
  config supplies provider AND model — contract always specified, code
  didn't complete it).

Out of scope (deferred to Phase 5):
- F5 (server bind / OLP_BIND env) — needs anonymous-key trust review
- F6 (doctor client-vs-server-side limit) — needs trigger-taxonomy ADR

Test count: 704 → 714 (+10 Suite 36 D75 regression tests: 36i–36r).
Files touched: lib/providers/codex.mjs, bin/olp.mjs, server.mjs,
test-features.mjs, package.json, CHANGELOG.md.

Phase 5 process learning: every provider plugin D-day must include a
real-CLI E2E on a remote operator host before merging — not on the
maintainer workstation (which may have an older CLI cached from a prior
install). D6/D7 codex E2E was deferred and that deferral compounded across
3 layers. F7 reinforces a separate lesson: when a function signature takes
(provider, model, ir), reviewers must check that `model` is consumed
everywhere downstream — not just at the call site they happened to look at.

Authority: ADR 0002 (provider contract — codex plugin), ADR 0004 (fallback
engine — per-hop model contract), lib/providers/codex.mjs D6 assumption
A2/A3/A4 docstrings (which all said "D7 will pin" and D7 never did); codex
CLI v0.133.0 on-disk schema + `codex exec --help` output verified
empirically on PI231 (2026-05-26 E2E session); Iron Rule 第二律
evidence-over-should-work; CLAUDE.md release_kit.phase_rolling_mode
cross-Phase discipline ("hotfix to a shipped Phase N deliverable → bump
patch, tag, release before next push").

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 12:50:41 +10:00
f3716a19fd fix+release(v0.4.1): D74 post-Phase-4 hotfix batch — 5 maintainer-review findings (#46)
* fix+release(v0.4.1): D74 post-Phase-4 hotfix batch — maintainer-review findings

Patch release fixing 5 issues caught by maintainer post-v0.4.0 independent
review (main / v0.4.0 / commit ee4d945). All five are real runtime bugs
the per-D-day fresh-context opus reviewers missed because they checked
spec text instead of runtime contracts (default auth.allow_anonymous:
false, real /health payload shape, real /cache/stats payload shape, real
/v0/management/dashboard-data payload shape).

Phase 4 process learning: every implementation D-day MUST include at
least one test that boots the server with default production config and
exercises the new feature end-to-end. D74 Suite 36 pins the wire-
contract shapes so a future D-day refactor can't silently re-break the
CLI / plugin / docs.

## P1-1 — olp doctor false-negative on auth-required /health

lib/doctor.mjs: buildBuiltinChecks() accepts opts.authHeaders and passes
to httpGet for both server.running + server.version probes. The
server.running check now distinguishes 401/403 ("server up, bearer token
missing or invalid — set OLP_API_KEY") from "server unreachable" so the
kind discriminator routes to a clean fix-auth path instead of fix_server
when operator just forgot to export the env var.

bin/olp.mjs cmdDoctor: threads authHeaders() through to runDoctor.

## P1-2 — olp-connect token validation + shell-quoting

bin/olp-connect: validate_olp_token() enforces ^olp_[A-Za-z0-9_-]{43}$
(per ADR 0007 § 3 token format) at THREE input sites: --key arg,
/health.anonymousKey server-advertised consumption, interactive prompt
fallback. shell_quote() POSIX-single-quote-wraps with embedded-quote
escape per:
  foo'bar  →  'foo'\''bar'
Applied to all rc-file writes + dry-run output. systemd
environment.d/olp.conf write additionally rejects embedded newlines.
Hostile or malformed keys can no longer persist as shell startup
injection.

## P2-3 — olp usage + olp cache human formatter wire-contract fix

bin/olp.mjs cmdUsage previously read body.usage_24h.requests /
body.providers / body.top_fallback_chains — none of which exist in the
real server payload (server.mjs:2027 + lib/audit-query.mjs). Users saw
"requests: ?" + missing per-provider quota + missing top-chains. Now
reads body.window_24h.request_count / body.cache_hit_24h.hit_rate /
body.quota / body.top_fallback_chains_24h.

bin/olp.mjs cmdCache previously read body.entries / body.bytes /
body.maxBytes (OCP-era field names). Real CacheStore.stats() returns
{hits, misses, size, inflightCount}. Now reads body.size /
body.inflightCount + computes hit rate from hits/(hits+misses).

## P2-4 — olp-plugin/ fmtHealth iterates providers.status

olp-plugin/index.js: previously walked Object.entries(body.providers)
which surfaced `enabled` / `available` / `status` as pseudo-providers
(chat showed "🟢 status" instead of "🟢 anthropic"). Now extracts the
real provider map from body.providers.status, renders enabled/available
counts in a header line, lists per-provider names with activeSpawns when
present. Falls back to flat body.providers.* for older OCP shape
(backwards compat).

## P3-5 — stale v0.3.0-era doc strings updated

README.md: header status line + Implementation Status § now reflect
v0.4.0 shipped + Phase 5 open. Known-limitations Phase 3 line moved out
of "pending v0.3.0" state; new Phase 4 line added with full deliverable
list.

server.mjs: startup banner no longer hardcodes "Phase 1 in progress"
(now just lists version + provider count). Banner derives state from
VERSION so future Phase boundaries don't need touch-ups here.

## Test count

696 (v0.4.0) → 704 (v0.4.1). +8 D74 regression tests in Suite 36:

- 36a: runDoctor accepts authHeaders + threads to checks
- 36b: server.running distinguishes 401 (auth) from "server down"
- 36c: olp-connect rejects malformed --key (validator fires before rc write)
- 36d: olp-connect accepts properly-formed olp_ token
- 36e: CacheStore.stats() shape pin + cmdCache source pin
- 36f: dashboard-data payload shape pin + cmdUsage source pin
- 36g: olp-plugin fmtHealth iterates providers.status not providers.*
- 36h: server.mjs banner doesn't hardcode stale phase

## Authority

- Maintainer independent review of main / v0.4.0 / commit ee4d945
  (2026-05-26 session — 5 findings P1×2 + P2×2 + P3×1)
- Iron Rule 第二律 (evidence over "should work") — runtime smoke
  against default production config now mandatory per Suite 36 pattern
- CLAUDE.md release_kit.phase_rolling_mode cross-Phase discipline
  ("hotfix to a shipped Phase N deliverable → bump patch, tag, release
  before next push")
- ADR 0007 § 3 (token format ^olp_[A-Za-z0-9_-]{43}$) — D74 P1-2
  validator authority

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

* fix: Suite 36 paths use import.meta.dirname for CI portability (was hardcoded /Users/taodeng/olp/)

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 10:43:12 +10:00
ee4d9459aa release(phase-4-close): v0.4.0 — Operator + Client UX (D60 → D73) (#45)
Closes Phase 4. Maintainer triggered the close per CLAUDE.md
release_kit.phase_rolling_mode.phase_close_trigger ("explicit maintainer
action — not automated") this session, 2026-05-26.

## Scope

5 D-day groups, ~13 D-days, all merged to main with fresh-context opus
reviewer per Iron Rule 10:

- D60   (PR #40) — Phase 4 charter (ADR 0010) + default port 3456→4567
- D61-D63 (PR #41) — SSE heartbeat + recentErrors[20] + /v0/management/status
- D64-D67 (PR #42) — olp Node CLI + olp doctor + ADR 0002 Amendment 7
- D68-D70 (PR #43) — olp-connect + /health.anonymousKey + ADR 0011
- D71-D73 (PR #44) — olp-plugin/ Telegram+Discord + 6 IDE docs + README

Test count arc: 623 (v0.3.2) → 696 (v0.4.0). +73 tests.

## Strategic decision recorded in ADR 0010

Phase 4 explicitly DEFERS /v1/messages (Anthropic-shape entry surface).
Re-open strictly gated on ADR 0009 P0 success AND maintainer-named
family CC user. README posture: Claude Code listed as NOT supported as
an OLP client; recommended alternative "Cline + OLP" (same fallback
chain available, better cross-provider compatibility because OpenAI
tool schema is the multi-provider lingua franca; Anthropic tool_use /
cache_control / computer_use / thinking blocks lack clean cross-provider
mapping).

## What this commit actually changes

- package.json: 0.3.2 → 0.4.0
- CHANGELOG.md: Unreleased promoted to "## v0.4.0 — 2026-05-26" with
  full D60-D73 entries (test counts, authority chains, reviewer P2
  fold-ins documented). New Unreleased: "(empty — Phase 5 entries land
  here once Phase 5 opens)"
- CLAUDE.md release_kit.phase_rolling_mode:
    current_phase: Phase 4 → Phase 5
    current_pre_release_identifier: "0.4.0-phase4" → "0.5.0-phase5"

## Phase 4 close checklist (per ADR 0010 § Exit gate)

- [x] All 5 D-day groups landed on main with reviewer APPROVE
- [x] CI green on every D-day merge + this release commit head
- [x] package.json bumped 0.3.2 → 0.4.0
- [x] CHANGELOG Unreleased promoted to v0.4.0 — 2026-05-26
- [x] CLAUDE.md release_kit phase_rolling_mode advanced
- [x] README § IDE Setup + § Telegram/Discord Usage + § Operator CLI surfaces
- [x] ADR 0010 + ADR 0011 + ADR 0002 Amendment 7 on disk
- [ ] Tag v0.4.0 pushed (next lifecycle step)
- [ ] release.yml triggers + GitHub Release auto-created on tag push

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 09:52:02 +10:00
53afea47ca feat+test+docs: D71+D72+D73 — olp-plugin/ (OpenClaw /olp Telegram+Discord) + docs/integrations/*.md + README cross-refs (#44)
Final Phase 4 substantive D-day group. 3 D-days bundled per Iron Rule 11
IDR (plugin consumes existing endpoints; integration docs reference plugin
+ olp CLI + olp-connect together; README index links all).

After this PR merges, Phase 4 has shipped all 5 D-day groups (D60 charter
+ port / D61-D63 SSE heartbeat+ring+/status / D64-D67 olp CLI+doctor /
D68-D70 olp-connect+anonymous-key+ADR0011 / D71-D73 plugin+docs). The
v0.4.0 close PR is maintainer-triggered per CLAUDE.md release_kit overlay.

## D71 — olp-plugin/ (OpenClaw gateway plugin)

Port OCP ocp-plugin/index.js (311 lines) → OLP olp-plugin/index.js (482
lines) as the /olp Telegram+Discord slash command, but MINUS mutations
(no /olp keys keygen, no /olp keys revoke, no /olp restart, no /olp logs
— all of these require SSH out of chat for security).

Plugin shape:
- olp-plugin/index.js — registers /olp command via OpenClaw api.registerCommand
- olp-plugin/openclaw.plugin.json — manifest, apiKey REQUIRED, proxyUrl
  default http://127.0.0.1:4567 (matches D60)
- olp-plugin/package.json — minimal: name/version/type:module + OpenClaw
  discovery block
- olp-plugin/README.md — install + configure + use docs; documents the
  "no mutations from chat" security stance and the dedicated-bot-key
  pattern (don't share maintainer's personal key with the bot)

Subcommand parity with olp CLI (D64-D67):
- /olp status   → GET /v0/management/status         (owner-only)
- /olp health   → GET /health                       (public-ok)
- /olp usage    → GET /v0/management/dashboard-data (owner-only)
- /olp models   → GET /v1/models                    (public-ok)
- /olp cache    → GET /cache/stats                  (owner-only)
- /olp providers → local cross-ref                  (public-ok)
- /olp chain show [<model>] → local                 (public-ok, advisory
  if no FS access — defer to ssh + olp chain show)
- /olp doctor   → informational (HTTP doctor endpoint deferred; advisory
  to ssh + olp doctor for live use)
- /olp help     → usage text

Port resolution: OLP_PROXY_URL env → OLP_PORT env → plugin config
proxyUrl → http://127.0.0.1:4567. Output: Telegram/Discord monospace
code block with status icons (🟢🟡🔴). Long responses truncated for the
4096-char message limit.

No npm deps. OpenClaw provides Telegram/Discord transport; plugin uses
fetch + node builtins only.

## D72 — docs/integrations/*.md (6 IDE pages + index)

Per the Phase 4 brainstorm prior-art survey + ADR 0010 § Out-of-scope
posture for Claude Code:

- continue.md    — config.yaml (NOT config.json); apiBase; requestOptions.headers
- cline.md       — "OpenAI Compatible" provider; Cline #7128 base-URL UI bug warning
- cursor.md     ⚠️  — known base-URL fragility; only enable models OLP serves
- aider.md       — OPENAI_API_BASE env + openai/ prefix; .env support
- claude-code.md  — explicitly NOT supported per ADR 0010 § /v1/messages defer
                     rationale; recommended alternative: Cline + OLP
- openclaw.md    — install olp-plugin via CLI or symlink; configure apiKey;
                    restart gateway

Each ~60-120 lines: status / quick setup / known issues / OLP-specific
notes / test-it command. docs/integrations/README.md is the index.

## D73 — README cross-references

- New § "IDE Setup" links to docs/integrations/README.md
- New § "Telegram / Discord Usage" — install + configure + restart + use
- Quick Start mentions olp-connect <ip> as family-onboarding command
- package.json `files` field extended to include olp-plugin/ so the
  published tarball ships the plugin

## Test count

672 → 696 (+24 D71-D73 tests in Suite 35: helpers / formatters /
dispatch / error paths). All 696 pass locally.

## Scope discipline

- server.mjs UNTOUCHED (plugin consumes EXISTING endpoints)
- No new npm deps (no Telegram or Discord SDK — OpenClaw provides transport)
- No /v1/messages (out of Phase 4 per ADR 0010)
- No CHANGELOG / package.json version bump (Phase 4 close handles versioning;
  only package.json `files` extended for olp-plugin/ publication)

## Implementor flagged for reviewer

1. /olp doctor returns SSH advisory (no HTTP doctor endpoint yet). When
   future phase exposes /v0/management/doctor, swap advisory branch for
   real fetchJSON + fmtDoctor (already implemented + tested).
2. /olp providers + chain show have no FS access (plugin runs in OpenClaw
   gateway process); registry read via lazy-imported models-registry.json
   from repo root. For live enabled-state visibility users still need
   /olp status (owner-tier) or ssh + olp providers / olp chain show.
3. No live-server wire test in Suite 35 — existing Suites 31/32 already
   cover the integration path against the same endpoints; mock-fetch in
   Suite 35 is sufficient signal for the plugin layer.

## Authority

- ADR 0010 § Phase 4 D-day plan D71-D73 line
- OCP ocp-plugin/index.js (port reference)
- ADR 0010 § Out-of-Phase-4-scope (claude-code.md  rationale)
- 2026-05-26 brainstorm (Top OCP inheritance candidates + prior-art
  survey IDE-specific quirks for cline/cursor/continue docs)

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 09:35:23 +10:00
0bdecd1235 feat+test+docs: D68-D70 — bin/olp-connect + /health.anonymousKey + ADR 0011 (#43)
* feat+test+docs: D68+D69+D70 — bin/olp-connect + /health.anonymousKey + ADR 0011

Third substantive Phase 4 implementation. 3 D-days bundled per Iron Rule
11 IDR — olp-connect consumes /health.anonymousKey for zero-config
client setup, both governed by ADR 0011's trusted-LAN-only invariant.

## D68 — bin/olp-connect (zero-config client setup)

Ports OCP ocp-connect (721 lines) → OLP olp-connect (564 lines, pure bash).
Bash over Node (per ADR 0010 § Notes) because client machines may lack
recent Node; bash + curl + python3 = max portability.

CLI: `olp-connect <host-ip> [--port PORT] [--key API_KEY] [--no-system-env]
                            [--dry-run] [--help] [--version]`

Workflow:
1. Connectivity probe (curl /health, 5s timeout, distinguishes TCP
   unreachable from auth-required)
2. Auth resolution: --key flag → /health.anonymousKey (D69) → interactive
   prompt fallback
3. Smoke test (GET /v1/models with bearer)
4. IDE detection + per-IDE config:
   - Claude Code: detect + warn (NOT supported as OLP client per ADR 0010)
   - Cline: detect + print manual VSCode-settings snippet
   - Continue.dev: detect (extension OR ~/.continue/config.yaml) + write
     idempotent models: entry
   - Cursor: detect + print snippet + WARNING (per prior-art known
     base-URL fragility)
   - Aider: detect + write OPENAI_API_BASE + OPENAI_API_KEY to rc files
   - OpenClaw: detect + print "install /olp plugin (D71-D73 deliverable)"
5. System-level env: macOS launchctl setenv / Linux ~/.config/environment.d
   (so VSCode/Cursor started via Dock inherit)
6. Summary + test command

Idempotent (bracketed `# OLP LAN (added by olp-connect)` ... `# /OLP LAN`
blocks in rc files). --dry-run exercises every state-change site without
modifying anything. Exit 0/1/2 conventions.

Installed via package.json bin so `npx olp-connect` works.

## D69 — /health.anonymousKey + auth.advertise_anonymous_key

server.mjs handleHealth emits OPTIONAL `anonymousKey: "olp_..."` field
when ALL THREE prerequisites hold:
1. config.json auth.advertise_anonymous_key === true
2. config.json auth.allow_anonymous === true (per ADR 0007 § 7)
3. At least one non-revoked key has plaintext_advertise field set

Default-off: field is ABSENT (not null) — preserves v0.3.x /health shape;
existing tests don't regress.

bin/olp-keys.mjs new flags: `keygen --anonymous --advertise` writes the
plaintext into the manifest's optional `plaintext_advertise` field AND
prints a WARNING about disk-storage + /health exposure + ADR 0011
pointer. Owner-tier --advertise rejected at BOTH CLI + lib layers.

Implementation note: reused existing guest tier (no new owner_tier:
'anonymous'); plaintext_advertise is a forward-compat optional manifest
field per ADR 0007 § 4 unknown-fields-allowed convention. Cleaner than
introducing a new tier.

anonymousKey appears in BOTH trimmed AND full /health payloads — the
trimmed payload's purpose is to be readable by anonymous clients so they
can self-bootstrap. Tested.

Startup warns on prereq failure (anonymous_key_advertised_but_denied /
anonymous_key_advertised_but_no_anonymous_key_exists) so the relaxed-
posture failure mode is observable. Graceful-degrade: server still
boots; handleHealth re-checks at request time and silently omits the
field when any prereq fails (defense-in-depth).

## D70 — ADR 0011 (anonymous-key deployment-context limits)

New ADR codifying the trusted-LAN-only invariant.

Trade-off documented: anonymous key advertised via /health = anyone who
can reach the server can read /health and use the key. Acceptable ONLY
when "anyone who can reach the server" ≈ "trusted family on the LAN".
Public-internet deployment = instant compromise.

Soft enforcement: server logs startup warn if BIND_ADDRESS resolves to
a public IP AND advertise_anonymous_key: true. No hard allowlist (TLS-
fronted private networks indistinguishable from public from server's
perspective).

Re-evaluation trigger: any time OLP gains "expose to public internet"
deployment mode (e.g., Cloudflare Tunnel guidance in README), revisit.

References ADR 0007 § 7 (identity classes), ADR 0010 § Phase 4 charter
D68-D70 line, OCP server.mjs:148/1454/1488/1555 (PROXY_ANONYMOUS_KEY
reference).

## Test count

658 → 672 (+14 D68-D70 tests across Suite 34: 5 keys.mjs unit + 6 /health
HTTP integration + 3 CLI integration).

## Scope discipline

NO /v1/messages entry surface (out of Phase 4 per ADR 0010).
NO olp-plugin/ Telegram plugin (D71-D73).
NO docs/integrations/*.md files (D71-D73).
NO CHANGELOG / package.json version bump (Phase 4 close handles versioning;
only package.json bin entry for olp-connect added).
NO new npm deps.

## Authority

- ADR 0010 § Phase 4 D-day plan D68-D70 line
- ADR 0011 (this commit — new ADR)
- ADR 0007 § 4 (manifest forward-compat unknown fields) + § 7 (identity
  classes) + § 9 (keygen flow) — extended by D69 plaintext_advertise
- OCP ocp-connect /Users/taodeng/ocp/ocp-connect (port reference)
- OCP server.mjs:148, 1454, 1488, 1555 (PROXY_ANONYMOUS_KEY reference)
- 2026-05-26 brainstorm (Top 5 OCP inheritance candidates, item 3:
  /health.anonymousKey + olp-connect zero-config UX)

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

* fix: D68-D70 reviewer P1 + P2 fold-in — README impact note + listKeys redaction + schema_version note

Reviewer APPROVE WITH MINOR — 0 P0, 1 P1, 2 P2; all three folded in.

P1 — README impact note for new Phase 4 user-visible surfaces.

Per CLAUDE.md release_kit.new_feature_doc_expectations:
- new env / config knob → README § Environment Variables
- new endpoint or response field → README § API Endpoints
- new CLI surface → dedicated §

README now documents:
- /health.anonymousKey optional field (in API Endpoints table) with cross-
  ref to ADR 0011 + the three-prereq gate
- streaming.heartbeat_interval_ms config (D61) + auth.advertise_anonymous_key
  config (D69) under new "config.json keys introduced at Phase 4" subsection
- Operator CLI surfaces summary: olp / olp-connect / olp-keys keygen
  --anonymous --advertise, with cross-refs to ADR 0010 + 0002 Amendment 7

P2-1 — lib/keys.mjs listKeys() now strips plaintext_advertise alongside
token_hash. Callers wanting the advertised plaintext for the /health
publication path MUST go through findAdvertisedKey() — the only sanctioned
read site. Defends against a future caller of listKeys() leaking the
plaintext into logs / HTTP responses / dashboards. Tests still pass
(no in-repo caller of listKeys depends on plaintext_advertise being
present).

P2-2 — ADR 0011 now documents the schema_version-stays-at-1 decision
explicitly. Additive optional fields don't require bump per ADR 0007 § 4,
but a future archaeologist asking "why didn't D69 bump schema_version?"
now has a one-line answer. Same paragraph documents the listKeys()
redaction policy in plain text alongside the manifest-field contract.

672/672 tests still pass.

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-26 09:15:09 +10:00
e69e908dae feat+test+docs: D64-D67 — olp Node CLI + doctor framework + per-provider doctor checks + ADR 0002 Amendment 7 (#42)
* feat+test+docs: D64+D65+D66+D67 — olp Node CLI + olp doctor framework + per-provider doctor checks + ADR 0002 Amendment 7

Second substantive Phase 4 implementation. 4 D-days bundled per Iron Rule
11 IDR — CLI dispatches to doctor; doctor calls into provider plugins via
the new contract method; ADR amendment authorizes the contract change.
Single PR is the minimum reviewable unit for "does plugin amendment +
plugin impl + doctor consumer line up?"

## D64 — bin/olp.mjs Node CLI scaffold

Operator surface for OLP. Node not bash (per ADR 0010 § Notes — bash with
python3 JSON parsing is a known fragile point; OLP standardizes on Node).

Subcommands:
- status / health / usage / models / cache — HTTP calls to existing endpoints
- providers — local: cross-references models-registry.json + config.json
- chain show [<model>] — local: prints routing.chains from ~/.olp/config.json
- logs [N] [--level X] — reads ~/.olp/logs/audit.ndjson via audit-query
- restart — launchctl (macOS) / systemctl --user (Linux), best-effort
- keys ... — delegates to bin/olp-keys.mjs runCli (no logic duplicated)
- doctor [--check <id|category>] [--json] — D65 framework
- help / --help / -h

Token / URL resolution:
- OLP_PROXY_URL env → OLP_PORT env → http://127.0.0.1:4567 (D60 default)
- OLP_API_KEY env → OLP_OWNER_TOKEN env (filesystem manifest tokens are
  one-way SHA-256 per ADR 0007 § 5 — not recoverable; CLI surfaces
  helpful 401 message pointing at olp-keys keygen)

Output:
- Default: human-readable ANSI-colored text (no chalk dep, auto-suppressed
  under --json)
- --json: raw JSON for scripting
- Exit codes: 0=ok / 1=usage / 2=network|HTTP / 3=auth

No npm deps. Built-ins only.

Installed via package.json bin entry so `npx olp <subcommand>` works.

## D65 — lib/doctor.mjs framework

Ports OCP scripts/doctor.mjs (the bedrock of AI-driven self-repair per the
OCP audit's #2 inheritance candidate). Machine-readable next_action so a
Claude Code / Cursor / etc. agent can self-repair OLP.

Check shape:
  { id, category, async run(): { status: 'ok'|'fail'|'warn', message, evidence? } }

Built-in checks: server.running, server.version, config.exists,
config.providers_enabled, config.chains_configured, auth.owner_key_exists,
system.node_version. Per-provider checks collected dynamically via
provider.doctorChecks() per D67.

--json output:
  { checks: [...], kind: noop|update|fix_oauth|fix_config|fresh_install|
    fix_server|fix_provider, next_action: { ai_executable: [],
    human_required: [], verify: 'olp doctor' }, summary }

--check <id-or-category> for tight repair-loop fast paths.

## D66 — Per-provider doctorChecks() implementations

Each shipped plugin contributes its own checks (lives in plugin file so the
provider's maintainer updates it naturally):

- anthropic.mjs: cli_available (claude --version) + oauth_token_present
  (~/.claude/.credentials.json OR ANTHROPIC_OAUTH_TOKEN env)
- codex.mjs: cli_available (codex --version) + auth_present
  (~/.codex/config.json)
- mistral.mjs: cli_available (vibe --version) + api_key_present
  (MISTRAL_API_KEY env OR ~/.vibe/.env)

Each fail returns evidence.fix_commands (for ai_executable[]) or
evidence.human_required (e.g., 'run: claude auth login').

## D67 — ADR 0002 Amendment 7

New amendment adds OPTIONAL provider.doctorChecks(): DoctorCheck[] to the
Provider contract. Backwards compatible — plugins without doctorChecks()
contribute no provider checks (default behavior). Validator extended in
lib/providers/base.mjs validateProvider.

## Test count

636 → 658 (+22 tests across Suites 32, 33).

- Suite 32 — bin/olp.mjs CLI scaffold (10 tests): parseArgv, USAGE,
  unknown-subcommand, providers local + --json, chain show, status via
  ephemeral server with owner token, ECONNREFUSED → exit 2,
  resolveBearerToken precedence
- Suite 33 — lib/doctor.mjs framework (12 tests): all kind branches
  (noop / fresh_install / fix_server / fix_oauth / fix_provider),
  collectProviderChecks reads doctorChecks(), throwing plugin captured,
  --check filter, built-in checks against temp HOME, anthropic plugin
  probe set, resolveProxyUrl precedence, deriveKind/deriveNextAction units

## Scope discipline

server.mjs UNTOUCHED. All HTTP subcommands consume EXISTING endpoints.
No new endpoints. No /health.anonymousKey. No olp-connect. No Telegram
plugin. No IDE docs bundle. No CHANGELOG / package.json version bump
(Phase 4 close handles versioning; only package.json bin entries updated).

## Known limitations (flagged for reviewer)

- olp restart not unit-tested (would require mocking child_process.spawn
  in invasive way; manual smoke-test only at this D-day)
- olp logs --level filtering matches optional level field if present in
  audit-event objects; appendAuditEvent already populates it where
  meaningful — no schema change needed in this bundle
- olp usage panel shape inferred from lib/audit-query.mjs exports; if
  /v0/management/dashboard-data wire shape differs in subtle ways,
  formatter degrades to '?' but --json always works

## Authority

- ADR 0010 § Phase 4 D-day plan D64-D67 line
- ADR 0002 Amendment 7 (this commit — new amendment)
- OCP ocp bash wrapper /Users/taodeng/ocp/ocp (subcommand reference,
  translated to Node)
- OCP scripts/doctor.mjs /Users/taodeng/ocp/scripts/doctor.mjs
  (framework reference)
- 2026-05-26 brainstorm (Top 5 OCP inheritance candidates, item 2:
  olp doctor machine-readable next_action)

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

* fix: D64-D67 reviewer P2 fold-in — shell-quote ai_executable paths + launchctl kickstart caveat

Reviewer APPROVE — 0 P0/P1, 2 P2 hardening notes folded in.

P2-1 — Shell-quote interpolated paths in fix_commands.

lib/doctor.mjs config.exists fix_commands previously interpolated
${olpHome} / ${configPath} unquoted into the printf template. A
malicious OLP_HOME env value containing shell metacharacters could
inject commands into the suggested-fix string an AI agent (or human)
pastes back into a terminal.

Added _shellQuote(s) helper (POSIX single-quote-wrap with escape for
embedded single quotes per POSIX shell rules). Risk surface is narrow
at family scale (operator local env, single-user proxy), but hardening
cost is one helper.

P2-2 — Document launchctl kickstart -k env-stale pitfall.

cmdRestart header now carries an explicit caveat that `launchctl
kickstart -k` does NOT re-read the plist EnvironmentVariables block —
launchd uses cached env from the most recent bootstrap. This is a
known OCP institutional lesson (PIT INDEX in cc-rules MEMORY.md). The
comment documents the bootout/bootstrap dance for env reloads and
notes that the Phase 4 installer (post-D73) will expose `olp restart
--full` for the safer reload path.

658/658 tests still pass; the _shellQuote change is invisible to
existing tests because the test fixtures use safe paths.

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-26 08:48:20 +10:00
e6701ff698 feat+test: D61+D62+D63 — SSE heartbeat + recentErrors[20] + /v0/management/status (#41)
* feat+test: D61+D62+D63 — SSE heartbeat + recentErrors[20] ring + /v0/management/status

First substantive Phase 4 implementation. Bundle of 3 D-days per Iron Rule 11
IDR rationale: all three converge on the same observability surface (status
endpoint reads recentErrors + provider stats + heartbeat-related counters;
heartbeat shares the streaming branch with recentErrors emission; all live
in server.mjs).

## D61 — SSE heartbeat

Ported from OCP server.mjs:660-685 startHeartbeat() with the OCP db11105
"eager-headers-post-spawn" fix folded in from day one.

- New config field streaming.heartbeat_interval_ms in ~/.olp/config.json
  (default 0 = disabled, matching OCP's safe default)
- When enabled (>0), streaming branch emits `: keepalive\n\n` SSE comment
  every interval_ms ms during silent windows
- Timer resets on every real chunk written
- Cleanup on stream end / error / abort / client disconnect
- SSE_DEFAULT_HEADERS constant centralizes Content-Type / Cache-Control /
  Connection / X-Accel-Buffering: no (the last was the missing OCP lesson
  that broke long streams behind nginx 60s idle)
- Per-attached-client lifecycle (each tee output gets its own timer)
- One heartbeat_active log per stream on first fire; no per-fire log noise

Note: heartbeat NOT wired in the buffered-replay streaming branch because
that branch writes the burst synchronously into the socket buffer — no
silent windows exist there. Inline comment notes this.

## D62 — recentErrors[20] ring buffer

Module-scope bounded ring, surfaced via /v0/management/status at D63.

- _pushError({ error, provider, path, statusCode }) entry shape:
  { time (ISO8601), message (200-char cap), code, provider, path, status_code }
- Filter: only ProviderError OR statusCode >= 500 (401/403 brute-force noise
  excluded — protects ring from auth-probe flooding)
- Path sanitization via .replace(/\/[\w./-]+/g, '[path]') ported from
  OCP server.mjs:1395 — strips internal paths before they leave the proxy
- Wired into 5 server-side error paths: chain-exhausted, pre-first-chunk
  streaming error, mid-stream IR error chunk, fallback-engine programming
  error, router-level unhandled error
- In-memory only (not persisted across restart) per OCP precedent
- Test seam __clearRecentErrors / __snapshotRecentErrors

## D63 — /v0/management/status combined endpoint

OCP /status equivalent, OLP-namespaced per stricter discipline.

- New route GET /v0/management/status, owner-only_block (matches ADR 0007
  § 7 + ADR 0008 Phase 3 management endpoint gating pattern)
- Returns { ok, version, uptime_ms, uptime_human, started_at,
  providers: {enabled, available, status},
  stats: {total_requests, active_requests, cache: cacheStore.stats()},
  recent_errors: [<ring>], generated_at }
- _totalRequests + _activeRequests module-scope counters incremented at top
  of handleChatCompletions; _activeRequests decremented in res.on('close'/
  'finish') with idempotent guard
- Counters NOT exposed via /health (owner-trim intentional there); only via
  /v0/management/status (owner-only_block)
- Reuses _runOwnerOnlyManagementEndpoint helper from D50 Phase 3 work

## Test count

623 → 636 (+13 D61-D63 tests across Suites 29, 30, 31). All 636 pass locally.

## Scope discipline

server.mjs + test-features.mjs + lib/fallback/engine.mjs only (engine.mjs
touched only to extend loadFallbackConfigSync to surface the new
streaming.heartbeat_interval_ms field; no engine behavior change).

Untouched: provider plugins, IR, cache layer, dashboard.html, audit-query,
README, CHANGELOG, package.json. /health payload unchanged. None of the
existing 623 tests regressed.

## Authority

- ADR 0010 § Phase 4 D-day plan (D61-D63 line)
- OCP server.mjs:660-685 (startHeartbeat reference impl)
- OCP commit db11105 (eager-headers-post-spawn fix)
- OCP server.mjs:301, 354-358 (recentErrors ring pattern)
- OCP server.mjs:1151-1188 (/status combined endpoint pattern)
- OCP server.mjs:1395 (error path sanitization)
- ADR 0007 § 7 (identity classes — owner-only_block gating)
- ADR 0008 (management endpoints pattern reused)
- 2026-05-26 brainstorm (Top 5 OCP inheritance candidates, items 1 + 4)

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

* fix: D61-D63 reviewer P2 fold-in — explicit 401/403 filter + null status_code for post-headers

Reviewer APPROVE WITH MINOR — 0 P0/P1, 2 P2 (both about _pushError filter
clarity / defense-in-depth).

P2-1 — explicit 401/403 reject at function level. The current call sites
never invoke _pushError from authenticate() failures (call-site discipline),
but a future contributor passing a ProviderError tagged statusCode=401
would slip past the isProviderError branch and flood the ring under
brute force. Added explicit `if (statusCode === 401 || statusCode === 403)
return;` as defense-in-depth.

P2-2 — pass `statusCode: null` for the two streaming-error-after-first-
chunk _pushError sites instead of `statusCode: 200`. Headers are already
sent so any numeric status is misleading; null + record-by-error-code is
the explicit intent. Avoids a future filter-refactor accidentally
dropping these entries because they look like 200-OK.

Test count unchanged 636/636 pass (filter behavior identical from
call-site perspective; the changes are defensive + intent-clarifying).

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-26 08:23:17 +10:00
0048481764 feat+docs: D60 — Phase 4 charter (ADR 0010) + default OLP_PORT 3456 → 4567 (#40)
* feat+docs: D60 — Phase 4 charter (ADR 0010) + default OLP_PORT 3456 → 4567

Opens Phase 4 (Operator + Client UX) end-to-end. Per release_kit.phase_rolling_mode,
the version bump fires at Phase 4 close (v0.4.0), not at this D-day; D60 only
ships governance + the port default change.

## ADR 0010 — Phase 4 Charter

Phase 4 scope = 5 D-day groups (~13 D-days total):
- D60 (this commit): charter + default port
- D61-D63: SSE heartbeat + recentErrors[20] ring + /status combined endpoint
- D64-D67: olp Node-based CLI scaffold + olp doctor next_action framework
- D68-D70: olp-connect zero-config IDE setup + /health.anonymousKey + ADR 0011
- D71-D73: olp-plugin/ OpenClaw gateway plugin + docs/integrations/*.md bundle

Charter records the EXPLICIT DECISION to DEFER /v1/messages (Anthropic-shape
entry surface) on the rationale: under ADR 0009 P0 failure it provides no
billing benefit AND degrades worse on fallback than OpenAI-shape clients
(because OpenAI tool schema is the cross-provider lingua franca; Anthropic-
specific features cache_control / computer_use / text_editor / thinking
blocks have no clean fallback mapping). Re-open trigger: (a) ADR 0009 P0
success AND (b) maintainer-named family CC user.

README posture updated: Claude Code listed as NOT SUPPORTED as an OLP
client; recommended alternative is "Cline + OLP" (same fallback chain
available, better cross-provider compatibility).

## Default port 3456 → 4567

server.mjs:74 default value moves so OLP and OCP (which stays on 3456) can
co-host on the same machine without OLP_PORT env override. Existing
deployments wanting the pre-D60 default can set OLP_PORT=3456 in launchd
plist / shell env.

Verified port-change invariants:
- All test-features.mjs suites use port: 0 (ephemeral) — 0 test-surface impact
- Cache / fallback / provider plugins port-agnostic
- Dashboard 30s poll + management endpoints use relative paths
- 623/623 tests pass on D60 branch HEAD

## ADR amendments

- ADR 0001 § "Decision" port-conflict paragraph: struck + amended (co-host
  is now possible via 3456 → 4567 + launchd labels dev.olp.proxy vs
  dev.ocp.proxy)
- ADR 0008 § 6.6 default-port reference updated
- docs/adr/README.md index gains ADR 0010 row

## Authority

- ADR 0010 (this commit)
- ADR 0009 (interactive-mode placeholder — /v1/messages defer rationale)
- 2026-05-26 brainstorm: OCP comprehensive feature audit (subagent output)
  + multi-provider proxy / IDE integration prior-art survey (subagent
  output, both this session)
- docs/v1x-roadmap.md (Phase 4 was the named destination)
- CLAUDE.md release_kit.phase_rolling_mode (current_phase already Phase 4;
  this charter formalizes contents)

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

* fix: D60 reviewer P2-1 — soften ADR 0001 launchd-label assertion to forward-tense

Reviewer flagged the ADR 0001 amendment's launchd-label collision claim
("avoided via dev.olp.proxy vs dev.ocp.proxy") as present-tense fact when
the OLP plist generator hasn't shipped yet (lands D64-D70 per ADR 0010).
Soften to forward-tense with explicit cross-reference. The factual
claim ("co-host is possible") still stands because plist label is
controlled by the OLP project anyway; this is precision, not correction.

P2-2 (README "since v0.4.0" forward-dated branding) explicitly accepted
as prior-art-consistent with D44+ Phase 2 mid-window doc conventions —
no change.

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-26 07:59:11 +10:00
33 changed files with 7759 additions and 87 deletions
+148 -1
View File
@@ -4,7 +4,154 @@ All notable changes to OLP land here. Per `CLAUDE.md` release_kit overlay, this
## Unreleased ## Unreleased
(empty — Phase 4 entries land here once Phase 4 opens) (empty — Phase 5 entries land here once Phase 5 opens)
## v0.4.3 — 2026-05-26
### D76 — README install-path overhaul + `OLP_BIND` env + AI-driven install prompt + ADR 0011 amendment
Patch release closing the install-experience gap. v0.4.0v0.4.2 README's Quick Start was placeholder text with fictional commands (`npm install -g @dtzp555-max/olp` — package isn't published; `olp setup` / `olp start` — don't exist). 10 real gaps catalogued + fixed in one D-day; `OLP_BIND` env wired so the documented LAN onboarding flow actually works; AI-driven install prompt added per the Phase 4 charter brainstorm's #2 OCP inheritance candidate (was deferred at D64-D67 to the doctor framework only; D76 closes the README half).
- **G1-G7 (README "Quick Start" was fictional)** — rewrote § "Manual install" with the real sequence: prerequisites (Node ≥ 18 + provider CLI install matrix) → `git clone``npm test` verify → `olp-keys keygen --owner` first → provider OAuth (claude/codex/mistral per-CLI flows) → write `~/.olp/config.json` with the minimum that actually serves traffic → `npm start` → smoke-test → IDE pointing. Each step empirically verified against the PI231 + Mac mini E2E session (2026-05-26).
- **G8 (LAN unreachable — F5)** — added `OLP_BIND` env (default `127.0.0.1`). Operators set `OLP_BIND=0.0.0.0` (or a specific LAN IP) to accept LAN connections so `olp-connect <ip>` can actually reach the server. Pre-D76 the server was hard-coded to `server.listen(PORT, '127.0.0.1', ...)`, making the documented LAN-onboarding flow only usable through an SSH tunnel. ADR 0011's original wording referenced a `BIND_ADDRESS` concept that didn't exist; D76 makes it operational.
- **G10 (no AI-install pattern)** — README § "Install with your AI (the fast path)" added. Verbatim prompt that the operator pastes into Claude Code / Cursor / Copilot / Aider; the AI follows the README + uses `olp doctor --json` machine-readable `next_action.ai_executable[]` (D64-D67) for self-repair, stopping only when `human_required[]` is non-empty (the provider OAuth dances). This closes the Phase 4 brainstorm Top-5 inheritance candidate #2 — the OCP "paste this prompt" pattern that D64-D67 only half-built.
- **Opening compressed** — § "Why OLP" (3 paragraphs of OCP billing history) removed from the top. The OCP-trigger context moved to § "Migration from OCP" at the bottom, condensed into a single paragraph. New users land on value-prop + § "What you get" + § "Install with your AI" / § "Manual install" without needing to digest 2026-05-14 / 2026-06-15 Anthropic billing history first. OCP users get a one-line pointer at the top.
- **§ "Configuration" full schema documentation** — replaced the placeholder with the actual `~/.olp/config.json` schema including every field that v0.4.x reads. Cross-references ADR 0004/0007/0010/0011.
- **§ "Environment Variables" extended** — added `OLP_BIND`, `OLP_API_KEY`, `OLP_OWNER_TOKEN`, `OLP_PROXY_URL` rows that were used throughout the manual-install flow but undocumented.
**ADR 0011 § "Deployment configurations" amendment.** Codifies the three deployment trust contexts (`127.0.0.1` loopback / RFC1918 + tailnet LAN / `0.0.0.0` public — with `advertise_anonymous_key: true` only safe in the first two). Documents the new `anonymous_key_advertised_with_lan_bind` startup warn event. Closes ADR 0011's pre-D76 dangling reference to a non-existent `BIND_ADDRESS`.
**Test count:** 714 (v0.4.2) → 717 (v0.4.3). +3 D76 regression tests in Suite 36 (36s/36t/36u) pinning `OLP_BIND` wiring + safety warn + ADR amendment.
**Out of D76 scope (deferred):**
- F6 (doctor client-side vs server-side check separation) — needs design ADR for a `--remote` mode. Phase 5.
- D75 reviewer P2-1 (ADR 0004 amendment for per-hop schema) + P2-2 (defensive `typeof hopModel === 'string'`) — both genuine follow-ups, neither blocking.
- `scripts/migrate-from-ocp.mjs` — Phase 7.
**Authority:** PI231 + Mac mini E2E session (2026-05-26, post-v0.4.2 verification revealed the 10 README gaps); ADR 0011 amendment self-cites; Phase 4 charter (ADR 0010) Top-5 inheritance candidate #2 (AI-driven self-repair). Process learning: every D-day reviewer rubric should add "open README in §-Quick-Start and verify the commands literally exist + work in the current repo" — would have caught G1-G7 at v0.4.0.
## v0.4.2 — 2026-05-26
### Post-v0.4.1 hotfix batch (D75) — real-machine E2E findings
Patch release fixing 5 bugs caught by **real-machine E2E testing on PI231 + Mac mini (2026-05-26 session)** — bugs that prior D-day reviewers AND the post-v0.4.0 maintainer review both missed because they reviewed against spec text and against the local OLP install's `~/.codex/auth.json` shape (cached from an older codex CLI version), not against real provider CLIs running on a remote operator host that did `npm install -g @openai/codex` for the first time on 2026-05-26 and got codex CLI v0.133.0.
**Root cause of the missed-bug class.** D6 (codex plugin authoring) explicitly documented three unpinned assumptions (A3 = access-token field name, A4 = NDJSON event schema, A2-adjacent = trusted-directory sandbox). D6 noted "D7 E2E will pin." D7 then shipped without performing real-codex-CLI E2E (the E2E gating mark was carried but the actual run was deferred). Every subsequent D-day reviewer trusted the D6/D7 codex plugin code unchanged because the static review couldn't see that the v0.133.0 CLI had moved the auth-token field, the event schema, AND added a new trusted-directory sandbox flag. The D74 maintainer review focused on `/health` / `/cache/stats` / `/v0/management/dashboard-data` payload shapes — none of which exercise the codex plugin's spawn path. F7 (per-hop model override) is a different class of miss — every reviewer read `executeHopFn(provider, model, ir)` and saw `model` consumed for cache key + audit ctx, but none traced through to confirm `model` is ALSO substituted into the IR passed to `provider.spawn()`. The function signature implied per-hop semantics that the body never fully delivered.
- **[F1] codex auth.json schema pin — codex CLI v0.133.0 nests the access token under `tokens.access_token`** (verified empirically on PI231 / Mac mini, 2026-05-26). Pre-D75 `readAuthArtifact()` read only top-level `creds.access_token` / `creds.token` / `creds.accessToken` — all undefined under v0.133.0 → returned `null` → OLP reported "auth artifact missing" via `/health` and `olp doctor` AND refused to spawn codex even when the user had fully completed `codex login`. Fix: prepend `creds?.tokens?.access_token` to the precedence chain at BOTH call sites (`OPENAI_CODEX_AUTH_PATH` override branch + default `$CODEX_HOME/auth.json` branch). Legacy top-level fields preserved as fallback for backward compat with older codex CLI versions.
- **[F2] codex spawn args — codex CLI v0.133.0 trusted-directory sandbox requires `--skip-git-repo-check`.** v0.133.0 refuses with `"Not inside a trusted directory and --skip-git-repo-check was not specified."` when spawned outside a git repo, exits non-zero with zero NDJSON output → OLP surfaces `SPAWN_FAILED` with no usable chunks → the fallback engine advances to next hop unnecessarily even when codex is configured and authenticated. OLP's typical deploy CWD (`~/olp/`) is NOT a git repo on operator hosts. Fix: add `'--skip-git-repo-check'` to the args array before `--model`. OLP is the trusted caller (operator's own server invoking the operator's own subscription via documented `codex exec` automation); the sandbox safeguards interactive shells, not pre-authorized automation.
- **[F3] codex NDJSON event shape pin — codex CLI v0.133.0 emits `item.completed` + `turn.completed` + `turn.failed`**, not the D6-assumed `content`/`delta`/`text` + `type:'stop'`/`done:true` shapes. Real v0.133.0 stream (verified empirically): `{"type":"thread.started",...}``{"type":"turn.started"}``{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"<response>"}}``{"type":"turn.completed","usage":{...}}`. Pre-D75, every chunk was silently dropped by `codexChunkToIR()` → response body had `content: null`. Fix: prepend three new recognizers (`item.completed` with `item.type === 'agent_message'` → IR delta; `turn.completed` → IR stop; `turn.failed` → IR error). Legacy D6 defensive recognizers preserved below as forward/backward compat fallbacks.
- **[F4] `olp status` reads `body.stats.cache.size` (not OCP-era `body.cache.entries`).** Same class as D74 P2-3 (which fixed `cmdUsage` + `cmdCache`); D74 missed the parallel bug in `cmdStatus`. Server payload nests cache stats as `body.stats.cache.{hits, misses, size, inflightCount}` per `server.mjs handleManagementStatus`, and `CacheStore.stats()` has no `entries` field per `lib/cache/store.mjs`. Pre-D75 output showed `entries=?`. Fix: read `c.size` for entries display; also surface `inflightCount` when present.
- **[F7] per-hop chain `model` field now overrides IR model in `provider.spawn()`.** Pre-D75 `executeHopFn(hopProvider, hopModel, irReq)` used `hopModel` for cache key + audit ctx but passed the ORIGINAL `irReq` (with `irReq.model` = user's original request) to `hopProviderPlugin.spawn(irReq, authContext)`. A chain config `[{provider:anthropic, model:claude-X}, {provider:openai, model:gpt-5.5}]` would always spawn BOTH plugins with `--model claude-X` — openai rejected the unknown model and the chain died. This broke the core OLP value prop (cross-provider fallback with provider-appropriate model substitution per hop). Fix: build a per-hop IR variant with `{...irReq, model: hopModel}` and pass that to spawn. Conditional skips clone when `hopModel === irReq.model` (common case: single-provider chains, or single-hop chains where the chain config repeats the request model). Applied to BOTH the buffered path (`executeHopFn`) AND the streaming path (`sourceFactory` for `getOrComputeStreaming`). **Authority:** ADR 0004 § Chain advancement step 1 (per-hop config supplies provider AND model — the contract was always specified, but the code didn't complete it).
**Phase 5 process learning recorded.** Every provider plugin's D-day must include a real-CLI E2E ON A REMOTE OPERATOR HOST before merging — not on the maintainer workstation (which may have an older CLI cached from a prior install, hiding new field renames / new sandbox flags / new event shapes). The D6/D7 codex E2E was deferred and that deferral compounded across 3 layers (D6 = unpinned, D7 = pinning deferred, D8+ = trusted D6/D7 unchanged). F7 reinforces a separate lesson: when a function signature takes `(provider, model, ir)`, reviewers must check that `model` is consumed everywhere downstream, not just at the call site they happened to look at.
**Out of D75 scope (deferred to Phase 5 explicit ADR amendments):**
- F5 (server bind 127.0.0.1 / `OLP_BIND` env) — needs `lib/keys.mjs` anonymous-key trust boundary review before binding to non-loopback by default
- F6 (`olp doctor` client-vs-server-side limit detection) — needs design ADR amendment for trigger taxonomy
- **Test count delta:** 704 (v0.4.1) → 714 (v0.4.2). +10 D75 regression tests in Suite 36 (36i through 36r).
- **Files touched:** `lib/providers/codex.mjs` (F1+F2+F3), `bin/olp.mjs` (F4 cmdStatus), `server.mjs` (F7 buffered + streaming spawn sites), `test-features.mjs` (Suite 36 extension), `package.json` (version), `CHANGELOG.md` (this entry).
- **Authority:** ADR 0002 (provider contract — codex plugin), ADR 0004 (fallback engine — per-hop model contract), `lib/providers/codex.mjs` D6 assumption A2/A3/A4 docstrings (which all said "D7 will pin" and D7 never did); codex CLI v0.133.0 on-disk schema + `codex exec --help` output verified empirically on PI231 (2026-05-26 E2E session); Iron Rule 第二律 evidence-over-should-work; CLAUDE.md `release_kit.phase_rolling_mode` cross-Phase discipline.
## v0.4.1 — 2026-05-26
### Post-Phase-4 hotfix batch (D74) — maintainer-review findings
Patch release fixing 5 issues caught by maintainer post-v0.4.0 independent review. Every finding was a real runtime bug that the per-D-day fresh-context opus reviewers all missed because they reviewed against spec text, not against the runtime contract (default `auth.allow_anonymous: false`, real `/health` payload shape, real `/cache/stats` payload shape, real `/v0/management/dashboard-data` payload shape). **Phase 4 lesson: future implementation D-days MUST include at least one test that boots the server with the default production config and exercises the new feature end-to-end** — not just stub-mocked codepaths.
- **[P1-1] `olp doctor` no longer false-negatives on auth-required `/health`.** `lib/doctor.mjs` now accepts an `authHeaders` option (threaded from `bin/olp.mjs` `cmdDoctor` via the existing `authHeaders()` chain) and passes it to the `server.running` + `server.version` probes. The `server.running` check now distinguishes 401/403 ("server up but bearer token missing/invalid — set `OLP_API_KEY`") from "server unreachable" — so the `kind` discriminator routes to a clean fix-auth path instead of `fix_server` when the operator just forgot to export the env var.
- **[P1-2] `bin/olp-connect` validates token shape + shell-quotes rc writes.** New `validate_olp_token <key> <source>` helper enforces the `^olp_[A-Za-z0-9_-]{43}$` regex (per ADR 0007 § 3 token format) at all 3 input sites: `--key` arg, `/health.anonymousKey` server-advertised consumption, and the interactive prompt fallback. New `shell_quote <value>` helper wraps rc-file writes (`export OPENAI_BASE_URL=$(shell_quote ...)`) so even a hypothetical bypass of the validator can't inject shell metacharacters into a sourced rc. systemd `environment.d/olp.conf` write additionally rejects embedded newlines. Hostile or malformed keys can no longer persist as shell startup injection.
- **[P2-3] `olp usage` + `olp cache` human formatter rewritten against the real payload shape.** `cmdUsage` previously read `body.usage_24h.requests` / `body.providers` / `body.top_fallback_chains` — all undefined under the actual server payload shape — so users saw "requests: ?" + missing per-provider quota + missing top-chains. Now reads `body.window_24h.request_count` / `body.cache_hit_24h.hit_rate` / `body.quota` / `body.top_fallback_chains_24h` per `server.mjs:2027` + `lib/audit-query.mjs`. `cmdCache` previously read `body.entries` / `body.bytes` / `body.maxBytes` (OCP-era field names). Now reads `body.size` / `body.inflightCount` per `CacheStore.stats()` and computes hit rate from `hits + misses`.
- **[P2-4] `olp-plugin/` `fmtHealth` iterates `providers.status` correctly.** Previously walked `Object.entries(body.providers)` which surfaced `enabled` / `available` / `status` as pseudo-providers (chat output showed `🟢 status` instead of `🟢 anthropic`). Now extracts the real provider map from `body.providers.status` and renders enabled/available counts in a header line + per-provider names with `activeSpawns` when present. Falls back to flat `body.providers.*` for the older OCP shape (backwards compat).
- **[P3-5] Stale v0.3.0-era doc strings updated.** README header status line + Implementation Status § now reflect v0.4.0 shipped + Phase 5 open. `server.mjs` startup banner no longer hardcodes "Phase 1 in progress" (now just lists version + provider count — derives accurate state from `VERSION` without future maintenance touch-ups).
**Phase 4 process learning recorded.** Per Iron Rule 第二律 (evidence over "should work"), every D-day review pass must include at least one runtime smoke against the default production config. The D-day reviewer rubric is updated implicitly — D74 Suite 36 tests pin the wire-contract shape so a future D-day refactoring server payloads can't silently re-break the CLI / plugin / docs.
- **Test count delta:** 696 (v0.4.0) → 704 (v0.4.1). +8 D74 regression tests in Suite 36.
- **Files touched:** `lib/doctor.mjs` (P1-1), `bin/olp.mjs` (P1-1 + P2-3), `bin/olp-connect` (P1-2), `olp-plugin/index.js` (P2-4), `server.mjs` (P3-5 banner), `README.md` (P3-5), `test-features.mjs` (Suite 36 regression), `package.json` (version), `CHANGELOG.md` (this entry).
- **Authority:** maintainer independent review of `main` / `v0.4.0` / commit `ee4d945` (2026-05-26 session); Iron Rule 第二律 evidence-over-should-work; CLAUDE.md `release_kit.phase_rolling_mode` cross-Phase discipline ("hotfix to a shipped Phase N deliverable → bump patch, tag, release before next push").
## v0.4.0 — 2026-05-26
### Phase 4 — Operator + Client UX (D60 → D73)
**Overview.** v0.4.0 closes Phase 4 — the "operator + client UX" track that grew OLP from "I built a multi-provider proxy" to "my family can use it without me holding their hand." 5 D-day groups (D60 → D73), ~13 D-days, all under standing-autopilot grant + per-D-day fresh-context opus reviewer per Iron Rule 10. The maintainer-triggered close PR lands all of it under one version tag.
**Test count: 623 (v0.3.2) → 696 (v0.4.0).** +73 tests across the Phase 4 arc.
**Strategic decision recorded:** Phase 4 explicitly DEFERS `/v1/messages` (Anthropic-shape entry surface) per ADR 0010 — re-open strictly gated on ADR 0009 P0 success AND maintainer-named family CC user. README posture: Claude Code listed as Not supported as an OLP client; recommended alternative "Cline + OLP" (same fallback chain available, better cross-provider compatibility because OpenAI tool schema is the multi-provider lingua franca).
**Phase 4 release_kit checklist**
- [x] All 5 D-day groups landed on main (D60 + D61-D63 + D64-D67 + D68-D70 + D71-D73)
- [x] CI green on every D-day merge commit + on this release commit's head
- [x] Fresh-context opus reviewer on every implementation D-day group + per-D-day P0/P1/P2 fold-ins where applicable
- [x] CHANGELOG "Unreleased" promoted to "## v0.4.0 — 2026-05-26"
- [x] `package.json` bumped 0.3.2 → 0.4.0
- [x] `CLAUDE.md release_kit.phase_rolling_mode.current_phase` Phase 4 → Phase 5; `current_pre_release_identifier` `0.4.0-phase4``0.5.0-phase5`
- [x] README § IDE Setup + § Telegram/Discord Usage + § Operator CLI surfaces (env var table extension)
- [x] ADR 0010 (Phase 4 charter), ADR 0011 (anonymous-key deployment-context limits), ADR 0002 Amendment 7 (provider doctorChecks contract) all on disk
- [ ] Tag pushed (next step in this PR's lifecycle)
- [ ] `release.yml` triggered + GitHub Release created (auto on tag push)
---
### D60 (PR #40) — Phase 4 charter (ADR 0010) + default port 3456 → 4567
Opens Phase 4. No functional code change beyond the default port value; substantive D-day work lands D61 onward.
- **Default `OLP_PORT` changed `3456 → 4567`.** OCP defaults to 3456; OLP and OCP can now co-host on the same machine without `OLP_PORT` env override. Tests use `port: 0` ephemeral — no test-surface impact.
- **ADR 0010 (Phase 4 charter) ratified.** Records 5 D-day group scope + explicit DEFER of `/v1/messages` with re-open trigger.
- **ADR 0001 + ADR 0008 amendments.** Port-conflict assumption struck-and-amended; § 6.6 default-port reference updated.
- **README quick start + Environment Variables table + Migration from OCP § note** updated.
### D61-D63 (PR #41) — SSE heartbeat + recentErrors[20] + /v0/management/status
First substantive Phase 4 implementation. 3 D-days bundled per Iron Rule 11 IDR (shared observability surface).
- **SSE heartbeat** via `streaming.heartbeat_interval_ms` config (default `0` = disabled, matches OCP safe default). When enabled, streaming branch emits `: keepalive\n\n` SSE comment every interval during silent windows; resets on real chunk; cleans up on stream end/error/abort/disconnect. Eager-headers-post-spawn from day one (the OCP `db11105` lesson). `X-Accel-Buffering: no` centralized via new `SSE_DEFAULT_HEADERS` constant. Per-attached-client lifecycle (each tee output gets its own timer).
- **`recentErrors[20]` ring buffer.** Module-scope bounded ring, populated from 5 server-side error paths. Filter: only `ProviderError` OR `statusCode >= 500` (401/403 brute-force noise excluded; D61-D63 reviewer P2-1 explicit-401/403-reject fold-in). Path sanitization via OCP `server.mjs:1395` port. In-memory only (per OCP precedent).
- **`GET /v0/management/status` combined endpoint** (owner-only_block). Returns `{ ok, version, uptime_ms, uptime_human, started_at, providers, stats, recent_errors, generated_at }`. `_totalRequests` + `_activeRequests` module-scope counters with idempotent-decrement guard.
- **Authority:** ADR 0010 § D61-D63; OCP `server.mjs:660-685` (startHeartbeat), `301, 354-358` (ring), `1151-1188` (/status), `1395` (path sanitization), commit `db11105` (eager-headers); ADR 0007 § 7 + ADR 0008 (owner-only_block pattern).
- **Test count delta:** 623 → 636 (+13).
### D64-D67 (PR #42) — `olp` Node CLI + `olp doctor` framework + per-provider doctor checks + ADR 0002 Amendment 7
Second substantive Phase 4 implementation. 4 D-days bundled — CLI dispatches to doctor; doctor calls plugins via new contract method; ADR amendment authorizes the contract change.
- **`bin/olp.mjs` Node CLI** with 11 subcommands: `status / health / usage / models / cache / providers / chain show / logs / restart / keys / doctor / help`. Node not bash (per ADR 0010 § Notes — bash's python3 JSON-parsing fragility avoided). Token resolution: `OLP_API_KEY` env → `OLP_OWNER_TOKEN` env → helpful 401 message (filesystem manifest tokens are one-way SHA-256 per ADR 0007 § 5, not recoverable). Output: human-readable ANSI text by default, `--json` for scripting. Exit codes `0=ok / 1=usage / 2=network|HTTP / 3=auth`. Installed via `package.json bin.olp` so `npx olp <subcommand>` works.
- **`lib/doctor.mjs` framework** with machine-readable `next_action.ai_executable[]` for AI-driven self-repair. Per check: `{ id, category, async run(): { status: 'ok'|'fail'|'warn', message, evidence? } }`. Built-in checks: `server.running / server.version / config.exists / config.providers_enabled / config.chains_configured / auth.owner_key_exists / system.node_version`. Per-provider checks dynamically collected via the new `provider.doctorChecks()` contract method. `--json` output emits `{ checks, kind: noop|update|fix_oauth|fix_config|fresh_install|fix_server|fix_provider, next_action: { ai_executable, human_required, verify }, summary }`. `--check <id|category>` for tight repair-loop fast paths. Reviewer P2 fold-in: `_shellQuote()` helper hardens `ai_executable[]` against malicious `OLP_HOME` shell-metacharacter injection.
- **Per-provider `doctorChecks()`** in anthropic / codex / mistral plugins: CLI-availability probe + auth-presence probe. Each fail returns `evidence.fix_commands` (for `ai_executable[]`) or `evidence.human_required`.
- **ADR 0002 Amendment 7** adds OPTIONAL `provider.doctorChecks(): DoctorCheck[]` to the Provider contract — backwards compatible (plugins without it contribute no provider checks).
- **`olp restart`** documented caveat (reviewer P2-2): `launchctl kickstart -k` does NOT re-read plist `EnvironmentVariables`; bootout/bootstrap dance noted for env reloads.
- **Authority:** ADR 0010 § D64-D67; ADR 0002 Amendment 7 (new); OCP `ocp` bash wrapper + `scripts/doctor.mjs` (port references); 2026-05-26 brainstorm Top 5 inheritance candidate #2.
- **Test count delta:** 636 → 658 (+22).
### D68-D70 (PR #43) — `bin/olp-connect` + `/health.anonymousKey` + ADR 0011
Third substantive Phase 4 implementation. 3 D-days bundled — olp-connect consumes /health.anonymousKey; both governed by ADR 0011 trusted-LAN invariant.
- **`bin/olp-connect <host-ip>` (bash, 564 lines)** zero-config LAN client setup. Bash over Node so client machines without recent Node still work. Auto-detects 6 IDEs and configures each: Claude Code (detect + warn — NOT supported per ADR 0010), Cline (print VSCode-settings snippet — manual), Continue.dev (write idempotent `models:` entry to `~/.continue/config.yaml`), Cursor (snippet + WARNING about known base-URL fragility), Aider (write `OPENAI_API_BASE` + `OPENAI_API_KEY` to rc files), OpenClaw (detect + point at `olp-plugin/`). macOS `launchctl setenv` / Linux `~/.config/environment.d/olp.conf` for GUI-app env inheritance. `--dry-run` exercises every state-change site without modifying anything. Idempotent rc-file writes via bracketed `# OLP LAN ... # /OLP LAN` block.
- **`/health.anonymousKey` opt-in field** + `auth.advertise_anonymous_key` config. Field appears in both trimmed AND full `/health` payloads when ALL THREE prerequisites hold: `auth.advertise_anonymous_key: true` + `auth.allow_anonymous: true` + at least one non-revoked guest-tier key has `plaintext_advertise` set. Default off — field ABSENT (not null), preserves v0.3.x `/health` shape. Three-prereq gate is graceful-degrade (server warns + boots; request-time re-checks).
- **`bin/olp-keys keygen --anonymous --advertise`** new flag. Writes plaintext into manifest `plaintext_advertise` field AND prints WARNING + ADR 0011 pointer. Owner-tier rejected at BOTH CLI and lib layers (defense-in-depth). Reviewer P2-1 fold-in: `listKeys()` strips `plaintext_advertise` alongside `token_hash` — callers wanting the advertised plaintext for the `/health` publication path MUST go through `findAdvertisedKey()` (the only sanctioned read site).
- **ADR 0011 (anonymous-key deployment-context)** new ADR codifying the trusted-LAN-only invariant. Threat model explicit; deployment-context table concrete; soft enforcement via startup warn if `BIND_ADDRESS` resolves to public IP AND `advertise_anonymous_key: true`. No hard allowlist (TLS-fronted private networks indistinguishable from public from server's perspective). Re-evaluation triggers named (Cloudflare Tunnel guidance / Phase 5 multi-tenant).
- **Authority:** ADR 0010 § D68-D70; ADR 0011 (new); ADR 0007 § 4 (manifest forward-compat unknown fields) + § 7 (identity classes) + § 9 (keygen flow); OCP `ocp-connect` (port reference); 2026-05-26 brainstorm Top 5 inheritance candidate #3.
- **Test count delta:** 658 → 672 (+14).
### D71-D73 (PR #44) — `olp-plugin/` (OpenClaw /olp Telegram+Discord) + `docs/integrations/*.md` + README cross-refs
Final Phase 4 substantive D-day group. 3 D-days bundled — plugin consumes existing endpoints; integration docs reference plugin + olp CLI + olp-connect together.
- **`olp-plugin/` OpenClaw gateway plugin** (482 lines). Port of OCP `ocp-plugin/index.js` minus mutations. Subcommand parity with `olp` CLI: `/olp status / usage / cache` (owner-only) + `/olp health / models / providers / chain show / doctor / help` (informational). **Explicitly NOT ported** for security: `/olp keys keygen` (chat = brute-force-prone), `/olp keys revoke` (mutation), `/olp restart` (misclick risk), `/olp logs` (PII risk). Port resolution: `OLP_PROXY_URL` env → `OLP_PORT` env → plugin config `proxyUrl``http://127.0.0.1:4567` (D60 default). Output: Telegram/Discord monospace code block with status icons (🟢🟡🔴). Long responses truncated for 4096-char Telegram limit. No npm deps (OpenClaw provides Telegram/Discord transport).
- **`docs/integrations/*.md` bundle** (6 pages + index). Per-IDE setup docs with status icons: Continue.dev ✅, Cline ✅ (cites Cline issue #7128 base-URL UI bug), Cursor ⚠️ (documented base-URL fragility), Aider ✅, **Claude Code ❌** (Anthropic wire format only; recommended alternative "Cline + OLP" per ADR 0010 § /v1/messages defer), OpenClaw ✅. Each ~60-120 lines: status / quick setup / known issues / OLP-specific notes / test-it command. `docs/integrations/README.md` is the index.
- **README updates.** New § "IDE Setup" linking `docs/integrations/README.md`. New § "Telegram / Discord Usage" with install + configure + restart + use. Quick Start mentions `olp-connect <ip>` as family-onboarding command. `package.json files` field extended to include `olp-plugin/` so the published tarball ships it.
- **Authority:** ADR 0010 § D71-D73; OCP `ocp-plugin/index.js` (port reference); 2026-05-26 brainstorm prior-art survey IDE-specific quirks.
- **Test count delta:** 672 → 696 (+24).
---
**Phase 4 close authority chain:** ADR 0010 (charter); CLAUDE.md `release_kit.phase_rolling_mode` (close trigger = explicit maintainer action — fired by maintainer 2026-05-26); standing autopilot grant covering D-day-by-D-day execution; 5 fresh-context opus reviewer passes (one per D-day group); 696/696 tests pass on this release commit head.
## v0.3.2 — 2026-05-25 ## v0.3.2 — 2026-05-25
+2 -2
View File
@@ -135,7 +135,7 @@ release_kit:
# This overlay is the authoritative source. If Iron Rule 5 appears to be silently # This overlay is the authoritative source. If Iron Rule 5 appears to be silently
# violated (no version bump after many D-day pushes), check this section first # violated (no version bump after many D-day pushes), check this section first
# before filing a compliance finding. # before filing a compliance finding.
current_phase: Phase 4 current_phase: Phase 5
current_pre_release_identifier: "0.4.0-phase4" current_pre_release_identifier: "0.5.0-phase5"
phase_close_trigger: explicit maintainer action (not automated) phase_close_trigger: explicit maintainer action (not automated)
``` ```
+306 -38
View File
@@ -1,42 +1,205 @@
# OLP — Open LLM Proxy # OLP — Open LLM Proxy
A personal- and family-scale multi-provider LLM proxy. One HTTP endpoint, many subscriptions behind it, automatic routing, automatic fallback, content-addressed caching — so your IDEs and family clients keep working as long as *any* of your subscriptions has quota left. 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.3.0 shipped (2026-05-25) — Phase 1 multi-provider proxy core (v0.1.0 + v0.1.1) + Phase 2 multi-key auth + audit + owner gating + keygen CLI (v0.2.0) + Phase 3 Dashboard + audit query layer + daily audit rotation (v0.3.0). Phase 4 (per-key per-provider auth + audit retention + SQLite hybrid + provider-cost weights) is the next planned milestone. Sections marked _placeholder_ land alongside the relevant phase of work (see [phase plan](#phase-plan)). > **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).
--- ---
## Why OLP ## What you get
On 2026-05-14, Anthropic announced (effective 2026-06-15) that `claude -p`, the Agent SDK, and third-party agent traffic move out of the Pro/Max subscription pool into a separate fixed monthly Agent SDK Credit pool. [OCP](https://github.com/dtzp555-max/ocp), OLP's predecessor, was a proxy around a single CLI — its core assumption was *"subscription = unlimited within rate limits"*. That assumption breaks for Anthropic on the effective date. - **OpenAI-compatible** `/v1/chat/completions` endpoint — any IDE that speaks OpenAI (Cline / Continue.dev / Cursor / Aider) plugs in
- **Multi-provider chain** — primary fails / quota dies → automatically falls back to the next provider (anthropic ↔ codex ↔ mistral by default; risk-tier framework guards which ones get enabled)
The structural response is to stop relying on one provider's subscription terms remaining favourable. OLP spreads risk across multiple providers whose subscriptions still include CLI/programmatic use, routes intelligently between them, and caches aggressively so every request that does spawn a CLI counts. - **Content-addressed cache** — repeat requests don't re-spawn the CLI; streaming requests dedup via singleflight tee
- **Multi-key auth** — owner key with full visibility, family-member keys with per-key audit log + per-provider scoping
OLP is **not**: a commercial multi-tenant SaaS; an enterprise gateway competing with LiteLLM / OpenCode / CLIProxyAPI on breadth; a model-capability router ("route to the smartest model" — you pick the model); a conversation-state store (your client handles that). - **Telegram / Discord** `/olp` slash commands (read-only — for "is OLP up?" checks from anywhere)
- **AI-driven self-repair**`olp doctor --json` emits machine-readable `next_action.ai_executable[]` so a Claude Code / Cursor / Copilot session can fix install issues for you (see [§ Install with your AI](#install-with-your-ai-the-fast-path))
See [`ALIGNMENT.md`](./ALIGNMENT.md) for OLP's constitution and [`docs/adr/`](./docs/adr/) for the founding ADRs. - **Observability** — owner-only `/dashboard` (quota / 24h stats / 30d spend trend / top fallback chains)
--- ---
## Quick Start ## Install with your AI (the fast path)
_placeholder — lands with Phase 1._ If the manual steps feel like a lot, paste this verbatim into your AI coding assistant (Claude Code / Cursor / Copilot / Aider). It walks you through everything:
Anticipated shape: ```
I want to install OLP (Open LLM Proxy) on this machine. The repo is at
https://github.com/dtzp555-max/olp and ships these tools that you can use:
1. `git clone` the repo to ~/olp
2. README.md § "Manual install" has the sequenced steps. Follow them in order.
3. After EACH step, run `node ~/olp/bin/olp.mjs doctor --json` (NOT `npx olp`
— the package isn't on npm). Parse the JSON. The `kind` field tells you
the state: noop / fresh_install / fix_server / fix_oauth / fix_provider /
fix_config.
4. If `kind != noop`, run the commands listed in `next_action.ai_executable[]`
verbatim. Then re-run doctor to verify.
5. STOP and ask me only when `next_action.human_required[]` is non-empty.
That's where I need to do a browser OAuth flow you can't do for me.
The provider CLIs OLP spawns (claude / codex / vibe) need their own one-time
OAuth — those are the only steps I personally have to do (Claude.ai login,
ChatGPT login, Mistral API key). Everything else (clone, npm install of the
provider CLIs, owner-key generation, config.json bootstrap, server start) is
in your `ai_executable[]` and you should run it without asking.
Begin.
```
Then sit back and respond when it asks for OAuth confirmation. This pattern works because `olp doctor` is purpose-built for AI consumption — every failure mode has a shell-executable repair command AND a human-required step listed separately.
---
## Manual install (5-10 min)
### 0. Prerequisites
- **Node.js ≥ 18.** Verify: `node --version`
- **The provider CLIs you want OLP to spawn.** Install whichever you'll actually use:
| Provider | Install | Subscription |
|---|---|---|
| `anthropic` (`claude -p`) | `npm install -g @anthropic-ai/claude-code` | Claude Pro/Max (OAuth) |
| `openai` (`codex exec`) | `npm install -g @openai/codex` | ChatGPT Plus/Pro (OAuth) or OpenAI API key |
| `mistral` (`vibe --prompt`) | follow the `vibe` install docs | Le Chat Pro API key |
You only need to install the ones you'll route to. Single-provider OLP works fine.
### 1. Clone and verify the test suite
```bash ```bash
# install git clone https://github.com/dtzp555-max/olp.git ~/olp
npm install -g @dtzp555-max/olp cd ~/olp
npm test # 714+ tests, ~5s, no external deps
# run setup (writes ~/.olp/config.json, asks which providers to enable)
olp setup
# start the proxy (default port 3456 — same as OCP if you migrate)
olp start
# point your IDE at http://localhost:3456/v1/chat/completions with the OLP API key from `olp keys list`.
``` ```
(If `npm test` fails here, stop — that means your Node version or the repo state is broken. Don't proceed to step 2.)
### 2. Bootstrap the owner key
The owner key is what you (and `olp-connect`) use to authenticate to OLP. Default config has `auth.allow_anonymous: false`, so you need a key BEFORE the server starts accepting requests.
```bash
node ~/olp/bin/olp-keys.mjs keygen --owner --name=$(whoami)-laptop
# Prints the plaintext token ONCE. Copy it now — you can't recover it later.
# Example: olp_l23-PN46tDljmPATV94-KfOgOBO0Ed8theVjTdAgQoY
```
Export it so the CLI subcommands can use it:
```bash
export OLP_API_KEY=olp_l23-PN46... # paste your real token
```
(Add to `~/.bashrc` / `~/.zshrc` to persist.)
### 3. Authenticate the providers (one-time OAuth)
Run each provider's own login flow. OLP's anthropic / openai / mistral plugins spawn these CLIs and reuse their cached credentials — OLP itself never touches the OAuth dance.
```bash
# Anthropic (Claude Pro/Max subscription)
claude setup-token
# Opens a TUI / prints a URL. Authorize in browser. Paste the returned code.
# Result: ~/.claude/.credentials.json
# OpenAI (ChatGPT subscription)
codex login --device-auth
# Prints a https://auth.openai.com/codex/device URL + 10-char code.
# Open URL in browser, enter code, authorize.
# Result: ~/.codex/auth.json
# Mistral (Le Chat API key)
export MISTRAL_API_KEY=sk-... # add to ~/.bashrc to persist
```
### 4. Write a minimum config
`~/.olp/config.json`:
```json
{
"auth": {
"allow_anonymous": false,
"owner_only_endpoints": [
"/health",
"/v0/management/dashboard-data",
"/v0/management/quota",
"/v0/management/status",
"/cache/stats",
"/dashboard"
],
"fallback_detail_header_policy": "owner_only"
},
"providers": {
"enabled": { "anthropic": true, "openai": true }
},
"routing": {
"chains": {
"claude-sonnet-4-6": [
{ "provider": "anthropic", "model": "claude-sonnet-4-6" },
{ "provider": "openai", "model": "gpt-5.5" }
],
"gpt-5.5": [{ "provider": "openai", "model": "gpt-5.5" }]
}
},
"streaming": { "heartbeat_interval_ms": 15000 }
}
```
(Enable only the providers you actually authenticated in step 3. Chains map `<your-IDE's-requested-model>` → ordered list of `{provider, model}` hops; the chain's per-hop `model` is what gets passed to that provider's CLI.)
### 5. Start the server
```bash
cd ~/olp
npm start
# OLP v0.4.3 listening on :4567 (2 providers enabled)
```
### 6. Smoke-test
```bash
curl -H "Authorization: Bearer $OLP_API_KEY" http://localhost:4567/health | jq
# Expect: {ok: true, providers: {enabled: 2, status: {anthropic: {ok: true...}, openai: {ok: true...}}}}
node ~/olp/bin/olp.mjs doctor
# Expect: "9 of 9 checks passed", kind=noop
```
### 7. Point your IDE at OLP
```
OPENAI_BASE_URL=http://localhost:4567/v1
OPENAI_API_KEY=$OLP_API_KEY
```
Per-IDE configuration details: [`docs/integrations/`](./docs/integrations/README.md).
---
## Family / LAN setup
To let other devices on your home network use the same OLP server, you need TWO things:
1. **Bind to the LAN interface** (not just loopback). On the SERVER:
```bash
OLP_BIND=0.0.0.0 npm start # or your specific LAN IP, e.g. 192.168.1.10
```
Default is `127.0.0.1` (loopback only). See [ADR 0011 § Deployment configurations](./docs/adr/0011-anonymous-key-deployment-context.md#deployment-configurations-d76-amendment-2026-05-26) for the trust-context table — **never set `OLP_BIND=0.0.0.0` on a public-internet-facing host** (use a tunnel like Tailscale instead).
2. **Onboard each family member's device** from THEIR machine:
```bash
bash <(curl -fsSL https://raw.githubusercontent.com/dtzp555-max/olp/main/bin/olp-connect) <olp-host-ip>
```
Detects Cline / Continue.dev / Cursor / Aider / OpenClaw locally and writes per-tool config pointing at your OLP host. Requires `python3` on the client. Prompts for the OLP API key — OR, if the server has `auth.advertise_anonymous_key: true` AND a key was created with `olp-keys keygen --anonymous --advertise`, picks the token up from `/health.anonymousKey` (zero out-of-band paste). See [ADR 0011](./docs/adr/0011-anonymous-key-deployment-context.md) for the trusted-LAN-only invariant.
Per-IDE setup details: [`docs/integrations/`](./docs/integrations/README.md). Telegram / Discord `/olp` slash command setup: [§ Telegram / Discord Usage](#telegram--discord-usage).
--- ---
## Supported Providers ## Supported Providers
@@ -66,12 +229,19 @@ OLP distinguishes **Candidate Providers** (declared as intended, not yet pinned)
## Configuration ## Configuration
_placeholder — full configuration reference lands with Phase 4 (fallback engine)._ OLP reads `~/.olp/config.json` at startup. § "[Manual install § Step 4](#4-write-a-minimum-config)" above has a working minimum example. The full schema:
OLP reads its config from `~/.olp/config.json`. The minimum useful shape:
```json ```json
{ {
"auth": {
"allow_anonymous": false,
"owner_only_endpoints": ["/health", "/dashboard", "/v0/management/..."],
"advertise_anonymous_key": false,
"fallback_detail_header_policy": "owner_only"
},
"providers": {
"enabled": { "<provider-key>": true }
},
"routing": { "routing": {
"chains": { "chains": {
"<requested-model>": [ "<requested-model>": [
@@ -82,13 +252,25 @@ OLP reads its config from `~/.olp/config.json`. The minimum useful shape:
"soft_triggers": { "soft_triggers": {
"<provider-key>": { "<trigger>": <threshold> } "<provider-key>": { "<trigger>": <threshold> }
} }
},
"streaming": {
"heartbeat_interval_ms": 0
} }
} }
``` ```
> **Note:** `routing.soft_triggers` thresholds are parsed and stored but have **no runtime effect at v0.1** — the quota polling path (`quotaStatus()` per hop) is deferred to v1.x per [ADR 0004 Amendment 2](./docs/adr/0004-fallback-engine.md#amendment-2--2026-05-24-soft-triggers-deferred-to-v1x-d22). The evaluation logic exists and is tested; only the production data ingestion path is deferred. Field guide:
Trigger types, fallback safety, idempotency rules, and the full example config land here when Phase 4 ships. See [ADR 0004 (Fallback Engine Semantics & Safety)](./docs/adr/0004-fallback-engine.md) for the design. - **`auth.allow_anonymous`** — default `false`. When false, every request needs a Bearer token; when true, anonymous-tier requests succeed (ADR 0007 § 7). Production posture is `false`.
- **`auth.owner_only_endpoints`** — list of endpoints that REQUIRE owner-tier auth (non-owner returns 401). The defaults above are minimum sane for production.
- **`auth.advertise_anonymous_key`** — default `false`. When true (+ `allow_anonymous: true` + a key created with `olp-keys keygen --anonymous --advertise`), `/health.anonymousKey` exposes the plaintext token so `olp-connect <ip>` is zero-config. **Trusted-LAN only** — see [ADR 0011](./docs/adr/0011-anonymous-key-deployment-context.md).
- **`auth.fallback_detail_header_policy`** — controls `X-OLP-Fallback-Detail` response header emission. `owner_only` (default) only shows tuples to owner identity; debug surface to LAN family without leaking to anonymous.
- **`providers.enabled`** — flip a provider plugin on. Only enable providers whose CLI you've authenticated; OLP doesn't do its own OAuth.
- **`routing.chains`** — keyed by the model name your IDE / client requests. Each entry is an ordered list of fallback hops; each hop's `model` is what gets passed to that provider's CLI. F7 fix (D75) — the hop-level `model` field finally overrides the IR's request model during cross-provider fallback.
- **`routing.soft_triggers`** — parsed and stored but **inert at v0.4.x** — the `quotaStatus()` polling data path is deferred to v1.x per [ADR 0004 Amendment 2](./docs/adr/0004-fallback-engine.md#amendment-2--2026-05-24-soft-triggers-deferred-to-v1x-d22). Startup emits a warn if non-empty so the inert state is visible.
- **`streaming.heartbeat_interval_ms`** — default `0` (disabled). Set > 0 (e.g. `15000`) to emit SSE keepalive frames during silent windows. Required behind reverse proxies (nginx / Cloudflare Tunnel / Tailscale Funnel) with 60s idle aborts.
See [ADR 0004 (Fallback Engine)](./docs/adr/0004-fallback-engine.md), [ADR 0007 (Multi-key auth)](./docs/adr/0007-multi-key-auth.md), [ADR 0010 (Phase 4 charter)](./docs/adr/0010-phase-4-charter-operator-and-client-ux.md), [ADR 0011 (Anonymous-key deployment)](./docs/adr/0011-anonymous-key-deployment-context.md).
--- ---
@@ -98,7 +280,7 @@ Trigger types, fallback safety, idempotency rules, and the full example config l
|---|---|---|---|---| |---|---|---|---|---|
| `/v1/chat/completions` | POST | 1 | ✅ Shipped | OpenAI-compatible Chat Completions entry. Internally normalized to IR, dispatched to a provider plugin, response shape converted back. | | `/v1/chat/completions` | POST | 1 | ✅ Shipped | OpenAI-compatible Chat Completions entry. Internally normalized to IR, dispatched to a provider plugin, response shape converted back. |
| `/v1/models` | GET | 1 | ✅ Shipped | Lists models from `models-registry.json`. | | `/v1/models` | GET | 1 | ✅ Shipped | Lists models from `models-registry.json`. |
| `/health` | GET | 1 | ✅ Shipped | Per-provider health snapshot. Phase 2 owner-only-trim: full per-provider details to owner identity; trimmed `{ ok, version }` to guest / anonymous. Gate via `auth.owner_only_endpoints` config. | | `/health` | GET | 1 | ✅ Shipped | Per-provider health snapshot. Phase 2 owner-only-trim: full per-provider details to owner identity; trimmed `{ ok, version }` to guest / anonymous. Gate via `auth.owner_only_endpoints` config. **Optional `anonymousKey` field (D69 / Phase 4, v0.4.0)** appears in both trimmed and full payloads when `auth.advertise_anonymous_key: true` AND `auth.allow_anonymous: true` AND at least one non-revoked guest-tier key has `plaintext_advertise: true` (see [ADR 0011](./docs/adr/0011-anonymous-key-deployment-context.md) for the trusted-LAN-only invariant). Default off — field absent when prereqs unmet. |
| `/dashboard` | GET | 3 | ✅ Shipped (D50 + D51) | Owner-only multi-provider dashboard HTML (4 panels: quota / 24h request stats / 30d spend trend / top fallback chains; 30s poll with visibilitychange pause). Owner-only_block; non-owner identities receive 401. Localhost-bound by default. | | `/dashboard` | GET | 3 | ✅ Shipped (D50 + D51) | Owner-only multi-provider dashboard HTML (4 panels: quota / 24h request stats / 30d spend trend / top fallback chains; 30s poll with visibilitychange pause). Owner-only_block; non-owner identities receive 401. Localhost-bound by default. |
| `/v0/management/dashboard-data` | GET | 3 | ✅ Shipped (D50) | JSON aggregate consumed by the dashboard 30s poll: `{ generated_at, window_24h, cache_hit_24h, quota, spend_trend_30d, top_fallback_chains_24h, cache_stats }`. Owner-only_block. | | `/v0/management/dashboard-data` | GET | 3 | ✅ Shipped (D50) | JSON aggregate consumed by the dashboard 30s poll: `{ generated_at, window_24h, cache_hit_24h, quota, spend_trend_30d, top_fallback_chains_24h, cache_stats }`. Owner-only_block. |
| `/v0/management/quota` | GET | 3 | ✅ Shipped (D50) | Per-provider quota snapshot via `provider.quotaStatus()` (subset of dashboard-data; useful for scripted monitoring). Owner-only_block. | | `/v0/management/quota` | GET | 3 | ✅ Shipped (D50) | Per-provider quota snapshot via `provider.quotaStatus()` (subset of dashboard-data; useful for scripted monitoring). Owner-only_block. |
@@ -112,11 +294,30 @@ _placeholder — full table lands per-phase as variables are introduced._
| Variable | Default | Description | | Variable | Default | Description |
|---|---|---| |---|---|---|
| `OLP_PORT` | `3456` | HTTP listener port. | | `OLP_PORT` | `4567` | HTTP listener port. Moved off `3456` at D60 / v0.4.0 to co-host with OCP — set `OLP_PORT=3456` to restore the pre-D60 default. |
| `OLP_BIND` | `127.0.0.1` | HTTP listener bind address. **Set to `0.0.0.0` or your LAN IP to accept LAN connections** (required for `olp-connect <ip>` to actually reach the server). Default loopback-only is the secure default. See [ADR 0011 § Deployment configurations](./docs/adr/0011-anonymous-key-deployment-context.md#deployment-configurations-d76-amendment-2026-05-26) for the trust-context table — never bind to a public-internet IP. |
| `OLP_API_KEY` | (none) | Owner-tier OLP API key (the `olp_...` plaintext from `olp-keys keygen --owner`) used by `olp` CLI subcommands as the bearer for management endpoints. |
| `OLP_OWNER_TOKEN` | (none) | Fallback used by `olp` CLI if `OLP_API_KEY` is absent. |
| `OLP_PROXY_URL` | `http://127.0.0.1:$OLP_PORT` | Override target URL for `olp` CLI subcommands (so the same binary works against a remote OLP via SSH tunnel or direct LAN). |
| `OLP_CLAUDE_BIN` | `claude` (from PATH) | Override path to the `claude` binary (Anthropic provider). Useful when multiple `claude` installs are present. | | `OLP_CLAUDE_BIN` | `claude` (from PATH) | Override path to the `claude` binary (Anthropic provider). Useful when multiple `claude` installs are present. |
| `OLP_CODEX_BIN` | `codex` (from PATH) | Override path to the `codex` binary (OpenAI provider). | | `OLP_CODEX_BIN` | `codex` (from PATH) | Override path to the `codex` binary (OpenAI provider). |
| `OLP_VIBE_BIN` | `vibe` (from PATH) | Override path to the `vibe` binary (Mistral provider). | | `OLP_VIBE_BIN` | `vibe` (from PATH) | Override path to the `vibe` binary (Mistral provider). |
### `config.json` keys introduced at Phase 4
These live in `~/.olp/config.json` (not env vars) — they're documented here alongside the env-var table for discoverability.
| Config key | Default | Description |
|---|---|---|
| `streaming.heartbeat_interval_ms` | `0` (disabled) | D61 / Phase 4. SSE keepalive comment frames during stream-silent windows. Set `>0` (e.g. `15000` for 15s) when OLP runs behind nginx / Cloudflare / Tailscale Funnel with idle-abort timeouts. |
| `auth.advertise_anonymous_key` | `false` | D69 / Phase 4. When `true`, surfaces an existing guest-tier key's plaintext via `/health.anonymousKey` so `olp-connect <ip>` can self-bootstrap clients on the LAN with zero out-of-band coordination. **Requires `auth.allow_anonymous: true` AND at least one key created via `olp-keys keygen --anonymous --advertise`.** Trusted-LAN-only — see [ADR 0011](./docs/adr/0011-anonymous-key-deployment-context.md). |
### Operator CLI surfaces (Phase 4)
- `olp` (Node CLI at `bin/olp.mjs`): `status / health / usage / models / cache / providers / chain show / logs / restart / keys / doctor`. Run `npx olp --help` for full subcommand reference. `olp doctor --json` emits a machine-readable `next_action.ai_executable[]` payload designed for AI agents to self-repair OLP. See [ADR 0010](./docs/adr/0010-phase-4-charter-operator-and-client-ux.md) § Phase 4 D-day plan and [ADR 0002 Amendment 7](./docs/adr/0002-plugin-architecture.md) (per-plugin `doctorChecks()` contract).
- `olp-connect` (bash at `bin/olp-connect`): zero-config LAN client setup — detects Cline / Continue.dev / Cursor / Aider / Claude Code / OpenClaw and configures each. Run `bash bin/olp-connect --help`. Requires `python3` for JSON parsing.
- `olp-keys keygen --anonymous --advertise`: creates a guest-tier key with the plaintext stored alongside its hash so `/health.anonymousKey` can publish it. Prints an explicit ADR-0011 warning at keygen time.
### Per-provider auth env vars ### Per-provider auth env vars
These variables configure credential discovery for each provider plugin. Setting the correct one for your provider is usually required for OLP to make successful requests. These variables configure credential discovery for each provider plugin. Setting the correct one for your provider is usually required for OLP to make successful requests.
@@ -164,9 +365,68 @@ If a fallback chain is exhausted, `X-OLP-Fallback-Exhausted` lists the tried pro
--- ---
## Implementation status (as of 2026-05-25, post-v0.2.0) ## IDE Setup
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 (per-key per-provider auth + audit retention + SQLite hybrid + provider-cost weights) is the next planned milestone. This table reflects what is currently shipped vs. what is designed for later phases. Per-tool setup pages live under [`docs/integrations/`](./docs/integrations/README.md). Index:
| Tool | Status | Notes |
|---|---|---|
| [Continue.dev](./docs/integrations/continue.md) | ✅ Supported | `config.yaml` `apiBase` (not `baseURL`); supports OLP custom headers |
| [Cline](./docs/integrations/cline.md) | ✅ Supported | "OpenAI Compatible" provider; watch Cline issue [#7128](https://github.com/cline/cline/issues/7128) |
| [Cursor](./docs/integrations/cursor.md) | ⚠️ Best-effort | "Override OpenAI Base URL" — known fragile across Cursor updates |
| [Aider](./docs/integrations/aider.md) | ✅ Supported | `OPENAI_API_BASE` env + `openai/` model prefix; no custom-header support |
| [Claude Code](./docs/integrations/claude-code.md) | ❌ Not supported | Anthropic wire format only; OLP serves OpenAI wire format. Use Cline + OLP instead |
| [OpenClaw](./docs/integrations/openclaw.md) | ✅ Supported | Telegram + Discord gateway via [`olp-plugin/`](./olp-plugin/) |
The fastest path is `olp-connect <olp-host-ip>` on the client device — it auto-detects what's installed and writes the per-tool config. See [Quick Start](#quick-start).
---
## Telegram / Discord Usage
OLP ships [`olp-plugin/`](./olp-plugin/) as a native OpenClaw gateway plugin. After install, family members get a read-only `/olp` slash command on whichever chat surfaces OpenClaw exposes (Telegram + Discord today).
**Install:**
```bash
# Option A — OpenClaw CLI
openclaw plugins install /path/to/olp/olp-plugin/
# Option B — symlink (equivalent)
mkdir -p ~/.openclaw/extensions/
ln -s /path/to/olp/olp-plugin/ ~/.openclaw/extensions/olp
```
**Configure:** edit `~/.openclaw/openclaw.json` and set the plugin's `apiKey` to an owner-tier OLP token created with:
```bash
npx olp-keys keygen --owner --name=openclaw-bot
```
Use a dedicated bot key — not the maintainer's personal owner key — so revocation is scoped.
```json
{
"plugins": {
"olp": {
"proxyUrl": "http://127.0.0.1:4567",
"apiKey": "olp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
}
}
}
```
**Restart:** `openclaw gateway restart`.
**Use:** `/olp status`, `/olp usage`, `/olp models`, `/olp health`, `/olp cache`, `/olp providers`, `/olp doctor`, `/olp help`.
**Read-only by design.** Mutating subcommands (`keygen`, `revoke`, `restart`, `logs`) are deliberately NOT exposed via chat — those are SSH-only via the local `olp` CLI. See [`olp-plugin/README.md`](./olp-plugin/README.md#what-you-can-not-do-from-chat-by-design) for the rationale.
---
## 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.
| File / artifact | Status | Notes | | File / artifact | Status | Notes |
|---|---|---| |---|---|---|
@@ -201,7 +461,9 @@ Behaviors that work correctly at personal/family scale but have ratified follow-
- **Soft triggers configured but inert.** `routing.soft_triggers` in `~/.olp/config.json` is honored by the engine's evaluation logic but `quotaStatus()` polling is not wired (ADR 0004 Amendment 2). A startup warning fires if the field is non-empty so the inert state is visible. - **Soft triggers configured but inert.** `routing.soft_triggers` in `~/.olp/config.json` is honored by the engine's evaluation logic but `quotaStatus()` polling is not wired (ADR 0004 Amendment 2). A startup warning fires if the field is non-empty so the inert state is visible.
- **Multi-key auth + owner gating + keygen CLI shipped at v0.2.0 (D44 + D45 + D46 + D47).** `lib/keys.mjs` (core), `lib/audit.mjs` (audit), owner-vs-guest `/health` payload trimming + `X-OLP-Fallback-Detail` policy gating, `bin/olp-keys.mjs` (keygen CLI). All 11 ADR 0007 § 10 acceptance criteria covered. v0.2.0 maintainer-merged 2026-05-25. - **Multi-key auth + owner gating + keygen CLI shipped at v0.2.0 (D44 + D45 + D46 + D47).** `lib/keys.mjs` (core), `lib/audit.mjs` (audit), owner-vs-guest `/health` payload trimming + `X-OLP-Fallback-Detail` policy gating, `bin/olp-keys.mjs` (keygen CLI). All 11 ADR 0007 § 10 acceptance criteria covered. v0.2.0 maintainer-merged 2026-05-25.
- **Phase 3 (Dashboard + audit query layer + rotation) shipped to main (D48-D54); v0.3.0 release pending.** `docs/adr/0008-dashboard-and-audit-query.md` ratified at D48. `lib/audit-query.mjs` (D49) implements the 5-function aggregate query API (in-memory ndjson scan, PII-guarded). 4 new owner-only_block endpoints at D50 (`/dashboard`, `/v0/management/dashboard-data`, `/v0/management/quota`, `/cache/stats`). `dashboard.html` full multi-panel UI at D51 (vanilla HTML+JS+fetch, 30s poll with visibilitychange pause). Daily audit rotation at D52 (synchronous on first append after UTC midnight; `audit-YYYY-MM-DD.ndjson` naming) + optional `bin/olp-audit-rotate.mjs` cron tool. `tried_providers` schema semantic fix at D53 (D45 P2 deferral). Phase 3 close to v0.3.0 is maintainer-triggered per CLAUDE.md `release_kit.phase_close_trigger`. - **Phase 3 (Dashboard + audit query layer + rotation) shipped at v0.3.0 (D48D54).** `docs/adr/0008-dashboard-and-audit-query.md` + `lib/audit-query.mjs` (D49) + 4 owner-only_block endpoints (D50) + `dashboard.html` (D51) + daily audit rotation (D52) + `tried_providers` schema fix (D53). All 15 ADR 0008 § 10 acceptance criteria covered.
- **Phase 4 (Operator + Client UX) shipped at v0.4.0 (D60 → D73).** ADR 0010 (charter) + ADR 0011 (anonymous-key trusted-LAN limits) + ADR 0002 Amendment 7 (provider `doctorChecks()` contract). Default `OLP_PORT` 3456 → 4567 so OLP and OCP can co-host. SSE heartbeat (D61) + `recentErrors[20]` + `/v0/management/status` (D62-D63). `bin/olp.mjs` Node CLI + `bin/olp-keys.mjs` + `lib/doctor.mjs` framework with `next_action.ai_executable[]` (D64-D67). `bin/olp-connect` bash zero-config IDE auto-config + opt-in `/health.anonymousKey` (D68-D70). `olp-plugin/` OpenClaw `/olp` Telegram-Discord plugin (read-only, no chat mutations) + 6 IDE integration docs at `docs/integrations/*.md` (D71-D73). Test count 623 → 696.
**Bootstrap workflow (D47):** for first-run / production setup: **Bootstrap workflow (D47):** for first-run / production setup:
@@ -216,7 +478,7 @@ Behaviors that work correctly at personal/family scale but have ratified follow-
npm start npm start
# 4. Validate the key works (substitute the captured plaintext token) # 4. Validate the key works (substitute the captured plaintext token)
curl -H "Authorization: Bearer olp_..." http://localhost:3456/health curl -H "Authorization: Bearer olp_..." http://localhost:4567/health
``` ```
**Recovery if owner token is lost:** `npx olp-keys keygen --owner --force` revokes the previous owner key + creates a fresh one (plaintext printed once). **Recovery if owner token is lost:** `npx olp-keys keygen --owner --force` revokes the previous owner key + creates a fresh one (plaintext printed once).
@@ -263,16 +525,22 @@ Full spec (decision rationale, open questions, risks): `~/.cc-rules/memory/proje
## Migration from OCP ## Migration from OCP
OLP is OCP's successor. The trigger was Anthropic's 2026-05-14 announcement (effective 2026-06-15) splitting `claude -p` / Agent SDK / third-party agent traffic out of the Pro/Max subscription pool into a separate fixed $100/month Agent SDK credit pool — invalidating OCP's foundational assumption (*"subscription = unlimited within rate limits"*) for its only provider. OLP's structural response is to spread risk across multiple subscriptions whose CLI/programmatic use remains in their main subscription pool, with intelligent fallback when one runs out.
Beyond the billing trigger, OLP is intentionally NOT a commercial multi-tenant SaaS (LiteLLM / OpenRouter / Portkey already serve that market with funding + SOC2), NOT an enterprise gateway competing on provider breadth, NOT a model-capability router ("route to the smartest model" — you pick the model in `routing.chains`), and NOT a conversation-state store (your client manages its own context). See [ADR 0001](./docs/adr/0001-project-founding.md) for the founding decision and [`ALIGNMENT.md`](./ALIGNMENT.md) for the constitution that governs every plugin / IR / entry-surface change.
### Migrating an existing OCP install
_placeholder — `scripts/migrate-from-ocp.mjs` lands with Phase 7 (📋 planned, not yet authored)._ _placeholder — `scripts/migrate-from-ocp.mjs` lands with Phase 7 (📋 planned, not yet authored)._
Anticipated user-facing flow (target: <5 minutes): Anticipated user-facing flow (target: <5 minutes):
1. Stop OCP (`launchctl bootout` the OCP service or `ocp stop`). 1. Stop OCP (`launchctl bootout` the OCP service or `ocp stop`).
2. Install OLP. 2. Install OLP (per [§ Manual install](#manual-install-5-10-min) above).
3. Run `olp migrate-from-ocp`copies `~/.ocp/keys/` to `~/.olp/keys/` and points provider plugins at OCP's existing auth artifacts where applicable. 3. Run `olp migrate-from-ocp`will copy `~/.ocp/keys/` to `~/.olp/keys/` and point provider plugins at OCP's existing auth artifacts where applicable.
4. Start OLP. Clients pointing at port 3456 keep working; their existing OLP API keys remain valid. 4. Start OLP. Clients pointing at port 4567 (or 3456 with `OLP_PORT=3456`) keep working; their existing OLP API keys remain valid.
OCP's cache directory is *not* migrated: OLP's cache key format includes provider+model and warms cold naturally. OCP enters maintenance mode (stability fixes only) when OLP v0.1 ships; new development happens in OLP. **Default port moved 3456 → 4567 at v0.4.0** so OCP and OLP can co-host on the same machine during the migration window — set `OLP_PORT=3456` if you want the pre-D60 default. OCP's cache directory is *not* migrated: OLP's cache key format includes provider+model and warms cold naturally. OCP enters maintenance mode (stability fixes only) when OLP v0.1 ships; new development happens in OLP.
--- ---
+618
View File
@@ -0,0 +1,618 @@
#!/usr/bin/env bash
# bin/olp-connect — Lightweight client script to connect this machine to a remote
# OLP (Open LLM Proxy). Ported from OCP's `ocp-connect` per ADR 0010 § Phase 4
# D68-D70 charter; uses /health.anonymousKey when the remote operator opted in
# via `auth.advertise_anonymous_key: true` (ADR 0011).
#
# Authority:
# - ADR 0010 (Phase 4 charter — D68 line: client-side IDE auto-config)
# - ADR 0011 (anonymous-key deployment-context limits — trusted-LAN invariant)
# - OCP `ocp-connect` v1.3.0 (prior-art reference)
#
# Why bash (not Node like `olp` CLI):
# olp-connect MUST run on CLIENT machines that may not have a recent Node
# installed (parents' laptops, work machines, Raspberry Pi). bash + curl +
# python3 give maximum portability; this script does not import any OLP
# Node modules.
#
# Dependencies: bash >=4, curl, python3 (for /health JSON parsing).
#
# Install:
# curl -fsSL https://raw.githubusercontent.com/dtzp555-max/olp/main/bin/olp-connect -o olp-connect
# chmod +x olp-connect
#
# Or via npm/npx (once `npm install -g olp` is run on a machine that has Node):
# olp-connect <ip>
#
# Or run directly via curl-pipe:
# curl -fsSL https://raw.githubusercontent.com/dtzp555-max/olp/main/bin/olp-connect | bash -s -- <host-ip>
set -euo pipefail
OLP_CONNECT_VERSION="0.4.0-phase4"
show_version() {
echo "olp-connect $OLP_CONNECT_VERSION"
}
show_help() {
cat <<'EOF'
olp-connect — Connect this machine to a remote OLP (Open LLM Proxy)
Configures OPENAI_BASE_URL + OPENAI_API_KEY in your shell rc file (and macOS
launchctl env / Linux systemd user env), then detects installed IDEs (Cline,
Continue.dev, Cursor, Aider, OpenClaw, Claude Code) and prints / writes the
provider-specific configuration each needs.
Usage:
olp-connect <host-ip> [options]
olp-connect --help
olp-connect --version
Options:
--port PORT Port OLP listens on (default: 4567 — OLP v0.4.0+ default;
set 3456 if connecting to a pre-D60 OLP install)
--key API_KEY OLP API key (from `olp-keys keygen` on the server). When
omitted, the script reads /health.anonymousKey (if the
server opted in via auth.advertise_anonymous_key=true) or
prompts interactively.
--no-system-env Skip macOS launchctl setenv / Linux systemd env writes;
only update shell rc files.
--dry-run Print everything the script would do without modifying
any file or setting any env var.
--version Print version and exit
--help, -h Show this help
Examples:
olp-connect 192.168.1.10
olp-connect 192.168.1.10 --port 8080
olp-connect 192.168.1.10 --key olp_AbcDef1234...
olp-connect 100.64.0.5 --dry-run
Requires:
bash, curl, python3 (for /health JSON parsing)
Exit codes:
0 success
1 bad arguments / unknown flag / missing required value
2 connectivity or auth failure / smoke test failure
Authority: ADR 0010 § Phase 4 D68-D70; ADR 0011 (anonymous-key trusted-LAN
invariant — when --key is auto-resolved from /health.anonymousKey, this
deployment MUST be on a trusted LAN).
EOF
}
# ── Globals populated by main() ─────────────────────────────────────────────
DRY_RUN=false
NO_SYSTEM_ENV=false
# ── Logging helpers ─────────────────────────────────────────────────────────
log_info() { echo " $*"; }
log_step() { echo " → $*"; }
log_ok() { echo " ✓ $*"; }
log_warn() { echo " ⚠ $*"; }
log_err() { echo " ✗ $*" >&2; }
# Echo a state change (a write / env-set) before executing — operator can
# Ctrl-C if something looks wrong. Returns 0 always.
log_change() { echo " • $*"; }
# ── IDE detection + configuration ───────────────────────────────────────────
# Truncate long keys for display (avoid leaking via screenshot / screen share).
key_display() {
local k="$1"
if [[ -z "$k" ]]; then
echo "(none — anonymous; most IDEs require a non-empty API Key)"
elif [[ ${#k} -gt 16 ]]; then
echo "${k:0:8}...${k: -4}"
else
echo "$k"
fi
}
# D74 P1-2: validate OLP API key format. Per ADR 0007 § 3, tokens are
# `olp_` + 32 random bytes base64url-encoded (43 chars, no padding). This
# regex pins the on-the-wire shape so a malformed or hostile `--key` /
# server-advertised `anonymousKey` never gets persisted into a shell rc.
# Returns 0 on valid, 1 on invalid (with diagnostic to stderr).
validate_olp_token() {
local k="$1" source="$2"
if [[ ! "$k" =~ ^olp_[A-Za-z0-9_-]{43}$ ]]; then
log_err "Rejected $source: token format does not match ^olp_[A-Za-z0-9_-]{43}$ (ADR 0007 § 3)."
log_err " Got ${#k}-char value starting with '$(echo "$k" | cut -c1-8)...'"
log_err " Expected: olp_ followed by 43 base64url chars. Run 'npx olp-keys list' on the server"
log_err " to confirm the key format, or have the operator regenerate with 'npx olp-keys keygen'."
return 1
fi
return 0
}
# D74 P1-2: POSIX shell-quote a value before interpolating into a shell rc
# write. Wraps in single quotes + escapes embedded single quotes per:
# foo'bar → 'foo'\''bar'
# Even with the validator above, this is defense-in-depth: any non-token
# string that slips through (e.g., environment.d KEY=VALUE writes) MUST be
# safe to source. Same helper pattern as lib/doctor.mjs _shellQuote.
shell_quote() {
local s="$1"
# Escape any single quotes: ' → '\''
printf "'%s'" "${s//\'/\'\\\'\'}"
}
# Detect Claude Code and print warn-only message. Per ADR 0010 § Out of
# Phase 4 scope, OLP does NOT ship /v1/messages and CC is not a supported
# client. The user is steered toward Cline + OLP.
detect_claude_code() {
if command -v claude &>/dev/null; then
log_info ""
log_info "Detected: Claude Code (`command -v claude`)"
log_warn "Claude Code is NOT supported as an OLP client (OLP does not ship"
log_warn " /v1/messages — see ADR 0010 § Out-of-Phase-4-scope)."
log_warn " Recommended alternative: install Cline (VSCode extension) + OLP."
log_warn " Cline uses OpenAI-shape /v1/chat/completions which OLP DOES serve."
fi
}
# Detect Cline VSCode extension and print manual-configure snippet.
# Cline cannot be auto-configured via env vars — user must paste into VSCode
# settings UI. We surface the values for them.
detect_cline() {
local base_url="$1" key="$2"
local exts=""
if command -v code &>/dev/null; then
exts=$(code --list-extensions 2>/dev/null || true)
fi
if [[ -z "$exts" && -d "$HOME/.vscode/extensions" ]]; then
exts=$(ls "$HOME/.vscode/extensions/" 2>/dev/null || true)
fi
if echo "$exts" | grep -qiE 'cline|saoudrizwan\.claude-dev'; then
log_info ""
log_info "Detected: Cline (VSCode extension)"
log_info " Cline must be configured via the VSCode settings UI."
log_info " Open VSCode → Cline panel → Settings → API Provider:"
log_info " API Provider: \"OpenAI Compatible\""
log_info " Base URL: $base_url/v1"
log_info " API Key: $(key_display "$key")"
log_info " Model ID: claude-sonnet-4-5 (or any model from /v1/models)"
fi
}
# Detect Continue.dev and write a `models:` entry to ~/.continue/config.yaml
# (idempotent — checks if an entry with the same name exists first).
detect_continue() {
local base_url="$1" key="$2"
local exts=""
if command -v code &>/dev/null; then
exts=$(code --list-extensions 2>/dev/null || true)
fi
local config_yaml="$HOME/.continue/config.yaml"
local config_json="$HOME/.continue/config.json"
local found=false
if echo "$exts" | grep -qi 'continue\.continue'; then found=true; fi
if [[ -f "$config_yaml" || -f "$config_json" ]]; then found=true; fi
if ! $found; then return 0; fi
log_info ""
log_info "Detected: Continue.dev"
log_info " Configuration snippet for ~/.continue/config.yaml:"
log_info " models:"
log_info " - name: OLP Sonnet"
log_info " provider: openai"
log_info " model: claude-sonnet-4-5"
log_info " apiBase: $base_url/v1"
log_info " apiKey: $(key_display "$key")"
log_info " Note: Continue.dev autoreload-on-save is fragile; restart VSCode if"
log_info " the new model doesn't appear in the model selector."
}
# Detect Cursor and print manual snippet + known-fragility warning.
detect_cursor() {
local base_url="$1" key="$2"
local found=false
if command -v cursor &>/dev/null; then found=true; fi
if [[ -d "$HOME/.cursor" ]]; then found=true; fi
if [[ -d "/Applications/Cursor.app" ]]; then found=true; fi
if ! $found; then return 0; fi
log_info ""
log_info "Detected: Cursor"
log_info " Cmd+Shift+P → 'Cursor Settings' → Models:"
log_info " OpenAI API Key: $(key_display "$key")"
log_info " Override OpenAI Base URL: $base_url/v1"
log_info " Custom OpenAI Models: claude-sonnet-4-5,claude-opus-4-1"
log_warn " Cursor's base-URL handling is known-fragile (issue #7128 et al);"
log_warn " if requests fail with 'malformed request', try removing then"
log_warn " re-adding the model in the Cursor models list."
}
# Detect Aider and write OPENAI_API_BASE / OPENAI_API_KEY to rc files.
# Aider reads these env vars at startup — already handled by the rc-file
# block in main(). We just announce detection here.
detect_aider() {
if command -v aider &>/dev/null; then
log_info ""
log_info "Detected: Aider (`command -v aider`)"
log_info " Aider reads OPENAI_API_BASE + OPENAI_API_KEY from env."
log_info " These are already being written to your shell rc — open a fresh"
log_info " shell and run: aider --model openai/claude-sonnet-4-5"
fi
}
# Detect OpenClaw. Per Phase 4 D71-D73 (NOT in this PR), olp will ship
# olp-plugin/ for OpenClaw with full Telegram/Discord /olp slash commands.
# Until that ships, we just announce detection and link.
detect_openclaw() {
if command -v openclaw &>/dev/null || [[ -f "$HOME/.openclaw/openclaw.json" ]]; then
log_info ""
log_info "Detected: OpenClaw"
log_info " The OpenClaw OLP plugin (D71-D73) is NOT YET SHIPPED."
log_info " When it ships, install with: openclaw plugin install olp"
log_info " For now, you can manually point OpenClaw at OLP via the OPENAI_BASE_URL"
log_info " env var (already written to your shell rc above)."
fi
}
# ── rc-file helpers ─────────────────────────────────────────────────────────
# Identify which shell rc files to write to. Returns paths on stdout, one per line.
detect_rc_files() {
local is_mac=false
[[ "$(uname)" == "Darwin" ]] && is_mac=true
if [[ "${SHELL:-}" == */fish ]]; then
log_warn "fish shell detected; writing to ~/.bashrc — add to fish config manually." >&2
echo "$HOME/.bashrc"
return
fi
if $is_mac; then
# macOS Catalina+ default shell is zsh
[[ -f "$HOME/.bashrc" ]] && echo "$HOME/.bashrc"
[[ -f "$HOME/.zshrc" ]] || { $DRY_RUN || touch "$HOME/.zshrc"; }
echo "$HOME/.zshrc"
else
[[ -f "$HOME/.bashrc" || "${SHELL:-}" == */bash ]] && echo "$HOME/.bashrc"
[[ -f "$HOME/.zshrc" || "${SHELL:-}" == */zsh ]] && echo "$HOME/.zshrc"
fi
}
# Remove any previously-written OLP block from an rc file (idempotent).
# The block is bracketed by:
# # OLP LAN (added by olp-connect) ... # /OLP LAN
strip_olp_block() {
local rc_file="$1"
[[ -f "$rc_file" ]] || return 0
if $DRY_RUN; then
if grep -qF '# OLP LAN (added by olp-connect)' "$rc_file" 2>/dev/null; then
log_change "[dry-run] would strip existing OLP block from $rc_file"
fi
return 0
fi
python3 - "$rc_file" <<'PYEOF'
import sys
path = sys.argv[1]
try:
with open(path) as f:
lines = f.readlines()
except OSError:
sys.exit(0)
out = []
skip = False
for line in lines:
s = line.rstrip('\n')
if s == '# OLP LAN (added by olp-connect)':
skip = True
continue
if skip and s == '# /OLP LAN':
skip = False
continue
if skip:
continue
out.append(line)
with open(path, 'w') as f:
f.writelines(out)
PYEOF
}
# Append a new OLP block to an rc file.
append_olp_block() {
local rc_file="$1" base_url="$2" key="$3"
if $DRY_RUN; then
log_change "[dry-run] would append OLP block to $rc_file:"
log_change " # OLP LAN (added by olp-connect)"
log_change " export OPENAI_BASE_URL=$(shell_quote "$base_url/v1")"
[[ -n "$key" ]] && log_change " export OPENAI_API_KEY=$(shell_quote "$(key_display "$key")")"
log_change " # /OLP LAN"
return 0
fi
# D74 P1-2: shell-quote values before writing to rc files. Defense-in-depth
# alongside validate_olp_token — even if a future code path bypasses the
# validator, the rc file remains safe to source.
{
echo ""
echo "# OLP LAN (added by olp-connect)"
echo "export OPENAI_BASE_URL=$(shell_quote "$base_url/v1")"
if [[ -n "$key" ]]; then
echo "export OPENAI_API_KEY=$(shell_quote "$key")"
fi
echo "# /OLP LAN"
} >> "$rc_file"
}
# ── System-level env (macOS launchctl / Linux systemd user) ────────────────
set_system_env() {
local base_url="$1" key="$2"
if $NO_SYSTEM_ENV; then
log_info "Skipping system-level env (--no-system-env)"
return 0
fi
if [[ "$(uname)" == "Darwin" ]]; then
if $DRY_RUN; then
log_change "[dry-run] would launchctl setenv OPENAI_BASE_URL=$base_url/v1"
[[ -n "$key" ]] && log_change "[dry-run] would launchctl setenv OPENAI_API_KEY=$(key_display "$key")"
return 0
fi
launchctl setenv OPENAI_BASE_URL "$base_url/v1" 2>/dev/null || log_warn "launchctl setenv OPENAI_BASE_URL failed"
if [[ -n "$key" ]]; then
launchctl setenv OPENAI_API_KEY "$key" 2>/dev/null || log_warn "launchctl setenv OPENAI_API_KEY failed"
fi
log_ok "launchctl setenv applied (visible to GUI apps + daemons)"
log_info " Note: launchctl env vars reset on reboot. Re-run olp-connect after restart"
log_info " or add the script to Login Items."
else
local env_dir="$HOME/.config/environment.d"
if $DRY_RUN; then
log_change "[dry-run] would write $env_dir/olp.conf"
return 0
fi
mkdir -p "$env_dir" 2>/dev/null
# D74 P1-2: systemd environment.d format is KEY=VALUE per line. While
# systemd does its own parsing (no shell sourcing), reject embedded
# newlines defensively — validate_olp_token already enforces the
# restricted charset for the API key, so this is belt-and-braces.
if [[ "$base_url" == *$'\n'* || "$key" == *$'\n'* ]]; then
log_err "Refusing to write environment.d entry: value contains newline."
return 2
fi
{
echo "OPENAI_BASE_URL=$base_url/v1"
if [[ -n "$key" ]]; then
echo "OPENAI_API_KEY=$key"
fi
} > "$env_dir/olp.conf"
log_ok "Wrote $env_dir/olp.conf (applies to systemd user services after re-login)"
fi
}
# ── Main ────────────────────────────────────────────────────────────────────
main() {
local host="" port=4567 key=""
# Parse args (POSIX-style; --flag value AND --flag=value both accepted)
while [[ $# -gt 0 ]]; do
case "$1" in
--port) port="${2:?--port requires a value}"; shift 2 ;;
--port=*) port="${1#*=}"; shift ;;
--key) key="${2:?--key requires a value}"
[[ -z "$key" ]] && { log_err "--key cannot be empty (omit --key for zero-config / auto-discovery)"; exit 1; }
# D74 P1-2: reject malformed --key before it ever reaches an rc write.
validate_olp_token "$key" "--key flag" || exit 1
shift 2 ;;
--key=*) key="${1#*=}"
[[ -z "$key" ]] && { log_err "--key cannot be empty (omit --key for zero-config / auto-discovery)"; exit 1; }
validate_olp_token "$key" "--key flag" || exit 1
shift ;;
--no-system-env) NO_SYSTEM_ENV=true; shift ;;
--dry-run) DRY_RUN=true; shift ;;
--version) show_version; exit 0 ;;
--help|-h) show_help; exit 0 ;;
--*) log_err "Unknown option: $1"; show_help >&2; exit 1 ;;
*) host="$1"; shift ;;
esac
done
if [[ -z "$host" ]]; then
log_err "host IP is required."
echo "" >&2
show_help >&2
exit 1
fi
if ! [[ "$host" =~ ^[a-zA-Z0-9._-]+$ ]]; then
log_err "invalid host '$host'"
exit 1
fi
# Dependency check
for cmd in curl python3; do
if ! command -v "$cmd" &>/dev/null; then
log_err "'$cmd' is required but not found in PATH."
[[ "$cmd" == "python3" ]] && log_err " python3 is used for /health + /v1/models JSON parsing."
exit 1
fi
done
local base_url="http://$host:$port"
echo "olp-connect v$OLP_CONNECT_VERSION"
echo "─────────────────────────────────────"
log_info "Remote: $base_url"
$DRY_RUN && log_info "Mode: DRY RUN (no files will be modified, no env vars will be set)"
echo ""
# Step 1: connectivity probe. We capture status separately from body so we
# can distinguish "TCP/HTTP unreachable" from "reached but 401" (the latter
# is a known surface when the server has auth.allow_anonymous=false AND
# auth.advertise_anonymous_key=false — user MUST provide --key).
log_step "Probing /health..."
local probe_body probe_status
probe_body=$(curl -s --max-time 5 -o /tmp/olp-connect-health.$$ -w "%{http_code}" "$base_url/health" 2>/dev/null || echo "000")
probe_status="$probe_body"
if [[ -f /tmp/olp-connect-health.$$ ]]; then
probe_body=$(cat /tmp/olp-connect-health.$$ 2>/dev/null || echo "")
rm -f /tmp/olp-connect-health.$$
fi
if [[ "$probe_status" == "000" ]]; then
log_err "Cannot reach $base_url/health (connection refused / timeout / DNS)"
log_err " Ensure OLP is running on $host and bound to 0.0.0.0 (LAN mode)."
log_err " Default port changed 3456 → 4567 at OLP v0.4.0; pass --port 3456 for older installs."
exit 2
fi
if [[ "$probe_status" == "401" ]]; then
log_warn "Server reachable but /health returned 401."
log_warn " Either the operator has not enabled auth.advertise_anonymous_key, or"
log_warn " the server requires auth (auth.allow_anonymous=false)."
if [[ -z "$key" ]]; then
log_err " Pass --key olp_... to continue, or ask the operator to advertise an anonymous key (see ADR 0011)."
exit 2
fi
# If user supplied --key, we proceed without /health body (auth-required mode).
log_info " Proceeding with the --key you supplied; skipping /health.anonymousKey discovery."
local health_json=""
local remote_version="?"
else
if [[ "$probe_status" != "200" ]]; then
log_err "/health returned HTTP $probe_status (expected 200 or 401)."
exit 2
fi
local health_json="$probe_body"
local remote_version
remote_version=$(echo "$health_json" | python3 -c "import sys,json
try: print(json.loads(sys.stdin.read()).get('version','?'))
except: print('?')" 2>/dev/null || echo "?")
log_ok "Connected — OLP v$remote_version"
fi
# Step 2: auth resolution
if [[ -z "$key" ]]; then
# Try /health.anonymousKey first (D69 / ADR 0011 opt-in).
local anon_key
anon_key=$(echo "$health_json" | python3 -c "import sys,json
try:
d = json.loads(sys.stdin.read())
k = d.get('anonymousKey')
print(k if isinstance(k, str) and k else '')
except: print('')" 2>/dev/null || echo "")
if [[ -n "$anon_key" ]]; then
# 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
log_err "Refusing to consume malformed advertised key. Use --key explicitly or contact the OLP operator."
exit 2
fi
key="$anon_key"
log_ok "Using server-advertised anonymous key: $(key_display "$key")"
log_info " (set by remote via auth.advertise_anonymous_key=true; see ADR 0011 for"
log_info " the trusted-LAN-only invariant — this assumes you and the remote are"
log_info " on the same trusted network)"
else
# No advertised key; prompt interactively (skip in dry-run for non-TTY safety)
if $DRY_RUN; then
log_info "[dry-run] would prompt for API key here (no --key + no anonymousKey)"
key="<prompted-at-runtime>"
else
echo ""
log_info "Remote does not advertise an anonymous key."
log_info "Ask the OLP operator to run on the server: olp-keys keygen --name <your-label>"
printf " Enter OLP API key: "
{ read -rs key </dev/tty; } 2>/dev/null || key=""
echo
if [[ -z "$key" ]]; then
log_err "No key provided and the remote did not advertise an anonymous key."
log_err " Re-run with: olp-connect $host --key olp_..."
exit 2
fi
# D74 P1-2: also validate the interactively-prompted key.
validate_olp_token "$key" "interactive prompt" || exit 1
fi
fi
fi
# Step 3: smoke test /v1/models
log_step "Smoke-testing /v1/models..."
if $DRY_RUN && [[ "$key" == "<prompted-at-runtime>" ]]; then
log_info "[dry-run] skipping smoke test (no real key)"
else
local models_out models_ok=0
if [[ -n "$key" ]]; then
models_out=$(curl -sf --max-time 10 \
-H "Authorization: Bearer $key" \
"$base_url/v1/models" 2>/dev/null) && models_ok=1
else
models_out=$(curl -sf --max-time 10 "$base_url/v1/models" 2>/dev/null) && models_ok=1
fi
if [[ $models_ok -eq 0 ]]; then
log_err "/v1/models request failed — key may be invalid, revoked, or not allowed for any provider."
exit 2
fi
local model_count
model_count=$(echo "$models_out" | python3 -c "import sys,json
try: print(len(json.loads(sys.stdin.read()).get('data', [])))
except: print('?')" 2>/dev/null || echo "?")
log_ok "/v1/models OK — $model_count models available"
fi
echo ""
# Step 4: write shell rc files
log_step "Writing shell rc files..."
local rc_files=()
while IFS= read -r line; do
[[ -n "$line" ]] && rc_files+=("$line")
done < <(detect_rc_files)
if [[ ${#rc_files[@]} -eq 0 ]]; then
log_warn "No shell rc files detected; falling back to ~/.bashrc"
rc_files=("$HOME/.bashrc")
fi
for rc_file in "${rc_files[@]}"; do
log_change "stripping old OLP block from $(basename "$rc_file") (idempotent)"
strip_olp_block "$rc_file"
log_change "appending new OLP block to $(basename "$rc_file")"
append_olp_block "$rc_file" "$base_url" "$key"
done
log_ok "Shell rc files updated:"
for rc_file in "${rc_files[@]}"; do
log_info " $rc_file"
done
echo ""
# Step 5: system-level env (macOS launchctl / Linux systemd)
log_step "Setting system-level env..."
set_system_env "$base_url" "$key"
echo ""
# Step 6: IDE detection + per-IDE config
log_step "Detecting installed IDEs..."
detect_claude_code
detect_cline "$base_url" "$key"
detect_continue "$base_url" "$key"
detect_cursor "$base_url" "$key"
detect_aider
detect_openclaw
echo ""
# Step 7: final summary
log_step "Done."
log_info "OLP base URL: $base_url/v1"
log_info "OLP API key: $(key_display "$key")"
log_info ""
log_info "Test it: open a fresh shell, run:"
log_info " curl -sf -H \"Authorization: Bearer \$OPENAI_API_KEY\" $base_url/v1/models | python3 -m json.tool | head -20"
log_info ""
log_info "Reload your current shell to apply env changes:"
for rc_file in "${rc_files[@]}"; do
log_info " source $rc_file"
done
}
main "$@"
+33 -4
View File
@@ -79,6 +79,7 @@ const USAGE = `OLP key management CLI
Usage: Usage:
olp-keys keygen --owner [--name=<label>] [--providers=<csv>] [--force] olp-keys keygen --owner [--name=<label>] [--providers=<csv>] [--force]
olp-keys keygen --name=<label> [--tier=guest|owner] [--providers=<csv>] olp-keys keygen --name=<label> [--tier=guest|owner] [--providers=<csv>]
olp-keys keygen --anonymous --advertise [--name=<label>] [--providers=<csv>]
olp-keys list [--owner-only] [--include-revoked] olp-keys list [--owner-only] [--include-revoked]
olp-keys revoke --id=<key-id> olp-keys revoke --id=<key-id>
@@ -86,7 +87,8 @@ Common flags:
--olp-home=<path> Override ~/.olp (default reads OLP_HOME env) --olp-home=<path> Override ~/.olp (default reads OLP_HOME env)
--help Print this message --help Print this message
Authority: ADR 0007 § 9 (bootstrap & recovery).`; Authority: ADR 0007 § 9 (bootstrap & recovery); ADR 0011 (anonymous-key
deployment-context limits trusted-LAN-only invariant for --advertise).`;
// ── Subcommand implementations ──────────────────────────────────────────── // ── Subcommand implementations ────────────────────────────────────────────
@@ -94,16 +96,36 @@ async function cmdKeygen(flags, ioOut, ioErr) {
const olpHome = flags['olp-home']; const olpHome = flags['olp-home'];
const owner = flags.owner === true; const owner = flags.owner === true;
const force = flags.force === true; const force = flags.force === true;
// D69 (ADR 0011): --anonymous is shorthand for "create a guest-tier key
// intended to be the zero-config /health.anonymousKey advertise key".
// It implies --tier=guest and defaults the name to 'anonymous'. The
// distinct field that actually triggers /health advertisement is
// --advertise (writes plaintext_advertise into the manifest). Either
// flag works on its own (--anonymous without --advertise is just a
// conventionally-named guest key); --advertise without --anonymous is
// accepted (operator may want to advertise a named guest key).
const isAnonymous = flags.anonymous === true;
const advertise = flags.advertise === true;
let tier = flags.tier; let tier = flags.tier;
if (owner) tier = 'owner'; if (owner) tier = 'owner';
if (isAnonymous && !owner) tier = 'guest';
if (!tier) tier = 'guest'; if (!tier) tier = 'guest';
if (tier !== 'owner' && tier !== 'guest') { if (tier !== 'owner' && tier !== 'guest') {
ioErr(`Error: --tier must be "owner" or "guest" (got "${tier}").\n`); ioErr(`Error: --tier must be "owner" or "guest" (got "${tier}").\n`);
return 1; return 1;
} }
const name = flags.name || (owner ? 'owner' : null); // D69: reject --owner --advertise (would expose owner identity unauthenticated).
if (advertise && tier !== 'guest') {
ioErr(`Error: --advertise requires guest tier (cannot advertise owner-tier key plaintext). See ADR 0011.\n`);
return 1;
}
let name = flags.name;
if (!name) { if (!name) {
ioErr('Error: --name is required (or use --owner to default to "owner").\n'); if (owner) name = 'owner';
else if (isAnonymous) name = 'anonymous';
}
if (!name) {
ioErr('Error: --name is required (or use --owner to default to "owner", or --anonymous to default to "anonymous").\n');
return 1; return 1;
} }
const providersFlag = flags.providers; const providersFlag = flags.providers;
@@ -136,7 +158,7 @@ async function cmdKeygen(flags, ioOut, ioErr) {
let result; let result;
try { try {
result = createKey({ name, owner_tier: tier, providers_enabled, olpHome }); result = createKey({ name, owner_tier: tier, providers_enabled, olpHome, plaintext_advertise: advertise });
} catch (err) { } catch (err) {
ioErr(`Error: createKey failed: ${err?.message ?? err}\n`); ioErr(`Error: createKey failed: ${err?.message ?? err}\n`);
return 2; return 2;
@@ -150,6 +172,13 @@ async function cmdKeygen(flags, ioOut, ioErr) {
ioOut(` providers_enabled: ${typeof result.manifest.providers_enabled === 'string' ? result.manifest.providers_enabled : `[${result.manifest.providers_enabled.join(', ')}]`}\n`); ioOut(` providers_enabled: ${typeof result.manifest.providers_enabled === 'string' ? result.manifest.providers_enabled : `[${result.manifest.providers_enabled.join(', ')}]`}\n`);
ioOut(` created_at: ${result.manifest.created_at}\n`); ioOut(` created_at: ${result.manifest.created_at}\n`);
ioOut(` manifest: ~/.olp/keys/${result.id}/manifest.json\n`); ioOut(` manifest: ~/.olp/keys/${result.id}/manifest.json\n`);
if (advertise) {
// D69 (ADR 0011): explicit warning when plaintext lands on disk + opt-in surface.
ioOut(` advertise: YES — plaintext stored in manifest; surfaced via /health.anonymousKey\n`);
ioErr(`\n WARNING: this key's plaintext is now stored on disk + will be exposed via\n`);
ioErr(` /health.anonymousKey when auth.advertise_anonymous_key=true AND\n`);
ioErr(` auth.allow_anonymous=true. Use ONLY on a trusted LAN. See ADR 0011.\n`);
}
ioOut(`\n token (plaintext): ${result.plaintext_token}\n\n`); ioOut(`\n token (plaintext): ${result.plaintext_token}\n\n`);
ioOut(` Pass via: Authorization: Bearer ${result.plaintext_token.slice(0, 12)}...\n`); ioOut(` Pass via: Authorization: Bearer ${result.plaintext_token.slice(0, 12)}...\n`);
ioOut(` or: x-api-key: ${result.plaintext_token.slice(0, 12)}...\n\n`); ioOut(` or: x-api-key: ${result.plaintext_token.slice(0, 12)}...\n\n`);
Executable
+774
View File
@@ -0,0 +1,774 @@
#!/usr/bin/env node
/**
* bin/olp.mjs OLP operator CLI (Phase 4 / D64)
*
* Authority: ADR 0010 § Phase 4 D64-D67. Ports OCP's `ocp` bash wrapper
* (https://github.com/dtzp555-max/ocp /ocp) to Node.js, eliminating the
* python3 JSON-parsing fragility called out in the ADR.
*
* Subcommands:
* status GET /v0/management/status (owner-only)
* health GET /health
* usage GET /v0/management/dashboard-data (owner-only)
* models GET /v1/models
* cache GET /cache/stats (owner-only)
* providers local: models-registry + config providers.enabled
* chain show [<model>] local: ~/.olp/config.json routing.chains
* logs [N] [--level X] local: read ~/.olp/logs/audit.ndjson via audit-query
* restart launchctl (macOS) / systemctl --user (Linux)
* doctor [--check X] run lib/doctor.mjs runDoctor + format
* keys ... delegate to bin/olp-keys.mjs
* help | --help usage
*
* Global flags:
* --json emit raw JSON (silences human-readable output)
* --proxy-url=<url> override resolved proxy URL
* --olp-home=<path> override ~/.olp
*
* URL resolution:
* OLP_PROXY_URL env (full URL) http://127.0.0.1:${OLP_PORT || 4567}
*
* Auth (Bearer token) resolution:
* 1. OLP_API_KEY env
* 2. OLP_OWNER_TOKEN env (synthetic env-owner per ADR 0007 § 9.4)
* 3. Most recently used active owner-tier key from listKeys() plaintext NOT recoverable from disk
*
* Note: the third option only works during the same session in which `olp-keys
* keygen --owner` was run if the operator captured the token + set OLP_OWNER_TOKEN.
* listKeys() returns manifests; manifest.token_hash is one-way. The CLI therefore
* reports "no owner token available" + remediation instructions if env vars are
* absent it does NOT try to crack the hash.
*
* Exit codes:
* 0 = success
* 1 = bad usage / unknown subcommand
* 2 = network or HTTP error (4xx/5xx)
* 3 = auth missing / forbidden
*/
import { request as httpRequest } from 'node:http';
import { request as httpsRequest } from 'node:https';
import { URL } from 'node:url';
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { spawn as spawnProc } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { realpathSync } from 'node:fs';
import { runDoctor, resolveProxyUrl, resolveOlpHome } from '../lib/doctor.mjs';
import { listKeys } from '../lib/keys.mjs';
import { readAuditWindow } from '../lib/audit-query.mjs';
import modelsRegistry from '../models-registry.json' with { type: 'json' };
import { runCli as runKeysCli } from './olp-keys.mjs';
// ── ANSI helpers (no chalk dep) ───────────────────────────────────────────
const ANSI = {
reset: '\x1b[0m',
bold: '\x1b[1m',
dim: '\x1b[2m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
cyan: '\x1b[36m',
gray: '\x1b[90m',
};
function colorize(s, code, useColor) {
if (!useColor) return s;
return `${code}${s}${ANSI.reset}`;
}
function statusBadge(status, useColor) {
const map = {
ok: { txt: 'PASS', col: ANSI.green },
warn: { txt: 'WARN', col: ANSI.yellow },
fail: { txt: 'FAIL', col: ANSI.red },
};
const m = map[status] ?? { txt: String(status).toUpperCase(), col: ANSI.gray };
return colorize(`[${m.txt}]`, m.col, useColor);
}
// ── Arg parser (mirror bin/olp-keys.mjs shape) ────────────────────────────
export function parseArgv(argv) {
const positional = [];
const flags = {};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a.startsWith('--')) {
const eq = a.indexOf('=');
if (eq > 0) {
flags[a.slice(2, eq)] = a.slice(eq + 1);
} else {
const name = a.slice(2);
const next = argv[i + 1];
if (next !== undefined && !next.startsWith('--')) {
flags[name] = next;
i++;
} else {
flags[name] = true;
}
}
} else {
positional.push(a);
}
}
return { positional, flags };
}
// ── Output helpers (respect --json) ───────────────────────────────────────
function makeIO(opts) {
const out = opts.out ?? (s => process.stdout.write(s));
const err = opts.err ?? (s => process.stderr.write(s));
const wantJson = opts.json === true;
// When wantJson, the only stdout writer used is `emitJson`. `log` becomes a no-op
// (debug noise suppression per the bundle requirements). `errln` always writes
// to stderr.
const log = (...parts) => { if (!wantJson) out(parts.join(' ') + '\n'); };
const errln = (...parts) => err(parts.join(' ') + '\n');
const emitJson = (obj) => out(JSON.stringify(obj, null, 2) + '\n');
return { log, errln, emitJson, wantJson, useColor: !wantJson && (opts.useColor ?? true) };
}
// ── HTTP helper ───────────────────────────────────────────────────────────
async function httpFetch(url, { method = 'GET', headers = {}, timeoutMs = 15000 } = {}) {
return new Promise(resolve => {
let done = false;
const finish = (v) => { if (!done) { done = true; resolve(v); } };
let urlObj;
try { urlObj = new URL(url); }
catch (e) { return finish({ ok: false, error: `invalid url: ${e?.message ?? e}` }); }
const isHttps = urlObj.protocol === 'https:';
const reqFn = isHttps ? httpsRequest : httpRequest;
let req;
try {
req = reqFn(url, { method, headers, timeout: timeoutMs }, res => {
let data = '';
res.on('data', c => { data += c; });
res.on('end', () => finish({ ok: true, status: res.statusCode, body: data, headers: res.headers }));
});
} catch (e) {
return finish({ ok: false, error: String(e?.message ?? e) });
}
req.on('error', e => finish({ ok: false, error: String(e?.message ?? e), code: e?.code }));
req.on('timeout', () => {
try { req.destroy(new Error(`timeout after ${timeoutMs}ms`)); } catch { /* ignore */ }
});
req.end();
});
}
// ── Token resolution ──────────────────────────────────────────────────────
/**
* Resolve a Bearer token. Returns the plaintext token string or null.
* Precedence: OLP_API_KEY OLP_OWNER_TOKEN null (manifests are one-way hashed).
*/
export function resolveBearerToken() {
if (process.env.OLP_API_KEY) return process.env.OLP_API_KEY;
if (process.env.OLP_OWNER_TOKEN) return process.env.OLP_OWNER_TOKEN;
return null;
}
function authHeaders() {
const tok = resolveBearerToken();
return tok ? { Authorization: `Bearer ${tok}` } : {};
}
// ── HTTP-error → exit code mapping ────────────────────────────────────────
function httpErrorToExit(res, io) {
if (!res.ok) {
if (res.code === 'ECONNREFUSED' || (res.error && res.error.includes('ECONNREFUSED'))) {
io.errln(`Error: OLP server unreachable (${res.error}). Is it running?`);
io.errln(`Hint: run 'npx olp restart' (or 'npm start' for foreground) — see 'npx olp doctor' for the full diagnostic.`);
return 2;
}
io.errln(`Error: network error: ${res.error}`);
return 2;
}
if (res.status === 401) {
io.errln(`Error: 401 unauthorized — set OLP_API_KEY env (Bearer token) or OLP_OWNER_TOKEN.`);
io.errln(`Hint: 'npx olp-keys keygen --owner' creates a new owner-tier key (capture the plaintext token).`);
return 3;
}
if (res.status === 403) {
io.errln(`Error: 403 forbidden — current key is not owner-tier (this endpoint is owner-only).`);
return 3;
}
if (res.status >= 400) {
io.errln(`Error: HTTP ${res.status}: ${res.body.slice(0, 200)}`);
return 2;
}
return 0;
}
// ── Human-readable formatters ─────────────────────────────────────────────
function formatBytes(n) {
if (typeof n !== 'number' || !Number.isFinite(n)) return '?';
if (n < 1024) return `${n}B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}K`;
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)}M`;
return `${(n / 1024 / 1024 / 1024).toFixed(1)}G`;
}
function formatMs(ms) {
if (typeof ms !== 'number' || !Number.isFinite(ms)) return '?';
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
if (ms < 3600000) return `${Math.floor(ms / 60000)}m${Math.floor((ms % 60000) / 1000)}s`;
return `${Math.floor(ms / 3600000)}h${Math.floor((ms % 3600000) / 60000)}m`;
}
// ── Subcommand: status ────────────────────────────────────────────────────
async function cmdStatus(flags, io) {
const url = resolveProxyUrl({ proxyUrl: flags['proxy-url'] });
const res = await httpFetch(`${url}/v0/management/status`, { headers: authHeaders() });
const ec = httpErrorToExit(res, io);
if (ec !== 0) return ec;
let body;
try { body = JSON.parse(res.body); }
catch { io.errln('Error: server returned non-JSON body'); return 2; }
if (io.wantJson) { io.emitJson(body); return 0; }
io.log(colorize('OLP status', ANSI.bold, io.useColor));
io.log('─'.repeat(60));
io.log(` version: ${body.version}`);
io.log(` uptime: ${body.uptime_human} (${formatMs(body.uptime_ms)})`);
io.log(` started: ${body.started_at}`);
io.log(` providers: ${body.providers?.enabled ?? '?'} enabled / ${body.providers?.available ?? '?'} available`);
if (body.providers?.status && typeof body.providers.status === 'object') {
for (const [name, s] of Object.entries(body.providers.status)) {
const okIcon = s?.ok ? colorize('ok', ANSI.green, io.useColor) : colorize('FAIL', ANSI.red, io.useColor);
io.log(` - ${name.padEnd(12)} ${okIcon} ${s?.error ? `(${s.error})` : ''}`);
}
}
io.log(` total reqs: ${body.stats?.total_requests ?? 0}`);
io.log(` active reqs: ${body.stats?.active_requests ?? 0}`);
// D75 F4 fix: server payload nests cache stats under stats.cache (per
// server.mjs handleManagementStatus, ~line 2092). The CacheStore.stats()
// contract returns { hits, misses, size, inflightCount } per
// lib/cache/store.mjs — there is no `entries` field. Pre-D75 cmdStatus read
// `body.stats.cache.entries` (OCP-era) which was always undefined → output
// showed "entries=?". Same pattern as D74 P2-3 applied to cmdCache/cmdUsage.
if (body.stats?.cache) {
const c = body.stats.cache;
io.log(` cache: hits=${c.hits ?? 0} misses=${c.misses ?? 0} entries=${c.size ?? 0}${typeof c.inflightCount === 'number' ? ` inflight=${c.inflightCount}` : ''}`);
}
if (Array.isArray(body.recent_errors) && body.recent_errors.length > 0) {
io.log(` recent errors: ${body.recent_errors.length}`);
for (const e of body.recent_errors.slice(0, 5)) {
io.log(` - [${e.at ?? '?'}] ${e.provider ?? '?'} ${e.path ?? '?'}${(e.message ?? '').slice(0, 80)}`);
}
}
return 0;
}
// ── Subcommand: health ────────────────────────────────────────────────────
async function cmdHealth(flags, io) {
const url = resolveProxyUrl({ proxyUrl: flags['proxy-url'] });
const res = await httpFetch(`${url}/health`, { headers: authHeaders() });
const ec = httpErrorToExit(res, io);
if (ec !== 0) return ec;
let body;
try { body = JSON.parse(res.body); }
catch { io.errln('Error: server returned non-JSON body'); return 2; }
if (io.wantJson) { io.emitJson(body); return 0; }
io.log(colorize(`OLP /health → ${res.status}`, ANSI.bold, io.useColor));
io.log('─'.repeat(60));
for (const [k, v] of Object.entries(body)) {
if (typeof v === 'object' && v !== null) {
io.log(` ${k}:`);
for (const [k2, v2] of Object.entries(v)) {
io.log(` ${k2}: ${typeof v2 === 'object' ? JSON.stringify(v2) : v2}`);
}
} else {
io.log(` ${k}: ${v}`);
}
}
return 0;
}
// ── Subcommand: usage ─────────────────────────────────────────────────────
async function cmdUsage(flags, io) {
const url = resolveProxyUrl({ proxyUrl: flags['proxy-url'] });
const res = await httpFetch(`${url}/v0/management/dashboard-data`, { headers: authHeaders() });
const ec = httpErrorToExit(res, io);
if (ec !== 0) return ec;
let body;
try { body = JSON.parse(res.body); }
catch { io.errln('Error: server returned non-JSON body'); return 2; }
if (io.wantJson) { io.emitJson(body); return 0; }
// D74 P2-3 fix: server payload shape is { generated_at, window_24h: { request_count, status_2xx,
// status_4xx, status_5xx, by_provider, by_owner_tier, by_path, median_latency_ms, p95_latency_ms },
// cache_hit_24h: { total, hit, miss, bypass, streaming_attached, hit_rate, by_provider }, quota: [{provider, ...}],
// spend_trend_30d: [{date, request_count, by_provider}], top_fallback_chains_24h: [{chain, count, ...}],
// cache_stats: { hits, misses, size, inflightCount } } per server.mjs:2027 + lib/audit-query.mjs.
io.log(colorize('OLP usage (24h)', ANSI.bold, io.useColor));
io.log('─'.repeat(60));
const w24 = body.window_24h ?? {};
const cache24 = body.cache_hit_24h ?? {};
if (typeof w24 === 'object' && (w24.request_count ?? 0) > 0) {
io.log(` requests: ${w24.request_count}`);
io.log(` 2xx / 4xx / 5xx: ${w24.status_2xx ?? 0} / ${w24.status_4xx ?? 0} / ${w24.status_5xx ?? 0}`);
if (typeof w24.median_latency_ms === 'number') {
io.log(` latency p50/p95: ${w24.median_latency_ms}ms / ${w24.p95_latency_ms ?? 0}ms`);
}
if (typeof cache24.hit_rate === 'number') {
const pct = (cache24.hit_rate * 100).toFixed(1);
io.log(` cache hit rate: ${pct}% (hit=${cache24.hit ?? 0} miss=${cache24.miss ?? 0}${cache24.streaming_attached ? ` streaming_attached=${cache24.streaming_attached}` : ''})`);
}
} else {
io.log(' (no 24h usage data — server may not have processed any requests yet)');
}
if (Array.isArray(body.quota) && body.quota.length > 0) {
io.log('');
io.log(colorize('Per-provider quota', ANSI.bold, io.useColor));
io.log('─'.repeat(60));
for (const p of body.quota) {
const label = String(p.provider ?? '?').padEnd(12);
if (p.error) {
io.log(` ${label} error: ${p.error}`);
} else if (typeof p.percent_used === 'number') {
io.log(` ${label} ${p.percent_used}% used${p.resets_in_human ? ` (resets in ${p.resets_in_human})` : ''}`);
} else if (p.available === false) {
io.log(` ${label} unavailable`);
} else {
io.log(` ${label} no quota api`);
}
}
}
if (Array.isArray(body.top_fallback_chains_24h) && body.top_fallback_chains_24h.length > 0) {
io.log('');
io.log(colorize('Top fallback chains (24h)', ANSI.bold, io.useColor));
io.log('─'.repeat(60));
for (const f of body.top_fallback_chains_24h.slice(0, 10)) {
io.log(` ${String(f.count ?? '?').padStart(5)} ${(f.chain ?? []).join(' → ')}`);
}
}
return 0;
}
// ── Subcommand: models ────────────────────────────────────────────────────
async function cmdModels(flags, io) {
const url = resolveProxyUrl({ proxyUrl: flags['proxy-url'] });
const res = await httpFetch(`${url}/v1/models`, { headers: authHeaders() });
const ec = httpErrorToExit(res, io);
if (ec !== 0) return ec;
let body;
try { body = JSON.parse(res.body); }
catch { io.errln('Error: server returned non-JSON body'); return 2; }
if (io.wantJson) { io.emitJson(body); return 0; }
io.log(colorize(`OLP models (${(body.data ?? []).length})`, ANSI.bold, io.useColor));
io.log('─'.repeat(60));
for (const m of body.data ?? []) {
io.log(` ${m.id.padEnd(35)} ${colorize(`(${m.owned_by ?? '?'})`, ANSI.gray, io.useColor)}`);
}
return 0;
}
// ── Subcommand: cache ─────────────────────────────────────────────────────
async function cmdCache(flags, io) {
const url = resolveProxyUrl({ proxyUrl: flags['proxy-url'] });
const res = await httpFetch(`${url}/cache/stats`, { headers: authHeaders() });
const ec = httpErrorToExit(res, io);
if (ec !== 0) return ec;
let body;
try { body = JSON.parse(res.body); }
catch { io.errln('Error: server returned non-JSON body'); return 2; }
if (io.wantJson) { io.emitJson(body); return 0; }
// D74 P2-3 fix: cacheStore.stats() returns { hits, misses, size, inflightCount }
// per lib/cache/store.mjs:320. There is no entries / evictions / bytes / maxBytes
// in the OLP cache model — those were OCP-era field names. Compute a hit rate from
// the numerator/denominator instead of fabricating bytes.
const hits = body.hits ?? 0;
const misses = body.misses ?? 0;
const denom = hits + misses;
const hitRate = denom > 0 ? ((hits / denom) * 100).toFixed(1) : '0.0';
io.log(colorize('OLP cache (live in-memory)', ANSI.bold, io.useColor));
io.log('─'.repeat(60));
io.log(` entries: ${body.size ?? 0}`);
io.log(` hits / misses: ${hits} / ${misses} (hit rate ${hitRate}%)`);
io.log(` inflight: ${body.inflightCount ?? 0}`);
if (body.generated_at) io.log(` generated_at: ${body.generated_at}`);
return 0;
}
// ── Subcommand: providers (local) ─────────────────────────────────────────
function cmdProviders(flags, io) {
const olpHome = resolveOlpHome({ olpHome: flags['olp-home'] });
const configPath = join(olpHome, 'config.json');
let enabled = {};
try {
const cfg = JSON.parse(readFileSync(configPath, 'utf8'));
enabled = cfg?.providers?.enabled ?? {};
} catch { /* fine — empty enabled map */ }
const providers = modelsRegistry?.providers ?? {};
const rows = [];
for (const [name, p] of Object.entries(providers)) {
rows.push({
name,
displayName: p?.displayName ?? name,
tier: p?.tier ?? '?',
modelCount: (p?.models ?? []).length,
enabled: enabled[name] === true,
candidate: p?.candidate === true,
});
}
if (io.wantJson) {
io.emitJson({ providers: rows, config_path: configPath });
return 0;
}
io.log(colorize(`OLP providers (${rows.length} in registry)`, ANSI.bold, io.useColor));
io.log('─'.repeat(60));
for (const r of rows) {
const enabledTxt = r.enabled
? colorize('enabled ', ANSI.green, io.useColor)
: colorize('disabled', ANSI.gray, io.useColor);
const candTxt = r.candidate ? colorize('(candidate)', ANSI.yellow, io.useColor) : '';
io.log(` ${r.name.padEnd(10)} ${enabledTxt} tier ${r.tier} models ${String(r.modelCount).padStart(2)} ${candTxt}`);
}
io.log('');
io.log(colorize(`config: ${configPath}`, ANSI.dim, io.useColor));
return 0;
}
// ── Subcommand: chain show ────────────────────────────────────────────────
function cmdChainShow(positional, flags, io) {
const target = positional[0] ?? null; // model name, or null = print all
const olpHome = resolveOlpHome({ olpHome: flags['olp-home'] });
const configPath = join(olpHome, 'config.json');
let chains = {};
try {
const cfg = JSON.parse(readFileSync(configPath, 'utf8'));
chains = cfg?.routing?.chains ?? {};
} catch { /* empty */ }
if (io.wantJson) {
if (target) {
io.emitJson({ model: target, chain: chains[target] ?? null });
} else {
io.emitJson({ chains });
}
return 0;
}
io.log(colorize('OLP routing.chains', ANSI.bold, io.useColor));
io.log('─'.repeat(60));
if (Object.keys(chains).length === 0) {
io.log(` (no chains configured in ${configPath})`);
return 0;
}
if (target) {
const chain = chains[target];
if (!chain) {
io.errln(`Error: model "${target}" not in routing.chains (configured: ${Object.keys(chains).join(', ')}).`);
return 1;
}
io.log(` ${target}:`);
for (const hop of chain) {
io.log(`${typeof hop === 'string' ? hop : JSON.stringify(hop)}`);
}
} else {
for (const [model, chain] of Object.entries(chains)) {
io.log(` ${model}:`);
for (const hop of chain) {
io.log(`${typeof hop === 'string' ? hop : JSON.stringify(hop)}`);
}
}
}
return 0;
}
// ── Subcommand: logs ──────────────────────────────────────────────────────
async function cmdLogs(positional, flags, io) {
const n = positional[0] ? parseInt(positional[0], 10) : 20;
if (!Number.isFinite(n) || n <= 0) {
io.errln(`Error: invalid log count "${positional[0]}"`);
return 1;
}
const olpHome = resolveOlpHome({ olpHome: flags['olp-home'] });
// readAuditWindow is a generator over [startMs, endMs). Default window = last 24h.
const windowMs = flags['window-ms'] ? parseInt(flags['window-ms'], 10) : 24 * 3600 * 1000;
const endMs = Date.now();
const startMs = endMs - windowMs;
let events = [];
try {
for (const ev of readAuditWindow({ startMs, endMs, olpHome })) {
events.push(ev);
}
} catch (e) {
io.errln(`Error: readAuditWindow failed: ${e?.message ?? e}`);
return 2;
}
let filtered = events;
if (flags.level) {
filtered = filtered.filter(e => e.level === flags.level);
}
// Tail (audit events are already chronological per generator order).
filtered = filtered.slice(-n);
if (io.wantJson) {
io.emitJson({ events: filtered });
return 0;
}
io.log(colorize(`OLP logs (last ${filtered.length} of ${events.length}${flags.level ? `, level=${flags.level}` : ''})`, ANSI.bold, io.useColor));
io.log('─'.repeat(60));
for (const e of filtered) {
// Audit event shape per lib/audit.mjs: { ts, event, ...data }. `level` is
// not always present in audit ndjson (it is in stderr-side logEvent).
const level = (e.level ?? 'info').toUpperCase();
const levelColor =
level === 'ERROR' ? ANSI.red
: level === 'WARN' ? ANSI.yellow
: ANSI.gray;
const summary = e.message ?? e.error ?? '';
io.log(` ${colorize(level.padEnd(5), levelColor, io.useColor)} ${e.ts ?? '?'} ${e.event ?? '?'} ${summary}`);
}
return 0;
}
// ── Subcommand: restart ───────────────────────────────────────────────────
async function cmdRestart(flags, io) {
// macOS: launchctl kickstart -k gui/$(id -u)/dev.olp.proxy
// Linux: systemctl --user restart olp-proxy
// Neither installed → fall through to a helpful error.
//
// CAVEAT (D64-D67 reviewer P2-2; known OCP institutional lesson per
// ~/.cc-rules/memory/auto/MEMORY.md PIT INDEX): `launchctl kickstart -k`
// does NOT re-read the plist's EnvironmentVariables block — launchd
// sticks to its cached env from the most recent bootstrap. If you edited
// ~/Library/LaunchAgents/dev.olp.proxy.plist's env, this subcommand will
// silently use stale values. Use `launchctl bootout gui/<uid>/dev.olp.proxy`
// followed by `launchctl bootstrap gui/<uid> ~/Library/LaunchAgents/dev.olp.proxy.plist`
// to force a clean env reload. The Phase 4 installer (planned post-D73)
// will expose `olp restart --full` for the bootout/bootstrap dance.
const platform = process.platform;
const uid = process.getuid?.() ?? null;
let cmd, args;
if (platform === 'darwin') {
if (uid == null) {
io.errln('Error: cannot resolve UID on this platform; cannot drive launchctl');
return 2;
}
cmd = 'launchctl';
args = ['kickstart', '-k', `gui/${uid}/dev.olp.proxy`];
} else if (platform === 'linux') {
cmd = 'systemctl';
args = ['--user', 'restart', 'olp-proxy'];
} else {
io.errln(`Error: platform "${platform}" not supported for 'olp restart'.`);
io.errln(`Hint: run 'npm start' (or whatever launches your OLP server) manually.`);
return 2;
}
// Spawn + wait for exit; bubble up any error.
const result = await new Promise(resolve => {
const child = spawnProc(cmd, args, { stdio: io.wantJson ? 'ignore' : 'inherit' });
child.on('error', e => resolve({ code: -1, error: e }));
child.on('exit', code => resolve({ code }));
});
if (result.code === 0) {
if (io.wantJson) {
io.emitJson({ ok: true, cmd, args });
} else {
io.log(colorize(`Restart issued (${cmd} ${args.join(' ')})`, ANSI.green, io.useColor));
}
return 0;
}
if (result.error?.code === 'ENOENT' || result.code === 127) {
io.errln(`Error: '${cmd}' not found on this system.`);
if (platform === 'darwin') {
io.errln(`Hint: no launchd service 'dev.olp.proxy' installed; run 'npm start' manually.`);
} else {
io.errln(`Hint: no systemd user unit 'olp-proxy' installed; run 'npm start' manually.`);
}
return 2;
}
io.errln(`Error: ${cmd} ${args.join(' ')} exited with code ${result.code}`);
return 2;
}
// ── Subcommand: doctor ────────────────────────────────────────────────────
async function cmdDoctor(flags, io) {
const olpHome = resolveOlpHome({ olpHome: flags['olp-home'] });
const proxyUrl = resolveProxyUrl({ proxyUrl: flags['proxy-url'] });
const checkFilter = typeof flags.check === 'string' ? flags.check : undefined;
// D74 P1-1: pass authHeaders so server.running / server.version checks
// succeed under the default production posture (auth.allow_anonymous:
// false). resolveBearerToken returns null when no env var is set; the
// doctor still runs but distinguishes 401 from "server down" by status
// code per the updated check.
const result = await runDoctor({
olpHome,
proxyUrl,
checkFilter,
authHeaders: authHeaders(),
});
if (io.wantJson) {
io.emitJson(result);
return result.fail_count === 0 ? 0 : 2;
}
io.log(colorize(`OLP doctor — ${result.summary}`, ANSI.bold, io.useColor));
io.log('─'.repeat(60));
for (const c of result.checks) {
io.log(` ${statusBadge(c.status, io.useColor)} ${c.id.padEnd(36)} ${c.message}`);
}
io.log('');
io.log(` fail=${result.fail_count} warn=${result.warn_count} ok=${result.ok_count} kind=${result.kind}`);
if (result.next_action.ai_executable.length > 0) {
io.log('');
io.log(colorize('Next (AI-executable):', ANSI.cyan, io.useColor));
for (const cmd of result.next_action.ai_executable) io.log(` $ ${cmd}`);
}
if (result.next_action.human_required.length > 0) {
io.log('');
io.log(colorize('Next (human-required):', ANSI.yellow, io.useColor));
for (const step of result.next_action.human_required) io.log(`${step}`);
}
io.log('');
io.log(colorize(`verify: ${result.next_action.verify}`, ANSI.dim, io.useColor));
return result.fail_count === 0 ? 0 : 2;
}
// ── Subcommand: keys (delegate) ───────────────────────────────────────────
async function cmdKeys(rest, io) {
// Re-use bin/olp-keys.mjs's runCli. Its `out`/`err` writers receive raw strings
// (no \n needed since the underlying CLI emits them itself).
return await runKeysCli(rest, {
out: s => process.stdout.write(s),
err: s => process.stderr.write(s),
});
}
// ── Usage ──────────────────────────────────────────────────────────────────
const USAGE = `OLP operator CLI — Phase 4 (ADR 0010)
Usage:
olp <subcommand> [args] [--json] [--proxy-url=<url>] [--olp-home=<path>]
Subcommands:
status GET /v0/management/status (owner-only)
health GET /health
usage GET /v0/management/dashboard-data (owner-only)
models GET /v1/models
cache GET /cache/stats (owner-only)
providers list providers (registry + config providers.enabled)
chain show [<model>] print routing.chains from ~/.olp/config.json
logs [N] [--level X] last N audit events from ~/.olp/logs/audit.ndjson
restart launchctl (macOS) / systemctl --user (Linux)
doctor [--check X] run diagnostic checks (id, category, or prefix filter)
keys [args ...] delegate to bin/olp-keys.mjs
help print this message
Global flags:
--json emit raw JSON (silences human-readable formatting)
--proxy-url=<url> override resolved proxy URL
--olp-home=<path> override ~/.olp
Env:
OLP_PROXY_URL full URL (overrides OLP_PORT)
OLP_PORT port for default URL (default: 4567)
OLP_API_KEY Bearer token for the proxy
OLP_OWNER_TOKEN synthetic env-owner token (ADR 0007 § 9.4)
OLP_HOME ~/.olp override
Exit codes:
0 success
1 bad usage / unknown subcommand
2 network or HTTP error (4xx/5xx)
3 auth missing / forbidden`;
// ── runCli (testable entry) ───────────────────────────────────────────────
/**
* Run the CLI with explicit argv + IO streams. Returns the exit code (no
* process.exit). Exported for tests.
*
* @param {string[]} argv args after the script name
* @param {object} [opts]
* @param {(s: string) => void} [opts.out]
* @param {(s: string) => void} [opts.err]
* @param {boolean} [opts.useColor] default true; tests pass false for deterministic strings
* @returns {Promise<number>}
*/
export async function runCli(argv, opts = {}) {
if (argv.length === 0 || argv.includes('--help') || argv.includes('-h') || argv[0] === 'help') {
const io = makeIO({ ...opts, json: false });
io.log(USAGE);
return argv.length === 0 ? 1 : 0;
}
const [subcommand, ...rest] = argv;
// `olp keys ...` passes the remaining argv straight to bin/olp-keys.mjs.
if (subcommand === 'keys') {
const io = makeIO({ ...opts, json: false });
return await cmdKeys(rest, io);
}
const { positional, flags } = parseArgv(rest);
const json = flags.json === true;
const io = makeIO({ ...opts, json });
switch (subcommand) {
case 'status': return await cmdStatus(flags, io);
case 'health': return await cmdHealth(flags, io);
case 'usage': return await cmdUsage(flags, io);
case 'models': return await cmdModels(flags, io);
case 'cache': return await cmdCache(flags, io);
case 'providers': return cmdProviders(flags, io);
case 'chain': {
// `olp chain show [model]`
const sub = positional[0];
if (sub !== 'show') {
io.errln(`Error: unknown 'chain' subcommand "${sub}". Try: olp chain show [model]`);
return 1;
}
return cmdChainShow(positional.slice(1), flags, io);
}
case 'logs': return await cmdLogs(positional, flags, io);
case 'restart': return await cmdRestart(flags, io);
case 'doctor': return await cmdDoctor(flags, io);
default:
io.errln(`Error: unknown subcommand "${subcommand}".`);
io.errln(USAGE);
return 1;
}
}
// ── Main guard ────────────────────────────────────────────────────────────
function _isMain() {
if (!process.argv[1]) return false;
try {
return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]);
} catch { return false; }
}
if (_isMain()) {
runCli(process.argv.slice(2))
.then(code => process.exit(code))
.catch(e => {
process.stderr.write(`Fatal: ${e?.stack ?? e}\n`);
process.exit(2);
});
}
+1 -1
View File
@@ -40,7 +40,7 @@ What OLP **does NOT inherit** from ADR 0005 (and where ADR 0005's reasoning does
- ADR 0005's separate-project recommendation came with two qualifiers that OLP rejects: "BYOK from day one" and "no `cli.js` spawn." Both qualifiers were appropriate for the *commercial* path ADR 0005 was contemplating. OLP is not commercial — it is personal- and family-scale, shares the maintainer's own subscription quota across family clients, and explicitly spawns provider CLIs (it is precisely the spawn-binary architecture that delivers the "subscription quota maximization" value proposition spec §1 names). - ADR 0005's separate-project recommendation came with two qualifiers that OLP rejects: "BYOK from day one" and "no `cli.js` spawn." Both qualifiers were appropriate for the *commercial* path ADR 0005 was contemplating. OLP is not commercial — it is personal- and family-scale, shares the maintainer's own subscription quota across family clients, and explicitly spawns provider CLIs (it is precisely the spawn-binary architecture that delivers the "subscription quota maximization" value proposition spec §1 names).
- OLP is therefore not the commercial pivot ADR 0005 endorsed. It is a personal-use re-architecture of the proxy-CLI pattern, which ADR 0005 did not contemplate. The supersession is honest about this gap. - OLP is therefore not the commercial pivot ADR 0005 endorsed. It is a personal-use re-architecture of the proxy-CLI pattern, which ADR 0005 did not contemplate. The supersession is honest about this gap.
OCP itself is not deleted. Per spec §7, OCP enters maintenance mode when OLP v0.1 ships. The two projects do not parallel-run in production (port-3456 conflict, single launchd service slot, one set of credentials per machine). OCP itself is not deleted. Per spec §7, OCP enters maintenance mode when OLP v0.1 ships. ~~The two projects do not parallel-run in production (port-3456 conflict, single launchd service slot, one set of credentials per machine).~~ **Amended at D60 (2026-05-26, ADR 0010 Phase 4 charter):** the port-conflict assumption is lifted. OLP's default port moved `3456 → 4567` at v0.4.0 so OCP (which stays on 3456) and OLP can co-host on the same machine during a transition window. Launchd label collision **will be** avoided via `dev.olp.proxy` (OLP plist generation lands at Phase 4 close per ADR 0010 D64D70; not on disk at D60) vs `dev.ocp.proxy` (OCP, already shipped). Credentials remain per-project (`~/.ocp/` vs `~/.olp/`). Co-host is explicit-opt-in, not the recommended steady state.
OCP ADR 0005 receives a header amendment on merge of this ADR: *"Superseded in part by OLP — see https://github.com/dtzp555-max/olp ADR 0001 for the narrow scope of the supersession (single-provider-sufficiency premise only; ADR 0005's commercial / BYOK / no-spawn recommendations are not adopted)."* The body of ADR 0005 is otherwise untouched. Future readers should see the original reasoning intact and the supersession marker scoped explicitly. OCP ADR 0005 receives a header amendment on merge of this ADR: *"Superseded in part by OLP — see https://github.com/dtzp555-max/olp ADR 0001 for the narrow scope of the supersession (single-provider-sufficiency premise only; ADR 0005's commercial / BYOK / no-spawn recommendations are not adopted)."* The body of ADR 0005 is otherwise untouched. Future readers should see the original reasoning intact and the supersession marker scoped explicitly.
+20 -1
View File
@@ -7,7 +7,26 @@
## Amendments ## Amendments
> **Note on numbering.** Sequence is 1, 3, 4, 5, 6 — Amendment 2 was never written. The reserved slot was originally planned for a separate `maxConcurrent` ratification, but that content was folded into Amendment 1 (the retroactive contract-sync amendment) at filing time and the gap was not backfilled. The gap is intentional and load-bearing — no missing content; do not renumber Amendments 3+ to close it (cross-references to Amendment N from other docs would silently break). > **Note on numbering.** Sequence is 1, 3, 4, 5, 6, 7 — Amendment 2 was never written. The reserved slot was originally planned for a separate `maxConcurrent` ratification, but that content was folded into Amendment 1 (the retroactive contract-sync amendment) at filing time and the gap was not backfilled. The gap is intentional and load-bearing — no missing content; do not renumber Amendments 3+ to close it (cross-references to Amendment N from other docs would silently break).
### Amendment 7 — 2026-05-26: Add OPTIONAL `doctorChecks()` to the Provider contract (D67 — Phase 4 operator UX)
- **Context:** ADR 0010 § Phase 4 D64-D67 ships `bin/olp.mjs` operator CLI + `olp doctor` framework. `olp doctor` runs a set of `Check` objects (id / category / async `run()` returning `{ status, message, evidence? }`) and discriminates the next remediation step via a `kind` field (`noop` / `fix_server` / `fix_oauth` / `fix_provider` / `fresh_install`). The framework needs per-provider checks so a user with a broken `claude` install gets a different fix recipe than a user with a broken `vibe` install. Hardcoding the recipes in `bin/olp.mjs` would re-introduce the kind of per-provider knowledge drift that ADR 0002 § Decision exists to prevent — when a new provider plugin lands, the operator CLI would have to be edited too.
- **Change — add to Provider contract:**
- Introduce **OPTIONAL** `doctorChecks()` returning `DoctorCheck[]` where each `DoctorCheck` has the shape:
- `id: string` — unique per check, conventionally `<provider>.<probe-name>` (e.g. `anthropic.cli_available`, `anthropic.oauth_token_present`).
- `category: 'provider'` — fixed for plugin-contributed checks. The framework reserves `'server'`, `'auth'`, `'config'`, `'system'` for built-in checks.
- `async run(): { status: 'ok' | 'fail' | 'warn', message: string, evidence?: { fix_commands?: string[], human_steps?: string[], reference?: string } }` — runs the probe. `status: 'fail'` makes `olp doctor` exit non-zero and contributes to the `kind: fix_provider` discriminator; `evidence.fix_commands[]` is concatenated into `next_action.ai_executable[]` and `evidence.human_steps[]` into `next_action.human_required[]`.
- **Backwards compatibility:** Plugins that omit `doctorChecks()` contribute zero provider checks. Their healthCheck() return value continues to flow through `/health.providers.status.<name>` exactly as today. No existing plugin behaviour changes; no existing test breaks. `validateProvider` in `lib/providers/base.mjs` is updated to type-check `doctorChecks` only when present (must be a function); absence is allowed.
- **What `doctorChecks()` is for vs. what `healthCheck()` is for:**
- `healthCheck()` answers "is this provider currently usable?" — checked at the request-execution layer; output feeds `/health` and per-request retry decisions.
- `doctorChecks()` answers "if this provider is broken, what specific actionable steps fix it?" — checked at the operator layer; output feeds `olp doctor` + the `next_action.ai_executable[]` repair templates that a downstream AI agent can paste-and-run.
- **Suggested probe set (per plugin):**
- `<provider>.cli_available` — spawn `<bin> --version` with short timeout (≤3s); fail → fix_commands include install instruction.
- `<provider>.<auth-artifact>_present` — check whether the auth file / env var the plugin's `readAuthArtifact()` reads is populated; fail → human_steps include the login command (which usually requires browser interaction and so cannot be in `ai_executable[]`).
- **Authority:** ADR 0010 § Phase 4 D64-D67 (this is the addition called out by that charter). No provider CLI doc citation needed — `doctorChecks()` is an internal contract field. Implementation lands in D67 (this PR): `lib/providers/anthropic.mjs`, `lib/providers/codex.mjs`, `lib/providers/mistral.mjs` each gain a `doctorChecks()` method covering `cli_available` + `<auth-artifact>_present`.
- **Tests:** Suite 32 (`bin/olp.mjs` CLI smoke) and Suite 33 (`olp doctor` framework) in `test-features.mjs` cover the contract amendment. Suite 33 specifically asserts: (a) a plugin without `doctorChecks()` contributes no provider checks (default behaviour), (b) a plugin with a failing `doctorChecks()` probe triggers `kind: fix_provider` and propagates its `evidence.fix_commands[]` into `next_action.ai_executable[]`, (c) all-passing checks yield `kind: noop`.
- **Procedural mechanism:** CC 开发铁律 v1.6 § 11 (IDR) — the contract amendment, the plugin implementations, the doctor framework, and the CLI scaffold are tightly coupled. They land as a single PR (D64-D67 bundle) because reviewing them separately cannot verify that consumer + producer line up. Iron Rule 10 fresh-context reviewer per CLAUDE.md hard requirement #3.
### Amendment 6 — 2026-05-24: `maxConcurrent` runtime enforcement landed (D38, issue #1) ### Amendment 6 — 2026-05-24: `maxConcurrent` runtime enforcement landed (D38, issue #1)
+1 -1
View File
@@ -195,7 +195,7 @@ The dashboard sets a 30s `setInterval` that calls `fetch('/v0/management/dashboa
### 6.6 Localhost-bound by default ### 6.6 Localhost-bound by default
The dashboard is served from the existing OLP HTTP port (default 3456) which is already bound to `127.0.0.1` per `server.mjs` startup (`server.listen(PORT, '127.0.0.1', ...)`). No additional binding logic. Remote operators access via SSH tunnel; ADR 0007 § 7 owner-only auth provides the per-request gate. The dashboard is served from the existing OLP HTTP port (default 4567 since v0.4.0 / D60; 3456 pre-v0.4.0) which is already bound to `127.0.0.1` per `server.mjs` startup (`server.listen(PORT, '127.0.0.1', ...)`). No additional binding logic. Remote operators access via SSH tunnel; ADR 0007 § 7 owner-only auth provides the per-request gate.
--- ---
@@ -0,0 +1,162 @@
# ADR 0010 — Phase 4 Charter: Operator + Client UX
**Status:** Accepted (Phase 4 open as of 2026-05-26)
**Date:** 2026-05-26
**D-day:** D60 (charter + default port change)
---
## Context
Phases 1 — 3 shipped OLP's structural core: HTTP entry surface, IR, provider plugins, fallback engine, content-addressed cache (including streaming-path singleflight at D57+D58 → v0.3.2), multi-key auth + audit ndjson + daily rotation, owner-only management endpoints + dashboard. v1.x roadmap items #1 / #2 / #4 / #7 are closed. Items #3 / #5 / #6 remain trigger-gated.
Two complementary brainstorm passes (2026-05-26) — a comprehensive OCP feature audit + a multi-provider proxy / IDE integration prior-art survey — converged on a clear gap: **OLP's operator and client surfaces are 0% inherited from OCP**. Today OLP has `bin/olp-keys` and `bin/olp-audit-rotate` as the entire operator CLI, no `olp doctor` / no `olp-connect`, no Telegram/Discord integration, no SSE heartbeat for long-running streams behind reverse proxies. Family members get OLP API keys via out-of-band paste, point their IDEs at OLP via the README's one-line example, and discover failure modes via curl. OCP's UX worked because of a load-bearing combination: README `paste-this-prompt-to-Claude-Code` instructions + machine-readable `ocp doctor next_action.ai_executable[]` + `ocp-connect` zero-config LAN setup + `/health.anonymousKey` self-advertising token + `/ocp` Telegram slash commands. **Phase 4 brings these forward as OLP-native primitives.**
A separate strategic decision — should OLP add `/v1/messages` (Anthropic-shape entry surface) for Claude Code support — was considered and **rejected for Phase 4** (see § "Out of Phase 4 scope" below). The decision is recorded with an explicit re-open trigger.
---
## Decision
Phase 4 scope is **Operator + Client UX**. The phase opens 2026-05-26 with D60 (this charter + default port change). Phase 4 close ships v0.4.0; per `CLAUDE.md release_kit.phase_rolling_mode`, the close PR is maintainer-triggered.
### In scope — Phase 4 D-day plan (~13 D-days)
| D-day | Deliverable | Authority | Estimate |
|---|---|---|---|
| **D60** | Default port `3456 → 4567` + this ADR 0010 charter + README / CHANGELOG / ADR 0001 + ADR 0008 amendments | This charter | 0.5d |
| **D61 — D63** | SSE heartbeat (opt-in via `streaming.heartbeat_interval_ms` config; eager-headers-post-spawn; `X-Accel-Buffering: no` constant) + `recentErrors[20]` ring buffer + `/status` combined endpoint | Port OCP `server.mjs:660-685` + `301-358` + `1151-1188`; OCP `docs/superpowers/specs/2026-04-25-47-sse-heartbeat-design.md` | 2.5d |
| **D64 — D67** | `olp` Node-based CLI scaffold (subcommands `status / health / usage / models / logs / cache / providers / chain show / restart / doctor`) + `olp doctor` machine-readable `next_action.ai_executable[]` framework + one fix-template per shipped provider plugin | Port OCP `ocp` bash wrapper (translated to Node — bash dep on python3 is a known fragile point) + OCP `scripts/doctor.mjs` framework | 4d |
| **D68 — D70** | `olp-connect <ip>` client-side IDE auto-config (Cline / Continue.dev / Cursor / Aider / Claude Code / OpenClaw detection) + `/health.anonymousKey` field (opt-in via `auth.advertise_anonymous_key` config; default off) + ADR 0011 (anonymous-key deployment-context limits — trusted-LAN-only invariant explicit) | Port OCP `ocp-connect` + `server.mjs:1454,1488` | 3d |
| **D71 — D73** | `olp-plugin/` (OpenClaw gateway plugin for `/olp` Telegram/Discord slash commands; subcommand parity with `olp` CLI minus mutations) + `docs/integrations/{continue.md,cline.md,cursor.md,aider.md,claude-code.md,openclaw.md}` IDE setup docs | Port OCP `ocp-plugin/index.js`; cross-ref Prior-Art § 3 + § 4 | 3d |
| **close** | v0.4.0 release PR — `package.json` bump, CHANGELOG promotion, `release_kit.phase_rolling_mode` advance to Phase 5 pre-release identifier | `CLAUDE.md release_kit overlay` | maintainer-triggered |
### Out of Phase 4 scope (with explicit triggers)
#### `/v1/messages` — Anthropic-shape entry surface
**Status:** Deferred. Re-enable strictly gated on ADR 0009 P0 success.
**Value matrix (decisive):**
| Scenario | Without `/v1/messages` | With `/v1/messages` |
|---|---|---|
| Maintainer's own Claude Code usage | Direct via Anthropic OAuth → subscription (today) or Agent SDK pool (post-2026-06-15) | Same — maintainer never routes own CC through OLP per stated workflow |
| Family member wanting CC access | Not supported (OAuth is full-account; OLP CLI tokens are scoped) | CC via `ANTHROPIC_BASE_URL=http://olp:4567` + `olp_*` token |
| **P0 succeeds** (ADR 0009 interactive-mode bills as subscription) | OpenAI-shape IDE clients (Cline/Continue/Cursor) all benefit automatically via OLP's anthropic plugin | CC users additionally benefit; both subscription-billed |
| **P0 fails** (interactive-mode bills as Agent SDK same as `-p`) | OpenAI-shape clients still work; no billing change | CC users get same billing as direct OAuth; **fallback to codex/mistral degrades Anthropic-specific features (tool_use schema mismatch / cache_control drop / computer_use no-op / thinking-block drop)** more severely than OpenAI-shape clients which speak the multi-provider lingua franca |
**Rationale.** Under P0 failure, `/v1/messages` provides no billing benefit AND degrades worse on fallback than OpenAI-shape clients (because OpenAI tool schema is the cross-provider standard). The security benefit (no OAuth exposure) is equally achievable via Cline/Continue/Cursor. **Net non-positive under P0 failure.**
**Re-open condition.** (a) ADR 0009 P0 confirms interactive-mode billing classification as subscription (≥ 2026-07-15) AND (b) maintainer explicitly opens Phase 5 "Anthropic-shape hub" scope with the name of at least one family member who wants CC access. If only (a) fires without (b), `/v1/messages` is reconsidered at the start of whichever phase covers it but is not auto-opened.
**README posture (Phase 4).** README § Supported Clients explicitly lists OpenAI-compatible clients (Cline, Continue.dev, Cursor, Aider, OpenClaw bots). Claude Code is listed as **Not supported as an OLP client**, with the explicit alternative "Cline + OLP" (same fallback chain available, better cross-provider compatibility). README links to this ADR for the reasoning.
#### Other deferred items
- **v1.x roadmap #3 (soft trigger reactivation)**, **#5 (provider `cacheKeyFields` mask)**, **#6 (streaming SPAWN_FAILED salvage)** — trigger conditions per `docs/v1x-roadmap.md` have not fired. Not in Phase 4.
- **Anthropic / codex billing audits** — date-gated (`anthropic.mjs:53, 416, 441` say 2026-06-16; `codex.mjs:572` post-D7 E2E audit). Not in Phase 4.
- **`context_window_exceeded` fallback trigger** (LiteLLM prior-art) — small ADR amendment + trigger taxonomy add; opportunistically in Phase 5 unless trigger fires sooner.
- **`X-OLP-Cost-USD` per-request response header** — depends on provider-cost weights table (Phase 5 prerequisite).
- **per-(provider, model) live stats Map** (replacing audit-query scan for dashboard 30s poll) — current scan latency adequate; Phase 5+.
- **OpenTelemetry GenAI span emission**`npm` dep + ~150 LOC; family-scale ROI marginal. Phase 6+ unless Langfuse self-host requested.
- **Intent-based routing**, **stackable transformer plugin model** — explicit non-goals per Prior-Art § 8 anti-patterns.
### Opportunistic Phase 4 micro-additions (not blocking)
Items small enough to land alongside a planned D-day without scope creep, if encountered:
- Env-var deny-list before provider plugin `spawn` (per OCP `server.mjs:531-534`; each plugin declares its own list)
- 5 MB request body cap with HTTP 413 (per OCP `server.mjs:1270,1278-1281`)
- Error-response path-sanitization (per OCP `server.mjs:1395`)
- Stable node-path resolution in launchd plist (Homebrew `/Cellar/<ver>/``/opt/` rewrite; per OCP `setup.mjs:344-351`)
- Legacy model alias resolution in `models-registry.json` (`aliases:` field; per OCP `legacyAliases`)
### Exit gate — v0.4.0 close criteria
1. D60 — D73 all merged with fresh-context opus reviewer APPROVE per Iron Rule 10.
2. CI green on every D-day merge commit and on the v0.4.0 release commit head.
3. README § Operator CLI + § IDE Setup + § Telegram/Discord Usage sections present.
4. ADR 0010 (this charter) + ADR 0011 (anonymous-key deployment-context limits) on disk.
5. `CHANGELOG.md "Unreleased"` promoted to `"## v0.4.0 — <date>"` with D60 — D73 entries.
6. `package.json` bumped to `0.4.0`.
7. `CLAUDE.md release_kit.phase_rolling_mode.current_phase` advances `Phase 4 → Phase 5`; `current_pre_release_identifier` advances `0.4.0-phase4 → 0.5.0-phase5`.
8. Standing autopilot grant covers D-day-by-D-day execution; v0.4.0 close PR is maintainer-triggered.
---
## Default port change (D60 specific)
The default `OLP_PORT` value moves `3456 → 4567` at this D-day. Rationale:
- OCP defaults to 3456 and the maintainer's existing OCP installs stay on 3456 indefinitely.
- A standard `olp` install on the same host without overriding `OLP_PORT` collides at bind time.
- Setting `OLP_PORT=4567` as the default makes co-host the recommended steady state during the migration window (and beyond — there is no enforced deprecation of OCP).
- Existing OLP deployments wanting the pre-D60 default can set `OLP_PORT=3456` in the launchd plist / shell env.
**Tested invariants preserved by the port change:**
- All `test-features.mjs` suites use `port: 0` (ephemeral assigned port) — no test depends on the default value. Verified via `grep -nE '\\b3456\\b' test-features.mjs` returning empty.
- All cache / fallback / provider plugin code is port-agnostic.
- Dashboard 30s poll uses relative paths — no port change required in `dashboard.html`.
- `/v0/management/*` endpoints use relative paths — no client-side update required.
**Files amended at D60:**
- `server.mjs:17` — env-var doc comment
- `server.mjs:74` — default value
- `README.md` quick start + Environment Variables table + Migration from OCP § note
- `docs/adr/0001-project-founding.md` § "Decision" paragraph about port conflict (struck and amended)
- `docs/adr/0008-dashboard-and-audit-query.md` § 6.6 port reference
- `CHANGELOG.md` Unreleased entry
- This ADR
---
## Consequences
**Positive.**
- Family member onboarding goes from "maintainer texts API key + edits IDE config" to `curl -fsSL .../olp-connect | bash -s -- <ip>`.
- `paste-this-prompt-to-Claude-Code` self-installation pattern unlocks AI-driven setup / upgrade / repair, eliminating the maintainer's Tier-1 support role.
- Long-reasoning streams behind nginx / Cloudflare / Tailscale Funnel no longer 502 at 60s idle.
- `/olp` Telegram slash commands enable "is OLP up?" / "show usage" / "rotate key" from anywhere with chat access.
- OCP and OLP co-host on the same workstation, lowering the maintainer's cost of running both.
**Negative.**
- Phase 4 is the first phase whose scope is primarily about *operator experience* rather than functional capability. The work doesn't unlock new requests OLP can serve; it makes OLP's existing capability survive contact with real users.
- The `olp-connect` IDE auto-detect logic accumulates IDE-specific quirks (Cline base-URL UI regressions per their issue #7128; Cursor's malformed-request-when-OpenRouter behavior; etc.). Maintenance burden grows.
- README size grows substantially with Operator CLI + IDE Setup + Telegram/Discord sections. Discoverability of the existing technical reference (ADRs, environment variables) may degrade unless the navigation is refactored.
**Neutral.**
- Phase 4 deliberately spends 0 D-days on `/v1/messages`. If ADR 0009 P0 succeeds in Q3 2026, Phase 5 "Anthropic-shape hub" becomes the natural next phase, with the prerequisite IR work that Phase 4 surfaces (every IDE doc page is a test of which IR fields actually flow through). If P0 fails, `/v1/messages` shelves indefinitely and the README simply documents CC as out-of-scope.
---
## Alternatives considered
1. **Phase 4 = `/v1/messages` first, operator UX later.** Rejected. The brainstorm matrix demonstrated `/v1/messages` is value-positive only if ADR 0009 P0 succeeds, and operator UX gains accrue regardless. Building speculative infrastructure ahead of P0 risks 5-7 D-days of work shelving.
2. **Phase 4 = operator + client UX + `/v1/messages` together (full kitchen sink).** Rejected. ~20 D-days lengthens the Phase 4 close window unnecessarily; the natural review chunks blur; maintainer review fatigue is real.
3. **Phase 4 = just D60 + opportunistic SSE heartbeat, no CLI / no plugin / no docs bundle.** Rejected. Each of the operator-UX items individually has small ROI; the value compounds when they ship together (CLI surfaces data → `/status` exposes shape → Telegram plugin renders → IDE docs reference → `olp-connect` automates). Splitting them across phases loses the compounding.
4. **Defer Phase 4 entirely; jump to Phase 5 Anthropic-shape hub when P0 lands.** Rejected. Operator UX is needed now (this session is itself evidence — the maintainer spent ~30 minutes confirming OCP feature inheritance because there's no `olp doctor` answer). Waiting for P0 stalls progress on independently-valuable work.
---
## Authority
- `docs/v1x-roadmap.md` — Phase 4 was named as the canonical destination for the post-cleanup batch since v0.3.0 close.
- `CLAUDE.md release_kit.phase_rolling_mode``current_phase: Phase 4` already; this charter formalizes the contents.
- OCP comprehensive feature audit (2026-05-26 subagent output, summarized in `~/.cc-rules/memory/auto/MEMORY.md` and in this session's transcript).
- Multi-provider proxy / IDE integration prior-art survey (2026-05-26 subagent output).
- ADR 0009 (Anthropic interactive-mode path placeholder) — establishes the gate for `/v1/messages` re-consideration.
- ADR 0001 (project founding) — § "Decision" paragraph about port conflict, amended at this D-day.
- ADR 0008 (dashboard + audit query) — § 6.6 default-port reference, amended at this D-day.
- `~/.cc-rules/memory/auto/standing_autopilot_phase_2.md` — standing autopilot grant covering D-day-by-D-day execution; v0.4.0 close PR is maintainer-triggered per `release_kit.phase_close_trigger`.
---
## Procedural mechanism
CC 开发铁律 v1.6 § 5.5 (release-kit overlay drives Phase boundaries) + § 10 (independent reviewer on every implementation D-day) + § 11 (minimum reviewable unit per PR — this charter ships as D60 PR alongside the default port change because both are governance-class and small).
@@ -0,0 +1,358 @@
# ADR 0011 — Anonymous-Key Deployment-Context Limits (Trusted-LAN Invariant)
**Status:** Accepted (2026-05-26)
**Date:** 2026-05-26
**D-day:** D70 (lands alongside D68 `olp-connect` + D69 `/health.anonymousKey`)
---
## Context
ADR 0010 § Phase 4 D68-D70 charter scoped a three-deliverable bundle:
- **D68** `olp-connect <ip>` — client-side bash script that auto-configures a
family member's machine to point at a remote OLP instance, including IDE
detection (Cline / Continue.dev / Cursor / Aider / OpenClaw) and rc-file +
system-level env var writes.
- **D69** `/health.anonymousKey` field — opt-in (`auth.advertise_anonymous_key:
true` in `~/.olp/config.json`; default `false`) surface that emits the
plaintext of a designated guest-tier key so `olp-connect` can pick it up
with zero-config (no out-of-band token paste).
- **D70** this ADR — codifies the deployment-context limits that make D69
safe.
The D69 mechanism is a deliberate port of OCP's clever `PROXY_ANONYMOUS_KEY` +
`/health.anonymousKey` pattern (OCP `server.mjs:148, 1454, 1488, 1555`,
shipped 2026-04 under OCP issue #12 § 14 Path A) which made family-member
onboarding go from "maintainer texts API key + edits IDE config" to
`curl -fsSL .../ocp-connect | bash -s -- <ip>`. The OCP pattern works on
trusted family LAN deployments; it would catastrophically fail on a public
internet deployment. ADR 0011 makes the trust assumption explicit before OLP
inherits the pattern.
The ADR also pins three implementation details that are NOT obvious from
reading the D69 patch alone:
1. The plaintext token must live on disk SOMEWHERE for the server to surface
it. ADR 0007 § 5 + § 6.2 explicitly forbid plaintext storage in the
default manifest. D69 introduces an explicit **opt-in** `plaintext_advertise`
manifest field for ONLY the advertised key — every other key retains the
ADR 0007 § 5 hash-only contract.
2. The advertised key is **guest-tier**, not owner-tier. Owner-tier
advertisement is rejected at keygen time AND at config-load time —
exposing the owner identity unauthenticated would grant any LAN caller
`/health` full payload, `/v0/management/*` mutating access, and
`X-OLP-Fallback-Detail` visibility — the exact inverse of the advertise
key's intent (a low-privilege zero-config tier).
3. Three prerequisites MUST hold simultaneously for `/health.anonymousKey`
to be emitted; missing any one is logged at startup but the server still
boots — graceful-degrade rather than refuse-to-start.
---
## Decision
### Three-prerequisite gate (server-side)
`/health` emits the `anonymousKey` field if and only if ALL THREE hold:
1. `auth.advertise_anonymous_key === true` in `~/.olp/config.json` (default
`false` — opt-in).
2. `auth.allow_anonymous === true` (the anonymous tier must be reachable for
the advertised key to be meaningful to zero-config callers; advertising a
key into a deployment that rejects anonymous requests is incoherent).
3. At least one active (`revoked_at === null`) manifest under `~/.olp/keys/`
carries a non-empty `plaintext_advertise: "olp_..."` field.
When prerequisite (1) holds but (2) or (3) fails, the server logs a
startup warn (`anonymous_key_advertised_but_denied` or
`anonymous_key_advertised_but_no_anonymous_key_exists`) but starts normally
and simply omits the field from `/health` responses.
### Plaintext storage mechanism (`plaintext_advertise` manifest field)
ADR 0007 § 5 forbids plaintext storage anywhere. D69 introduces a single,
**explicitly opt-in** exception: the manifest of the designated advertised
key gains a `plaintext_advertise` field whose value is the plaintext token.
This field is written ONLY when the operator runs:
```
olp-keys keygen --anonymous --advertise
```
(or `--advertise` alone on a guest-tier `keygen` invocation; `--anonymous`
is a friendly shorthand for `--tier=guest --name=anonymous`). The keygen
command surfaces an explicit `WARNING` to stderr at creation time:
```
WARNING: this key's plaintext is now stored on disk + will be exposed via
/health.anonymousKey when auth.advertise_anonymous_key=true AND
auth.allow_anonymous=true. Use ONLY on a trusted LAN. See ADR 0011.
```
Every other key (every existing key, and every newly-created key without
`--advertise`) retains the ADR 0007 § 5 hash-only contract — `manifest.json`
contains `token_hash` and NEVER `plaintext_advertise`.
**Schema-version note (D69 reviewer P2-2).** This adds a new optional field
to the manifest. Per ADR 0007 § 4 ("Increment `schema_version` on any
non-additive change"), additive optional fields do NOT require a
`schema_version` bump — older parsers ignore unknown fields per the same
section's forward-compat rule. The manifest stays at `schema_version: 1`.
Documented here so a future archaeologist asking "why didn't D69 bump
`schema_version`?" has a one-line answer.
**`listKeys()` redaction (D69 reviewer P2-1).** `lib/keys.mjs listKeys()`
strips BOTH `token_hash` AND `plaintext_advertise` from its return value.
Callers wanting the advertised plaintext for the `/health` publication
path MUST go through `findAdvertisedKey()` — the only sanctioned read
site. This protects against a future caller of `listKeys()` accidentally
emitting the plaintext into logs / HTTP responses / dashboards.
### Tier restriction (guest only)
`createKey()` rejects `plaintext_advertise: true` for `owner_tier: 'owner'`
with the error `createKey: plaintext_advertise requires owner_tier="guest"`.
The CLI also rejects `--owner --advertise` with a clear error pointing at
this ADR.
Rationale: owner-tier confers `/health` full payload visibility,
`/v0/management/*` mutating access, and `X-OLP-Fallback-Detail` header
visibility. Advertising owner-tier plaintext unauthenticated would let any
LAN caller assume the owner identity — the exact opposite of the design
intent.
### Trusted-LAN deployment invariant
`auth.advertise_anonymous_key: true` is permitted ONLY when the OLP server
is bound to a trust-equivalent address space:
| Tier | Address space | Permitted? |
|------|---------------|------------|
| Loopback | `127.0.0.0/8` | yes |
| RFC 1918 LAN | `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16` | yes |
| Tailnet | `100.64.0.0/10` (CGNAT range used by Tailscale) | yes |
| Localhost domains | `localhost`, `*.local`, `*.internal` | yes |
| Public internet | any routable IPv4/IPv6 outside the above | NO |
This is a **soft constraint** at v0.4.0 — OLP does not enforce IP-allowlist
or BIND_ADDRESS inspection. The constraint is documented here, surfaced in
README § "Anonymous-key advertise mode (trusted-LAN-only)", and warned-but-
not-blocked at server startup when `auth.advertise_anonymous_key=true` and
the bind address looks public.
Hard enforcement (refuse to start when bind is public + advertise enabled)
is deferred. The maintainer's deployments are LAN-only, the family-scale
audience cannot tolerate a startup-refuse mode that bricks the proxy on
ambiguous network topology (e.g., TLS-fronted private network where the
underlying bind IP IS public but the network itself is trusted), and the
trade-off in ADR 0010 explicitly accepted operator-discretion gates for
soft constraints of this class.
---
## Threat model
The advertised anonymous key is **public** within the boundary of "anyone
who can reach `GET /health`." Anyone within that boundary can read the
plaintext from `/health.anonymousKey` and use it for `/v1/chat/completions`,
`/v1/models`, etc.
| Deployment | Boundary | Acceptable? |
|------------|----------|-------------|
| Mac mini + Tailscale, only family devices on tailnet | family devices | YES |
| Home LAN with no guest WiFi, no port-forward | household + neighbors-within-WiFi-range | YES (within risk tolerance) |
| Home LAN with guest WiFi joined to same VLAN as proxy | EVERYONE who visits and connects to guest WiFi | borderline; treat with caution |
| Coffee shop / open WiFi | EVERYONE physically present | NO |
| Public internet via Cloudflare Tunnel / port-forward / VPS | EVERYONE on the internet | NO — instant compromise |
The capability gain for an attacker who reads `/health.anonymousKey` is
**equal to the capability the operator deliberately granted the
anonymous-tier key**:
- `providers_enabled` (when `'*'`, the attacker can dispatch any provider —
burning the operator's subscription quotas).
- `/v1/chat/completions` access (LLM use under the operator's billing).
- Cache pollution under `__anonymous__` namespace (per ADR 0007 § 7.1; the
advertised guest key uses its own `<key-id>` namespace — but anonymous
callers who DON'T present the key use `__anonymous__`).
What the attacker does NOT get:
- `/health` full payload — gated to `owner_tier === 'owner'` (ADR 0007 § 7.1).
- `/v0/management/*` mutating endpoints — gated to owner (ADR 0008 § 7).
- `X-OLP-Fallback-Detail` header — `'owner_only'` policy default (ADR 0007 § 7.2).
- The owner key's plaintext (which is never stored anywhere; only its
`token_hash` is on disk per ADR 0007 § 5).
The "burn the operator's subscription quotas" failure mode is bounded by
the per-provider quota limits AND the maintainer's monitoring (`/health`
owner-view shows quota status per provider; `/v0/management/audit` shows
per-key usage). Detection is fast; the question is how much quota the
attacker can burn between compromise and key revocation.
---
## `olp-connect` integration (D68 client-side)
`bin/olp-connect <ip>` queries `GET /health` as its first action. If the
response contains `anonymousKey: "olp_..."`, the script uses that value
silently for the rest of the run (printing a one-line `Using server-
advertised anonymous key: olp_...XXXX` notice + a pointer to this ADR).
This is what makes `olp-connect <ip>` a true zero-config command — no
out-of-band token paste needed.
If `anonymousKey` is absent (the default, when `auth.advertise_anonymous_key`
is false), the script falls back to interactive prompt or `--key` flag.
`olp-connect` does NOT perform any of the trusted-LAN soft-checks itself —
it trusts that an operator who set `auth.advertise_anonymous_key: true`
knows their deployment context. The script does, however, document the
trade-off in its `--help` output and prints the ADR 0011 reference
alongside the "using server-advertised key" notice.
---
## Deployment configurations (D76 amendment, 2026-05-26)
Original ADR 0011 referenced a `BIND_ADDRESS` concept that did not exist in the v0.4.0v0.4.2 codebase — the server was hard-coded to `server.listen(PORT, '127.0.0.1', ...)`. D76 closes this gap by adding the `OLP_BIND` env var (default `127.0.0.1`), making the deployment-context discussion below operational rather than aspirational.
Three deployment configurations are supported:
| `OLP_BIND` value | Reachability | Anonymous-key publication |
|---|---|---|
| `127.0.0.1` (default) | Loopback only | Safe with any auth posture (no LAN exposure at all) |
| RFC1918 IP / tailnet IP / `0.0.0.0` on a trusted LAN | LAN clients only | Safe when `advertise_anonymous_key: true` — the documented "trusted-LAN" zero-config family onboarding flow |
| Public IP / `0.0.0.0` on a public-facing host | Public internet | **Incompatible with `advertise_anonymous_key: true`.** Operator MUST keep `advertise_anonymous_key: false` (default). |
The server emits a startup warn event `anonymous_key_advertised_with_lan_bind` when `OLP_BIND` is non-loopback AND `advertise_anonymous_key: true` (per the `lib/keys.mjs` + `server.mjs` checks). The warn is a **checkpoint, not a hard gate** — the server cannot tell from the bind address alone whether the operator is on a trusted LAN (RFC1918 / tailnet) or has accidentally exposed a public IP. The Re-evaluation trigger #1 below escalates to a hard gate when OLP gains a public-internet deployment mode.
`olp-connect <ip>` consumes `/health.anonymousKey` over the network — therefore requires `OLP_BIND` to include the LAN interface on the server side. Without setting `OLP_BIND=<lan-ip>` (or `0.0.0.0`), `olp-connect <ip>` will fail with `connect ECONNREFUSED` because the server only accepts loopback connections.
---
## Re-evaluation triggers
Re-open this ADR when ANY of the following fires:
1. OLP gains a "expose to public internet" deployment mode in the README
(e.g., Cloudflare Tunnel guidance, ngrok recipe). At that point the
soft-constraint MUST become a hard constraint (bind-address inspection
at startup, refusal to enable `advertise_anonymous_key` when bind is
public — likely with a separate `OLP_TRUSTED_PUBLIC_OVERRIDE=1` env
escape hatch for operators who run their own TLS termination).
2. The OCP `/health.anonymousKey` model is found to have caused a
real-world quota-burn incident; that learning amends this ADR.
3. Phase 5 introduces multi-tenant SaaS-like deployments (currently
non-goal per ADR 0001); the entire family-scale assumption is
re-examined.
---
## Consequences
**Positive.**
- Family-member onboarding becomes a single command: `olp-connect <ip>`. No
out-of-band token paste. No "wait, what's the API key?" friction loop.
- The trust trade-off is now an explicit, single-knob config decision, not
an implicit consequence of OCP-pattern inheritance.
- The `plaintext_advertise` field is a single auditable on-disk surface —
`grep plaintext_advertise ~/.olp/keys/*/manifest.json` answers "which key
is advertised?" definitively, and an operator who wants to disable the
feature can simply revoke that key.
- Owner-tier advertisement is impossible (both at keygen and at config
load), eliminating an entire class of foot-gun.
**Negative.**
- ADR 0007 § 5's "no plaintext on disk, ever" property is weakened to "no
plaintext on disk except for ONE explicitly-opted-in field on ONE key."
The exception is narrow and audit-grep-able but the property is no
longer absolute.
- Operators who enable advertise mode then move the deployment from LAN to
public internet (e.g., add a Cloudflare Tunnel without revisiting the
config) silently invert the threat model. The startup warn for "public
bind detected" does not currently fire (soft constraint per § "Trusted-
LAN deployment invariant" above).
- The OCP precedent shows operators sometimes share `olp-connect <ip>`
invocations in chat / docs that include their IP; an LLM training corpus
could harvest these IPs. The advertised key is only useful while the
network reaches the IP, but the IP-disclosure surface grows.
**Neutral.**
- The plaintext storage is per-key, not global. Revoking the advertised key
removes the plaintext exposure within one filesystem write (the manifest
stays on disk for audit attribution per ADR 0007 § 6.1, but `revoked_at`
becomes non-null and `findAdvertisedKey()` skips revoked manifests).
---
## Alternatives considered
1. **Store plaintext in `config.json` directly.** Rejected. Mixes secrets
with operational config; complicates git-crypt boundary; loses the
per-key revocation path (you'd have to edit JSON to "revoke" the
exposure rather than running `olp-keys revoke --id=<id>`).
2. **Add an `anonymous` owner_tier instead of using `guest` + `plaintext_
advertise`.** Rejected. Bumps ADR 0007 § 4 schema version (a
non-additive change), adds a third identity class that the rest of the
codebase (cache namespacing, /health gating, audit attribution) has no
reason to know about, and conflicts with ADR 0007 § 7.1's "anonymous =
no auth header + allow_anonymous=true" definition. A single optional
field on the manifest is strictly less invasive.
3. **Hard-enforce trusted-LAN bind address at startup.** Rejected for
v0.4.0; deferred until a public-deployment-mode README section ships
(see Re-evaluation triggers § 1). Soft constraint + startup warn is
appropriate while OLP has zero public-internet deployment recipes.
4. **Encrypt `plaintext_advertise` at rest with a key derived from
`OLP_HOME` path or a separate `OLP_ADVERTISE_KEY` env var.** Rejected.
The threat model is "anyone who can read `/health` reads the plaintext
token over the wire," not "anyone who can read `~/.olp/keys/`." Both
require LAN-reach; encrypting on-disk doesn't change the over-the-wire
exposure. Adds complexity for no security gain in the relevant attack
model.
5. **Make `--advertise` allowed only when `--name` is exactly `anonymous`.**
Rejected as over-restrictive. The CLI's `--anonymous` shorthand
defaults `--name=anonymous`, but operators may legitimately want a
named advertised key (e.g., `family-guest`, `lan-zero-config`). The
discriminator is the field, not the name.
---
## Authority citations
- **ADR 0007 § 7** (Identity-class table — anonymous tier definition;
`__anonymous__` keyId).
- **ADR 0007 § 5** (Token format — establishes hash-only on-disk; D69 is
the explicit opt-in exception).
- **ADR 0007 § 4** (Manifest schema — D69 adds optional `plaintext_advertise`
field; § 4 already specifies "unrecognized fields cause a warn but not a
reject (forward-compat)" so the addition is non-breaking for older
parsers).
- **ADR 0007 § 7.2** (Configuration — D69 adds `auth.advertise_anonymous_key`
alongside existing `allow_anonymous` / `owner_only_endpoints` /
`fallback_detail_header_policy`).
- **ADR 0010 § Phase 4 charter D68-D70 row** (scope authority for this ADR).
- **OCP `server.mjs:148, 1454, 1488, 1555`** (prior-art for the
`PROXY_ANONYMOUS_KEY` env + `/health.anonymousKey` pattern; OCP v3.13.0).
- **OCP issue #12 § 14 Path A** (the original anonymous-key decision
context for OCP; the "Path A" label is OCP-specific and not used in
OLP).
- **`bin/olp-connect`** (D68 client-side consumer of `/health.anonymousKey`).
- **`bin/olp-keys.mjs`** (D69 keygen `--advertise` flag implementation).
- **`lib/keys.mjs` `findAdvertisedKey()`** (D69 server-side resolver).
- **`server.mjs handleHealth`** (D69 emission point + startup-warn site).
---
## Procedural mechanism
CC 开发铁律 v1.6 § 10 (independent reviewer per implementation D-day) — D68
+ D69 + D70 ship as ONE PR per Iron Rule 11 IDR (the three deliverables
are mutually constituting: `olp-connect` consumes `/health.anonymousKey`,
`/health.anonymousKey` is governed by ADR 0011, ADR 0011 documents
`olp-connect`'s trust posture). The reviewer is a fresh-context opus
subagent.
+2
View File
@@ -23,6 +23,8 @@ New ADRs increment from the highest existing number. Filenames are `NNNN-<short-
| [0007](0007-multi-key-auth.md) | Multi-Key Auth (`lib/keys.mjs`) | Phase 2 design ADR (D43-B, 2026-05-25). Option 2 (filesystem manifest at `~/.olp/keys/<key-id>/manifest.json`) + opaque `olp_<32-byte>` token + SHA-256 hash. Owner / guest / anonymous tier gating with explicit `config.json auth.allow_anonymous` (default false). Bootstrap keygen command surface + `OLP_OWNER_TOKEN` env override with stable synthetic `key_id`. Audit ndjson append-only at `~/.olp/logs/audit.ndjson`, warn+1-retry on append failure. Rejects direct SQLite port at v0.2.0 due to Node baseline (`engines >=18` + CI 20/24 vs `node:sqlite` added 22.5.0 / RC); Option 3 hybrid documented as forward path when Phase 3+ Dashboard / SQL-aggregate quota arrives. | | [0007](0007-multi-key-auth.md) | Multi-Key Auth (`lib/keys.mjs`) | Phase 2 design ADR (D43-B, 2026-05-25). Option 2 (filesystem manifest at `~/.olp/keys/<key-id>/manifest.json`) + opaque `olp_<32-byte>` token + SHA-256 hash. Owner / guest / anonymous tier gating with explicit `config.json auth.allow_anonymous` (default false). Bootstrap keygen command surface + `OLP_OWNER_TOKEN` env override with stable synthetic `key_id`. Audit ndjson append-only at `~/.olp/logs/audit.ndjson`, warn+1-retry on append failure. Rejects direct SQLite port at v0.2.0 due to Node baseline (`engines >=18` + CI 20/24 vs `node:sqlite` added 22.5.0 / RC); Option 3 hybrid documented as forward path when Phase 3+ Dashboard / SQL-aggregate quota arrives. |
| [0008](0008-dashboard-and-audit-query.md) | Dashboard + Audit Query Layer | Phase 3 design ADR (D48, 2026-05-25). Static HTML dashboard + vanilla JS + fetch (no build step). In-memory ndjson scan for aggregate queries (O(N) per call; family-scale acceptable; defers SQLite migration to Option 3 hybrid trigger). Daily audit rotation `audit-YYYY-MM-DD.ndjson` on first append after UTC midnight; cross-file query layer for rolling 30-day windows. Owner-only gating on `/dashboard` + 3 `/v0/management/*` JSON endpoints reusing ADR 0007 § 7 auth model. 30s page poll (no SSE infra). Panels: per-provider quota / 24h request+cache+fallback / 30d spend trend / top-N fallback chains per spec § 4.6. Opens ADR 0007 § 12 Phase 3 deferral (Dashboard + audit query + rotation). | | [0008](0008-dashboard-and-audit-query.md) | Dashboard + Audit Query Layer | Phase 3 design ADR (D48, 2026-05-25). Static HTML dashboard + vanilla JS + fetch (no build step). In-memory ndjson scan for aggregate queries (O(N) per call; family-scale acceptable; defers SQLite migration to Option 3 hybrid trigger). Daily audit rotation `audit-YYYY-MM-DD.ndjson` on first append after UTC midnight; cross-file query layer for rolling 30-day windows. Owner-only gating on `/dashboard` + 3 `/v0/management/*` JSON endpoints reusing ADR 0007 § 7 auth model. 30s page poll (no SSE infra). Panels: per-provider quota / 24h request+cache+fallback / 30d spend trend / top-N fallback chains per spec § 4.6. Opens ADR 0007 § 12 Phase 3 deferral (Dashboard + audit query + rotation). |
| [0009](0009-interactive-mode-path-placeholder.md) | Anthropic Interactive-Mode Path (Placeholder) | Placeholder ADR (2026-05-25, Draft) — blocked on OCP ADR 0007 P0 experiment outcome. Records the maintainer's "wait + port" decision: do NOT independently implement; ride OCP's P0 result. If P0 confirms Transport A (stdio NDJSON) or B (PTY) bills as subscription rather than Agent SDK credit, port to OLP `lib/providers/anthropic.mjs` (Option 1 parallel impl, or Option 2 OCP-as-backend; decision deferred to P0-resolution time). If P0 fails on both, shelve. No Phase 4 D-day scheduled until P0 lands AND maintainer issues explicit "go" naming this ADR. | | [0009](0009-interactive-mode-path-placeholder.md) | Anthropic Interactive-Mode Path (Placeholder) | Placeholder ADR (2026-05-25, Draft) — blocked on OCP ADR 0007 P0 experiment outcome. Records the maintainer's "wait + port" decision: do NOT independently implement; ride OCP's P0 result. If P0 confirms Transport A (stdio NDJSON) or B (PTY) bills as subscription rather than Agent SDK credit, port to OLP `lib/providers/anthropic.mjs` (Option 1 parallel impl, or Option 2 OCP-as-backend; decision deferred to P0-resolution time). If P0 fails on both, shelve. No Phase 4 D-day scheduled until P0 lands AND maintainer issues explicit "go" naming this ADR. |
| [0010](0010-phase-4-charter-operator-and-client-ux.md) | Phase 4 Charter — Operator + Client UX | Phase 4 scope ratification (2026-05-26, Accepted). Phase 4 = operator + client UX (SSE heartbeat / `olp` CLI + doctor / `olp-connect` zero-config + Telegram-Discord plugin + IDE docs bundle). ~13 D-days, D60 → v0.4.0. Records the explicit decision to DEFER `/v1/messages` (Anthropic-shape entry surface) on the rationale that under ADR 0009 P0 failure it provides no billing benefit AND degrades worse on fallback than OpenAI-shape clients. Re-open trigger: ADR 0009 P0 success + maintainer-named family CC user. Also closes the OCP-OLP port co-host ambiguity from ADR 0001 (default `OLP_PORT` 3456 → 4567). |
| [0011](0011-anonymous-key-deployment-context.md) | Anonymous-Key Deployment-Context Limits (Trusted-LAN Invariant) | D70 (2026-05-26, Accepted). Codifies the trust posture for `/health.anonymousKey` opt-in field (D69) + `bin/olp-connect` zero-config consumer (D68). Three-prerequisite gate (`auth.advertise_anonymous_key=true` + `auth.allow_anonymous=true` + an active key with `plaintext_advertise` field). Guest-tier-only restriction (`createKey()` + CLI reject owner+advertise). Trusted-LAN deployment invariant (loopback / RFC1918 / tailnet / `.local` / `.internal` — soft constraint at v0.4.0; hard enforcement deferred until OLP gains a public-deployment recipe). Re-evaluation trigger: any "expose to public internet" README mode. |
## When to write a new ADR ## When to write a new ADR
+47
View File
@@ -0,0 +1,47 @@
# OLP IDE & client integrations
This directory documents per-tool setup for the IDEs and AI clients OLP
supports. Every page follows the same shape: one-line description, status
icon, copy-paste-able config block, known issues, OLP-specific notes, and a
one-line verification command.
## Index
| Tool | Status | Path | Notes |
|---|---|---|---|
| [Continue.dev](./continue.md) | ✅ Supported | VS Code / JetBrains extension | `config.yaml` (NOT `config.json`); supports custom headers |
| [Cline](./cline.md) | ✅ Supported | VS Code extension | OpenAI-compatible provider; UI field occasionally vanishes (Cline #7128) |
| [Cursor](./cursor.md) | ⚠️ Best-effort | Cursor editor | "Override OpenAI Base URL" — known fragile across releases |
| [Aider](./aider.md) | ✅ Supported | terminal CLI | `OPENAI_API_BASE` env + `openai/` model prefix |
| [Claude Code](./claude-code.md) | ❌ Not supported | terminal CLI | Anthropic wire format only; OLP serves OpenAI wire format. Use Cline instead. |
| [OpenClaw](./openclaw.md) | ✅ Supported | Telegram + Discord gateway | `/olp` slash command via the [`olp-plugin/`](../../olp-plugin/) plugin |
## Status legend
- ✅ **Supported** — works against OLP's OpenAI-compatible `/v1/chat/completions`
endpoint; the tool's IR fields flow through OLP's IR without lossy translation
warnings on the documented chain.
- ⚠️ **Best-effort** — works in current versions but the tool has known
upstream bugs around base-URL configuration; expect occasional weirdness.
- ❌ **Not supported** — the tool's wire protocol or transport is incompatible
with what OLP serves; recommended alternative is documented on the page.
## How OLP's response headers help debugging
Every response carries (see [README § Response Headers](../../README.md#response-headers)):
- `X-OLP-Provider-Used` — which provider's plugin served the request
- `X-OLP-Model-Used` — which model the served provider used
- `X-OLP-Fallback-Hops``0` = primary chain entry served it
- `X-OLP-Cache``hit | miss | bypass`
- `X-OLP-Latency-Ms` — end-to-end latency at the proxy
When something looks wrong in an IDE, the first sanity check is `curl -i`
against `/v1/chat/completions` with the same key — those headers tell you
whether the IDE config is broken or OLP routed somewhere unexpected.
## Cross-references
- [ADR 0010](../adr/0010-phase-4-charter-operator-and-client-ux.md) — Phase 4 charter; documents why `/v1/messages` is not supported and points to Cline as the recommended Anthropic-CLI replacement.
- [ADR 0011](../adr/0011-anonymous-key-deployment-context.md) — trusted-LAN-only invariant for `auth.advertise_anonymous_key`.
- [`bin/olp-connect`](../../bin/olp-connect) — automated client setup helper (D68-D70).
+106
View File
@@ -0,0 +1,106 @@
# Aider + OLP
[Aider](https://aider.chat) is a terminal-native pair programmer that
edits files in your local git repo and commits each change. It speaks
OpenAI's `/v1/chat/completions` wire format via the `openai/` model
prefix.
**Status:** ✅ Supported.
**Tested against:** Aider v0.6x. Aider's OpenAI integration has been stable
across many releases — this is the most reliable IDE/CLI binding to OLP.
## Quick setup
Three knobs, all environment variables or `.env`:
```bash
# Required: point Aider at OLP's chat-completions endpoint
export OPENAI_API_BASE=http://127.0.0.1:4567/v1
# Required: OLP plaintext token
export OPENAI_API_KEY=olp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
# Then invoke Aider with an OLP-routable model, prefixed `openai/`:
aider --model openai/claude-sonnet-4-5
```
The `openai/` prefix tells Aider to use its OpenAI-compatible adapter for
the named model. Aider's litellm layer parses this and sends the request
to whatever `OPENAI_API_BASE` resolves to.
Replace the API key with the plaintext token printed by `olp-keys keygen
--name=aider`. Family members on the LAN should substitute the OLP host's
IP for `127.0.0.1` (or use `olp-connect <ip>`).
## Aider's `.env` support
Aider auto-loads a `.env` file from the current directory or the git repo
root. The accepted keys are:
```bash
# .env at the project root
OPENAI_API_BASE=http://127.0.0.1:4567/v1
OPENAI_API_KEY=olp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
# Optional: Aider's own AIDER_-prefixed equivalents work too
AIDER_OPENAI_API_BASE=http://127.0.0.1:4567/v1
```
The `AIDER_` prefix wins over the bare prefix when both are set. Pick one;
mixing them invites surprises during debugging.
**Hygiene:** add `.env` to `.gitignore` if your repo doesn't already. The
OLP token is plaintext-recoverable from disk only because the chat surface
explicitly opts into it (see [ADR 0011](../adr/0011-anonymous-key-deployment-context.md))
— do not let your IDE bind unintentionally.
## Known issues
- **No custom-headers support.** Aider does not expose a way to set extra
HTTP headers on outgoing requests. OLP's optional `X-OLP-Chain` /
`X-OLP-Bypass-Cache` headers are therefore not available via Aider —
routing is determined by the model name alone.
- **`/v1` trailing matters.** `OPENAI_API_BASE` must end at `/v1` (without
`/chat/completions`); Aider appends the remainder. Setting it to the bare
host or with a trailing `/chat/completions` causes 404s.
- **Aider sends `max_tokens` by default.** OLP forwards `max_tokens` to
every provider. If you see "model X does not support max_tokens" errors,
the underlying provider rejects it — check `X-OLP-Provider-Used` and
filter that provider out of the chain for the affected model.
## OLP-specific notes
Aider's request shape is faithful to OpenAI's `/v1/chat/completions`
spec — `messages`, `model`, `max_tokens`, `stream`, `temperature`,
`tools`. All map cleanly into OLP's IR with no lossy-translation warnings.
For long-context work (codebase summaries, large diffs), set
`streaming.heartbeat_interval_ms: 15000` in `~/.olp/config.json` (see
[README § Environment Variables](../../README.md#configjson-keys-introduced-at-phase-4))
so the SSE stream stays alive through reverse proxies during silent
windows.
## Test it
```bash
# In a scratch dir:
aider --model openai/claude-haiku-4-5 --no-stream --message "say ok"
```
Then check OLP's audit log:
```bash
npx olp logs 5
```
The most recent entry should show `provider: anthropic` (or whatever
provider haiku routes to in your chain) and `cache_status: miss`.
## Cross-references
- Aider model config docs: https://aider.chat/docs/llms/openai-compat.html
- [`olp-connect`](../../bin/olp-connect) writes `~/.aider/.env` if Aider is
detected on PATH.
+89
View File
@@ -0,0 +1,89 @@
# Claude Code + OLP
[Claude Code](https://docs.anthropic.com/en/docs/claude-code/overview) is
Anthropic's official terminal-native agent. It speaks the Anthropic
`/v1/messages` wire format and cannot be configured to use an
OpenAI-compatible chat-completions endpoint.
**Status:** ❌ Not supported.
## Why
OLP serves only the OpenAI `/v1/chat/completions` wire format. Adding
`/v1/messages` (the Anthropic shape) was explicitly considered for
Phase 4 and rejected, per
[ADR 0010 § Out of Phase 4 scope](../adr/0010-phase-4-charter-operator-and-client-ux.md).
The short version of the rationale:
- **No billing benefit.** After Anthropic's 2026-06-15 split, `claude -p` /
Agent SDK / third-party agent traffic moves out of the Pro/Max
subscription pool and into a separate paid Agent SDK Credit pool. OLP's
fallback discipline ("when one provider's quota runs out, try the next")
does not save money for this traffic — it just routes the same paid
request to a different paid backend. The subscription leverage that
makes OLP valuable for OpenAI-shape traffic does not exist for
Anthropic-shape traffic.
- **Degrades worse on fallback.** When OLP's primary chain entry (Anthropic)
is exhausted, the fallback hop is typically OpenAI Codex or Mistral Vibe.
Those providers speak OpenAI tool-calling schema; OLP would have to
translate Anthropic's `/v1/messages` tool shape into OpenAI tool shape on
every fallback. That translation is lossy and is what ADR 0010 calls out
as "net non-positive under P0 failure".
- **Same outcome reachable via the recommended alternative.** Cline, Cursor,
Aider, and Continue.dev all speak OpenAI's wire format and have parity
with Claude Code on the "AI edits files in my repo" use case. OLP serves
them today.
## What to use instead
**Recommended:** [Cline](./cline.md). It's an in-IDE autonomous coder that
operates on the same loop Claude Code does (read files, propose edits,
run tools, iterate). The "OpenAI Compatible" provider points cleanly at
OLP's `/v1/chat/completions` endpoint. You get OLP's full fallback chain
(Anthropic → OpenAI Codex → Mistral) instead of being pinned to one
provider.
For terminal users specifically:
- **[Aider](./aider.md)** if you want the Claude-Code-style git-aware
pair programmer in the terminal.
- **OpenClaw** if you want Telegram/Discord-driven access to the
fallback chain (see [`openclaw.md`](./openclaw.md)).
## Re-open trigger
ADR 0010 documents the conditions under which OLP would reconsider
`/v1/messages`:
> (a) ADR 0009 P0 confirms interactive-mode billing classification as
> subscription (≥ 2026-07-15) AND (b) maintainer explicitly opens
> Phase 5 "Anthropic-shape hub" scope with the name of at least one
> family member who wants CC access.
Until both conditions fire, OLP intentionally does not implement
`/v1/messages`. The decision is recorded in ADR 0010 § "Out of Phase 4
scope" and ADR 0009 (Anthropic interactive-mode path placeholder).
## If you absolutely must use Claude Code
Point Claude Code at api.anthropic.com directly. OLP cannot proxy that
traffic. You will:
- Burn against the Anthropic Pro/Max OAuth subscription (pre-2026-06-15) or
the Agent SDK Credit pool (≥ 2026-06-15).
- Lose every fallback property OLP provides — when Anthropic's quota is
exhausted, Claude Code stops working until the quota resets.
- Lose OLP's response headers (`X-OLP-Provider-Used` etc.), audit log
entries, cache hits, and `/health` visibility.
This is documented here only so the trade-off is explicit, not as a
recommendation.
## Cross-references
- [ADR 0010](../adr/0010-phase-4-charter-operator-and-client-ux.md) § "Out of Phase 4 scope" — full defer rationale.
- [ADR 0009](../adr/0009-interactive-mode-path-placeholder.md) — Anthropic 2026-06-15 billing split and re-open trigger.
- [`cline.md`](./cline.md) — the recommended alternative for Claude-Code-style workflows.
+94
View File
@@ -0,0 +1,94 @@
# Cline + OLP
[Cline](https://github.com/cline/cline) is an autonomous-coder VS Code
extension. It speaks OpenAI's `/v1/chat/completions` wire format via its
"OpenAI Compatible" provider option.
**Status:** ✅ Supported.
**Tested against:** Cline v3.x (extension version visible in VS Code's
extension panel). Cline's settings UI has shipped multiple variants of the
base-URL field across 2025-2026; if your version doesn't show the field
described below, see the Known Issues section.
## Quick setup
1. Open the Cline panel in VS Code (sidebar icon).
2. Click the settings gear → "API Provider".
3. Select **OpenAI Compatible**.
4. Fill the fields:
| Field | Value |
|---|---|
| Base URL | `http://127.0.0.1:4567/v1` |
| API Key | `olp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX` |
| Model ID | `claude-sonnet-4-5` |
5. Save. Cline shows the model name in the bottom-right corner of the panel.
Replace the API key with the plaintext token printed by `olp-keys keygen
--name=cline`. Family members on the LAN should substitute the OLP host's
IP for `127.0.0.1` (or use `olp-connect <ip>`).
## Known issues
- **Cline issue [#7128](https://github.com/cline/cline/issues/7128) —
base-URL UI field intermittently disappears.** Several Cline releases in
2025-2026 shipped a settings UI where the "Base URL" field is hidden when
the "OpenAI Compatible" provider is freshly selected. Workaround: switch
to a different provider, save, switch back to "OpenAI Compatible" — the
field returns. Verify the field is visible in your version BEFORE
troubleshooting OLP itself.
- **Cline writes settings to `.vscode/settings.json` under a
`cline.apiConfiguration` key (workspace-scoped) and to the VS Code global
state (machine-scoped) depending on the "save to workspace" toggle.** If
Cline keeps "forgetting" the OLP base URL across VS Code restarts, the
workspace state is overriding the global state. Either save to workspace
explicitly, or clear the workspace key and use global state.
- **Cline sometimes lowercases the model ID before sending.** OLP's
`models-registry.json` uses canonical case (e.g. `claude-sonnet-4-5`).
This is fine — OLP's router lowercases the requested model for chain
lookup. But if you see `unknown model` errors, double-check the exact
string Cline sent via the OLP response headers (curl test below).
## OLP-specific notes
Cline does not expose a custom-headers field in its OpenAI Compatible
provider UI as of v3.x. The OLP routing chain is selected purely from the
model ID — pick the canonical name (e.g. `claude-sonnet-4-5`) that matches
a `routing.chains` key in your `~/.olp/config.json`.
OLP's response headers (`X-OLP-Provider-Used`, `X-OLP-Cache`,
`X-OLP-Latency-Ms`) are not visible in Cline's UI but are captured by VS
Code's Developer Tools Network panel when Cline runs the request.
## Test it
```bash
# 1. Verify OLP accepts Cline-shape requests
curl -sI -X POST http://127.0.0.1:4567/v1/chat/completions \
-H "Authorization: Bearer olp_XXXXXX" \
-H "Content-Type: application/json" \
-d '{"model":"claude-sonnet-4-5","messages":[{"role":"user","content":"ok"}],"max_tokens":5,"stream":false}' \
| grep -i x-olp
```
Expect `X-OLP-Provider-Used: anthropic` (or whichever provider serves
sonnet in your chain) and `X-OLP-Cache: miss` on first request.
## Why not /v1/messages?
Cline supports Anthropic-shape requests via a separate "Anthropic" provider
in its UI. OLP does not implement `/v1/messages`. Use Cline's **OpenAI
Compatible** option pointed at OLP rather than Cline's **Anthropic** option
pointed at api.anthropic.com — the OLP chain gives you fallback to OpenAI
Codex / Mistral / etc. when the Anthropic subscription hits its quota
ceiling. See [ADR 0010 § /v1/messages defer rationale](../adr/0010-phase-4-charter-operator-and-client-ux.md).
## Cross-references
- Cline issue tracker: https://github.com/cline/cline/issues
- [`olp-connect`](../../bin/olp-connect) automates writing the Cline workspace
state.
+95
View File
@@ -0,0 +1,95 @@
# Continue.dev + OLP
[Continue.dev](https://continue.dev) is an open-source autocomplete +
chat extension for VS Code and JetBrains IDEs. It speaks OpenAI's
`/v1/chat/completions` wire format, so it works against OLP with no
shim layer.
**Status:** ✅ Supported.
**Tested against:** Continue.dev v0.10.x (`config.yaml` schema). The
older `config.json` schema (≤ v0.8) is **not** documented here — Continue
deprecated it in late 2025 and emits a one-shot migration warning.
## Quick setup
Edit `~/.continue/config.yaml` (or open the Continue config from the IDE's
extension panel and paste this in):
```yaml
models:
- name: olp-chat
provider: openai
model: claude-sonnet-4-5
apiBase: http://127.0.0.1:4567/v1
apiKey: olp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
roles:
- chat
requestOptions:
headers:
# Optional: pin which routing chain key applies. If omitted, OLP
# looks up the chain via the model name above.
X-OLP-Chain: claude-sonnet-4-5
- name: olp-autocomplete
provider: openai
model: claude-haiku-4-5
apiBase: http://127.0.0.1:4567/v1
apiKey: olp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
roles:
- autocomplete
```
Replace the API key with the plaintext token printed by `olp-keys keygen
--name=continue-dev`. Family members on the LAN should substitute the OLP
host's IP for `127.0.0.1` (or use `olp-connect <ip>` to do this for them
automatically).
## Known issues
- **`apiBase`, NOT `baseURL`.** Continue's YAML schema uses `apiBase` (no
`URL` casing). The older `config.json` `baseURL` key was renamed during the
v0.10 schema cut. If you copy a snippet from a 2024 blog post and it
silently routes to api.openai.com, this is why.
- **Trailing `/v1` matters.** OLP's chat-completions endpoint is at
`/v1/chat/completions`; Continue appends `/chat/completions` to whatever
`apiBase` resolves to. Set `apiBase: http://host:4567/v1` (with `/v1`),
not the bare host.
- **Provider stays `openai`.** Continue's `provider: anthropic` would send
Anthropic-shape requests to `/v1/messages`, which OLP does not implement
(see [`claude-code.md`](./claude-code.md) for the rationale).
## OLP-specific notes
Continue's `requestOptions.headers` lets you pin OLP-specific routing
behaviour without altering the model name itself. Useful headers:
- `X-OLP-Chain: <chain-key>` — explicitly select the routing chain.
- `X-OLP-Bypass-Cache: true` — force a fresh spawn for the next request
(debugging cache-poisoning suspicions).
OLP's response headers (`X-OLP-Provider-Used`, `X-OLP-Cache`, etc.) are
visible via VS Code's `Developer: Toggle Developer Tools` → Network panel
when Continue runs the request.
## Test it
After config save, open the Continue chat panel and send a one-word
message ("ok"). Then on the terminal:
```bash
curl -sI -X POST http://127.0.0.1:4567/v1/chat/completions \
-H "Authorization: Bearer olp_XXXXXX" \
-H "Content-Type: application/json" \
-d '{"model":"claude-haiku-4-5","messages":[{"role":"user","content":"ok"}],"max_tokens":5}' \
| grep -i x-olp
```
Expect `X-OLP-Provider-Used: anthropic` (or whichever provider your chain
routes haiku to) and `X-OLP-Cache: miss` on first request, `hit` on the
second.
## Cross-references
- Continue.dev config reference: https://docs.continue.dev/customization/models
- [`olp-connect`](../../bin/olp-connect) automates the Continue.dev branch
of this setup.
+116
View File
@@ -0,0 +1,116 @@
# Cursor + OLP
[Cursor](https://cursor.com) is an AI-first VS Code fork. It has an
"Override OpenAI Base URL" setting that, when populated, routes its
default-model traffic to your URL using the OpenAI wire format.
**Status:** ⚠️ Best-effort.
**Reason:** Cursor's base-URL override is known to be fragile across
releases. Multiple 2025-2026 forum threads document the setting silently
reverting, model-list dropdowns not populating from the override URL, and
streaming responses falling back to the default backend on parse errors.
The behaviour is not specific to OLP — every OpenAI-compatible proxy
maintainer documents the same caveats — but Cursor's release cadence is
faster than most third-party proxies can test against.
## Quick setup
1. Open Cursor → Settings → "Models" → enable **OpenAI API Key**.
2. Paste your OLP plaintext token into the **API Key** field.
3. Click "Override OpenAI Base URL" and paste:
```
http://127.0.0.1:4567/v1
```
4. Click "Verify". Cursor sends a probe; on success the indicator turns
green.
5. **Crucial step:** in the model list, disable every model that is NOT
in your `~/.olp/config.json` `routing.chains`. Cursor's chat will round-
robin across enabled models and any model OLP can't route will error.
Replace the API key with the plaintext token printed by `olp-keys keygen
--name=cursor`. Family members on the LAN should substitute the OLP host's
IP for `127.0.0.1` (or use `olp-connect <ip>`).
## Known issues
- **Override URL silently reverts on Cursor update.** Two reported variants:
(a) the field empties; (b) the field shows the OLP URL but Cursor still
hits api.openai.com under the hood. Workaround: after every Cursor
update, re-open settings, click "Verify" again, and check the OLP
server's `/health` for incoming probe requests.
- **Model-list dropdown does not populate from the override URL.** Cursor
hardcodes its model list rather than reading `GET /v1/models`. This is
why step 5 above is required — there is no way to make Cursor "discover"
your models. You have to disable each model individually that OLP can't
serve.
- **Streaming response parsing is stricter than OpenAI's actual SSE spec.**
Cursor occasionally falls back to the default backend if the SSE stream
contains a slightly malformed chunk (e.g. an empty `data:` line that
OpenAI's API does emit but Cursor's parser doesn't expect). OLP's SSE
emitter follows the spec; this is on Cursor's side. If you see traffic
hitting api.openai.com despite the override, this is the most likely
cause.
- **Cursor's "Tab" autocomplete is NOT covered by the override.** Tab
completion uses a Cursor-proprietary endpoint that is not affected by the
OpenAI base URL setting. Only the chat panel is. This is documented
Cursor behaviour and is not a bug.
## OLP-specific notes
Cursor sends `model: "gpt-4"` or `model: "gpt-3.5-turbo"` (legacy aliases)
unless you explicitly select another from its dropdown. Add aliases to
your `~/.olp/config.json` `routing.chains` so these route somewhere sane:
```json
{
"routing": {
"chains": {
"gpt-4": [ { "provider": "openai", "model": "gpt-5" } ],
"gpt-3.5-turbo": [ { "provider": "openai", "model": "gpt-5-mini" } ]
}
}
}
```
(Substitute the OpenAI Codex model names listed by `olp models`.)
## Recommendation
**Do not engineer workarounds for Cursor-side bugs.** Cursor's release
cadence will fix or re-break the override URL handling at unpredictable
intervals. If your daily-driver flow is unreliable, switch to Cline (see
[`cline.md`](./cline.md)) — it has a stable OpenAI-compatible provider
that does not break across releases.
## Test it
```bash
curl -sI -X POST http://127.0.0.1:4567/v1/chat/completions \
-H "Authorization: Bearer olp_XXXXXX" \
-H "Content-Type: application/json" \
-d '{"model":"gpt-4","messages":[{"role":"user","content":"ok"}],"max_tokens":5}' \
| grep -i x-olp
```
After hitting "Send" in Cursor's chat, check the OLP server's recent
requests via:
```bash
npx olp logs 10
```
If you don't see Cursor's request in the audit log, traffic isn't reaching
OLP — re-check the override URL.
## Cross-references
- Cursor forum threads on base-URL fragility: https://forum.cursor.com/ (search "OpenAI base URL")
- [`olp-connect`](../../bin/olp-connect) writes Cursor's `cursorrc` if
detected, but cannot guarantee the override survives a Cursor update.
+146
View File
@@ -0,0 +1,146 @@
# 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.
**Status:** ✅ Supported.
## What you get
After install, from Telegram or Discord:
| Slash command | Maps to | Tier |
|---|---|---|
| `/olp status` | GET `/v0/management/status` | owner |
| `/olp health` | GET `/health` | public |
| `/olp usage` | GET `/v0/management/dashboard-data` | owner |
| `/olp models` | GET `/v1/models` | public |
| `/olp cache` | GET `/cache/stats` | owner |
| `/olp providers` | local registry view | public |
| `/olp chain show [model]` | local chain view | public |
| `/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.
## Quick setup
### 1. Install the plugin
Two install paths — either works.
**Option A — OpenClaw CLI:**
```bash
openclaw plugins install /path/to/olp/olp-plugin/
```
**Option B — symlink:**
```bash
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):
```bash
npx olp-keys keygen --owner --name=openclaw-bot
```
Capture the printed plaintext token — it is shown exactly once.
### 3. Configure
Edit `~/.openclaw/openclaw.json`:
```json
{
"plugins": {
"olp": {
"proxyUrl": "http://127.0.0.1:4567",
"apiKey": "olp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
}
}
}
```
### 4. Restart the gateway
```bash
openclaw gateway restart
```
The plugin is now active. Try `/olp help` in your bot's chat.
## Known issues
- **`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.
- **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.
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:
- 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.
## Test it
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).
## Cross-references
- [`olp-plugin/README.md`](../../olp-plugin/README.md) — full plugin docs.
- [ADR 0010 § Phase 4 D71-D73](../adr/0010-phase-4-charter-operator-and-client-ux.md) — the plugin's charter.
- [OCP `/ocp` plugin](https://github.com/dtzp555-max/ocp/tree/main/ocp-plugin) — the OCP predecessor (includes mutating subcommands that OLP deliberately drops).
+607
View File
@@ -0,0 +1,607 @@
/**
* lib/doctor.mjs OLP doctor framework (Phase 4 / D65)
*
* Authority: ADR 0010 § Phase 4 D64-D67 + ADR 0002 Amendment 7 (D67)
* per-provider `doctorChecks()` contract method that this framework consumes.
*
* `olp doctor` runs a set of `Check` objects. Each check has:
* - id: string (unique, e.g. 'server.running', 'anthropic.cli_available')
* - category: 'server'|'auth'|'config'|'provider'|'system'
* - async run(): { status: 'ok'|'fail'|'warn', message, evidence? }
*
* Built-in checks (categories server / auth / config / system) are defined in
* `buildBuiltinChecks()` below; per-provider checks are sourced from each loaded
* plugin's optional `doctorChecks()` method (ADR 0002 Amendment 7).
*
* Output shape (machine-readable consumed by `bin/olp.mjs --json`):
* {
* schema_version: 1,
* generated_at: '2026-05-26T...',
* checks: [{ id, category, status, message, evidence? }],
* fail_count: number,
* warn_count: number,
* kind: 'noop'|'fix_server'|'fix_oauth'|'fix_config'|'fix_provider'|'fresh_install',
* next_action: { ai_executable: string[], human_required: string[], verify: string },
* summary: string,
* }
*
* `kind` precedence (highest first the most upstream blocker wins):
* 1. fresh_install config.exists FAIL (~/.olp/config.json missing/malformed)
* 2. fix_server server.running FAIL
* 3. fix_oauth auth.owner_key_exists FAIL
* 4. fix_provider any provider-category FAIL
* 5. fix_config any other config-category FAIL
* 6. noop all OK (or WARN-only)
*
* `next_action.ai_executable[]` aggregates `evidence.fix_commands[]` from every
* FAIL check; `next_action.human_required[]` aggregates `evidence.human_steps[]`.
* `verify` is always `olp doctor` (re-run after applying the fix).
*
* Design notes:
* - Pure functions + dependency injection: callers pass `{ checks }` (which can
* be overridden for tests) plus a `{ now }` clock for deterministic timestamps.
* - No filesystem writes. No process.exit. No console.log. Callers handle I/O.
* - All checks run in parallel via Promise.all individual check failures are
* captured (not propagated) so one broken check does not hide others.
*/
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { request as httpRequest } from 'node:http';
import { loadProviders } from './providers/index.mjs';
import { loadFallbackConfigSync } from './fallback/engine.mjs';
import { listKeys } from './keys.mjs';
// ── Schema version ────────────────────────────────────────────────────────
export const DOCTOR_SCHEMA_VERSION = 1;
// ── Built-in check builders ───────────────────────────────────────────────
/**
* Resolve the OLP base URL the CLI / doctor will probe.
* Precedence:
* 1. opts.proxyUrl (explicit caller override)
* 2. OLP_PROXY_URL env (full URL like http://host:port)
* 3. http://127.0.0.1:${OLP_PORT || 4567}
*/
export function resolveProxyUrl(opts = {}) {
if (opts.proxyUrl) return String(opts.proxyUrl).replace(/\/+$/, '');
if (process.env.OLP_PROXY_URL) return String(process.env.OLP_PROXY_URL).replace(/\/+$/, '');
const port = process.env.OLP_PORT ?? '4567';
return `http://127.0.0.1:${port}`;
}
/**
* Resolve the OLP_HOME directory (mirrors lib/keys.mjs precedence).
* 1. opts.olpHome
* 2. OLP_HOME env
* 3. ~/.olp
*/
export function resolveOlpHome(opts = {}) {
if (opts.olpHome) return opts.olpHome;
if (process.env.OLP_HOME) return process.env.OLP_HOME;
return join(homedir(), '.olp');
}
/**
* Helper issue a GET to the proxy with a tight timeout. Returns
* `{ ok: true, status, body }` or `{ ok: false, error }`. Never throws.
*/
async function httpGet(url, { timeoutMs = 3000, headers = {} } = {}) {
return new Promise(resolve => {
let done = false;
const finish = (v) => { if (!done) { done = true; resolve(v); } };
let req;
try {
req = httpRequest(url, { method: 'GET', headers, timeout: timeoutMs }, res => {
let data = '';
res.on('data', c => { data += c; });
res.on('end', () => finish({ ok: true, status: res.statusCode, body: data, headers: res.headers }));
});
} catch (e) {
finish({ ok: false, error: String(e?.message ?? e) });
return;
}
req.on('error', e => finish({ ok: false, error: String(e?.message ?? e) }));
req.on('timeout', () => {
try { req.destroy(new Error(`timeout after ${timeoutMs}ms`)); } catch { /* ignore */ }
});
req.end();
});
}
/**
* Build the default check set. Test-mode overrides:
* - opts.injectChecks: [...Check] REPLACES the built-in set entirely
* - opts.providersOverride: Map<name, plugin> REPLACES loaded providers for the per-provider sweep
* - opts.skipNetwork: true omit server.running / server.version (offline mode)
* - opts.olpHome: override ~/.olp lookup
* - opts.proxyUrl: override resolveProxyUrl()
*/
/**
* D64-D67 reviewer P2-1: shell-quote a path before interpolation into
* `ai_executable[]` strings. Single-quote-wrap + escape any embedded single
* quote per POSIX shell rules: `foo'bar` `'foo'\''bar'`. Defends against
* a malicious `OLP_HOME` env value injecting shell metacharacters into the
* suggested-fix command an AI agent (or human) might paste back.
*
* Risk surface is narrow at family scale (operator local env, single-user
* proxy), but the hardening cost is one helper.
*
* @param {string} s
* @returns {string} single-quoted shell-safe string
*/
function _shellQuote(s) {
return `'${String(s).replace(/'/g, "'\\''")}'`;
}
export function buildBuiltinChecks(opts = {}) {
if (opts.injectChecks) return opts.injectChecks;
const olpHome = resolveOlpHome(opts);
const configPath = join(olpHome, 'config.json');
const proxyUrl = resolveProxyUrl(opts);
// D74 P1-1 fix: server.running and server.version probe /health, which
// under default production posture (auth.allow_anonymous: false) requires
// an Authorization: Bearer header. Without it, the probe gets 401 and
// doctor falsely reports the server down. Caller passes the resolved
// bearer token via opts.authHeaders (a `{Authorization: 'Bearer ...'}`
// object). Empty headers means "no token configured" — the probe still
// fires but a 401 response is treated as "auth misconfigured" rather
// than "server down" (see server.running check below).
const authHeaders = opts.authHeaders ?? {};
const checks = [];
// ── system.* ─────────────────────────────────────────────────────────
checks.push({
id: 'system.node_version',
category: 'system',
async run() {
// package.json engines.node = >=18; check process.versions.node major >= 18
const major = parseInt(String(process.versions.node).split('.')[0], 10);
if (Number.isFinite(major) && major >= 18) {
return { status: 'ok', message: `Node ${process.versions.node} (>=18)` };
}
return {
status: 'fail',
message: `Node ${process.versions.node} is below the required >=18 (per package.json engines.node)`,
evidence: {
human_steps: [
'Install a current Node.js LTS (>=18) — see https://nodejs.org/en/download',
],
},
};
},
});
// ── config.* ─────────────────────────────────────────────────────────
checks.push({
id: 'config.exists',
category: 'config',
async run() {
if (!existsSync(configPath)) {
return {
status: 'fail',
message: `${configPath} not found`,
evidence: {
// D64-D67 reviewer P2-1: shell-quote paths via _shellQuote so a
// malicious OLP_HOME env can't inject shell metacharacters into
// the suggested-fix command pasted into an AI agent.
fix_commands: [
`mkdir -p ${_shellQuote(olpHome)}`,
`printf '%s\\n' '{"auth":{"allow_anonymous":false,"owner_only_endpoints":["/health"],"fallback_detail_header_policy":"owner_only"},"providers":{"enabled":{}},"routing":{"chains":{},"soft_triggers":{}},"streaming":{"heartbeat_interval_ms":0}}' > ${_shellQuote(configPath)}`,
],
reference: 'docs/adr/0007-multi-key-auth.md § 3, docs/adr/0004-fallback-engine.md',
},
};
}
try {
const parsed = JSON.parse(readFileSync(configPath, 'utf8'));
if (parsed && typeof parsed === 'object') {
return { status: 'ok', message: `${configPath} parses` };
}
return { status: 'fail', message: `${configPath} parses but is not a JSON object` };
} catch (e) {
return {
status: 'fail',
message: `${configPath} unreadable / malformed: ${e?.message ?? e}`,
evidence: {
human_steps: [
`Inspect ${configPath} — fix the JSON syntax error (or delete the file to fall back to the empty default and re-run olp doctor)`,
],
},
};
}
},
});
checks.push({
id: 'config.providers_enabled',
category: 'config',
async run() {
try {
const cfg = loadFallbackConfigSync(configPath);
const enabled = cfg.providersEnabled ?? {};
const enabledNames = Object.keys(enabled).filter(k => enabled[k] === true);
if (enabledNames.length > 0) {
return { status: 'ok', message: `${enabledNames.length} provider(s) enabled: ${enabledNames.join(', ')}` };
}
return {
status: 'warn',
message: 'No providers enabled in config.json (all /v1/chat/completions requests will 503)',
evidence: {
human_steps: [
`Edit ${configPath} → set providers.enabled.<name> = true for at least one provider (anthropic / openai / mistral)`,
],
reference: 'docs/adr/0002-plugin-architecture.md § Disable model',
},
};
} catch (e) {
return { status: 'fail', message: `Could not read providers.enabled from ${configPath}: ${e?.message ?? e}` };
}
},
});
checks.push({
id: 'config.chains_configured',
category: 'config',
async run() {
try {
const cfg = loadFallbackConfigSync(configPath);
const chains = cfg.chains ?? {};
const chainNames = Object.keys(chains);
if (chainNames.length > 0) {
return { status: 'ok', message: `${chainNames.length} chain(s) configured: ${chainNames.join(', ')}` };
}
return {
status: 'warn',
message: 'No routing chains configured (single-hop mode; cross-provider fallback inactive)',
evidence: {
reference: 'docs/adr/0004-fallback-engine.md § Chain configuration',
},
};
} catch (e) {
return { status: 'fail', message: `Could not read routing.chains from ${configPath}: ${e?.message ?? e}` };
}
},
});
// ── auth.* ───────────────────────────────────────────────────────────
checks.push({
id: 'auth.owner_key_exists',
category: 'auth',
async run() {
// Per ADR 0007 § 9.4: process.env.OLP_OWNER_TOKEN also satisfies "owner identity".
if (process.env.OLP_OWNER_TOKEN) {
return { status: 'ok', message: 'OLP_OWNER_TOKEN env var present (synthetic env-owner per ADR 0007 § 9.4)' };
}
try {
const keys = listKeys({ olpHome });
const activeOwner = keys.find(k => k.owner_tier === 'owner' && k.revoked_at === null);
if (activeOwner) {
return { status: 'ok', message: `active owner key found: id=${activeOwner.id} name="${activeOwner.name}"` };
}
return {
status: 'fail',
message: 'No active owner-tier key found in ~/.olp/keys/ and OLP_OWNER_TOKEN env unset',
evidence: {
fix_commands: [
'npx olp-keys keygen --owner',
],
reference: 'docs/adr/0007-multi-key-auth.md § 9.1 (bootstrap & recovery)',
},
};
} catch (e) {
return { status: 'fail', message: `listKeys failed: ${e?.message ?? e}` };
}
},
});
// ── server.* ─────────────────────────────────────────────────────────
if (!opts.skipNetwork) {
checks.push({
id: 'server.running',
category: 'server',
async run() {
// D74 P1-1: pass authHeaders so the probe works under the default
// production posture (auth.allow_anonymous: false).
const r = await httpGet(`${proxyUrl}/health`, { timeoutMs: 3000, headers: authHeaders });
if (!r.ok) {
return {
status: 'fail',
message: `${proxyUrl}/health unreachable: ${r.error}`,
evidence: {
fix_commands: [
'npx olp restart',
],
reference: 'README.md § Running OLP',
},
};
}
// 401: server is up but the caller has no/wrong bearer token. NOT a
// "server down" condition — distinguish so the kind discriminator
// doesn't route to fix_server when the user just needs OLP_API_KEY.
if (r.status === 401 || r.status === 403) {
return {
status: 'fail',
message: `${proxyUrl}/health returned ${r.status} — server is up but the bearer token is missing or invalid. Set OLP_API_KEY env to an owner-tier token (npx olp-keys list).`,
evidence: {
fix_commands: [
'echo "set OLP_API_KEY=<your owner token> or OLP_OWNER_TOKEN=<...> then rerun olp doctor"',
],
human_required: [
'Locate an owner-tier OLP API key plaintext (or run `npx olp-keys keygen --owner` to mint a new one — printed ONCE).',
'Export it: `export OLP_API_KEY=olp_...`',
],
reference: 'docs/adr/0007-multi-key-auth.md § 9.1 + README § Environment Variables',
},
};
}
if (r.status !== 200) {
return { status: 'fail', message: `${proxyUrl}/health returned status=${r.status}` };
}
return { status: 'ok', message: `${proxyUrl}/health → 200` };
},
});
checks.push({
id: 'server.version',
category: 'server',
async run() {
// Read local package.json version
let localVersion = null;
try {
// Resolve relative to this file — lib/doctor.mjs → ../package.json
// import.meta.url gives a file:// URL; convert and join.
const here = new URL('../package.json', import.meta.url);
const pkg = JSON.parse(readFileSync(here, 'utf8'));
localVersion = pkg.version ?? null;
} catch {
return { status: 'warn', message: 'Could not read local package.json — skipping version comparison' };
}
// D74 P1-1: same auth-headers fix as server.running.
const r = await httpGet(`${proxyUrl}/health`, { timeoutMs: 3000, headers: authHeaders });
if (!r.ok || r.status !== 200) {
return { status: 'warn', message: `Could not fetch /health to compare version (${r.error ?? `status ${r.status}`})` };
}
let serverVersion = null;
try {
serverVersion = JSON.parse(r.body)?.version ?? null;
} catch {
return { status: 'warn', message: '/health returned non-JSON; cannot compare version' };
}
if (!serverVersion) {
return { status: 'warn', message: '/health did not include version; cannot compare' };
}
if (serverVersion === localVersion) {
return { status: 'ok', message: `local v${localVersion} matches running v${serverVersion}` };
}
return {
status: 'warn',
message: `local v${localVersion} differs from running v${serverVersion} — restart to pick up the new code`,
evidence: {
fix_commands: [
'npx olp restart',
],
},
};
},
});
}
return checks;
}
/**
* Sweep loaded providers for doctorChecks() (ADR 0002 Amendment 7).
* Plugins without doctorChecks() contribute nothing (default back-compat).
*
* @param {object} opts
* @param {Map} [opts.providersOverride] Map<name, plugin> for tests
* @param {object} [opts.providersEnabled] Record<string, boolean>; default = all from config.json
* @returns {Check[]}
*/
export function collectProviderChecks(opts = {}) {
let providers;
if (opts.providersOverride) {
providers = opts.providersOverride;
} else {
const olpHome = resolveOlpHome(opts);
const configPath = join(olpHome, 'config.json');
let enabled = opts.providersEnabled;
if (!enabled) {
try {
enabled = loadFallbackConfigSync(configPath).providersEnabled ?? {};
} catch {
enabled = {};
}
}
providers = loadProviders({ enabled });
}
const checks = [];
for (const [_name, plugin] of providers) {
if (typeof plugin?.doctorChecks !== 'function') continue;
let pluginChecks;
try {
pluginChecks = plugin.doctorChecks();
} catch (e) {
// Misbehaving plugin — surface as a synthesized fail check, do not crash.
checks.push({
id: `${plugin.name}.doctor_checks_threw`,
category: 'provider',
async run() {
return { status: 'fail', message: `doctorChecks() threw: ${e?.message ?? e}` };
},
});
continue;
}
if (!Array.isArray(pluginChecks)) continue;
for (const c of pluginChecks) {
if (c && typeof c.id === 'string' && typeof c.run === 'function') {
checks.push({
id: c.id,
category: c.category ?? 'provider',
run: c.run,
});
}
}
}
return checks;
}
// ── Discriminator (kind precedence) ───────────────────────────────────────
/**
* Given a flat results array, determine the next-action discriminator.
* Per ADR 0010 § D65 framework:
* fresh_install > fix_server > fix_oauth > fix_provider > fix_config > noop
*/
export function deriveKind(results) {
const failed = results.filter(r => r.status === 'fail');
if (failed.length === 0) return 'noop';
if (failed.some(r => r.id === 'config.exists')) return 'fresh_install';
if (failed.some(r => r.category === 'server')) return 'fix_server';
if (failed.some(r => r.category === 'auth')) return 'fix_oauth';
if (failed.some(r => r.category === 'provider')) return 'fix_provider';
if (failed.some(r => r.category === 'config')) return 'fix_config';
return 'fix_config';
}
/**
* Compose the next_action block from FAIL results' evidence.
*/
export function deriveNextAction(results, kind) {
const ai_executable = [];
const human_required = [];
for (const r of results) {
if (r.status !== 'fail') continue;
const ev = r.evidence ?? {};
if (Array.isArray(ev.fix_commands)) ai_executable.push(...ev.fix_commands);
if (Array.isArray(ev.human_steps)) human_required.push(...ev.human_steps);
}
return {
ai_executable,
human_required,
verify: kind === 'noop' ? 'already healthy' : 'olp doctor',
};
}
// ── runDoctor (main entry) ────────────────────────────────────────────────
/**
* Execute every check in parallel; aggregate; derive kind + next_action.
*
* @param {object} [opts]
* @param {Check[]} [opts.injectChecks] REPLACE the built-in + provider checks entirely
* @param {Check[]} [opts.extraChecks] APPEND extra checks (after defaults)
* @param {string} [opts.checkFilter] restrict to checks whose id OR category matches
* @param {Map} [opts.providersOverride] for the per-provider sweep
* @param {object} [opts.providersEnabled] Record<string, boolean>
* @param {string} [opts.olpHome] override ~/.olp
* @param {string} [opts.proxyUrl] override the proxy URL
* @param {boolean} [opts.skipNetwork] omit server.* checks
* @param {() => Date} [opts.now] clock injection
* @returns {Promise<DoctorResult>}
*/
export async function runDoctor(opts = {}) {
const now = opts.now ?? (() => new Date());
let checks;
if (opts.injectChecks) {
checks = [...opts.injectChecks];
} else {
checks = [
...buildBuiltinChecks(opts),
...collectProviderChecks(opts),
];
}
if (opts.extraChecks) checks.push(...opts.extraChecks);
// --check <filter>: restrict to checks whose id OR category startsWith / equals the filter.
// Match rule: exact id match, exact category match, OR id startsWith `<filter>.`
// (so --check anthropic matches both anthropic.cli_available and anthropic.oauth_token_present).
if (opts.checkFilter) {
const f = String(opts.checkFilter);
checks = checks.filter(c =>
c.id === f
|| c.category === f
|| c.id.startsWith(`${f}.`)
);
}
// Run all checks in parallel. Capture per-check failures (do not let one throw
// hide the rest of the diagnostic).
const results = await Promise.all(checks.map(async c => {
try {
const r = await c.run();
return {
id: c.id,
category: c.category,
status: r?.status ?? 'fail',
message: r?.message ?? '(check returned no message)',
...(r?.evidence !== undefined ? { evidence: r.evidence } : {}),
};
} catch (e) {
return {
id: c.id,
category: c.category,
status: 'fail',
message: `check threw: ${e?.message ?? e}`,
};
}
}));
const fail_count = results.filter(r => r.status === 'fail').length;
const warn_count = results.filter(r => r.status === 'warn').length;
const ok_count = results.filter(r => r.status === 'ok').length;
const kind = deriveKind(results);
const next_action = deriveNextAction(results, kind);
let summary;
if (fail_count === 0 && warn_count === 0) {
summary = `all ${ok_count} checks ok`;
} else if (fail_count === 0) {
summary = `${ok_count} ok, ${warn_count} warn — no FAIL; kind=${kind}`;
} else {
const firstFail = results.find(r => r.status === 'fail');
summary = `${fail_count} of ${results.length} checks failed — ${firstFail?.id ?? '?'} (${firstFail?.message?.slice(0, 80) ?? ''})`;
}
return {
schema_version: DOCTOR_SCHEMA_VERSION,
generated_at: now().toISOString(),
checks: results,
fail_count,
warn_count,
ok_count,
kind,
next_action,
summary,
};
}
/**
* @typedef {Object} Check
* @property {string} id
* @property {'server'|'auth'|'config'|'provider'|'system'} category
* @property {() => Promise<{ status: 'ok'|'fail'|'warn', message: string, evidence?: { fix_commands?: string[], human_steps?: string[], reference?: string } }>} run
*/
/**
* @typedef {Object} DoctorResult
* @property {number} schema_version
* @property {string} generated_at (ISO timestamp)
* @property {Array<{ id: string, category: string, status: 'ok'|'fail'|'warn', message: string, evidence?: object }>} checks
* @property {number} fail_count
* @property {number} warn_count
* @property {number} ok_count
* @property {'noop'|'fix_server'|'fix_oauth'|'fix_config'|'fix_provider'|'fresh_install'} kind
* @property {{ ai_executable: string[], human_required: string[], verify: string }} next_action
* @property {string} summary
*/
+14 -2
View File
@@ -667,24 +667,36 @@ function defaultConfigPath() {
* Returns empty config (no chains, no soft triggers, no enabled providers) if the * Returns empty config (no chains, no soft triggers, no enabled providers) if the
* file is absent, unreadable, or malformed. * file is absent, unreadable, or malformed.
* *
* D61 (ADR 0010 § Phase 4 D61-D63): adds `streaming` block. Currently
* exposes `heartbeat_interval_ms` (default 0 = heartbeat disabled). When
* heartbeat_interval_ms > 0, the streaming branch emits `: keepalive\n\n`
* SSE comment frames during silent windows of length >= the interval. Default
* 0 preserves backwards compat (no behavioural change).
*
* @param {string} [configPath] override path (for testing do NOT write to ~/.olp/config.json in tests) * @param {string} [configPath] override path (for testing do NOT write to ~/.olp/config.json in tests)
* @returns {{ chains: object, soft_triggers: object, providersEnabled: Record<string, boolean> }} * @returns {{ chains: object, soft_triggers: object, providersEnabled: Record<string, boolean>, streaming: { heartbeat_interval_ms: number } }}
*/ */
export function loadFallbackConfigSync(configPath) { export function loadFallbackConfigSync(configPath) {
const DEFAULT_STREAMING = { heartbeat_interval_ms: 0 };
try { try {
const path = configPath ?? defaultConfigPath(); const path = configPath ?? defaultConfigPath();
const raw = readFileSync(path, 'utf8'); const raw = readFileSync(path, 'utf8');
const parsed = JSON.parse(raw); const parsed = JSON.parse(raw);
const routing = parsed?.routing ?? {}; const routing = parsed?.routing ?? {};
const providers = parsed?.providers ?? {}; const providers = parsed?.providers ?? {};
const streaming = parsed?.streaming ?? {};
const hb = Number(streaming.heartbeat_interval_ms);
return { return {
chains: routing.chains ?? {}, chains: routing.chains ?? {},
soft_triggers: routing.soft_triggers ?? {}, soft_triggers: routing.soft_triggers ?? {},
providersEnabled: providers.enabled ?? {}, providersEnabled: providers.enabled ?? {},
streaming: {
heartbeat_interval_ms: Number.isFinite(hb) && hb >= 0 ? hb : 0,
},
}; };
} catch { } catch {
// File absent, unreadable, or malformed → no fallback config (single-hop mode) // File absent, unreadable, or malformed → no fallback config (single-hop mode)
// Empty providersEnabled → all providers disabled → 503 per ALIGNMENT.md v0.1 posture. // Empty providersEnabled → all providers disabled → 503 per ALIGNMENT.md v0.1 posture.
return { chains: {}, soft_triggers: {}, providersEnabled: {} }; return { chains: {}, soft_triggers: {}, providersEnabled: {}, streaming: { ...DEFAULT_STREAMING } };
} }
} }
+75 -4
View File
@@ -249,7 +249,7 @@ async function _withKeyLock(id, fn) {
* never logged. The manifest contains only the hash. * never logged. The manifest contains only the hash.
*/ */
export function createKey(args = {}) { export function createKey(args = {}) {
const { name, owner_tier = 'guest', providers_enabled = '*', notes = '', olpHome } = args; const { name, owner_tier = 'guest', providers_enabled = '*', notes = '', olpHome, plaintext_advertise = false } = args;
if (typeof name !== 'string' || name.length === 0) { if (typeof name !== 'string' || name.length === 0) {
throw new Error('createKey: name is required (non-empty string)'); throw new Error('createKey: name is required (non-empty string)');
} }
@@ -259,6 +259,15 @@ export function createKey(args = {}) {
if (!(providers_enabled === '*' || Array.isArray(providers_enabled))) { if (!(providers_enabled === '*' || Array.isArray(providers_enabled))) {
throw new Error('createKey: providers_enabled must be "*" or string array'); throw new Error('createKey: providers_enabled must be "*" or string array');
} }
// D69 plaintext_advertise (ADR 0011): only valid on guest tier — see ADR
// 0011 § "Trusted-LAN invariant + tier restriction". Owner-tier advertisement
// is rejected because exposing the owner identity unauthenticated would
// grant unauthenticated callers /health full payload, /v0/management/* access,
// and X-OLP-Fallback-Detail visibility — the inverse of the advertise key's
// intent (a low-privilege zero-config tier).
if (plaintext_advertise && owner_tier !== 'guest') {
throw new Error('createKey: plaintext_advertise requires owner_tier="guest" (ADR 0011)');
}
const id = generateKeyId(); const id = generateKeyId();
const plaintext_token = generateToken(); const plaintext_token = generateToken();
@@ -276,10 +285,59 @@ export function createKey(args = {}) {
last_used_at: null, last_used_at: null,
notes, notes,
}; };
// D69 (ADR 0011): when the operator explicitly opts in via --advertise on
// keygen, the plaintext token is co-located with the hash so the server can
// surface it via /health.anonymousKey for zero-config family-LAN setup.
// This is the ONLY place plaintext ever lands on disk; see ADR 0011 for
// the trusted-LAN-only invariant + threat model.
if (plaintext_advertise) {
manifest.plaintext_advertise = plaintext_token;
}
writeManifestAtomic(id, manifest, { olpHome }); writeManifestAtomic(id, manifest, { olpHome });
return { id, plaintext_token, manifest }; return { id, plaintext_token, manifest };
} }
// ── D69 advertise-key discovery (ADR 0011) ───────────────────────────────
/**
* Find the active key marked for /health advertisement. Returns the manifest
* (including `plaintext_advertise`) or null when no such key exists.
*
* Scans every manifest under ~/.olp/keys/; selects the FIRST active
* (revoked_at === null) manifest that carries a non-empty `plaintext_advertise`
* string. Deterministic ordering is unstable across filesystems operators
* are expected to keep at most one advertised key on disk at a time.
*
* Returns null if:
* - the keys directory doesn't exist
* - no manifest carries plaintext_advertise
* - the only matching manifest is revoked
*
* Used by server.mjs handleHealth (D69) + olp-keys CLI 'list' subcommand
* (advertise badge).
*
* @param {object} [opts]
* @param {string} [opts.olpHome] - test override; defaults to ~/.olp
* @returns {object|null} manifest object (NOT redacted; carries plaintext_advertise)
*/
export function findAdvertisedKey(opts = {}) {
const dir = _keysDir(opts);
if (!existsSync(dir)) return null;
let entries;
try { entries = readdirSync(dir); } catch { return null; }
for (const id of entries) {
if (id.startsWith('.')) continue;
let m;
try { m = readManifest(id, opts); } catch { continue; }
if (m === null) continue;
if (m.revoked_at !== null) continue;
if (typeof m.plaintext_advertise === 'string' && m.plaintext_advertise.length > 0) {
return m;
}
}
return null;
}
/** /**
* List all keys. Returns array of manifest objects with `token_hash` redacted * List all keys. Returns array of manifest objects with `token_hash` redacted
* (kept on disk; omitted from list output per common operational hygiene * (kept on disk; omitted from list output per common operational hygiene
@@ -299,8 +357,14 @@ export function listKeys(opts = {}) {
try { try {
const m = readManifest(id, opts); const m = readManifest(id, opts);
if (m === null) continue; if (m === null) continue;
// Redact token_hash from list output (keep on disk). // D69 reviewer P2-1 (footgun-removal): strip BOTH `token_hash` AND
const { token_hash, ...rest } = m; // `plaintext_advertise` from list output. Callers wanting the
// advertised plaintext for the /health publication path must go
// through `findAdvertisedKey()` instead, which is the only sanctioned
// read site. Future callers of `listKeys()` that emit results into
// logs / HTTP responses / dashboards therefore can't accidentally
// leak the advertised plaintext.
const { token_hash, plaintext_advertise, ...rest } = m;
out.push(rest); out.push(rest);
} catch { } catch {
// Skip invalid manifest; production impl would log warn. // Skip invalid manifest; production impl would log warn.
@@ -456,12 +520,17 @@ export async function touchLastUsed(id, opts = {}) {
* - owner_only_endpoints: ['/health'] (D46 consumes; D45 only loads) * - owner_only_endpoints: ['/health'] (D46 consumes; D45 only loads)
* - fallback_detail_header_policy: 'owner_only' (D46 consumes; D45 only loads) * - fallback_detail_header_policy: 'owner_only' (D46 consumes; D45 only loads)
* *
* D69 / ADR 0011:
* - advertise_anonymous_key: false (default off; opt-in surfaces
* findAdvertisedKey() plaintext via
* /health.anonymousKey)
*
* Returns the auth config object. Never throws missing file / parse * Returns the auth config object. Never throws missing file / parse
* error / missing `auth` key all fall back to defaults. * error / missing `auth` key all fall back to defaults.
* *
* @param {object} [opts] * @param {object} [opts]
* @param {string} [opts.olpHome] - test override; defaults to ~/.olp * @param {string} [opts.olpHome] - test override; defaults to ~/.olp
* @returns {{ allow_anonymous: boolean, owner_only_endpoints: string[], fallback_detail_header_policy: 'owner_only'|'all'|'none' }} * @returns {{ allow_anonymous: boolean, owner_only_endpoints: string[], fallback_detail_header_policy: 'owner_only'|'all'|'none', advertise_anonymous_key: boolean }}
*/ */
export function loadAuthConfigSync(opts = {}) { export function loadAuthConfigSync(opts = {}) {
const olpHome = _resolveOlpHome(opts); const olpHome = _resolveOlpHome(opts);
@@ -470,6 +539,7 @@ export function loadAuthConfigSync(opts = {}) {
allow_anonymous: false, allow_anonymous: false,
owner_only_endpoints: ['/health'], owner_only_endpoints: ['/health'],
fallback_detail_header_policy: 'owner_only', fallback_detail_header_policy: 'owner_only',
advertise_anonymous_key: false,
}; };
if (!existsSync(path)) return { ...DEFAULTS }; if (!existsSync(path)) return { ...DEFAULTS };
try { try {
@@ -484,6 +554,7 @@ export function loadAuthConfigSync(opts = {}) {
fallback_detail_header_policy: ['owner_only', 'all', 'none'].includes(auth.fallback_detail_header_policy) fallback_detail_header_policy: ['owner_only', 'all', 'none'].includes(auth.fallback_detail_header_policy)
? auth.fallback_detail_header_policy ? auth.fallback_detail_header_policy
: DEFAULTS.fallback_detail_header_policy, : DEFAULTS.fallback_detail_header_policy,
advertise_anonymous_key: typeof auth.advertise_anonymous_key === 'boolean' ? auth.advertise_anonymous_key : DEFAULTS.advertise_anonymous_key,
}; };
} catch { } catch {
// Malformed JSON / unreadable file → safe defaults // Malformed JSON / unreadable file → safe defaults
+60
View File
@@ -485,6 +485,64 @@ function _defaultBinaryExists() {
} }
} }
// ── doctorChecks (ADR 0002 Amendment 7, D67) ──────────────────────────────
// Per-plugin probe templates consumed by `olp doctor`. Each check returns
// { status, message, evidence? } where evidence.fix_commands[] flow into the
// next_action.ai_executable[] block and evidence.human_steps[] into
// next_action.human_required[].
//
// Probes:
// anthropic.cli_available — `claude --version` resolves on PATH (or via OLP_CLAUDE_BIN)
// anthropic.oauth_token_present — `readAuthArtifact()` returns a non-empty accessToken
//
// Both probes share the existing test seams (binaryExists / readAuthArtifact) so the
// suite can stub them deterministically without spawning the real binary.
export function doctorChecks({ _binaryExistsFn, _authReadFn } = {}) {
const binaryExists = _binaryExistsFn ?? _defaultBinaryExists;
const authRead = _authReadFn ?? readAuthArtifact;
return [
{
id: 'anthropic.cli_available',
category: 'provider',
async run() {
if (binaryExists()) {
return { status: 'ok', message: '`claude` binary resolved on PATH' };
}
return {
status: 'fail',
message: '`claude` binary not found on PATH (and OLP_CLAUDE_BIN unset/invalid)',
evidence: {
fix_commands: [
'npm install -g @anthropic-ai/claude-code',
],
reference: 'https://docs.anthropic.com/en/docs/claude-code/setup',
},
};
},
},
{
id: 'anthropic.oauth_token_present',
category: 'provider',
async run() {
const auth = authRead();
if (auth?.accessToken) {
return { status: 'ok', message: 'OAuth credential present (.credentials.json / env / keychain)' };
}
return {
status: 'fail',
message: 'Anthropic OAuth credential missing — none of ANTHROPIC_OAUTH_TOKEN env, ~/.claude/.credentials.json, or login keychain returned a token',
evidence: {
human_steps: [
'run: claude (the first interactive launch prompts for browser OAuth login)',
],
reference: 'https://docs.anthropic.com/en/docs/claude-code/setup#authentication',
},
};
},
},
];
}
// ── Provider export ─────────────────────────────────────────────────────── // ── Provider export ───────────────────────────────────────────────────────
// Conforms to ADR 0002 § "Provider contract (v1.0 interface)" including D4 // Conforms to ADR 0002 § "Provider contract (v1.0 interface)" including D4
// contractVersion fold-in per reviewer F3. // contractVersion fold-in per reviewer F3.
@@ -509,6 +567,8 @@ const anthropic = {
estimateCost, estimateCost,
quotaStatus, quotaStatus,
healthCheck, healthCheck,
// ADR 0002 Amendment 7 (D67): OPTIONAL doctorChecks() — consumed by `olp doctor`.
doctorChecks: () => doctorChecks(),
hints: { hints: {
requiresTTY: false, requiresTTY: false,
concurrentSpawnSafe: true, concurrentSpawnSafe: true,
+15
View File
@@ -30,6 +30,13 @@
* collectAllChunks directly. ADR 0002 Amendment 3 (D23). * collectAllChunks directly. ADR 0002 Amendment 3 (D23).
*/ */
/**
* @typedef {Object} DoctorCheck
* @property {string} id - unique per check, e.g. 'anthropic.cli_available'
* @property {'provider'} category - fixed for plugin-contributed checks (ADR 0002 Amendment 7)
* @property {function} run - async () => { status: 'ok'|'fail'|'warn', message: string, evidence?: { fix_commands?: string[], human_steps?: string[], reference?: string } }
*/
/** /**
* @typedef {Object} ProviderContractV1 * @typedef {Object} ProviderContractV1
* @property {string} name - unique lowercase key * @property {string} name - unique lowercase key
@@ -41,6 +48,7 @@
* @property {function} estimateCost - (request) => {inputTokens, outputTokensEstimate, currency, usd}|null * @property {function} estimateCost - (request) => {inputTokens, outputTokensEstimate, currency, usd}|null
* @property {function} quotaStatus - async (authContext) => {available, percentUsed, resetsAt, pool}|null * @property {function} quotaStatus - async (authContext) => {available, percentUsed, resetsAt, pool}|null
* @property {function} healthCheck - async () => {ok: boolean, latencyMs: number, error?: string} * @property {function} healthCheck - async () => {ok: boolean, latencyMs: number, error?: string}
* @property {function} [doctorChecks] - OPTIONAL () => DoctorCheck[] (ADR 0002 Amendment 7, D67)
* @property {ProviderHints} hints * @property {ProviderHints} hints
*/ */
@@ -113,6 +121,13 @@ export function validateProvider(p) {
errors.push('healthCheck must be a function'); errors.push('healthCheck must be a function');
} }
// ADR 0002 Amendment 7 (D67): doctorChecks() is optional. When present it must be
// a function; absence is allowed (plugin contributes no provider-tier checks to
// `olp doctor` — built-in server/system/auth checks still run).
if (p.doctorChecks !== undefined && typeof p.doctorChecks !== 'function') {
errors.push('doctorChecks must be a function or omitted');
}
if (!p.hints || typeof p.hints !== 'object') { if (!p.hints || typeof p.hints !== 'object') {
errors.push('hints must be an object with { requiresTTY, concurrentSpawnSafe, maxConcurrent } + optional { maxSpawnTimeMs, cacheable }'); errors.push('hints must be an object with { requiresTTY, concurrentSpawnSafe, maxConcurrent } + optional { maxSpawnTimeMs, cacheable }');
} else { } else {
+163 -3
View File
@@ -157,6 +157,36 @@ function resolveCodexBin() {
// D6 assumption A2: auth file is named auth.json (unconfirmed — D7 will pin). // D6 assumption A2: auth file is named auth.json (unconfirmed — D7 will pin).
// D6 assumption A3: access token field is `access_token` or `token` (unconfirmed). // D6 assumption A3: access token field is `access_token` or `token` (unconfirmed).
// //
// ── D75 (v0.4.2) F1 — codex CLI v0.133.0 schema pin ─────────────────────────
//
// Real codex CLI v0.133.0 auth.json (verified empirically on PI231 / Mac mini,
// 2026-05-26 E2E session):
//
// {
// "auth_mode": "chatgpt",
// "OPENAI_API_KEY": null | "<key>",
// "tokens": {
// "id_token": "<JWT>",
// "access_token": "<opaque-or-JWT>", <-- THIS is the access token
// "refresh_token": "<opaque>",
// "account_id": "<uuid>"
// },
// "last_refresh": "<ISO8601>"
// }
//
// D6 assumption A3 originally tried `creds.access_token` at TOP level. Under
// codex v0.133.0 this field does not exist at the top level → readAuthArtifact()
// returned null even when the user had fully completed `codex login`. OLP then
// reported "auth artifact missing" via /health and `olp doctor`, and refused
// to spawn codex — false negative blocking the entire openai provider.
//
// Fix: prepend `creds?.tokens?.access_token` to the precedence chain. Keep all
// existing fallbacks unchanged so older codex CLI versions (pre-v0.133) and any
// future shape variants still resolve.
//
// Authority pin: codex CLI v0.133.0 source + on-disk auth.json captured during
// PI231 E2E. See D75 commit body for the verification transcript.
//
// Returns { accessToken: string } or null (never throws). // Returns { accessToken: string } or null (never throws).
export function readAuthArtifact() { export function readAuthArtifact() {
// 1. Explicit test override — always takes precedence. // 1. Explicit test override — always takes precedence.
@@ -165,7 +195,12 @@ export function readAuthArtifact() {
try { try {
const raw = readFileSync(authPathOverride, 'utf8'); const raw = readFileSync(authPathOverride, 'utf8');
const creds = JSON.parse(raw); const creds = JSON.parse(raw);
const token = creds?.access_token ?? creds?.token ?? creds?.accessToken; // D75 F1: codex CLI v0.133.0 nests the token under `tokens.access_token`.
// Preserve top-level fallbacks for backward / forward compat.
const token = creds?.tokens?.access_token
?? creds?.access_token
?? creds?.token
?? creds?.accessToken;
if (token && typeof token === 'string') return { accessToken: token }; if (token && typeof token === 'string') return { accessToken: token };
} catch { /* fall through */ } } catch { /* fall through */ }
return null; // explicit path set but file missing / malformed return null; // explicit path set but file missing / malformed
@@ -178,8 +213,12 @@ export function readAuthArtifact() {
try { try {
const raw = readFileSync(authPath, 'utf8'); const raw = readFileSync(authPath, 'utf8');
const creds = JSON.parse(raw); const creds = JSON.parse(raw);
// D6 assumption A3: try common OAuth field names in precedence order. // D75 F1: codex CLI v0.133.0 nests the token under `tokens.access_token`.
const token = creds?.access_token ?? creds?.token ?? creds?.accessToken; // Try the nested location FIRST, then fall back to legacy top-level fields.
const token = creds?.tokens?.access_token
?? creds?.access_token
?? creds?.token
?? creds?.accessToken;
if (token && typeof token === 'string') return { accessToken: token }; if (token && typeof token === 'string') return { accessToken: token };
} catch { /* file missing or malformed */ } } catch { /* file missing or malformed */ }
@@ -242,9 +281,30 @@ export function irToCodex(irRequest) {
// model string (e.g., gpt-5.5, gpt-5.4, gpt-5.3-codex). // model string (e.g., gpt-5.5, gpt-5.4, gpt-5.3-codex).
// PROMPT: "Initial instruction for the task. Use '-' to pipe the prompt // PROMPT: "Initial instruction for the task. Use '-' to pipe the prompt
// from stdin." // from stdin."
//
// ── D75 (v0.4.2) F2 — codex CLI v0.133.0 trusted-directory sandbox ─────────
// codex CLI v0.133.0 added a trusted-directory sandbox: invocations outside
// a git repo (or outside any directory explicitly trusted via
// `codex config trusted-directories`) refuse with:
// "Not inside a trusted directory and --skip-git-repo-check was not specified."
// and exit non-zero with zero NDJSON output → OLP surfaces SPAWN_FAILED with
// no usable chunks → fallback engine advances to next hop unnecessarily.
//
// The CWD that OLP spawns from is typically the server install dir (`~/olp/`
// on Pi231) which is a git repo on maintainer workstations but is NOT a git
// repo on most operator hosts. We bypass the sandbox unconditionally because
// OLP is the trusted caller (it is the operator's own server invoking its own
// configured Codex subscription via the documented `codex exec` automation
// entry point). The trusted-directory sandbox is a foot-gun safeguard for
// interactive users; OLP's spawn is non-interactive and pre-authorized.
//
// Authority: codex CLI v0.133.0 release notes / `codex exec --help` output
// documenting `--skip-git-repo-check`. Verified empirically on PI231 E2E
// 2026-05-26.
const args = [ const args = [
'exec', 'exec',
'--json', '--json',
'--skip-git-repo-check',
'--model', irRequest.model, '--model', irRequest.model,
]; ];
@@ -291,6 +351,51 @@ export function codexChunkToIR(rawNDJSONLine) {
if (!event || typeof event !== 'object') return null; if (!event || typeof event !== 'object') return null;
// ── D75 (v0.4.2) F3 — codex CLI v0.133.0 event shape pin ─────────────────
// Real codex CLI v0.133.0 NDJSON event stream (verified empirically on PI231
// / Mac mini, 2026-05-26 E2E session):
// {"type":"thread.started","thread_id":"019e..."}
// {"type":"turn.started"}
// {"type":"item.started","item":{"id":"item_0","type":"reasoning","text":""}}
// {"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"<response>"}}
// {"type":"turn.completed","usage":{"input_tokens":..,"output_tokens":..}}
//
// The D6 defensive parser recognized `content`/`delta`/`text` fields at the
// top level and `type === 'stop'`/`done === true`. None of these match
// v0.133.0's actual shape → every chunk was silently dropped → response body
// had `content: null`. F3 adds three NEW recognizers (item.completed →
// agent_message; turn.completed → stop; turn.failed → error) BEFORE the
// legacy fallback chain. Legacy recognizers preserved for forward/backward
// compat (older codex versions; future shape variants).
// F3-a: agent_message item completion.
// codex v0.133.0 emits assistant text as a single item.completed event whose
// item.type is 'agent_message' and item.text carries the full text. There
// are no incremental deltas — the entire response arrives in one chunk.
if (event.type === 'item.completed'
&& event.item?.type === 'agent_message'
&& typeof event.item?.text === 'string') {
return { type: 'delta', content: event.item.text };
}
// F3-b: turn completion → stop chunk.
// codex v0.133.0 emits turn.completed with a usage block when the model
// finishes. We map this to IR stop with finish_reason 'stop'.
if (event.type === 'turn.completed') {
return { type: 'stop', finish_reason: 'stop' };
}
// F3-c: turn failure → error chunk.
// codex v0.133.0 emits turn.failed with an embedded error object when the
// turn cannot complete. Extract a human-readable message for the IR error.
if (event.type === 'turn.failed') {
const errMsg = (typeof event.error === 'string')
? event.error
: (event.error?.message ?? 'codex turn.failed');
return { type: 'error', error: errMsg };
}
// ── Legacy/fallback recognizers (kept for backward + forward compat) ────
// Error event: type === 'error' or error field present // Error event: type === 'error' or error field present
// A4: defensive — error shape unconfirmed; D7 will pin actual field names // A4: defensive — error shape unconfirmed; D7 will pin actual field names
if (event.type === 'error' || (event.error && typeof event.error === 'string')) { if (event.type === 'error' || (event.error && typeof event.error === 'string')) {
@@ -637,6 +742,59 @@ function _defaultBinaryExists() {
} }
} }
// ── doctorChecks (ADR 0002 Amendment 7, D67) ──────────────────────────────
// See lib/providers/anthropic.mjs doctorChecks header for the contract.
//
// Probes:
// openai.cli_available — `codex --version` resolves on PATH (or via OLP_CODEX_BIN)
// openai.auth_present — `readAuthArtifact()` returns a non-empty accessToken
// (Codex CLI reference § Authentication: credentials in $CODEX_HOME, default ~/.codex/auth.json)
export function doctorChecks({ _binaryExistsFn, _authReadFn } = {}) {
const binaryExists = _binaryExistsFn ?? _defaultBinaryExists;
const authRead = _authReadFn ?? readAuthArtifact;
return [
{
id: 'openai.cli_available',
category: 'provider',
async run() {
if (binaryExists()) {
return { status: 'ok', message: '`codex` binary resolved on PATH' };
}
return {
status: 'fail',
message: '`codex` binary not found on PATH (and OLP_CODEX_BIN unset/invalid)',
evidence: {
fix_commands: [
'npm install -g @openai/codex',
],
reference: 'https://developers.openai.com/codex/cli/reference',
},
};
},
},
{
id: 'openai.auth_present',
category: 'provider',
async run() {
const auth = authRead();
if (auth?.accessToken) {
return { status: 'ok', message: 'Codex auth artifact present ($CODEX_HOME/auth.json)' };
}
return {
status: 'fail',
message: 'Codex auth artifact missing — $CODEX_HOME/auth.json does not contain access_token (default $CODEX_HOME=~/.codex)',
evidence: {
human_steps: [
'run: codex (the first interactive launch prompts for OAuth login per Codex CLI reference § Authentication)',
],
reference: 'https://developers.openai.com/codex/cli/reference',
},
};
},
},
];
}
// ── Provider export ─────────────────────────────────────────────────────── // ── Provider export ───────────────────────────────────────────────────────
// Conforms to ADR 0002 § "Provider contract (v1.0 interface)" + contractVersion. // Conforms to ADR 0002 § "Provider contract (v1.0 interface)" + contractVersion.
@@ -662,6 +820,8 @@ const codex = {
estimateCost, estimateCost,
quotaStatus, quotaStatus,
healthCheck, healthCheck,
// ADR 0002 Amendment 7 (D67): OPTIONAL doctorChecks() — consumed by `olp doctor`.
doctorChecks: () => doctorChecks(),
hints: { hints: {
requiresTTY: false, // codex exec runs headless (per CLI reference § exec) requiresTTY: false, // codex exec runs headless (per CLI reference § exec)
concurrentSpawnSafe: true, // each invocation is independent concurrentSpawnSafe: true, // each invocation is independent
+56
View File
@@ -764,6 +764,60 @@ function _defaultBinaryExists() {
} }
} }
// ── doctorChecks (ADR 0002 Amendment 7, D67) ──────────────────────────────
// See lib/providers/anthropic.mjs doctorChecks header for the contract.
//
// Probes:
// mistral.cli_available — `vibe --version` resolves on PATH (or via OLP_VIBE_BIN)
// mistral.api_key_present — readAuthArtifact() returns apiKey (MISTRAL_API_KEY env or ~/.vibe/.env)
// (DOCS-2: https://docs.mistral.ai/mistral-vibe/terminal/configuration —
// auth from MISTRAL_API_KEY env / ~/.vibe/.env)
export function doctorChecks({ _binaryExistsFn, _authReadFn } = {}) {
const binaryExists = _binaryExistsFn ?? _defaultBinaryExists;
const authRead = _authReadFn ?? readAuthArtifact;
return [
{
id: 'mistral.cli_available',
category: 'provider',
async run() {
if (binaryExists()) {
return { status: 'ok', message: '`vibe` binary resolved on PATH' };
}
return {
status: 'fail',
message: '`vibe` binary not found on PATH (and OLP_VIBE_BIN unset/invalid)',
evidence: {
fix_commands: [
'npm install -g @mistralai/vibe',
],
reference: 'https://docs.mistral.ai/mistral-vibe/terminal/quickstart',
},
};
},
},
{
id: 'mistral.api_key_present',
category: 'provider',
async run() {
const auth = authRead();
if (auth?.apiKey) {
return { status: 'ok', message: 'Mistral API key present (env MISTRAL_API_KEY or ~/.vibe/.env)' };
}
return {
status: 'fail',
message: 'Mistral API key missing — neither MISTRAL_API_KEY env nor ~/.vibe/.env (or $VIBE_HOME/.env) supplied a key',
evidence: {
human_steps: [
'export MISTRAL_API_KEY=<your-key> # or write MISTRAL_API_KEY=... into ~/.vibe/.env',
],
reference: 'https://docs.mistral.ai/mistral-vibe/terminal/configuration',
},
};
},
},
];
}
// ── Provider export ─────────────────────────────────────────────────────── // ── Provider export ───────────────────────────────────────────────────────
// Conforms to ADR 0002 § "Provider contract (v1.0 interface)" + contractVersion. // Conforms to ADR 0002 § "Provider contract (v1.0 interface)" + contractVersion.
@@ -795,6 +849,8 @@ const mistral = {
estimateCost, estimateCost,
quotaStatus, quotaStatus,
healthCheck, healthCheck,
// ADR 0002 Amendment 7 (D67): OPTIONAL doctorChecks() — consumed by `olp doctor`.
doctorChecks: () => doctorChecks(),
hints: { hints: {
requiresTTY: false, // vibe --prompt runs headless per DOCS-1 programmatic mode requiresTTY: false, // vibe --prompt runs headless per DOCS-1 programmatic mode
concurrentSpawnSafe: true, // each invocation is independent concurrentSpawnSafe: true, // each invocation is independent
+153
View File
@@ -0,0 +1,153 @@
# olp-plugin
OpenClaw gateway plugin that exposes a `/olp` slash command on Telegram and
Discord, with subcommand parity to the local `olp` CLI (`bin/olp.mjs`) minus
mutating operations.
**Authority:** [ADR 0010 § Phase 4 D71-D73](../docs/adr/0010-phase-4-charter-operator-and-client-ux.md).
## Status
✅ Shipped at v0.4.0 (read-only subset of `olp` CLI).
## What you can do from chat
| Slash command | Maps to | Tier |
|---|---|---|
| `/olp status` | GET `/v0/management/status` | owner |
| `/olp health` | GET `/health` | public |
| `/olp usage` | GET `/v0/management/dashboard-data` | owner |
| `/olp models` | GET `/v1/models` | public |
| `/olp cache` | GET `/cache/stats` | owner |
| `/olp providers` | local registry view | public |
| `/olp chain show [model]` | local chain view (empty unless wired) | public |
| `/olp doctor` | informational only (HTTP doctor endpoint not yet shipped) | — |
| `/olp help` | usage text | — |
## What you can NOT do from chat (by design)
The following `olp` CLI subcommands are **deliberately not** ported to the
chat surface, because Telegram + Discord are shared / persistent message
streams and key material or raw audit logs should not be flowing across
them:
- `olp keys keygen` — key material would land in chat history
- `olp keys revoke` — accidental misclick could lock out clients
- `olp restart` — a misclick should not cycle the proxy
- `olp logs` — audit content may carry PII
Use SSH to the host running OLP and the local `olp` CLI for those.
## Install
The plugin is shipped inside the OLP repo at `olp-plugin/`. Two install paths:
### Option A — OpenClaw CLI
```bash
openclaw plugins install /path/to/olp/olp-plugin/
```
### Option B — symlink
```bash
mkdir -p ~/.openclaw/extensions/
ln -s /path/to/olp/olp-plugin/ ~/.openclaw/extensions/olp
```
Either path makes the plugin discoverable; restart the gateway to pick it up:
```bash
openclaw gateway restart
```
## Configure
Edit `~/.openclaw/openclaw.json` and add a config block for the `olp` plugin:
```json
{
"plugins": {
"olp": {
"proxyUrl": "http://127.0.0.1:4567",
"apiKey": "olp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
}
}
}
```
- `proxyUrl` — full URL of the OLP proxy. Default `http://127.0.0.1:4567`
(OLP's default since v0.4.0 / D60). Overridable via `OLP_PROXY_URL` or
`OLP_PORT` env if you run the gateway under launchd / systemd with custom
env.
- `apiKey`**owner-tier** OLP API key. Required for the subcommands marked
`owner` in the table above. Create one with:
```bash
# On the OLP host, NOT in chat:
npx olp-keys keygen --owner --name=openclaw-bot
# Capture the plaintext token from the output — it is printed exactly once.
```
Use a dedicated bot key (the `--name=openclaw-bot` example above) so you
can `npx olp-keys revoke --id=<id>` later without affecting the
maintainer's personal key.
## Use
In Telegram or Discord, after the gateway picks up the plugin:
```
/olp status
/olp usage
/olp models
/olp help
```
Output is wrapped in a monospace code block. Long responses are truncated
to fit Telegram's ~4096-char per-message limit; a `... [truncated, use SSH
for full]` suffix marks where the cut happened.
## Authorization model
The plugin sends `Authorization: Bearer <apiKey>` on every request. OLP's
server enforces:
- **public-tier endpoints** (`/health`, `/v1/models`) accept any non-revoked
key (or no key at all if `auth.allow_anonymous: true`).
- **owner-tier endpoints** (`/v0/management/*`, `/cache/stats`) reject any
non-owner key with 403.
If you see `401 unauthorized` or `403 forbidden` in chat:
- Verify the configured `apiKey` is a non-revoked **owner**-tier key.
- Verify the key was created on the same host running the OLP server (keys
are stored under `~/.olp/keys/` and validated by hash on the server side).
- Check the OLP server's `/health` directly with `curl` to confirm
reachability.
## Port resolution priority
1. `OLP_PROXY_URL` env (full URL) — useful when the gateway runs on a
different host than OLP and you proxy in via Tailscale.
2. `OLP_PORT` env (port only; localhost assumed).
3. Plugin config `proxyUrl`.
4. Fallback `http://127.0.0.1:4567`.
## Why no Telegram/Discord SDK dependency
OpenClaw provides the transport (Telegram bot + Discord bot are gateway
features). This plugin only registers a slash command — it does not open
its own websocket / long-poll connection. That means:
- No new npm dependency.
- No bot tokens stored in plugin config.
- Plugin works for any OpenClaw-supported chat surface (currently Telegram +
Discord; future surfaces inherit automatically).
## Cross-references
- [Local `olp` CLI](../bin/olp.mjs) — the full mutating-capable surface.
- [OCP `/ocp` plugin](https://github.com/dtzp555-max/ocp/tree/main/ocp-plugin) — the OCP predecessor this is ported from.
- [ADR 0010](../docs/adr/0010-phase-4-charter-operator-and-client-ux.md) — Phase 4 charter.
- [ADR 0007](../docs/adr/0007-multi-key-auth.md) — multi-key auth model that gates owner-tier subcommands.
+457
View File
@@ -0,0 +1,457 @@
/**
* OLP Plugin registers /olp as a native slash command in the OpenClaw gateway.
* Calls the local OLP proxy and formats the response for Telegram/Discord.
*
* Authority: ADR 0010 § Phase 4 D71-D73 (operator + client UX bundle). Ports
* OCP's ocp-plugin/index.js (https://github.com/dtzp555-max/ocp /ocp/ocp-plugin)
* to the OLP namespace with two structural differences:
*
* 1. **Read-only by design.** All mutating subcommands (`keygen`, `revoke`,
* `restart`, `logs`) are deliberately NOT ported. Telegram + Discord are
* shared / persistent surfaces; rotating an owner key or pulling raw audit
* logs from a chat client is a security regression. Use SSH + the local
* `olp` CLI for those operations.
*
* 2. **Bearer auth required.** OLP enforces multi-key auth at every /v1/* and
* /v0/management/* endpoint (ADR 0007 § 7). Owner-only subcommands need an
* OLP API key with owner_tier="owner". The plugin config carries that key;
* operators are advised to mint a dedicated bot key (NOT the maintainer's
* personal owner key) so revocation is scoped.
*
* Port resolution (in priority order):
* 1. OLP_PROXY_URL env (full URL, e.g. http://10.0.0.5:4567)
* 2. OLP_PORT env (port only; localhost assumed)
* 3. Plugin config `proxyUrl`
* 4. Fallback: http://127.0.0.1:4567 (OLP default port since v0.4.0 / D60)
*
* Subcommand parity with the local `olp` CLI (bin/olp.mjs at D64-D67) MINUS
* mutating operations. Mapping table is in ./README.md.
*/
// ── Output helpers (Telegram/Discord-friendly) ─────────────────────────────
/** Wrap output in a monospace code block (Telegram + Discord render this fine). */
export function mono(text) {
return "```\n" + text + "\n```";
}
/** ASCII progress bar — `pct` ∈ [0, 1] clamped. width=16 → 16 cells. */
export function bar(pct, width = 16) {
const p = Number.isFinite(pct) ? Math.max(0, Math.min(1, pct)) : 0;
const filled = Math.round(p * width);
return "█".repeat(filled) + "░".repeat(width - filled);
}
/** Status icon — used in `/olp status` summary lines. */
export function statusIcon(status) {
if (status === "ok" || status === true) return "🟢";
if (status === "degraded" || status === "warn") return "🟡";
return "🔴";
}
/** Truncate to fit Telegram's 4096-char message limit (with mono wrapper). */
export function truncateForChat(text, maxChars = 3900) {
if (text.length <= maxChars) return text;
const SUFFIX = "\n... [truncated, use SSH for full]";
// Reserve room for the suffix so the final string is <= maxChars.
const room = Math.max(0, maxChars - SUFFIX.length);
return text.slice(0, room) + SUFFIX;
}
// ── Proxy URL resolution ───────────────────────────────────────────────────
/**
* Resolve the proxy base URL. Order: OLP_PROXY_URL env OLP_PORT env
* plugin config `proxyUrl` default http://127.0.0.1:4567.
*
* Exported for test injection. Pass `env` to override `process.env` and
* `config` to override the plugin config block.
*/
export function resolveProxyUrl({ env = process.env, config = {} } = {}) {
if (env.OLP_PROXY_URL) return env.OLP_PROXY_URL;
if (env.OLP_PORT) return `http://127.0.0.1:${env.OLP_PORT}`;
if (config.proxyUrl) return config.proxyUrl;
return "http://127.0.0.1:4567";
}
// ── HTTP helper ────────────────────────────────────────────────────────────
/**
* Fetch a JSON endpoint. Sends Authorization: Bearer <apiKey> if provided.
*
* Exported so unit tests can inject a fetch mock via the `fetchFn` arg.
*/
export async function fetchJSON(url, { apiKey, fetchFn = fetch, timeoutMs = 15000 } = {}) {
const headers = {};
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
const resp = await fetchFn(url, {
headers,
signal: AbortSignal.timeout(timeoutMs),
});
if (resp.status === 401) {
throw new Error(`401 unauthorized — set plugin "apiKey" config (owner-tier required for ${new URL(url).pathname})`);
}
if (resp.status === 403) {
throw new Error(`403 forbidden — the configured key is not owner-tier (${new URL(url).pathname} is owner-only)`);
}
if (!resp.ok) {
throw new Error(`proxy ${resp.status}: ${resp.statusText}`);
}
return resp.json();
}
// ── Subcommand formatters ──────────────────────────────────────────────────
//
// Each cmdXxx() is pure: takes the JSON body the server returned, returns a
// string. The dispatcher fetches + delegates. This split makes the formatters
// unit-testable without an HTTP mock.
export function fmtStatus(body) {
const icon = statusIcon(body.ok ? "ok" : "fail");
let out = `${icon} OLP v${body.version ?? "?"} | up ${body.uptime_human ?? "?"}\n`;
out += `Providers: ${body.providers?.enabled ?? "?"} enabled / ${body.providers?.available ?? "?"} available\n`;
if (body.providers?.status && typeof body.providers.status === "object") {
for (const [name, s] of Object.entries(body.providers.status)) {
const i = statusIcon(s?.ok ? "ok" : "fail");
out += ` ${i} ${name.padEnd(10)} ${s?.error ? `(${String(s.error).slice(0, 40)})` : "ok"}\n`;
}
}
out += `Requests: ${body.stats?.total_requests ?? 0} total | ${body.stats?.active_requests ?? 0} active\n`;
const c = body.stats?.cache;
if (c) {
out += `Cache: ${c.hits ?? 0} hit / ${c.misses ?? 0} miss / ${c.size ?? "?"} entries\n`;
}
if (Array.isArray(body.recent_errors) && body.recent_errors.length > 0) {
out += `\nRecent errors (${body.recent_errors.length}):\n`;
for (const e of body.recent_errors.slice(0, 3)) {
const ts = (e.time || "").slice(11, 19);
const msg = String(e.message ?? "").slice(0, 60);
out += ` ${ts} ${e.provider ?? "?"} ${msg}\n`;
}
}
return out;
}
export function fmtHealth(body) {
const icon = statusIcon(body.ok ? "ok" : "fail");
let out = `${icon} Status: ${body.ok ? "ok" : "fail"} | v${body.version ?? "?"}\n`;
if (body.uptime_human || body.uptimeHuman) {
out += `Uptime: ${body.uptime_human ?? body.uptimeHuman}\n`;
}
// D74 P2-4 fix: server.mjs /health full payload is
// body.providers = { enabled: N, available: N, status: { <name>: {...} } }
// The plugin previously iterated Object.entries(body.providers), which
// surfaced `enabled`, `available`, and `status` as pseudo-providers
// (typeof status === 'object' → loop body fired with name='status').
// Walk providers.status when present; fall back to providers.* for the
// older OCP shape that lacks the .status wrapper.
if (body.providers && typeof body.providers === "object") {
const enabled = body.providers.enabled;
const available = body.providers.available;
if (typeof enabled === "number" || typeof available === "number") {
out += `Providers: ${enabled ?? "?"} enabled / ${available ?? "?"} available\n`;
}
const statusMap = body.providers.status && typeof body.providers.status === "object"
? body.providers.status
: body.providers;
const entries = Object.entries(statusMap).filter(
([name, s]) => typeof s === "object" && s !== null && name !== "enabled" && name !== "available" && name !== "status"
);
if (entries.length > 0) {
out += `\nProviders:\n`;
for (const [name, s] of entries) {
const i = statusIcon(s?.ok ? "ok" : "fail");
const spawn = typeof s?.activeSpawns === "number" ? ` spawns=${s.activeSpawns}` : "";
out += ` ${i} ${name}${spawn}\n`;
}
}
}
return out;
}
export function fmtUsage(body) {
let out = "OLP usage (24h)\n";
out += "─────────────────────────────\n";
const w = body.window_24h ?? body.usage_24h ?? {};
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]
out += `Cache hit (24h): ${(body.cache_hit_24h * 100).toFixed(1)}%\n`;
}
if (Array.isArray(body.quota) && body.quota.length > 0) {
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`;
}
}
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)) {
out += ` ${String(f.count ?? "?").padStart(5)} ${(f.chain ?? []).join(" → ")}\n`;
}
}
return out;
}
export function fmtModels(body) {
const data = body.data ?? [];
if (data.length === 0) return "No models.";
let out = `Models (${data.length})\n`;
out += "─────────────────────────────\n";
for (const m of data) {
out += ` ${m.id}${m.owned_by ? ` (${m.owned_by})` : ""}\n`;
}
return out;
}
export function fmtCache(body) {
let out = "OLP cache\n";
out += "─────────────────────────────\n";
out += `Entries: ${body.size ?? body.entries ?? "?"}\n`;
out += `Hits: ${body.hits ?? 0}\n`;
out += `Misses: ${body.misses ?? 0}\n`;
out += `Inflight: ${body.inflightCount ?? 0}\n`;
if (typeof body.evictions === "number") {
out += `Evictions: ${body.evictions}\n`;
}
return out;
}
export function fmtProviders(registry, configEnabled) {
const providers = registry?.providers ?? {};
const names = Object.keys(providers);
let out = `OLP providers (${names.length} in registry)\n`;
out += "─────────────────────────────\n";
for (const name of names) {
const p = providers[name];
const enabled = configEnabled?.[name] === true ? "enabled " : "disabled";
const tier = p?.tier ?? "?";
const modelCount = (p?.models ?? []).length;
const candidate = p?.candidate === true ? " (candidate)" : "";
out += ` ${name.padEnd(10)} ${enabled} tier ${tier} models ${String(modelCount).padStart(2)}${candidate}\n`;
}
return out;
}
export function fmtChainShow(chains, target) {
if (!chains || Object.keys(chains).length === 0) {
return "No chains configured.";
}
if (target) {
const chain = chains[target];
if (!chain) {
return `Model "${target}" not in routing.chains.\nConfigured: ${Object.keys(chains).join(", ")}`;
}
let out = `${target}:\n`;
for (const hop of chain) {
out += `${typeof hop === "string" ? hop : JSON.stringify(hop)}\n`;
}
return out;
}
let out = "OLP routing.chains\n";
out += "─────────────────────────────\n";
for (const [model, chain] of Object.entries(chains)) {
out += `${model}:\n`;
for (const hop of chain) {
out += `${typeof hop === "string" ? hop : JSON.stringify(hop)}\n`;
}
}
return out;
}
export function fmtDoctor(body) {
// body shape: { checks, fail_count, warn_count, ok_count, kind, summary, next_action }
let out = `OLP doctor — ${body.summary ?? "?"}\n`;
out += "─────────────────────────────\n";
for (const c of (body.checks ?? []).slice(0, 20)) {
const icon = c.status === "ok" ? "🟢" : c.status === "warn" ? "🟡" : "🔴";
out += ` ${icon} ${String(c.id ?? "?").padEnd(34)} ${String(c.message ?? "").slice(0, 60)}\n`;
}
if ((body.checks ?? []).length > 20) {
out += ` ... (${body.checks.length - 20} more — use SSH 'olp doctor' for full output)\n`;
}
out += `\nfail=${body.fail_count ?? 0} warn=${body.warn_count ?? 0} ok=${body.ok_count ?? 0} kind=${body.kind ?? "?"}\n`;
if (body.next_action?.ai_executable?.length > 0) {
out += `\nNext (AI-executable):\n`;
for (const cmd of body.next_action.ai_executable.slice(0, 5)) {
out += ` $ ${cmd}\n`;
}
}
if (body.next_action?.human_required?.length > 0) {
out += `\nNext (human-required):\n`;
for (const step of body.next_action.human_required.slice(0, 5)) {
out += `${step}\n`;
}
}
return out;
}
// ── Help text ──────────────────────────────────────────────────────────────
export function cmdHelp() {
return `OLP Commands (read-only)
/olp status Process + provider + cache snapshot
/olp health /health endpoint (public-ok)
/olp usage 24h request stats + per-provider quota
/olp models Available models
/olp cache Cache stats
/olp providers Provider registry + enabled flags
/olp chain show [model] Routing chain(s) from server config
/olp doctor Diagnostic checks + suggested next action
/olp help This message
Mutating commands (keygen / revoke / restart / logs) are NOT
available from chat by design use SSH + the local 'olp' CLI.`;
}
// ── Dispatcher ─────────────────────────────────────────────────────────────
/**
* Pure subcommand dispatcher. Returns `{ text }` always caller wraps in
* mono() for the chat surface.
*
* Exported for unit tests. Injects:
* - fetchFn (default global fetch)
* - proxyUrl (resolved upstream so tests can pin)
* - apiKey (from plugin config)
* - registry (models-registry.json caller provides since this module
* ships in `olp-plugin/` and the file is a sibling concept living at
* the repo root)
* - chainsLocal (local routing.chains override usually empty; the
* server-side /v0/management/status already exposes provider+chain
* state, but chain-show is the one local-config touch that mirrors
* `olp chain show`)
*/
export async function dispatch(rawArgs, opts) {
const {
proxyUrl,
apiKey,
registry,
chainsLocal = {},
fetchFn = fetch,
} = opts;
const raw = (rawArgs || "").trim();
const spaceIdx = raw.indexOf(" ");
const subcmd = spaceIdx === -1 ? raw : raw.slice(0, spaceIdx);
const subargs = spaceIdx === -1 ? "" : raw.slice(spaceIdx + 1).trim();
try {
switch (subcmd) {
case "status": {
const body = await fetchJSON(`${proxyUrl}/v0/management/status`, { apiKey, fetchFn });
return { text: fmtStatus(body) };
}
case "health": {
const body = await fetchJSON(`${proxyUrl}/health`, { apiKey, fetchFn });
return { text: fmtHealth(body) };
}
case "usage": {
const body = await fetchJSON(`${proxyUrl}/v0/management/dashboard-data`, { apiKey, fetchFn });
return { text: fmtUsage(body) };
}
case "models": {
const body = await fetchJSON(`${proxyUrl}/v1/models`, { apiKey, fetchFn });
return { text: fmtModels(body) };
}
case "cache": {
const body = await fetchJSON(`${proxyUrl}/cache/stats`, { apiKey, fetchFn });
return { text: fmtCache(body) };
}
case "providers": {
// models-registry.json + (optionally) the server's idea of which are
// enabled. /v0/management/status carries that and is owner-gated, but
// /v1/models lists what's exposed publicly. For the chat surface we
// use the public registry shape — config.enabled is a local-config
// concept and the plugin doesn't have filesystem access to
// ~/.olp/config.json by design.
return { text: fmtProviders(registry, {}) };
}
case "chain": {
// /olp chain show [model]
const inner = subargs.trim();
const parts = inner.split(/\s+/).filter(Boolean);
if (parts[0] !== "show") {
return { text: `Usage: /olp chain show [model]` };
}
const target = parts[1] ?? null;
return { text: fmtChainShow(chainsLocal, target) };
}
case "doctor": {
// /v0/management/doctor doesn't exist yet — D67 added doctor as a CLI
// surface only. The plugin reports that explicitly so families know
// to use SSH + `olp doctor` rather than waiting for a chat response.
return {
text: `/olp doctor is not yet wired through HTTP (planned for Phase 5+).\n` +
`Run \`olp doctor\` over SSH on the host running the OLP server\n` +
`for the full diagnostic output.`,
};
}
case "help":
case "--help":
case "-h":
case "":
return { text: cmdHelp() };
default:
return { text: `Unknown subcommand: ${subcmd}\n\n${cmdHelp()}` };
}
} catch (err) {
return { text: `OLP error: ${err.message ?? String(err)}` };
}
}
// ── Plugin entry point (consumed by OpenClaw gateway) ──────────────────────
/**
* OpenClaw plugin entry. The gateway calls this with its `api` registration
* object; we register the `/olp` slash command and a handler that resolves
* the proxy URL + API key from plugin config + env, then delegates to
* `dispatch()`.
*
* The `registry` (models-registry.json) is read lazily inside the handler
* so that a stale plugin install doesn't bind to an old snapshot and so
* the plugin module stays import-time-pure for tests.
*/
export default function (api) {
api.registerCommand({
name: "olp",
description: "OLP — usage, health, status, doctor, etc. (read-only)",
acceptsArgs: true,
requireAuth: true,
handler: async (ctx) => {
const cfg = ctx.config ?? {};
const apiKey = cfg.apiKey ?? process.env.OLP_API_KEY ?? null;
const proxyUrl = resolveProxyUrl({ env: process.env, config: cfg });
// Lazy load to avoid binding the import to the OpenClaw gateway's
// ESM cache (which may pre-resolve at plugin-discovery time).
let registry;
try {
// Convert file path to URL for ESM `import(...)`.
const { fileURLToPath, pathToFileURL } = await import("node:url");
const { dirname, resolve: pathResolve } = await import("node:path");
const here = dirname(fileURLToPath(import.meta.url));
const registryUrl = pathToFileURL(pathResolve(here, "..", "models-registry.json")).href;
registry = (await import(registryUrl, { with: { type: "json" } })).default;
} catch (e) {
registry = { providers: {} };
}
// Local chains config is not currently surfaced through HTTP. For
// chat-side chain-show we fall back to an empty map; operators
// wanting the live config view should use `olp chain show` over SSH.
const chainsLocal = {};
const { text } = await dispatch(ctx.args ?? "", {
proxyUrl,
apiKey,
registry,
chainsLocal,
});
return { text: mono(truncateForChat(text)) };
},
});
}
+22
View File
@@ -0,0 +1,22 @@
{
"id": "olp",
"name": "OLP Commands",
"description": "Slash commands for OLP — /olp status, /olp usage, /olp health, etc. (read-only by design; mutations require SSH).",
"version": "0.4.0",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"proxyUrl": {
"type": "string",
"default": "http://127.0.0.1:4567",
"description": "Full URL of the OLP proxy. Default matches D60 OLP_PORT=4567. Overridable via OLP_PROXY_URL or OLP_PORT env."
},
"apiKey": {
"type": "string",
"description": "Owner-tier OLP API key (olp_xxx). Required for owner-only subcommands (status / usage / cache). Use a dedicated bot key — DO NOT share the maintainer's personal owner key."
}
},
"required": ["apiKey"]
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "olp-plugin",
"version": "0.4.0",
"description": "OpenClaw gateway plugin — /olp slash commands for the OLP proxy (read-only)",
"main": "index.js",
"type": "module",
"keywords": ["openclaw", "plugin", "olp", "proxy"],
"license": "MIT",
"private": true,
"openclaw": {
"type": "plugin",
"id": "olp",
"pluginManifest": "openclaw.plugin.json"
}
}
+20 -3
View File
@@ -1,19 +1,36 @@
{ {
"name": "olp", "name": "olp",
"version": "0.3.2", "version": "0.4.3",
"description": "Personal multi-provider LLM proxy. Successor to OCP. One HTTP endpoint, multiple subscriptions behind it, automatic routing + fallback + caching.", "description": "Personal multi-provider LLM proxy. Successor to OCP. One HTTP endpoint, multiple subscriptions behind it, automatic routing + fallback + caching.",
"type": "module", "type": "module",
"main": "server.mjs", "main": "server.mjs",
"bin": { "bin": {
"olp": "./bin/olp.mjs",
"olp-keys": "./bin/olp-keys.mjs", "olp-keys": "./bin/olp-keys.mjs",
"olp-audit-rotate": "./bin/olp-audit-rotate.mjs" "olp-audit-rotate": "./bin/olp-audit-rotate.mjs",
"olp-connect": "./bin/olp-connect"
}, },
"scripts": { "scripts": {
"start": "node server.mjs", "start": "node server.mjs",
"test": "node test-features.mjs", "test": "node test-features.mjs",
"olp": "node bin/olp.mjs",
"olp-keys": "node bin/olp-keys.mjs", "olp-keys": "node bin/olp-keys.mjs",
"olp-audit-rotate": "node bin/olp-audit-rotate.mjs" "olp-audit-rotate": "node bin/olp-audit-rotate.mjs",
"olp-connect": "bash bin/olp-connect"
}, },
"files": [
"server.mjs",
"bin/",
"lib/",
"olp-plugin/",
"models-registry.json",
"dashboard.html",
"README.md",
"ALIGNMENT.md",
"CHANGELOG.md",
"LICENSE",
"docs/"
],
"engines": { "engines": {
"node": ">=18" "node": ">=18"
}, },
+513 -27
View File
@@ -14,7 +14,18 @@
* - Zero runtime npm dependencies in the proxy core * - Zero runtime npm dependencies in the proxy core
* *
* Env vars: * Env vars:
* OLP_PORT listen port (default: 3456) * OLP_PORT listen port (default: 4567 since D60 / v0.4.0 moved off 3456
* so OLP can co-host with OCP for migration windows. ADR 0010 §
* Default port. Set OLP_PORT=3456 explicitly to restore the
* pre-D60 default when not co-hosting with OCP.)
* OLP_BIND listen address (default: 127.0.0.1 since D76 / v0.4.3). Set
* to 0.0.0.0 (or a specific interface IP) to accept connections
* from LAN clients (required for olp-connect <ip> to actually
* reach the server). Server emits a startup warn if BIND
* resolves to a non-loopback address AND auth.allow_anonymous
* is true (anonymous-key over LAN may be acceptable; anonymous-
* key over public internet is not see ADR 0011 § Deployment
* configurations).
*/ */
import { createServer } from 'node:http'; import { createServer } from 'node:http';
@@ -49,10 +60,12 @@ import {
loadFallbackConfigSync, loadFallbackConfigSync,
} from './lib/fallback/engine.mjs'; } from './lib/fallback/engine.mjs';
// Phase 2 / D45 — multi-key auth integration per ADR 0007. // Phase 2 / D45 — multi-key auth integration per ADR 0007.
// D69 (ADR 0011): findAdvertisedKey for /health.anonymousKey opt-in surface.
import { import {
validateKey, validateKey,
touchLastUsed, touchLastUsed,
loadAuthConfigSync, loadAuthConfigSync,
findAdvertisedKey,
ANONYMOUS_KEY_ID, ANONYMOUS_KEY_ID,
ENV_OWNER_KEY_ID, ENV_OWNER_KEY_ID,
} from './lib/keys.mjs'; } from './lib/keys.mjs';
@@ -71,7 +84,15 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
const pkg = JSON.parse(readFileSync(join(__dirname, 'package.json'), 'utf8')); const pkg = JSON.parse(readFileSync(join(__dirname, 'package.json'), 'utf8'));
const VERSION = pkg.version; const VERSION = pkg.version;
const PORT = parseInt(process.env.OLP_PORT ?? '3456', 10); const PORT = parseInt(process.env.OLP_PORT ?? '4567', 10);
// F5 / D76: OLP_BIND env. Defaults to 127.0.0.1 (loopback only — secure
// default). Operators expose LAN by setting OLP_BIND=0.0.0.0 (or a specific
// interface). Per ADR 0011 § Deployment configurations:
// - 127.0.0.1: trusted single-machine; safe with any auth posture
// - RFC1918 / tailnet / specific LAN IP: trusted-LAN — anonymous_key OK
// - 0.0.0.0: ALL interfaces — operator MUST ensure auth posture matches the
// network reachability (e.g. no advertise_anonymous_key on public IP)
const BIND = process.env.OLP_BIND ?? '127.0.0.1';
const BODY_LIMIT = 5 * 1024 * 1024; // 5 MB const BODY_LIMIT = 5 * 1024 * 1024; // 5 MB
// ── Logging ─────────────────────────────────────────────────────────────── // ── Logging ───────────────────────────────────────────────────────────────
@@ -87,6 +108,117 @@ function logEvent(level, event, data = {}) {
} }
} }
// ── SSE response headers (D61, ADR 0010 § Phase 4 D61-D63) ────────────────
// Canonical SSE response-header set used by every streaming response. Pulled
// out as a top-level constant so all streaming write sites share a single
// source of truth (per OCP db11105 lesson: drift between branches caused the
// "headers sent but no Buffering directive" 502 regression).
//
// Authority: RFC 8895 (text/event-stream); nginx `X-Accel-Buffering: no`
// disables the upstream proxy buffer (load-bearing for real-time delivery
// behind nginx / Cloudflare / Tailscale Funnel).
const SSE_DEFAULT_HEADERS = Object.freeze({
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
});
// ── D62 recentErrors[20] ring buffer (ADR 0010 § Phase 4 D61-D63) ─────────
// Last 20 server-side error events. Surfaced via /status (owner-only). Ported
// from OCP server.mjs:301 + 354-358 — same shape adapted to OLP's identity-
// aware error vocabulary (key_id present when known; never client-tier-only
// errors like 401/403 which would let brute-force loops fill the ring).
//
// Lifecycle: module-scope, in-memory only. NOT persisted across restart
// (OCP precedent: same; acceptable per the spec).
//
// Sanitization: filesystem-path-like tokens in `message` are replaced with
// `[path]` so an unintentionally logged absolute path doesn't surface in a
// `/status` payload an owner copy-pastes elsewhere. Port of OCP server.mjs:1395.
//
// 200-char message cap: matches OCP precedent; keeps the ring bounded even
// for verbose stack traces (the dashboard / `olp` CLI surface a one-line
// preview, not the full trace).
const RECENT_ERRORS_MAX = 20;
const recentErrors = [];
/**
* Push a server-side error event onto the recentErrors ring. Filters out
* client-tier auth rejections (401/403) so a brute-force loop cannot flood
* the ring. ProviderError instances are always recorded; other errors are
* recorded when `statusCode >= 500`.
*
* @param {object} opts
* @param {Error|null|undefined} opts.error
* @param {string|null} [opts.provider]
* @param {string|null} [opts.path]
* @param {number|null} [opts.statusCode]
*/
function _pushError({ error, provider = null, path = null, statusCode = null }) {
// D61-D63 reviewer P2-1 (defense-in-depth): explicitly reject 401/403 at the
// function level even if a caller mistakenly passes one. The current call
// sites all use the "no auth" path → never invoke _pushError, but a future
// contributor passing a ProviderError tagged statusCode=401 would otherwise
// slip past the isProviderError branch and flood the ring under brute force.
if (statusCode === 401 || statusCode === 403) return;
// Filter: any other status is recorded when (a) statusCode >= 500 OR (b) the
// error is a ProviderError (provider-shape failure, always interesting for
// operators).
const isProviderError = error?.code === 'PROVIDER_ERROR'
|| error?.code === 'SPAWN_FAILED'
|| error?.code === 'CONCURRENCY_LIMIT'
|| error?.constructor?.name === 'ProviderError';
const looksServerSide = statusCode !== null && statusCode >= 500;
if (!isProviderError && !looksServerSide) return;
const rawMessage = String(error?.message ?? error ?? 'unknown error');
// Strip filesystem-path-like tokens. Port of OCP server.mjs:1395 — the
// regex catches `/foo/bar` plus dotted/dashed variants. Replaces every
// match with `[path]`. Truncate to 200 chars after sanitization.
const sanitized = rawMessage
.replace(/\/[\w./-]+/g, '[path]')
.slice(0, 200);
recentErrors.push({
time: new Date().toISOString(),
message: sanitized,
code: error?.code ?? null,
provider,
path,
status_code: statusCode,
});
while (recentErrors.length > RECENT_ERRORS_MAX) recentErrors.shift();
}
/** @internal — test seam: clear the recentErrors ring. */
export function __clearRecentErrors() {
recentErrors.length = 0;
}
/** @internal — test seam: snapshot the recentErrors ring (copy). */
export function __snapshotRecentErrors() {
return recentErrors.slice();
}
// ── D63 request counters (ADR 0010 § Phase 4 D61-D63) ─────────────────────
// Module-scope counters surfaced via /status (owner-only). _totalRequests is
// the cumulative count of /v1/chat/completions invocations since process
// start; _activeRequests is the live concurrent count.
//
// NOT exposed via /health (already owner-trimmed per Phase 2 D46); only
// /status (owner-only_block) reads them. Ported from OCP `stats.totalRequests`
// + `stats.activeRequests` (server.mjs:288-300).
let _totalRequests = 0;
let _activeRequests = 0;
const _serverStartMs = Date.now();
/** @internal — test seam: reset request counters. */
export function __resetRequestCounters() {
_totalRequests = 0;
_activeRequests = 0;
}
// ── Startup config ──────────────────────────────────────────────────────── // ── Startup config ────────────────────────────────────────────────────────
// Read ~/.olp/config.json once at startup. Provides: // Read ~/.olp/config.json once at startup. Provides:
// - providers.enabled → which providers are loaded (ADR 0002 § Disable model) // - providers.enabled → which providers are loaded (ADR 0002 § Disable model)
@@ -117,10 +249,50 @@ if (_authConfig.allow_anonymous === true) {
message: 'config.json auth.allow_anonymous is true — all routes accept requests without an OLP API key; production deployments should set this to false (ADR 0007 § 7).', message: 'config.json auth.allow_anonymous is true — all routes accept requests without an OLP API key; production deployments should set this to false (ADR 0007 § 7).',
}); });
} }
// D69 (ADR 0011): startup-time prerequisite validation for /health.anonymousKey
// advertisement. The field is emitted ONLY when all three prerequisites hold:
// (1) auth.advertise_anonymous_key === true (config opt-in)
// (2) auth.allow_anonymous === true (anonymous tier must be reachable
// for the advertised key to be
// meaningful to zero-config callers)
// (3) at least one advertised key exists on disk (manifest with
// plaintext_advertise field, not revoked)
// Violations log warn at startup but the server still boots; the runtime
// handleHealth checks the same conditions and simply omits the field.
if (_authConfig.advertise_anonymous_key === true) {
if (_authConfig.allow_anonymous !== true) {
logEvent('warn', 'anonymous_key_advertised_but_denied', {
message: 'auth.advertise_anonymous_key=true but auth.allow_anonymous=false — the advertised key would not be usable anonymously. /health.anonymousKey will NOT be emitted until allow_anonymous=true. See ADR 0011.',
});
} else if (findAdvertisedKey() === null) {
logEvent('warn', 'anonymous_key_advertised_but_no_anonymous_key_exists', {
message: 'auth.advertise_anonymous_key=true but no active key with plaintext_advertise exists. Run `olp-keys keygen --anonymous --advertise` to create one. /health.anonymousKey will NOT be emitted until then. See ADR 0011.',
});
}
// F5 / D76 (ADR 0011 Deployment configurations): publishing the anonymous
// key via /health is only safe when /health is reachable ONLY from a
// trusted network. If OLP_BIND is set to a non-loopback address AND
// advertise_anonymous_key is true, warn the operator. We can't tell from
// here whether the non-loopback bind is "trusted LAN" (RFC1918 / tailnet)
// or "public internet" — that's the operator's responsibility. The warn is
// a checkpoint, not a hard gate.
if (BIND !== '127.0.0.1' && BIND !== 'localhost' && BIND !== '::1') {
logEvent('warn', 'anonymous_key_advertised_with_lan_bind', {
message: `auth.advertise_anonymous_key=true with OLP_BIND=${BIND} — /health.anonymousKey will be reachable from any host that can connect to ${BIND}:${PORT}. Confirm this address is on a trusted LAN (RFC1918 / tailnet) — never a public IP. See ADR 0011 § Deployment configurations.`,
bind: BIND,
});
}
}
/** @internal — test seam: inject a synthetic auth config (no file I/O). */ /** @internal — test seam: inject a synthetic auth config (no file I/O). */
export function __setAuthConfig(config) { export function __setAuthConfig(config) {
_authConfig = config ?? { allow_anonymous: false, owner_only_endpoints: ['/health'], fallback_detail_header_policy: 'owner_only' }; _authConfig = config ?? { allow_anonymous: false, owner_only_endpoints: ['/health'], fallback_detail_header_policy: 'owner_only', advertise_anonymous_key: false };
// D69: defensively normalize so tests passing partial configs still get a
// boolean advertise_anonymous_key value (downstream code treats undefined
// as falsy but explicit normalisation matches loadAuthConfigSync's contract).
if (typeof _authConfig.advertise_anonymous_key !== 'boolean') {
_authConfig.advertise_anonymous_key = false;
}
} }
/** @internal — reset auth config to file-loaded state. */ /** @internal — reset auth config to file-loaded state. */
@@ -128,6 +300,76 @@ export function __resetAuthConfig() {
_authConfig = loadAuthConfigSync(); _authConfig = loadAuthConfigSync();
} }
// ── Streaming config (D61, ADR 0010 § Phase 4 D61-D63) ────────────────────
// Reads `streaming.heartbeat_interval_ms` from ~/.olp/config.json via the
// same loader used by routing/providers config. Default 0 = disabled;
// behaviour is opt-in. Tests inject synthetic configs via __setStreamingConfig.
let _streamingConfig = _startupConfig.streaming ?? { heartbeat_interval_ms: 0 };
/** @internal — test seam: inject a synthetic streaming config (no file I/O). */
export function __setStreamingConfig(config) {
_streamingConfig = config ?? { heartbeat_interval_ms: 0 };
}
/** @internal — reset streaming config to file-loaded state. */
export function __resetStreamingConfig() {
_streamingConfig = loadFallbackConfigSync().streaming ?? { heartbeat_interval_ms: 0 };
}
// ── SSE heartbeat (D61, ADR 0010 § Phase 4 D61-D63) ───────────────────────
// Port of OCP `startHeartbeat` (ocp/server.mjs:660-685). Emits `: keepalive\n\n`
// SSE comment frames on `res` every `intervalMs` ms during silent windows.
// The timer is reset on each real chunk write (`reset()`); cancelled on
// stream end / error / client disconnect (`stop()`).
//
// Discipline (load-bearing): this is a downstream liveness hint only — it
// MUST NOT abort or time out a request. The OCP timeout-tier regressions
// (v2.2-v2.5; see OCP spec) showed that any auto-cancellation primitive in
// the streaming branch is hostile to long-reasoning prompts.
//
// Per-attached-client: each call to startHeartbeat creates its own timer
// state. The streaming-singleflight `attached` client and the `source`
// client each get their own heartbeat instance (they share an underlying
// spawn but write to different `res` objects). The `res.on('close')` handler
// in the streaming branch calls `stop()` to cancel each client's timer
// independently.
//
// @param {import('node:http').ServerResponse} res
// @param {number} intervalMs — 0 or negative ⇒ no-op (timer never starts)
// @param {string} requestId — for the one-time `heartbeat_active` log event
// @returns {{ reset: () => void, stop: () => void }}
function startHeartbeat(res, intervalMs, requestId) {
if (!intervalMs || intervalMs <= 0) {
return { reset: () => {}, stop: () => {} };
}
let handle = null;
let hasFired = false;
const onFire = () => {
if (res.writableEnded || res.destroyed) return;
try {
res.write(': keepalive\n\n');
} catch {
// res may have been destroyed between the writableEnded check and the
// write — swallow so the heartbeat is a no-op on a dead socket.
return;
}
if (!hasFired) {
hasFired = true;
logEvent('info', 'heartbeat_active', { interval_ms: intervalMs, request_id: requestId });
}
handle = setTimeout(onFire, intervalMs);
};
handle = setTimeout(onFire, intervalMs);
return {
reset: () => {
if (handle) { clearTimeout(handle); handle = setTimeout(onFire, intervalMs); }
},
stop: () => {
if (handle) { clearTimeout(handle); handle = null; }
},
};
}
// ── Provider registry ───────────────────────────────────────────────────── // ── Provider registry ─────────────────────────────────────────────────────
// ALIGNMENT.md § Provider Inventory: 0 Enabled Providers at v0.1 unless // ALIGNMENT.md § Provider Inventory: 0 Enabled Providers at v0.1 unless
// ~/.olp/config.json has providers.enabled.X = true. // ~/.olp/config.json has providers.enabled.X = true.
@@ -567,9 +809,34 @@ async function handleHealth(req, res) {
}); });
} }
// D69 (ADR 0011): compute opt-in /health.anonymousKey. The field is added
// to BOTH trimmed and full payloads (the trimmed payload's whole purpose
// is to be readable by anonymous clients so they can self-bootstrap into
// a real key). Three prerequisites must hold (see startup warns above):
// (1) auth.advertise_anonymous_key === true
// (2) auth.allow_anonymous === true
// (3) findAdvertisedKey() returns a non-null active manifest
// When any fails, the field is absent (NOT null) — preserves the v0.3.x
// /health payload shape for clients that strict-validate keys.
let anonymousKey = null;
if (_authConfig.advertise_anonymous_key === true && _authConfig.allow_anonymous === true) {
try {
const adv = findAdvertisedKey();
if (adv && typeof adv.plaintext_advertise === 'string' && adv.plaintext_advertise.length > 0) {
anonymousKey = adv.plaintext_advertise;
}
} catch {
// Filesystem error reading manifests is non-fatal; just omit the field.
anonymousKey = null;
}
}
if (isGated && !isOwner) { if (isGated && !isOwner) {
// Trimmed payload per § 7.1. // Trimmed payload per § 7.1. D69: include anonymousKey here too — zero-
return sendJSON(res, 200, { ok: true, version: VERSION }); // config olp-connect callers read it BEFORE they have a key.
const trimmed = { ok: true, version: VERSION };
if (anonymousKey !== null) trimmed.anonymousKey = anonymousKey;
return sendJSON(res, 200, trimmed);
} }
// Full payload (owner OR /health removed from owner_only_endpoints). // Full payload (owner OR /health removed from owner_only_endpoints).
@@ -590,11 +857,13 @@ async function handleHealth(req, res) {
providerStatuses[name] = { ok: false, error: e.message, activeSpawns }; providerStatuses[name] = { ok: false, error: e.message, activeSpawns };
} }
} }
sendJSON(res, 200, { const fullPayload = {
ok: true, ok: true,
version: VERSION, version: VERSION,
providers: { enabled, available, status: providerStatuses }, providers: { enabled, available, status: providerStatuses },
}); };
if (anonymousKey !== null) fullPayload.anonymousKey = anonymousKey;
sendJSON(res, 200, fullPayload);
} }
/** /**
@@ -702,6 +971,22 @@ function handleModels(req, res) {
async function handleChatCompletions(req, res) { async function handleChatCompletions(req, res) {
const startMs = Date.now(); const startMs = Date.now();
// D63 (ADR 0010 § Phase 4 D61-D63): request counters for /status. Increment
// at the top (every request counts, even authentication failures); decrement
// in a single `res.on('close')` listener that fires on both normal end and
// client disconnect, ensuring _activeRequests cannot leak. `res.on('finish')`
// alone would miss client-disconnect-before-finish cases.
_totalRequests++;
_activeRequests++;
let _activeDecremented = false;
const _decrementActive = () => {
if (_activeDecremented) return;
_activeDecremented = true;
_activeRequests = Math.max(0, _activeRequests - 1);
};
res.on('close', _decrementActive);
res.on('finish', _decrementActive);
// Audit context — fields populated as the request proceeds; § 8 schema. // Audit context — fields populated as the request proceeds; § 8 schema.
// Fired on res.on('finish') below regardless of success / error path. // Fired on res.on('finish') below regardless of success / error path.
const auditCtx = { const auditCtx = {
@@ -968,13 +1253,25 @@ async function handleChatCompletions(req, res) {
} }
const chunks = []; const chunks = [];
// try/finally: releaseSpawn MUST fire on every exit path — success // D75 (v0.4.2) F7 — per-hop model override.
// (return at end), spawn throw (caught and re-thrown below), or the // The chain config (routing.chains[<requested-model>][<hop>].model) is the
// D16 truncation-salvage return. The finally is the only mechanism // model name to pass to THIS hop's provider plugin — NOT the model the
// that guarantees release across all three. // user originally requested. Pre-D75, executeHopFn used hopModel for the
// cache key + audit ctx but passed the ORIGINAL irReq (with irReq.model
// still set to the user's request) into hopProviderPlugin.spawn(). Result:
// a 2-hop chain like [{anthropic, claude-sonnet-4-6}, {openai, gpt-5.5}]
// would spawn codex with --model claude-sonnet-4-6 on hop 1 — openai rejects
// the unknown model and the chain dies. This broke the core OLP value prop
// (cross-provider fallback with provider-appropriate model substitution).
//
// Fix: build a per-hop IR variant with the hop's model substituted. Skip
// the clone when hopModel === irReq.model (single-provider chains and
// chain hops whose model matches the request). Authority: ADR 0004 §
// Chain advancement step 1 (per-hop config supplies provider AND model).
const hopIrReq = irReq.model === hopModel ? irReq : { ...irReq, model: hopModel };
try { try {
try { try {
for await (const irChunk of hopProviderPlugin.spawn(irReq, authContext)) { for await (const irChunk of hopProviderPlugin.spawn(hopIrReq, authContext)) {
// D16: check error chunks BEFORE pushing — preserves the invariant that // D16: check error chunks BEFORE pushing — preserves the invariant that
// chunks array contains only delta/stop chunks. Without this, the catch // chunks array contains only delta/stop chunks. Without this, the catch
// block's `chunks.length > 0` would mistake a single error chunk for // block's `chunks.length > 0` would mistake a single error chunk for
@@ -1166,9 +1463,16 @@ async function handleChatCompletions(req, res) {
// releaseSpawn fires exactly once in finally — regardless of normal // releaseSpawn fires exactly once in finally — regardless of normal
// exhaustion, mid-stream throw, or iterator.return() from cache-layer // exhaustion, mid-stream throw, or iterator.return() from cache-layer
// sourceAbortController propagation (§9). // sourceAbortController propagation (§9).
//
// D75 (v0.4.2) F7 — per-hop model override (streaming path).
// Mirror the buffered-path fix: pass the hop's configured model (streamModel)
// into streamPlugin.spawn(), not the user's original ir.model. Skip the
// clone when ir.model === streamModel. See executeHopFn() above for the
// full F7 rationale + authority citation.
const streamIr = ir.model === streamModel ? ir : { ...ir, model: streamModel };
return (async function* sourceWithRelease() { return (async function* sourceWithRelease() {
try { try {
for await (const irChunk of streamPlugin.spawn(ir, authContext)) { for await (const irChunk of streamPlugin.spawn(streamIr, authContext)) {
yield irChunk; yield irChunk;
} }
} finally { } finally {
@@ -1213,6 +1517,12 @@ async function handleChatCompletions(req, res) {
model: streamModel, model: streamModel,
error: e.message, error: e.message,
}); });
_pushError({
error: e,
provider: streamProvider,
path: '/v1/chat/completions',
statusCode: 502,
});
return sendError(res, 502, e.message ?? 'Provider error', 'provider_error', return sendError(res, 502, e.message ?? 'Provider error', 'provider_error',
olpHeaders({ providerUsed: streamProvider, modelUsed: streamModel, startMs, cacheStatus: 'miss', fallbackHops: 0 })); olpHeaders({ providerUsed: streamProvider, modelUsed: streamModel, startMs, cacheStatus: 'miss', fallbackHops: 0 }));
} }
@@ -1247,8 +1557,16 @@ async function handleChatCompletions(req, res) {
// after the response is fully sent, which is too late for our abort // after the response is fully sent, which is too late for our abort
// propagation needs. Without this wire-up the cache layer would never // propagation needs. Without this wire-up the cache layer would never
// learn of disconnect → potential orphan spawns. // learn of disconnect → potential orphan spawns.
// Forward declaration of the heartbeat handle so the close-listener
// installed here can stop the timer when the client disconnects.
// (The handle itself is assigned a few lines below — startHeartbeat
// is invoked after eager-headers run, but the close listener must
// already be wired so a disconnect during the source-factory await
// path still propagates iterator return + heartbeat stop.)
let heartbeatRef = null;
const onClose = () => { const onClose = () => {
try { stream.return?.(); } catch { /* best-effort */ } try { stream.return?.(); } catch { /* best-effort */ }
try { heartbeatRef?.stop?.(); } catch { /* best-effort */ }
}; };
res.on('close', onClose); res.on('close', onClose);
@@ -1279,6 +1597,37 @@ async function handleChatCompletions(req, res) {
// that pre-first-chunk errors can still produce a JSON 502 (matching // that pre-first-chunk errors can still produce a JSON 502 (matching
// the buffered path). Calling writeHead unconditionally was the D14 // the buffered path). Calling writeHead unconditionally was the D14
// defect. // defect.
//
// D61 (ADR 0010 § Phase 4 D61-D63): when streaming heartbeat is
// enabled (streaming.heartbeat_interval_ms > 0), headers must be
// flushed BEFORE the first chunk so the `: keepalive\n\n` SSE comment
// frame has somewhere to land. Without eager-headers the heartbeat
// would write into a buffer that the client never sees (OCP db11105
// lesson — the bug was that `ensureHeaders()` returned `false` after
// headers were sent for the "connection-dead" case, which made all
// post-headers chunks no-ops). We send eager-headers only when
// heartbeat is enabled — preserving D14's lazy-headers default so
// pre-first-chunk errors still surface as JSON 502s when heartbeat
// is off (the legacy / current path).
const heartbeatIntervalMs = _streamingConfig.heartbeat_interval_ms ?? 0;
const eagerHeaders = heartbeatIntervalMs > 0;
if (eagerHeaders && !res.headersSent) {
res.writeHead(200, {
...SSE_DEFAULT_HEADERS,
...streamHeaders,
});
}
// Heartbeat instance is per-attached-client (each call to
// startHeartbeat creates its own timer state; source and attached
// clients write to different `res` objects and therefore each get
// their own timer). Heartbeat fires only AFTER headers are sent
// (eager-headers gate above); when intervalMs is 0 the returned
// object is a no-op stub so `reset()` / `stop()` calls are cheap.
const heartbeat = startHeartbeat(res, heartbeatIntervalMs, requestId);
// Expose to the onClose listener so a client disconnect cancels the
// timer immediately rather than waiting for the finally block.
heartbeatRef = heartbeat;
const streamedChunks = []; const streamedChunks = [];
let firstChunkEmitted = false; let firstChunkEmitted = false;
@@ -1294,6 +1643,12 @@ async function handleChatCompletions(req, res) {
error: irChunk.error, error: irChunk.error,
}); });
auditCtx.error_code = 'streaming_error_after_first_chunk'; auditCtx.error_code = 'streaming_error_after_first_chunk';
_pushError({
error: { message: irChunk.error ?? 'streaming_error_after_first_chunk', code: 'SPAWN_FAILED' },
provider: streamProvider,
path: '/v1/chat/completions',
statusCode: null, // headers already sent; status_code intentionally null — record by error code only (D61-D63 reviewer P2-2: explicit intent over numeric-but-meaningless 200)
});
res.write(irChunkToOpenAISSE({ type: 'stop', finish_reason: 'length' }, requestId, ir.model)); res.write(irChunkToOpenAISSE({ type: 'stop', finish_reason: 'length' }, requestId, ir.model));
res.write(SSE_DONE); res.write(SSE_DONE);
res.end(); res.end();
@@ -1304,15 +1659,16 @@ async function handleChatCompletions(req, res) {
if (!res.headersSent) { if (!res.headersSent) {
res.writeHead(200, { res.writeHead(200, {
'Content-Type': 'text/event-stream', ...SSE_DEFAULT_HEADERS,
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
...streamHeaders, ...streamHeaders,
}); });
} }
streamedChunks.push(irChunk); streamedChunks.push(irChunk);
res.write(irChunkToOpenAISSE(irChunk, requestId, ir.model)); res.write(irChunkToOpenAISSE(irChunk, requestId, ir.model));
// D61: reset the heartbeat timer on every real chunk so the next
// keepalive frame fires only after another silent window of
// intervalMs ms (rather than rapidly chasing the data stream).
heartbeat.reset();
firstChunkEmitted = true; firstChunkEmitted = true;
if (irChunk.type === 'stop') { if (irChunk.type === 'stop') {
@@ -1349,10 +1705,7 @@ async function handleChatCompletions(req, res) {
// yielded), emit headers + [DONE] so the response is still valid SSE. // yielded), emit headers + [DONE] so the response is still valid SSE.
if (!res.headersSent) { if (!res.headersSent) {
res.writeHead(200, { res.writeHead(200, {
'Content-Type': 'text/event-stream', ...SSE_DEFAULT_HEADERS,
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
...streamHeaders, ...streamHeaders,
}); });
} }
@@ -1373,6 +1726,12 @@ async function handleChatCompletions(req, res) {
model: streamModel, model: streamModel,
error: e.message, error: e.message,
}); });
_pushError({
error: e,
provider: streamProvider,
path: '/v1/chat/completions',
statusCode: null, // headers already sent; status_code intentionally null — record by error code only (D61-D63 reviewer P2-2)
});
res.write(irChunkToOpenAISSE({ type: 'stop', finish_reason: 'length' }, requestId, ir.model)); res.write(irChunkToOpenAISSE({ type: 'stop', finish_reason: 'length' }, requestId, ir.model));
res.write(SSE_DONE); res.write(SSE_DONE);
res.end(); res.end();
@@ -1384,6 +1743,12 @@ async function handleChatCompletions(req, res) {
error: e.message, error: e.message,
}); });
auditCtx.error_code = e?.code ?? 'provider_error'; auditCtx.error_code = e?.code ?? 'provider_error';
_pushError({
error: e,
provider: streamProvider,
path: '/v1/chat/completions',
statusCode: 502,
});
if (!res.headersSent) { if (!res.headersSent) {
sendError(res, 502, e.message ?? 'Provider error', 'provider_error', sendError(res, 502, e.message ?? 'Provider error', 'provider_error',
olpHeaders({ providerUsed: streamProvider, modelUsed: streamModel, startMs, cacheStatus: 'miss', fallbackHops: 0 })); olpHeaders({ providerUsed: streamProvider, modelUsed: streamModel, startMs, cacheStatus: 'miss', fallbackHops: 0 }));
@@ -1396,6 +1761,10 @@ async function handleChatCompletions(req, res) {
// source caller acquired). The req-close listener is detached here // source caller acquired). The req-close listener is detached here
// so it doesn't leak past the response lifetime. // so it doesn't leak past the response lifetime.
res.removeListener('close', onClose); res.removeListener('close', onClose);
// D61: cancel the heartbeat timer on every exit path (normal stop,
// error, generator-exhausted, client-disconnect). The no-op stub
// returned when intervalMs<=0 makes this cheap when disabled.
heartbeat.stop();
} }
return; return;
} }
@@ -1411,6 +1780,12 @@ async function handleChatCompletions(req, res) {
} catch (e) { } catch (e) {
// executeWithFallback throws only on programming errors (empty chain). // executeWithFallback throws only on programming errors (empty chain).
logEvent('error', 'fallback_engine_error', { error: e.message }); logEvent('error', 'fallback_engine_error', { error: e.message });
_pushError({
error: e,
provider: null,
path: '/v1/chat/completions',
statusCode: 500,
});
return sendError(res, 500, 'Internal server error', 'internal_error', return sendError(res, 500, 'Internal server error', 'internal_error',
olpErrorHeaders({ startMs, model: ir.model })); olpErrorHeaders({ startMs, model: ir.model }));
} }
@@ -1434,6 +1809,20 @@ async function handleChatCompletions(req, res) {
triedProviders, triedProviders,
error: originalError?.message, error: originalError?.message,
}); });
// D62: surface the chain-exhausted / provider-error onto the recentErrors
// ring so /status (D63) shows it to owner identities. The ring filter
// requires either a ProviderError code or statusCode >= 500; spawn errors
// typically map to 502 below — fall back to 500 if no statusCode is
// present so a programming-error throw still records.
{
const httpStatus = originalError?.statusCode ?? originalError?.status ?? 502;
_pushError({
error: originalError ?? new Error('unknown chain-exhausted error'),
provider: providerUsed ?? (chain[0]?.provider ?? null),
path: '/v1/chat/completions',
statusCode: httpStatus,
});
}
// Emit exhausted header if more than one provider was tried // Emit exhausted header if more than one provider was tried
const exhaustedHeader = triedProviders.length > 1 const exhaustedHeader = triedProviders.length > 1
@@ -1540,11 +1929,13 @@ async function handleChatCompletions(req, res) {
// Streaming response path: burst replay from buffered chunks. // Streaming response path: burst replay from buffered chunks.
// Reaches here only when: bypassCacheForFirstHop=true OR preCheckHit=true OR chain.length>1. // Reaches here only when: bypassCacheForFirstHop=true OR preCheckHit=true OR chain.length>1.
// (Single-hop cache-miss streaming is handled by the real-streaming path above.) // (Single-hop cache-miss streaming is handled by the real-streaming path above.)
// D61: SSE headers are emitted from the shared SSE_DEFAULT_HEADERS
// constant so all streaming paths agree on `X-Accel-Buffering: no`.
// Heartbeat is intentionally not wired in the buffered replay path: the
// burst write completes synchronously into the socket buffer, so silent
// windows are bounded by the chunk count, not by provider think-time.
res.writeHead(200, { res.writeHead(200, {
'Content-Type': 'text/event-stream', ...SSE_DEFAULT_HEADERS,
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
...headers, ...headers,
}); });
@@ -1715,6 +2106,89 @@ async function handleManagementQuota(req, res) {
}); });
} }
/**
* GET /v0/management/status
*
* D63 (ADR 0010 § Phase 4 D61-D63). Combined snapshot of process lifecycle,
* per-provider health, request counters, cache stats, and recentErrors[20].
* Owner-only_block per ADR 0008 §8 (matches the rest of the management
* namespace).
*
* Port of OCP server.mjs:1151-1188 adapted to OLP's namespace + identity model:
* - Path is /v0/management/status (not root /status OLP's namespace
* discipline is stricter than OCP's).
* - No "plan" field (OCP's plan came from the Anthropic Pro/Max API which
* OLP does not call directly).
* - Adds provider-quota subset + cache stats + cumulative request counters.
* - recentErrors is filtered upstream (no 401/403 entries) and capped at 20.
*
* Response shape:
* {
* ok: true,
* version: "0.4.0-phase4",
* uptime_ms: <number>,
* uptime_human: "Xh Ym Zs",
* started_at: <ISO8601>,
* providers: {
* enabled: <number>,
* available: <number>,
* status: { [providerKey]: { ok, ...healthCheck, activeSpawns } }
* },
* stats: {
* total_requests: <number>,
* active_requests: <number>,
* cache: { hits, misses, size, inflightCount, ... },
* },
* recent_errors: [{ time, message, code, provider, path, status_code }, ...],
* generated_at: <ISO8601>,
* }
*/
async function handleManagementStatus(req, res) {
return _runOwnerOnlyManagementEndpoint(req, res, 'GET', '/v0/management/status',
async (_req, res2, _identity, _auditCtx) => {
const now = Date.now();
const uptimeMs = now - _serverStartMs;
const hours = Math.floor(uptimeMs / 3600000);
const minutes = Math.floor((uptimeMs % 3600000) / 60000);
const seconds = Math.floor((uptimeMs % 60000) / 1000);
// Provider status subset: ok + activeSpawns + a single optional error
// string (mirrors the /health full payload shape but trimmed — /status
// is for owner monitoring, not external probes).
const providerStatus = {};
for (const [name, provider] of loadedProviders) {
const activeSpawns = getActiveSpawnCount(name);
try {
const hc = await provider.healthCheck();
providerStatus[name] = { ...hc, activeSpawns };
} catch (err) {
providerStatus[name] = { ok: false, error: err?.message ?? String(err), activeSpawns };
}
}
const payload = {
ok: true,
version: VERSION,
uptime_ms: uptimeMs,
uptime_human: `${hours}h ${minutes}m ${seconds}s`,
started_at: new Date(_serverStartMs).toISOString(),
providers: {
enabled: loadedProviders.size,
available: listAllProviderNames().length,
status: providerStatus,
},
stats: {
total_requests: _totalRequests,
active_requests: _activeRequests,
cache: cacheStore.stats(),
},
recent_errors: recentErrors.slice(),
generated_at: new Date().toISOString(),
};
sendJSON(res2, 200, payload);
});
}
/** /**
* GET /cache/stats * GET /cache/stats
* Live in-memory CacheStore stats. Owner-only_block. * Live in-memory CacheStore stats. Owner-only_block.
@@ -1764,6 +2238,9 @@ async function router(req, res) {
if (method === 'GET' && path === '/v0/management/quota') { if (method === 'GET' && path === '/v0/management/quota') {
return await handleManagementQuota(req, res); return await handleManagementQuota(req, res);
} }
if (method === 'GET' && path === '/v0/management/status') {
return await handleManagementStatus(req, res);
}
if (method === 'GET' && path === '/cache/stats') { if (method === 'GET' && path === '/cache/stats') {
return await handleCacheStats(req, res); return await handleCacheStats(req, res);
} }
@@ -1772,6 +2249,12 @@ async function router(req, res) {
sendError(res, 404, `Route ${method} ${path} not found`, 'not_found'); sendError(res, 404, `Route ${method} ${path} not found`, 'not_found');
} catch (e) { } catch (e) {
logEvent('error', 'unhandled_request_error', { method, path, error: e?.message }); logEvent('error', 'unhandled_request_error', { method, path, error: e?.message });
_pushError({
error: e,
provider: null,
path,
statusCode: 500,
});
if (!res.headersSent) { if (!res.headersSent) {
sendError(res, 500, 'Internal server error', 'internal_error'); sendError(res, 500, 'Internal server error', 'internal_error');
} }
@@ -1804,10 +2287,13 @@ const isMain = (() => {
if (isMain) { if (isMain) {
const server = createOlpServer(); const server = createOlpServer();
server.listen(PORT, '127.0.0.1', () => { server.listen(PORT, BIND, () => {
const enabledCount = loadedProviders.size; const enabledCount = loadedProviders.size;
// D74 P3-5: banner no longer hardcodes the phase. Derives from VERSION
// (which advances at every Phase close) so banner stays accurate
// without future-maintenance touch-ups at every Phase boundary.
process.stdout.write( process.stdout.write(
`OLP v${VERSION} listening on :${PORT} (${enabledCount} providers enabled — Phase 1 in progress)\n`, `OLP v${VERSION} listening on :${PORT} (${enabledCount} providers enabled)\n`,
); );
}); });
} }
+2471
View File
File diff suppressed because it is too large Load Diff