Compare commits

..
Author SHA1 Message Date
taodengandClaude <claude-opus-4-8> <noreply@anthropic.com> 5ba407078f docs(readme): drop the CLAUDE_SYSTEM_PROMPT row (dead env var) + fix systemd stop to --user
Reviewer traced consumers: SYSTEM_PROMPT (server.mjs:326) is only echoed on
/health (:2906) and the startup log (:3270) — extractSystemPrompt() never reads
it, so the row documented behavior that does not exist. The dead var + the two
stale in-code comments (server.mjs:19, :1084) go on the backlog: wire it or
remove it (a server.mjs change, out of this docs PR's scope).

Also: setup.mjs installs the systemd unit under --user (:514), so the
Troubleshooting stop line loses the sudo and gains --user.

Co-Authored-By: Claude <claude-opus-4-8> <noreply@anthropic.com>
2026-07-17 09:30:28 +10:00
taodengandClaude <claude-opus-4-8> <noreply@anthropic.com> a70684fa3c docs(readme): sweep stale content — 6 models, drop phantom ocp stop, document 2 live env vars, fix ocp-connect claim
P1 findings from a full README-vs-code staleness audit (each verified against the
tree at 0c3e42b):

- "5 models" x4 (L124/151/174/323) -> 6; the /v1/models curl example (L244) and
  ocp-connect sample output (L353) now include claude-sonnet-5 (added #152).
- Troubleshooting told users to run `ocp stop`, which has never existed (the ocp
  case table has no stop) -> replaced with the real launchctl/systemctl commands
  + a note that stopping goes through the service manager.
- CLAUDE_SYSTEM_PROMPT (server.mjs:326, applied at :1084) and CLAUDE_MCP_CONFIG
  (server.mjs:1124 -p path; lib/tui/session.mjs:447 FULL_TOOLS panes) are live
  config with no env-table row -> added both rows.
- "ocp-connect detects and configures Claude Code, Cursor, ..." over-claimed:
  it auto-configures OpenClaw only, prints hints for Cursor/Cline/Continue/opencode,
  and has no Claude Code logic at all (OCP exposes an OpenAI-compat surface;
  Claude Code speaks the Anthropic protocol) -> reworded L28 + removed Claude Code
  from the client-connect prompt template (L160).
- Upgrade examples presented v3.14 as the frontier -> refreshed to current-era
  numbers (v3.21.0->v3.21.1 patch, v3.18->v3.22 cross-minor). Historical feature
  attributions ("as of v3.14.0", bootstrap-quirk notes) kept — they are facts.

Docs only; no code change. P0 (billing status note) and P2 (structure) are
tracked separately.

Co-Authored-By: Claude <claude-opus-4-8> <noreply@anthropic.com>
2026-07-17 09:24:41 +10:00
0c3e42b2e4 feat(models): repoint default sonnet alias to claude-sonnet-5 (#168)
* feat(models): add Claude Sonnet 5 to models.json SPOT

Adds `claude-sonnet-5` (the latest Sonnet, supported by claude CLI >= 2.1.206)
to models.json — the single source of truth (ADR 0003). Both the /v1/models
endpoint and setup.mjs OpenClaw registration derive from it automatically.

- New model entry `claude-sonnet-5` (reasoning, 200k ctx, 16k max tokens),
  mirroring the existing Sonnet entry shape.
- Point the `sonnet` alias at `claude-sonnet-5` (newest Sonnet), consistent
  with `opus` -> `claude-opus-4-8`. Previous `claude-sonnet-4-6` is retained
  for pinning.
- README "Available Models" table updated (release-kit 5.3).
- Update the aliases.sonnet SPOT test to the new default.

Endpoint class: B.1 (/v1/models), data-only via the models.json SPOT.
Authorized by ADR 0006 (OpenAI shim scope) + ADR 0003 (models.json SPOT).
Verified: `claude --model claude-sonnet-5 -p` returns a valid response on a
current subscription CLI (2.1.206); npm test green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(models): make PR #152 purely additive + close ocp-connect drift + real SPOT test

Addresses the maintainer's review. Rescopes this PR to the additive change only —
adding claude-sonnet-5 to models.json — and defers the `sonnet` alias repoint to
its own PR per Iron Rule 11 (the alias is the default for every request that omits
`model`; repointing it is a behavior change that deserves separate review + a
CHANGELOG entry). No server.mjs change, so no cli.js citation required.

Metadata confirmed unchanged: contextWindow 200000 / maxTokens 16384 stay, per the
maintainer's correction (OCP truncates at MAX_PROMPT_CHARS, and contextWindow feeds
OpenClaw's compaction budget — advertising a larger window than OCP delivers just
makes OpenClaw overshoot).

Fixes vs review:

1. Reverted `aliases.sonnet` back to claude-sonnet-4-6 — this PR only *adds* the
   model; the repoint ships separately. README updated to match (5 is available by
   full ID; 4-6 remains the alias default).

2. Replaced the tautological SPOT test. The old assertion read a literal out of
   models.json and asserted it equalled the same literal — it passed even with a
   dangling alias. Added referential-integrity tests: every aliases/legacyAliases
   value must resolve to a real models[].id, plus an explicit assertion that
   claude-sonnet-5 exists in models[]. This is the guard that actually catches an
   alias pointing at a non-existent model (VALID_MODELS keys on alias names, never
   targets, so nothing else checks this).

3. Fixed ocp-connect classification drift. Its prefix table pinned "claude-sonnet-4",
   which misses "claude-sonnet-5" and falls through to the non-reasoning / 8k-output
   default. Broadened both the model_meta and alias_prefixes tables to family
   prefixes (claude-opus / claude-sonnet / claude-haiku) so any future versioned ID
   classifies correctly with no per-model edit. /v1/models does not expose
   reasoning/maxTokens (OpenAI /v1/models schema has no such fields — adding them
   would be a Rule 2 invention), so family classification stays in ocp-connect.
   primary_model stays claude-sonnet-4-6, matching the (unchanged) sonnet alias — it
   moves with the alias in the repoint PR.

Tests: 266 passed, 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(models): repoint default `sonnet` alias to claude-sonnet-5

Split out from #152 per Iron Rule 11: the additive model entry (#152) lands the
claude-sonnet-5 metadata; this PR makes the behavior change — moving the default
`sonnet` alias from claude-sonnet-4-6 to claude-sonnet-5.

`aliases.sonnet` is the model used for every /v1/chat/completions request that omits
`model` (server.mjs default) and, via ocp-connect, OpenClaw's OCP primary. Repointing
it changes behavior for every such client, so it gets its own PR + CHANGELOG entry
separate from the additive entry.

- models.json: aliases.sonnet -> claude-sonnet-5 (claude-sonnet-4-6 kept by full ID
  for pinning). Both are pricing tier_3_15 — no cost regression.
- ocp-connect: primary_model now prefers claude-sonnet-5 (falls back to 4-6, then
  first model), tracking the alias default so OpenClaw's primary matches.
- README: swap the "default for sonnet alias" annotation onto claude-sonnet-5.
- CHANGELOG: Unreleased § Changed entry documenting the default change + how to pin.
- test: SPOT assertion updated to claude-sonnet-5; referential-integrity tests from
  #152 continue to guard that the alias target actually exists in models[].

No server.mjs change, so no cli.js citation required.

Depends on #152 (needs the claude-sonnet-5 models[] entry to exist, else the
referential-integrity test fails). Rebase/merge after #152 lands.

Tests: 266 passed, 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: vvlasy-openclaw <vvlasy@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: dtzp555 <dtzp555@gmail.com>
2026-07-17 08:10:33 +10:00
dtzp555-maxGitHubtaodengClaude <claude-opus-4-8> <noreply@anthropic.com>
0fc8d6973b chore(release): v3.22.1 — retitle unpublished v3.22.0 + fold in #161 (Windows resolve) and #152 (Sonnet 5) (#169)
v3.22.0 (#166) was merged but never tagged; #161 and #152 then landed on main,
so the prepared release no longer matched HEAD. Owner opted to renumber: the
v3.22.0 CHANGELOG section becomes v3.22.1 (with an explicit version note),
gains entries for #161 and #152, and the tag will be cut at this release
commit — no tagging of historical commits needed. package.json 3.22.0 → 3.22.1.

Semver note: still a minor-family bump from 3.21.1 (features: #156/#158/#159,
plus #152's new model entry); 3.22.0 is simply skipped — semver requires
increasing versions, not contiguous ones.

Release-kit walk (delta vs #166's walk): #152 → README "Available Models"
table row already present (added in #152 itself) + models.json is the SPOT;
#161 → no new env var/endpoint; README §Windows guidance unchanged (Windows
support deliberately NOT advertised until #167 lands + real-Windows E2E).

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude <claude-opus-4-8> <noreply@anthropic.com>
2026-07-17 07:59:45 +10:00
27216646c8 feat(models): add Claude Sonnet 5 to models.json SPOT (#152)
* feat(models): add Claude Sonnet 5 to models.json SPOT

Adds `claude-sonnet-5` (the latest Sonnet, supported by claude CLI >= 2.1.206)
to models.json — the single source of truth (ADR 0003). Both the /v1/models
endpoint and setup.mjs OpenClaw registration derive from it automatically.

- New model entry `claude-sonnet-5` (reasoning, 200k ctx, 16k max tokens),
  mirroring the existing Sonnet entry shape.
- Point the `sonnet` alias at `claude-sonnet-5` (newest Sonnet), consistent
  with `opus` -> `claude-opus-4-8`. Previous `claude-sonnet-4-6` is retained
  for pinning.
- README "Available Models" table updated (release-kit 5.3).
- Update the aliases.sonnet SPOT test to the new default.

Endpoint class: B.1 (/v1/models), data-only via the models.json SPOT.
Authorized by ADR 0006 (OpenAI shim scope) + ADR 0003 (models.json SPOT).
Verified: `claude --model claude-sonnet-5 -p` returns a valid response on a
current subscription CLI (2.1.206); npm test green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(models): make PR #152 purely additive + close ocp-connect drift + real SPOT test

Addresses the maintainer's review. Rescopes this PR to the additive change only —
adding claude-sonnet-5 to models.json — and defers the `sonnet` alias repoint to
its own PR per Iron Rule 11 (the alias is the default for every request that omits
`model`; repointing it is a behavior change that deserves separate review + a
CHANGELOG entry). No server.mjs change, so no cli.js citation required.

Metadata confirmed unchanged: contextWindow 200000 / maxTokens 16384 stay, per the
maintainer's correction (OCP truncates at MAX_PROMPT_CHARS, and contextWindow feeds
OpenClaw's compaction budget — advertising a larger window than OCP delivers just
makes OpenClaw overshoot).

Fixes vs review:

1. Reverted `aliases.sonnet` back to claude-sonnet-4-6 — this PR only *adds* the
   model; the repoint ships separately. README updated to match (5 is available by
   full ID; 4-6 remains the alias default).

2. Replaced the tautological SPOT test. The old assertion read a literal out of
   models.json and asserted it equalled the same literal — it passed even with a
   dangling alias. Added referential-integrity tests: every aliases/legacyAliases
   value must resolve to a real models[].id, plus an explicit assertion that
   claude-sonnet-5 exists in models[]. This is the guard that actually catches an
   alias pointing at a non-existent model (VALID_MODELS keys on alias names, never
   targets, so nothing else checks this).

3. Fixed ocp-connect classification drift. Its prefix table pinned "claude-sonnet-4",
   which misses "claude-sonnet-5" and falls through to the non-reasoning / 8k-output
   default. Broadened both the model_meta and alias_prefixes tables to family
   prefixes (claude-opus / claude-sonnet / claude-haiku) so any future versioned ID
   classifies correctly with no per-model edit. /v1/models does not expose
   reasoning/maxTokens (OpenAI /v1/models schema has no such fields — adding them
   would be a Rule 2 invention), so family classification stays in ocp-connect.
   primary_model stays claude-sonnet-4-6, matching the (unchanged) sonnet alias — it
   moves with the alias in the repoint PR.

Tests: 266 passed, 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: vvlasy-openclaw <vvlasy@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 07:35:42 +10:00
d501e786b8 fix(init): resolve Windows claude.exe only (#161)
* fix(init): resolve Windows claude.exe only

Windows startup can resolve npm or Git Bash shims from PATH and then pass that path into shell-less child_process calls. Those .cmd, .bat, .ps1, or extensionless shim matches are not spawnable as the CLAUDE binary, so fail fast with a clear hint instead of returning an unusable path.

No cli.js citation: this only changes local startup binary discovery and fatal diagnostics. It does not change any Class A/Class B endpoint, header, request field, response field, or wire behavior.

Co-Authored-By: Codex <codex@openai.com>

* fix(init): simplify Windows claude lookup

Remove the redundant where.exe claude.exe probe and explain the intentional native .exe allow-list for shell-less Windows spawning.

Alignment: no cli.js citation applies; this changes only local executable discovery and fatal diagnostics, not a Class A or Class B wire operation.

Co-Authored-By: claude-flow <ruv@ruv.net>

---------

Co-authored-by: nyxst4ck <289980115+nyxst4ck@users.noreply.github.com>
Co-authored-by: Codex <codex@openai.com>
Co-authored-by: nyxst4ck <nyxst4ck@users.noreply.github.com>
Co-authored-by: claude-flow <ruv@ruv.net>
2026-07-16 09:34:35 +10:00
dtzp555-maxGitHubtaodengClaude <claude-opus-4-8> <noreply@anthropic.com>
b7463a63f5 chore(release): v3.22.0 — TUI effort/pool/streaming (opt-in) + post-audit hardening (#166)
Consolidates #155–#165 into a minor release. Version 3.21.1 → 3.22.0.

Minor (not patch) because it adds user-facing opt-in features and new env vars
(OCP_TUI_EFFORT #156, OCP_TUI_POOL_SIZE #158, OCP_TUI_STREAM + OCP_TUI_STREAM_HOLDBACK/
_DIR/_POLL_MS #159/#160). All default OFF, so the default request path (-p /
--output-format stream-json) is byte-for-byte unchanged — no breaking change.

Release-kit walk (CLAUDE.md 5.5): all four new env vars already carry README
§ "Environment Variables" rows + dedicated § "How It Works" coverage (added by the
feature PRs); no new endpoint (TUI streaming reuses /v1/chat/completions); no
models.json change (Available Models table unchanged — #152 not merged). Version is
sourced from package.json (server.mjs VERSION = _pkg.version), so no other file needs
editing. The README:899 "pre-3.21.1" note is historical (the #148 boot-reap migration)
and stays.

Tag push (v3.22.0) triggers .github/workflows/release.yml to create the GitHub Release.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude <claude-opus-4-8> <noreply@anthropic.com>
2026-07-16 05:47:32 +10:00
dtzp555-maxGitHubtaodengClaude <claude-opus-4-8> <noreply@anthropic.com>
eeec2bf83d fix(setup): never carry test-only key-store redirection vars into a server OCP launches (A4) (#165)
* fix(setup): never carry test-only key-store redirection vars into a server OCP launches (A4)

Defense-in-depth for the key-store isolation shipped in #163, plus a correction to the
overstated claim that fix's comments made. Surfaced by an independent (Codex) re-review.

Background: keys.mjs honors OCP_DIR_OVERRIDE only when NODE_ENV === "test", so the key store
can be pointed at a scratch dir for the test suite. If BOTH vars reached a production daemon's
environment, it would open a scratch/empty key store instead of ~/.ocp/ocp.db — in AUTH_MODE=multi
a silent total auth outage. #163's comments claimed a production server "runs without NODE_ENV, so
it CANNOT honor the override no matter how the variable got in." That is not something keys.mjs can
enforce — it is only true while the daemon's env happens to lack NODE_ENV=test. This PR makes it
true for every server OCP itself launches, and softens the docs to stop overclaiming.

Three parts (all in OCP's own launch/installer paths — no server.mjs change, no cli.js analogue):

1. scripts/lib/plist-merge.mjs — new exported NEVER_PRESERVE = {NODE_ENV, OCP_DIR_OVERRIDE},
   stripped from the preserved set in BOTH mergePlistEnv and mergeSystemdEnv. The preservation
   rule ("keys only in the EXISTING unit are kept verbatim") was the vector: a unit that once
   carried these test-only vars would otherwise survive every setup re-run. setup.mjs's template
   never injects them, so preservation was the only entry path, and this closes it.

2. ocp (cmd_restart manual fallback) — the one direct `node server.mjs` launch OCP controls now
   runs under `env -u NODE_ENV -u OCP_DIR_OVERRIDE`, so a maintainer who exported both while
   debugging and then restarted can't silently boot the daemon onto a scratch store.

3. keys.mjs + test-env.mjs — softened the overstated comments to state what is actually enforced
   (the two-key gate makes neither var alone do anything; OCP's launchers strip both) and to name
   the one residual path honestly: a hand-rolled `node server.mjs` with both vars explicitly
   exported, bypassing every launcher — for which the loud getDb() "NOT the default" log is the
   backstop. No library-level gate can catch an operator who both sets a test flag and bypasses
   the launchers; the honest fix is a non-silent wrong-store, which #163 already provides.

Severity: LOW (defense-in-depth; the default/shipped path was already safe). No behavior change on
any correctly-configured install.

ALIGNMENT.md: this PR does not touch server.mjs, so the cli.js-citation hard requirement does not
apply; and no cli.js operation is involved — key-store isolation and installer env hygiene are
entirely OCP-owned (no Class A / cli.js-mirror surface).

Tests: +4 mutation-proven (3 behavioral: drop the `!NEVER_PRESERVE.has(k)` guard in either merge
fn and they fail — verified 326 passed / 3 failed under mutation; restored). The `ocp` bash
`env -u` line is verified by `bash -n` + inspection (the suite does not exec the installer/daemon).
Full suite: 329 passed / 0 failed (was 325).

Version bump + CHANGELOG deferred to the later chore(release) PR, per the repo's #148/#149/#150 ->
#151 convention (matching PR #164).

Co-Authored-By: Claude <claude-opus-4-8> <noreply@anthropic.com>

* test(setup): assert NEVER_PRESERVE.size === 2 so the "exactly two" test matches its name

Reviewer nit (LOW): the membership assertion let a future spurious third entry slip past a
test whose name promises "exactly the two". Behavior stays guarded by the 3 mutation-proof
tests; this just makes the contract test honest.

Co-Authored-By: Claude <claude-opus-4-8> <noreply@anthropic.com>

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude <claude-opus-4-8> <noreply@anthropic.com>
2026-07-15 22:28:49 +10:00
dtzp555-maxGitHubtaodengClaude <claude-opus-4-8> <noreply@anthropic.com>
63c2de7128 fix(tui): clamp stream holdback to safe floor (A1) + record cc_entrypoint before honesty gates (A3) (#164)
Two OCP-internal correctness fixes on the TUI streaming/observation path, surfaced
by an independent (Codex) re-review. Neither touches the cli.js wire.

A1 — OCP_TUI_STREAM_HOLDBACK now has an enforced floor (DEFAULT_HOLDBACK_CHARS=100).
The C-1 auth-banner gate's first-message guarantee rests on the holdback being at
least the default banner detector's 100-char reach. The env var's own doc said
"Only raise it", but the code trusted the operator: a sub-floor value (e.g. 50) or a
NaN typo ("unlimited") let the first chars of a real auth banner stream to the client
before the end-of-turn detector could classify the whole message and reject the turn.
resolveStreamHoldback() clamps UP to the floor and returns {value, clamped}; server.mjs
emits a boot WARNING when it had to clamp. Default (unset) is unchanged and unflagged.

A3 — recordTuiEntrypoint() now runs the moment runTuiTurn() returns, BEFORE the honesty
gates (wall-clock truncation / auth banner / stream divergence) that throw. The entrypoint
(cli vs sdk-cli) is which billing pool the turn consumed; a turn that then fails a gate
STILL spent that pool, and those failed turns are exactly the ones most likely to signal a
silent degrade to the metered Agent SDK pool. The old placement recorded only on the success
path, so /health's lastEntrypoint and entrypointMismatches were blind to every failed turn —
the billing-drift signal missed the cases it most needed to catch. recordModelSuccess stays
on the success path. The catch block does not record the entrypoint, so there is no double
count; a client-disconnect (TuiAbortError) throws from inside runTuiTurn before the destructure,
so no phantom entrypoint is recorded.

ALIGNMENT.md Rule 2 (No Invention): no cli.js citation applies. cli.js does not perform either
operation — both are proxy-internal. A1 hardens OCP's own SSE holdback (a safety mechanism on
the Class B.1 OpenAI-compat streaming surface; wire format authority is the OpenAI spec via
ADR 0006). A3 reorders when OCP records its own /health observability counters (Class B.2,
grandfathered under ADR 0006; the TUI spawn authority is ADR 0007). No endpoint, header,
request field, or response field is added or altered; the bytes to and from cli.js are
byte-identical. This is observation/safety-layer hardening, not extension.

Tests: +5 mutation-proven unit tests for resolveStreamHoldback (deleting the floor clamp
fails 3 of them). Full suite 325 passed / 0 failed (was 320). A3 is a server.mjs control-flow
reorder; server.mjs is not imported by the test suite, so A3 is verified by reviewer inspection
of the diff, stated honestly here rather than vouched for by a test.

Version bump + CHANGELOG deliberately omitted: this is a fix PR, consolidated into a later
chore(release) PR per the repo's #148/#149/#150 -> #151 (v3.21.1) convention.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude <claude-opus-4-8> <noreply@anthropic.com>
2026-07-15 21:40:13 +10:00
1d65bc309e fix(test): stop the suite writing live API keys into the operator's real key store (#163)
* fix(test): stop the suite writing live API keys into the operator's real key store

`npm test` wrote real, UNREVOKED rows into ~/.ocp/ocp.db — the SAME sqlite database the
running server reads — two per run, unbounded. On the maintainer's host that had accumulated
**737 test-suite keys against 12 real operator keys** (749 rows total), all revoked=0.

Not a credential leak: createKey() mints `randomBytes(24)` and the suite discards the
plaintext, so nobody holds a usable token. But it is real damage:
  - the operator's key store grows by 2 rows on every test run, forever
  - `ocp keys list` is unusable (749 rows, 12 of them real)
  - the suite is RACY: two concurrent runs (e.g. two review worktrees) share one file, so
    listKeys() can miss "test-user-1" and `"quota_daily" in undefined` throws a TypeError
    rather than failing cleanly. That is the ~1-in-6 flake in `listKeys includes quota
    fields`, reported by a reviewer and initially not reproducible serially — it needs a
    concurrent run to surface, which is exactly what four parallel reviewers produced.

Root cause: keys.mjs resolved `OCP_DIR`/`DB_PATH` at MODULE TOP-LEVEL and read no env var.
test-features.mjs carried a comment claiming it could "set env before the first getDb() call"
— it could not, on two counts: nothing in keys.mjs read an env var, and ESM hoists imports, so
the assignment would have run after keys.mjs was already evaluated anyway. The isolation was
never real; it just LOOKED real, which is why it survived.

Fix:
  - keys.mjs resolves the dir lazily, inside getDb(), honoring OCP_DIR_OVERRIDE. Deliberately
    NOT a generic `OCP_DIR`: pointing a RUNNING server at a different key store silently
    changes which credentials authenticate, so this must be awkward to set by accident.
  - new test-env.mjs, imported BEFORE keys.mjs, mints a per-run scratch dir. A separate module
    is required — ESM hoisting means a statement in the test's own body is too late.
  - export getDbPath() so the store's location can be asserted.
  - as a side effect, importing keys.mjs no longer creates directories in the operator's home.

Two guards added, both MUTATION-TESTED (revert the override → both fail, 317/2):
  - the key store under test is a scratch db, NOT the operator's real ~/.ocp/ocp.db
  - listKeys does not depend on rows left behind by an earlier or concurrent run

Proven, not asserted: the real ~/.ocp/ocp.db held at 749 rows across two full test runs
(it previously grew by 2 each run). The 737 existing junk rows are NOT cleaned up here —
that is a destructive change to the maintainer's live database and is his call, not a
side effect of a test fix.

npm test: 319 passed, 0 failed (was 317).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VqgWJcjxrjjL9L9SkpZyXR

* fix(keys): gate the test override behind NODE_ENV so a prod server can never honor it

Review fold-in. The reviewer landed the sharpest possible critique: the first cut closed a
test-hygiene hole by opening a quieter AUTH-CORRECTNESS one, and its only guard against that
was "the variable has an awkward name" — a naming convention plus a comment. That is precisely
the failure mode this whole PR exists to indict (a comment describing an intention that nothing
enforces). It was demonstrated live:

    OCP_DIR_OVERRIDE=/tmp/evil-store  ->  server opens /tmp/evil-store/ocp.db, 0 keys visible

server.mjs imports keys.mjs, and `ocp start`'s nohup fallback inherits the invoking shell's env
— so a maintainer who exported the var while debugging THIS issue and then started the server
would get a server silently authenticating against an empty key store. In AUTH_MODE=multi that
is a total auth outage: every real key 401s, nothing logged, nothing on /health.

F1 — the gate is now the actual guard: OCP_DIR_OVERRIDE is honored ONLY when
     NODE_ENV === "test". A production server runs without NODE_ENV and therefore CANNOT be
     redirected, however the variable reached its environment. Proven both directions:
       no NODE_ENV      + OCP_DIR_OVERRIDE=/tmp/evil-store -> /Users/<op>/.ocp/ocp.db  (ignored)
       NODE_ENV=test    + OCP_DIR_OVERRIDE=/tmp/scratch    -> /tmp/scratch/ocp.db      (honored)
     Plus: getDb() now LOGS the store whenever it is not the default. Silence was the other half
     of the bug — a server on the wrong key store looks exactly like one on the right store until
     every request 401s.

F2 — restore the 0700 guarantee on ~/.ocp. Removing keys.mjs's top-level mkdirSync (a good
     change on its own) silently dropped it: prepareSpawnHome (server.mjs:477) does
     mkdirSync(recursive) with NO mode, so on a fresh install it can create ~/.ocp as a
     world-listable 0755 parent. Verified: 755 via the spawn-home path vs 700 via resolveOcpDir.
     The invariant used to be inherited by luck; it is now stated.

F3 — test-env.mjs removes its scratch dir on exit. Otherwise the fix traded unbounded growth in
     ~/.ocp/ocp.db for unbounded growth in $TMPDIR. Verified: 2 runs, delta 0 dirs.
F4 — closeDb() clears dbPath; getDbPath() no longer hands back a path to a closed db.
F5 — dropped the dead unlinkSync import and explained the leftover HOME normalization.

New test, and it is the one that matters: "a PRODUCTION process (no NODE_ENV) must IGNORE
OCP_DIR_OVERRIDE" — so nothing can re-widen the gate without a red test.

server.mjs IS touched (one mkdirSync mode). Not endpoint-touching: no request handler, endpoint,
header, or wire field — so no cli.js citation applies (ALIGNMENT.md Rule 2 / CLAUDE.md hard-req #1).
Note memory/constitution.md § II lists keys.mjs as a protected file requiring maintainer approval.

npm test: 320 passed, 0 failed. Real ~/.ocp/ocp.db unchanged at 751 rows throughout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VqgWJcjxrjjL9L9SkpZyXR

* fix(test): make the F1 gate test REAL — it was theatre, and proved it

The reviewer deleted the entire NODE_ENV gate from keys.mjs and the suite still reported
320 passed, 0 failed. The one test written to stop this bug recurring was the one thing in
the PR that would have let it recur — and it would have merged green, with a false sense of
coverage.

Why it was worthless: it re-implemented the predicate INSIDE THE TEST BODY —

    const resolve = (nodeEnv, override) =>
      (nodeEnv === "test" ? override : null) || join(homedir(), ".ocp");

— and never called resolveOcpDir(), getDb(), or getDbPath(). It asserted that a closure
defined three lines above behaved as written. A copy of the predicate is not the predicate.
Its own comment said "exercising the same predicate keys.mjs uses" — that phrase was the tell.

This is the same failure class the PR exists to indict (an assertion of an intention that
nothing enforces), reproduced one layer up, in the fix for it. Fourth time in this repo that
a correctly-named test has vouched for nothing.

The real test must run OUT OF PROCESS: the parent is irreversibly NODE_ENV=test by the time
any test runs (test-env.mjs sets it before keys.mjs is imported), so the production path is
simply unreachable in-process. It now spawns a child with no NODE_ENV, the override set, and
HOME redirected to a temp dir (so the real key store is never opened), and asserts what the
REAL keys.mjs actually did.

MUTATION-PROVEN, against the exact revert that used to pass:
  delete the whole NODE_ENV gate -> 319 passed, 1 failed
    ✗ a PRODUCTION process (no NODE_ENV) must IGNORE OCP_DIR_OVERRIDE
  restore                        -> 320 passed, 0 failed

Also folded in: setup.mjs created ~/.ocp at the umask default (755, world-listable) on a fresh
install via the logs dir — pre-existing, self-healing on first server start, now stated
explicitly (mode 0700) rather than left to luck. Same class as the F2 fix.

npm test: 320 passed, 0 failed. Real ~/.ocp/ocp.db unchanged at 751 rows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VqgWJcjxrjjL9L9SkpZyXR

* fix(setup): rescue the semicolon from the comment; assert the child SAW the override

Two review nits on the way in.

setup.mjs:393 — the statement's semicolon had been swallowed INTO the trailing comment, so
the line parsed only because ASI rescued it (the next token is `if` on a new line). The repo
has no linter, so nothing would have caught it. Comment moved above the statement.

test-features.mjs — negative control on the prod-gate probe. The reviewer noticed the test's
robustness was INCIDENTAL: because the child env is spread from process.env, it inherits the
parent's own OCP_DIR_OVERRIDE, so a future refactor that renamed the var and missed this test's
explicit `env` object would still have gone red — but by luck, not by assertion. The child now
prints the override it SAW as well as the store it opened, and the test asserts both. The claim
is now 'a prod process saw the override and ignored it', not merely 'a prod process opened the
right store' (which could pass for the wrong reason).

Mutation re-proven after both edits: delete the NODE_ENV gate -> 319 passed, 1 failed; restore
-> 320 passed, 0 failed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VqgWJcjxrjjL9L9SkpZyXR

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 08:33:08 +10:00
88d8bed2e3 docs(readme): stop the feature bullet promising what § honest limits forbids (#136) (#162)
* docs(readme): stop the feature bullet promising what § honest limits forbids (#136)

Issue #136: an external user reported that Claude Code REFUSES to run OCP's own
copy-paste install prompt, on the grounds that the premise — pooling one Pro/Max
subscription across a family — violates Anthropic's Usage Policy.

The install prompts themselves were already fixed since that report ('my own devices
on the network', plus a ToS warning on the LAN section). What remained was a
self-contradiction in the README:

  line 27  (feature bullet):  'share one Claude Pro/Max subscription with family,
                               friends, or your own devices'
  line 427 (§ honest limits): 'The defensible framing is "one person, your own
                               devices" — sharing with friends or a team is not.'

The top-of-funnel bullet was promoting exactly what the project's own ToS section
calls indefensible. That is a defect on its own terms, independent of anyone's view
on the underlying policy: a reader who trusts the bullet is walked straight into the
thing the same document later tells them not to do.

Aligned the bullet to the position the project ALREADY took, and linked the honest-limits
section from it. No change to the auth modes, the LAN feature, or the maintainer's own
account of how they use it — this only stops the doc arguing with itself.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VqgWJcjxrjjL9L9SkpZyXR

* docs(readme): fix the misdirected anchor + the same defect at line 52 (review fold-in)

Independent reviewer caught two things in the first cut:

1. The new link pointed at #auth-modes — which resolves to the '### Auth Modes' mode
   table (line 408), NOT to the 'Sharing with family / a team — honest limits'
   paragraph (line 422), which sits under '### Deployment model & security (read
   this)' (line 418). A bullet that says 'see the honest limits' and then sends you
   somewhere else is worse than no link. Correct anchor verified two ways (github-slugger
   + the live rendered page): #deployment-model--security-read-this (double hyphen — the
   '&' is stripped but both surrounding spaces survive).

2. Line 52 carried the SAME defect the PR was written to fix, a few lines below it:
   'share one Claude Pro/Max subscription across IDEs, devices, and people'. So the
   original claim — 'this only stops the document arguing with itself' — was not yet
   true: it stopped one instance and left an adjacent one standing, in the same
   top-of-README section an install-time reviewer reads first.

Left alone deliberately: the maintainer's own account of their household's use (lines 7
and 1178). That is theirs to make, and a docs PR should not quietly rewrite it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VqgWJcjxrjjL9L9SkpZyXR

* docs(readme): carry the ToS caveat into the MANUAL install path too (README:218)

Reviewer found a third instance, and it is the one that most directly reproduces #136.

README has two forms of the same LAN-mode install: the copy-paste AI prompt (line 132) and
the handbook form (line 218). Line 180 explicitly asserts they are 'the same steps in handbook
form'. They were not:

  line 132 (prompt)   'install OCP as a server so YOUR OWN DEVICES on the LAN can reach it
                       (Claude Pro/Max are per-user accounts — review Anthropic's Usage Policy
                       before extending access to other people)'   + example keys: laptop, tablet
  line 218 (handbook) 'share with other devices on your network:'  + 'create API keys for each
                       PERSON/device' + no ToS pointer at all

So a reader who takes the manual route instead of the copy-paste route is still walked into
per-person key creation with zero ToS mention — reproducing #136's trigger through the door
the first commit did not close. The caveat now matches its twin.

Deliberately NOT scrubbing the wife-laptop / son-ipad example key names: they recur in seven
further places, it is a bigger diff at a different severity, and it edges into scrubbing the
maintainer's household out of their own documentation. With the pointer restored at 218 those
examples inherit the caveat — which is exactly the posture § honest limits takes. It never
forbids family sharing; it says it is the account holder's call and their risk, and refuses to
hide that.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VqgWJcjxrjjL9L9SkpZyXR

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 08:33:05 +10:00
a90f830b5d fix(tui): a null message_id on the first hook fire must not disarm the F1 guard (#160)
Residual found by the independent reviewer's second pass, by PROBING the fixed code rather
than reading it — the F1 fix was correct but its ARMING could be skipped entirely.

TuiDeltaAssembler.messageId was initialized to `null`. A first MessageDisplay payload carrying
message_id:null therefore compared EQUAL to the sentinel, registered no boundary, and left
`messages` at 0. When the real boundary then arrived, `else if (this.messages > 1)` evaluated
1 > 1 === false, so restartedAfterEmit never armed, and the `released` branch forwarded the
auth banner to the client — the exact leak F1 closed, reachable again through a single null
field. parseDeltaChunk does not validate message_id, so such a payload does reach push().

Two changes, both narrowing:
  - messageId now initializes to a Symbol sentinel, which is === to nothing a JSON payload can
    produce, so the first fire ALWAYS registers as message 1 whatever its message_id is.
  - the boundary branch drops the `this.messages > 1` sub-condition. It bought nothing and was
    the sole cause. The real invariant is "a boundary occurred while emitted !== ''" — which is
    unrecoverable regardless of how many messages have been seen — and that is now what the
    code says.

Whether claude ever emits message_id:null is unverified (the observed contract has it present),
so this is defense-in-depth, not a live bug. But the guard is the mechanism this feature
nominates as its primary safety property; it should not be disarmable by a field's absence.

Mutation-tested: restore the null sentinel + the messages>1 condition and the new test fails;
with the fix, 317 passed / 0 failed (was 316).

Class B (ADR 0007, OCP-owned TUI spawn) — cli.js does NOT perform this operation.


Claude-Session: https://claude.ai/code/session_01VqgWJcjxrjjL9L9SkpZyXR

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 08:21:30 +10:00
13 changed files with 503 additions and 82 deletions
+33
View File
@@ -1,5 +1,38 @@
# Changelog # Changelog
## Unreleased
### Changed
- **Default `sonnet` alias → `claude-sonnet-5`.** The `sonnet` alias (the model used for every `/v1/chat/completions` request that omits `model`, and OpenClaw's OCP primary via `ocp-connect`) now resolves to `claude-sonnet-5` instead of `claude-sonnet-4-6`. `claude-sonnet-4-6` remains available by full ID for pinning. This is a behavior change for clients relying on the default — pin `claude-sonnet-4-6` explicitly to retain the previous model. Split out from the additive `claude-sonnet-5` model entry (#152) per Iron Rule 11.
## v3.22.1 — 2026-07-17
Minor release: TUI-mode latency and streaming features — **all opt-in and off by default**, so the default request path (`-p` / `--output-format stream-json`) is byte-for-byte unchanged — plus hardening from an independent (Codex) re-review of the streaming work, Windows `claude.exe` startup resolution, and the Claude Sonnet 5 model entry. No new `cli.js` wire behavior and no new endpoint; the new surface is entirely OCP-owned TUI-mode configuration (env vars), startup binary discovery, model metadata, and `/health` observation. Every code PR carried a fresh-context reviewer (Iron Rule 10). (Version note: v3.22.0 was prepared but never tagged; its contents ship here as v3.22.1 together with the additions below.)
### Added
- **Claude Sonnet 5 in the model SPOT (#152, contributed by @vvlasy-openclaw)** — `claude-sonnet-5` added to `models.json` (`contextWindow` 200000 / `maxTokens` 16384 / `reasoning` true, consistent with existing entries), exposed via `/v1/models` and the OpenClaw sync. Purely additive: the `sonnet` alias still resolves to `claude-sonnet-4-6` (the repoint is tracked separately in #168). `ocp-connect`'s model classifier now matches on the model *family* prefix (`claude-sonnet`/`claude-opus`/`claude-haiku`) instead of version-pinned prefixes, so current and future versioned IDs register with correct `reasoning`/`maxTokens` metadata. New referential-integrity tests guard that every alias target exists in `models[]`.
- **Windows `claude.exe` startup resolution (#161, contributed by @nyxst4ck, diagnosis credit #147 @Justinsato)** — on Windows, `resolveClaude()` now discovers a native `claude.exe` (`%USERPROFILE%\.local\bin`, WinGet Links, WindowsApps, then `where.exe`) and rejects npm `.cmd`/`.bat`/`.ps1` shims, which cannot be spawned without a shell — previously startup resolved a shim and failed. A non-`.exe` `CLAUDE_BIN` on Windows is a fatal error with an actionable hint. The macOS/Linux path is byte-for-byte unchanged. Note: this is startup binary resolution only — full Windows support is not yet claimed (snapshot-path portability is tracked in #167).
### Added — TUI mode (all opt-in, default off)
- **Spawn effort control — `OCP_TUI_EFFORT` (default `low`) (#156)** — the interactive `claude` is now spawned with an explicit `--effort` flag. `low` cuts measured TTFT p50 by ~40% and collapses run-to-run variance ~15× versus an inherited `xhigh`; proxied requests rarely benefit from extended thinking. Set `inherit` to omit the flag and restore the pre-flag HOME-dependent behaviour. Banner-verified to stay on the subscription pool (`· Claude Max`); an invalid value warns and falls back to `low`. README § "Environment Variables".
- **Warm pane pool — `OCP_TUI_POOL_SIZE` (default `0` / off) (#158)** — pre-boots up to 4 single-use `claude` panes so a request skips the cold boot: measured end-to-end p50 `10.17s``6.00s` (41%) on a Mac mini (Sonnet 4.6, `--effort low`). Opt-in because each warm pane is a live idle process held whether or not a request ever arrives. Panes are single-use (one turn, then killed and replaced in the background), port-scoped (`ocp-tui-<port>-p<hex>`), and coexist with the zombie reaper by a synchronous drain→reap→resume sweep. README §§ "Environment Variables" + "How It Works".
- **Real SSE streaming — `OCP_TUI_STREAM` (default `0` / off) (#159, #160)** — `stream:true` turns emit real `delta.content` chunks as `claude` generates them, sourced from `claude`'s own `MessageDisplay` hook (registered via `--settings` on the ordinary interactive spawn — banner-verified on the subscription pool). Granularity is block-level, and it moves the *first* byte, not the last. The transcript stays authoritative: streamed text is asserted equal to it at end-of-turn, the auth-banner and truncation gates still run before anything is committed, and a turn whose stream cannot be reconciled is **refused** (SSE error frame, not cached) and counted on `/health` (`tui.streamDivergences`; a silent total-hook-failure is counted separately as `tui.streamZeroDeltaTurns`). Tunables: `OCP_TUI_STREAM_HOLDBACK` (default `100`), `OCP_TUI_STREAM_DIR`, `OCP_TUI_STREAM_POLL_MS`. See ADR 0007 (2026-07-13 amendment). README §§ "Environment Variables" + "How It Works".
### Fixed
- **Streaming auth-banner guard: a null `message_id` on the first hook fire (#160)** — a first `MessageDisplay` fire with a null `message_id` could disarm the auth-banner guard; re-landed after a #159 squash dropped it (`lib/tui/stream.mjs`).
- **Test suite wrote live, unrevoked API keys into the operator's real key store (#163)** — `npm test` had been opening `~/.ocp/ocp.db` (the running server's DB) and writing two junk `api_keys` rows per run (737 accumulated on the maintainer's host), because the isolation the comments claimed was never wired (ESM import hoisting). `keys.mjs` now honors `OCP_DIR_OVERRIDE` under `NODE_ENV=test` and the suite points at a scratch dir; a child-process probe verifies a production process (no `NODE_ENV`) cannot be redirected.
- **Streaming holdback floor + billing-pool observation on failed turns (#164)** — (A1) `OCP_TUI_STREAM_HOLDBACK` now clamps up to the safe floor (`100`) with a boot warning, closing a latent auth-banner leak when an operator set a sub-floor value. (A3) the `cc_entrypoint` (billing-pool) observation is now recorded before the honesty gates that throw, so `/health` no longer goes blind to exactly the failed turns most likely to signal a silent degrade to the metered Agent SDK pool.
- **Test-only key-store redirection vars can no longer reach a server OCP launches (#165)** — (A4) `NODE_ENV`/`OCP_DIR_OVERRIDE` are stripped from every service unit `setup.mjs` writes (`plist-merge`'s `NEVER_PRESERVE`) and from the `ocp restart` manual nohup fallback (`env -u`); #163's overstated "a prod server can NEVER be redirected" comments were softened to name the one residual hand-launch path and the loud `getDb()` "NOT the default" backstop.
### Docs
- **README billing honesty (#162, closes #136)** — removed a feature bullet that promised what the § "honest limits" section forbids.
- **TUI latency plans + streaming-achievability spike (#155, #157)** — measured latency decomposition, backlog, and the `MessageDisplay`-hook streaming prereq spike under `docs/plans/2026-07-13-tui-latency/`.
## v3.21.1 — 2026-07-07 ## v3.21.1 — 2026-07-07
Patch release: three bug fixes from an independent concurrency/session-lifecycle audit, each its own PR with a fresh-context reviewer (Iron Rule 10). No new `cli.js` wire behavior, no new endpoint, header, or env var; the `/health` field set is unchanged (only value truthfulness improved). Patch release: three bug fixes from an independent concurrency/session-lifecycle audit, each its own PR with a fresh-context reviewer (Iron Rule 10). No new `cli.js` wire behavior, no new endpoint, header, or env var; the `/health` field set is unchanged (only value truthfulness improved).
+20 -17
View File
@@ -24,8 +24,8 @@ One proxy. Multiple IDEs. All models. **$0 API cost.**
There are several Claude proxy projects. OCP picks a specific lane: **align tightly with what `cli.js` actually does, observe + multiplex what's already there, don't extend the protocol.** What you get: There are several Claude proxy projects. OCP picks a specific lane: **align tightly with what `cli.js` actually does, observe + multiplex what's already there, don't extend the protocol.** What you get:
- **LAN multi-user keys** (v3.7.0) — share one Claude Pro/Max subscription with family, friends, or your own devices. Each user gets a per-key API token (no OAuth session leak), with independent usage tracking and one-line revocation. - **LAN multi-user keys** (v3.7.0) — reach one Claude Pro/Max subscription from your own devices across the LAN. Each device gets a per-key API token (no OAuth session leak), with independent usage tracking and one-line revocation. Pro/Max are **per-user** accounts — see [Sharing with family / a team — honest limits](#deployment-model--security-read-this) before extending access to other **people**.
- **`ocp-connect` one-shot IDE setup** — one command on the client machine detects and configures Claude Code, Cursor, Cline, Continue.dev, OpenCode, and OpenClaw. No pasting `OPENAI_BASE_URL` six times. - **`ocp-connect` one-shot client setup** — one command on the client machine auto-configures OpenClaw, and detects Cursor, Cline, Continue.dev, and opencode to print ready-to-paste setup hints for each. No hunting for where each tool keeps its `OPENAI_BASE_URL`.
- **Response cache with per-key isolation + singleflight** (v3.13.0). Optional SHA-256 prompt cache, isolated per API key (cross-user pollution is impossible by hash construction, not by application logic), with stampede protection on concurrent identical prompts. Off by default. ([PR #65](https://github.com/dtzp555-max/ocp/pull/65), [PR #66](https://github.com/dtzp555-max/ocp/pull/66)) - **Response cache with per-key isolation + singleflight** (v3.13.0). Optional SHA-256 prompt cache, isolated per API key (cross-user pollution is impossible by hash construction, not by application logic), with stampede protection on concurrent identical prompts. Off by default. ([PR #65](https://github.com/dtzp555-max/ocp/pull/65), [PR #66](https://github.com/dtzp555-max/ocp/pull/66))
- **Per-key request quotas** (v3.8.0). Daily / weekly / monthly limits per key — set a kid's iPad to 20/day, a partner's laptop to 100/week. ([PR #18](https://github.com/dtzp555-max/ocp/pull/18)) - **Per-key request quotas** (v3.8.0). Daily / weekly / monthly limits per key — set a kid's iPad to 20/day, a partner's laptop to 100/week. ([PR #18](https://github.com/dtzp555-max/ocp/pull/18))
- **SSE heartbeat for long reasoning** ([v3.12.0](https://github.com/dtzp555-max/ocp/releases/tag/v3.12.0), opt-in). If you've ever watched your IDE die at the 60s idle mark during a long Claude tool-use pause — that's nginx/Cloudflare default behavior. OCP emits an SSE comment frame to keep the connection alive without polluting the response. ([PR #49](https://github.com/dtzp555-max/ocp/pull/49)) - **SSE heartbeat for long reasoning** ([v3.12.0](https://github.com/dtzp555-max/ocp/releases/tag/v3.12.0), opt-in). If you've ever watched your IDE die at the 60s idle mark during a long Claude tool-use pause — that's nginx/Cloudflare default behavior. OCP emits an SSE comment frame to keep the connection alive without polluting the response. ([PR #49](https://github.com/dtzp555-max/ocp/pull/49))
@@ -49,7 +49,7 @@ OCP and the alternatives serve adjacent but distinct needs. Pick the one that fi
| GitHub stars / ecosystem size | small | large | mid | | GitHub stars / ecosystem size | small | large | mid |
| Governance discipline (CI-enforced alignment with cli.js) | yes | n/a | n/a | | Governance discipline (CI-enforced alignment with cli.js) | yes | n/a | n/a |
**Plain English**: `claude-code-router` is the routing-and-switching power tool — pick it if you want to mix Anthropic, OpenAI, Gemini, and local models behind one endpoint. `anthropic-proxy` is the minimal forwarder. **OCP focuses on disciplined `cli.js`-aligned forwarding plus subscription multiplexing** — pick it if you want to share one Claude Pro/Max subscription across IDEs, devices, and people, with LAN auth, quotas, and a governance contract that prevents endpoint drift. **Plain English**: `claude-code-router` is the routing-and-switching power tool — pick it if you want to mix Anthropic, OpenAI, Gemini, and local models behind one endpoint. `anthropic-proxy` is the minimal forwarder. **OCP focuses on disciplined `cli.js`-aligned forwarding plus subscription multiplexing** — pick it if you want to reach one Claude Pro/Max subscription from your own IDEs and devices, with LAN auth, quotas, and a governance contract that prevents endpoint drift.
### Related: OLP — Open LLM Proxy ### Related: OLP — Open LLM Proxy
@@ -121,7 +121,7 @@ Please follow https://github.com/dtzp555-max/ocp/blob/main/README.md
installed and logged in (`claude auth status`). Install missing pieces installed and logged in (`claude auth status`). Install missing pieces
using my system's package manager. using my system's package manager.
2. git clone the repo, cd in, and run `node setup.mjs`. 2. git clone the repo, cd in, and run `node setup.mjs`.
3. Verify with `curl http://127.0.0.1:3456/v1/models` (should list 5 models). 3. Verify with `curl http://127.0.0.1:3456/v1/models` (should list 6 models).
4. Add `export OPENAI_BASE_URL=http://127.0.0.1:3456/v1` to my shell rc. 4. Add `export OPENAI_BASE_URL=http://127.0.0.1:3456/v1` to my shell rc.
5. Tell me to reload my shell and try a tool like Cline / Continue / Cursor. 5. Tell me to reload my shell and try a tool like Cline / Continue / Cursor.
@@ -148,7 +148,7 @@ Please follow https://github.com/dtzp555-max/ocp/blob/main/README.md
5. Add OCP_ADMIN_KEY to my shell rc (~/.zshrc or ~/.bashrc). 5. Add OCP_ADMIN_KEY to my shell rc (~/.zshrc or ~/.bashrc).
6. Run `ocp lan` to show me the LAN IP and connect command. 6. Run `ocp lan` to show me the LAN IP and connect command.
7. Optionally create example keys: `ocp keys add laptop`, `ocp keys add tablet`. 7. Optionally create example keys: `ocp keys add laptop`, `ocp keys add tablet`.
8. Verify: `curl http://127.0.0.1:3456/v1/models` returns 5 models. 8. Verify: `curl http://127.0.0.1:3456/v1/models` returns 6 models.
Tell me each step before running it. On error, diagnose before retrying. Tell me each step before running it. On error, diagnose before retrying.
``` ```
@@ -157,8 +157,7 @@ Tell me each step before running it. On error, diagnose before retrying.
```text ```text
There's an OCP server at <SERVER_IP> on my LAN. Configure this machine to There's an OCP server at <SERVER_IP> on my LAN. Configure this machine to
use it for any local IDEs (Cursor, Cline, Continue.dev, OpenCode, Claude use it for any local IDEs (Cursor, Cline, Continue.dev, OpenCode, OpenClaw).
Code, OpenClaw).
Server IP: <SERVER_IP> Server IP: <SERVER_IP>
API key (leave blank if the server has anonymous mode enabled): <OPTIONAL_KEY> API key (leave blank if the server has anonymous mode enabled): <OPTIONAL_KEY>
@@ -171,7 +170,7 @@ Please follow https://github.com/dtzp555-max/ocp/blob/main/README.md
chmod +x ocp-connect chmod +x ocp-connect
2. Run `./ocp-connect <SERVER_IP>` (add `--key <KEY>` if you have one). 2. Run `./ocp-connect <SERVER_IP>` (add `--key <KEY>` if you have one).
3. Follow any IDE-specific manual hints it prints. 3. Follow any IDE-specific manual hints it prints.
4. Verify: `curl http://<SERVER_IP>:3456/v1/models` returns 5 models. 4. Verify: `curl http://<SERVER_IP>:3456/v1/models` returns 6 models.
5. Tell me to reload my shell + restart any IDE that was already running. 5. Tell me to reload my shell + restart any IDE that was already running.
Don't auto-retry on error. Tell me the failure mode first. Don't auto-retry on error. Tell me the failure mode first.
@@ -215,7 +214,7 @@ After install the `ocp` CLI lives at `~/ocp/ocp`. To put it on your PATH, either
export OPENAI_BASE_URL=http://127.0.0.1:3456/v1 export OPENAI_BASE_URL=http://127.0.0.1:3456/v1
``` ```
**LAN mode** — share with other devices on your network: **LAN mode** — reach OCP from your own devices on the network (Claude Pro/Max are per-user accounts — see [Sharing with family / a team — honest limits](#deployment-model--security-read-this) before extending access to other people):
```bash ```bash
# Enable LAN access with per-user auth (recommended) # Enable LAN access with per-user auth (recommended)
node setup.mjs --bind 0.0.0.0 --auth-mode multi node setup.mjs --bind 0.0.0.0 --auth-mode multi
@@ -241,7 +240,7 @@ Run `ocp lan` to see your IP and ready-to-share instructions.
**Verify:** **Verify:**
```bash ```bash
curl http://127.0.0.1:3456/v1/models curl http://127.0.0.1:3456/v1/models
# Returns: claude-opus-4-8, claude-opus-4-7, claude-opus-4-6, claude-sonnet-4-6, claude-haiku-4-5-20251001 # Returns: claude-opus-4-8, claude-opus-4-7, claude-opus-4-6, claude-sonnet-5, claude-sonnet-4-6, claude-haiku-4-5-20251001
``` ```
#### Headless install notes #### Headless install notes
@@ -320,7 +319,7 @@ OCP Connect v1.3.0
(set by admin via PROXY_ANONYMOUS_KEY; see issue #12 §14 Path A) (set by admin via PROXY_ANONYMOUS_KEY; see issue #12 §14 Path A)
Testing API access... Testing API access...
✓ API accessible (5 models available) ✓ API accessible (6 models available)
Shell config: Shell config:
✓ .bashrc ✓ .bashrc
@@ -353,6 +352,7 @@ OCP Connect v1.3.0
• ocp/claude-opus-4-8 • ocp/claude-opus-4-8
• ocp/claude-opus-4-7 • ocp/claude-opus-4-7
• ocp/claude-opus-4-6 • ocp/claude-opus-4-6
• ocp/claude-sonnet-5
• ocp/claude-sonnet-4-6 • ocp/claude-sonnet-4-6
• ocp/claude-haiku-4-5-20251001 • ocp/claude-haiku-4-5-20251001
Priority: PRIMARY (default model) Priority: PRIMARY (default model)
@@ -577,9 +577,9 @@ The simplest path: ask your AI.
What `ocp update` does: What `ocp update` does:
- **Patch bump** (e.g. `v3.14.0 → v3.14.1`): - **Patch bump** (e.g. `v3.21.0 → v3.21.1`):
light path (git pull + npm install + restart). light path (git pull + npm install + restart).
- **Cross-minor** (e.g. `v3.10 → v3.14`): - **Cross-minor** (e.g. `v3.18 → v3.22`):
full path: pre-flight check, snapshot, `setup.mjs` (with plist env-merge), full path: pre-flight check, snapshot, `setup.mjs` (with plist env-merge),
service restart, post-flight `/health` and `/v1/models` verification. service restart, post-flight `/health` and `/v1/models` verification.
- **Old version** (< v3.4.0): - **Old version** (< v3.4.0):
@@ -716,7 +716,8 @@ Any tool use happens server-side, under the `--allowedTools` set configured on t
| `claude-opus-4-8` | Most capable (default for `opus` alias) | | `claude-opus-4-8` | Most capable (default for `opus` alias) |
| `claude-opus-4-7` | Previous Opus, retained for pinning | | `claude-opus-4-7` | Previous Opus, retained for pinning |
| `claude-opus-4-6` | Older Opus, retained for pinning | | `claude-opus-4-6` | Older Opus, retained for pinning |
| `claude-sonnet-4-6` | Good balance of speed/quality (default for `sonnet` alias) | | `claude-sonnet-5` | Latest Sonnet (default for `sonnet` alias) |
| `claude-sonnet-4-6` | Previous Sonnet, retained for pinning |
| `claude-haiku-4-5-20251001` | Fastest, lightweight (default for `haiku` alias) | | `claude-haiku-4-5-20251001` | Fastest, lightweight (default for `haiku` alias) |
The canonical list lives in [`models.json`](./models.json) — the single source of truth as of v3.11.0. Both `server.mjs` (the `/v1/models` endpoint) and `setup.mjs` (the OpenClaw registration) derive from it. Adding a new model is now a one-file edit: The canonical list lives in [`models.json`](./models.json) — the single source of truth as of v3.11.0. Both `server.mjs` (the `/v1/models` endpoint) and `setup.mjs` (the OpenClaw registration) derive from it. Adding a new model is now a one-file edit:
@@ -826,11 +827,12 @@ lsof -nP -iTCP:3456 -sTCP:LISTEN
If it's an old OCP process, stop it before re-running setup: If it's an old OCP process, stop it before re-running setup:
```bash ```bash
ocp stop # if the CLI is on PATH launchctl bootout gui/$(id -u)/dev.ocp.proxy # macOS launchd
launchctl bootout gui/$(id -u)/dev.ocp.proxy # macOS launchd fallback systemctl --user stop ocp-proxy # Linux systemd (installed as a --user unit)
sudo systemctl stop ocp-proxy # Linux systemd fallback
``` ```
(There is no `ocp stop` subcommand — the proxy runs as a service, so stopping it goes through the service manager above. `ocp restart` exists for the bounce case.)
### Setup fails with "node: command not found" or version error ### Setup fails with "node: command not found" or version error
OCP requires Node.js 22.5+. Install: OCP requires Node.js 22.5+. Install:
@@ -947,6 +949,7 @@ See [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) and ADR 0007 PR-
| `CLAUDE_CACHE_TTL` | `0` | Response cache TTL (ms, 0 = disabled). Set to e.g. `300000` for 5-min cache | | `CLAUDE_CACHE_TTL` | `0` | Response cache TTL (ms, 0 = disabled). Set to e.g. `300000` for 5-min cache |
| `CLAUDE_ALLOWED_TOOLS` | `Bash,Read,...,Agent` | Comma-separated tools to pre-approve | | `CLAUDE_ALLOWED_TOOLS` | `Bash,Read,...,Agent` | Comma-separated tools to pre-approve |
| `CLAUDE_SKIP_PERMISSIONS` | `false` | Bypass all permission checks | | `CLAUDE_SKIP_PERMISSIONS` | `false` | Bypass all permission checks |
| `CLAUDE_MCP_CONFIG` | *(unset)* | Path to an MCP server config JSON, passed to the spawned `claude` as `--mcp-config` (both the `-p` path and TUI `OCP_TUI_FULL_TOOLS` panes) |
| `CLAUDE_NO_CONTEXT` | `false` | Suppress CLAUDE.md and auto-memory injection (pure API mode) | | `CLAUDE_NO_CONTEXT` | `false` | Suppress CLAUDE.md and auto-memory injection (pure API mode) |
| `PROXY_API_KEY` | *(unset)* | Bearer token for shared-mode authentication | | `PROXY_API_KEY` | *(unset)* | Bearer token for shared-mode authentication |
| `PROXY_ANONYMOUS_KEY` | *(unset)* | Well-known anonymous key allowlist (multi mode). When set, this exact string bypasses `validateKey()` and grants public access. Exposed via `/health.anonymousKey` only to localhost, or to all callers when `PROXY_ADVERTISE_ANON_KEY=1`. See [Anonymous Access](#anonymous-access-optional). | | `PROXY_ANONYMOUS_KEY` | *(unset)* | Well-known anonymous key allowlist (multi mode). When set, this exact string bypasses `validateKey()` and grants public access. Exposed via `/health.anonymousKey` only to localhost, or to all callers when `PROXY_ADVERTISE_ANON_KEY=1`. See [Anonymous Access](#anonymous-access-optional). |
+56 -8
View File
@@ -6,26 +6,74 @@ import { join } from "node:path";
import { mkdirSync, chmodSync } from "node:fs"; import { mkdirSync, chmodSync } from "node:fs";
import { homedir } from "node:os"; import { homedir } from "node:os";
const OCP_DIR = join(homedir(), ".ocp"); // Resolved LAZILY, on first getDb() — not at module top-level. Two reasons, and the second is
mkdirSync(OCP_DIR, { recursive: true, mode: 0o700 }); // the bug this fixes:
// Tighten the directory mode in case it already existed with broader permissions. //
try { chmodSync(OCP_DIR, 0o700); } catch { /* ignore EPERM on pre-existing dirs */ } // 1. Merely IMPORTING keys.mjs should not, as a side effect, create directories in the
const DB_PATH = join(OCP_DIR, "ocp.db"); // operator's home.
// 2. OCP_DIR_OVERRIDE exists so the test suite can point the key store at a scratch dir — and
// because ESM hoists imports, a top-level `const OCP_DIR = ...` here would be evaluated
// BEFORE an importing module's body could set the env var. Eager resolution made the
// override unsettable in the one place that needs it. (test-features.mjs carried a comment
// claiming it could "set env before the first getDb() call" — it could not, because nothing
// here ever read an env var. So `npm test` wrote real, UNREVOKED api_keys rows into the
// operator's live ~/.ocp/ocp.db: two per run, unbounded — 737 junk keys against 12 real ones
// on the maintainer's host — and two concurrent runs raced one file, which is the ~1-in-6
// flake in `listKeys includes quota fields`.)
//
// The override is gated on NODE_ENV === "test", and that gate is the ACTUAL guard. An earlier
// cut of this fix relied on the variable merely having an awkward name — i.e. a naming convention
// plus a comment — which is precisely the failure mode this whole change exists to indict (a
// comment describing an intention that nothing enforces). The two-key gate means NEITHER var
// alone does anything: a stray OCP_DIR_OVERRIDE with no NODE_ENV is inert, and NODE_ENV=test with
// no override just resolves the default dir.
//
// This gate does NOT, by itself, prove a production daemon can't be redirected — an earlier
// version of this comment overclaimed that ("a production server runs without NODE_ENV, so it
// CANNOT honor the override no matter how the variable got in"). That is only true while the
// daemon's env actually lacks NODE_ENV=test, which is an assumption, not something this file can
// enforce. What makes it hold in the shipped configuration is defense-in-depth in OCP's launchers:
// the plist/systemd units strip both vars on every (re)install (scripts/lib/plist-merge.mjs
// NEVER_PRESERVE), and `ocp` restart's manual nohup fallback strips them (`env -u`). So a server
// OCP itself started cannot carry the test-only redirection. The one residual path is an operator
// who hand-launches `node server.mjs` with BOTH vars explicitly exported, bypassing every
// launcher — a case no library-level gate can catch. The loud getDb() log below ("NOT the default
// ~/.ocp/ocp.db") is the backstop there: a wrong key store is at least never silent (in
// AUTH_MODE=multi that would otherwise be a total auth outage with nothing on /health to show it).
function resolveOcpDir() {
const override = process.env.NODE_ENV === "test" ? process.env.OCP_DIR_OVERRIDE : null;
const dir = override || join(homedir(), ".ocp");
mkdirSync(dir, { recursive: true, mode: 0o700 });
// Tighten the directory mode in case it already existed with broader permissions.
try { chmodSync(dir, 0o700); } catch { /* ignore EPERM on pre-existing dirs */ }
return dir;
}
let db; let db;
let dbPath; // resolved on first open, alongside the db handle
export function getDb() { export function getDb() {
if (!db) { if (!db) {
db = new DatabaseSync(DB_PATH); dbPath = join(resolveOcpDir(), "ocp.db");
// Say which store we opened. Silence was the other half of the bug: a server on the wrong
// key store looks exactly like a server on the right one until every request 401s.
if (dbPath !== join(homedir(), ".ocp", "ocp.db")) {
console.error(`[keys] key store: ${dbPath} (NOT the default ~/.ocp/ocp.db)`);
}
db = new DatabaseSync(dbPath);
db.exec("PRAGMA journal_mode = WAL"); db.exec("PRAGMA journal_mode = WAL");
db.exec("PRAGMA foreign_keys = ON"); db.exec("PRAGMA foreign_keys = ON");
initSchema(); initSchema();
// Tighten mode on the DB file (0600) after creation / first open. // Tighten mode on the DB file (0600) after creation / first open.
try { chmodSync(DB_PATH, 0o600); } catch { /* ignore — same-user access still works */ } try { chmodSync(dbPath, 0o600); } catch { /* ignore — same-user access still works */ }
} }
return db; return db;
} }
// Which file the key store actually opened. Exported so a test can ASSERT it is not the
// operator's real db — the bug this replaced was invisible precisely because nothing checked.
export function getDbPath() { return dbPath; }
function initSchema() { function initSchema() {
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS api_keys ( CREATE TABLE IF NOT EXISTS api_keys (
@@ -426,5 +474,5 @@ export function findKey(idOrName) {
} }
export function closeDb() { export function closeDb() {
if (db) { db.close(); db = null; } if (db) { db.close(); db = null; dbPath = undefined; } // clear both — a path to a closed db is a footgun
} }
+15
View File
@@ -45,6 +45,21 @@ import { detectTuiUpstreamError } from "./transcript.mjs";
// Default holdback before the first byte is released to the client. See TuiDeltaAssembler. // Default holdback before the first byte is released to the client. See TuiDeltaAssembler.
export const DEFAULT_HOLDBACK_CHARS = 100; export const DEFAULT_HOLDBACK_CHARS = 100;
// Resolve OCP_TUI_STREAM_HOLDBACK to a SAFE value. The whole C-1 auth-banner guarantee rests
// on the holdback being at least the default banner detector's max message length — which is
// exactly DEFAULT_HOLDBACK_CHARS. So this is a FLOOR, not a hint: a smaller value (or a NaN
// typo like "unlimited"/"5MB") would let a real banner fragment release before the terminal
// detector could classify the whole message, silently reopening the leak the assembler exists
// to prevent. The env var's own doc says "Only raise it"; this enforces that instead of trusting
// it. Returns { value, clamped } so the caller can warn when it had to clamp — a silent floor is
// less honest than a noticed one.
export function resolveStreamHoldback(raw, floor = DEFAULT_HOLDBACK_CHARS) {
const parsed = parseInt(raw ?? "", 10);
if (!Number.isFinite(parsed)) return { value: floor, clamped: raw != null && String(raw).trim() !== "" };
if (parsed < floor) return { value: floor, clamped: true };
return { value: parsed, clamped: false };
}
// The hook script. POSIX sh, no interpreter startup beyond /bin/sh, one fork (`cat`). // The hook script. POSIX sh, no interpreter startup beyond /bin/sh, one fork (`cat`).
// //
// - `printf` is a shell BUILTIN in sh/dash/bash, so the newline costs no fork. // - `printf` is a shell BUILTIN in sh/dash/bash, so the newline costs no fork.
+9 -1
View File
@@ -26,6 +26,14 @@
"contextWindow": 200000, "contextWindow": 200000,
"maxTokens": 16384 "maxTokens": 16384
}, },
{
"id": "claude-sonnet-5",
"displayName": "Claude Sonnet 5",
"openclawName": "Claude Sonnet 5 (via CLI)",
"reasoning": true,
"contextWindow": 200000,
"maxTokens": 16384
},
{ {
"id": "claude-sonnet-4-6", "id": "claude-sonnet-4-6",
"displayName": "Claude Sonnet 4.6", "displayName": "Claude Sonnet 4.6",
@@ -45,7 +53,7 @@
], ],
"aliases": { "aliases": {
"opus": "claude-opus-4-8", "opus": "claude-opus-4-8",
"sonnet": "claude-sonnet-4-6", "sonnet": "claude-sonnet-5",
"haiku": "claude-haiku-4-5-20251001" "haiku": "claude-haiku-4-5-20251001"
}, },
"legacyAliases": { "legacyAliases": {
+6 -1
View File
@@ -622,7 +622,12 @@ cmd_restart() {
self_r="${BASH_SOURCE[0]}" self_r="${BASH_SOURCE[0]}"
while [[ -L "$self_r" ]]; do self_r="$(readlink "$self_r")"; done while [[ -L "$self_r" ]]; do self_r="$(readlink "$self_r")"; done
script_dir="$(cd "$(dirname "$self_r")" && pwd)" script_dir="$(cd "$(dirname "$self_r")" && pwd)"
DISABLE_AUTOUPDATER=1 nohup node "$script_dir/server.mjs" >> "$HOME/.ocp/logs/proxy.log" 2>&1 & # env -u strips test-only key-store redirection vars (A4): if the invoking shell had
# NODE_ENV=test + OCP_DIR_OVERRIDE exported (e.g. from a debugging session), this manual
# fallback would otherwise inherit them and start the daemon against a scratch/empty key
# store — a silent auth outage in AUTH_MODE=multi. The plist/systemd paths strip these via
# plist-merge's NEVER_PRESERVE; this covers the one direct-launch path OCP controls.
DISABLE_AUTOUPDATER=1 env -u NODE_ENV -u OCP_DIR_OVERRIDE nohup node "$script_dir/server.mjs" >> "$HOME/.ocp/logs/proxy.log" 2>&1 &
fi fi
sleep 3 sleep 3
if curl -sf --max-time 5 "$PROXY/health" > /dev/null 2>&1; then if curl -sf --max-time 5 "$PROXY/health" > /dev/null 2>&1; then
+21 -10
View File
@@ -122,11 +122,17 @@ provider = {
"models": [] "models": []
} }
# Model metadata mapping (prefix match for versioned IDs like claude-haiku-4-5-20251001) # Model metadata mapping. Prefix match on the model FAMILY (claude-opus / -sonnet /
# -haiku), not a pinned version. A version-pinned prefix like "claude-sonnet-4"
# silently misses "claude-sonnet-5" and falls through to the non-reasoning /
# 8k-output default (PR #152 review) — every future Sonnet/Opus/Haiku bump would
# re-trip it. Family prefixes classify any versioned ID correctly with no per-model
# edit. (ADR 0003: models.json is the SPOT for model existence; /v1/models does not
# expose reasoning/maxTokens, so family classification stays here.)
model_meta = { model_meta = {
"claude-opus-4": {"name": "Claude Opus (OCP)", "reasoning": True, "maxTokens": 16384}, "claude-opus": {"name": "Claude Opus (OCP)", "reasoning": True, "maxTokens": 16384},
"claude-sonnet-4": {"name": "Claude Sonnet (OCP)", "reasoning": True, "maxTokens": 16384}, "claude-sonnet": {"name": "Claude Sonnet (OCP)", "reasoning": True, "maxTokens": 16384},
"claude-haiku-4": {"name": "Claude Haiku (OCP)", "reasoning": False, "maxTokens": 8192}, "claude-haiku": {"name": "Claude Haiku (OCP)", "reasoning": False, "maxTokens": 8192},
} }
def get_model_meta(mid): def get_model_meta(mid):
@@ -178,11 +184,11 @@ config.setdefault("agents", {})
config["agents"].setdefault("defaults", {}) config["agents"].setdefault("defaults", {})
config["agents"]["defaults"].setdefault("models", {}) config["agents"]["defaults"].setdefault("models", {})
# Build alias map (prefix match) # Build alias map (family prefix match — version-agnostic, see model_meta note)
alias_prefixes = { alias_prefixes = {
"claude-opus-4": "Claude Opus", "claude-opus": "Claude Opus",
"claude-sonnet-4": "Claude Sonnet", "claude-sonnet": "Claude Sonnet",
"claude-haiku-4": "Claude Haiku", "claude-haiku": "Claude Haiku",
} }
for mid in model_ids: for mid in model_ids:
@@ -196,8 +202,13 @@ for mid in model_ids:
# Handle primary/backup # Handle primary/backup
if priority == "1": if priority == "1":
# OCP as primary — pick the best model (prefer sonnet for daily use) # OCP as primary — pick the best model (prefer the latest Sonnet for daily use,
primary_model = provider_name + "/claude-sonnet-4-6" if "claude-sonnet-4-6" in model_ids else provider_name + "/" + model_ids[0] # tracking the `sonnet` alias default in models.json; fall back across versions).
_sonnet_pref = ["claude-sonnet-5", "claude-sonnet-4-6"]
primary_model = next(
(provider_name + "/" + m for m in _sonnet_pref if m in model_ids),
provider_name + "/" + model_ids[0],
)
config["agents"]["defaults"].setdefault("model", {}) config["agents"]["defaults"].setdefault("model", {})
config["agents"]["defaults"]["model"]["primary"] = primary_model config["agents"]["defaults"]["model"]["primary"] = primary_model
# Keep existing fallbacks # Keep existing fallbacks
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "open-claude-proxy", "name": "open-claude-proxy",
"version": "3.21.1", "version": "3.22.1",
"description": "OCP (Open Claude Proxy) — use your Claude Pro/Max subscription as an OpenAI-compatible API for any IDE. Works with Cline, OpenCode, Aider, Continue.dev, OpenClaw, and more.", "description": "OCP (Open Claude Proxy) — use your Claude Pro/Max subscription as an OpenAI-compatible API for any IDE. Works with Cline, OpenCode, Aider, Continue.dev, OpenClaw, and more.",
"type": "module", "type": "module",
"bin": { "bin": {
+15 -2
View File
@@ -8,6 +8,19 @@
// //
// No new dependencies — regex-based, plist <key>X</key><string>Y</string> shape // No new dependencies — regex-based, plist <key>X</key><string>Y</string> shape
// is stable enough for our hand-written templates in setup.mjs. // is stable enough for our hand-written templates in setup.mjs.
//
// SECURITY DENYLIST (A4): keys that must NEVER be carried into a service unit, even when a
// prior unit already contained them. OCP's key store honors OCP_DIR_OVERRIDE only when
// NODE_ENV === "test" (keys.mjs). If BOTH somehow reached a daemon's environment, the server
// would open a scratch/empty key store instead of ~/.ocp/ocp.db — in AUTH_MODE=multi a silent
// total auth outage. The preservation rule below ("keys only in EXISTING are kept verbatim")
// is exactly a vector for that: a unit that once carried these test-only vars would otherwise
// survive every setup re-run. So we strip them from the preserved set unconditionally. This is
// defense-in-depth: setup.mjs's own template never injects them, so the only way they enter is
// preservation, and this closes it. (The residual path — a hand-rolled `node server.mjs` with
// both vars exported — is out of any launcher's reach; keys.mjs's loud "NOT the default" log is
// the backstop there.)
export const NEVER_PRESERVE = new Set(["NODE_ENV", "OCP_DIR_OVERRIDE"]);
// Note: setup.mjs XML-escapes all injected values before writing (via xmlEscape()), // Note: setup.mjs XML-escapes all injected values before writing (via xmlEscape()),
// so raw `<` / `>` / `&` never appear in plist <string> bodies — the [^<]* regex below is safe. // so raw `<` / `>` / `&` never appear in plist <string> bodies — the [^<]* regex below is safe.
@@ -36,7 +49,7 @@ export function mergePlistEnv(existing, template) {
const preserved = {}; const preserved = {};
for (const [k, v] of Object.entries(existingEnv)) { for (const [k, v] of Object.entries(existingEnv)) {
if (!KNOWN.has(k)) preserved[k] = v; if (!KNOWN.has(k) && !NEVER_PRESERVE.has(k)) preserved[k] = v;
} }
if (Object.keys(preserved).length === 0) return template; if (Object.keys(preserved).length === 0) return template;
@@ -72,7 +85,7 @@ export function mergeSystemdEnv(existing, template) {
const KNOWN = new Set(Object.keys(templateEnv)); const KNOWN = new Set(Object.keys(templateEnv));
const preservedLines = Object.entries(existingEnv) const preservedLines = Object.entries(existingEnv)
.filter(([k]) => !KNOWN.has(k)) .filter(([k]) => !KNOWN.has(k) && !NEVER_PRESERVE.has(k))
.map(([k, v]) => `Environment=${k}=${v}`); .map(([k, v]) => `Environment=${k}=${v}`);
if (preservedLines.length === 0) return template; if (preservedLines.length === 0) return template;
+86 -18
View File
@@ -47,7 +47,7 @@ import { runTuiTurn, reapStaleTuiSessions, resolveTuiHome, bootTuiPane, tuiPaneH
import { detectTuiUpstreamError } from "./lib/tui/transcript.mjs"; import { detectTuiUpstreamError } from "./lib/tui/transcript.mjs";
import { TuiSemaphore, SemaphoreAbortError, recordTuiEntrypoint, buildTuiHealthBlock } from "./lib/tui/semaphore.mjs"; import { TuiSemaphore, SemaphoreAbortError, recordTuiEntrypoint, buildTuiHealthBlock } from "./lib/tui/semaphore.mjs";
import { TuiPanePool, resolvePoolSize, POOL_MAX_SIZE } from "./lib/tui/pool.mjs"; import { TuiPanePool, resolvePoolSize, POOL_MAX_SIZE } from "./lib/tui/pool.mjs";
import { TuiDeltaAssembler, DEFAULT_HOLDBACK_CHARS } from "./lib/tui/stream.mjs"; import { TuiDeltaAssembler, DEFAULT_HOLDBACK_CHARS, resolveStreamHoldback } from "./lib/tui/stream.mjs";
import { createSerialMutex, createTtlCache, isTokenExpiring, orderLabelsLastGoodFirst } from "./lib/spawn-auth.mjs"; import { createSerialMutex, createTtlCache, isTokenExpiring, orderLabelsLastGoodFirst } from "./lib/spawn-auth.mjs";
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -97,8 +97,40 @@ function _collectNodeManagerCandidates(home) {
return out; return out;
} }
function _joinIfBase(base, ...parts) {
return base ? join(base, ...parts) : null;
}
function _collectWindowsClaudeCandidates() {
const userProfile = process.env.USERPROFILE || process.env.HOME || "";
const localAppData = process.env.LOCALAPPDATA || "";
return [
_joinIfBase(userProfile, ".local", "bin", "claude.exe"),
_joinIfBase(localAppData, "Microsoft", "WinGet", "Links", "claude.exe"),
_joinIfBase(localAppData, "Microsoft", "WindowsApps", "claude.exe"),
].filter(Boolean);
}
function _isWindowsSpawnableBinary(path) {
return /\.exe$/i.test(path);
}
function _lookupLines(out) {
return out.split(/\r?\n/).map(line => line.trim()).filter(Boolean);
}
function _warnUnspawnableWindowsMatches(lines) {
const unspawnable = lines.filter(p => !/\.exe$/i.test(p));
if (unspawnable.length > 0) {
console.warn(`[init] Ignoring non-exe Windows claude command(s): ${unspawnable.join(", ")}`);
}
}
function resolveClaude() { function resolveClaude() {
const isWin = process.platform === "win32";
if (process.env.CLAUDE_BIN) { if (process.env.CLAUDE_BIN) {
if (isWin && !_isWindowsSpawnableBinary(process.env.CLAUDE_BIN)) {
console.error(
`FATAL: CLAUDE_BIN="${process.env.CLAUDE_BIN}" is not a native Windows executable.\n` +
" Set CLAUDE_BIN to claude.exe; shell shims cannot be spawned without a shell."
);
process.exit(1);
}
try { try {
accessSync(process.env.CLAUDE_BIN, constants.X_OK); accessSync(process.env.CLAUDE_BIN, constants.X_OK);
return process.env.CLAUDE_BIN; return process.env.CLAUDE_BIN;
@@ -108,8 +140,10 @@ function resolveClaude() {
} }
} }
const home = process.env.HOME || ""; const home = process.env.HOME || process.env.USERPROFILE || "";
const candidates = [ const candidates = isWin
? _collectWindowsClaudeCandidates()
: [
"/opt/homebrew/bin/claude", "/opt/homebrew/bin/claude",
"/usr/local/bin/claude", "/usr/local/bin/claude",
"/usr/bin/claude", "/usr/bin/claude",
@@ -120,16 +154,29 @@ function resolveClaude() {
try { accessSync(p, constants.X_OK); console.warn(`[init] CLAUDE_BIN not set, resolved to ${p}`); return p; } catch {} try { accessSync(p, constants.X_OK); console.warn(`[init] CLAUDE_BIN not set, resolved to ${p}`); return p; } catch {}
} }
if (isWin) {
try {
const lines = _lookupLines(execFileSync("where.exe", ["claude"], { encoding: "utf8", timeout: 5000 }));
const resolved = lines.find(_isWindowsSpawnableBinary);
if (resolved) { console.warn(`[init] CLAUDE_BIN not set, resolved via where.exe: ${resolved}`); return resolved; }
_warnUnspawnableWindowsMatches(lines);
} catch {}
} else {
try { try {
const resolved = execFileSync("which", ["claude"], { encoding: "utf8", timeout: 5000 }).trim(); const resolved = execFileSync("which", ["claude"], { encoding: "utf8", timeout: 5000 }).trim();
if (resolved) { console.warn(`[init] CLAUDE_BIN not set, resolved via which: ${resolved}`); return resolved; } if (resolved) { console.warn(`[init] CLAUDE_BIN not set, resolved via which: ${resolved}`); return resolved; }
} catch {} } catch {}
}
console.error( console.error(
"FATAL: claude binary not found.\n" + "FATAL: claude binary not found.\n" +
" Set CLAUDE_BIN=/path/to/claude or ensure claude is in PATH.\n" + (isWin
? " Set CLAUDE_BIN to the absolute path of claude.exe or ensure claude.exe is in PATH.\n" +
" Hint: npm .cmd/.bat/.ps1 shims cannot be spawned without a shell.\n" +
" The .exe requirement is an intentional allow-list for shell-less spawning.\n"
: " Set CLAUDE_BIN=/path/to/claude or ensure claude is in PATH.\n" +
" Hint: if you use nvm/fnm/asdf, set CLAUDE_BIN to the absolute path\n" + " Hint: if you use nvm/fnm/asdf, set CLAUDE_BIN to the absolute path\n" +
" shown by `which claude` in your interactive shell.\n" + " shown by `which claude` in your interactive shell.\n") +
" Checked: " + candidates.join(", ") " Checked: " + candidates.join(", ")
); );
process.exit(1); process.exit(1);
@@ -379,7 +426,20 @@ const TUI_STREAM_DIR = process.env.OCP_TUI_STREAM_DIR || `${process.env.HOME}/.o
// exceeds this, which puts it out of the default banner detector's <=100-char reach — the // exceeds this, which puts it out of the default banner detector's <=100-char reach — the
// FIRST of the two halves of the guarantee (see the assembler's class comment for the second: // FIRST of the two halves of the guarantee (see the assembler's class comment for the second:
// no further emission at all once a message boundary follows an emit). Only raise it. // no further emission at all once a message boundary follows an emit). Only raise it.
const TUI_STREAM_HOLDBACK = parseInt(process.env.OCP_TUI_STREAM_HOLDBACK || String(DEFAULT_HOLDBACK_CHARS), 10); // resolveStreamHoldback enforces the DEFAULT_HOLDBACK_CHARS floor: the "Only raise it" comment
// above is now load-bearing, not advisory. A sub-floor value (or garbage) is clamped UP to the
// floor and reported via `_holdback.clamped`, because a holdback below the default banner
// detector's 100-char reach would let the first chars of a real auth banner stream before the
// end-of-turn gate rejects the turn (the A1 leak). We can only ever raise the guarantee, never
// weaken it below the detector's bound.
const _holdback = resolveStreamHoldback(process.env.OCP_TUI_STREAM_HOLDBACK);
const TUI_STREAM_HOLDBACK = _holdback.value;
if (TUI_MODE && TUI_STREAM && _holdback.clamped) {
console.error(
`[tui] WARNING: OCP_TUI_STREAM_HOLDBACK=${JSON.stringify(process.env.OCP_TUI_STREAM_HOLDBACK)} is below the\n` +
` safe floor (${DEFAULT_HOLDBACK_CHARS}) or not a number; clamped up to ${DEFAULT_HOLDBACK_CHARS}. The holdback can only be raised.`
);
}
if (TUI_MODE && TUI_STREAM && process.env.CLAUDE_TUI_ERROR_PATTERNS != null && TUI_STREAM_HOLDBACK <= DEFAULT_HOLDBACK_CHARS) { if (TUI_MODE && TUI_STREAM && process.env.CLAUDE_TUI_ERROR_PATTERNS != null && TUI_STREAM_HOLDBACK <= DEFAULT_HOLDBACK_CHARS) {
// The holdback's FIRST-MESSAGE half (see TuiDeltaAssembler) is sound for the DEFAULT // The holdback's FIRST-MESSAGE half (see TuiDeltaAssembler) is sound for the DEFAULT
// auth-banner detector (which cannot match a message longer than 100 chars). An // auth-banner detector (which cannot match a message longer than 100 chars). An
@@ -474,7 +534,12 @@ const SPAWN_HOME_DIR = `${process.env.HOME}/.ocp/spawn-home`;
// erroring loudly — never a silent auth/credential corruption (there are no credentials here). // erroring loudly — never a silent auth/credential corruption (there are no credentials here).
function prepareSpawnHome(dir = SPAWN_HOME_DIR) { function prepareSpawnHome(dir = SPAWN_HOME_DIR) {
try { try {
mkdirSync(`${dir}/.claude`, { recursive: true }); // mode 0700, and it matters for the PARENT: with `recursive`, this call can create ~/.ocp
// itself on a fresh install (spawn homes live under it), and without an explicit mode that
// parent lands at the umask default — world-listable 0755. keys.mjs used to pre-create it
// 0700 as an import side effect; it no longer does (it resolves its dir lazily), so the
// 0700 guarantee has to be stated here rather than inherited by luck.
mkdirSync(`${dir}/.claude`, { recursive: true, mode: 0o700 });
// Belt-and-braces: ensure no settings.json/plugins leak in (this home is fully ours). // Belt-and-braces: ensure no settings.json/plugins leak in (this home is fully ours).
for (const f of [`${dir}/.claude/settings.json`, `${dir}/.claude/settings.local.json`]) { for (const f of [`${dir}/.claude/settings.json`, `${dir}/.claude/settings.local.json`]) {
try { if (existsSync(f)) rmSync(f, { force: true }); } catch { /* best effort */ } try { if (existsSync(f)) rmSync(f, { force: true }); } catch { /* best effort */ }
@@ -1489,6 +1554,17 @@ async function callClaudeTui(model, messages, _conversationId, _keyName, res, st
streamDir: TUI_STREAM ? TUI_STREAM_DIR : null, streamDir: TUI_STREAM ? TUI_STREAM_DIR : null,
abortSignal: streamCtx ? streamCtx.signal : null, abortSignal: streamCtx ? streamCtx.signal : null,
}); });
// ── Billing-pool observation (issue #115, #133) — A3 fix: record the entrypoint the moment
// runTuiTurn returns, BEFORE the honesty gates below that can throw. The entrypoint (cli vs
// sdk-cli) is which BILLING POOL the turn consumed; a turn that then fails a gate (wall-clock
// truncation, auth banner, stream divergence) STILL spent that pool — and those failed turns
// are exactly the ones most likely to signal a silent degrade to the metered Agent SDK pool.
// Recording only on the success path (the old placement) blinded /health's entrypointMismatches
// and lastEntrypoint to every failed turn. recordModelSuccess still runs later, only on success.
if (recordTuiEntrypoint(tuiStats, entrypoint, TUI_ENTRYPOINT)) {
logEvent("warn", "tui_entrypoint_mismatch", { expected: "cli", got: entrypoint, model: cliModel });
}
// ── Honesty gates (issue #133) ─ run BEFORE recordModelSuccess / cache write-back. // ── Honesty gates (issue #133) ─ run BEFORE recordModelSuccess / cache write-back.
// A throw here propagates to the catch below (recordModelError + reject), so the // A throw here propagates to the catch below (recordModelError + reject), so the
// result never reaches the downstream setCachedResponse / singleflight / SUCCESS path. // result never reaches the downstream setCachedResponse / singleflight / SUCCESS path.
@@ -1566,17 +1642,9 @@ async function callClaudeTui(model, messages, _conversationId, _keyName, res, st
} }
recordModelSuccess(cliModel, 0); // elapsed not measurable here; wallclock at reader level recordModelSuccess(cliModel, 0); // elapsed not measurable here; wallclock at reader level
// Assert the subscription-pool classification. TUI exists to keep cc_entrypoint=cli // Entrypoint/billing-pool observation was already recorded above, right after runTuiTurn
// (subscription pool); a silent degrade to sdk-cli (metered Agent SDK pool) would still // returned — see the A3-fix comment there (it must cover failed turns too, so it cannot live
// return text but cost money — warn loudly so it's visible. (issue #115) // on this success-only path).
// C-5: also surface the observation on /health. recordTuiEntrypoint sets lastEntrypoint
// unconditionally (operators can poll it to confirm cli) and increments
// entrypointMismatches when expected=cli but observed≠cli — the same condition the
// journald warning already covers — so a silent metered-pool drift is visible on /health
// without tailing logs.
if (recordTuiEntrypoint(tuiStats, entrypoint, TUI_ENTRYPOINT)) {
logEvent("warn", "tui_entrypoint_mismatch", { expected: "cli", got: entrypoint, model: cliModel });
}
return text; return text;
} catch (err) { } catch (err) {
// A mid-turn client disconnect (streaming path only — abortSignal) is NOT an upstream // A mid-turn client disconnect (streaming path only — abortSignal) is NOT an upstream
+3 -1
View File
@@ -390,7 +390,9 @@ if (!DRY_RUN) {
// and "ocp-proxy" keeps the proxy invisible to that heuristic. // and "ocp-proxy" keeps the proxy invisible to that heuristic.
const OCP_HOME = join(HOME, ".ocp"); const OCP_HOME = join(HOME, ".ocp");
const ocpLogsDir = join(OCP_HOME, "logs"); const ocpLogsDir = join(OCP_HOME, "logs");
if (!existsSync(ocpLogsDir)) mkdirSync(ocpLogsDir, { recursive: true }); // mode 0700: with `recursive`, this call can create ~/.ocp ITSELF on a fresh install, and
// without an explicit mode that parent lands at the umask default (world-listable 0755).
if (!existsSync(ocpLogsDir)) mkdirSync(ocpLogsDir, { recursive: true, mode: 0o700 });
// Uninstall legacy service names if present (upgrade path) // Uninstall legacy service names if present (upgrade path)
if (platform === "darwin") { if (platform === "darwin") {
+26
View File
@@ -0,0 +1,26 @@
// Imported FIRST by test-features.mjs, before keys.mjs, so this runs before anything can open
// the key store. ESM hoists imports and evaluates them in order, so a `process.env.X = ...`
// statement in the test's own body would run too late — hence a separate module.
//
// Why this exists: `npm test` used to write real, UNREVOKED api_keys rows into the operator's
// live ~/.ocp/ocp.db (the same database the running server reads) — two per run, unbounded.
// It also made the suite racy: two concurrent runs (e.g. review worktrees) shared one file, so
// `listKeys()` could miss "test-user-1" and the `in` check would throw on undefined.
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
export const TEST_OCP_DIR = mkdtempSync(join(tmpdir(), "ocp-test-"));
// BOTH are required. keys.mjs honors OCP_DIR_OVERRIDE only when NODE_ENV === "test", so neither
// var alone redirects anything — a stray OCP_DIR_OVERRIDE in a production env is inert without
// NODE_ENV=test alongside it. (A daemon OCP launches never carries either: the service units and
// the `ocp` restart fallback strip both — see plist-merge NEVER_PRESERVE / keys.mjs's comment.)
process.env.NODE_ENV = "test";
process.env.OCP_DIR_OVERRIDE = TEST_OCP_DIR;
// Remove the scratch store on exit. Without this the fix would trade unbounded growth in
// ~/.ocp/ocp.db for unbounded growth in $TMPDIR — better, but still litter.
process.on("exit", () => {
try { rmSync(TEST_OCP_DIR, { recursive: true, force: true }); } catch { /* best effort */ }
});
+201 -12
View File
@@ -3,22 +3,24 @@
* Integration test for Quota + Cache features. * Integration test for Quota + Cache features.
* Tests database layer functions directly no server needed. * Tests database layer functions directly no server needed.
*/ */
import { getDb, createKey, listKeys, validateKey, recordUsage, checkQuota, updateKeyQuota, getKeyQuota, findKey, cacheHash, getCachedResponse, setCachedResponse, clearCache, getCacheStats, closeDb, hasCacheControl, singleflight, getInflightStats } from "./keys.mjs"; // MUST come before keys.mjs: redirects the key store to a scratch dir (see test-env.mjs).
import { TEST_OCP_DIR } from "./test-env.mjs";
import { getDb, getDbPath, createKey, listKeys, validateKey, recordUsage, checkQuota, updateKeyQuota, getKeyQuota, findKey, cacheHash, getCachedResponse, setCachedResponse, clearCache, getCacheStats, closeDb, hasCacheControl, singleflight, getInflightStats } from "./keys.mjs";
import { isLoopbackBind } from "./lib/net.mjs"; import { isLoopbackBind } from "./lib/net.mjs";
import { createSerialMutex, createTtlCache, isTokenExpiring, orderLabelsLastGoodFirst } from "./lib/spawn-auth.mjs"; import { createSerialMutex, createTtlCache, isTokenExpiring, orderLabelsLastGoodFirst } from "./lib/spawn-auth.mjs";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { strict as assert } from "node:assert"; import { strict as assert } from "node:assert";
import { unlinkSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { execFileSync } from "node:child_process";
import { homedir } from "node:os"; import { homedir } from "node:os";
// Use a test database to avoid corrupting real data process.env.HOME = homedir(); // normalize HOME so homedir()-derived paths are stable across shells
const TEST_DB = join(homedir(), ".ocp", "ocp-test.db");
try { unlinkSync(TEST_DB); } catch {}
// Monkey-patch DB_PATH for testing (override the module-level variable) // The scaffolding that used to live here CLAIMED to use "a test database to avoid corrupting
// Since keys.mjs uses lazy init, we can set env before first getDb() call // real data" by setting an env var before the first getDb(). It never worked: keys.mjs read no
process.env.HOME = homedir(); // ensure consistent // env var, and ESM hoisting meant the assignment ran after the import anyway. The redirect is
// now real, and lives in test-env.mjs (imported above, before keys.mjs). This test proves it.
let passed = 0; let passed = 0;
let failed = 0; let failed = 0;
@@ -535,7 +537,7 @@ async function runSingleflightTests() {
await runSingleflightTests(); await runSingleflightTests();
// ── Plist Env Merge Tests ── // ── Plist Env Merge Tests ──
import { mergePlistEnv, mergeSystemdEnv } from "./scripts/lib/plist-merge.mjs"; import { mergePlistEnv, mergeSystemdEnv, NEVER_PRESERVE } from "./scripts/lib/plist-merge.mjs";
console.log("\nPlist env merge:"); console.log("\nPlist env merge:");
@@ -649,6 +651,78 @@ test("mergePlistEnv is idempotent", () => {
assert.equal(mergePlistEnv(r1, SAMPLE_TEMPLATE_PLIST), r1); assert.equal(mergePlistEnv(r1, SAMPLE_TEMPLATE_PLIST), r1);
}); });
// ── A4: security denylist — test-only key-store redirection vars must NEVER survive a setup
// re-run, even when a prior unit already carried them. Mutation-proof: drop the
// `!NEVER_PRESERVE.has(k)` guard in either merge fn and these fail (the vars get preserved).
test("NEVER_PRESERVE denylists exactly the two key-store redirection vars", () => {
assert.ok(NEVER_PRESERVE.has("NODE_ENV") && NEVER_PRESERVE.has("OCP_DIR_OVERRIDE"));
assert.equal(NEVER_PRESERVE.size, 2, "exactly two — a new entry needs its own rationale + test");
});
const PLIST_EXISTING_WITH_TEST_VARS = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>dev.ocp.proxy</string>
<key>EnvironmentVariables</key>
<dict>
<key>CLAUDE_PROXY_PORT</key>
<string>3456</string>
<key>CLAUDE_CACHE_TTL</key>
<string>600</string>
<key>NODE_ENV</key>
<string>test</string>
<key>OCP_DIR_OVERRIDE</key>
<string>/tmp/scratch-store</string>
</dict>
</dict>
</plist>`;
test("mergePlistEnv strips test-only redirection vars (A4) but keeps legit user keys", () => {
const merged = mergePlistEnv(PLIST_EXISTING_WITH_TEST_VARS, SAMPLE_TEMPLATE_PLIST);
assert.match(merged, /<key>CLAUDE_CACHE_TTL<\/key>\s*<string>600<\/string>/, "a legit user key is still preserved");
assert.doesNotMatch(merged, /<key>NODE_ENV<\/key>/, "NODE_ENV must never reach a service unit");
assert.doesNotMatch(merged, /OCP_DIR_OVERRIDE/, "OCP_DIR_OVERRIDE must never reach a service unit (key or value)");
});
test("mergePlistEnv: an existing unit whose ONLY extras are denylisted → template unchanged", () => {
const existing = `<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0">
<dict>
<key>EnvironmentVariables</key>
<dict>
<key>CLAUDE_PROXY_PORT</key>
<string>3456</string>
<key>NODE_ENV</key>
<string>test</string>
<key>OCP_DIR_OVERRIDE</key>
<string>/tmp/scratch-store</string>
</dict>
</dict>
</plist>`;
assert.equal(mergePlistEnv(existing, SAMPLE_TEMPLATE_PLIST), SAMPLE_TEMPLATE_PLIST, "nothing left to preserve → clean template");
});
const SYSTEMD_EXISTING_WITH_TEST_VARS = `[Unit]
Description=OCP Open Claude Proxy
[Service]
ExecStart=/usr/bin/node /home/u/ocp/server.mjs
Environment=CLAUDE_PROXY_PORT=3456
Environment=CLAUDE_CACHE_TTL=600
Environment=NODE_ENV=test
Environment=OCP_DIR_OVERRIDE=/tmp/scratch-store
Restart=always
`;
test("mergeSystemdEnv strips test-only redirection vars (A4) but keeps legit user keys", () => {
const merged = mergeSystemdEnv(SYSTEMD_EXISTING_WITH_TEST_VARS, SAMPLE_TEMPLATE_SYSTEMD);
assert.match(merged, /Environment=CLAUDE_CACHE_TTL=600/, "a legit user key is still preserved");
assert.doesNotMatch(merged, /Environment=NODE_ENV=/, "NODE_ENV must never reach a service unit");
assert.doesNotMatch(merged, /OCP_DIR_OVERRIDE/, "OCP_DIR_OVERRIDE must never reach a service unit");
});
test("mergeSystemdEnv is idempotent", () => { test("mergeSystemdEnv is idempotent", () => {
const r1 = mergeSystemdEnv(SAMPLE_EXISTING_SYSTEMD, SAMPLE_TEMPLATE_SYSTEMD); const r1 = mergeSystemdEnv(SAMPLE_EXISTING_SYSTEMD, SAMPLE_TEMPLATE_SYSTEMD);
assert.equal(mergeSystemdEnv(r1, SAMPLE_TEMPLATE_SYSTEMD), r1); assert.equal(mergeSystemdEnv(r1, SAMPLE_TEMPLATE_SYSTEMD), r1);
@@ -3248,8 +3322,33 @@ test("models.json aliases.haiku === 'claude-haiku-4-5-20251001' (usage-probe SPO
assert.equal(_spotModels.aliases.haiku, "claude-haiku-4-5-20251001"); assert.equal(_spotModels.aliases.haiku, "claude-haiku-4-5-20251001");
}); });
test("models.json aliases.sonnet === 'claude-sonnet-4-6' (default-request-model SPOT)", () => { test("models.json aliases.sonnet === 'claude-sonnet-5' (default-request-model SPOT)", () => {
assert.equal(_spotModels.aliases.sonnet, "claude-sonnet-4-6"); assert.equal(_spotModels.aliases.sonnet, "claude-sonnet-5");
});
// ── Referential integrity (PR #152 review) ──────────────────────────────────
// The value-mirror assertions above only prove the alias equals a string literal —
// they pass even if that literal points at a model that does not exist in
// models[]. A one-line slip (edit an alias, forget the models[] entry) would leave
// /v1/models missing the model while every `model: "<alias>"` request passes
// validation and then fails at CLI spawn. VALID_MODELS keys on alias *names*, so
// nothing else checks alias *targets*. This is the guard with teeth.
const _spotModelIds = new Set(_spotModels.models.map(m => m.id));
test("models.json: claude-sonnet-5 is present in models[] (the entry this PR adds)", () => {
assert.ok(_spotModelIds.has("claude-sonnet-5"), "claude-sonnet-5 must exist as a models[].id");
});
test("models.json: every aliases value resolves to a real models[].id (referential integrity)", () => {
for (const [name, target] of Object.entries(_spotModels.aliases)) {
assert.ok(_spotModelIds.has(target), `aliases.${name} -> '${target}' is a dangling alias (no matching models[].id)`);
}
});
test("models.json: every legacyAliases value resolves to a real models[].id (referential integrity)", () => {
for (const [name, target] of Object.entries(_spotModels.legacyAliases || {})) {
assert.ok(_spotModelIds.has(target), `legacyAliases.${name} -> '${target}' is a dangling alias (no matching models[].id)`);
}
}); });
// ── escapeHtml + key-name validator (issue #114) ──────────────────────────── // ── escapeHtml + key-name validator (issue #114) ────────────────────────────
@@ -3451,7 +3550,7 @@ async function runAsyncTests() {
// ── TUI real streaming: MessageDisplay hook sink (backlog #2) ─────────────── // ── TUI real streaming: MessageDisplay hook sink (backlog #2) ───────────────
// Pure-logic coverage for lib/tui/stream.mjs: sink parsing, the concat===T assertion, // Pure-logic coverage for lib/tui/stream.mjs: sink parsing, the concat===T assertion,
// prefix-stability, the auth-banner holdback, message scoping, and the error paths. // prefix-stability, the auth-banner holdback, message scoping, and the error paths.
import { TuiDeltaAssembler, parseDeltaChunk, buildStreamSettings, streamFilePath, HOOK_SCRIPT, prepareStreamHook } from "./lib/tui/stream.mjs"; import { TuiDeltaAssembler, parseDeltaChunk, buildStreamSettings, streamFilePath, HOOK_SCRIPT, prepareStreamHook, resolveStreamHoldback, DEFAULT_HOLDBACK_CHARS } from "./lib/tui/stream.mjs";
test("stream: parseDeltaChunk consumes only COMPLETE lines (a torn write stays unread)", () => { test("stream: parseDeltaChunk consumes only COMPLETE lines (a torn write stays unread)", () => {
const p = (i, d, final = false) => JSON.stringify({ hook_event_name: "MessageDisplay", session_id: "s", message_id: "m", index: i, final, delta: d }); const p = (i, d, final = false) => JSON.stringify({ hook_event_name: "MessageDisplay", session_id: "s", message_id: "m", index: i, final, delta: d });
@@ -3519,6 +3618,42 @@ test("stream: holdback releases once past the detector's reach, and only then",
assert.equal(a.push(mdFire(2, "tail")), "tail", "subsequent deltas stream straight through"); assert.equal(a.push(mdFire(2, "tail")), "tail", "subsequent deltas stream straight through");
}); });
// ── resolveStreamHoldback: the FLOOR under OCP_TUI_STREAM_HOLDBACK (A1 fix) ────────────
// The C-1 auth-banner guarantee holds only while the holdback >= the default detector's
// 100-char reach. These tests pin that the resolver CLAMPS UP to the floor. They are
// mutation-proof: delete the `parsed < floor` branch and the sub-floor cases below fail
// (a 50 would pass straight through, reopening the leak). The clamped flag drives the boot
// warning in server.mjs, so its truthiness is asserted alongside every value.
test("holdback: a sub-floor value is clamped UP to the floor and flagged", () => {
assert.deepEqual(resolveStreamHoldback("50"), { value: DEFAULT_HOLDBACK_CHARS, clamped: true });
assert.deepEqual(resolveStreamHoldback("0"), { value: DEFAULT_HOLDBACK_CHARS, clamped: true });
assert.deepEqual(resolveStreamHoldback("-5"), { value: DEFAULT_HOLDBACK_CHARS, clamped: true });
assert.deepEqual(resolveStreamHoldback("99"), { value: DEFAULT_HOLDBACK_CHARS, clamped: true });
});
test("holdback: garbage / NaN falls back to the floor and is flagged (not silently 0)", () => {
assert.deepEqual(resolveStreamHoldback("unlimited"), { value: DEFAULT_HOLDBACK_CHARS, clamped: true });
assert.deepEqual(resolveStreamHoldback("5MB"), { value: DEFAULT_HOLDBACK_CHARS, clamped: true });
});
test("holdback: an above-floor value passes through unchanged and is NOT flagged", () => {
assert.deepEqual(resolveStreamHoldback("200"), { value: 200, clamped: false });
assert.deepEqual(resolveStreamHoldback("101"), { value: 101, clamped: false });
assert.deepEqual(resolveStreamHoldback(String(DEFAULT_HOLDBACK_CHARS)), { value: DEFAULT_HOLDBACK_CHARS, clamped: false });
});
test("holdback: an unset env var takes the floor WITHOUT flagging (no spurious boot warning)", () => {
assert.deepEqual(resolveStreamHoldback(undefined), { value: DEFAULT_HOLDBACK_CHARS, clamped: false });
assert.deepEqual(resolveStreamHoldback(null), { value: DEFAULT_HOLDBACK_CHARS, clamped: false });
assert.deepEqual(resolveStreamHoldback(""), { value: DEFAULT_HOLDBACK_CHARS, clamped: false });
assert.deepEqual(resolveStreamHoldback(" "), { value: DEFAULT_HOLDBACK_CHARS, clamped: false });
});
test("holdback: the floor is a parameter, so a deployment can raise (never lower) it", () => {
assert.deepEqual(resolveStreamHoldback("150", 200), { value: 200, clamped: true }, "custom floor still clamps up");
assert.deepEqual(resolveStreamHoldback("300", 200), { value: 300, clamped: false });
});
test("stream: a short answer never passes the holdback and is delivered whole at terminal", () => { test("stream: a short answer never passes the holdback and is delivered whole at terminal", () => {
const T = "The capital of France is Paris."; const T = "The capital of France is Paris.";
const a = new TuiDeltaAssembler(); const a = new TuiDeltaAssembler();
@@ -3811,6 +3946,60 @@ test("REGRESSION: a WARM (pooled) pane streams — the sink comes off the pane,
assert.equal(out.text, "Hello world", "and the transcript stays authoritative for the final text"); assert.equal(out.text, "Hello world", "and the transcript stays authoritative for the final text");
}); });
console.log("\nTest isolation (the suite must never touch the operator's live key store):");
test("the key store under test is a scratch db, NOT the operator's real ~/.ocp/ocp.db", () => {
// The guard that was missing. `npm test` wrote live, UNREVOKED api_keys rows straight into the
// operator's real ~/.ocp/ocp.db — the same database the running server reads — two per run,
// unbounded (737 junk keys vs 12 real ones on the maintainer's host before this landed). It
// went unnoticed for so long precisely because NOTHING asserted where the store actually was.
const real = join(homedir(), ".ocp", "ocp.db");
const used = getDbPath();
assert.ok(used, "getDb() must have opened something by now");
assert.notEqual(used, real, "the suite must NOT open the operator's live key database");
assert.ok(used.startsWith(TEST_OCP_DIR), `expected a scratch db under ${TEST_OCP_DIR}, got ${used}`);
});
test("a PRODUCTION process (no NODE_ENV) must IGNORE OCP_DIR_OVERRIDE", () => {
// Must run OUT OF PROCESS. The parent is irreversibly NODE_ENV=test by the time any test runs
// (test-env.mjs set it before keys.mjs was imported), so the production path is unreachable
// from in here — and an in-process test can only ever RE-IMPLEMENT the predicate, which is
// worthless: the first cut of this test did exactly that, and deleting the whole NODE_ENV gate
// from keys.mjs still left the suite at 320 passed / 0 failed. A copy of the predicate is not
// the predicate. So: spawn a child with no NODE_ENV, the override set, and HOME redirected to
// a temp dir (so the real key store is never opened), and assert what the REAL keys.mjs did.
const home = mkdtempSync(join(tmpdir(), "ocp-prodsim-"));
const evil = mkdtempSync(join(tmpdir(), "ocp-evil-"));
try {
const keysUrl = pathToFileURL(join(import.meta.dirname, "keys.mjs")).href;
// The child prints the override it SAW, then the store it actually opened. Printing both is
// the negative control: without it, a future refactor that renamed the env var and missed
// this test's `env` object would leave the child with no override at all — and "prod opened
// the right store" would pass for the wrong reason. Asserting the child saw it and ignored
// it anyway is the claim we actually want to make.
const probe = `import { getDb, getDbPath, closeDb } from ${JSON.stringify(keysUrl)};
getDb(); process.stdout.write(process.env.OCP_DIR_OVERRIDE + "\\n" + getDbPath()); closeDb();`;
const env = { ...process.env, HOME: home, OCP_DIR_OVERRIDE: evil };
delete env.NODE_ENV; // a production server has no NODE_ENV
const [seen, opened] = execFileSync(process.execPath, ["--input-type=module", "-e", probe],
{ env, encoding: "utf8" }).trim().split("\n");
assert.equal(seen, evil, "precondition: the child must actually SEE the override");
assert.equal(opened, join(home, ".ocp", "ocp.db"), "a prod process must open HOME/.ocp/ocp.db");
assert.ok(!opened.startsWith(evil), "…having seen the override, a prod process must IGNORE it");
} finally {
rmSync(home, { recursive: true, force: true });
rmSync(evil, { recursive: true, force: true });
}
});
test("listKeys does not depend on rows left behind by an earlier or concurrent run", () => {
// The ~1-in-6 flake: two runs sharing one db file. keys.find() returned undefined and the
// caller's `in` check threw a TypeError instead of failing cleanly. With a per-run scratch db
// the store starts empty, so the count is exactly what THIS run created.
const mine = listKeys().filter((k) => k.name === "test-user-1");
assert.equal(mine.length, 1, "exactly one test-user-1 — a shared store would accumulate duplicates");
});
runAsyncTests().then(() => Promise.all(pendingAsync)).then(() => { runAsyncTests().then(() => Promise.all(pendingAsync)).then(() => {
closeDb(); closeDb();
console.log(`\n=== Results: ${passed} passed, ${failed} failed ===\n`); console.log(`\n=== Results: ${passed} passed, ${failed} failed ===\n`);