mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-21 21:15:09 +00:00
383dea0c82bab950427ca97ae7476bcf718ada26
70
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
383dea0c82 |
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> |
||
|
|
26b09af5a8 |
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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
|
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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>
|
||
|
|
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
|
||
|
|
6bfffd2cba |
chore(server): revert stale-cache compensation for hallucinated endpoint (#23)
Removes the stale-cache fallback branches in handleUsage() and handleStatus() originally introduced by |
||
|
|
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:
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
471bda2c40 |
fix: make /dashboard a public endpoint (no auth required)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
2b18884d2a |
feat: add LAN bind, 3-mode auth, usage recording, key/usage API endpoints
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
3843ec8fe6 |
refactor: simplify timeout to single CLAUDE_TIMEOUT (default 600s)
Remove firstByteTimeout, adaptive tier algorithm, and MODEL_TIMEOUT_TIERS. Claude tool-use causes 30s-5min pauses in token streams, making fine-grained timeouts unreliable — they repeatedly killed valid requests. A single generous timeout (10 min, matching LiteLLM/OpenAI SDK defaults) is simpler and correct. Breaking: CLAUDE_FIRST_BYTE_TIMEOUT env var removed, default timeout changed from 300s to 600s. Bump to v3.3.0. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> |
||
|
|
afe0c6e1be |
fix: computeFirstByteTimeout now respects CLAUDE_FIRST_BYTE_TIMEOUT env var
The adaptive timeout calculation ignored BASE_FIRST_BYTE_TIMEOUT (set via CLAUDE_FIRST_BYTE_TIMEOUT env), always using the tier base (120s for Sonnet). Now uses whichever is larger: adaptive or env override. Bump to v3.2.2. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> |
||
|
|
3de1868313 |
fix: read OAuth token from Linux credentials file as fallback
On Linux (no macOS keychain), read ~/.claude/.credentials.json first. Falls through to macOS keychain if file not found. Fixes /ocp usage showing "No OAuth token" on Linux servers and RPi. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |