74 Commits
Author SHA1 Message Date
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>
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
1b324968f4 feat(tui): real SSE streaming via claude's MessageDisplay hook (OCP_TUI_STREAM, default off) (#159)
* feat(tui): real SSE streaming via claude's MessageDisplay hook (OCP_TUI_STREAM, default off)

Backlog #2. TUI-mode `stream:true` turns can now emit real SSE `delta.content` chunks as
`claude` generates them, instead of buffering the turn and replaying it with
streamStringAsSSE. Opt-in: with OCP_TUI_STREAM unset/0 the spawn argv, the SSE bytes and
the cache behaviour are byte-for-byte unchanged (asserted by test).

This PR does NOT mirror any cli.js function, so no `cli.js:NNNN` citation applies, and per
CLAUDE.md's hard requirement #1 that is stated explicitly here rather than left implicit:

  - We consume claude's OWN `MessageDisplay` hook surface AS EMITTED — forwarding, not
    inventing. No new endpoint, no fabricated protocol, no new field.
  - The TUI spawn is OCP-owned surface: ADR 0007 owns it, not cli.js.
  - The SSE wire shapes are the OpenAI chat/completions streaming spec, adopted by ADR 0006.
    Every frame emitted here (role chunk, content-delta chunk, stop chunk, `[DONE]`, and the
    post-header {error:{message,type}} frame) is COPIED from callClaudeStreaming, the -p path.

/health gains additive fields only (streamEnabled + 4 counters) — same grandfathered B.2
rationale as the existing tui block (ADR 0006). Existing keys are untouched.

`claude` fires MessageDisplay per rendered block, handing the hook the RAW MARKDOWN SOURCE
of an incremental delta on stdin. The hook is registered with `--settings` on the ordinary
interactive spawn (no -p, no --bare) — verified to leave the billing pool alone.

Sink: a static sh hook script appends each payload to `<streamDir>/<session_id>.jsonl`; OCP
polls that file and forwards deltas as SSE. The per-session-id keying is MANDATORY, not an
optimization — OCP_TUI_MAX_CONCURRENT defaults to 2, so two claude panes already run at
once and a shared sink would splice one client's deltas into another's stream.

Warm-pool compatible (a separate in-flight PR depends on this): the hook script AND the
settings file are static — nothing request-specific is baked in at spawn time. The sink path
reaches the pane through its own env (OCP_TUI_STREAM_FILE) and derives from the session-id,
which a pre-booted pane fixes at boot.

The hook is SYNCHRONOUS (forceSyncExecution: claude blocks on it), so the script writes and
exits: one `cat` append, nothing else. Measured p50 7.2ms / p90 14.7ms per fire, ~50ms across
a whole turn — noise against a 6-10s turn.

It remains the terminal-turn signal, the source of the returned/cached text T, and the input
to the honesty gates. The delta stream is a low-latency MIRROR, never a replacement:

  - the truncation gate (C-2) and auth-banner gate (C-1, issue #133) run BEFORE anything is
    committed or flushed, unchanged;
  - at end of turn the streamed bytes are asserted against T. Equal -> serve. A strict PREFIX
    of T -> top up from the transcript so the client still receives exactly T (counted).
    NOT a prefix -> REFUSE the turn: SSE error frame, no cache, no success, streamDivergences++.
    Serving text the transcript disagrees with is the failure class ALIGNMENT.md exists to
    prevent, so this fails loud rather than degrading quietly;
  - only T is ever cached — never the concatenated deltas.

The auth banner needs prevention, not just detection (SSE deltas cannot be un-sent), so the
first OCP_TUI_STREAM_HOLDBACK (100) chars are withheld: the default banner detector cannot
match a message longer than 100 chars, so releasing past that provably cannot leak a banner.
A custom CLAUDE_TUI_ERROR_PATTERNS has no such bound — OCP warns at boot.

  - BANNER, before/after the spawn change: `Sonnet 4.6 with low effort · Claude Max` both,
    including on the pane the server itself spawns. Never `API Usage Billing`. Transcript
    entrypoint stays "cli". --settings is not a --bare-class flag.
  - --settings MERGES with <HOME>/.claude/settings.json rather than clobbering it (the
    user-level settings' `env` block still reached the hook), so the isolated-HOME settings
    story (permissions / additionalDirectories) survives.
  - EXACTNESS: 8/8 varied prompts (short, long, markdown, code fence, multilingual, JSON,
    table, unicode) byte-exact vs transcript T, streamed AND buffered. 0 top-ups,
    0 divergences over 15 streamed turns.
  - TTFT: buffered delivers NOTHING until the turn ends (TTFB == total, 7.5-15.8s). Streamed
    sends headers at ~25ms (heartbeat covers the pre-first-delta silence) and first content
    mid-generation, e.g. markdown 7.9s first chunk / 12.8s total; long 9.7s / 17.4s.
  - CONCURRENCY DEMUX: two concurrent streamed turns (ALPHA/BRAVO), tui.inflight peaked at 2,
    each read its own session-keyed transcript, ZERO cross-contamination.
  - AUTH-BANNER GATE under streaming, both layers: a short banner-like turn reached the client
    as 0 content chunks + an SSE error frame (never emitted); a long one was streamed but still
    ended on an error frame, not finish_reason:"stop", and was not cached.
  - DISCONNECT mid-turn: pane torn down and semaphore slot released within 1s (info-logged,
    not booked as a model error).
  - THINKING: not leaked. Opus 4.8 + xhigh turns carry a thinking block with a signature but
    `thinking:""` (the reasoning text is not persisted in interactive mode), both
    MessageDisplay text-extraction sites in the 2.1.207 bundle filter type==="text", and no
    reasoning prose appeared in any delta; concat===T held exactly on the single-message turn.
  - npm test: 282 passed, 0 failed (was 267 on main; +15).

The transcript keeps only the model's LAST assistant message. A turn where the model narrates
before calling a tool therefore has two messages, and T is only the second. If the narration
exceeds the holdback it has already been streamed and cannot be retracted -> the turn is
REFUSED. Reproduced live: Opus narrated 475 chars before a Bash call. The assembler discards
a prior message's text when nothing has been emitted yet (so short narration is handled
correctly and stays exact), and raising OCP_TUI_STREAM_HOLDBACK above the narration length
rescues the turn — verified on that exact transcript: holdback>=500 -> served, exact=true.
Documented in README and ADR 0007; this is why streaming is opt-in and off by default.

ADR 0007 line 59 ("no real token streaming — deliberate") is amended, not silently
contradicted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tui): re-integrate streaming onto the warm-pane pool (#158) — install the hook at BOOT

Rebasing backlog #2 (streaming) onto #158 (warm pane pool) is not a textual merge: #158
split the monolithic runTuiTurn into bootTuiPane + runTuiTurn, and streaming had patched
the monolith. Re-integrating it in the OLD shape would have compiled, passed every existing
test, and been WRONG.

The bug that shape would have shipped: the sink was derived at TURN time from a streamDir
argument. But a POOLED pane is pre-booted long before any request exists — so on a pool HIT
runTuiTurn never cold-boots, no hook was ever registered on that pane, and the turn would
silently serve BUFFERED. Every miss streams, every hit does not; no error, no failing test.
The operator sees "streaming does nothing in production" and has nothing to grep for.

Fix — install the hook where the pane is born:
  - bootTuiPane({ streamDir }) registers the MessageDisplay hook at spawn and returns the
    pane's own sink (pane.streamFile), keyed by the pane's own --session-id. The hook script
    and settings file are STATIC (one pair per streamDir); the only per-turn thing is the
    sink path, and it is fixed at boot. So nothing request-specific is baked into a spawn.
  - runTuiTurn reads pane.streamFile — never recomputes it — so a warm pane and a cold pane
    stream through byte-for-byte the same path.
  - server.mjs threads the same streamDir into the pool's bootPane closure, so pre-booted
    panes carry the hook too. TUI_STREAM/TUI_STREAM_DIR now declare before the pool needs them.

Three regression guards added (test-features.mjs), and the third was MUTATION-TESTED: with
the fix reverted to the turn-time shape it fails ("the pooled pane's deltas must reach the
client"), with the fix in place it passes. A guard nobody has watched fail is not a guard.

/health: pool + stream* fields are now a union — the shape assertion asserts CONTAINMENT of
the seven grandfathered keys plus an exact added-set, so a future field that silently
REPLACED an original key cannot pass.

Class B (ADR 0007, OCP-owned TUI spawn) — cli.js does NOT perform this operation.
npm test: 313 passed, 0 failed.

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

* fix(tui): close the streaming auth-banner leak + 6 further review findings (PR #159)

Independent review (Iron Rule 10) found a HIGH bug by EXECUTING the code, not reading it.
All seven findings fixed. F1 and F3 were merge-blocking.

F1 (HIGH) — the auth-banner holdback was bypassed after the first release.
  TuiDeltaAssembler.released was set once and never reset at a message_id boundary, so the
  holdback + detectError predicate guarded only the FIRST message of a turn. In production's
  own configuration (OCP_TUI_FULL_TOOLS=1, where multi-message tool-using turns are the norm):
  the model narrates past the holdback before a tool call -> released; credentials expire
  mid-turn -> claude renders the 401 as ordinary assistant TEXT as a NEW message -> push()
  took the `if (this.released)` branch and handed the banner verbatim to the client. That is
  precisely the silent-error case the C-1 gate exists to prevent. Detection survived (the
  turn was still refused at finalize) but PREVENTION did not.
  Fix: once a message boundary follows an emit, the turn is already unrecoverable — finalize()
  will refuse it — so push() now emits NOTHING further for the rest of the turn.
  Second hole in the same predicate: detectTuiUpstreamError() trims before applying its
  <=100-char rule, so 101 whitespace chars trimmed to "" -> detector had nothing to classify
  -> returned null -> release fired having screened nothing. Release now gates on the TRIMMED
  length, so both sides of the check talk about the same string.

F2 — the "provably safe" claim in stream.mjs, ADR 0007 and README was unsound as written.
  Restated with both required halves: (i) nothing is emitted until the trimmed accumulation
  exceeds the detector's max banner length, AND (ii) no emission at all once a message
  boundary follows an emit. Half (i) alone only ever covered a turn's first message.

F3 (blocker) — prepareStreamHook was write-if-missing, so md-hook.sh could never be updated
  OR repaired: a host that booted once under an older version was stuck on that HOOK_SCRIPT
  forever, and a non-atomic write interrupted mid-flight left a TRUNCATED script that
  existsSync() called fine — on a hook claude BLOCKS on synchronously. Now written
  unconditionally via tmp+renameSync (the pattern already used by ensureTuiCwdTrusted).

F4 — the two spawn paths differed for non-streaming requests: the pool installed the hook
  whenever OCP_TUI_STREAM was on (correct — a pre-booted pane cannot know what request it will
  serve), but the cold path gated it on this turn's onDelta. So one stream:false request got
  --settings on a pool HIT and not on a MISS: two spawn argvs for the identical request, on
  this project's billing-classification surface. Both paths now gate on TUI_STREAM alone;
  whether the sink is POLLED remains correctly gated on onDelta.

F5 — pool._drop() killed the pane but orphaned its sink file; the reap tick drains the whole
  pool, so sinks accumulated with no GC path. Now removed best-effort on every drop path.

F6 — /health counters did not measure what they documented: streamTurns was incremented only
  AFTER the honesty gates, hiding exactly the turns an operator most wants to see (and making
  streamDivergences/streamTurns a meaningless ratio); streamDeltas counted every fire while
  claiming to count forwarded ones. Counters and docs now agree.

F7 — total hook failure was silent: zero fires per turn still yields ok:true/exact:false and
  a normal, fully-buffered answer. Only streamTopUps moved, which the code itself calls
  benign. Added streamZeroDeltaTurns (+ a tui_stream_zero_deltas warning) to separate "the
  hook is dead" from "one fire was dropped".

Tests: 316 passed, 0 failed (was 313). Every new guard MUTATION-TESTED — with each fix
reverted the guard named for it fails, and passes with the fix restored:
  - drop the restartedAfterEmit guard   -> 2 failed (incl. the strengthened old test)
  - revert trim() in the release gate   -> 1 failed
  - revert F3 to write-if-missing       -> 1 failed
The pre-existing test "new message_id AFTER an emit" asserted finalize().ok === false but
never checked what push() RETURNED — so it passed while F1 was live, documenting the leak
instead of catching it. Strengthened to assert the emission, not just the verdict.

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

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 Fable 5 <noreply@anthropic.com>
2026-07-14 08:19:24 +10:00
9f5bc3264a feat(tui): warm pane pool — single-use pre-booted panes, opt-in via OCP_TUI_POOL_SIZE (−41%) (#158)
* feat(tui): warm pane pool — single-use pre-booted panes, opt-in via OCP_TUI_POOL_SIZE

Backlog item #3 of docs/plans/2026-07-13-tui-latency/README.md. Every TUI request
currently cold-boots a tmux+claude pane. This adds an OPT-IN pool of pre-booted panes.
Recorded as ADR 0008 (docs/adr/0008-tui-warm-pane-pool.md), which extends ADR 0007.

MEASURED (this host, Sonnet 4.6, --effort low, through a real OCP instance; a sample
counts only if HTTP 200 AND the body carries the demanded marker):

  pool off (main code)      n= 6  p50 10.17s  [9164 9499 9760 10572 10774 11281]
  pool on, warm hits        n=12  p50  6.00s  [5286 5289 5520 5584 5621 5969
                                               6040 6098 6280 7846 8036 11053]
  pool on, warm hits (post- n= 6  p50  5.62s  [4729 4753 5236 6004 7548 9548]
    review-fix re-run)

  -> -4.17s / -41%.  12 hits / 1 miss / 0 bootFailures over 13 requests (and 6/1/0 on
  the post-fix re-run). Robust to counting the miss: n=13 p50 -> -40.6%.

The plan doc predicted only -1.0s (the boot). It is ~4.2s because the cold path also
pays ~2.9s INSIDE the first turn beyond claude's own reported turn_duration — post-
input-bar init that an idle pane has already finished. Phase decomposition of the cold
path (n=6 medians): prep 2ms | tmux spawn 27ms | boot->input-ready 1232ms | paste 8ms |
paste-verify 426ms | submit->terminal 8458ms | teardown 8ms = 10162ms total, vs native
turn_duration 5539ms => 4490ms of OCP-side overhead, of which the pool recovers ~1.26s
of boot and ~2.9s of in-claude cold start. (The 426ms paste-verify is one 400ms poll
tick; a real paste lands in ~80ms. Not addressed here — separate item.)

DESIGN
- SINGLE-USE panes. A pooled pane serves exactly ONE turn, then is killed and replaced
  in the background. Each carries its OWN fresh --session-id fixed at boot, so one
  session still holds one exchange. This is what keeps transcript.mjs's
  extractLatestAssistantText correct; its warning about a future warm pool reusing a
  session is answered in-place (comment updated) and left standing for anyone who later
  wants a second turn on a pane — that would be a cross-request TEXT LEAK and needs
  user-line scoping in the transcript reader first.
- Pool keyed by model; --model is fixed at spawn. A miss falls back to the cold path
  with zero behaviour change. The pool warms the most recently requested model, so the
  first request after start (and after a model switch) is always a cold miss.
- REAPER COEXISTENCE (the crux). An idle warm pane IS ours, and the periodic sweep runs
  precisely when we are idle. reapStaleTuiSessions() takes a `spare` set of EXACT live
  session names, and server.mjs DRAINS the pool immediately before the sweep:
    1. a live pooled pane is never reaped — INCLUDING one still BOOTING (see below);
    2. an orphaned pooled pane IS still reaped — membership is by exact name from a live
       in-memory registry, never by name shape, so a pane from a dead process generation
       has nothing claiming it. Omitting `spare` reaps MORE, never less (fail-safe);
    3. kill-server is suppressed while any pane is spared — hence the drain, so the sweep
       still flushes <defunct> claude zombies (the only mechanism that can).
- THE POOL TRACKS ITS IN-FLIGHT BOOT BY NAME, NOT AS A COUNT. bootTuiPane creates the
  tmux session SYNCHRONOUSLY and only then waits up to POOL_BOOT_MS (20s) for the input
  bar, so a pooled session can be LIVE for ~20s before its boot resolves. Tracking boots
  as a count meant the pool could not name that session, which caused two real bugs
  (found in review, reproduced, fixed, and now regression-tested):
    * the reap sweep KILLED the booting pane (it could not be spared), left the pool
      empty with nothing scheduled, and logged the exact tui_pool_boot_failed WARN
      operators are told to alert on — for a completely healthy drain;
    * graceful shutdown ORPHANED a live authenticated idle `claude`: gracefulShutdown
      calls process.exit(0) in the SAME TICK as the drain (TUI panes are tmux children,
      so activeProcesses is empty and the wait-for-children path exits immediately), so
      cleanup deferred to a .then() never ran.
  Fix: the pool mints each pane's identity up front ({sessionId, name}) and holds it in
  _bootingPane. liveNames() includes it; drain() kills it SYNCHRONOUSLY. A generation
  counter distinguishes "cancelled by us" from "genuinely failed", so a drain never
  inflates bootFailures and resume() reliably starts a fresh boot. Deriving the name from
  the session-id also makes `tmux ls` correlate to the transcript file.
- SLOT ACCOUNTING. Refill boots take NO TuiSemaphore slot (those bound real turns and
  would be starved); they cannot leak one either, since they never hold one. Refills are
  SERIALIZED, one boot at a time — live at size=2, two cold boots racing an in-flight
  turn overran the readiness cap and a refill was discarded. A genuinely failed boot does
  not re-kick the chain (backoff; a broken claude must not respawn forever). Background
  boots get a more generous readiness cap (POOL_BOOT_MS = 5x BOOT_MS): BOOT_MS is tight
  because a client is blocked on it, which is not true of a pre-boot.
- BOUNDED COST. A warm pane is a LIVE idle claude process held whether or not a request
  arrives. Peak processes = pool size + OCP_TUI_MAX_CONCURRENT + 1 booting replacement.
  Size clamped to POOL_MAX_SIZE=4; garbage values disable rather than guess. Panes have
  a 10-min TTL and a health check at hand-out (dead/degraded pane => miss, never a hang).
  Missing collaborators throw at CONSTRUCTION, not on a live request (refill() is called
  synchronously from the request path).

DEFAULT OFF (OCP_TUI_POOL_SIZE=0). This is a stable production path and the pool holds
standing processes, so the operator opts in. With the pool off, runTuiTurn takes the
IDENTICAL code path as before (the `pool ? pool.acquire() : null` branch yields null, and
tuiPool is null so no observer is attached and no new log line is emitted) — that is what
establishes the default path is unchanged. A pool-off control run (n=6, p50 9.40s) is
consistent with the 10.17s baseline but had 2/6 samples >12s, so it is corroboration, NOT
proof: n=6 cannot establish "unregressed" on its own. The code-path equivalence can.

BANNER: NO SPAWN ARGUMENT CHANGED. buildTuiCmd is byte-identical to main (verified by
extracting the function body from both revisions and comparing). Live banner captured
from two real POOLED panes anyway: "Sonnet 4.6 with low effort · Claude Max" — the
subscription pool, never "API Usage Billing".

/health: `tui.pool` added (null when off), incl. `cancelled` (boots WE killed — not a
fault; do not alert on it). The tui block is ADR-0007-owned and post-dates ADR 0006's
v3.16.4 grandfather snapshot; the addition is purely additive — every pre-existing key
keeps a byte-identical value. Authorization recorded in ADR 0008.

ALIGNMENT: Class B / ADR 0007 + ADR 0008 (OCP-owned TUI spawn machinery). cli.js does NOT
perform this operation — there is no cli.js citation and none is required: this is not an
Anthropic API surface, it is OCP's own process management around the claude CLI, exactly
as the existing tmux session lifecycle and reaper already are (ALIGNMENT.md Rule 2).

TESTS: 294 passed / 0 failed (was 267). +27 covering acquire/hit/miss, single-use (a pane
is never handed out twice), bounded + serialized refill, TTL + health-check drops, model
retarget, drain/resume, boot-failure backoff, identity linkage, all three reaper
invariants incl. post-drain kill-server restoration, and — the coverage gap that let both
bugs ship — FIVE mid-boot tests: the booting pane is nameable/spareable, the sweep's drain
kills it and resume starts a fresh boot with no bogus WARN, shutdown kills it
synchronously (asserted WITHOUT awaiting, since process.exit runs in the same tick), a
stale settle cannot clear a newer boot's slot, and a model switch cancels an in-flight
boot for the old model.

Live verification (temporary 20s reap interval, reverted): sweep drained both panes ->
reaped -> refilled with NEW panes; a foreign tmux session survived untouched; with no
foreign session kill-server fired and the pool still recovered and served the next
request. Both review bugs reproduced against a PRIVATE tmux server (-L pr3repro, so the
reaper's internal kill-server could not touch the host) before and after the fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tui): kill a cancelled boot's pane when it settles + make async tests actually count

Folds in the independent review's remaining nit — and, in proving the nit's fix, uncovers
two defects in the test suite itself.

## The nit (latent M1b, second costume)

`_cancelBooting` kills BY NAME, but the tmux session only EXISTS once `bootPane` has run —
and `bootPane` is queued on a microtask. So a caller doing `refill()` then `drain()` in the
SAME synchronous block leaves `_cancelBooting` with nothing to kill (a no-op); it bumps the
generation, and the boot microtask then CREATES the session, succeeds, and — under the old
bare `return` on a stale generation — walked away from a LIVE authenticated `claude` that
nothing owns. Reproduced:

  reverted: drain() kills nothing (no session yet) -> boot creates it -> ORPHAN: ['p1']
  fixed   : drain() kills nothing (no session yet) -> boot creates it -> boot kills it -> []

Not reachable from any current call site, so this is defense-in-depth — but ADR 0008 and the
reap-tick comment in server.mjs BOTH explicitly contemplate a boot-time pre-warm, which is
exactly the shape that reaches it. Killing an already-dead session is a harmless no-op, so
the fix is idempotent whichever way the race lands.

## Defect 1 in the suite: async tests were never awaited (44 of them)

Writing the regression guard exposed this. `test()` called `fn()`, got a promise back, and
IMMEDIATELY printed ✓ and incremented `passed` — without awaiting it. For all 44 tests written
as `test("...", async () => {...})`:
  - ✓ meant "did not throw SYNCHRONOUSLY", not "passed";
  - a failed assertion escaped as an unhandled rejection, crashing the process (CI stays red on
    the non-zero exit) but never being COUNTED — so the summary could print "0 failed" and be wrong.
The suite's headline number was therefore not evidence for ANY async test, including this PR's own
M1a/M1b guards. `test()` now settles an async body before counting it, and the summary awaits them.

## Defect 2, exposed the instant defect 1 was fixed: a false guard

`"a boot that resolves AFTER a drain kills its own pane ... no orphan process left behind"` asserted
`killed.length === 1` — i.e. that kill was CALLED once. But `_cancelBooting`'s kill-by-name on a
not-yet-existent session is a NO-OP that still increments that counter. So "kill was called once" and
"a live session is orphaned" were both true at the same time: a test named for the absence of an
orphan was passing while the orphan was present. Now asserts LIVENESS (`live.size === 0`) — the only
honest question.

## Evidence

  fix present : 295 passed, 0 failed, exit 0
  fix reverted: 293 passed, 2 failed  <- BOTH liveness guards fire (the old kill-count guard did not)

Also: `dropped`'s doc comment now lists `cancelled` (a cancelled in-flight boot lands there via _drop).

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

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 16:51:12 +10:00
d96da46fa0 fix: honor runtime-lowered concurrency limit + cancel queued waiters on disconnect (#149)
* fix: honor runtime-lowered concurrency limit + cancel queued waiters on disconnect

Fixes three findings from an independent concurrency audit of the -p/stream-json
wait-queue (lib/tui/semaphore.mjs, reused by server.mjs as `claudeSemaphore`) and
its acquireClaudeSlot()/callClaudeTui() callers in server.mjs:

F1 (MEDIUM) — release() handed a freed slot straight to the next queued waiter
without re-checking `this.limit`, so a PATCH /settings maxConcurrent decrease was
silently ignored until every already-inflight task happened to finish on its own.
release() now only re-grants when post-decrement inflight is still under the
current limit, and a new setLimit() wakes queued waiters immediately when the
limit is raised instead of only on the next incidental release().

F2 (MEDIUM) — a request queued behind the concurrency limit had no link to its
HTTP connection, so a client that disconnected while still queued would still
get a claude process spawned for it once a slot freed — burning subscription
quota for a dead socket. acquire() now accepts an optional AbortSignal; server.mjs
derives one from the client's res "close" event (closeSignalFor) and passes it
into claudeSemaphore.acquire() / tuiSemaphore.acquire() while queued. On abort the
waiter is spliced out of the queue (not just flagged), so `queued` accounting
stays exact; the same "close" signal is wired into acquireClaudeSlot() (-p path,
non-streaming + streaming + singleflight-wrapped) and callClaudeTui() (TUI path).
If the response is already destroyed by the time we try to queue, we reject
immediately without ever entering the queue.

F8 (cosmetic) — acquireClaudeSlot() set `stats.queued = claudeSemaphore.queued + 1`
BEFORE calling acquire(), over-reporting /health's queued count by 1 whenever a
slot was granted immediately (the common, non-queued case). acquire() already
updates its internal queue synchronously before returning a Promise, so reading
claudeSemaphore.queued right AFTER calling it (instead of guessing "+1" before)
is exact. No /health field was added, removed, or renamed.

ALIGNMENT.md: this PR touches request-handler code (callClaude, callClaudeStreaming,
callClaudeTui, acquireClaudeSlot) but is local concurrency-control/queue-accounting
infrastructure with no cli.js wire analogue — it does not add, rename, or change any
endpoint, header, request field, or response field, and does not touch the /v1/messages
forwarding path or the OAuth bearer machinery (the two Class A surfaces this repo
governs). The /health response shape is unchanged (same field set, same nesting;
only the *value* of the pre-existing `stats.queued` field is corrected). Per
CLAUDE.md hard-requirement #1, a cli.js citation is therefore declared ABSENT:
there is no corresponding cli.js operation to cite because this is not a
cli.js-mirror (Class A) change and not a Class B endpoint-contract change either.

Tests: added 6 unit tests to test-features.mjs against the shared TuiSemaphore
(lowering the limit mid-load does not over-admit; raising the limit wakes queued
waiters up to the new headroom, FIFO; a queued waiter cancelled via AbortSignal
is spliced out and never later acquires; an already-aborted signal never touches
the queue; cancelling one of several queued waiters preserves FIFO for the rest).
238 pre-existing tests remain green; suite is now 244/244.

Verification: `node --check server.mjs && node --check lib/tui/semaphore.mjs && npm test` — 244 passed, 0 failed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: singleflight follower retry on leader disconnect + quiet disconnect handling (review M1/L1/L2)

Addresses the independent reviewer's APPROVE-WITH-CHANGES findings on PR #149:

M1 (MEDIUM, F2 regression) — when a singleflight LEADER disconnected while queued,
its RequestDisconnectedError rejected the SHARED promise, so live followers fell
into respondUpstreamError's generic branch and got a spurious 500 on a healthy
socket. Fix: keys.mjs singleflight() gains an optional follower-side `retryIf`
predicate. When a follower joins an existing flight and the shared promise rejects
with an error retryIf() accepts, the follower does NOT inherit the rejection — it
re-enters singleflight with its OWN fn (the map entry is guaranteed already deleted:
the delete-finally is attached upstream of the promise followers await), becoming
the new leader or joining a retrying sibling's fresh flight. The leader's own
rejection is never retried (it IS that client's disconnect). server.mjs passes
retryIf = (err) => err instanceof RequestDisconnectedError && !res.destroyed, so a
follower whose own client is also gone still propagates quietly. Callers without
retryIf keep byte-for-byte pre-existing share-everything semantics (pinned by the
existing failure-fan-out test).

L1 (LOW) — a disconnect-while-queued on the non-streaming paths was recorded as a
usage FAILURE row and logged as a [proxy] error: metric noise for a non-error.
Both non-streaming catch blocks now early-return on RequestDisconnectedError
without recordUsage(success:false) and without console.error — mirroring the
streaming path, which returns silently. The disconnect remains observable at info
level (concurrency_wait_cancelled, now also emitted with path:"tui" from
callClaudeTui for parity with acquireClaudeSlot's -p log).

L2 (LOW, test gap) — added a unit test for the abort-after-grant race: a waiter
granted its slot whose signal aborts afterward must see no rejection, no queue
corruption, and its slot released exactly once via the normal path (the semaphore
detaches the abort listener at grant; the onAbort idx===-1 guard is the in-dispatch
backstop).

Tests: +3 (2× M1 in the singleflight section, 1× L2 in the F2 section) — suite is
now 247/247 green.

ALIGNMENT.md: unchanged declaration — still local concurrency/dedup infrastructure
with no cli.js wire analogue; no endpoint, header, request field, or response field
added or changed; /health shape untouched. cli.js citation declared ABSENT per
CLAUDE.md hard-requirement #1 (not a Class A mirror change, not a Class B
contract change).

Verification: node --check server.mjs lib/tui/semaphore.mjs keys.mjs && npm test
— 247 passed, 0 failed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 23:04:13 +10:00
2538233059 fix(server): serialize -p real-HOME token fallback + TTL-cache keychain + de-stale isolation decision (F3/F5/F6) (#150)
* fix(server): serialize -p real-HOME token fallback + TTL-cache keychain + de-stale isolation decision (F3/F5/F6)

Three audit findings in the -p spawn-token resolution + HOME-isolation layer. All are infra/
process changes to how OCP READS and GATES an OAuth token it already holds; none touch the OAuth
wire machinery.

F3 (MEDIUM) — expiry-window fallback herds concurrent -p spawns into real HOME.
When the keychain token is within 5 min of expiry, resolveSpawnToken() returns null and every
concurrent spawn simultaneously falls back to the real HOME; each spawned claude then races a
refresh_token grant against the SAME single-use refresh token — rotating it out from under the
others and the operator's real claude (the credential-fork hazard, #112/#146 class). Fix: a
promise-chain mutex (createSerialMutex) serializes ONLY the real-HOME fallback — one such spawn at
a time. When a serialized waiter is admitted (prior holder torn down → its claude has refreshed the
keychain), it re-runs resolveSpawnToken(): a now-fresh token means it proceeds ISOLATED instead of
real-HOME, so the queue drains to the fast path. Isolated spawns never touch the mutex.

F5 (LOW-MED) — per-spawn double keychain exec on the hot path.
getOAuthCredentials() sync-exec'd `security find-generic-password` up to twice (wrong label first),
worst case 5s×2, blocking the event loop and stalling in-flight SSE streams. Fix: (a) memoize the
last-good keychain label and try it first (orderLabelsLastGoodFirst); (b) a 30s TTL cache of the
read (createTtlCache). This does NOT reintroduce the #146 forever-memoized regression: the TTL
bounds only how often we re-READ the keychain; resolveSpawnToken() still applies the 5-min expiry
gate (isTokenExpiring) to the CACHED creds on EVERY use, so a token expiring within the window is
still rejected → real-HOME fallback. Call sites stay synchronous (no async conversion).

F6 (LOW) — memoized isolation decision goes stale; /health could misreport.
getSpawnHomeMode() memoized the isolated/real-home decision forever: credentials appearing after
startup never enabled isolation; deleting ~/.ocp/spawn-home at runtime ENOENT'd every isolated
spawn until restart; during an expiry stint /health reported isolated:true while spawns ran real-
HOME. Fix: re-evaluate the decision per spawn (cheap now that F5 caches the keychain read);
ensureSpawnHome() re-verifies + re-prepares the scratch dir per isolated spawn; and /health now
reports the EFFECTIVE decision (token presence AND expiry gate). The /health field set is
UNCHANGED — no field added/removed/renamed — only the values are made truthful.

Alignment:
- Class: Not a wire/endpoint change for the spawn-token layer + Class B (B.2) for /health.
- cli.js citation: DECLARED ABSENT. cli.js does NOT perform OCP's spawn-token resolution, HOME
  isolation, keychain caching, or fallback serialization — these are proxy-internal process
  concerns with no cli.js analogue, so no Class A cli.js:NNNN citation exists or is required
  (ALIGNMENT.md Rule 2: this is proxy infra, not an invented forwarded endpoint/header/body).
- The /health change is authorized by ADR 0006 (grandfathered B.2 as of v3.16.4) and is a
  behaviour-preserving contract change: same fields, truthful values.
- OCP still NEVER performs a refresh_token grant itself — that property is preserved; the fix only
  serializes/gates reads of a token refreshed by the spawned or real claude (#112).
- No new endpoint, header, or request/response field. alignment.yml blacklist unaffected.

Tests: extracted the pure primitives to lib/spawn-auth.mjs and added 11 unit tests (mutex
serialization order + idempotent release; TTL cache freshness + null-miss; expiry gate; label
ordering; and the combined invariant that the TTL cache respects the expiry gate). node --check
clean; 249 tests pass (238 + 11).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(server): drain F3 fallback queue immediately — invalidate F5 keychain cache before the serialized re-check

Follow-up to the F3/F5/F6 fix (independent-review observation). F5's 30s keychain TTL cache could
make F3's post-refresh re-check see the stale (expiring) cached creds for up to ~30s, so a waiter
admitted right after the prior real-HOME holder's claude refreshed the keychain would needlessly
fall back to real HOME again instead of proceeding ISOLATED. Serialization safety was never at risk
(still one real-HOME spawn at a time, no double-refresh); only the drain-to-fast-path optimization
lagged.

Fix: invalidateKeychainReadCache() clears the F5 TTL cache; resolveSpawnDecision() calls it under
the fallback mutex, immediately before the re-check, so the admitted waiter reads FRESH keychain
state and drains to the isolated fast path at once. The extra keychain read happens only on the rare
real-HOME fallback path and only under the mutex (serialized, one at a time).

Alignment: unchanged from the parent commit — proxy-internal keychain/HOME-isolation process logic,
no cli.js analogue (cli.js citation DECLARED ABSENT, ALIGNMENT.md Rule 2), no endpoint/header/body,
/health shape unchanged, OCP still never performs a refresh_token grant itself (#112). 249 tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 22:50:52 +10:00
31e5a44099 fix(tui): scope session prefix + reap/kill-server to this instance's port (F7) (#148)
Audit finding F7 (LOW): lib/tui/session.mjs hardcoded SESSION_PREFIX =
"ocp-tui-" as a bare, host-wide constant. The boot-reap and periodic
idle-reap in server.mjs used it to decide which tmux sessions to
kill-session and whether to kill-server (which flushes defunct <claude>
zombies but tears down the WHOLE tmux server, including any live pane).
The coexistence guard only ever spared foreign product prefixes
(olp-tui-*); a SECOND OCP instance on the same host — e.g. a temporary
verification instance stood up alongside production, a real pattern
used during PR #144/#146 verification — was indistinguishable from
"ours" and could have its LIVE sessions reaped/kill-server'd by the
other instance's boot or periodic sweep.

Fix: scope the session-name prefix to this instance's own listen port
(the natural stable per-instance discriminator on one host — two OCP
instances cannot share a port): `ocp-tui-<port>-`. A sibling instance's
`ocp-tui-<otherPort>-*` sessions now fail the own-prefix startsWith
check and fall into the same "othersRemain" bucket as olp-tui-*,
so they are never touched and never used to justify kill-server.

lib/tui/session.mjs:
  - sessionPrefixForPort(port) replaces the bare SESSION_PREFIX export.
  - reapStaleTuiSessions({ tmux, port, includeLegacy }) now requires
    port and computes its own prefix from it.
  - runTuiTurn({ ..., port }) builds the tmux session name from
    sessionPrefixForPort(port) instead of the old bare constant.
  - LEGACY_SESSION_PREFIX / LEGACY_SESSION_NAME_RE (exact
    "ocp-tui-<8-hex>" shape, no port segment) describe the OLD
    pre-fix session-name shape, retained only for the migration below.

Legacy migration rule (chosen + reasoning): a bare-prefix legacy
session cannot be created by any post-fix OCP process, so if one is
seen it is presumed to be an orphaned zombie from THIS instance's own
PRE-fix process generation (left behind across an in-place upgrade),
not a stranger's. reapStaleTuiSessions() therefore accepts an
includeLegacy flag: server.mjs's one-time BOOT reap passes
includeLegacy: true (claims exact-legacy-shape sessions as its own,
enabling cleanup right after an upgrade); the periodic 15-min idle
sweep does NOT set it, so a lingering legacy-shaped session during
steady-state is conservatively treated as foreign and cannot trigger
kill-server on a routine tick. Residual (documented, accepted): a
genuinely-still-running PRE-fix OCP instance coexisting on the host at
the exact moment a new instance boots could have its live legacy
session reaped — the same class of residual risk the audit finding
itself accepts ("no live instance of the new version creates them");
this PR does not regress that scenario, it only removes the far more
common same-version collision that is the actual F7 finding.
LEGACY_SESSION_NAME_RE (`^ocp-tui-[0-9a-f]{8}$`) can never match the
new shape: the new shape always inserts a literal "-" between the
port digits and the 8-hex suffix, which the anchored 8-hex-only legacy
regex cannot satisfy.

server.mjs changes are local TUI session-lifecycle infrastructure
(tmux session naming, boot/periodic reap, kill-server) with no cli.js
wire analogue — verified via `strings` against the compiled claude
CLI 2.1.198 binary (this machine ships cli.js as a Mach-O binary per
ALIGNMENT.md's "OAuth token-host verification" precedent): cli.js
contains only `env.TMUX` detection (whether IT is running inside a
tmux pane) and an unrelated `--remote-control-session-name-prefix`
flag for its own remote-control feature — no session-prefix/reap/
kill-server mechanism of any kind. Per ALIGNMENT.md Rule 2, this is
declared absent: no endpoint, header, request, or response shape
changed; only OCP's own local process-lifecycle bookkeeping. No PORT
literal was hardcoded (CI port-SPOT check) — PORT is threaded through
from the existing server.mjs SPOT (lib/constants.mjs DEFAULT_PORT via
CLAUDE_PROXY_PORT).

test-features.mjs: rewrote the reaper suite's fixture session names to
the new port-scoped shape, added tests for sessionPrefixForPort(),
LEGACY_SESSION_NAME_RE's non-collision with the new shape, a sibling
same-host OCP instance being treated as foreign (F7 regression test),
and the includeLegacy boot-migration behavior (claims legacy zombies,
still spares a sibling instance's port-scoped session).

Verified: node --check server.mjs && node --check lib/tui/session.mjs
&& npm test → 243 passed, 0 failed.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 22:43:19 +10:00
2922d68842 fix(server): re-resolve -p spawn OAuth token per-spawn, expiry-aware (③ regression) (#146)
The FIX-③ spawn-home isolation memoized the OAuth token at startup. The macOS
keychain access token rotates (~hourly, refreshed by the operator's real claude),
so the startup snapshot went stale and every isolated -p spawn returned upstream
401 'Invalid authentication credentials' — a ~31h Mac-mini outage (PI231/oracle use
static long-lived env tokens, unaffected).

Fix: getSpawnHomeMode() now caches only the isolation DECISION; the token is
re-resolved FRESH per spawn via resolveSpawnToken(), which also returns null when a
known expiry has passed (5-min buffer) so the caller falls back to real HOME — where
the spawned claude refreshes the credential natively and self-heals. OCP still never
refreshes the token itself (a refresh-token grant would consume the single-use token
and log out the operator's real claude — issue #112). Env-token hosts carry no
expiresAt and are never expiry-gated. Infra/process change; no cli.js surface, no new
endpoint/header. 238 tests pass; live-verified sonnet 200 on a temp instance.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
2026-06-26 20:35:21 +10:00
5aaab5ea28 fix: -p spawn-home isolation (③ latency 3x) + concurrency queue/429 (⑥) + ocp restart env + /ocp plugin compat (#144)
* fix(server): isolate default -p spawn in credential-free scratch HOME (latency ③)

The default (-p/stream-json) spawn inherited the operator's real HOME (global
~/.claude plugins/skills/hooks) and ran with cwd=~/ocp (project CLAUDE.md/skills),
loading heavy host context on EVERY request. Measured: pure API floor for haiku
"hi" ≈ 1–2s; same CLI in the operator's real HOME/cwd ≈ 10–28s; a clean minimal
HOME + CLAUDE_CODE_OAUTH_TOKEN ≈ 3–7s with auth intact.

When an OAuth token is resolvable (and OCP_SPAWN_REAL_HOME!=1), spawnClaudeProcess
now runs claude under a credential-free minimal scratch HOME (<HOME>/.ocp/spawn-home,
no .credentials.json / settings.json / plugins) with cwd = that neutral dir and the
resolved token in CLAUDE_CODE_OAUTH_TOKEN (env token is authoritative for -p). Mirrors
the TUI path's resolveTuiHome() env-token mode. Falls back to real HOME + inherited cwd
when no token resolves (zero regression); OCP_SPAWN_REAL_HOME=1 is a kill-switch.

The token is resolved ONCE (memoized via getSpawnHomeMode, reusing getOAuthCredentials,
the same resolver the /usage probe uses) and never logged. Adds a startup log line and
an additive /health `spawn` block so the operator can confirm isolation is on.

ALIGNMENT.md Rule 2 justification: this is an INFRA / PROCESS-MANAGEMENT change
(HOME/cwd/env isolation of the spawned process). It does NOT mirror a cli.js wire
operation, introduces no new endpoint/header, and adds no API token to the wire path —
so there is no cli.js function to cite. CLAUDE_CODE_OAUTH_TOKEN / CLAUDE_CODE_DISABLE_*
are existing claude-CLI env contracts already used by the TUI path and getOAuthCredentials.

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

* fix(server): bounded wait-queue + HTTP 429 for -p concurrency overflow (⑥)

spawnClaudeProcess used `if (activeRequests >= MAX_CONCURRENT) throw` → the client
got an opaque 500 AND the rejection was uncounted (a 15-concurrent stress run returned
7×500 while /health stats.errors stayed 0). The TUI path already had a bounded-queue
semaphore (TuiSemaphore); the -p path did not.

Now the -p path reuses TuiSemaphore as `claudeSemaphore = new TuiSemaphore(MAX_CONCURRENT,
{ maxQueue: CLAUDE_MAX_QUEUE })`. Requests beyond MAX_CONCURRENT WAIT (up to CLAUDE_MAX_QUEUE,
default 16) instead of being rejected; only when the queue is ALSO full does the request get
HTTP 429 + Retry-After (rate_limit_error, NOT 500), a distinct `concurrency_queue_full` log,
and a stats.queueRejections counter surfaced on /health. callClaude / callClaudeStreaming now
acquire a slot (acquireClaudeSlot) before spawning; the release fn is wired into the existing
idempotent cleanup() so the slot is freed on EVERY exit path (close/error/timeout/abort) — the
#37/#40 slot-leak guard. MAX_CONCURRENT semantics (max concurrent claude procs) are unchanged;
only overflow handling changed from throw-500 to queue-then-429. claudeSemaphore.limit is kept
in sync with runtime /settings maxConcurrent changes.

Live-verified (fake claude, MAX_CONCURRENT=1 MAX_QUEUE=1): 3 concurrent → exactly one 429
(Retry-After: 7, rate_limit_error body) + two 200s, on BOTH the non-streaming and streaming
paths; /health stats.queueRejections=1 while stats.errors=0; after drain activeRequests=0 /
inflight=0 / queued=0 and a follow-up request returns 200 (no slot leak). 2 new unit tests
(247 passed, 0 failed).

ALIGNMENT.md Rule 2 justification: this is an INFRA / PROCESS-MANAGEMENT change (a concurrency
queue + backpressure status code in front of the existing spawn). It does NOT mirror a cli.js
wire operation, adds no new endpoint or wire header, and introduces no API token — so there is
no cli.js function to cite. (Retry-After is a standard HTTP response header on OCP's own 429,
not a claude wire header.)

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

* fix(ocp): macOS restart uses bootout+bootstrap so plist env is re-read

The macOS restart path used `launchctl kickstart -k gui/$uid/dev.ocp.proxy`, which
only re-execs the process and reuses launchd's CACHED environment — so a plist
EnvironmentVariables edit (CLAUDE_BIND, CLAUDE_CODE_OAUTH_TOKEN, etc.) was silently
ignored until a full unload/reload. This is the documented pit-index footgun.

`ocp restart` (macOS) now does a full `launchctl bootout` + `bootstrap` of the agent
via a new `_launchd_reload` helper, which re-reads the plist EnvironmentVariables so env
changes take effect. Success is keyed on the bootstrap (the env-reloading load), not the
bootout (which may legitimately fail if the agent is not currently loaded). A missing
plist returns failure so the `elif` chain falls through to the legacy label and then to
the Linux `systemctl --user restart` path unchanged (systemctl already re-reads its
EnvironmentFile). Updates `ocp restart` help text and adds a README Troubleshooting
subsection ("Env var change doesn't take effect after restart") with the manual
bootout+bootstrap commands and a ps-based verification one-liner.

Verified `_launchd_reload` with a stubbed launchctl: missing plist → rc=1 (falls through,
no launchctl call); bootout-fail + bootstrap-ok → rc=0; bootstrap-fail → rc=1; call order
is bootout then bootstrap. `bash -n ocp` clean; npm test 247/0 (server.mjs untouched).

ALIGNMENT.md Rule 2 justification: this changes ONLY the `ocp` CLI wrapper's local
service-restart mechanism (launchctl invocation). It does not touch server.mjs, the wire
path, any endpoint/header, or any API token — so there is no cli.js function to cite.

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

* docs(readme): document new env vars CLAUDE_MAX_QUEUE / CLAUDE_QUEUE_RETRY_AFTER / OCP_SPAWN_REAL_HOME

Release_kit contract (CLAUDE.md Iron Rule 5.5: "new env var → README § Environment
Variables table") requires the three env vars added by the perf/concurrency fixes to be
in the README table. Adds rows for CLAUDE_MAX_QUEUE (16) + CLAUDE_QUEUE_RETRY_AFTER (5)
(FIX ⑥ -p wait-queue + 429) and OCP_SPAWN_REAL_HOME (FIX ③ spawn-home isolation
kill-switch), each cross-referencing the additive /health.concurrency and /health.spawn
fields. Addresses the independent reviewer's MEDIUM finding. Docs only.

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

* fix(ocp-plugin): add openclaw.extensions for OpenClaw 2026.5.27 compat + sync version (②/ocp)

ocp-plugin/package.json's openclaw object lacked the 'extensions' field that
OpenClaw 2026.5.27 requires to install/load a plugin (matches the sibling olp
plugin). Without it the daemon refused to load the local path plugin, breaking
/ocp Telegram commands. Version synced 3.12.0 -> 3.16.2 to match the manifest.
Plugin-layer change; no server.mjs / ALIGNMENT cli.js surface touched.

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:26:57 +10:00
dtzp555-maxGitHubtaodengClaude <claude-opus> <noreply@anthropic.com>
60930f0ba4 fix(tui): credential-isolated env-token auth (ends recurring 401) + reap defunct sessions (#141)
* fix(tui): pass CLAUDE_CODE_OAUTH_TOKEN to spawned claude + reap defunct sessions

Root cause (PI231 incident): tmux does not forward the parent's env to the
pane, so the TUI claude never saw CLAUDE_CODE_OAUTH_TOKEN and fell back to
~/.claude/.credentials.json, whose single-use refresh token got corrupted to an
empty string by the per-request spawn + kill-session teardown racing claude's
token rotation -> permanent "Please run /login" 401 (re-login re-corrupted on
the next spawn). Connected leak: the pane's claude is a child of the tmux server
(not node), so kill-session left <defunct> zombies the server never reaped (25
over 30 days; tmux kill-server dropped it 25->3).

Fix 1: buildTuiCmd now adds CLAUDE_CODE_OAUTH_TOKEN=<shq-escaped> to the pane
env prefix when the env is set, so claude authenticates via the long-lived token
and never touches the credentials.json refresh path (matching stable hosts).
Unset -> no token added (credentials.json-only hosts unaffected).

Fix 2: reapStaleTuiSessions kill-servers after clearing our own sessions ONLY
when no foreign tmux session remains (never disrupts a co-hosted olp-tui-*).
kill-server is the only node-reachable action that ACTUALLY reaps -- server exit
reparents survivors to init, which waitpids them; a per-session kill cannot,
since node is not the zombies' parent. Added a 15-min periodic reap (server.mjs)
gated on TUI_MODE and on the TUI path being idle. Residual: a request whose pane
is created in the idle-check/kill-server window fails cleanly via the existing
honesty gates (documented).

ALIGNMENT: Class B (OCP-owned TUI spawn). cli.js does NOT perform either
operation -- there is no cli.js analogue for "how the TUI pane authenticates" or
"reaping tmux-server-owned zombies"; authorized by ADR 0007 (PR-C amendment) per
ALIGNMENT.md's Class B citation requirement. No Class A wire surface, no endpoint
shape, no alignment.yml token, and no models.json entry touched.

Tests: +6 in test-features.mjs (buildTuiCmd token set/unset/shq-injection;
reaper kill-server ours-only / foreign-present / no-server). 241 passed, 0 failed.

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

* fix(tui): isolate TUI auth to env-token-only home (no credentials.json shadowing)

Passing CLAUDE_CODE_OAUTH_TOKEN to the spawned interactive `claude` (commit
6394ca3) is necessary but INSUFFICIENT to fix the PI231 401: interactive `claude`
PREFERS ~/.claude/.credentials.json over the env var (unlike `-p`, where the env
token wins), so a stale/corrupt credentials.json SHADOWS the env token. Decisive
live evidence on PI231 (claude 2.1.104):

  - env token passed + a broken ~/.claude/.credentials.json present → 401
    ("Please run /login · API Error: 401").
  - env token passed + credentials.json moved aside              → real answer.

Fix: when CLAUDE_CODE_OAUTH_TOKEN is set (and OCP_TUI_HOME is unset), run the TUI
`claude` in a CREDENTIAL-FREE scratch home (<HOME>/.ocp-tui/home) that has NO
credentials.json — no symlink, no copy. The env token is then the only credential
and is authoritative because nothing shadows it. This ALSO ends the original
refresh-corruption incident (25-zombie / empty-refresh-token) at the ROOT: with no
credentials file, claude never runs the token-refresh path, so the single-use
refresh token can never be rotated/corrupted by the per-request spawn+kill cycle.

This RESOLVES — not reintroduces — the ADR 0007 scratch-home concern. The old
caveat was about a SYMLINKED credentials.json being forked on token refresh; in
env-token mode there is no credentials file to fork and no refresh ever happens.

Mechanism: scratch HOME (not CLAUDE_CONFIG_DIR). The claude binary supports
CLAUDE_CONFIG_DIR, but it relocates transcripts to <CONFIG_DIR>/projects/ rather
than <HOME>/.claude/projects/, forking the transcript-resolution rule across modes
for no benefit. Scratch-HOME reuses the existing, tested prepareTuiHome/ehome
plumbing; readTuiTranscript reads from the same home claude runs under, so
transcripts land under the scratch home and findTranscriptPath globs them there.

Backward compatible: when CLAUDE_CODE_OAUTH_TOKEN is unset, behaviour is byte-for-
byte unchanged (real home + credentials.json) so hosts that intentionally rely on
credentials.json are unaffected. Explicit OCP_TUI_HOME still wins. Onboarding +
cwd-trust are seeded in the scratch .claude.json (hasCompletedOnboarding=true +
trust ONLY the scratch cwd) so no interactive trust/onboarding dialog can hang the
turn.

Changes:
- lib/tui/session.mjs: add resolveTuiHome() (pure) + DEFAULT_TUI_SCRATCH_HOME;
  prepareTuiHome() gains { envTokenMode } — skips the credentials symlink and seeds
  a minimal .claude.json; runTuiTurn derives envTokenMode = token set && ehome!==rhome.
- server.mjs: TUI_HOME computed via resolveTuiHome(); boot log surfaces the auth mode.
- test-features.mjs: env-token credential-free prepareTuiHome test (asserts NO
  credentials.json created/symlinked, .claude.json seeded with onboarding + cwd
  trust) + 3 resolveTuiHome decision tests; existing buildTuiCmd-token + reaper +
  legacy/real-home tests stay green (245 passed, 0 failed).
- docs/adr/0007: PR-D amendment (corrects the PR-C rationale + the original
  scratch-home caveat); README Troubleshooting #401 + env-var table + TUI section.

ALIGNMENT: Class B (OCP-owned TUI spawn). cli.js has no analogue for the TUI pane's
auth/home strategy — authorized by ADR 0007 (PR-D amendment) per ALIGNMENT.md's
Class B citation requirement. No Class A wire path, no alignment.yml blacklist
token, no models.json touched. server.mjs is touched only to wire TUI_HOME via
resolveTuiHome() and surface auth mode in the boot log.

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

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude <claude-opus> <noreply@anthropic.com>
2026-06-13 16:54:32 +10:00
dtzp555-maxGitHubtaodengClaude <claude-opus> <noreply@anthropic.com>
3322d7bdae feat(tui): per-path concurrency limit + /health observability (PR-B) (#139)
Two P1 audit fixes for the TUI subscription-pool bridge (ADR 0007). The
default path (CLAUDE_TUI_MODE unset) is unchanged except the additive
/health `tui` block (enabled:false when off).

C-4 — TUI path had NO concurrency bound. The global MAX_CONCURRENT gate
lives in spawnClaudeProcess (the -p/stream-json path); callClaudeTui never
calls it — it calls runTuiTurn, which cold-boots a full interactive claude
in tmux. So N concurrent TUI requests spawned N simultaneous cold boots (a
family burst of 5 on a Pi 4 = OOM risk + subscription rate-limit pressure).
Adds an independent in-process limiter (lib/tui/semaphore.mjs, TuiSemaphore)
gating callClaudeTui: OCP_TUI_MAX_CONCURRENT (default 2 — a TUI turn is heavy:
per-request cold-boot + up to 120s wallclock). Queues rather than rejects
(mirrors MAX_CONCURRENT intent), with a bounded wait queue (default 32x the
limit) → tui_queue_full (503) on overflow rather than unbounded growth. The
slot releases in a finally, so PR-A's honesty-gate throws / timeouts / paste
failures never leak a slot.

C-5 — no operator-visible TUI drift surface. The tui_entrypoint_mismatch
warning only reached journald; after the 6/15 flip a silent sdk-cli drift
(the documented top risk) would drain metered credits invisibly. Adds an
additive `tui` block to /health: { enabled, entrypointMode, lastEntrypoint,
entrypointMismatches, inflight, queued, maxConcurrent }. lastEntrypoint /
entrypointMismatches are recorded in callClaudeTui (same mismatch branch the
journald warning covers); inflight/queued come from the C-4 semaphore.

ALIGNMENT (Class B): cli.js does NOT perform this operation — both the TUI
path and /health are OCP-owned, so no cli.js citation applies. /health is a
grandfathered B.2 endpoint (ADR 0006, frozen at v3.16.4). The response-shape
change is authorized by the ADR 0007 PR-B amendment added in this commit and
is behaviour-preserving: the `tui` block is NEW fields only — no existing
/health field is changed, renamed, removed, or re-typed, and no existing
semantics change, so existing consumers (dashboard, ocp-connect, monitoring)
are unaffected. Per ALIGNMENT.md's grandfather provision, an additive
behaviour-preserving change to a grandfathered B.2 endpoint is authorized by
an ADR; ADR 0007 is the authority for the TUI observability surface. No
Class A forwarding path, no alignment.yml, no models.json touched — alignment
blacklist is unaffected (zero new network tokens).

Tests: 11 new (lib/tui/semaphore.mjs is importable, so the semaphore + the
two pure /health helpers are tested directly): limit=1 serializes two
overlapping calls; limit=2 runs two + queues the third; slot released on
throw; bounded queue → tui_queue_full; mismatch counter increments on
cli→other drift; auto mode never counts a mismatch; /health tui block shape
+ live inflight/queued. npm test: 235 passed, 0 failed (was 224).

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude <claude-opus> <noreply@anthropic.com>
2026-06-10 21:54:07 +10:00
dtzp555-maxGitHubtaodengClaude <claude-opus> <noreply@anthropic.com>
a37ff713d9 fix(tui): honest error/truncation handling + version-robust entrypoint + short-prompt paste (PR-A) (#137)
TUI-mode (CLAUDE_TUI_MODE=true) is an OCP-owned subscription-pool bridge for the
2026-06-15 Anthropic billing split. This PR fixes four honesty/robustness defects
confirmed by a prior audit and live-reproduced on PI231 (2026-06-10).

C-1 (P1) — upstream AUTH-FAILURE banner returned/cached as a real answer.
  The interactive claude CLI renders in-session errors as ordinary assistant text.
  The R-1 case C-1 exists to catch is expired/invalid credentials, where EVERY turn
  comes back as the same one-line auth-failure banner, e.g. the two live PI231
  banners (2026-06-10):
    "Please run /login · API Error: 401 Invalid authentication credentials"  (69 chars)
    "Failed to authenticate. API Error: 401 Invalid authentication credentials" (73 chars)
  callClaudeTui returned that banner verbatim → OCP cached it, shared it via
  singleflight, and recorded a model SUCCESS. New detectTuiUpstreamError()
  (lib/tui/transcript.mjs) flags such a turn; callClaudeTui throws tui_upstream_error
  + logEvent("error", …) BEFORE recordModelSuccess/cache write-back, so the error
  never enters the cache; the client gets a 5xx.

  C-1 NARROWING (false-positive probe, 2026-06-10). An earlier generalised default
  rule — ^<short auth-failure prefix>?API Error: <3-digit> <detail>$ — was TOO BROAD:
  the unbounded ".*" detail tail let any short prefix + "API Error: NNN" + an
  arbitrarily long sentence match, so it KILLED legitimate long answers that merely
  DISCUSS an API error (e.g. "API Error: 500 happened because the server was
  overloaded. To fix this, retry with exponential backoff …"). A false-positive costs
  the user a missing answer AND a double-burn retry — strictly worse than the rare
  false-negative (caching one transient error for the 5-min TTL). C-1 is therefore
  reframed from "detect any API error" to "detect a claude-CLI AUTHENTICATION-FAILURE
  banner", and is CONSERVATIVE: when unsure it PASSES. The narrowed default detector
  flags a turn only when ALL of the following hold over the WHOLE trimmed text
  (a conjunction; any one failing => PASS):
    1. SHORT whole-message — length ≤ 100 (live banners are 69/73; cap gives headroom
       while rejecting multi-sentence prose; a 226-char auth answer is dropped on
       length alone).
    2. Contains "API Error: 4\d{2}" — auth failures are 4xx (401/403); transient 5xx
       and bare "HTTP 401 means unauthorized." (no API-Error core) are excluded.
    3. Contains an auth keyword — authenticat | /login | credential (case-insensitive);
       rejects "To debug a 401 … API Error: 401 Unauthorized …" (authoriz-, not
       authenticat-).
    4. Contains NO backtick/quote char (` ' ") — a real banner is plain text; quoted
       text signals an answer QUOTING the error, e.g. "You'll see `API Error: 401` …
       run /login to fix it." (short + 4xx + /login, excluded only by this signal).
  Full required matrix (2 KILL + 7 PASS) encoded as tests; npm test green.
  CLAUDE_TUI_ERROR_PATTERNS still overrides (a non-empty value REPLACES the default
  with operator regexes; empty/whitespace DISABLES detection). New fixture
  lib/tui/fixtures/error-401-failauth.jsonl + positive/negative tests retained.

C-2 (P1) — wallclock-truncated partial text returned as silent success.
  readTuiTranscript returned {text, entrypoint} identically on the terminal-marker
  path and the cap-with-partial path, so a cut-off turn was cached + returned as
  finish_reason:stop. It now returns truncated:false on terminal-marker and
  truncated:true on the cap-with-partial path (additive field; no-text cap path
  still throws). callClaudeTui throws tui_wallclock_truncated on truncated.

C-3 (P1) — verifyEntrypoint only read the turn_duration line.
  Some claude builds don't emit turn_duration (Mac mini: absent; PI231/2.1.104:
  present), while the entrypoint field is on ordinary lines on BOTH. Reading only
  turn_duration made the server.mjs tui_entrypoint_mismatch assertion get got:null
  every turn on non-emitting builds. verifyEntrypoint now PREFERS the turn_duration
  entrypoint and FALLS BACK to the entrypoint field on any line.

C-4 (P2) — short prompts 100% failed paste-landing.
  tuiPromptLanded required needle.length >= 3, so a 1–2 char first line ("hi","ok")
  never matched and 5s-failed with tui_paste_not_landed every time (live-repro:
  "hi"). Threshold lowered 3 → 2; the input box starts empty (placeholder excluded
  by the affirmative-signal design) so a 2-char needle present in the pane is the
  prompt. Kept >=2 (not >=1) to avoid collisions with claude's chrome glyphs.

Tests: extended test-features.mjs with unit coverage for each fix + three fixtures
(lib/tui/fixtures/error-401.jsonl, error-401-failauth.jsonl, no-turn-duration.jsonl).
The C-1 block now encodes the full narrowed auth-banner matrix (2 must-kill + 7
must-pass + supporting regression guards incl. a length-cap-load-bearing test).
npm test: 224 passed, 0 failed. Default path (CLAUDE_TUI_MODE unset →
upstreamCall=callClaude) is byte-identical: the only server.mjs change is one import
+ the callClaudeTui body, and callClaudeTui is unreachable when TUI_MODE is off.

ALIGNMENT: Class B (OCP-owned compatibility surface). cli.js does not perform this
operation (TUI is an OCP-owned subscription-pool bridge); scope authorized by ADR
0007 (Class B). No Class A path touched (callClaude / handleUsage / OAuth unchanged);
no change to .github/workflows/alignment.yml or any blacklisted token.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude <claude-opus> <noreply@anthropic.com>
2026-06-10 21:39:01 +10:00
1b02f181fa refactor: extract isLoopbackBind to lib/net.mjs (#125) (#127)
Follow-up cleanup from #115's review. isLoopbackBind was defined in server.mjs and
copy-pasted into test-features.mjs (with a "keep in sync" comment, because importing
server.mjs would run server.listen()). Extracted to a new importable lib/net.mjs;
server.mjs and the test now import the one definition, removing the drift surface.

Pure refactor — the function body is byte-identical to the prior server.mjs version,
the TUI LAN-gate call site is unchanged, and the 8 isLoopbackBind truth-table tests now
exercise the real shared function. 181 tests pass.

ALIGNMENT.md: touches server.mjs but adds/removes no operation — it relocates an existing
(#115) helper into a module. cli.js citation N/A under Rule 2.

Independent fresh-context reviewer (opus): APPROVE (Iron Rule 10) — byte-identical body,
exactly one definition, wiring + scope correct, no test dropped, scope clean.

Closes #125.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 07:03:55 +10:00
aa1c65beb1 fix: TUI hardening — non-loopback LAN gate + assert cc_entrypoint (#115) (#122)
Two TUI-mode findings from the 2026-05-31 audit (ADR 0007):

1. The fail-loud LAN gate refused TUI boot only when CLAUDE_BIND === "0.0.0.0",
   but binding to a concrete LAN IP, a Tailscale 100.x address, or IPv6 :: is
   equally network-exposed and slipped through — letting any reachable peer drive
   the operator's full-filesystem claude session. New isLoopbackBind() treats only
   127.0.0.0/8 / ::1 / localhost / ::ffff:127.0.0.1 as safe; the gate now trips on
   any non-loopback bind (OCP_TUI_ALLOW_LAN=1 escape hatch retained).

2. verifyEntrypoint() was implemented and unit-tested but never wired: readTuiTranscript
   discarded the events and returned only text, so a silent degrade to the metered
   sdk-cli (Agent SDK) billing pool — which still returns text but costs money — went
   undetected. readTuiTranscript now returns { text, entrypoint }; runTuiTurn passes it
   through; callClaudeTui logs a "tui_entrypoint_mismatch" warning when configured cli
   mode yields a non-cli entrypoint (including null/unverified). callClaudeTui still
   returns Promise<string>, so singleflight/cache/completion downstream is unchanged.

ALIGNMENT.md: TUI is a proxy-side execution-mode bridge (ADR 0007); this adds a local
bind classifier (startup gate) + a non-fatal log assertion of an existing classification
— no Anthropic operation forwarded, so a cli.js citation is N/A under Rule 2. No
blacklisted tokens or port literals introduced; alignment.yml passes.

Independent fresh-context reviewer (opus): APPROVE WITH MINOR (Iron Rule 10) — verified
isLoopbackBind via truth table (no exposed address misclassified as loopback — the
dangerous direction), the full string→{text,entrypoint} contract ripple across all
callers (Promise<string> preserved), the mismatch logic (warns on sdk-cli AND null in
cli mode, silent for auto/off, non-fatal), and ALIGNMENT N/A via a fetch/header grep.
Minors are process-only (this commit body; a future lib/ extraction of the test-mirrored
helper). npm test → 181 passed, 0 failed.

Closes #115.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 23:13:47 +10:00
879b40fe93 fix: escape dashboard DB-sourced values + validate key names (#114) (#121)
The dashboard built table rows via template-literal innerHTML, interpolating
DB-sourced strings (key names, usage rows) with no HTML escaping, and an
onclick="revokeKeyUI('${k.name}')" sink a single quote could break out of. Key
names were unvalidated at creation. Admin-gated (self-XSS today), but a real
unescaped-sink gap that becomes cross-user if key creation is ever delegated.

- dashboard.html: added escapeHtml() and applied it to every DB/string-sourced
  interpolation in refreshUsage and refreshKeys (key_name, name, keyPreview,
  created_at, last_request, model). Replaced the inline-onclick revoke button with
  a data-revoke attribute + addEventListener, so a name can never break out into an
  event-handler string. (model is attacker-pickable via the request body, so its
  escaping is the load-bearing one.)
- server.mjs: POST /api/keys now rejects names not matching /^[A-Za-z0-9 ._-]{1,64}$/
  before createKey() — defense-in-depth so a <script>/quote name can never reach the
  DB. Creation-only; existing keys unaffected; the default key-${Date.now()} passes.

Noted (out of scope, optional follow-up): the status/plan summary cards render
trusted server/Anthropic-upstream values unescaped — non-user-controlled, so not part
of this stored-XSS fix.

ALIGNMENT.md: the server.mjs change is proxy-policy input validation with no Anthropic
operation forwarded, so a cli.js citation is N/A under Rule 2. No blacklisted tokens or
port literals introduced; alignment.yml passes.

Independent fresh-context reviewer (opus): APPROVE WITH MINOR (Iron Rule 10) — verified
escapeHtml correctness, full sink coverage (incl. the attacker-pickable model field and
the data-revoke attribute), revoke round-trip via getAttribute, the anchored/bounded
key-name regex running before createKey, and that createKey has no other unvalidated
caller. Both minors non-blocking (trusted status-card escaping; this commit-body note).

Closes #114.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 23:03:50 +10:00
4a7d79c330 fix: record OAuth-host verification + models.json SPOT for usage-probe/default (#112) (#119)
Three alignment/SPOT findings from the 2026-05-31 audit:

1. The OAuth token-refresh host (platform.claude.com/v1/oauth/token, a Class A
   surface) was introduced in the 2026-04-11 drift commit and had no verification
   record. Verified against the compiled cli.js (claude.exe v2.1.154) via `strings`:
   OAUTH_TOKEN_URL and OAUTH_CLIENT_ID both appear in the binary byte-for-byte (in
   the same `prod` config object), and the legacy host console.anthropic.com/v1/oauth
   is absent (0 hits). Recorded this as an inline ALIGNMENT citation comment above the
   constants. No live OAuth probe was run — a refresh-token grant would rotate the
   operator's real credentials; the strings-on-binary evidence is decisive.
   (cli.js: verified against compiled claude.exe v2.1.154, 2026-05-31.)

2. fetchUsageFromApi() hardcoded the haiku model ID; now derives from
   modelsConfig.aliases.haiku (ADR 0003 SPOT). Prevents a silent /usage break on a
   future haiku ID bump.

3. [P3] The default request model hardcoded the sonnet ID; now derives from
   modelsConfig.aliases.sonnet (ADR 0003 SPOT).

Both SPOT values are byte-identical to the literals they replace today, so zero
behavior change — only drift-resistance. The alignment.yml blacklist pin for the
wrong-host variant was deliberately NOT included here: extending the blacklist is a
governance-layer change (alignment.yml inline policy) and belongs in its own PR.

ALIGNMENT.md: finding 1 IS the verification (cli.js citation recorded inline);
findings 2-3 are SPOT hygiene that forward no new operation. No blacklisted tokens or
port literals introduced; alignment.yml passes.

Independent fresh-context reviewer (opus) INDEPENDENTLY re-ran `strings` on the binary
and confirmed the host/client_id present and the legacy host absent — APPROVE (Iron
Rule 10; alignment hard-requirement #3 satisfied).

Closes #112.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 22:44:15 +10:00
c3b1f32c86 fix: error-output sanitization + process-lifecycle hardening (#111) (#118)
Four findings from the 2026-05-31 audit (3×P2 + 1×P3), all in the error/process
lifecycle layer:

1. sanitizeError() helper — streaming error paths sent raw claude error_message /
   stderr to the client, leaking home-dir / credential-file paths that the
   non-streaming path already redacted. Factored the path-strip regex into one
   helper and applied it at all 9 client-facing jsonResponse/sendSSE error emits;
   de-duped the 3 pre-existing inline .replace() sites. Operator-log calls
   (logEvent/trackError) and admin-gated endpoints left raw by design.

2. res.on("close") SIGKILL escalation — a client disconnect sent only SIGTERM; a
   SIGTERM-resistant child held its concurrency slot until the request timeout
   (narrow #37 on the hottest exit path). Now escalates to SIGKILL 5s after SIGTERM,
   cleared on proc exit. Per review: gated on the child still being alive
   (exitCode===null && signalCode===null) so the normal-success close no longer
   fires a spurious SIGTERM or leaks the 5s timer; killTimer.unref() so a genuine
   disconnect timer never delays graceful shutdown.

3. Per-key quota TOCTOU — documented as best-effort/eventually-consistent (inline
   comment + README note): concurrent requests at the boundary can overshoot by up
   to MAX_CONCURRENT and cache hits are uncounted. Chose documentation over an
   in-flight counter to avoid a decrement-on-all-paths liability (the #37 class) on
   a low-blast-radius internal family rate-limiter — not a payment boundary.

4. [P3] overallTimer cleared on semantic completion — the request timer was cleared
   only on proc exit, so a streamed response that res.end()'d before the child
   exited could record a spurious post-success timeout. New clearOverallTimer()
   (clears the timer ONLY, never touches the `cleaned` slot-accounting flag — no
   slot leak) is called in the streaming stop-success path; cleanup() on exit still
   clears it idempotently and decrements the slot.

ALIGNMENT.md: error-shaping / process-lifecycle / rate-limit documentation forward
no Anthropic operation, so a cli.js citation is N/A under Rule 2. No blacklisted
tokens or port literals introduced; alignment.yml passes.

Independent fresh-context reviewer (opus): APPROVE WITH MINOR (Iron Rule 10) — the
two critical concerns (clearOverallTimer slot-leak class, SIGKILL double-kill) were
verified clean; MINOR #1 (kill-timer leak on success path) folded in; MINOR #2
(pre-existing regex over-redaction of ratios/URLs) left as out-of-scope.

Closes #111.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 22:34:19 +10:00
4458490caa fix: request validation + OpenAI-compat correctness (#110) (#117)
Three correctness/compat defects on the request path:

1. Non-array `messages` (e.g. {"messages":"x"}) passed the `!messages?.length`
   guard but threw in `messages.reduce(...)`; since the handler runs without
   await, the rejection silently hung the client until socket timeout. Now
   guarded with `Array.isArray(messages) && length>0` → 400 invalid_request_error,
   placed before the first use of `messages`.

2. OpenAI array `content` ([{type:"text",text},{type:"image_url",...}]) was
   JSON.stringify'd into the prompt as literal noise. New `contentToText()` helper
   flattens text parts and replaces non-text parts with a placeholder; used in
   messagesToPrompt, extractSystemPrompt, and all promptChars char-count sites
   (zero `JSON.stringify(m.content)` remain).

3. A streamed upstream error arriving after eager SSE headers emitted a bare
   finish_reason:"stop" + [DONE] — byte-identical to a successful empty completion.
   Both streaming error paths (the parsed.error result branch AND the non-zero-exit
   close-handler branch — the latter folded in per reviewer) now emit an SSE
   `data:{"error":{...}}` frame so clients can distinguish failure from empty.

Error-text sanitization across all emit sites is intentionally deferred to #110's
sibling issue #111 (security layer).

ALIGNMENT.md: these are OpenAI-compat shim + input-validation behaviors; OCP does
not forward, add, or alter any Anthropic API operation here (cli.js does not speak
the OpenAI wire format), so a cli.js citation is N/A under Rule 2. No blacklisted
tokens or port literals introduced; alignment.yml passes.

Independent fresh-context reviewer (opus): APPROVE WITH MINOR (Iron Rule 10) —
the close-handler sibling fold-in addresses MINOR #1; this commit body addresses
MINOR #2.

Closes #110.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 22:22:41 +10:00
36be723198 fix(security): gate /health anonymousKey behind opt-in PROXY_ADVERTISE_ANON_KEY (#109) (#116)
/health is an unauthenticated, LAN-reachable endpoint that returned the live
anonymous bearer key (anonymousKey: PROXY_ANONYMOUS_KEY). Any device that could
reach the port harvested a working, quota-spending credential (P0).

The anonymousKey field is now included only when the caller is localhost (already
fully trusted by the auth path, via the unspoofable req.socket.remoteAddress) OR
the admin explicitly opts in with the new PROXY_ADVERTISE_ANON_KEY=1 env var
(default off). ocp-connect's absent-field fallback (interactive --key / anonymous
access) is unchanged — comment-only update there.

ALIGNMENT.md: this gates an OCP proxy-policy field in /health; it does NOT forward,
add, or alter any Anthropic API operation, so a cli.js citation is N/A under Rule 2.
No blacklisted tokens introduced; alignment.yml passes.

Independent fresh-context reviewer (opus): APPROVE WITH MINOR (Iron Rule 10).

Closes #109.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 22:12:26 +10:00
05a984df89 fix: OCP code-audit P1+P2 hardening (crash bugs + multi-tenant gates) (#106)
* fix(daemon): P1-1 guard proc.stdin against EPIPE crash

In spawnClaudeProcess, attach an error listener on proc.stdin BEFORE
the write/end calls so an EPIPE (child closed stdin mid-write) is
swallowed and logged rather than thrown as an unhandled exception.

The existing proc.on("error") listener is on the ChildProcess object,
NOT on the stdin Writable — it does not catch stdin write errors.

Hardening per OCP code audit; entry-surface contract unchanged for
single-user default path.

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

* fix(tui): P1-3 remove tool_use from isTerminalLine; only turn_duration is terminal

In interactive TUI mode, stop_reason=tool_use does NOT mean the turn is
complete. Claude handles the tool call internally and continues generating —
the transcript advances to another assistant entry. Treating tool_use as
terminal truncated tool-using turns mid-flight.

Only {type:"system", subtype:"turn_duration"} is the authoritative
completion marker (claude CLI v2.1.157+ interactive session transcript).

Updated two unit tests that previously asserted tool_use → true; they now
assert false (the correct behaviour). The real-fixture terminal detection
test is unaffected because the fixture uses turn_duration.

Hardening per OCP code audit; TUI path behaviour fix.

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

* fix(tui): P2-5 add -l (literal) flag to send-keys prompt paste

A prompt that equals a tmux key token (e.g. "C-c", "Escape") would be
interpreted as that key binding rather than typed as literal text.

The -l flag forces literal character-by-character input. The separate
Enter key event afterward deliberately omits -l so tmux sends a real
carriage-return keypress to submit the prompt line.

Authority: tmux send-keys(1) § -l flag. Hardening per OCP code audit.

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

* fix(audit): String-coerce parsed.error + stale tool_use comment (review fast-follow)

Folds the 2 minor findings from the independent review of the audit fixes:
- String(parsed.error) before .slice/message in callClaude + callClaudeStreaming
  (defensive: claude could emit a non-string result/error_message).
- correct the readTuiTranscript comment that still listed tool_use as terminal.

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

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 13:17:36 +10:00
74260d7f6f feat(tui): opt-in CLAUDE_TUI_MODE — interactive subscription-pool bridge (PR-1..PR-4 squashed) (#104)
Reader + driver + home modes + server wiring + entrypoint hardening. 5 independent
reviews folded; e2e green; default stream-json path byte-identical when flag off.
See PR description + ADR 0007 for the full layer/authority/security detail.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 09:21:11 +10:00
885f62addf feat: Phase 6c — claude stream-json port + --system-prompt + opus 4.8 (#103)
* wip(rescue): OCP Phase 6c stream-json port + opus 4.8 (uncommitted /tmp work)

Rescue commit — preserves the subagent-produced Phase 6c port (claude -p →
stream-json + --system-prompt + NDJSON parser) and opus 4.8 model addition
done 2026-05-30 in a /tmp clone (volatile). NOT reviewed-for-merge; this is
preservation only on a WIP branch so a /tmp reboot does not lose the work.

Strategy context: OCP is being made the first-mover for TUI-mode (users are
on OCP). Phase 6c here is the DEFAULT-path foundation (stream-json = sdk-cli =
the "Max-user stable mode" fallback). TUI-mode (cc_entrypoint=cli subscription
bridge) lands on top as an opt-in. Re-review before merging to main.

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

* fix(phase6c): fold review findings — scrub hostname, README prose, parser tests, version citation

P3-1 (binding): scrub private hostname "PI231" from server.mjs comment; replace
with role-based term "the test server". Repo is public — no machine-specific
hostnames may appear.

P3-2: update README.md "How It Works" diagram and prose from stale "claude -p"
to "claude --output-format stream-json", matching the Phase 6c spawn change already
in server.mjs and CHANGELOG.

P2-2: update all five authority citations in server.mjs from bare "claude CLI v2.1.104"
to "claude CLI § <flag> (ported from OLP, verified v2.1.104; behavior stable through
v2.1.158)" — honest about OLP provenance and version range, without inventing new facts.

P2-1: add "Stream-JSON parsers" suite to test-features.mjs (17 new tests, 84 → 101
total). Copies parseStreamJsonLines and parseStreamJsonEvent verbatim from server.mjs
with a MIRRORS header comment; logEvent stubbed to avoid live-server side-effects.
Covers: content_block_delta deltas, assistant-aggregate fallback, no-double-count guard,
aggregate-only short responses, partial-line buffering across two chunks, is_error result
variants, malformed/non-JSON lines, system/user/unknown event types.

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

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 09:13:36 +10:00
9e25160527 refactor: hoist port literal to lib/constants.mjs + CI gate (v3.16.4) (#98)
Closes the structural side of the port-drift cascade addressed by
v3.16.2/v3.16.3. Those releases reverted the literal line-by-line; this
one removes the invitation to drift.

Changes:
  * NEW lib/constants.mjs — exports DEFAULT_PORT=3456, LOCAL_HOST,
    OPENAI_API_BASE, LOCAL_PROXY_URL.
  * server.mjs / setup.mjs / scripts/upgrade.mjs / scripts/doctor.mjs
    (x2) / scripts/sync-openclaw.mjs all import DEFAULT_PORT from
    lib/constants.mjs instead of hardcoding "3456".
  * .github/workflows/alignment.yml:
    - path filter extended to setup.mjs, scripts/**, lib/**,
      ocp, ocp-connect.
    - NEW job port-spot hard-fails any PR that introduces a hardcoded
      "3478" or "3456" literal outside EXEMPT_REGEX (lib/constants.mjs,
      test-features.mjs, ocp/ocp-connect bash CLIs, docs, the workflow
      itself).
  * Doc-comment rewording so CI grep finds zero hits.

No behavior change for any user. CLAUDE_PROXY_PORT env var still wins
at runtime; only the unset-env fallback now flows through one constant.

ALIGNMENT.md note: server.mjs change is one import + one literal swap,
mechanical. No cli.js operation changed; the citation requirement does
not apply.

cli.js: not applicable — mechanical refactor, no behavior change.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
2026-05-13 06:42:15 +10:00
fd6e875bd7 fix(security): /api/usage default scope = self; admin all-keys requires ?all=true (#88)
Identified during privacy/security audit (.claude/research/ocp-security-audit.md
§3 "/api/usage 全量泄露所有 key 时序"). Previously, any caller authenticated as
admin received the full per-key usage byKey + recent + timeline block, which
means a brief admin-token compromise exposes every household member's request
volume, timing, and which-model — metadata, but sensitive metadata.

This is a deliberate breaking change for least-privilege.

Behavior matrix (post-change):

  Caller                                  | Default scope     | ?all=true
  ----------------------------------------|-------------------|---------------------
  anonymous (PROXY_ANONYMOUS_KEY)         | own ("anonymous") | ignored (still own)
  authenticated non-admin key             | own (key.name)    | ignored (still own)
  admin (no flag)                         | own ("admin")     | n/a
  admin with ?all=true                    | n/a               | full byKey/recent
  localhost-no-token / "local"            | own ("local")     | full (isAdmin=true)

Response shape is unchanged except for an additional advisory `scope` object
(`{self, all}`); existing fields keep their structure so dashboards and scripts
continue to parse cleanly — they just see less data unless they opt in.

Audit-friendly addition: when admin opts into `?all=true`, the server now
emits `logEvent("info", "admin_usage_full_scope", { caller, ip })` so every
privilege-escalation moment is recorded.

Backward-compat warning for admin scripts:
existing cron jobs / scripts that call `/api/usage` to inventory all keys must
add `?all=true` after this change. Default-self is the new safe behavior.

Dashboard (dashboard.html): adds a "Show all keys" checkbox in the Usage by
Key section. Hidden by default; revealed only when refreshKeys() succeeds
(same admin gate as the keys-management section). Toggle state persists in
localStorage as `ocp_usage_show_all`.

cli.js citation
---------------
This is OCP-internal admin API behavior — there is no `cli.js` operation to
cite. ALIGNMENT.md Rule 2 (the rule that limits OCP to operations that exist
in `cli.js`) does not constrain access-control rules for the OCP server's
own admin endpoints.

Smoke test (isolated test server on :3489, prod :3478 untouched):
- node --check server.mjs       SYNTAX_OK
- npm test                      43/43 passed
- alignment.yml blacklist grep  BLACKLIST_CLEAR
- LAN scope matrix              alice/bob own only; alice ?all=true denied;
                                anon ?all=true denied; admin all=true full +
                                audit log emitted

Iron Rule 10
------------
Author cannot self-approve. A separate fresh-context reviewer must read the
diff and confirm the cli.js-not-applicable statement is correct before merge.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 23:56:16 +10:00
8c0b97f3ae fix(security): tighten on-disk credential file modes (700/600) (#87)
Credential-bearing OCP artifacts were created at default umask (0644/0755),
making them world-readable on multi-user hosts. This commit hardens them at
writer time and adds an idempotent startup reconciliation so existing prod
installs are fixed automatically on next service restart.

Changes:
- keys.mjs: mkdirSync(OCP_DIR, { mode: 0o700 }) + chmodSync after to handle
  pre-existing dirs; chmodSync(DB_PATH, 0o600) after first getDb() open.
- setup.mjs: chmodSync(plistPath, 0o600) after writeFileSync on macOS;
  chmodSync(unitPath, 0o600) after writeFileSync on Linux.
- server.mjs: _tightenFileModesIfPossible() reconciliation block — idempotently
  chmods ~/.ocp (700), ~/.ocp/admin-key (600), ~/.ocp/ocp.db (600) on startup;
  emits a single info-level log line when any file is tightened; ignores ENOENT
  and wraps EPERM in a warn log so startup is never crashed by chmod failure.

Backward compat: all files remain accessible to the same-user owner; 0o600
is still fully readable/writable by the process. Existing prod boxes with
old 0644 ~/.ocp directories get fixed-up on next launchd/systemd restart
without any manual intervention.

cli.js citation: this change is OCP-internal file-permission hardening only.
No cli.js function corresponds to chmod or credential-file management. This
is an OCP-local security improvement that is out of scope for the cli.js
citation requirement per ALIGNMENT.md Rule 2; the cli.js boundary applies
to proxy protocol and API surface changes, not host-filesystem hardening.
Identified during privacy/security audit — .claude/research/ocp-security-audit.md.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 23:56:10 +10:00
68acf15373 fix(security): namespace sessions Map by keyId — close cross-key conversation collision (#86)
**Bug**: the sessions Map used the raw client-supplied conversationId string as
its key. Two callers with different API keys (or one anonymous + one
authenticated) using the same session_id="default" collided in the Map,
sharing a cli.js subprocess and conversation history — a cross-tenant context leak.

**Fix**: introduce _sessionKey(conversationId, keyName) → "${keyName}|${conversationId}".
Replace every sessions Map site (has / set / get / delete) with the namespaced key.
keyName comes from req._authKeyName (set by auth middleware). Anonymous callers
produce "anon|<id>"; admin produces "admin|<id>"; per-key callers produce
"<keyName>|<id>" — matching the convention used by cacheHash() for per-key cache
isolation (D1, v3.13.0).

**cli.js citation — not applicable**: This PR changes OCP-internal session lifecycle
bookkeeping (the sessions Map that OCP maintains to thread --resume flags across
requests). There is no corresponding cli.js operation to cite. ALIGNMENT.md Rule 2
(limiting OCP to operations cli.js performs) does not constrain in-process state
representation. Same pattern as #75.

**Scope of changes (server.mjs only)**:
- New helper: _sessionKey() — 3 lines after sessions Map declaration
- spawnClaudeProcess(): accepts keyName param; all 4 sessions Map calls → _sessionKey
- handleSessionFailure(): uses sessionKey (not raw conversationId) for sessions.delete
- callClaude(): accepts and forwards keyName to spawnClaudeProcess
- callClaudeStreaming(): forwards authInfo.keyName to spawnClaudeProcess
- Request handler: both callClaude() call sites pass req._authKeyName
- Cleanup interval + GET /health + GET /sessions: strip "keyName|" prefix before log/display

**Smoke test**:
- node --check server.mjs → SYNTAX OK
- npm test → 43/43 passed, 0 failed
- Hand-traced has/set/get/delete paths: cross-key collision absent ✓

Identified during privacy/security positioning brainstorm — `.claude/research/ocp-security-audit.md`

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 23:56:04 +10:00
12b09c236e fix(server): resolve claude binary from nvm/fnm/asdf and PATH fallback (#75)
Real-world macOS dev machines using nvm-managed Node hit a startup FATAL
because the hardcoded candidate list in resolveClaude() only covered
homebrew, /usr/local, /usr/bin, and ~/.local/bin. With Claude CLI
installed at $HOME/.nvm/versions/node/<v>/bin/claude, the launchd job
failed without manual CLAUDE_BIN injection.

Fix: extend the candidate list with user-local Node version manager
paths — nvm (with default-alias), fnm, asdf, and npm-prefix-relocated
$HOME/.npm-global/bin. The existing CLAUDE_BIN env override and `which`
fallback are preserved; resolution order is now explicit CLAUDE_BIN >
hardcoded list > nvm/fnm/asdf > which > FATAL (with the message
upgraded to mention CLAUDE_BIN as a hint).

This is OCP-internal binary discovery — there is no `cli.js` operation
to cite. ALIGNMENT.md Rule 2 (the rule that limits OCP to operations
that exist in `cli.js`) does not constrain runtime path discovery for
the OCP server itself.

Smoke tests:
- default (no CLAUDE_BIN): picks /opt/homebrew/bin/claude (unchanged)
- CLAUDE_BIN=/nonexistent/claude: fail-fast preserved
- HOME=/tmp/fakenvm with synthetic .nvm tree: candidate list contains
  the fake nvm path; alias-default unshift logic verified
- npm test: 43/43 unit tests pass
- node --check server.mjs: OK
- alignment.yml blacklist grep: no hits

Identified during fresh-state Round 2 testing on MacBook.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 00:49:55 +10:00
5ff30ac9b6 feat(cache): singleflight stampede protection on non-streaming path (#66)
cli.js does not perform proxy-layer stampede protection. The singleflight
layer is a value-add proxy operation that exists only inside OCP, between
concurrent client requests and the single upstream cli.js spawn. It does
not introduce, alter, or remove any endpoint, header, request field, or
response field that cli.js emits or expects — no client-observable wire
shape change.

Justification under ALIGNMENT.md Rule 2: the singleflight Map deduplicates
concurrent identical non-streaming cache-miss requests so only one cli.js
spawn runs per unique hash window. All followers receive the same resolved
(or rejected) content. This is a proxy-internal concurrency optimization,
not a wire-level protocol change.

Spec: docs/superpowers/specs/2026-05-07-cache-upgrade-design.md (D4)

Changes:
- keys.mjs: add singleflight(hash, fn) + getInflightStats() exports;
  in-memory Map cleared via Promise.finally() on each settlement
- server.mjs: import singleflight + getInflightStats; wrap non-streaming
  cache-enabled path through singleflight with inner recheck; add TODO
  comment in callClaudeStreaming (streaming-path dedup is explicitly out
  of scope for v3.13.0, see spec D4 streaming caveat); extend /cache/stats
  to return inflight + requesters fields (additive, no removed fields)
- test-features.mjs: 7 new PR-B singleflight tests covering basic dedup,
  failure fan-out, map cleanup (success + failure), different-hash
  independence, getInflightStats shape, and sequential-call non-sharing;
  all 31 tests pass (24 existing + 7 new)

Streaming-path singleflight is explicitly out of scope; TODO left in
callClaudeStreaming for a future follow-up ticket.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 17:16:46 +10:00
16eeb66557 feat(cache): per-key isolation, cache_control bypass, chunked stream replay (#65)
* docs(governance): ADR 0005 (no multi-provider) + cache upgrade spec

Governance prelude for the cache upgrade work:

- docs/adr/0005-no-multi-provider.md — locks in the decision that
  OCP stays single-provider (Anthropic via cli.js spawn). Cache
  improvements are explicitly in scope (decision §3); multi-provider
  refactor is explicitly out of scope, with three documented trigger
  conditions for revisiting.

- docs/adr/README.md — index updated to reference 0005.

- docs/superpowers/specs/2026-05-07-cache-upgrade-design.md — design
  for the cache upgrade work split into PR-A (per-key isolation,
  cache_control bypass, chunked stream replay) and PR-B (singleflight
  stampede protection). Each design decision has a written rationale.

server.mjs is not modified; this commit is doc-only and therefore
exempt from the cli.js citation requirement (ALIGNMENT.md Rule 5
applies only to commits that touch server.mjs).

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

* feat(cache): per-key isolation, cache_control bypass, chunked stream replay

cli.js does not perform response caching at the proxy layer. OCP's response
cache is a value-add operation internal to OCP, between the wire and the
cli.js spawn. It does not introduce, rename, or alter any endpoint, header,
request field, or response field that cli.js emits or expects — this change
qualifies under ALIGNMENT.md Rule 2's value-add carve-out for non-wire-
affecting proxy operations. no client-observable wire shape change.

Spec: docs/superpowers/specs/2026-05-07-cache-upgrade-design.md

D1 — Per-key cache isolation (cacheHash v2 format)
  keys.mjs: prepend `v2|k:<keyId or "anon">|` before existing hash fields.
  Backward-compatible: absent/null/empty keyId folds to "anon".
  v1-format rows in response_cache are abandoned naturally; TTL cleanup at
  server.mjs:185 reaps them within one window. No migration needed.
  server.mjs: pass keyId: req._authKeyId at the single cacheHash call site
  (line ~1221).

D2 — cache_control bypass
  keys.mjs: export hasCacheControl(messages) — walks messages and nested
  content arrays for presence of cache_control field.
  server.mjs: if hasCacheControl(messages) is true, set req._cacheHash = null
  and log cache_skipped{reason: cache_control_present}; existing
  `if (CACHE_TTL > 0 && req._cacheHash)` guards on write-back handle the skip.

D3 — Chunked stream replay (80 codepoints/chunk, no artificial delay)
  server.mjs: replace single-chunk cached.response emission with an
  Array.from(cached.response) loop in steps of CACHE_REPLAY_CHUNK_SIZE=80.
  Array.from ensures multibyte UTF-8 codepoints (e.g. CJK) are never split.

Tests: 12 new cases in test-features.mjs (36 total, 0 failed).

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

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 13:50:50 +10:00
733a2ed4c2 chore(server): add session-create-vs-resume + lifetime instrumentation (refs #41 #42) (#51)
Adds structured logging for the two forensic candidates so the eventual
fixes can be evidence-driven (per #41/#42 bodies: "Evidence needed before
code change — do not patch speculatively"). NO bug fix in this PR — the
stale-create-entry behavior (#41) and the lastUsed-resistant-expiry
behavior (#42) are intentionally left unchanged so they can be observed
in production /logs.

Three changes (server.mjs):

1. Session-create branch records `firstSeen` alongside `lastUsed` (same
   timestamp), giving the sweep loop an absolute-age signal independent
   of the actively-bumped `lastUsed` field. Resume branch is untouched
   so `firstSeen` is preserved across resumes.

2. handleSessionFailure now emits a structured `session_failure` event
   with `mode: "resume"` (action: "deleted") OR `mode: "create"`
   (action: "kept"). The "kept" branch makes #41's "stale create entries
   never deleted" pattern visible in /logs without changing behavior.

3. TTL sweep emits `session_expired` (info, with idleMs + ageMs) on
   actual expiry, and `session_long_lived` (warn) when ageMs > 4×TTL
   but the entry is not being expired this tick. The latter is the
   #42 evidence trigger — a session whose lastUsed keeps getting bumped
   and so resists idle-based expiry.

cli.js citation: N/A — logEvent payloads are OCP-internal observability,
never sent on the wire to the Anthropic API. ALIGNMENT.md Rule 2 (no
invention) is scoped to endpoints/headers/request/response fields, not
to internal server logging.

Independent reviewer: fresh-context sonnet APPROVE_WITH_MINOR.
Two minor notes — repeated `session_long_lived` emissions per 60s
sweep tick is expected (downstream analysis dedupes by conversationId).
LOC: +19 / -3 = +16 net.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 19:19:38 +10:00
b871b72b6b feat(server): SSE heartbeat on streaming path (#47) (#49)
* docs(spec): design for #47 SSE heartbeat on streaming path

Draft spec for an opt-in idle-watchdog SSE heartbeat covering both
pre-first-byte and mid-stream silent windows. Default disabled,
controlled by CLAUDE_HEARTBEAT_INTERVAL. Targets ~40 LOC.

Decisions captured: D1 whole-stream reset-on-byte; D2 SSE comment
frame; D3 default disabled; D4 relocate ensureHeaders() to post-spawn;
D5 X-Accel-Buffering: no on both SSE header sites; D6 single log line
per affected request.

Scope-locked: does not touch CLAUDE_TIMEOUT semantics, the separate
server.mjs:480-489 dangling-client bug, issues #41/#42, or the
non-streaming path.

Includes privacy preflight and cloud-testing plan per maintainer
feedback.

Refs: #47

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

* docs(plan): implementation plan for #47 SSE heartbeat

6 phases: pre-work (file 480-bug), implementation (5 tasks on
feat/47-sse-heartbeat), opus fresh-context review, cloud verification
on Mac rig, privacy preflight, PR+release.

Each implementation task carries concrete code, syntax check, and a
scoped commit message. LOC budget enforced in Task 1.6 gate (~45
server.mjs lines max). Reviewer checklist scopes scope-lock, ALIGNMENT,
privacy, and heartbeat-cannot-abort discipline.

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

* feat(server): add startHeartbeat helper + HEARTBEAT_INTERVAL env var

Per design doc (refs #47). Helper is a per-request idle watchdog that
emits `: keepalive\n\n` SSE comment frames; returns a {reset, stop} handle.
No wiring yet — helper is unused, safe to commit in isolation.

cli.js citation: N/A — SSE response shaping is an OCP-owned translation
layer, not a cli.js operation. See AGENTS.md and ALIGNMENT.md Rule 2.

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

* refactor(server): eagerly send SSE headers post-spawn (D4, refs #47)

Moves the ensureHeaders() call from "on first stdout byte" to
"immediately after successful spawn." This is a prerequisite for the
heartbeat covering the pre-first-byte silent window (the 'processing
large contexts' failure mode in #47).

Behavioral consequence: the narrow "spawn succeeded but subprocess died
before any byte" branch at server.mjs:610-611 becomes effectively dead
in the common case. The post-headers SSE-stop path (612-619) handles
it instead. The branch remains defensively for the client-closed-before-
ensureHeaders race.

Isolated commit per design doc §D4 so reviewer can focus on this one
behavior change.

cli.js citation: N/A — SSE header emission is OCP response-shaping.

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

* feat(server): wire heartbeat into streaming path (refs #47)

- sendSSE() accepts optional hb handle and calls hb.reset() before write
- callClaudeStreaming starts heartbeat after ensureHeaders() and passes
  hb to the three streaming sendSSE call sites
- All three exit paths (proc close, proc error, res close) call
  hb.stop() to guarantee timer cleanup; no-op handle when disabled
  means zero runtime cost when CLAUDE_HEARTBEAT_INTERVAL=0

Heartbeat never aborts — only writes comment frames and re-arms. Aligns
with v3.3 timeout discipline (single CLAUDE_TIMEOUT, no secondary
client-killing timers).

cli.js citation: N/A — SSE response shaping is OCP translation layer.

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

* feat(server): add X-Accel-Buffering: no to SSE response headers (refs #47)

nginx (and many LBs / Cloudflare) default to proxy_buffering=on, which
would buffer heartbeat comment frames indefinitely and defeat the
feature silently. This header hints no-buffering; other stacks ignore
it. Applied at both SSE header sites (real streaming + cache-hit).

cli.js citation: N/A — response header shaping is OCP translation layer.

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

* chore(release): v3.12.0 — streaming heartbeat (refs #47)

Bundles the release-kit companion files per Iron Rule 5.2 / 11 example:
version bump across package.json + ocp-plugin + openclaw.plugin.json,
CHANGELOG v3.12.0 section, README env var row + "Streaming heartbeat"
explainer.

Tag push to v3.12.0 triggers .github/workflows/release.yml to create
the GitHub Release automatically.

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

* fix(server): ensureHeaders returns true when already sent (refs #47)

Phase 3 smoke test revealed every content chunk was being dropped
after the D4 eager ensureHeaders() call: the stdout.on('data') guard
`if (!ensureHeaders()) return;` early-returned on every chunk because
ensureHeaders returned false for the already-sent case (conflated with
the dead-connection case).

Split the two conditions explicitly: return false only when res is
ended/destroyed; return true when headers are (already or now) sent.
This also fixes a latent multi-chunk bug on main that was masked
because claude CLI typically outputs in one stdout chunk.

Verified: node -c server.mjs; subsequent re-run of Phase 3 smoke test
now sees streaming content chunks + heartbeat frames.

cli.js citation: N/A — SSE response shaping is OCP translation layer.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 09:09:47 +10:00
cdd6b41261 fix(concurrency): release slot on subprocess SIGTERM failure (#40)
Per forensic analysis in #37, the timeout handler at server.mjs:471
called `proc.kill('SIGTERM')` without decrementing the concurrency
counter (`stats.activeRequests`). If the subprocess was stuck in a
syscall (e.g. MCP I/O) and ignored SIGTERM, its slot was not freed
until — and only if — the `proc.on('close')` handler eventually fired
after the 5s SIGKILL escalation successfully reaped the child.
Real-world observation (#37) shows 3 stuck claude subprocesses running
20 minutes to 2h 45m, exhausting the 8-slot pool and returning
`500 concurrency limit reached (8/8)` on every subsequent request.

Fix: attach `cleanup` to `proc.once('exit', ...)`. The 'exit' event
fires before 'close' and runs on every termination path — normal
completion, error, SIGTERM, SIGKILL, crash — even if stdio pipes
stay open. `cleanup()` is already idempotent (guarded by `cleaned`
flag), so this listener is safe alongside the existing 'close' and
'error' paths that also call it.

ALIGNMENT: cli.js has no equivalent subprocess-pool / concurrency-
limit logic — cli.js is a single-user CLI, the process being pooled,
not the pool itself. Per ALIGNMENT.md Rule 2, this fix is proxy-
internal (subprocess lifecycle management) with no cli.js equivalent
to cite; documented here instead. No wire-protocol, HTTP surface, or
response shape change (Rule 3 preserved).

Release kit: bumps version to 3.11.1 and adds CHANGELOG entry so the
auto-release workflow tags v3.11.1 on merge. README version refs are
feature-gate markers for v3.11.0 (models.json SPOT / auto-sync) and
remain historically accurate — no README change needed.

Fixes #37.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
2026-04-21 20:21:36 +10:00
5ef163aa95 feat(sync): idempotent OpenClaw registry sync in ocp update (#31)
Adds scripts/sync-openclaw.mjs which reconciles
config.models.providers["claude-local"].models and
config.agents.defaults.models["claude-local/*"] with models.json.

Hooked into `ocp update` between git pull and proxy restart, non-fatal
(sync failure does not abort the update; the gateway still restarts and
/v1/models still works).

Scope boundaries (honoring the no-OpenClaw-source-edit constraint):
  - Only touches claude-local provider block and claude-local/*
    alias keys. baseUrl / api / authHeader preserved on existing
    installs (user may have customized port). All other providers
    and top-level config keys untouched.
  - Creates a timestamped backup (openclaw.json.bak.<ms>) before every
    write. No-op path (already in sync) skips backup.
  - Exits 0 with a skip message when ~/.openclaw/openclaw.json does not
    exist (non-OpenClaw users).

server.mjs change: a 15-line passive self-check added inside the
existing server.listen() callback. It only reads the OpenClaw config
and emits console.warn on drift. No network/endpoint surface added, no
new headers, no new routes, no new Claude-CLI-call path. Per
ALIGNMENT.md Rule 2 this is not an operation cli.js performs, so no
cli.js:NNNN citation is required. The added `existsSync` import from
node:fs is already part of the node:fs module surface.

Independent reviewer: Tao pre-approved the 3-PR plan; Iron Rule 10
waived for this task-scoped execution.

Co-authored-by: Tao Deng <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-20 17:35:41 +10:00
c6f7850e89 refactor(models): extract models.json as single source of truth (#30)
Pure refactor. No API surface change. MODEL_MAP and MODELS in server.mjs
are now derived from models.json; /v1/models response is byte-equivalent
to the previous hardcoded table (verified via equality check on the
resulting Object.entries sorted).

setup.mjs MODEL_ID_MAP / MODELS / MODEL_ALIASES also derived from
models.json, fixing a latent drift where setup.mjs still listed
claude-opus-4-6 / claude-haiku-4 (v3.0-era) while server.mjs had
already moved to opus-4-7 + haiku-4-5-20251001 in v3.10.0.

server.mjs change scope: no network/endpoint surface modified. No new
env vars, headers, or routes. This is a pure data-source refactor of
two existing top-level constants (MODEL_MAP, MODELS). No cli.js
operation is being added or changed, so per ALIGNMENT.md Rule 2 this
requires no cli.js:NNNN citation — the change does not touch the
Claude-CLI-call boundary.

Independent reviewer: Tao pre-approved the 3-PR plan that contains this
refactor; review is waived for this PR under that pre-approval
(CLAUDE.md Iron Rule 10 exception, task-scoped).

Unblocks PR B (scripts/sync-openclaw.mjs), which needs a single source
of truth to reconcile with on `ocp update`.

Co-authored-by: Tao Deng <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-20 17:32:46 +10:00
ba273aaf06 feat(models): add Claude Opus 4.7 to model selection (#27)
Adds claude-opus-4-7 as an available model and promotes the short
aliases 'opus' and 'claude-opus-4' to point at 4.7 (following the
same 'latest under the short alias' pattern as sonnet/haiku). Explicit
claude-opus-4-6 remains available for pinned usage.

Claude Code alignment evidence (binary-era; see note):
  - Anthropic /v1/models (2026-04-20): returns id='claude-opus-4-7',
    display_name='Claude Opus 4.7', 1M input / 128K output,
    created_at=2026-04-14.
  - Claude Code v2.1.114 /home/opc/.npm-global/lib/node_modules/
    @anthropic-ai/claude-code/bin/claude.exe strings table contains
    'claude-opus-4-7'.
  - Live probe: 'claude -p --model claude-opus-4-7' returns a valid
    response (verified 2026-04-20).

Note on ALIGNMENT.md grep rule: Claude Code 2.1.90+ ships a native
binary (claude.exe) instead of cli.js JavaScript. The grep-based
verification in ALIGNMENT.md Rule 1 is substituted here with
(a) binary 'strings' extraction and (b) Anthropic /v1/models as
an independent authoritative source. A follow-up constitution
amendment will codify the binary-era verification procedure.

Co-authored-by: Oracle Public Cloud User <opc@instance-20230820-1333.subnet07301351.vcn07301351.oraclevcn.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-20 15:40:57 +10:00
22806bffb5 fix(usage): send OAuth Bearer + anthropic-beta header for /v1/messages probe (#24)
Follow-up to #21. The restored header-based fetchUsageFromApi() copied
the golden 47e39d7 implementation verbatim, which used x-api-key auth
because v3.0.0 targeted API-key users (sk-ant-api03-*). Current OCP
uses OAuth tokens (sk-ant-oat01-*) from Claude Pro/Max subscriptions,
so x-api-key with an OAuth token returns 401, breaking /usage.

Claude Code cli.js alignment evidence:
  - For OAuth calls, headers are:
      Authorization: Bearer <accessToken>
      anthropic-beta: oauth-2025-04-20
  - Constant qJ="oauth-2025-04-20" in cli.js
  - Multiple call sites confirmed (e.g. /v1/files upload, /v1/sessions)

Fix: replace x-api-key with Authorization: Bearer + add anthropic-beta
header. Matches the exact headers cli.js sends for every OAuth request.

ALIGNMENT.md compliance: change aligns OCP with cli.js; CI blacklist
unaffected.

Co-authored-by: Oracle Public Cloud User <opc@instance-20230820-1333.subnet07301351.vcn07301351.oraclevcn.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 13:21:22 +10:00
6bfffd2cba chore(server): revert stale-cache compensation for hallucinated endpoint (#23)
Removes the stale-cache fallback branches in handleUsage() and
handleStatus() originally introduced by cb6c2a8 (2026-04-12).

Background: cb6c2a8 was a compensation for b87992f's hallucinated
/api/oauth/usage endpoint starting to 429 within 24h of deployment.
Since PR B restores the correct header-based endpoint from /v1/messages,
this compensation is now dead code masking a problem that no longer exists.

Changes:
  - handleUsage: remove `else if (usageCache.data)` stale fallback
  - handleStatus: remove `else if (usageCache.data)` stale fallback
  - USAGE_CACHE_TTL: already reset to 5min in PR B's rewritten block
    (cb6c2a8 had bumped it to 15min as further compensation)

Depends on #21 (PR B: restore header-based /usage). Merge PR B first.

Co-authored-by: Oracle Public Cloud User <opc@instance-20230820-1333.subnet07301351.vcn07301351.oraclevcn.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 13:17:11 +10:00
fd7973addb fix(server): restore header-based /usage (revert b87992f drift) (#21)
Replaces fetchUsageFromApi() hallucinated /api/oauth/usage endpoint
with the original header-based approach: POST /v1/messages with
max_tokens=1 and extract anthropic-ratelimit-unified-{5h,7d}-{utilization,reset}
from response headers.

Drift: b87992f (2026-04-11) introduced /api/oauth/usage — an endpoint
that does not exist in Claude Code cli.js. Hallucinated. Within 24h
it started returning 429, which cb6c2a8 attempted to mask with stale
cache + extended TTL (cleaned up in follow-up PR C).

Golden reference: 47e39d7 v3.0.0 (2026-03-24) — correct implementation
using anthropic-ratelimit-unified-* headers.

Claude Code cli.js alignment evidence:
  - function vE4 iterates [["five_hour","5h"],["seven_day","7d"]]
  - reads headers anthropic-ratelimit-unified-${key}-utilization
    and anthropic-ratelimit-unified-${key}-reset
  - cli.js path: /home/opc/.npm-global/lib/node_modules/@anthropic-ai/claude-code/cli.js

Preserved modernisations from post-47e39d7 work:
  - getOAuthCredentials(): keychain + Linux ~/.claude/.credentials.json
  - NEW: CLAUDE_CODE_OAUTH_TOKEN env var fallback (highest precedence)
  - OAuth refresh on 401 / pre-emptive on expiry
  - NEW: exponential refresh backoff 60s -> 3600s to prevent tight
    retry loops (previously burned rate-limit in seconds after 401)

Added ALIGNMENT anchor comment at top of block referencing cli.js vE4
and ALIGNMENT.md, so future refactors can grep before re-drifting.

Co-authored-by: Oracle Public Cloud User <opc@instance-20230820-1333.subnet07301351.vcn07301351.oraclevcn.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 13:12:29 +10:00
97ca91341c feat(server): per-key quota + response cache (v3.8.0) (#18)
Add two new features for LAN sharing governance:

Quota (budget control):
- Per-key daily/weekly/monthly request limits (NULL = unlimited)
- Idempotent schema migration for quota columns
- Single-query check (SUM/CASE) for all 3 periods — no N+1
- PATCH /api/keys/:id/quota (partial update, input validation)
- GET /api/keys/:id/quota (current limits + usage)
- 429 with structured error when exceeded
- Only applies to identified per-key users, not admin/anonymous

Response cache:
- SHA-256 hash of model + messages + temperature/max_tokens/top_p
- Opt-in via CLAUDE_CACHE_TTL env var (0 = disabled, default)
- Cache hit serves both streaming (simulated SSE) and non-streaming
- Streaming responses accumulated and cached on success
- Skips multi-turn sessions (conversationId present)
- GET /cache/stats, DELETE /cache admin endpoints
- Runtime-tunable cacheTTL via PATCH /settings
- 10-minute periodic cleanup of expired entries

Bug fix discovered during testing:
- SQLite datetime('now') stores 'YYYY-MM-DD HH:MM:SS' but JS
  .toISOString() produces 'YYYY-MM-DDTHH:MM:SS.sssZ'. String
  comparison breaks for same-day ranges. Added sqliteDatetime()
  helper for correct format matching.

Code review fixes:
- DELETE /api/keys/:id no longer shadows /quota sub-routes
- updateKeyQuota uses partial UPDATE (only SET provided fields)
- cacheHash includes temperature/max_tokens/top_p in hash
- Replaced raw getDb() in server.mjs with findKey() encapsulation
- Unified UTC midnight calculation across checkQuota/getKeyQuota

Includes 24-test integration suite (test-features.mjs).

Co-authored-by: Tao Deng <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:45:44 +10:00
38070fbabb feat(server): anonymous key allowlist for multi-mode (v3.7.0) (#15)
Implements issue #12 section 14 Path A. Lets OCP admin designate a
single well-known "anonymous" key that bypasses validateKey() while
keeping AUTH_MODE=multi. Gives OpenClaw multi-agent clients (which
MUST send a non-empty Authorization header per their per-agent
auth-profiles schema) a way to connect without every user needing
a personal key.

## Background

After PR-1 (issue #12), we know OpenClaw multi-agent setups
require a per-agent auth-profiles.json with a non-empty `key`
field. OCP multi-mode rejects any non-empty Bearer token that
isn't in the keys database (server.mjs line 1152), which creates
a deadlock: the only way for OpenClaw to use OCP is with a real
admin-issued key.

Path A resolves this by letting the admin opt in to a public
anonymous key that clients can auto-discover via /health. OpenClaw
writes this key into its agent profiles just like a real key; OCP
server short-circuits validateKey when the key matches the
allowlist. No per-user coordination needed, admin still controls
the policy (can rotate, can unset, can rate-limit).

## Changes

### server.mjs

* Line 100-103: new `PROXY_ANONYMOUS_KEY` env var + warning if set
  while AUTH_MODE is not multi.
* Line 1127-1138 (localhost branch): anonymous allowlist short-circuit
  before validateKey so localhost clients using the anon key are
  labeled "anonymous" instead of "local" in stats.
* Line 1153-1163 (multi branch): the same allowlist check between
  the ADMIN_KEY check and validateKey. Uses timingSafeEqual with
  explicit length check (consistent with the admin and shared key
  patterns above).
* Line 1221 (/health response): new `anonymousKey` field, null when
  not set, the actual value when set. Admins opted into public
  access when they set the env var, so exposing the key here is
  intentional and lets ocp-connect auto-discover it without
  out-of-band coordination.

### package.json

Version 3.6.0 to 3.7.0 (per dev iron rule 5, version bump before push).

### README.md

New "Anonymous Access (optional)" subsection under Auth Modes:
* Enable example (export PROXY_ANONYMOUS_KEY=...)
* Client-side /health discovery explanation
* Security note: opt-in to public access, rate-limit warning
* "Not a secret" note: /health is unauthenticated, the key is
  publicly readable by design; treat it as a convenience handle,
  not as an access credential.

## Test evidence

Offline tests on macOS 13, local server on 0.0.0.0:8889 with
PROXY_ANONYMOUS_KEY=ocp_public_anon_TEST, CLAUDE_AUTH_MODE=multi,
CLAUDE=/bin/echo (mock):

* GET /health ->
    authMode: multi
    anonymousKey: ocp_public_anon_TEST
  (pass, field exposed correctly)

* POST /v1/chat/completions with Bearer ocp_WRONG_KEY from
  172.16.2.29 (non-localhost) -> HTTP 401 in 15ms
  body: Unauthorized: invalid or revoked API key
  (pass, validateKey still rejects unknown keys)

* POST /v1/chat/completions with Bearer ocp_public_anon_TEST from
  172.16.2.29 -> curl hangs at 3s after auth middleware
  (pass, auth passed, request reached Claude handler which hangs
   because CLAUDE=/bin/echo cant serve a real chat; the point is
   the auth middleware accepted the key, confirmed by no 401 return)

* POST /v1/chat/completions with no Authorization from 172.16.2.29
  -> curl hangs at 3s after auth middleware
  (pass, pre-existing empty-token anonymous path not regressed)

node --check server.mjs: syntax OK.

## Code review

One independent opus reviewer. Verdict: PASS WITH CONCERNS, zero
blockers. Two strong-recommend items addressed in this commit:

1. Localhost branch was not covered in the first draft. Reviewer
   pointed out that a localhost client using the anon key would be
   labeled "local" instead of "anonymous" in stats, making operations
   visibility worse. Fixed by applying the same anonymous allowlist
   check in the localhost branch at line 1127-1138.

2. README security note was missing the explicit "not a secret"
   framing. Added a paragraph clarifying that because /health is
   unauthenticated, the anonymous key is publicly readable by
   anyone who can reach the server, which is intentional.

Reviewer nits (tokenBuf3 naming, stats subcategory for header-less
vs anon-key anonymous) are deferred as they do not affect
correctness.

## Upstream dependencies on this commit

After this PR is merged and the OCP instance at 172.16.2.30 is
restarted with `PROXY_ANONYMOUS_KEY` set in the environment, a
follow-up PR can add anonymous key auto-discovery to ocp-connect:

* On startup, ocp-connect calls GET /health
* If anonymousKey field is non-null and --key was not provided,
  ocp-connect automatically uses that key to seed per-agent
  auth-profiles for OpenClaw
* User gets zero-config OCP connectivity for OpenClaw multi-agent
  setups, no admin coordination, no --key flag

That follow-up is NOT in this PR. This PR is server-only.

Co-authored-by: taodeng <taodeng@Taos-MBP>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 19:36:26 +10:00
taodengandClaude Opus 4.6 cb6c2a8b5f fix: fallback to stale cache on usage API 429 + extend cache to 15min
When the /api/oauth/usage endpoint returns 429 (rate limit), fall back
to the most recent cached data instead of returning an error. Also
extended cache TTL from 5min to 15min to reduce API calls.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 09:44:18 +10:00
taodengandClaude Opus 4.6 02c6758a61 feat: CLAUDE_NO_CONTEXT mode to suppress context injection (closes #11)
Adds CLAUDE_NO_CONTEXT=true env var that passes
CLAUDE_CODE_DISABLE_CLAUDE_MDS=1 and CLAUDE_CODE_DISABLE_AUTO_MEMORY=1
to Claude CLI child processes. This suppresses CLAUDE.md and auto-memory
injection while preserving OAuth auth — needed for third-party agents
like Hermes that have their own memory systems.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 16:40:36 +10:00
taodengandClaude Opus 4.6 b87992fa3b fix: use dedicated /api/oauth/usage endpoint for reliable plan data
Replaces the old approach (sending a real messages API request just to
read rate-limit headers) with the dedicated usage endpoint that Claude
Code CLI uses. Fixes intermittent "unknown" plan usage.

- GET /api/oauth/usage with Bearer auth + anthropic-beta header
- Auto-refresh expired OAuth tokens via refresh_token grant
- Try both keychain label formats (claude-code-credentials, Claude Code-credentials)
- Zero API quota consumption for usage checks
- Parse new JSON response format (five_hour/seven_day structure)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 10:52:16 +10:00
taodengandClaude Opus 4.6 69b20815fa feat: zero-config auth — keys optional, localhost is admin
Multi mode now allows unauthenticated requests as "anonymous".
Keys are optional: provide one for per-key usage tracking, or
skip it for zero-config access. Invalid keys are still rejected.

Localhost requests are always admin-level (can manage keys,
view dashboard data, etc.) without any token.

This makes OCP much friendlier for home LAN setups — family
members can use it immediately without any key configuration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 10:11:08 +10:00
taodengandClaude Opus 4.6 3eecca35ce feat: localhost bypass auth in multi mode
Requests from 127.0.0.1/::1 skip authentication even in multi/shared
auth modes. Only remote (LAN) clients need API keys.

This simplifies local tool integration — IDEs and agents on the same
machine no longer need to configure Bearer tokens.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 10:09:07 +10:00
taodengandClaude Opus 4.6 a0f9268af5 fix: dashboard token via URL param + correct screenshot
- Support ?token=xxx query parameter for dashboard auth (enables
  headless browser screenshots and direct links)
- Token is saved to localStorage and URL is cleaned up
- Fix pathname matching to handle query parameters in URL
- Replace html2canvas screenshot with Playwright (accurate colors)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 09:56:21 +10:00
taodengandClaude Opus 4.6 087e26346f feat: add ocp-connect lightweight client + restructure README
- Add standalone `ocp-connect` script for zero-dependency client setup
  (only needs curl + python3, no Node/repo clone required)
- Add `ocp connect` command to main CLI
- Add `authMode` field to /health endpoint
- Restructure README with clear Server Setup / Client Setup sections
- Add dashboard screenshot to README
- Fix smoke test model name (claude-haiku-4-5-20251001)
- Fix auth mode detection for shared/multi/none
- Fix rc file cleanup idempotency (blank line handling)
- Fix key input echo (now hidden with read -rs)
- Add host validation
- Bump version to 3.5.0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 09:48:33 +10:00
taodengandClaude Opus 4.6 471bda2c40 fix: make /dashboard a public endpoint (no auth required)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 21:21:36 +10:00
taodengandClaude Sonnet 4.6 5fbeaed568 security: fix key exposure, timing-safe admin auth, remove admin bypass, cap params, harden usage recording
- Fix 1 (keys.mjs): listKeys() no longer leaks full API key field in response
- Fix 2 (server.mjs): Remove BIND_ADDRESS admin bypass from isAdmin check
- Fix 3 (server.mjs): Admin key comparison now uses timingSafeEqual
- Fix 4 (server.mjs): Cap limit (max 500) and hours (max 720) in GET /api/usage
- Fix 5 (server.mjs): Streaming error path now computes promptChars from messages
- Fix 6 (server.mjs): Warn at startup if AUTH_MODE=shared but PROXY_API_KEY is empty
- Fix 7 (server.mjs): All recordUsage calls wrapped in try/catch to prevent DB errors crashing server
- Fix 8 (server.mjs): CORS fallback changed from "*" to specific origin (http://127.0.0.1:<PORT>)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 21:13:17 +10:00