mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-21 21:15:09 +00:00
docs/readme-restructure
100
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
faea02d951 |
docs(readme): date-stamp the billing-split status (PAUSED) + scope LAN mode to chat-class workloads (#171)
P0 from the README audit, direction set by the maintainer's history lesson: the 2026-06-15 -p billing split was announced (2026-05-14) but PAUSED by Anthropic on the effective date — officially: "For now, nothing has changed: Claude Agent SDK, claude -p, and third-party app usage still draw from your subscription's usage limits" (support.claude.com article 15036540). The README asserted the split as in-force in five places while the top-of-README "$0 / no API billing" pitch said the opposite — a self-contradiction where the PITCH was the currently-true half. - How It Works: added the dated billing-policy status note (official quote + link); the "$0" claims stand, now anchored to it. - TUI § "What it is and why": status banner; the cc_entrypoint table relabeled "announced regime, currently paused"; TUI-mode reframed as the ready-made HEDGE for if/when a reworked change lands (advance notice promised). - "2026-06-15 operator checklist" -> "Operator checklist for the (paused) billing split": nothing needs flipping while the pause holds; checklist retained verbatim as the re-landing runbook. - OCP_SKIP_AUTH_TEST env row + /health tui-block prose: conditioned on the paused regime instead of asserting it. - Client-tools boundary: new "workload fit" paragraph (maintainer-approved positioning): LAN/multi-device OCP = chat-class workloads; client-machine coding agents are architecturally out of scope (tools execute on the OCP host) — run claude/OCP on the machine where the code lives. Lesson encoded: policy-dependent claims carry dates and the official source, so the next policy swing is an edit, not an archaeology dig. Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude <claude-opus-4-8> <noreply@anthropic.com> |
||
|
|
6f18613f9d |
docs(readme): staleness sweep — model count/examples, phantom ocp stop, undocumented env vars, ocp-connect claim (#170)
* docs(readme): sweep stale content — 6 models, drop phantom `ocp stop`, document 2 live env vars, fix ocp-connect claim
P1 findings from a full README-vs-code staleness audit (each verified against the
tree at
|
||
|
|
0fc8d6973b |
chore(release): v3.22.1 — retitle unpublished v3.22.0 + fold in #161 (Windows resolve) and #152 (Sonnet 5) (#169)
v3.22.0 (#166) was merged but never tagged; #161 and #152 then landed on main, so the prepared release no longer matched HEAD. Owner opted to renumber: the v3.22.0 CHANGELOG section becomes v3.22.1 (with an explicit version note), gains entries for #161 and #152, and the tag will be cut at this release commit — no tagging of historical commits needed. package.json 3.22.0 → 3.22.1. Semver note: still a minor-family bump from 3.21.1 (features: #156/#158/#159, plus #152's new model entry); 3.22.0 is simply skipped — semver requires increasing versions, not contiguous ones. Release-kit walk (delta vs #166's walk): #152 → README "Available Models" table row already present (added in #152 itself) + models.json is the SPOT; #161 → no new env var/endpoint; README §Windows guidance unchanged (Windows support deliberately NOT advertised until #167 lands + real-Windows E2E). Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude <claude-opus-4-8> <noreply@anthropic.com> |
||
|
|
b7463a63f5 |
chore(release): v3.22.0 — TUI effort/pool/streaming (opt-in) + post-audit hardening (#166)
Consolidates #155–#165 into a minor release. Version 3.21.1 → 3.22.0. Minor (not patch) because it adds user-facing opt-in features and new env vars (OCP_TUI_EFFORT #156, OCP_TUI_POOL_SIZE #158, OCP_TUI_STREAM + OCP_TUI_STREAM_HOLDBACK/ _DIR/_POLL_MS #159/#160). All default OFF, so the default request path (-p / --output-format stream-json) is byte-for-byte unchanged — no breaking change. Release-kit walk (CLAUDE.md 5.5): all four new env vars already carry README § "Environment Variables" rows + dedicated § "How It Works" coverage (added by the feature PRs); no new endpoint (TUI streaming reuses /v1/chat/completions); no models.json change (Available Models table unchanged — #152 not merged). Version is sourced from package.json (server.mjs VERSION = _pkg.version), so no other file needs editing. The README:899 "pre-3.21.1" note is historical (the #148 boot-reap migration) and stays. Tag push (v3.22.0) triggers .github/workflows/release.yml to create the GitHub Release. Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude <claude-opus-4-8> <noreply@anthropic.com> |
||
|
|
eeec2bf83d |
fix(setup): never carry test-only key-store redirection vars into a server OCP launches (A4) (#165)
* fix(setup): never carry test-only key-store redirection vars into a server OCP launches (A4) Defense-in-depth for the key-store isolation shipped in #163, plus a correction to the overstated claim that fix's comments made. Surfaced by an independent (Codex) re-review. Background: keys.mjs honors OCP_DIR_OVERRIDE only when NODE_ENV === "test", so the key store can be pointed at a scratch dir for the test suite. If BOTH vars reached a production daemon's environment, it would open a scratch/empty key store instead of ~/.ocp/ocp.db — in AUTH_MODE=multi a silent total auth outage. #163's comments claimed a production server "runs without NODE_ENV, so it CANNOT honor the override no matter how the variable got in." That is not something keys.mjs can enforce — it is only true while the daemon's env happens to lack NODE_ENV=test. This PR makes it true for every server OCP itself launches, and softens the docs to stop overclaiming. Three parts (all in OCP's own launch/installer paths — no server.mjs change, no cli.js analogue): 1. scripts/lib/plist-merge.mjs — new exported NEVER_PRESERVE = {NODE_ENV, OCP_DIR_OVERRIDE}, stripped from the preserved set in BOTH mergePlistEnv and mergeSystemdEnv. The preservation rule ("keys only in the EXISTING unit are kept verbatim") was the vector: a unit that once carried these test-only vars would otherwise survive every setup re-run. setup.mjs's template never injects them, so preservation was the only entry path, and this closes it. 2. ocp (cmd_restart manual fallback) — the one direct `node server.mjs` launch OCP controls now runs under `env -u NODE_ENV -u OCP_DIR_OVERRIDE`, so a maintainer who exported both while debugging and then restarted can't silently boot the daemon onto a scratch store. 3. keys.mjs + test-env.mjs — softened the overstated comments to state what is actually enforced (the two-key gate makes neither var alone do anything; OCP's launchers strip both) and to name the one residual path honestly: a hand-rolled `node server.mjs` with both vars explicitly exported, bypassing every launcher — for which the loud getDb() "NOT the default" log is the backstop. No library-level gate can catch an operator who both sets a test flag and bypasses the launchers; the honest fix is a non-silent wrong-store, which #163 already provides. Severity: LOW (defense-in-depth; the default/shipped path was already safe). No behavior change on any correctly-configured install. ALIGNMENT.md: this PR does not touch server.mjs, so the cli.js-citation hard requirement does not apply; and no cli.js operation is involved — key-store isolation and installer env hygiene are entirely OCP-owned (no Class A / cli.js-mirror surface). Tests: +4 mutation-proven (3 behavioral: drop the `!NEVER_PRESERVE.has(k)` guard in either merge fn and they fail — verified 326 passed / 3 failed under mutation; restored). The `ocp` bash `env -u` line is verified by `bash -n` + inspection (the suite does not exec the installer/daemon). Full suite: 329 passed / 0 failed (was 325). Version bump + CHANGELOG deferred to the later chore(release) PR, per the repo's #148/#149/#150 -> #151 convention (matching PR #164). Co-Authored-By: Claude <claude-opus-4-8> <noreply@anthropic.com> * test(setup): assert NEVER_PRESERVE.size === 2 so the "exactly two" test matches its name Reviewer nit (LOW): the membership assertion let a future spurious third entry slip past a test whose name promises "exactly the two". Behavior stays guarded by the 3 mutation-proof tests; this just makes the contract test honest. Co-Authored-By: Claude <claude-opus-4-8> <noreply@anthropic.com> --------- Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude <claude-opus-4-8> <noreply@anthropic.com> |
||
|
|
63c2de7128 |
fix(tui): clamp stream holdback to safe floor (A1) + record cc_entrypoint before honesty gates (A3) (#164)
Two OCP-internal correctness fixes on the TUI streaming/observation path, surfaced
by an independent (Codex) re-review. Neither touches the cli.js wire.
A1 — OCP_TUI_STREAM_HOLDBACK now has an enforced floor (DEFAULT_HOLDBACK_CHARS=100).
The C-1 auth-banner gate's first-message guarantee rests on the holdback being at
least the default banner detector's 100-char reach. The env var's own doc said
"Only raise it", but the code trusted the operator: a sub-floor value (e.g. 50) or a
NaN typo ("unlimited") let the first chars of a real auth banner stream to the client
before the end-of-turn detector could classify the whole message and reject the turn.
resolveStreamHoldback() clamps UP to the floor and returns {value, clamped}; server.mjs
emits a boot WARNING when it had to clamp. Default (unset) is unchanged and unflagged.
A3 — recordTuiEntrypoint() now runs the moment runTuiTurn() returns, BEFORE the honesty
gates (wall-clock truncation / auth banner / stream divergence) that throw. The entrypoint
(cli vs sdk-cli) is which billing pool the turn consumed; a turn that then fails a gate
STILL spent that pool, and those failed turns are exactly the ones most likely to signal a
silent degrade to the metered Agent SDK pool. The old placement recorded only on the success
path, so /health's lastEntrypoint and entrypointMismatches were blind to every failed turn —
the billing-drift signal missed the cases it most needed to catch. recordModelSuccess stays
on the success path. The catch block does not record the entrypoint, so there is no double
count; a client-disconnect (TuiAbortError) throws from inside runTuiTurn before the destructure,
so no phantom entrypoint is recorded.
ALIGNMENT.md Rule 2 (No Invention): no cli.js citation applies. cli.js does not perform either
operation — both are proxy-internal. A1 hardens OCP's own SSE holdback (a safety mechanism on
the Class B.1 OpenAI-compat streaming surface; wire format authority is the OpenAI spec via
ADR 0006). A3 reorders when OCP records its own /health observability counters (Class B.2,
grandfathered under ADR 0006; the TUI spawn authority is ADR 0007). No endpoint, header,
request field, or response field is added or altered; the bytes to and from cli.js are
byte-identical. This is observation/safety-layer hardening, not extension.
Tests: +5 mutation-proven unit tests for resolveStreamHoldback (deleting the floor clamp
fails 3 of them). Full suite 325 passed / 0 failed (was 320). A3 is a server.mjs control-flow
reorder; server.mjs is not imported by the test suite, so A3 is verified by reviewer inspection
of the diff, stated honestly here rather than vouched for by a test.
Version bump + CHANGELOG deliberately omitted: this is a fix PR, consolidated into a later
chore(release) PR per the repo's #148/#149/#150 -> #151 (v3.21.1) convention.
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude <claude-opus-4-8> <noreply@anthropic.com>
|
||
|
|
1d65bc309e |
fix(test): stop the suite writing live API keys into the operator's real key store (#163)
* fix(test): stop the suite writing live API keys into the operator's real key store
`npm test` wrote real, UNREVOKED rows into ~/.ocp/ocp.db — the SAME sqlite database the
running server reads — two per run, unbounded. On the maintainer's host that had accumulated
**737 test-suite keys against 12 real operator keys** (749 rows total), all revoked=0.
Not a credential leak: createKey() mints `randomBytes(24)` and the suite discards the
plaintext, so nobody holds a usable token. But it is real damage:
- the operator's key store grows by 2 rows on every test run, forever
- `ocp keys list` is unusable (749 rows, 12 of them real)
- the suite is RACY: two concurrent runs (e.g. two review worktrees) share one file, so
listKeys() can miss "test-user-1" and `"quota_daily" in undefined` throws a TypeError
rather than failing cleanly. That is the ~1-in-6 flake in `listKeys includes quota
fields`, reported by a reviewer and initially not reproducible serially — it needs a
concurrent run to surface, which is exactly what four parallel reviewers produced.
Root cause: keys.mjs resolved `OCP_DIR`/`DB_PATH` at MODULE TOP-LEVEL and read no env var.
test-features.mjs carried a comment claiming it could "set env before the first getDb() call"
— it could not, on two counts: nothing in keys.mjs read an env var, and ESM hoists imports, so
the assignment would have run after keys.mjs was already evaluated anyway. The isolation was
never real; it just LOOKED real, which is why it survived.
Fix:
- keys.mjs resolves the dir lazily, inside getDb(), honoring OCP_DIR_OVERRIDE. Deliberately
NOT a generic `OCP_DIR`: pointing a RUNNING server at a different key store silently
changes which credentials authenticate, so this must be awkward to set by accident.
- new test-env.mjs, imported BEFORE keys.mjs, mints a per-run scratch dir. A separate module
is required — ESM hoisting means a statement in the test's own body is too late.
- export getDbPath() so the store's location can be asserted.
- as a side effect, importing keys.mjs no longer creates directories in the operator's home.
Two guards added, both MUTATION-TESTED (revert the override → both fail, 317/2):
- the key store under test is a scratch db, NOT the operator's real ~/.ocp/ocp.db
- listKeys does not depend on rows left behind by an earlier or concurrent run
Proven, not asserted: the real ~/.ocp/ocp.db held at 749 rows across two full test runs
(it previously grew by 2 each run). The 737 existing junk rows are NOT cleaned up here —
that is a destructive change to the maintainer's live database and is his call, not a
side effect of a test fix.
npm test: 319 passed, 0 failed (was 317).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VqgWJcjxrjjL9L9SkpZyXR
* fix(keys): gate the test override behind NODE_ENV so a prod server can never honor it
Review fold-in. The reviewer landed the sharpest possible critique: the first cut closed a
test-hygiene hole by opening a quieter AUTH-CORRECTNESS one, and its only guard against that
was "the variable has an awkward name" — a naming convention plus a comment. That is precisely
the failure mode this whole PR exists to indict (a comment describing an intention that nothing
enforces). It was demonstrated live:
OCP_DIR_OVERRIDE=/tmp/evil-store -> server opens /tmp/evil-store/ocp.db, 0 keys visible
server.mjs imports keys.mjs, and `ocp start`'s nohup fallback inherits the invoking shell's env
— so a maintainer who exported the var while debugging THIS issue and then started the server
would get a server silently authenticating against an empty key store. In AUTH_MODE=multi that
is a total auth outage: every real key 401s, nothing logged, nothing on /health.
F1 — the gate is now the actual guard: OCP_DIR_OVERRIDE is honored ONLY when
NODE_ENV === "test". A production server runs without NODE_ENV and therefore CANNOT be
redirected, however the variable reached its environment. Proven both directions:
no NODE_ENV + OCP_DIR_OVERRIDE=/tmp/evil-store -> /Users/<op>/.ocp/ocp.db (ignored)
NODE_ENV=test + OCP_DIR_OVERRIDE=/tmp/scratch -> /tmp/scratch/ocp.db (honored)
Plus: getDb() now LOGS the store whenever it is not the default. Silence was the other half
of the bug — a server on the wrong key store looks exactly like one on the right store until
every request 401s.
F2 — restore the 0700 guarantee on ~/.ocp. Removing keys.mjs's top-level mkdirSync (a good
change on its own) silently dropped it: prepareSpawnHome (server.mjs:477) does
mkdirSync(recursive) with NO mode, so on a fresh install it can create ~/.ocp as a
world-listable 0755 parent. Verified: 755 via the spawn-home path vs 700 via resolveOcpDir.
The invariant used to be inherited by luck; it is now stated.
F3 — test-env.mjs removes its scratch dir on exit. Otherwise the fix traded unbounded growth in
~/.ocp/ocp.db for unbounded growth in $TMPDIR. Verified: 2 runs, delta 0 dirs.
F4 — closeDb() clears dbPath; getDbPath() no longer hands back a path to a closed db.
F5 — dropped the dead unlinkSync import and explained the leftover HOME normalization.
New test, and it is the one that matters: "a PRODUCTION process (no NODE_ENV) must IGNORE
OCP_DIR_OVERRIDE" — so nothing can re-widen the gate without a red test.
server.mjs IS touched (one mkdirSync mode). Not endpoint-touching: no request handler, endpoint,
header, or wire field — so no cli.js citation applies (ALIGNMENT.md Rule 2 / CLAUDE.md hard-req #1).
Note memory/constitution.md § II lists keys.mjs as a protected file requiring maintainer approval.
npm test: 320 passed, 0 failed. Real ~/.ocp/ocp.db unchanged at 751 rows throughout.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VqgWJcjxrjjL9L9SkpZyXR
* fix(test): make the F1 gate test REAL — it was theatre, and proved it
The reviewer deleted the entire NODE_ENV gate from keys.mjs and the suite still reported
320 passed, 0 failed. The one test written to stop this bug recurring was the one thing in
the PR that would have let it recur — and it would have merged green, with a false sense of
coverage.
Why it was worthless: it re-implemented the predicate INSIDE THE TEST BODY —
const resolve = (nodeEnv, override) =>
(nodeEnv === "test" ? override : null) || join(homedir(), ".ocp");
— and never called resolveOcpDir(), getDb(), or getDbPath(). It asserted that a closure
defined three lines above behaved as written. A copy of the predicate is not the predicate.
Its own comment said "exercising the same predicate keys.mjs uses" — that phrase was the tell.
This is the same failure class the PR exists to indict (an assertion of an intention that
nothing enforces), reproduced one layer up, in the fix for it. Fourth time in this repo that
a correctly-named test has vouched for nothing.
The real test must run OUT OF PROCESS: the parent is irreversibly NODE_ENV=test by the time
any test runs (test-env.mjs sets it before keys.mjs is imported), so the production path is
simply unreachable in-process. It now spawns a child with no NODE_ENV, the override set, and
HOME redirected to a temp dir (so the real key store is never opened), and asserts what the
REAL keys.mjs actually did.
MUTATION-PROVEN, against the exact revert that used to pass:
delete the whole NODE_ENV gate -> 319 passed, 1 failed
✗ a PRODUCTION process (no NODE_ENV) must IGNORE OCP_DIR_OVERRIDE
restore -> 320 passed, 0 failed
Also folded in: setup.mjs created ~/.ocp at the umask default (755, world-listable) on a fresh
install via the logs dir — pre-existing, self-healing on first server start, now stated
explicitly (mode 0700) rather than left to luck. Same class as the F2 fix.
npm test: 320 passed, 0 failed. Real ~/.ocp/ocp.db unchanged at 751 rows.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VqgWJcjxrjjL9L9SkpZyXR
* fix(setup): rescue the semicolon from the comment; assert the child SAW the override
Two review nits on the way in.
setup.mjs:393 — the statement's semicolon had been swallowed INTO the trailing comment, so
the line parsed only because ASI rescued it (the next token is `if` on a new line). The repo
has no linter, so nothing would have caught it. Comment moved above the statement.
test-features.mjs — negative control on the prod-gate probe. The reviewer noticed the test's
robustness was INCIDENTAL: because the child env is spread from process.env, it inherits the
parent's own OCP_DIR_OVERRIDE, so a future refactor that renamed the var and missed this test's
explicit `env` object would still have gone red — but by luck, not by assertion. The child now
prints the override it SAW as well as the store it opened, and the test asserts both. The claim
is now 'a prod process saw the override and ignored it', not merely 'a prod process opened the
right store' (which could pass for the wrong reason).
Mutation re-proven after both edits: delete the NODE_ENV gate -> 319 passed, 1 failed; restore
-> 320 passed, 0 failed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VqgWJcjxrjjL9L9SkpZyXR
---------
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
88d8bed2e3 |
docs(readme): stop the feature bullet promising what § honest limits forbids (#136) (#162)
* docs(readme): stop the feature bullet promising what § honest limits forbids (#136) Issue #136: an external user reported that Claude Code REFUSES to run OCP's own copy-paste install prompt, on the grounds that the premise — pooling one Pro/Max subscription across a family — violates Anthropic's Usage Policy. The install prompts themselves were already fixed since that report ('my own devices on the network', plus a ToS warning on the LAN section). What remained was a self-contradiction in the README: line 27 (feature bullet): 'share one Claude Pro/Max subscription with family, friends, or your own devices' line 427 (§ honest limits): 'The defensible framing is "one person, your own devices" — sharing with friends or a team is not.' The top-of-funnel bullet was promoting exactly what the project's own ToS section calls indefensible. That is a defect on its own terms, independent of anyone's view on the underlying policy: a reader who trusts the bullet is walked straight into the thing the same document later tells them not to do. Aligned the bullet to the position the project ALREADY took, and linked the honest-limits section from it. No change to the auth modes, the LAN feature, or the maintainer's own account of how they use it — this only stops the doc arguing with itself. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VqgWJcjxrjjL9L9SkpZyXR * docs(readme): fix the misdirected anchor + the same defect at line 52 (review fold-in) Independent reviewer caught two things in the first cut: 1. The new link pointed at #auth-modes — which resolves to the '### Auth Modes' mode table (line 408), NOT to the 'Sharing with family / a team — honest limits' paragraph (line 422), which sits under '### Deployment model & security (read this)' (line 418). A bullet that says 'see the honest limits' and then sends you somewhere else is worse than no link. Correct anchor verified two ways (github-slugger + the live rendered page): #deployment-model--security-read-this (double hyphen — the '&' is stripped but both surrounding spaces survive). 2. Line 52 carried the SAME defect the PR was written to fix, a few lines below it: 'share one Claude Pro/Max subscription across IDEs, devices, and people'. So the original claim — 'this only stops the document arguing with itself' — was not yet true: it stopped one instance and left an adjacent one standing, in the same top-of-README section an install-time reviewer reads first. Left alone deliberately: the maintainer's own account of their household's use (lines 7 and 1178). That is theirs to make, and a docs PR should not quietly rewrite it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VqgWJcjxrjjL9L9SkpZyXR * docs(readme): carry the ToS caveat into the MANUAL install path too (README:218) Reviewer found a third instance, and it is the one that most directly reproduces #136. README has two forms of the same LAN-mode install: the copy-paste AI prompt (line 132) and the handbook form (line 218). Line 180 explicitly asserts they are 'the same steps in handbook form'. They were not: line 132 (prompt) 'install OCP as a server so YOUR OWN DEVICES on the LAN can reach it (Claude Pro/Max are per-user accounts — review Anthropic's Usage Policy before extending access to other people)' + example keys: laptop, tablet line 218 (handbook) 'share with other devices on your network:' + 'create API keys for each PERSON/device' + no ToS pointer at all So a reader who takes the manual route instead of the copy-paste route is still walked into per-person key creation with zero ToS mention — reproducing #136's trigger through the door the first commit did not close. The caveat now matches its twin. Deliberately NOT scrubbing the wife-laptop / son-ipad example key names: they recur in seven further places, it is a bigger diff at a different severity, and it edges into scrubbing the maintainer's household out of their own documentation. With the pointer restored at 218 those examples inherit the caveat — which is exactly the posture § honest limits takes. It never forbids family sharing; it says it is the account holder's call and their risk, and refuses to hide that. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VqgWJcjxrjjL9L9SkpZyXR --------- Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
a90f830b5d |
fix(tui): a null message_id on the first hook fire must not disarm the F1 guard (#160)
Residual found by the independent reviewer's second pass, by PROBING the fixed code rather
than reading it — the F1 fix was correct but its ARMING could be skipped entirely.
TuiDeltaAssembler.messageId was initialized to `null`. A first MessageDisplay payload carrying
message_id:null therefore compared EQUAL to the sentinel, registered no boundary, and left
`messages` at 0. When the real boundary then arrived, `else if (this.messages > 1)` evaluated
1 > 1 === false, so restartedAfterEmit never armed, and the `released` branch forwarded the
auth banner to the client — the exact leak F1 closed, reachable again through a single null
field. parseDeltaChunk does not validate message_id, so such a payload does reach push().
Two changes, both narrowing:
- messageId now initializes to a Symbol sentinel, which is === to nothing a JSON payload can
produce, so the first fire ALWAYS registers as message 1 whatever its message_id is.
- the boundary branch drops the `this.messages > 1` sub-condition. It bought nothing and was
the sole cause. The real invariant is "a boundary occurred while emitted !== ''" — which is
unrecoverable regardless of how many messages have been seen — and that is now what the
code says.
Whether claude ever emits message_id:null is unverified (the observed contract has it present),
so this is defense-in-depth, not a live bug. But the guard is the mechanism this feature
nominates as its primary safety property; it should not be disarmable by a field's absence.
Mutation-tested: restore the null sentinel + the messages>1 condition and the new test fails;
with the fix, 317 passed / 0 failed (was 316).
Class B (ADR 0007, OCP-owned TUI spawn) — cli.js does NOT perform this operation.
Claude-Session: https://claude.ai/code/session_01VqgWJcjxrjjL9L9SkpZyXR
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
1b324968f4 |
feat(tui): real SSE streaming via claude's MessageDisplay hook (OCP_TUI_STREAM, default off) (#159)
* feat(tui): real SSE streaming via claude's MessageDisplay hook (OCP_TUI_STREAM, default off) Backlog #2. TUI-mode `stream:true` turns can now emit real SSE `delta.content` chunks as `claude` generates them, instead of buffering the turn and replaying it with streamStringAsSSE. Opt-in: with OCP_TUI_STREAM unset/0 the spawn argv, the SSE bytes and the cache behaviour are byte-for-byte unchanged (asserted by test). This PR does NOT mirror any cli.js function, so no `cli.js:NNNN` citation applies, and per CLAUDE.md's hard requirement #1 that is stated explicitly here rather than left implicit: - We consume claude's OWN `MessageDisplay` hook surface AS EMITTED — forwarding, not inventing. No new endpoint, no fabricated protocol, no new field. - The TUI spawn is OCP-owned surface: ADR 0007 owns it, not cli.js. - The SSE wire shapes are the OpenAI chat/completions streaming spec, adopted by ADR 0006. Every frame emitted here (role chunk, content-delta chunk, stop chunk, `[DONE]`, and the post-header {error:{message,type}} frame) is COPIED from callClaudeStreaming, the -p path. /health gains additive fields only (streamEnabled + 4 counters) — same grandfathered B.2 rationale as the existing tui block (ADR 0006). Existing keys are untouched. `claude` fires MessageDisplay per rendered block, handing the hook the RAW MARKDOWN SOURCE of an incremental delta on stdin. The hook is registered with `--settings` on the ordinary interactive spawn (no -p, no --bare) — verified to leave the billing pool alone. Sink: a static sh hook script appends each payload to `<streamDir>/<session_id>.jsonl`; OCP polls that file and forwards deltas as SSE. The per-session-id keying is MANDATORY, not an optimization — OCP_TUI_MAX_CONCURRENT defaults to 2, so two claude panes already run at once and a shared sink would splice one client's deltas into another's stream. Warm-pool compatible (a separate in-flight PR depends on this): the hook script AND the settings file are static — nothing request-specific is baked in at spawn time. The sink path reaches the pane through its own env (OCP_TUI_STREAM_FILE) and derives from the session-id, which a pre-booted pane fixes at boot. The hook is SYNCHRONOUS (forceSyncExecution: claude blocks on it), so the script writes and exits: one `cat` append, nothing else. Measured p50 7.2ms / p90 14.7ms per fire, ~50ms across a whole turn — noise against a 6-10s turn. It remains the terminal-turn signal, the source of the returned/cached text T, and the input to the honesty gates. The delta stream is a low-latency MIRROR, never a replacement: - the truncation gate (C-2) and auth-banner gate (C-1, issue #133) run BEFORE anything is committed or flushed, unchanged; - at end of turn the streamed bytes are asserted against T. Equal -> serve. A strict PREFIX of T -> top up from the transcript so the client still receives exactly T (counted). NOT a prefix -> REFUSE the turn: SSE error frame, no cache, no success, streamDivergences++. Serving text the transcript disagrees with is the failure class ALIGNMENT.md exists to prevent, so this fails loud rather than degrading quietly; - only T is ever cached — never the concatenated deltas. The auth banner needs prevention, not just detection (SSE deltas cannot be un-sent), so the first OCP_TUI_STREAM_HOLDBACK (100) chars are withheld: the default banner detector cannot match a message longer than 100 chars, so releasing past that provably cannot leak a banner. A custom CLAUDE_TUI_ERROR_PATTERNS has no such bound — OCP warns at boot. - BANNER, before/after the spawn change: `Sonnet 4.6 with low effort · Claude Max` both, including on the pane the server itself spawns. Never `API Usage Billing`. Transcript entrypoint stays "cli". --settings is not a --bare-class flag. - --settings MERGES with <HOME>/.claude/settings.json rather than clobbering it (the user-level settings' `env` block still reached the hook), so the isolated-HOME settings story (permissions / additionalDirectories) survives. - EXACTNESS: 8/8 varied prompts (short, long, markdown, code fence, multilingual, JSON, table, unicode) byte-exact vs transcript T, streamed AND buffered. 0 top-ups, 0 divergences over 15 streamed turns. - TTFT: buffered delivers NOTHING until the turn ends (TTFB == total, 7.5-15.8s). Streamed sends headers at ~25ms (heartbeat covers the pre-first-delta silence) and first content mid-generation, e.g. markdown 7.9s first chunk / 12.8s total; long 9.7s / 17.4s. - CONCURRENCY DEMUX: two concurrent streamed turns (ALPHA/BRAVO), tui.inflight peaked at 2, each read its own session-keyed transcript, ZERO cross-contamination. - AUTH-BANNER GATE under streaming, both layers: a short banner-like turn reached the client as 0 content chunks + an SSE error frame (never emitted); a long one was streamed but still ended on an error frame, not finish_reason:"stop", and was not cached. - DISCONNECT mid-turn: pane torn down and semaphore slot released within 1s (info-logged, not booked as a model error). - THINKING: not leaked. Opus 4.8 + xhigh turns carry a thinking block with a signature but `thinking:""` (the reasoning text is not persisted in interactive mode), both MessageDisplay text-extraction sites in the 2.1.207 bundle filter type==="text", and no reasoning prose appeared in any delta; concat===T held exactly on the single-message turn. - npm test: 282 passed, 0 failed (was 267 on main; +15). The transcript keeps only the model's LAST assistant message. A turn where the model narrates before calling a tool therefore has two messages, and T is only the second. If the narration exceeds the holdback it has already been streamed and cannot be retracted -> the turn is REFUSED. Reproduced live: Opus narrated 475 chars before a Bash call. The assembler discards a prior message's text when nothing has been emitted yet (so short narration is handled correctly and stays exact), and raising OCP_TUI_STREAM_HOLDBACK above the narration length rescues the turn — verified on that exact transcript: holdback>=500 -> served, exact=true. Documented in README and ADR 0007; this is why streaming is opt-in and off by default. ADR 0007 line 59 ("no real token streaming — deliberate") is amended, not silently contradicted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(tui): re-integrate streaming onto the warm-pane pool (#158) — install the hook at BOOT Rebasing backlog #2 (streaming) onto #158 (warm pane pool) is not a textual merge: #158 split the monolithic runTuiTurn into bootTuiPane + runTuiTurn, and streaming had patched the monolith. Re-integrating it in the OLD shape would have compiled, passed every existing test, and been WRONG. The bug that shape would have shipped: the sink was derived at TURN time from a streamDir argument. But a POOLED pane is pre-booted long before any request exists — so on a pool HIT runTuiTurn never cold-boots, no hook was ever registered on that pane, and the turn would silently serve BUFFERED. Every miss streams, every hit does not; no error, no failing test. The operator sees "streaming does nothing in production" and has nothing to grep for. Fix — install the hook where the pane is born: - bootTuiPane({ streamDir }) registers the MessageDisplay hook at spawn and returns the pane's own sink (pane.streamFile), keyed by the pane's own --session-id. The hook script and settings file are STATIC (one pair per streamDir); the only per-turn thing is the sink path, and it is fixed at boot. So nothing request-specific is baked into a spawn. - runTuiTurn reads pane.streamFile — never recomputes it — so a warm pane and a cold pane stream through byte-for-byte the same path. - server.mjs threads the same streamDir into the pool's bootPane closure, so pre-booted panes carry the hook too. TUI_STREAM/TUI_STREAM_DIR now declare before the pool needs them. Three regression guards added (test-features.mjs), and the third was MUTATION-TESTED: with the fix reverted to the turn-time shape it fails ("the pooled pane's deltas must reach the client"), with the fix in place it passes. A guard nobody has watched fail is not a guard. /health: pool + stream* fields are now a union — the shape assertion asserts CONTAINMENT of the seven grandfathered keys plus an exact added-set, so a future field that silently REPLACED an original key cannot pass. Class B (ADR 0007, OCP-owned TUI spawn) — cli.js does NOT perform this operation. npm test: 313 passed, 0 failed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VqgWJcjxrjjL9L9SkpZyXR * fix(tui): close the streaming auth-banner leak + 6 further review findings (PR #159) Independent review (Iron Rule 10) found a HIGH bug by EXECUTING the code, not reading it. All seven findings fixed. F1 and F3 were merge-blocking. F1 (HIGH) — the auth-banner holdback was bypassed after the first release. TuiDeltaAssembler.released was set once and never reset at a message_id boundary, so the holdback + detectError predicate guarded only the FIRST message of a turn. In production's own configuration (OCP_TUI_FULL_TOOLS=1, where multi-message tool-using turns are the norm): the model narrates past the holdback before a tool call -> released; credentials expire mid-turn -> claude renders the 401 as ordinary assistant TEXT as a NEW message -> push() took the `if (this.released)` branch and handed the banner verbatim to the client. That is precisely the silent-error case the C-1 gate exists to prevent. Detection survived (the turn was still refused at finalize) but PREVENTION did not. Fix: once a message boundary follows an emit, the turn is already unrecoverable — finalize() will refuse it — so push() now emits NOTHING further for the rest of the turn. Second hole in the same predicate: detectTuiUpstreamError() trims before applying its <=100-char rule, so 101 whitespace chars trimmed to "" -> detector had nothing to classify -> returned null -> release fired having screened nothing. Release now gates on the TRIMMED length, so both sides of the check talk about the same string. F2 — the "provably safe" claim in stream.mjs, ADR 0007 and README was unsound as written. Restated with both required halves: (i) nothing is emitted until the trimmed accumulation exceeds the detector's max banner length, AND (ii) no emission at all once a message boundary follows an emit. Half (i) alone only ever covered a turn's first message. F3 (blocker) — prepareStreamHook was write-if-missing, so md-hook.sh could never be updated OR repaired: a host that booted once under an older version was stuck on that HOOK_SCRIPT forever, and a non-atomic write interrupted mid-flight left a TRUNCATED script that existsSync() called fine — on a hook claude BLOCKS on synchronously. Now written unconditionally via tmp+renameSync (the pattern already used by ensureTuiCwdTrusted). F4 — the two spawn paths differed for non-streaming requests: the pool installed the hook whenever OCP_TUI_STREAM was on (correct — a pre-booted pane cannot know what request it will serve), but the cold path gated it on this turn's onDelta. So one stream:false request got --settings on a pool HIT and not on a MISS: two spawn argvs for the identical request, on this project's billing-classification surface. Both paths now gate on TUI_STREAM alone; whether the sink is POLLED remains correctly gated on onDelta. F5 — pool._drop() killed the pane but orphaned its sink file; the reap tick drains the whole pool, so sinks accumulated with no GC path. Now removed best-effort on every drop path. F6 — /health counters did not measure what they documented: streamTurns was incremented only AFTER the honesty gates, hiding exactly the turns an operator most wants to see (and making streamDivergences/streamTurns a meaningless ratio); streamDeltas counted every fire while claiming to count forwarded ones. Counters and docs now agree. F7 — total hook failure was silent: zero fires per turn still yields ok:true/exact:false and a normal, fully-buffered answer. Only streamTopUps moved, which the code itself calls benign. Added streamZeroDeltaTurns (+ a tui_stream_zero_deltas warning) to separate "the hook is dead" from "one fire was dropped". Tests: 316 passed, 0 failed (was 313). Every new guard MUTATION-TESTED — with each fix reverted the guard named for it fails, and passes with the fix restored: - drop the restartedAfterEmit guard -> 2 failed (incl. the strengthened old test) - revert trim() in the release gate -> 1 failed - revert F3 to write-if-missing -> 1 failed The pre-existing test "new message_id AFTER an emit" asserted finalize().ok === false but never checked what push() RETURNED — so it passed while F1 was live, documenting the leak instead of catching it. Strengthened to assert the emission, not just the verdict. Class B (ADR 0007, OCP-owned TUI spawn) — cli.js does NOT perform this operation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VqgWJcjxrjjL9L9SkpZyXR --------- Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
9f5bc3264a |
feat(tui): warm pane pool — single-use pre-booted panes, opt-in via OCP_TUI_POOL_SIZE (−41%) (#158)
* feat(tui): warm pane pool — single-use pre-booted panes, opt-in via OCP_TUI_POOL_SIZE Backlog item #3 of docs/plans/2026-07-13-tui-latency/README.md. Every TUI request currently cold-boots a tmux+claude pane. This adds an OPT-IN pool of pre-booted panes. Recorded as ADR 0008 (docs/adr/0008-tui-warm-pane-pool.md), which extends ADR 0007. MEASURED (this host, Sonnet 4.6, --effort low, through a real OCP instance; a sample counts only if HTTP 200 AND the body carries the demanded marker): pool off (main code) n= 6 p50 10.17s [9164 9499 9760 10572 10774 11281] pool on, warm hits n=12 p50 6.00s [5286 5289 5520 5584 5621 5969 6040 6098 6280 7846 8036 11053] pool on, warm hits (post- n= 6 p50 5.62s [4729 4753 5236 6004 7548 9548] review-fix re-run) -> -4.17s / -41%. 12 hits / 1 miss / 0 bootFailures over 13 requests (and 6/1/0 on the post-fix re-run). Robust to counting the miss: n=13 p50 -> -40.6%. The plan doc predicted only -1.0s (the boot). It is ~4.2s because the cold path also pays ~2.9s INSIDE the first turn beyond claude's own reported turn_duration — post- input-bar init that an idle pane has already finished. Phase decomposition of the cold path (n=6 medians): prep 2ms | tmux spawn 27ms | boot->input-ready 1232ms | paste 8ms | paste-verify 426ms | submit->terminal 8458ms | teardown 8ms = 10162ms total, vs native turn_duration 5539ms => 4490ms of OCP-side overhead, of which the pool recovers ~1.26s of boot and ~2.9s of in-claude cold start. (The 426ms paste-verify is one 400ms poll tick; a real paste lands in ~80ms. Not addressed here — separate item.) DESIGN - SINGLE-USE panes. A pooled pane serves exactly ONE turn, then is killed and replaced in the background. Each carries its OWN fresh --session-id fixed at boot, so one session still holds one exchange. This is what keeps transcript.mjs's extractLatestAssistantText correct; its warning about a future warm pool reusing a session is answered in-place (comment updated) and left standing for anyone who later wants a second turn on a pane — that would be a cross-request TEXT LEAK and needs user-line scoping in the transcript reader first. - Pool keyed by model; --model is fixed at spawn. A miss falls back to the cold path with zero behaviour change. The pool warms the most recently requested model, so the first request after start (and after a model switch) is always a cold miss. - REAPER COEXISTENCE (the crux). An idle warm pane IS ours, and the periodic sweep runs precisely when we are idle. reapStaleTuiSessions() takes a `spare` set of EXACT live session names, and server.mjs DRAINS the pool immediately before the sweep: 1. a live pooled pane is never reaped — INCLUDING one still BOOTING (see below); 2. an orphaned pooled pane IS still reaped — membership is by exact name from a live in-memory registry, never by name shape, so a pane from a dead process generation has nothing claiming it. Omitting `spare` reaps MORE, never less (fail-safe); 3. kill-server is suppressed while any pane is spared — hence the drain, so the sweep still flushes <defunct> claude zombies (the only mechanism that can). - THE POOL TRACKS ITS IN-FLIGHT BOOT BY NAME, NOT AS A COUNT. bootTuiPane creates the tmux session SYNCHRONOUSLY and only then waits up to POOL_BOOT_MS (20s) for the input bar, so a pooled session can be LIVE for ~20s before its boot resolves. Tracking boots as a count meant the pool could not name that session, which caused two real bugs (found in review, reproduced, fixed, and now regression-tested): * the reap sweep KILLED the booting pane (it could not be spared), left the pool empty with nothing scheduled, and logged the exact tui_pool_boot_failed WARN operators are told to alert on — for a completely healthy drain; * graceful shutdown ORPHANED a live authenticated idle `claude`: gracefulShutdown calls process.exit(0) in the SAME TICK as the drain (TUI panes are tmux children, so activeProcesses is empty and the wait-for-children path exits immediately), so cleanup deferred to a .then() never ran. Fix: the pool mints each pane's identity up front ({sessionId, name}) and holds it in _bootingPane. liveNames() includes it; drain() kills it SYNCHRONOUSLY. A generation counter distinguishes "cancelled by us" from "genuinely failed", so a drain never inflates bootFailures and resume() reliably starts a fresh boot. Deriving the name from the session-id also makes `tmux ls` correlate to the transcript file. - SLOT ACCOUNTING. Refill boots take NO TuiSemaphore slot (those bound real turns and would be starved); they cannot leak one either, since they never hold one. Refills are SERIALIZED, one boot at a time — live at size=2, two cold boots racing an in-flight turn overran the readiness cap and a refill was discarded. A genuinely failed boot does not re-kick the chain (backoff; a broken claude must not respawn forever). Background boots get a more generous readiness cap (POOL_BOOT_MS = 5x BOOT_MS): BOOT_MS is tight because a client is blocked on it, which is not true of a pre-boot. - BOUNDED COST. A warm pane is a LIVE idle claude process held whether or not a request arrives. Peak processes = pool size + OCP_TUI_MAX_CONCURRENT + 1 booting replacement. Size clamped to POOL_MAX_SIZE=4; garbage values disable rather than guess. Panes have a 10-min TTL and a health check at hand-out (dead/degraded pane => miss, never a hang). Missing collaborators throw at CONSTRUCTION, not on a live request (refill() is called synchronously from the request path). DEFAULT OFF (OCP_TUI_POOL_SIZE=0). This is a stable production path and the pool holds standing processes, so the operator opts in. With the pool off, runTuiTurn takes the IDENTICAL code path as before (the `pool ? pool.acquire() : null` branch yields null, and tuiPool is null so no observer is attached and no new log line is emitted) — that is what establishes the default path is unchanged. A pool-off control run (n=6, p50 9.40s) is consistent with the 10.17s baseline but had 2/6 samples >12s, so it is corroboration, NOT proof: n=6 cannot establish "unregressed" on its own. The code-path equivalence can. BANNER: NO SPAWN ARGUMENT CHANGED. buildTuiCmd is byte-identical to main (verified by extracting the function body from both revisions and comparing). Live banner captured from two real POOLED panes anyway: "Sonnet 4.6 with low effort · Claude Max" — the subscription pool, never "API Usage Billing". /health: `tui.pool` added (null when off), incl. `cancelled` (boots WE killed — not a fault; do not alert on it). The tui block is ADR-0007-owned and post-dates ADR 0006's v3.16.4 grandfather snapshot; the addition is purely additive — every pre-existing key keeps a byte-identical value. Authorization recorded in ADR 0008. ALIGNMENT: Class B / ADR 0007 + ADR 0008 (OCP-owned TUI spawn machinery). cli.js does NOT perform this operation — there is no cli.js citation and none is required: this is not an Anthropic API surface, it is OCP's own process management around the claude CLI, exactly as the existing tmux session lifecycle and reaper already are (ALIGNMENT.md Rule 2). TESTS: 294 passed / 0 failed (was 267). +27 covering acquire/hit/miss, single-use (a pane is never handed out twice), bounded + serialized refill, TTL + health-check drops, model retarget, drain/resume, boot-failure backoff, identity linkage, all three reaper invariants incl. post-drain kill-server restoration, and — the coverage gap that let both bugs ship — FIVE mid-boot tests: the booting pane is nameable/spareable, the sweep's drain kills it and resume starts a fresh boot with no bogus WARN, shutdown kills it synchronously (asserted WITHOUT awaiting, since process.exit runs in the same tick), a stale settle cannot clear a newer boot's slot, and a model switch cancels an in-flight boot for the old model. Live verification (temporary 20s reap interval, reverted): sweep drained both panes -> reaped -> refilled with NEW panes; a foreign tmux session survived untouched; with no foreign session kill-server fired and the pool still recovered and served the next request. Both review bugs reproduced against a PRIVATE tmux server (-L pr3repro, so the reaper's internal kill-server could not touch the host) before and after the fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(tui): kill a cancelled boot's pane when it settles + make async tests actually count Folds in the independent review's remaining nit — and, in proving the nit's fix, uncovers two defects in the test suite itself. ## The nit (latent M1b, second costume) `_cancelBooting` kills BY NAME, but the tmux session only EXISTS once `bootPane` has run — and `bootPane` is queued on a microtask. So a caller doing `refill()` then `drain()` in the SAME synchronous block leaves `_cancelBooting` with nothing to kill (a no-op); it bumps the generation, and the boot microtask then CREATES the session, succeeds, and — under the old bare `return` on a stale generation — walked away from a LIVE authenticated `claude` that nothing owns. Reproduced: reverted: drain() kills nothing (no session yet) -> boot creates it -> ORPHAN: ['p1'] fixed : drain() kills nothing (no session yet) -> boot creates it -> boot kills it -> [] Not reachable from any current call site, so this is defense-in-depth — but ADR 0008 and the reap-tick comment in server.mjs BOTH explicitly contemplate a boot-time pre-warm, which is exactly the shape that reaches it. Killing an already-dead session is a harmless no-op, so the fix is idempotent whichever way the race lands. ## Defect 1 in the suite: async tests were never awaited (44 of them) Writing the regression guard exposed this. `test()` called `fn()`, got a promise back, and IMMEDIATELY printed ✓ and incremented `passed` — without awaiting it. For all 44 tests written as `test("...", async () => {...})`: - ✓ meant "did not throw SYNCHRONOUSLY", not "passed"; - a failed assertion escaped as an unhandled rejection, crashing the process (CI stays red on the non-zero exit) but never being COUNTED — so the summary could print "0 failed" and be wrong. The suite's headline number was therefore not evidence for ANY async test, including this PR's own M1a/M1b guards. `test()` now settles an async body before counting it, and the summary awaits them. ## Defect 2, exposed the instant defect 1 was fixed: a false guard `"a boot that resolves AFTER a drain kills its own pane ... no orphan process left behind"` asserted `killed.length === 1` — i.e. that kill was CALLED once. But `_cancelBooting`'s kill-by-name on a not-yet-existent session is a NO-OP that still increments that counter. So "kill was called once" and "a live session is orphaned" were both true at the same time: a test named for the absence of an orphan was passing while the orphan was present. Now asserts LIVENESS (`live.size === 0`) — the only honest question. ## Evidence fix present : 295 passed, 0 failed, exit 0 fix reverted: 293 passed, 2 failed <- BOTH liveness guards fire (the old kill-count guard did not) Also: `dropped`'s doc comment now lists `cancelled` (a cancelled in-flight boot lands there via _drop). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VqgWJcjxrjjL9L9SkpZyXR --------- Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
e7ce9899f3 |
docs(plans): TUI streaming IS achievable (MessageDisplay hook) — prereq spike + honest latency constraints (#157)
* docs(plans): TUI streaming is not achievable — prereq spike result + honest README constraints Backlog #2 of docs/plans/2026-07-13-tui-latency demanded a prereq spike before any streaming design: does the transcript JSONL grow during a turn, or only at the end? The spike was run. All three candidate sources are dead: (a) transcript JSONL — grows at EVENT granularity; the assistant's text event is written as ONE complete line, ~0.3s before the terminal turn_duration event (observed: turn_duration 7319ms; text event at t+7.0s, terminal at t+7.3s). (b) tmux capture-pane — the pane is a RENDERED view, not the text. Same turn, transcript T = '## Semaphore\n\nA **semaphore** is a synchronization…' pane = '⏺ Semaphore' / ' A semaphore is a synchronization…' '## ', '**' and ```-fences are absent from the pane entirely (rendered to ANSI, then stripped by capture-pane -p). T.startsWith(paneText) is FALSE both raw and indent-stripped — not on redraw, but on essentially every markdown answer. capture-pane -e recovers styling, never source spelling: no unique inverse. (c) --debug-file — byte-exact ('last_assistant_message':'## Title\n\n**alpha…'), but only inside end-of-turn Stop-hook payloads; zero content_block_delta / text_delta events; ~2.7MB per turn. --output-format stream-json, the only interface emitting token deltas, requires -p — the metered-billing path TUI mode exists to avoid (cc_entrypoint=sdk-cli). The constraint is structural. OCP's TUI SSE is, and remains, replay-only. Also corrects this plan's own "~20s waiting for the whole turn" decomposition, which was inferred from an external 30-32s report and never measured through OCP. Measured through a real OCP instance (TUI, claude-sonnet-4-6, n=5): median 11.30s before #156, 9.55s after, vs a native turn_duration of ~7.3s → OCP's own overhead is ~2-4s, not ~20s. The remainder is generation time, which streaming would not shorten (it moves the first byte, not the last) — so a consumer needing the COMPLETE answer, which is the JSON-card case that motivated this work, would have gained nothing from streaming. Backlog #4 measured while here: --exclude-dynamic-system-prompt-sections gives ZERO marginal benefit (TTFT median 6.39s vs 6.17s for --effort low alone, n=5, one worse outlier). Do not adopt. Banner stayed on Claude Max. README: documents the ~6s TTFT floor plainly (TUI mode cannot serve interactive-latency consumers) and states that no-token-streaming is structural rather than a missing feature. No code change. No version bump (docs-only). Not endpoint-touching: no server.mjs diff, so no cli.js citation applies. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VqgWJcjxrjjL9L9SkpZyXR * docs: fold in adversarial review — correct the debug-log reasoning + the overhead number Independent adversarial reviewer (tasked with REFUTING this doc) confirmed the central claim — no byte-faithful incremental source exists on the TUI path — but found four factual defects in the prose. A negative claim that will be cited for years has to be right in its reasoning, not just its conclusion. 1. --debug-file: the "written at end-of-turn" reasoning was WRONG. The default log level is `debug`, which suppresses every `verbose` site; the original probe therefore ran with the stream logging OFF. At CLAUDE_CODE_DEBUG_LOG_LEVEL=verbose there ARE 16 mid-turn `[shoji-engine] yield stream_event/-` lines spread over ~3.9s of generation. The conclusion survives because those lines carry TIMING ONLY, no text payload (content_block_delta / text_delta / content_block_start / message_start = 0 at any verbosity or category filter). Reasoning rewritten: "logs when tokens arrive, never what they are" — as written before, the doc was falsifiable in 30 seconds. 2. The 7.319s `turn_duration` is NOT a "native" (non-OCP) baseline: it comes from an OCP-driven turn (cwd .ocp-tui/work, same 7451-char prompt, same 204-char answer as pr1 baseline row i=5, elapsed 11563ms). Reframed as what it actually is — a SAME-TURN decomposition, 11.563s wall - 7.319s CLI-internal = ~4.2s OCP overhead (n=1), which is a cleaner comparison than the doc originally claimed. 3. Dropped the "~2-4s" range: its low end mixed an effort-HIGH turn_duration with the effort-LOW wall-clock median, which understates overhead (a low-effort turn generates faster, so its own turn_duration would be lower). No turn_duration sample exists for the effort-low config. Now stated as ~4s (n=1, baseline config), with both caveats. 4. Softened "ZERO marginal benefit" (backlog #4) to "no benefit detectable at n=5" — n=5 cannot prove zero — and added the mechanistic reason the reviewer supplied, which is far stronger than the empirical null: `--help` says the flag improves cross-user prompt-cache REUSE, and OCP is single-user, so there is no cross-user cache to share. Also folded in the reviewer's independent sweep, which closes the search space rather than sampling it: the hook registry was enumerated from the shipped binary (no per-chunk / streaming hook exists among the 21 events); `capture-pane -e` was tested and shown to be a provably non-unique inverse (an H2 and a bold span emit IDENTICAL SGR 1); and sessions/<pid>.json, history.jsonl, CLAUDE_CODE_INCLUDE_PARTIAL_MESSAGES (undocumented), sessionMirror, --sdk-url and --input-format stream-json were each checked and each dies (contentless, or gated behind --output-format stream-json -> --print -> the metered sdk-cli pool). Prompt-mutation (asking the model for plain text) is named and rejected on ALIGNMENT grounds so it is not re-litigated later. Docs-only. No code change, no version bump. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VqgWJcjxrjjL9L9SkpZyXR * docs: REVERSE the streaming verdict — MessageDisplay hook makes it achievable The previous commit on this branch concluded TUI streaming was impossible. That was WRONG, and this corrects it before it could be merged. The adversarial reviewer commissioned to refute the claim found, on a second pass while verifying the fold-in, that its OWN first-pass hook enumeration had been truncated by a 400-char grep cap: it reported 21 hook events; the shipped 2.1.207 bundle has 30. Event #30 is MessageDisplay. Independently reproduced before acting on it (30 events confirmed via `strings` on the binary; payload shape `hook_event_name:"MessageDisplay",turn_id,message_id,index,final, delta`), then live-tested with a MessageDisplay command hook registered via --settings on a PLAIN INTERACTIVE TUI spawn (no -p, no --bare), claude-sonnet-4-6, --effort low: banner: "Sonnet 4.6 with low effort · Claude Max" ← subscription pool, verified 7 fires, mid-turn, spread across generation: index=0 final=false '## Mutex\n\n' index=1 final=false 'A **mutual exclusion lock** prevents concurrent access to a shar…' index=4 final=false 'let counter = 0;\n\nasync function increment() {\n const release =…' index=6 final=true '```' concat(deltas) === T (transcript-authoritative) -> TRUE (579 == 579 bytes) T.startsWith(S) at EVERY step -> TRUE (prefix-stable) '## ' / '**' / '```javascript' present in deltas -> raw markdown SOURCE, not rendered This satisfies every invariant the previous version declared unobtainable: byte-faithful, incremental, prefix-stable, no -p, subscription pool. Granularity is block-level (~5-7 chunks/answer), not token-level — which is all an SSE delta.content needs. Backlog #2 REOPENS and should be built. Implementer caveat recorded: the hook's source sets forceSyncExecution -> claude BLOCKS on it, so the hook must write and exit immediately (FIFO/socket), never work inline. Only text blocks fire it (thinking excluded). ALIGNMENT: consumes claude's OWN hook surface as emitted — forwarding, not inventing (Class B / ADR 0007; no cli.js citation applies). Everything still true is kept, and the dead ends are kept as dead ends (they document what NOT to build): the pane is a rendered view whose source markers are irrecoverable (capture-pane -e emits IDENTICAL SGR 1 for an H2 and a bold span — a provably non-unique inverse); the transcript is event-granular; --debug-file carries timing but no payload; --output-format stream-json requires -p (the metered pool). Also kept: the ~4s (n=1 same-turn) overhead correction, backlog #4's null result with its mechanistic single-user reason, and the honest value framing — streaming moves the FIRST byte, not the last, so the complete-answer consumer that motivated this work gains nothing from it. The wrong conclusion and its refutation are both preserved in the doc. "We checked, it's impossible" is the most expensive claim to get wrong: it closes a door nobody re-opens. Docs-only. No code change, no version bump. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VqgWJcjxrjjL9L9SkpZyXR * docs: fold in re-review — the two caveats that would have bitten the implementer Re-review of the reversed doc came back APPROVE_WITH_MINOR. The reversal itself was verified complete (worktree-wide grep: no surviving impossibility claim) and NOT over-claimed in the other direction (the reviewer recomputed every headline number from the committed messagedisplay-deltas.jsonl and re-ran the invariant on two further turns: 4 independent turns total, 5/6/4/18 fires, 609/696/239/1973 bytes, concat(deltas) === T TRUE in all four). But two additive caveats were missing, and both are load-bearing for the streaming PR now in flight: 1. CONCURRENCY DEMUX (severe, and live TODAY — not a warm-pool future problem). OCP_TUI_MAX_CONCURRENT defaults to 2, so two `claude` processes already run concurrently. One MessageDisplay hook writing to one shared sink would INTERLEAVE deltas from two different turns into a single stream — request A's client receiving request B's text. A single-request test never surfaces it. The payload carries session_id, so the sink must be keyed by it (which also keeps the design warm-pool compatible: a pre-booted pane's session-id is fixed at boot, so one static hook script serves every pane). Documented, with the required ≥2-concurrent-request test. 2. THINKING-EXCLUSION IS NOT STRESS-TESTED (severe if wrong). The exclusion was inferred from a code snippet that turns out to be the final:true call site, not the incremental one. Four live turns showed no thinking in any delta — but every transcript's thinking block was EMPTY (thinking:"", 0 chars), so it was never actually stressed. If thinking deltas do fire on Opus/xhigh, concat(deltas) !== T AND OCP streams the model's private reasoning to the caller; the concat === T assertion detects that but cannot un-send an SSE delta. Flagged as a must-verify-before-shipping item. Also: the "5-7 chunks per answer" figure is size-dependent (18 fires on a ~2 KB answer) — rescoped to "once per rendered block, scales with answer length" in both the doc and the README, so no implementer hard-codes a chunk-count assumption. Both caveats were relayed to the streaming implementation immediately rather than waiting for this merge. Docs-only. No code change, no version bump. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VqgWJcjxrjjL9L9SkpZyXR --------- Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
5258d5d395 |
feat(tui): pin spawn effort via OCP_TUI_EFFORT (default low) (#156)
* feat(tui): pin spawn effort via OCP_TUI_EFFORT (default low) buildTuiCmd never passed --effort, so the pane's claude inherited a HOME-dependent effortLevel: real-home mode inherits the operator's ~/.claude/settings.json (high/xhigh on typical operator hosts), env-token scratch mode inherits claude's built-in default — proxied-turn latency silently depended on which HOME mode resolveTuiHome() picked and on an unrelated operator setting. Pass --effort explicitly, from new env var OCP_TUI_EFFORT (default "low"; allowlist low|medium|high|xhigh|max per `claude --help` 2.1.207; "inherit" restores the pre-flag argv byte-for-byte; an invalid value warns and falls back to "low" so a typo can never reach the pane argv). Not endpoint-touching: no server.mjs change, no wire-level change — the flag rides the existing interactive spawn (ADR 0007). Billing-pool safety verified per the docs/plans/2026-07-13-tui-latency banner protocol: startup banner stays "Claude Max" with "low effort". Measured through a test OCP instance (:3979, TUI mode, real-home, claude-sonnet-4-6, n=5+5, same ~1850-token prompt as floor.sh): before: median 11.30s range 9.05-12.38s (spread 3.32s) banner: high effort - Claude Max after: median 9.55s range 9.27-9.77s (spread 0.50s) banner: low effort - Claude Max OCP_TUI_EFFORT=inherit: banner back to "high effort" (pre-flag behavior restored) README: new row in the Environment Variables table (release_kit new_feature_doc_expectations: new env var -> README table). Tests: 4 new buildTuiCmd cases (default, explicit level, inherit, invalid fallback); suite 267 passed / 0 failed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VqgWJcjxrjjL9L9SkpZyXR * docs(readme): review nit — 'pre-v3.22' → 'pre-flag' (next version not fixed yet) Reviewer nit from the Iron Rule 10 independent review of PR #156. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VqgWJcjxrjjL9L9SkpZyXR --------- Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
6854075c01 |
docs(plans): TUI-mode latency floor — measured decomposition + backlog (#155)
* docs(plans): TUI-mode latency floor — measured decomposition + backlog An external consumer measured OCP's prompt path at TTFT p50 30-32s and excluded OCP on that basis. This documents where those 30 seconds actually go, with a reproducible harness (n=15) that bypasses OCP and measures the underlying subscription path's true first-token time. Findings: - boot -> input-ready is only ~1.0s; it is NOT the bottleneck - true TTFT is 6-10s; the remaining ~20s is runTuiTurn polling the transcript until turn_duration (ADR 0007 step 4) — i.e. waiting for the WHOLE turn. There is no streaming. - buildTuiCmd never passes --effort, so the spawned claude inherits the operator's global effortLevel (xhigh on this host) — every request runs extended thinking. Passing --effort low: TTFT p50 9.70s -> 6.17s (-36%), spread 7.85-13.07s -> 5.87-6.44s. Stays on Claude Max. - ⚠️ --bare SILENTLY drops off the subscription pool (banner flips 'Claude Max' -> 'API Usage Billing'). It does cut boot to ~0.5s, but defeats the entire purpose of ADR 0007. Failure is silent: all 5 --bare samples produced no answer at all (no error, no crash, just never a token). Anyone optimizing boot MUST diff the banner line. - Floor after all fixes is ~6s (claude always injects the full CC system prompt + tool definitions). TUI mode therefore cannot serve real-time consumers — a constraint worth stating in the README. Backlog ranked by value/effort: (1) OCP_TUI_EFFORT env var, default low; (2) real streaming instead of turn_duration polling (~20s, the big one); (3) warm pane pool (~1s); (4) prefill trim (probably not worth it). Docs-only; no version bump (matches repo convention — bump lands in the chore(release) commit). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dx5Ncq6wWBrF27vJKHZ9Hr * docs(plans): address review — restore --bare evidence, qualify effort claim, add banner captures Reviewer (fresh-context, Iron Rule 10) returned REQUEST_CHANGES. All four technical conclusions survived independent verification (source-read + live repro); the defects were in the evidence file, and they were real: - H-1: measurements.jsonl claimed n=15 but held 10 rows, and the --bare group — the basis of this PR's headline warning — had ZERO rows. The author had stripped them as 'invalid samples' (ttft_ms:-1) when they were in fact the evidence. Regenerated: n=15, three groups × 5, all with tag/extra_args. --bare reproduces exactly (5/5 no answer, boot 0.43-0.45s). - M-1: the effort-inheritance claim was written unconditionally, but it depends on resolveTuiHome()'s mode. Real-home (current service config) inherits the operator's effortLevel: xhigh; env-token scratch home (~/.ocp-tui/home) has no effortLevel in its settings.json and prepareTuiHome() never writes one, so the pane gets claude's built-in default. Now documented as a table — and the mode split makes passing --effort explicitly MORE valuable, not less. - M-2: baseline rows were produced by a pre-parameterized script and lacked tag/extra_args. Re-run with the committed script. Recomputed effect: -40% (was -36%). - L-1: documented that the harness suppresses OCP's periodic kill-server tick via the othersRemain coexistence guard (by design, resumes next tick). - L-2: documented that floor.sh's readiness marker differs from OCP's tuiInputReady(), so the ~1.0s boot figure is not apples-to-apples with BOOT_MS. - Direct-API reference figure now explicitly labeled as external (not in this dataset). - New: billing-banner.txt captures all three configs live, including confirmation that --effort low stays on Claude Max (reviewer noted this was asserted but unevidenced). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dx5Ncq6wWBrF27vJKHZ9Hr * docs(plans): scope the effort claim to TUI mode (currently off), drop nonexistent bin/ Re-review (APPROVE_WITH_MINOR) caught two accuracy defects: - MIN-1: 'every OCP request runs extended thinking' over-extrapolated. TUI mode is currently OFF on this host (CLAUDE_TUI_MODE=false; /health tui.enabled=false), so live traffic takes the -p path. The claim is about what happens WHEN TUI mode is enabled — now scoped, and the same qualifier applied to the kill-server interaction note (that reap tick is itself gated on TUI_MODE). - NIT-2: the quoted grep included bin/, which does not exist in the repo (exit 2). Dropped; the zero-hit result over lib/ + server.mjs is unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dx5Ncq6wWBrF27vJKHZ9Hr --------- Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
45152d58b0 |
chore(release): v3.21.1 — concurrency queue + spawn-token + TUI session-scope fixes (#151)
Patch release bundling three merged bug fixes (no new cli.js wire behavior, no new endpoint/header/env var): - #148 fix(tui): session prefix + reap/kill-server scoped per-instance by port - #150 fix(server): serialize -p real-HOME token fallback behind a mutex + 30s TTL keychain read cache + de-staled isolation decision (new lib/spawn-auth.mjs) - #149 fix: semaphore honors runtime-lowered maxConcurrent, queued requests cancelled on client disconnect, singleflight follower retry on leader disconnect, exact queued accounting, quiet disconnect handling Release-kit walk (CLAUDE.md Iron Rule 5.5): package.json version bump, CHANGELOG.md entry, one-sentence Troubleshooting note for the genuinely operator-visible upgrade-overlap caveat from #148. No changes to models.json / Available Models / API Endpoints / Environment Variables tables — verified by diffing all three merged commits (2922d68..d96da46); none add an endpoint, header, or env var. Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
d96da46fa0 |
fix: honor runtime-lowered concurrency limit + cancel queued waiters on disconnect (#149)
* fix: honor runtime-lowered concurrency limit + cancel queued waiters on disconnect Fixes three findings from an independent concurrency audit of the -p/stream-json wait-queue (lib/tui/semaphore.mjs, reused by server.mjs as `claudeSemaphore`) and its acquireClaudeSlot()/callClaudeTui() callers in server.mjs: F1 (MEDIUM) — release() handed a freed slot straight to the next queued waiter without re-checking `this.limit`, so a PATCH /settings maxConcurrent decrease was silently ignored until every already-inflight task happened to finish on its own. release() now only re-grants when post-decrement inflight is still under the current limit, and a new setLimit() wakes queued waiters immediately when the limit is raised instead of only on the next incidental release(). F2 (MEDIUM) — a request queued behind the concurrency limit had no link to its HTTP connection, so a client that disconnected while still queued would still get a claude process spawned for it once a slot freed — burning subscription quota for a dead socket. acquire() now accepts an optional AbortSignal; server.mjs derives one from the client's res "close" event (closeSignalFor) and passes it into claudeSemaphore.acquire() / tuiSemaphore.acquire() while queued. On abort the waiter is spliced out of the queue (not just flagged), so `queued` accounting stays exact; the same "close" signal is wired into acquireClaudeSlot() (-p path, non-streaming + streaming + singleflight-wrapped) and callClaudeTui() (TUI path). If the response is already destroyed by the time we try to queue, we reject immediately without ever entering the queue. F8 (cosmetic) — acquireClaudeSlot() set `stats.queued = claudeSemaphore.queued + 1` BEFORE calling acquire(), over-reporting /health's queued count by 1 whenever a slot was granted immediately (the common, non-queued case). acquire() already updates its internal queue synchronously before returning a Promise, so reading claudeSemaphore.queued right AFTER calling it (instead of guessing "+1" before) is exact. No /health field was added, removed, or renamed. ALIGNMENT.md: this PR touches request-handler code (callClaude, callClaudeStreaming, callClaudeTui, acquireClaudeSlot) but is local concurrency-control/queue-accounting infrastructure with no cli.js wire analogue — it does not add, rename, or change any endpoint, header, request field, or response field, and does not touch the /v1/messages forwarding path or the OAuth bearer machinery (the two Class A surfaces this repo governs). The /health response shape is unchanged (same field set, same nesting; only the *value* of the pre-existing `stats.queued` field is corrected). Per CLAUDE.md hard-requirement #1, a cli.js citation is therefore declared ABSENT: there is no corresponding cli.js operation to cite because this is not a cli.js-mirror (Class A) change and not a Class B endpoint-contract change either. Tests: added 6 unit tests to test-features.mjs against the shared TuiSemaphore (lowering the limit mid-load does not over-admit; raising the limit wakes queued waiters up to the new headroom, FIFO; a queued waiter cancelled via AbortSignal is spliced out and never later acquires; an already-aborted signal never touches the queue; cancelling one of several queued waiters preserves FIFO for the rest). 238 pre-existing tests remain green; suite is now 244/244. Verification: `node --check server.mjs && node --check lib/tui/semaphore.mjs && npm test` — 244 passed, 0 failed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: singleflight follower retry on leader disconnect + quiet disconnect handling (review M1/L1/L2) Addresses the independent reviewer's APPROVE-WITH-CHANGES findings on PR #149: M1 (MEDIUM, F2 regression) — when a singleflight LEADER disconnected while queued, its RequestDisconnectedError rejected the SHARED promise, so live followers fell into respondUpstreamError's generic branch and got a spurious 500 on a healthy socket. Fix: keys.mjs singleflight() gains an optional follower-side `retryIf` predicate. When a follower joins an existing flight and the shared promise rejects with an error retryIf() accepts, the follower does NOT inherit the rejection — it re-enters singleflight with its OWN fn (the map entry is guaranteed already deleted: the delete-finally is attached upstream of the promise followers await), becoming the new leader or joining a retrying sibling's fresh flight. The leader's own rejection is never retried (it IS that client's disconnect). server.mjs passes retryIf = (err) => err instanceof RequestDisconnectedError && !res.destroyed, so a follower whose own client is also gone still propagates quietly. Callers without retryIf keep byte-for-byte pre-existing share-everything semantics (pinned by the existing failure-fan-out test). L1 (LOW) — a disconnect-while-queued on the non-streaming paths was recorded as a usage FAILURE row and logged as a [proxy] error: metric noise for a non-error. Both non-streaming catch blocks now early-return on RequestDisconnectedError without recordUsage(success:false) and without console.error — mirroring the streaming path, which returns silently. The disconnect remains observable at info level (concurrency_wait_cancelled, now also emitted with path:"tui" from callClaudeTui for parity with acquireClaudeSlot's -p log). L2 (LOW, test gap) — added a unit test for the abort-after-grant race: a waiter granted its slot whose signal aborts afterward must see no rejection, no queue corruption, and its slot released exactly once via the normal path (the semaphore detaches the abort listener at grant; the onAbort idx===-1 guard is the in-dispatch backstop). Tests: +3 (2× M1 in the singleflight section, 1× L2 in the F2 section) — suite is now 247/247 green. ALIGNMENT.md: unchanged declaration — still local concurrency/dedup infrastructure with no cli.js wire analogue; no endpoint, header, request field, or response field added or changed; /health shape untouched. cli.js citation declared ABSENT per CLAUDE.md hard-requirement #1 (not a Class A mirror change, not a Class B contract change). Verification: node --check server.mjs lib/tui/semaphore.mjs keys.mjs && npm test — 247 passed, 0 failed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
38da104b97 |
chore(release): v3.21.0 — TUI cleanup + client-tools/ToS docs + promotion plan (#145)
* refactor(tui): dead-code / footgun cleanup (A1/A2/A3)
ALIGNMENT.md Rule 2: infra-only, no new cli.js wire behavior.
No protocol, no endpoint, no credential changes.
A1 (session.mjs): delete resolveTuiEntrypointEnv() and its call site
+ the redundant env-strip block around the spawnSync call. The function
mutated a {env} object passed to spawnSync (tmux itself), but tmux does
NOT forward that env to the pane; the pane's claude gets its env ONLY
from the `env` prefix string built inside buildTuiCmd (verified live
2026-06-01). The spawnSync {env} is intentionally minimal now; only
env.HOME is retained (tmux binary reads it). All claude-specific vars
go via the buildTuiCmd prefix string, unchanged. Tests for the now-
deleted function removed; test count drops by 7 (expected).
A2 (transcript.mjs): delete encodeCwd() and transcriptPath() exports.
Production resolves transcripts exclusively via findTranscriptPath()
(glob by session-id); these two helpers carried a fragile path-encoding
rule used only by their own tests. grep confirms zero non-test importers.
Added a TODO comment near findTranscriptPath() noting a CI fixture-
contract test would make claude-schema drift fail loudly. Tests removed;
count drops by 2.
A3 (session.mjs + README): remove the CLAUDE_SKIP_PERMISSIONS branch
that pushed --dangerously-skip-permissions when OCP_TUI_FULL_TOOLS=1.
OCP_TUI_FULL_TOOLS=1 now always takes the --allowedTools path. Rationale:
claude v2.1.x shows an interactive bypass-acceptance screen that a
headless tmux TUI cannot answer — bricks the turn (tui_paste_not_landed
/ wallclock cap), not recoverable without a human. The working path is
--allowedTools + scratch-home settings.json additionalDirectories.
README OCP_TUI_FULL_TOOLS row updated to document the removal and the
correct alternative; CLAUDE_SKIP_PERMISSIONS row for the -p path is
unchanged (still used in server.mjs). Test updated: skip-permissions
case replaced with an assertion that --dangerously-skip-permissions is
absent from the full-tools command.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* docs: client-tools boundary, ToS honesty, promotion plan (B1/B2/B3)
ALIGNMENT.md Rule 2: docs-only, no new cli.js wire behavior.
No protocol, endpoint, or credential changes.
B1 (README): add 'Client-tools boundary' subsection under 'How It
Works'. Documents that OCP is a text-prompt bridge only — it does not
pass OpenAI tools/functions or Anthropic tool_use blocks to the client.
Clients receive assistant TEXT only; client-local tool execution is
not supported by design (bypassing cli.js = out of scope per
ALIGNMENT.md).
B2 (README): two updates to 'Why OCP?' and the LAN-sharing section.
(a) New bullet: OCP drives the official claude CLI as-is — no OAuth
token extraction, no binary patching, no protocol invention — so
traffic looks like genuine Claude Code (cc_entrypoint=cli).
(b) LAN-sharing paragraph strengthened: pooling one Claude subscription
across multiple distinct people may violate Anthropic's Consumer ToS
and risk account suspension by the abuse classifier. The defensible
framing is 'one person, your own devices'; friends/team sharing is
not. Replaces the softer 'account terms are your call' language.
Feature and auth-mode docs are unchanged.
B3 (docs/PROMOTION.md): new promotion strategy doc. Covers: goal
(polish + low-key OSS visibility, NOT growth-hacking given the live
ToS/billing risk), pre-requisites (stability first), honest ToS
disclosure requirement, items explicitly skipped (multi-backend
routing, gateway model-discovery — delegated to OLP; raw API
passthrough — ALIGNMENT.md scope), TUI toggle as billing-split
insurance, and low-key visibility actions. Framed as a recommendation
for the maintainer to review, not a committed plan.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore(release): v3.21.0 — TUI cleanup + docs honesty + promotion plan
ALIGNMENT.md Rule 2: release prep; no new cli.js wire behavior.
Bump version 3.20.1 → 3.21.0. CHANGELOG entry covers:
- A1/A2/A3 TUI dead-code removals (inert entrypoint-env path,
test-only transcript helpers, headless-unusable skip-permissions)
- B1/B2/B3 docs (client-tools boundary, ToS honesty, Why-OCP posture,
promotion plan)
- Previously-shipped v3.20.x items documented for completeness:
spawn-home isolation, bounded concurrency queue + 429, ocp restart,
ocp-plugin OpenClaw compat.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.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> |
||
|
|
3bd19956ff |
docs(readme): honest ToS framing for LAN sharing (#136) (#143)
External user (#136) reported the LAN setup prompt instructs Claude to "share my Claude Pro/Max subscription with family" — which Claude Code refuses (Anthropic Usage Policy: per-user accounts). Reword the LAN setup prompt to "my own devices ... reach my subscription via a local OpenAI-compatible endpoint" (no longer triggers the refusal), and add an honest "account terms are your call" note to the existing sharing-limits section. Keeps the truthful origin story and the existing honest security-limits framing. Docs only. No server.mjs / Class A surface / models.json touched. Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude <claude-opus> <noreply@anthropic.com> |
||
|
|
fe615cb0d3 |
chore(release): v3.20.1 — TUI credential-isolated auth (ends recurring 401) + defunct-session reaping (#141) (#142)
Bump 3.20.0 → 3.20.1 + CHANGELOG. Ships the already-merged, twice-reviewed #141 (credential-isolated env-token home + zombie reaping). README/docs updated in #141. Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude <claude-opus> <noreply@anthropic.com> |
||
|
|
60930f0ba4 |
fix(tui): credential-isolated env-token auth (ends recurring 401) + reap defunct sessions (#141)
* fix(tui): pass CLAUDE_CODE_OAUTH_TOKEN to spawned claude + reap defunct sessions
Root cause (PI231 incident): tmux does not forward the parent's env to the
pane, so the TUI claude never saw CLAUDE_CODE_OAUTH_TOKEN and fell back to
~/.claude/.credentials.json, whose single-use refresh token got corrupted to an
empty string by the per-request spawn + kill-session teardown racing claude's
token rotation -> permanent "Please run /login" 401 (re-login re-corrupted on
the next spawn). Connected leak: the pane's claude is a child of the tmux server
(not node), so kill-session left <defunct> zombies the server never reaped (25
over 30 days; tmux kill-server dropped it 25->3).
Fix 1: buildTuiCmd now adds CLAUDE_CODE_OAUTH_TOKEN=<shq-escaped> to the pane
env prefix when the env is set, so claude authenticates via the long-lived token
and never touches the credentials.json refresh path (matching stable hosts).
Unset -> no token added (credentials.json-only hosts unaffected).
Fix 2: reapStaleTuiSessions kill-servers after clearing our own sessions ONLY
when no foreign tmux session remains (never disrupts a co-hosted olp-tui-*).
kill-server is the only node-reachable action that ACTUALLY reaps -- server exit
reparents survivors to init, which waitpids them; a per-session kill cannot,
since node is not the zombies' parent. Added a 15-min periodic reap (server.mjs)
gated on TUI_MODE and on the TUI path being idle. Residual: a request whose pane
is created in the idle-check/kill-server window fails cleanly via the existing
honesty gates (documented).
ALIGNMENT: Class B (OCP-owned TUI spawn). cli.js does NOT perform either
operation -- there is no cli.js analogue for "how the TUI pane authenticates" or
"reaping tmux-server-owned zombies"; authorized by ADR 0007 (PR-C amendment) per
ALIGNMENT.md's Class B citation requirement. No Class A wire surface, no endpoint
shape, no alignment.yml token, and no models.json entry touched.
Tests: +6 in test-features.mjs (buildTuiCmd token set/unset/shq-injection;
reaper kill-server ours-only / foreign-present / no-server). 241 passed, 0 failed.
Co-Authored-By: Claude <claude-opus> <noreply@anthropic.com>
* fix(tui): isolate TUI auth to env-token-only home (no credentials.json shadowing)
Passing CLAUDE_CODE_OAUTH_TOKEN to the spawned interactive `claude` (commit
|
||
|
|
c86e3d014f |
chore(release): v3.20.0 — TUI billing-safety hardening for 2026-06-15 (PR-A/B/C, #137-139) (#140)
Bump 3.19.0 → 3.20.0 + CHANGELOG. Aggregates three already-reviewed, already-merged PRs: - #137 (PR-A) TUI honesty/cache correctness - #139 (PR-B) TUI concurrency limit + /health observability - #138 (PR-C) 6/15 canary + flip/rollback runbooks + setup auth-probe guard README env-var / endpoint tables were updated in the respective feature PRs. Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude <claude-fable-5> <noreply@anthropic.com> |
||
|
|
3322d7bdae |
feat(tui): per-path concurrency limit + /health observability (PR-B) (#139)
Two P1 audit fixes for the TUI subscription-pool bridge (ADR 0007). The
default path (CLAUDE_TUI_MODE unset) is unchanged except the additive
/health `tui` block (enabled:false when off).
C-4 — TUI path had NO concurrency bound. The global MAX_CONCURRENT gate
lives in spawnClaudeProcess (the -p/stream-json path); callClaudeTui never
calls it — it calls runTuiTurn, which cold-boots a full interactive claude
in tmux. So N concurrent TUI requests spawned N simultaneous cold boots (a
family burst of 5 on a Pi 4 = OOM risk + subscription rate-limit pressure).
Adds an independent in-process limiter (lib/tui/semaphore.mjs, TuiSemaphore)
gating callClaudeTui: OCP_TUI_MAX_CONCURRENT (default 2 — a TUI turn is heavy:
per-request cold-boot + up to 120s wallclock). Queues rather than rejects
(mirrors MAX_CONCURRENT intent), with a bounded wait queue (default 32x the
limit) → tui_queue_full (503) on overflow rather than unbounded growth. The
slot releases in a finally, so PR-A's honesty-gate throws / timeouts / paste
failures never leak a slot.
C-5 — no operator-visible TUI drift surface. The tui_entrypoint_mismatch
warning only reached journald; after the 6/15 flip a silent sdk-cli drift
(the documented top risk) would drain metered credits invisibly. Adds an
additive `tui` block to /health: { enabled, entrypointMode, lastEntrypoint,
entrypointMismatches, inflight, queued, maxConcurrent }. lastEntrypoint /
entrypointMismatches are recorded in callClaudeTui (same mismatch branch the
journald warning covers); inflight/queued come from the C-4 semaphore.
ALIGNMENT (Class B): cli.js does NOT perform this operation — both the TUI
path and /health are OCP-owned, so no cli.js citation applies. /health is a
grandfathered B.2 endpoint (ADR 0006, frozen at v3.16.4). The response-shape
change is authorized by the ADR 0007 PR-B amendment added in this commit and
is behaviour-preserving: the `tui` block is NEW fields only — no existing
/health field is changed, renamed, removed, or re-typed, and no existing
semantics change, so existing consumers (dashboard, ocp-connect, monitoring)
are unaffected. Per ALIGNMENT.md's grandfather provision, an additive
behaviour-preserving change to a grandfathered B.2 endpoint is authorized by
an ADR; ADR 0007 is the authority for the TUI observability surface. No
Class A forwarding path, no alignment.yml, no models.json touched — alignment
blacklist is unaffected (zero new network tokens).
Tests: 11 new (lib/tui/semaphore.mjs is importable, so the semaphore + the
two pure /health helpers are tested directly): limit=1 serializes two
overlapping calls; limit=2 runs two + queues the third; slot released on
throw; bounded queue → tui_queue_full; mismatch counter increments on
cli→other drift; auto mode never counts a mismatch; /health tui block shape
+ live inflight/queued. npm test: 235 passed, 0 failed (was 224).
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude <claude-opus> <noreply@anthropic.com>
|
||
|
|
79c1d61e1d |
docs(tui): 6/15 canary + flip/rollback runbooks + setup auth-probe guard (PR-C) (#138)
Docs scope (no server.mjs touched): - docs/runbooks/615-canary.md — executable canary runbook: quiesce host, read Agent SDK credit balance manually (no programmatic API exists for that pool — this is stated explicitly to avoid endpoint hallucination), send one Haiku turn via TUI-mode, confirm cc_entrypoint=cli in transcript, re-read balance, green/red decision tree, periodic self-classification mini-canary with OCP_TUI_ENTRYPOINT=auto. - docs/runbooks/tui-flip-rollback.md — flip and rollback procedure for systemd (EnvironmentFile + daemon-reload) and launchd (plist edit + bootout/bootstrap cycle). Calls out both known pitfalls explicitly: daemon-reload required on systemd; launchctl kickstart -k does NOT reload plist env on launchd (matches MEMORY.md operational pit entry). - README.md § "2026-06-15 operator checklist" — brief fleet checklist + pointers to both runbooks. Placed inside the existing TUI-mode section just before "Architecture and design decisions". - README.md § Environment Variables — adds OCP_SKIP_AUTH_TEST row. Operator-tooling scope (setup.mjs, not a Class A wire path): - setup.mjs auth quick-test: wraps the existing claude -p probe in an OCP_SKIP_AUTH_TEST=1 gate; adds inline comment warning that after 2026-06-15 the probe draws from the Agent SDK credit pool. The setup flow is unchanged when OCP_SKIP_AUTH_TEST is unset. Alignment note: this PR does not touch server.mjs. The setup.mjs change is operator-tooling only — it guards a local test spawn, not any wire path. No cli.js citation required (no Class A surface is modified). Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude claude-sonnet-4-6 <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>
|
||
|
|
6d4751f983 |
feat(tui): OCP_TUI_FULL_TOOLS gate — -p-equivalent tools for single-user TUI (#135)
* feat(tui): OCP_TUI_FULL_TOOLS gate — -p-equivalent tool surface for single-user TUI Lets a SINGLE-USER / trusted TUI deployment run a tool-using / MCP agent (e.g. an OpenClaw assistant) on the subscription pool. When OCP_TUI_FULL_TOOLS=1, buildTuiCmd grants the interactive session the SAME tool surface as the -p A-path — --allowedTools (+ optional --mcp-config / --dangerously-skip-permissions, read from the same CLAUDE_ALLOWED_TOOLS / CLAUDE_MCP_CONFIG / CLAUDE_SKIP_PERMISSIONS env as buildCliArgs) — instead of the default MCP-walled, built-in-tools-only set. Motivation: the default TUI tool wall (--strict-mcp-config --disallowedTools mcp__*) exists for multi-tenant safety, but it also blocks a trusted single-operator agent from its MCP tools — forcing tool-using agents onto the metered -p pool after 2026-06-15. This gate resolves that for the single-user case. Safe to gate ON only because TUI is hard-incompatible with AUTH_MODE=multi (server.mjs refuses to boot, see existing guard), so this can NEVER widen a guest's tool surface. Default (gate unset) is unchanged: MCP wall + built-in tools only. Verified live on PI231 through the real OCP TUI path (spike 2026-06-02): - built-in tools: claude created + read a file on the host (2 tool_use entries); - MCP: claude invoked a custom MCP server's tool (mcp__spike__echo_marker) and returned its output; - billing: entrypoint=cli (subscription pool) WITH full tools + MCP; - completion: end_turn, no permission stall, no hang. Tests: +1 (full-tools branch: --allowedTools present + MCP wall dropped; skip-permissions supersedes; mcp-config threaded). 195 pass. README: new OCP_TUI_FULL_TOOLS env var entry. ALIGNMENT.md: changes lib/tui/session.mjs, not server.mjs — server.mjs hard requirements do not trigger. Mirrors buildCliArgs() permissions logic; the flags are documented Claude Code CLI flags, not invented endpoints. cli.js citation N/A under Rule 2. ADR 0007 (A-path). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tui): shq operator tool tokens in full-tools shell string (reviewer Finding 2) Independent reviewer (APPROVE WITH CHANGES) caught that buildTuiCmd returns a SHELL STRING (run by tmux via sh -c), unlike buildCliArgs which returns an argv array to spawn(). Operator-supplied CLAUDE_ALLOWED_TOOLS can be a scoped specifier such as "Bash(npm run test:*)" or "Read(~/**)", whose ( ) * ~ would break / inject the shell command if pasted bare. Now shq() each allowed-tool token (operator-self-injection only — guests cannot reach TUI per the multi-mode boot guard, but it's a real correctness bug). Also: tightened the README OCP_TUI_FULL_TOOLS wording per reviewer Finding 1 — the precise safety property is "no guest key can reach the TUI path (multi-mode boot is a hard exit)", and noted the AUTH_MODE=shared + OCP_TUI_ALLOW_LAN trust model is unchanged. Test: +scoped-specifier assertions (token shq'd; never appears unquoted). 195 pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
0dced52215 |
chore(release): v3.19.0 — TUI large-prompt reliability (#130) + proxy-purity context (#4) (#134)
Release prep only: bump package.json 3.18.0 → 3.19.0, CHANGELOG entry, README TUI "what changes / what doesn't" note that the host CLAUDE.md/auto-memory is never injected. Bundles two already-merged, independently-reviewed TUI fixes: - #130 (PR #131): reliable large multi-line paste + version-robust turn detection. - #4 (PR #132): never inject host CLAUDE.md / auto-memory into proxied turns. Both verified live on PI231 + Oracle; adversarial multi-host battery passed (0 hangs / 0 crashes / 0 injection / 0 leaks). Follow-up: #133. No server.mjs change in this PR (package.json + CHANGELOG + README only) → cli.js citation N/A. release_kit (Iron Rule 5.5): version_source=package.json, changelog, README docs all updated; no new env var / endpoint / subcommand. Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
d291331998 |
fix(tui): never inject the host's CLAUDE.md / auto-memory into proxied turns (#4) (#132)
OCP is a PROXY, not a Claude Code session. The proxied client (OpenClaw / an IDE) owns its own context and memory — the HOST's CLAUDE.md and auto-memory must never leak into the agent OCP runs on the user's behalf. buildTuiCmd now adds CLAUDE_CODE_DISABLE_CLAUDE_MDS=1 + CLAUDE_CODE_DISABLE_AUTO_MEMORY=1 to the pane-command env-prefix, unconditionally (proxy purity is not an opt-in). Diagnosis (per Iron Rule 3, diagnose-before-fix) of the earlier "suppression works in recon but not through OCP" gap: it was NEVER an env-delivery bug. The hosts simply have no CLAUDE.md to suppress — PI231 has no ~/.claude/CLAUDE.md, no ~/CLAUDE.md, no ~/.claude/memory, no cwd/walk-up CLAUDE.md. The earlier "recon worked" run was on a machine that DID have a global CLAUDE.md. Different machines, different CLAUDE.md presence — not a code gap. The ~35K one-line-prompt context is therefore the inherent floor (interactive system prompt + built-in tool schemas; MCP is already hard-disabled via --strict-mcp-config), not CLAUDE.md. Verified live 2026-06-02 with a behavioral marker (Iron Rule 2, evidence-first): - Planted /home/tlab/.ocp-tui/work/CLAUDE.md = "end every reply with QUACKMARKER_42". - BEFORE fix, through OCP: "Reply with: hello" -> "hello\n\nQUACKMARKER_42" (host CLAUDE.md obeyed = leaked). - AFTER fix, same marker file present, through OCP: -> "hello" (marker blocked). This proves both that the host CLAUDE.md was being injected AND that the env-prefix delivery of the disable flag reaches claude and stops it. Tests: buildTuiCmd is now exported; +2 regression guards (suppression present; version-pin/entrypoint/MCP-wall retained, auto-mode leaves entrypoint unset). 194 pass. Scope (Iron Rule 11): TUI path only. The -p path has an equivalent but GATED mechanism (CLAUDE_NO_CONTEXT). Making -p unconditionally proxy-pure is a separate decision, noted as a follow-up, not bundled here. ALIGNMENT.md: this changes lib/tui/session.mjs, not server.mjs, so the server.mjs hard requirements do not trigger. TUI is the ADR-0007 proxy-side execution bridge; the two flags are documented Claude Code env vars (the same two the -p path already uses), not invented endpoints — cli.js citation N/A under Rule 2. Closes #4 (TUI portion). Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
9568411bcb |
fix(tui): reliable large-prompt paste + version-robust turn detection (#130) (#131)
TUI mode hung ("stuck typing") on the OpenClaw agent's real (large, multi-line) prompts.
Three root causes, all fixed:
1. transcript.mjs — terminal detection only recognized {system, turn_duration}, which
claude 2.1.114 (the cloud host) does not emit → readTuiTranscript never saw completion
and ran to the 120s wallclock even though claude had answered. Now also treats an
{assistant} line with message.stop_reason ∈ {end_turn, stop_sequence, max_tokens} as
terminal (version-robust; stop_reason "tool_use" stays non-terminal, so tool turns are
not truncated — preserves the v3.17.1 narrowing).
2. session.mjs paste — send-keys -l "$(cat file)" delivers a large multi-line prompt's
newlines as separate key events (≈repeated Enter), so the prompt never lands. Replaced
with tmux load-buffer + paste-buffer -p (bracketed paste): atomic, no shell arg limit,
claude ingests it as one "[Pasted text]".
3. session.mjs verify — the old "placeholder-gone" heuristic false-positived on claude's
empty curly-quote placeholder, so Enter fired into an empty box (THE root cause of the
hang). tuiPromptLanded now trusts only positive signals ([Pasted text] indicator or the
prompt's own text); readiness-poll + paste-verify-poll + fast-fail (tui_paste_not_landed)
turns a 120s wallclock hang into a deterministic ~5s error.
Also: env delivered to the pane via an `env`-prefix on the pane command (tmux does NOT
forward spawnSync's env, and new-session -e needs tmux ≥3.2 while the cloud host runs 2.7),
carrying DISABLE_AUTOUPDATER (version pin) + CLAUDE_CODE_ENTRYPOINT labeling.
Validated live on both hosts: a 300-line / ~30KB prompt that previously hung now returns
in ~5-8s; small prompts ~4-5s. 192 tests pass (new: terminal-detection across schemas,
positive-signal verify incl. the curly-quote false-positive guard, paste predicates).
Scope discipline: an attempt to also strip claude's CLAUDE.md/auto-memory injection (context
reduction) was REVERTED — it didn't measurably reduce context through OCP, caused an
off-response, and is a separate concern. It will be its own measured branch.
ALIGNMENT.md: TUI is the ADR-0007 proxy-side execution bridge, not a forwarded Anthropic
operation, so a cli.js citation is N/A under Rule 2. No blacklisted tokens / port literals.
Independent fresh-context reviewer (opus): APPROVE WITH CHANGES (Iron Rule 10) — validated
the three fixes, caught that a per-session tmux socket would break the startup stale-session
reaper, and flagged the context-suppression scope-creep; both were reverted per the review.
Closes #130.
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
1f577c075f |
chore(release): v3.18.0 — code-audit hardening (P0 /health + P2/P3 batch) + 3 follow-ups (#129)
Bundles the 2026-05-31 multi-agent code audit remediation and its follow-ups: - #109 (P0): /health no longer leaks the anonymous quota key to LAN (opt-in PROXY_ADVERTISE_ANON_KEY) - #110: request-validation + OpenAI-compat correctness - #111: error-output sanitization + process-lifecycle hardening - #112: OAuth-host verification + models.json SPOT - #113: CLI/installer hardening - #114: dashboard XSS escaping + key-name validation - #115: TUI non-loopback LAN gate + cc_entrypoint assertion - #123: alignment.yml wrong-host pin + ALIGNMENT.md amendment - #124: dashboard status/plan card escaping - #125: isLoopbackBind extracted to lib/net.mjs Each landed as its own PR with a fresh-context independent reviewer (Iron Rule 10) and green alignment CI. Version bumped 3.17.1 → 3.18.0; CHANGELOG finalized. 181 tests pass. Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
6dff36959a |
chore(governance): pin legacy OAuth host in alignment.yml + ALIGNMENT.md amendment (#123) (#128)
Follow-up to the 2026-05-31 audit (deferred from #112). The OAuth token host platform.claude.com/v1/oauth/token was verified against the compiled cli.js in #119; this pins the legacy WRONG host so a future accidental revert hard-fails CI. - .github/workflows/alignment.yml: add "console.anthropic.com/v1/oauth/token" to the BLACKLIST; rewrite the comment + failure message so the blacklist now documents TWO kinds of token — known hallucinations AND pinned wrong-host variants of a verified Class A endpoint (a hit means a drift, not necessarily a hallucination). The pinned token is absent from server.mjs (which uses platform.claude.com), so CI stays green. - ALIGNMENT.md: new "OAuth token-host verification (2026-05-31)" subsection recording the binary verification (claude.exe 2.1.154, strings, no live probe) and the dual-purpose blacklist policy. Purely additive; Rules / audit pin / Historical Lesson untouched. Per ALIGNMENT.md Amendment Procedure: (a) motivating evidence cited (issues #112/#119/#123), (b) independent fresh-context opus reviewer APPROVE — verified the pinned token does not trip the build (absent from server.mjs; live host not blacklisted), YAML valid, amendment consistent with the server.mjs verification comment, purely additive scope. (c) not incident-driven (a confirming verification, not a new drift) so Historical Lesson unchanged. Closes #123. Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Opus 4.8 <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> |
||
|
|
0000926358 |
fix: escape dashboard status/plan cards (#124) (#126)
Follow-up defense-in-depth from #114's review. The status-cards and plan-cards in dashboard.html rendered string values into innerHTML without escaping. These are trusted-but-external (p.version/p.uptime are server-local; s.percent/s.resetsIn/ w.percent/w.resetsIn come from Anthropic's upstream plan API), so not the stored-XSS vector #114 fixed — but wrapping them in the existing escapeHtml() helper gives uniform defense-in-depth across all innerHTML sinks. Numeric/computed fields (request counts, sPct/wPct, barColor) are left unescaped (not injectable). dashboard.html is a client-side static asset, not server.mjs → cli.js citation N/A. Independent fresh-context reviewer (opus): APPROVE (Iron Rule 10) — confirmed every string sink wrapped, numbers correctly untouched, escapeHtml in scope, template literals intact, 181 tests pass (no regression). Closes #124. 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>
|
||
|
|
68d58e7df4 |
fix: CLI/installer hardening — restart labels, key permissions, unit-secret escaping (#113) (#120)
Three CLI/installer findings from the 2026-05-31 audit (no server.mjs): 1. ocp-plugin `cmdRestart` hardcoded uid 501 + the legacy `ai.openclaw.proxy` label, and fell through to a dangerous `pkill -f 'node.*server.mjs' && cd ~/.openclaw/projects/*/; node server.mjs &` that could kill an unrelated node process and relaunch an env-less ghost proxy from a glob-ambiguous dir. Now uses process.getuid() + the live labels (dev.ocp.proxy on macOS, ocp-proxy on Linux systemd; the OpenClaw gateway label is unchanged) and drops the pkill fallback entirely (returns a manual `ocp restart` message on failure). 2. ocp-connect wrote the quota key unquoted into rc files and a world-readable environment.d/ocp.conf. Now single-quotes the value and chmod 600s the rc files and ocp.conf (matching the existing auth-profiles.json 0o600 convention). 3. setup.mjs interpolated the injected service-unit secrets (CLAUDE_BIN, OCP_ADMIN_KEY, PROXY_ANONYMOUS_KEY) raw into plist <string> and systemd Environment= lines. Added xmlEscape() for all plist <string> values and assertSafeInjectValue() which rejects control characters (\x00-\x1f — newline, CR, tab) before any unit is written, blocking a newline-injected rogue Environment= directive. Spaces are intentionally allowed (CLAUDE_BIN paths may contain them). XML-escaping on write also resolves plist-merge.mjs's [^<]* regex concern transitively (no raw < reaches it) — comment added, logic unchanged. ALIGNMENT.md: CLI/installer scripts only, no Anthropic operation forwarded → cli.js citation N/A. No blacklisted tokens or port literals introduced; alignment.yml passes. Independent fresh-context reviewer (opus): APPROVE (Iron Rule 10) — verified the control-char regex byte-exact via od (no space-rejection regression), the pkill fallback fully removed, restart labels match setup.mjs ground truth, OCP keys (base64url) cannot break the single-quoting, and the validator runs before any write. Closes #113. 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> |
||
|
|
7b065600aa |
docs(readme): honest multi-user/security positioning + 6/15 framing + OLP cross-link (#108)
- Deployment model & security section: single-user multi-IDE is the supported model; shared/multi keys give usage/quota tracking but NOT a security isolation boundary (claude runs with operator FS, not sandboxed) — trusted users only; real per-user sandboxed isolation is planned post-2026-06-15. - Soften the multi-mode 'recommended' label accordingly. - Add a 'Related: OLP' section linking the multi-provider sibling project. Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
1b5a742711 |
chore(release): v3.17.1 — code-audit P1/P2 hardening (crash fixes + multi-tenant gates) (#107)
Co-authored-by: dtzp555 <dtzp555@gmail.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>
|
||
|
|
a30b20978c |
chore(release): v3.17.0 — Phase 6c stream-json default + opus 4.8 + opt-in TUI-mode (#105)
- Phase 6c: default claude spawn → stream-json + --system-prompt (~64% cost cut) - opus 4.8 model - opt-in CLAUDE_TUI_MODE interactive subscription-pool bridge (single-user) Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
cd98b51b96 |
docs(plans): add anthropic-only sandbox strategy handoff (#102)
Forward-looking planning doc capturing prior-art analysis from OLP's Phase 7 PR-B re-evaluation, scoped down to OCP's single-provider (anthropic) deployment. Documents: - Multi-tenant gap for OCP (cross-key lateral read, OAuth exposure) - Why OLP's outer-bwrap PR-B approach is the wrong path to copy (Anthropic design intent, ~/.claude.json upstream not-planned) - Three viable alternatives: A. Ephemeral $HOME via env var (~50 LOC, recommended Phase 1) B. bwrap --tmpfs $HOME + ro-bind credentials (apt dep, Linux only) C. OverlayFS lowerdir+upperdir (needs CAP_SYS_ADMIN) - Orthogonal cross-key isolation layer (per-spawn customConfig denyRead or per-OS-user spawning) - Trust-tier framing (single-user / family-zone / shared-host) Not an ADR — becomes one only when work actually starts. Not binding; ALIGNMENT.md authority requirements still apply when sandbox code lands. Cross-references OLP's parallel multi-provider work at dtzp555-max/olp ADR 0014 Amendment 1 (pending) and archive branch phase-7-pr-b-outer-bwrap-snapshot. Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Opus 4.7 <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> |
||
|
|
1dd6fb9440 |
docs(governance): add ADR 0006 OpenAI shim scope + Class A/B taxonomy (#100)
Introduces an explicit two-class taxonomy of OCP endpoints to resolve the structural ambiguity surfaced by PR #99 (external response_format honoring on /v1/chat/completions): - Class A: cli.js-mirror endpoints. Rules 1-5 of ALIGNMENT.md apply verbatim. Citation requirement unchanged (cli.js:NNNN). The 2026-04-11 drift discipline is preserved without weakening. - Class B: OCP-owned compatibility endpoints. Anchored to OpenAI's /v1/chat/completions specification (B.1) or to an authorizing ADR (B.2). Citation shifts to spec section + ADR number. Class B inherits the same anti-invention discipline; the anchor differs. Grandfather provision in ADR 0006 retroactively authorizes the 12 existing B.2 administrative endpoints at v3.16.4 behaviour (one-time, contract-frozen). New B.2 endpoints or any new method on a grandfathered endpoint requires its own ADR per Rule 4 (Class B mapping). Changes: - new: docs/adr/0006-openai-shim-scope.md - new: docs/openai-compat-pin.md (placeholder for first B.1 audit) - mod: ALIGNMENT.md (Class B section, rule mapping table, updated Unalignable Policy and Annual Audit scope; Rules 1-5 byte-identical) - mod: .github/PULL_REQUEST_TEMPLATE.md (Endpoint Class radio, separate evidence sections for A vs B, reviewer checklist updated) - mod: docs/adr/README.md (index entry for ADR 0006) Independent reviewer (fresh-context opus per Iron Rule 10) verified: all 12 load-bearing checks pass — Rules 1-5 byte-identical to origin/main, 2026-04-11 drift narrative unchanged, explicit non-relitigation safeguard present in ADR 0006, grandfather provision narrowly scoped, Class B inventory matches server.mjs reality (14/14), single governance layer per Iron Rule 11 (no server.mjs touch), alignment.yml unmodified. Verdict: APPROVE_WITH_MINOR with 3 of 5 non-blocking suggestions folded in (operations-vs-endpoints precision, PR template Hybrid wording, openai-compat-pin.md stub). Triggering incident: PR #99 by external contributor (response_format honoring on /v1/chat/completions). This governance PR ships separately per Iron Rule 11 (IDR); the feature PR #99 will rebase on this and declare Class B with ADR 0006 citation. Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Opus 4.7 <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>
|
||
|
|
49c6d32e3b |
fix(scripts): default CLAUDE_PROXY_PORT to 3456 (completes v3.16.2 revert) (#97)
Three places in scripts/ still defaulted to 3478 after v3.16.2's plugin / manifest / README / plist revert: scripts/upgrade.mjs:137 scripts/doctor.mjs:84 scripts/doctor.mjs:205 These were the residual cascade source for the port-drift bug originally caused by the PR #71 dogfood accident on 2026-05-08 (see ~/.cc-rules/memory/learnings/subagent_setup_mjs_prod_host_collision.md and v3.16.2 CHANGELOG entry). Every `ocp doctor` / `ocp upgrade` invocation without an explicit `CLAUDE_PROXY_PORT` in env probed port 3478 — got "OCP not responding" against a healthy 3456 instance — and on the maintainer's host this cascaded into wrong baseUrl writes for the OpenClaw `claude-local` provider, taking out the OpenClaw Telegram agent ("大内总管") on 2026-05-13. This change is the smallest possible: three string literals `"3478"` → `"3456"`, aligning unset-env defaults with `server.mjs:126`. Env-set users are unaffected (env precedence is unchanged). cli.js: not applicable — this is a scripts/ change, not server.mjs. ALIGNMENT.md hard-requirements (cli.js citation, blacklist CI, independent reviewer) target server.mjs; this PR honors the SPOT spirit by ending the literal-port drift across the codebase. Bumps to v3.16.3. CHANGELOG entry added. Co-authored-by: dtzp555 <dtzp555@gmail.com> |
||
|
|
7766fa0868 |
fix(plugin): revert default to 3456 + correct v3.16.1 narrative; v3.16.2 (#96)
v3.16.1's narrative ("OCP server moved to 3478 default in v3.14+") was
incorrect. OCP source default has been 3456 since
|
||
|
|
70faeff067 |
fix(plugin): default OCP plugin port to 3478 + env-overridable (v3.16.1) (#95)
* fix(plugin): default OCP plugin port to 3478 + env-overridable; v3.16.1 ocp-plugin/index.js hard-coded http://127.0.0.1:3456 since the plugin was created. OCP server moved to 3478 default in v3.14+ as part of the same wave that renamed the launchd label (dev.ocp.proxy). The plugin never got the memo. Result: `/ocp usage` from OpenClaw bots (e.g. the home Telegram bot 大内总管) hit the dead port 3456 and returned "OCP error: fetch failed". Fix: - Default PROXY → http://127.0.0.1:3478 - Read OCP_PROXY_URL env (full URL) first - Else read CLAUDE_PROXY_PORT env (port only, localhost assumed) - Else fall back to the 3478 default openclaw.plugin.json bumped (3.12.0 → 3.16.1) and configSchema default updated. Plugin version now matches OCP version. Top-level package.json bumped 3.16.0 → 3.16.1. CHANGELOG entry added. Diagnostic trail: caught 2026-05-12 when home Telegram bot reported "OCP error: fetch failed" against `/ocp usage`. Mac mini OCP service was healthy on port 3478; lsof -iTCP:3456 had no listener; plugin index.js had hardcoded 3456. No cli.js citation needed: this is OCP-internal plugin code with no corresponding cli.js operation. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * docs(readme): document OCP_PROXY_URL + CLAUDE_PROXY_PORT plugin reuse (per release_kit 5.5) --------- Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
7a69d72886 |
feat(snapshot): gcSnapshots + ocp update --rollback --gc + auto-GC; v3.16.0 (#94)
Adds snapshot garbage collection with retention policy: keep last 5, keep snapshots within 30 days, always keep the most recent. Configurable via keepCount / keepDays opts. Wire-up: - ocp update --rollback --gc (manual trigger; --dry-run supported) - runFullUpgrade auto-GC after successful upgrade (best-effort, swallows errors) 4 unit tests: keepCount enforcement, keepDays override, never-delete- most-recent safety, dry-run mode. Bumps to v3.16.0 (bundles PR #93 --check oauth + this GC feature). No cli.js citation needed: this is OCP-internal snapshot lifecycle with no corresponding cli.js operation. Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
a8601a6d30 |
feat(doctor): --check oauth fast path (#93)
* feat(doctor): --check oauth fast path Implements the --check oauth fast path documented in cmd_doctor_help but previously unimplemented. Skips version detection, from-version check, git operations, and models endpoint — runs only the curl against /health + auth.ok extraction. Use cases: - After `claude auth login`, fast verify OCP can spawn cli.js - After a known service blip, quick health gate before larger ops - AI agent's setup-repair loop: ./ocp doctor --check oauth in a retry-after-fix step 3 unit tests cover: PASS path, OAuth FAIL → fix_oauth, service down → fix_service. No cli.js citation needed: this is OCP-internal doctor logic with no corresponding cli.js operation. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore(doctor): nit fixes for --check oauth (N3 body=null test + N4 skipped sentinel comment) --------- Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
cd6ec2a212 |
fix(doctor): dynamic latest_version from origin/main; release v3.15.1 (#92)
v3.15.0 doctor used a hard-coded `latest = "v3.14.0"` fallback, causing any v3.15.0+ install to report kind=upgrade against a stale value. `ocp update` would then attempt `git checkout v3.14.0` — a downgrade. Doctor now fetches `git -C ~/ocp show origin/main:package.json` to determine the actual latest. On failure (offline, fresh clone, no remote), falls back to currentVersion so kind=noop instead of recommending a downgrade. Regression test added: doctor with unreachable ocpDir falls back to currentVersion as latest (not the old hardcoded v3.14.0). Caught during v3.15.0 post-deploy verification on home-mac: ./ocp doctor reported `kind=upgrade` immediately after v3.15.0 install, which would have been a critical user-facing bug. No cli.js citation needed: this is OCP-internal doctor logic with no corresponding cli.js operation. Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
ab03c13332 |
feat(upgrade): ocp doctor + cross-version ocp update + rollback + AI prompts (#91)
* feat(doctor): add ocp doctor with --json + next_action contract Implements scripts/doctor.mjs with semver-aware path selection (noop/update/upgrade/fresh_install/fix_oauth/fix_service) and the JSON contract documented in the design spec. Service health + OAuth checks integrated; mockable via opts.mockHealth for unit tests. 8 unit tests cover the kind dispatch tree and the next_action shape for each kind. No cli.js citation needed: this is OCP-internal tooling with no corresponding cli.js operation. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ocp): wire cmd_doctor into bash CLI; dispatch to scripts/doctor.mjs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(doctor): handle unparseable version + empty health body Three issues raised by code-quality reviewer on |
||
|
|
55c576bbb1 |
fix(setup): preserve user-customised plist/systemd env vars on re-setup (#90)
* fix(setup): merge plist/systemd env vars instead of overwriting
setup.mjs previously wrote the launchd plist and the Linux systemd unit
with writeFileSync(path, NEW_TEMPLATE_STRING), which silently dropped any
user-customised env vars (CLAUDE_HEARTBEAT_INTERVAL, CLAUDE_CACHE_TTL,
etc.) on every re-setup or upgrade. This change introduces
scripts/lib/plist-merge.mjs which preserves keys present only in the
existing file and lets the template's known keys win. Linux variant uses
the same logic against Environment=KEY=VALUE lines.
Tests added in test-features.mjs cover preserve / override / first-install
for both formats.
No cli.js citation needed: this is OCP-internal installer behaviour with
no corresponding cli.js operation.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(setup): plist-merge code-quality follow-up
Three issues raised by the code-quality reviewer on
|
||
|
|
750b25ba77 |
chore(release): v3.14.0 — security hardening (sessions namespacing + file modes + /api/usage scope) (#89)
Bump version 3.13.0 → 3.14.0. No functional code change in this PR; all three security fixes are already merged in main via PRs #86, #87, #88. This PR covers only metadata + docs: - package.json: version bump to 3.14.0 - CHANGELOG.md: v3.14.0 entry with Features / Behavior changes / Verification / Governance sections - README.md: /api/usage self-scope note in Auth Modes §, API Endpoints table row update, file-mode bullet in Important Notes § cli.js citation N/A: this release PR modifies no server.mjs / setup.mjs / keys.mjs. The three underlying security PRs (#86, #87, #88) each carry their own cli.js-citation-not-applicable disclaimer per PR #75 pattern, as they are OCP-internal access-control, session-state, and file-permission changes with no corresponding cli.js operation. Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.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>
|
||
|
|
a71c939bf8 |
docs(readme): remove zhihu blog backlink + badge (article was removed) (#85)
The Chinese blog post linked from these two places was taken down by the 知乎 platform shortly after publication. Both pointers now resolve to a "content removed" warning page, which is a worse signal to visitors than no link at all. Removing both before they reach more README readers. Reverts the additions from #81 specifically: - Top badges row: drop the "blog · engineering story" shield - §Why OCP? closing blockquote: drop the line pointing at the article The 知乎 article URL is no longer reachable, so retaining the references would route incoming traffic to a dead page that suggests the project is itself problematic. Better to leave that section silent until a working canonical write-up exists somewhere. Doc-only change. server.mjs not touched. ALIGNMENT.md Rule 5 (cli.js citation) does not apply. Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
d245c62df7 |
docs(images): replace dashboard.png with multi-client + multi-session traffic (#84)
Continues #82 / #83 — those captures had stats counters at 0 / 3 respectively, with 0 active sessions. The Sessions card therefore visually undermined the rest of the dashboard. Re-captured after deliberately seeding LAN-side traffic from both Pi231 and MacBook clients (each holding a per-key API token issued on the Mac mini server), mixing single-turn and multi-turn requests with distinct session_id values to populate the in-memory sessions Map. New screenshot data points: - Uptime: 21h 56m - Requests: 24 / 0 active (up from 3 / 0) - Errors: 0 / 0 timeouts - Sessions: 8 active (was 0) - Plan Usage: 5h 20%, weekly 28% - byKey table: 11 keys with rich history, now including pi231-test + macbook-test rows from this round - Recent Requests: visible row count grown Multi-turn correctness was incidentally verified during seeding — a Pi231 request with session_id=pi-multi-01 turn 2 correctly recalled the number passed in turn 1 ("the number 7"), confirming session state persists across cli.js subprocess turns as designed. Capture method unchanged (Chrome headless, 1400x2400, --virtual-time-budget=14000). Doc-only change. server.mjs not touched. ALIGNMENT.md Rule 5 (cli.js citation) does not apply. Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
047750e642 |
docs(images): replace dashboard.png with stats-populated version (#83)
#82 captured the dashboard mid-idle — server had been up 21h with zero traffic since restart, so the in-memory stats counters all showed 0 (Requests 0 / Errors 0 / Sessions 0). That made the top strip of the screenshot look like a dead service even though the historical byKey + Plan Usage data below were healthy. Re-captured after firing 3 small haiku requests through localhost to populate stats.totalRequests. The new screenshot now shows: - Status: ok / v3.13.0 - Uptime: 21h 39m (up from 21h 28m) - Requests: 3 / 0 active (was 0 / 0) - Plan Usage: 5h 17%, weekly 28% (was 13% / 27%) - Recent Requests: 2 visible rows showing model + latency + status (was empty) Same capture method as #82: Chrome headless --window-size=1400,2400 --virtual-time-budget=12000. The 3 priming requests were short haiku prompts ("reply with the single word OKn"), max_tokens=12 each. Plan-usage delta < 1%, byKey table already had 11 active keys with rich history so the priming did not distort the long-tail data. This addresses feedback that the previous screenshot's empty stats strip undermined the rest of the dashboard's data richness for first- time README readers. Doc-only change. server.mjs not touched. ALIGNMENT.md Rule 5 (cli.js citation) does not apply. Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
3bdeb50ed5 |
docs(images): refresh dashboard.png with current prod data (#82)
The previous dashboard.png was captured on day-1, before any real traffic — the screenshot was effectively empty (no Plan Usage bars, single API key, no Recent Requests). It made the Web Dashboard look like a placeholder rather than a working observability surface. Replaced with a fresh capture of the maintainer's Mac mini production OCP (running v3.13.0, multi auth) showing: - Status / uptime / active+queued counters - Plan Usage bars (5h: 13%, weekly: 27%) — real subscription draw - Usage by Key table — 11 active keys with request counts, success/error rates, average latency, last-seen timestamps - API Keys section — all 32 registered keys with truncated key prefixes + creation date + active/revoked status (truncation matches existing dashboard rendering, no full keys are exposed) - Recent Requests log — request stream with model, prompt size, latency Capture method: Chrome headless (no Playwright extension required) at 1400x2400, --virtual-time-budget=12000 to let dashboard JS finish fetching + rendering before the screenshot frame. PNG dimensions: 1400 x 2400 (was 1400 x 1739). The added height comes from the now-populated Usage by Key + API Keys + Recent Requests sections — not from layout changes. Doc-only change. server.mjs not touched. ALIGNMENT.md Rule 5 (cli.js citation) does not apply. Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
fbbf3b6c7c |
docs(readme): add engineering-story backlink to 知乎 article + badge (#81)
Adds two pointers from README to a Chinese-language engineering blog post (https://zhuanlan.zhihu.com/p/2036388634207770402) that walks through OCP's cli.js-alignment philosophy, the 2026-04-11 drift incident (the 9-day hallucinated /api/oauth/usage endpoint), the three-tier guardrail design, and the recent fresh-state E2E pass that produced PRs #74-#78. Two minimal touches to README only: 1. Badges row: a "blog · engineering story" shield linking to the article. Slot fits between the existing Release badge and the Buy Me a Coffee badge so the row's visual rhythm is preserved. 2. New blockquote line at the end of §Why OCP? (between the single-maintainer / pre-1.0 disclosure and §Supported Tools). Brief, factual, no marketing voice. Why this PR The 知乎 piece is a self-contained engineering narrative about maintaining a single-maintainer LLM-assisted proxy without endpoint drift. README is the project's primary surface; pointing at the narrative lets readers who land on the repo decide if the project's discipline matters to them before they invest in install. Reverse direction: GitHub readers who follow the link bring some traffic back to the article, validating that engineering content has a home. Doc-only change. server.mjs untouched. ALIGNMENT.md Rule 5 (cli.js citation) does not apply. Same pattern as #68 / #69 / #71 / #78 / #79. Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
d760d7fcce |
docs(readme): add restrained star + issue CTA below tagline (#79)
* docs(install): remove false symlink claim, fix Anonymous mode startup command Three doc inaccuracies surfaced during fresh-state install testing on Pi231 + MacBook (Round 1+2): 1. README §Server Setup claimed setup.mjs "Symlink `ocp` to /usr/local/bin for CLI access" — false. setup.mjs writes start.sh + plist/systemd unit but creates no PATH symlink. Removed the line; added a short PATH tip showing the user's options (manual symlink to ~/.local/bin or /usr/local/bin, or shell alias) right after the install summary. 2. README §Anonymous Access told users to run `ocp start` to enable the feature — there is no `ocp start` subcommand. Available commands are restart / stop / status / logs / keys / usage / update / lan / health / clear / settings (verified via `~/ocp/ocp` enumeration). Replaced with the correct flow: export PROXY_ANONYMOUS_KEY, then `node setup.mjs --bind 0.0.0.0 --auth-mode multi`. 3. The same paragraph implied that exporting PROXY_ANONYMOUS_KEY in an interactive shell is enough to enable anonymous access — but the running proxy is auto-started by launchd/systemd from the service unit's own env, not from the user's shell. Spelled this out and noted that if OCP was installed before exporting the env var, the user must re-run setup.mjs (idempotent) so the service unit env is refreshed, then `ocp restart`. The PROXY_ANONYMOUS_KEY mechanism described becomes 100% accurate when PR B (`fix/setup-inject-service-env`, sibling PR) lands; current setup.mjs on main does not yet inject this env into the service unit. Doc-only — no `server.mjs` change, no version bump, no `cli.js` citation required. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * docs(readme): add restrained star + issue CTA below tagline A single italic line under the existing personal-note italic, asking readers to ⭐ the repo if they get value, and to file issues — framed explicitly as "issues are even more useful than stars" so the CTA reads as feedback-seeking rather than vanity-metric chasing. Tone matches the rest of README: low-key, single-maintainer self-deprecating, no marketing voice. No "save money / free / $0" verbiage. Coexists with the existing buy-me-a-coffee personal-note line above it (different ask: funding vs. social-proof). This is doc-only. ALIGNMENT.md Rule 5 (cli.js citation) does not apply. Same pattern as #68 / #69 / #71 / #78. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
5e2effd05b |
fix(ocp-connect): write ~/.zshrc on macOS (default shell since Catalina) (#77)
macOS default shell has been zsh since Catalina (2019). The previous
rc-file selection logic treated macOS the same as Linux, so on a fresh
Mac where ~/.zshrc did not already exist AND the script was invoked via
`bash -s --` (e.g. `curl | bash`), the $SHELL guard was `/bin/bash`
and neither condition for zshrc was true — resulting in only ~/.bashrc
being written. Since ~/.bashrc is not sourced by interactive zsh
sessions, the OPENAI_BASE_URL / OPENAI_API_KEY exports were invisible
to interactive shells.
Fix:
- Add an explicit `elif $is_mac` branch that unconditionally includes
~/.zshrc: create the file (empty) if it does not yet exist, since
zsh tolerates an empty ~/.zshrc.
- On macOS, ~/.bashrc is only written if it already exists — consistent
with the task spec ("don't create ~/.bashrc if it didn't exist").
- Linux path is preserved unchanged.
- Fix the "Reload your shell" hint at script end: previously it printed
only the last loop variable `$rc_file` (stale reference outside the
loop). Now it iterates `${rc_files[@]}` so both files are shown on
macOS (reproducing the Round A bug: hint said only `source ~/.bashrc`
even when zshrc should also be reloaded).
Smoke-tested with HOME=/tmp/fakehome redirect for three scenarios:
1. Fresh MacBook (no .bashrc, no .zshrc) → only .zshrc created+written
2. macOS with existing .bashrc → both .bashrc and .zshrc written
3. Linux with bash, no .bashrc → .bashrc written (unchanged)
Identified during Round A testing on MacBook 2026-05-08.
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
|
||
|
|
fb2d1d3feb |
fix(ocp-cli): replace eval-curl with bash array to preserve JSON body quoting (#74)
`eval curl "$_AUTH_HEADER" "$@"` re-tokenizes its argument list according
to bash word-splitting rules. When OCP_ADMIN_KEY is set, the JSON body
`'{"name": "laptop"}'` (which contains a space) gets split into
`'{name:'` and `'laptop}'` — two separate args — so curl receives a
malformed body and the server rejects the request.
Fix: replace the `_AUTH_HEADER` string + `eval` pattern with a bash array
`_AUTH_ARGS`. Array expansion via `"${_AUTH_ARGS[@]}"` preserves word
boundaries across substitution with no eval required. Both code paths
(OCP_ADMIN_KEY env var and ~/.ocp/admin-key file fallback) and the empty
case (no admin key) are preserved unchanged.
Verified via `bash -x` trace:
Before: `curl … -d '{name:' 'laptop}'` (body split, malformed)
After: `curl … -d '{"name": "testkey-pr-c"}'` (body intact, single arg)
Identified during fresh-state Round 2 testing on MacBook.
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> |
||
|
|
c0f2d3ab20 |
docs(install): remove false symlink claim, fix Anonymous mode startup command (#78)
Three doc inaccuracies surfaced during fresh-state install testing on Pi231 + MacBook (Round 1+2): 1. README §Server Setup claimed setup.mjs "Symlink `ocp` to /usr/local/bin for CLI access" — false. setup.mjs writes start.sh + plist/systemd unit but creates no PATH symlink. Removed the line; added a short PATH tip showing the user's options (manual symlink to ~/.local/bin or /usr/local/bin, or shell alias) right after the install summary. 2. README §Anonymous Access told users to run `ocp start` to enable the feature — there is no `ocp start` subcommand. Available commands are restart / stop / status / logs / keys / usage / update / lan / health / clear / settings (verified via `~/ocp/ocp` enumeration). Replaced with the correct flow: export PROXY_ANONYMOUS_KEY, then `node setup.mjs --bind 0.0.0.0 --auth-mode multi`. 3. The same paragraph implied that exporting PROXY_ANONYMOUS_KEY in an interactive shell is enough to enable anonymous access — but the running proxy is auto-started by launchd/systemd from the service unit's own env, not from the user's shell. Spelled this out and noted that if OCP was installed before exporting the env var, the user must re-run setup.mjs (idempotent) so the service unit env is refreshed, then `ocp restart`. The PROXY_ANONYMOUS_KEY mechanism described becomes 100% accurate when PR B (`fix/setup-inject-service-env`, sibling PR) lands; current setup.mjs on main does not yet inject this env into the service unit. Doc-only — no `server.mjs` change, no version bump, no `cli.js` citation required. Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
0d61da5153 |
fix(setup): inject CLAUDE_BIN, OCP_ADMIN_KEY, PROXY_ANONYMOUS_KEY into service env (#76)
Gap #1 partial (CLAUDE_BIN): setup.mjs now detects `which claude` at install time (or reads $CLAUDE_BIN) and writes CLAUDE_BIN into the service unit EnvironmentVariables dict. Works for nvm/homebrew paths not in server.mjs's hardcoded list, and for older server.mjs deployments. Gap #2 (OCP_ADMIN_KEY): reads $OCP_ADMIN_KEY from the user's shell env and conditionally injects it into the plist/systemd unit. Empty/unset → key is omitted entirely (server.mjs treats empty string as "no admin"). Key value is never logged; only its length is reported. Gap #6 partial (PROXY_ANONYMOUS_KEY): reads $PROXY_ANONYMOUS_KEY and conditionally injects it. Unset → key is omitted (anonymous access disabled). All three keys are read via process.env at install time; no new CLI flags. Injection status is logged before the !DRY_RUN guard so dry-run shows what would be written. Identified during fresh-state Round 2 testing. Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
49baffe2da |
fix(setup): decouple OpenClaw config patch from being mandatory (#73)
OCP markets itself as a standalone OpenAI-compatible proxy with six
supported IDE clients (Cline / Cursor / Continue.dev / OpenCode / Aider /
OpenClaw). The README §Server Setup says "node setup.mjs" is the
installer entrypoint. But on a truly fresh box without OpenClaw
installed, setup.mjs hard-fails at line 111:
if (!existsSync(CONFIG_PATH)) fail(`OpenClaw config not found...`);
This contradicts the standalone-proxy stance and was caught during
PR #71 dogfood testing on Pi231.
Root cause: the OpenClaw config patch (lines 110-148) was baked in as
required because OCP started life as an OpenClaw-internal helper. The
project has since rebranded to standalone OCP without decoupling the
installer.
Fix: gate the OpenClaw config patch (Step 2) and auth-profiles patch
(Step 3) on existsSync(CONFIG_PATH). When OpenClaw is present, behavior
is byte-for-byte identical to current main (verified via dry-run hash
diff against ~/.openclaw/openclaw.json — UNCHANGED). When OpenClaw is
absent, the installer logs a graceful warning, skips both patch
sections, and continues to start.sh / launchd-plist / systemd-unit
creation as before. The summary banner is also conditional: when
OpenClaw is absent, it points the user at README § "Client Setup"
instead of giving openclaw.json edit instructions.
Also moves `import { readdirSync }` from mid-file (line 178, post-use)
to the top-level imports block; this was a latent ESM-hoisting quirk
that worked but is now syntactically required at the top because the
readdirSync call moved inside an `if` block.
Out of scope (Iron Rule 11, single-layer PR): server.mjs, models.json,
scripts/sync-openclaw.mjs, README.md, ALIGNMENT.md, package.json
version, the duplicate-spawn / health-verify logic at line 268+ (that's
a separate fix/setup-spawn-conflict PR).
Mac mini production unaffected: ~/.openclaw/openclaw.json already
exists there, so the OpenClaw-present path is preserved. `ocp update`
doesn't invoke setup.mjs, so running services are not touched.
Smoke-tested locally:
- Path 1 (OpenClaw present): dry-run output functionally identical to
current main; config sha256 unchanged after run.
- Path 2 (OpenClaw absent, OPENCLAW_STATE_DIR=/tmp/no-such-dir-*):
graceful warn, both patch sections skipped, banner shows
standalone-mode message, dry-run completes successfully.
- npm test: 43/43 pass.
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
4b01d4e768 |
fix(setup): remove duplicate server spawn; verify health post-install (#72)
## Dogfood evidence (Pi231, 2026-05-08)
A user ran `node setup.mjs --bind 0.0.0.0 --auth-mode multi` and got:
- setup.mjs exit 0
- /health responded with authMode:"none" and server bound to 127.0.0.1 only
- ps showed two server.mjs processes: one orphan from setup (wrong config),
one systemd child restart-looping on EADDRINUSE
## Root cause (two-step conflict)
Step 6 (the deleted block) called `execSync('bash "${startPath}"')` which ran
start.sh's `nohup node server.mjs &` — spawning the server WITHOUT exporting
CLAUDE_BIND or CLAUDE_AUTH_MODE. That server ran with default bind=127.0.0.1
and authMode=none, ignoring the user's CLI flags.
Step 7 then wrote the systemd unit/launchd plist WITH the correct env vars and
bootstrapped the service — but port 3456 was already taken by Step 6's spawn,
causing EADDRINUSE and a silent restart loop. setup.mjs exited 0 because Step 6
had "succeeded" (a server was running, just the wrong one).
## What changed
1. Deleted Step 6 entirely (the `execSync('bash "${startPath}"')` block).
The systemd/launchd service installed in Step 7 is now the sole authoritative
start path. start.sh is unchanged and still available for manual non-systemd use.
2. Added Step 8 inside the `if (!DRY_RUN)` block: after Step 7 bootstrap,
waits 3 s, then GETs http://127.0.0.1:${PORT}/health with a 5 s timeout.
- On 200 OK: logs version, authMode, and bind socket (best-effort).
- On failure: prints clear error pointing to service logs, exits 1.
- Skipped when --no-start is set (existing flag).
Identified during PR #71 dogfood testing on Pi231 (RPi4 / Debian Bookworm).
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
|
||
|
|
36fa81d1e6 |
docs(install): add §Quick install with AI assistance + plug five new-user pitfalls (#71)
* docs(install): add §Quick install with AI assistance + plug five new-user pitfalls
Context: a returning user observed that letting an AI assistant follow the
README to install OCP would mostly work but stumble on a handful of small
gaps — missing OS qualifier, no admin-key generation hint, server IP
discovery buried, and a few common setup errors not in Troubleshooting.
This patch closes those gaps and adds a copy-paste prompt section for
new users who'd rather have an AI walk them through the install.
Five additive changes (README only, server.mjs untouched):
1. **§Server Setup prerequisites** — add the macOS/Linux qualifier
("Windows is not supported — setup.mjs installs launchd / systemd")
and `git`, both of which were implicit before.
2. **OCP_ADMIN_KEY generation hint** — replace the placeholder
`your-secret-admin-key` with a one-line `openssl rand -base64 32`
example, plus a reminder to add the export to ~/.zshrc / ~/.bashrc
so it survives shells.
3. **§Client Setup** — add an inline note pointing readers to run
`ocp lan` on the server to discover the server's LAN IP. Previously
`ocp lan` was mentioned only in §Server Setup, leaving client-side
readers to guess.
4. **§Troubleshooting** — three new entries for setup-time errors
(claude not found / EADDRINUSE 3456 / node version), with the
specific recovery commands. Existing entries left unchanged.
5. **§Installation → new ###Quick install with AI assistance subsection**
— three copy-paste prompts (single-machine, LAN server, client) that
pin the AI to the right README path, name the verification step, and
forbid silent retries. Includes a pointer to the manual handbook
sections for readers who prefer that path.
Doc-only change. server.mjs not modified. ALIGNMENT.md Rule 5 (cli.js
citation requirement) does not apply. Same pattern as #68, #69, #70.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* docs(install): add Claude CLI install command + ####Headless install notes
Live Pi231 (RPi4 / Debian Bookworm) test of PR #71's "Quick install with AI
assistance → LAN server" prompt surfaced two more new-user pitfalls in the
README's Prerequisites + Server Setup flow:
1. **Claude CLI install command was missing.** Previous text said "Claude CLI
installed and authenticated" with a docs link — but the actual install
command (`npm install -g @anthropic-ai/claude-code`) appeared nowhere.
An AI assistant following the prompt has to fetch external docs to
guess at the install path. Now inlined.
2. **Headless servers (Pi / NAS / VPS) had no auth guidance.** OCP's main
deployment targets are always-on headless devices. `claude auth login`
actually works headless (prints URL + code, OAuth completes on any
browser-capable device), and `claude setup-token` provides a long-lived
token — but neither was documented. New ####Headless install notes
subsection explains both paths.
Test trail (Pi231):
- ✅ Linux (aarch64 Debian Bookworm), Node v22.22.2, git 2.39.5 — prereqs
satisfied except Claude CLI.
- ❌ `claude` not on PATH (expected — fresh Pi). README didn't tell the
user how to install it. → fix #1 above.
- ⚠️ Even after AI fetches the install command, headless OAuth was a
documentation gap. → fix #2 above.
Doc-only change. server.mjs not modified. ALIGNMENT.md Rule 5 does not
apply. Extends PR #71 (same install-UX layer per Iron Rule 11 IDR).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
cce0110253 |
docs(why-ocp): reorder bullets + soften alignment framing + visceral hooks (#70)
The "Why OCP?" section previously led with technical/governance bullets
(SSE heartbeat, alignment, models.json) and buried the most relatable
selling points (LAN multi-user keys, ocp-connect IDE auto-config, cache).
First-time readers stopped reading before reaching the bullets that would
have sold them on the project.
Changes (README.md only, line 19-26):
1. Reorder: human-relatable benefits first, governance discipline last.
New order: LAN multi-user → ocp-connect → cache → quota → SSE heartbeat
→ alignment → models.json. Quota is split out from the old combined
"quota + cache" bullet so each gets its own one-liner.
2. Soften the alignment bullet's framing. The previous prose flagged
"Other Claude proxies have shipped exactly that" and was deleted —
no need to call out competitors. Replaced with a measured "LLM-assisted
code drifts easily — it's tempting to invent plausible-looking endpoints
that cli.js doesn't actually use" plus a deliberately understated
payoff: "your setup keeps working when cli.js ships its next minor."
3. Add visceral hooks where bullets benefit from them:
- SSE heartbeat: "If you've ever watched your IDE die at the 60s idle
mark during a long Claude tool-use pause — that's nginx/Cloudflare
default behavior" (frames the problem in user-felt terms before
describing the fix).
- Per-key quota: concrete example "set a kid's iPad to 20/day, a
partner's laptop to 100/week" (replaces abstract "limits per key").
4. Cache bullet now explicitly states the per-key isolation guarantee:
"cross-user pollution is impossible by hash construction, not by
application logic" — this addresses the most common pre-adoption
concern (and reflects the v3.13.0 D1 design).
5. Total length compressed ~20% despite added context — old bullets had
redundant "what" descriptions; new bullets lead with the "what for".
Doc-only change. server.mjs not touched. ALIGNMENT.md Rule 5 (cli.js
citation requirement) does not apply. Same pattern as #68, #69.
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
40391791a1 |
docs(funding): raise sponsorship visibility — top badges + intro CTA + expanded support section (#69)
Context: Buy Me a Coffee + Stripe onboarding now fully live (verified buymeacoffee.com/dtzp555 returns og:type=profile with Support CTA, default $3 price tier, membership tier active). Previous §Support OCP at line 709 was effectively buried — last section before License, missed by most readers. Changes (README.md only, server.mjs untouched): 1. Top of file — three shields.io badges (License MIT, latest release, Buy Me a Coffee) under H1, before tagline. Standard OSS pattern (Vue / Vite / Tailwind), high visibility, doesn't disrupt prose. 2. Just under tagline — one-line italic personal note pointing to §Support OCP, with inline ☕ link as fallback for users who don't scroll. Quotes the spirit of the longer section without duplicating it. 3. §Support OCP expanded — adds the "open source from day one" framing (not freemium, not commercial-turned-open), the "my family uses it daily" angle, and an explicit feedback / issues invitation. The debugging-history paragraph is preserved verbatim. Doc-only change. ALIGNMENT.md Rule 5 (cli.js citation) does not apply — server.mjs is not modified. Same pattern as #68. Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
342a0a44f5 |
docs(funding): add Buy Me a Coffee link + GitHub FUNDING.yml (#68)
- .github/FUNDING.yml — enables GitHub's native "Sponsor" button on the repo page, pointing to buymeacoffee.com/dtzp555. Other platforms (GitHub Sponsors, Ko-fi) are commented out and can be enabled later by uncommenting + filling in handles. - README.md § Support OCP — new section just before License. States the free-and-open-source commitment, lists the kinds of work that don't show up in commits (multi-machine debugging, IDE validation, drift incidents, concurrency leaks), and offers a single ☕ link for users who want to support continued maintenance. Explicitly disclaims paid tiers / premium features so the open-source posture stays unambiguous. 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: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
9494fd6c69 |
chore(release): v3.13.0 — cache layer hardening (per-key isolation + bypass + chunked replay + singleflight) (#67)
Per release_kit overlay (CLAUDE.md § Iron Rule 5.5): - package.json bumped 3.12.0 → 3.13.0 - CHANGELOG.md updated with v3.13.0 entry - README.md § Response Cache updated to document the four hardening features This is a release preparation commit. Tag push to v3.13.0 will trigger .github/workflows/release.yml which auto-creates the GitHub Release. cli.js does not perform proxy-layer response caching, stampede protection, or replay; this release only ships internal cache-layer correctness and concurrency improvements that do not change the OpenAI-compatible wire surface visible to clients. No client-observable wire shape change. 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> |
||
|
|
c998d21a4f |
chore(ci): add gitleaks workflow to scan PRs and pushes to main (#64)
The repo has shipped `.gitleaks.toml` (with project-specific allowlist entries — public OAuth client ID, README placeholders, an old plan doc) since the privacy remediation work, but no GitHub Action invoked it. The config was orphan: real protection only when someone ran gitleaks locally, never gating merges. This workflow wires `.gitleaks.toml` into CI: - Triggers on every `pull_request` (any branch) and `push` to `main`. - Uses `gitleaks/gitleaks-action@v2`, which auto-detects the repo-root `.gitleaks.toml` and applies its allowlist. - Hard-fails on any leak. No `continue-on-error`. Public repo policy. - `permissions: contents: read` — minimum required scope. - `fetch-depth: 0` so the action can scan full history (the action's default behavior; explicit here for clarity). Verification path: - The workflow runs on this PR itself; if any secret were ever committed to the repo, the scan fails here. Prior audit confirmed the tracked tree is clean of real secrets, so this PR's own scan should pass. Refs: audit side-finding 4 of 4. Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
5be369ed68 |
docs(changelog): normalize version heading format to ## vX.Y.Z — YYYY-MM-DD (#63)
Audit side-finding: heading style drift in CHANGELOG.md. Before: ## v3.12.0 (2026-04-25) ← parens style (1 entry) ## v3.11.1 — 2026-04-21 ← em-dash style (canonical, majority) ## v3.11.0 — 2026-04-20 ← em-dash style (canonical, majority) After: all 3 entries use the em-dash form (2 of 3 entries already used it, so it's the canonical pattern by majority). Only the v3.12.0 heading line changed. The body content under each heading is untouched — only the date-format separator changes from parens to em-dash. Verification: - `grep "^## " CHANGELOG.md` after the edit → all entries match `## vX.Y.Z — YYYY-MM-DD`. - `git diff CHANGELOG.md` shows exactly one line changed. Refs: audit side-finding 3 of 4. Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
3d52ffc152 |
chore(deps): bump engines to >=22.5 to match node:sqlite usage in keys.mjs (#62)
OCP uses Node.js built-in SQLite (`node:sqlite`) in keys.mjs:3 for the
LAN-mode key store. The `node:sqlite` module is only available in:
- Node 22.5.0+ behind --experimental-sqlite flag
- Node 23.0.0+ without any flag (fully stable)
The previous `engines: ">=18"` was inaccurate and would have caused
opaque "Cannot find module 'node:sqlite'" failures for users on
Node 18-22.4 the moment LAN mode (multi-key) was enabled.
Changes:
- package.json: engines.node ">=18" → ">=22.5"
- README.md: prerequisite "Node.js 18+" → "Node.js 22.5+ (Node 23+ recommended …)"
with a one-line note explaining the flag distinction so users on 22.x know
they may need --experimental-sqlite.
Verification:
- Source usage of node:sqlite confirmed via grep: keys.mjs:3
(`import { DatabaseSync } from "node:sqlite";`).
- Local node --version = v25.8.0 (well above 22.5); `npm install` exit 0
with no engine-mismatch warning.
- No other source file imports node:sqlite (single point of usage).
Refs: audit side-finding 2 of 4.
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
68b0838074 |
chore(deps): delete stale package-lock.json (was v3.4.0 vs package.json v3.12.0) (#61)
The repo-tracked package-lock.json declared "version": "3.4.0" while package.json is at v3.12.0 — 8 minor versions of drift. Since package.json has zero dependencies (only built-in node:sqlite, node:http, node:https), the lockfile carried no useful information. It was pure noise that would mislead anyone running `npm ci` into thinking they were installing v3.4.0. Verification: - package.json has no `dependencies` or `devDependencies` field (grep -A2 '"dependencies"' package.json → no match). - Fresh clone + checkout + `npm install` → exit 0, "audited 1 package in 93ms, found 0 vulnerabilities". - node_modules is correctly empty after install (no bin shims to relink). Refs: audit side-finding 1 of 4. Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
313cb13a78 |
docs: align README + governance docs with current state (uninstall, files, ADR index, ship-archive) (#59)
Multiple documentation polish items rolled into one PR (one layer: "docs alignment with current state"). ### README.md - **Uninstall section** added between Server Setup and Client Setup (was missing — `node uninstall.mjs` exists but went undocumented). - **OpenClaw definition** added as a footnote on first README mention (the architecture diagram and Supported Tools table both reference OpenClaw without ever defining it). - **Repository Layout section** added before Security — table of top-level files (server.mjs, setup.mjs, uninstall.mjs, keys.mjs, models.json, ocp/ocp-connect, dashboard.html, scripts/, .claude/skills/, ocp-plugin/, docs/adr/, ALIGNMENT.md, AGENTS.md, CLAUDE.md) so a new contributor knows what each file does. - **LICENSE link** added to the License section footer. ### docs/adr/README.md (new) - Index of the three published ADRs (0002, 0003, 0004) with a one-line description each. - Explains the `0001` placeholder (early internal proposal that was superseded; numbering deliberately starts at `0002`). - Guidance on when to write a new ADR vs. when a commit message suffices. ### Spec/plan housekeeping - `specs/.gitkeep` removed (the empty `specs/` placeholder confused the picture; canonical paths are `docs/superpowers/plans/` for active plans and `docs/superpowers/specs/` for long-lived design docs that other code references). - Shipped plans moved to `docs/superpowers/plans/shipped/`: - `2026-04-10-lan-mode.md` (shipped: README LAN mode section) - `2026-04-25-47-sse-heartbeat-plan.md` (shipped: v3.12.0 per CHANGELOG) - `2026-04-25-47-sse-heartbeat-design.md` left in `docs/superpowers/specs/` unchanged because both `server.mjs:565` and `CHANGELOG.md:7` link to that exact path; moving it would require a `server.mjs` edit, which needs `cli.js` citation per ALIGNMENT.md Rule 1. ### AGENTS.md - Updated "Key files to know" to add `docs/adr/README.md`, `docs/superpowers/plans/`, and `memory/constitution.md`. - Note explaining `memory/constitution.md` is spec-kit's standard location, distinct from `~/.cc-rules/memory/` and `ALIGNMENT.md`. - Updated "Handoff expectations" item 5 from `docs/superpowers/specs/*/tasks.md` (which never matched anything — there were no `tasks.md` files there) to `docs/superpowers/plans/` (excluding `shipped/`). ### Coordination with PR #53 PR #53 is open and adds "Why OCP?", "Comparison", and "Governance" sections to README. This PR deliberately avoids those areas — only edits the Supported Tools table footnote, inserts Uninstall before Client Setup, inserts Repository Layout before Security, and updates the License footer. No expected merge conflict. Refs: audit findings M6, M8, M9, M11, L2, L3, L6. Co-authored-by: dtzp555 <dtzp555@gmail.com> |
||
|
|
e4b010af5e |
docs(readme): add Why OCP, comparison table, governance section (#53)
Adds three positioning sections to make the README convert clones-to-stars better: 1. "Why OCP?" near the top — 6 differentiator bullets with evidence links (SSE heartbeat / ALIGNMENT.md / models.json SPOT / multi-key / per-key quota / ocp-connect). 2. "Comparison" subsection — honest table vs claude-code-router and anthropic-proxy. Acknowledges CCR's larger ecosystem; positions OCP as cli.js-aligned + subscription-multiplexing focused. 3. "Governance" section near the bottom — links to ALIGNMENT.md, AGENTS.md, ADRs, alignment.yml. Consolidates the governance-link surface in one place rather than scattered. Net diff +43 -0. No content removed. No anchors broken. No code changes. Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
0752f666fb |
test: wire test-features.mjs to npm test + add minimal CI smoke workflow (#60)
`test-features.mjs` shipped at v3.8.0 (per CHANGELOG of the keys.mjs quota+cache work) and is referenced from AGENTS.md as the project's only test artifact, but until now nothing actually ran it — no `npm test` script, no CI step. Wiring it up so it runs on every push and PR. ### Changes - `package.json`: add `"test": "node test-features.mjs"` to scripts. - `.github/workflows/test.yml` (new): single-job workflow that runs `npm test` on push to main and on every PR. Uses Node 24 because `keys.mjs` imports `node:sqlite`, which is stable in Node 23+ (Node 24 is the current LTS; Node 22 would need `--experimental-sqlite`). No `npm install` step — OCP has zero external runtime dependencies per `package-lock.json`. - `AGENTS.md`: note that `test-features.mjs` runs via `npm test` and is enforced by `.github/workflows/test.yml`. ### Why this is a hard check, not a soft check `test-features.mjs` is self-contained — it imports `keys.mjs` and exercises the SQLite-backed key/quota/cache code paths against a throwaway test DB at `~/.ocp/ocp-test.db`. It does NOT require: - a live claude CLI binary - a running OCP server - any network access So CI can run it as a real check; no `continue-on-error` needed. ### Local verification ``` $ npm test [...] === Results: 24 passed, 0 failed === ``` 24 assertions cover createKey / listKeys / quota math / cache hash determinism / cache TTL / clearCache. Exit code is 1 on any failure (`process.exit(failed > 0 ? 1 : 0)` at the bottom of test-features.mjs). ### Future expansion If the suite later grows to include tests that DO require a live claude CLI or a running OCP, mark those steps `continue-on-error: true` (or split them into a separate job). The comment in `test.yml` documents this contract. Refs: audit (test-features.mjs orphan / unrunnable in CI). Co-authored-by: dtzp555 <dtzp555@gmail.com> |
||
|
|
ae4a829904 |
chore(naming): rename package + code refs from openclaw-claude-proxy to OCP (#57)
The npm name `ocp` is squatted (deprecated `node-ocp` v0.0.1), so the
package name is renamed to `open-claude-proxy` (verified via
`npm view open-claude-proxy` → 404, confirming availability).
### Changes
- `package.json`:
- `"name"`: `openclaw-claude-proxy` → `open-claude-proxy`
- `"bin"` map UNCHANGED: both `openclaw-claude-proxy` and `ocp`
binaries still resolve, preserving back-compat for existing installs
that reference `openclaw-claude-proxy` as a CLI command.
- `setup.mjs`: header JSDoc + start.sh banner string
- `uninstall.mjs`: header JSDoc
### Intentionally NOT changed
- `server.mjs` startup banner (line 1628):
`console.log('openclaw-claude-proxy v...')`. Editing `server.mjs`
requires `cli.js:NNNN` citation per ALIGNMENT.md Rule 1, and a
cosmetic log-string rename has no `cli.js` correspondence. Deferred
— would need an ALIGNMENT.md scope justification PR if pursued.
- `bin/openclaw-claude-proxy` symlink/path: existing installs depend on
this name; kept as a back-compat alias.
### Evidence
```
$ npm view ocp
ocp@0.0.1 | DEPRECATED — squatted by node-ocp
$ npm view open-claude-proxy
404 — available
```
Refs: audit (naming consistency).
Co-authored-by: dtzp555 <dtzp555@gmail.com>
|
||
|
|
51e908e145 |
chore(repo): remove broken/undocumented Docker files (#58)
The Dockerfile, docker-compose.yml, and .dockerignore are removed because the Dockerfile is structurally broken AND Docker is not a documented runtime for OCP. ### Evidence — Dockerfile is broken The Dockerfile COPYs only 3 files: ``` COPY server.mjs ./ COPY setup.mjs ./ COPY package.json ./ ``` But `setup.mjs:43` reads `models.json` at runtime (`JSON.parse(readFileSync(join(__dirname, "models.json"), ...))`), and `server.mjs` requires `keys.mjs`, `dashboard.html`, etc. The container as built would crash on first run. ### Evidence — Docker is undocumented ``` $ grep -i docker README.md AGENTS.md CLAUDE.md # 0 hits ``` No README install path, no AGENTS.md runtime mention, no CLAUDE.md release-kit reference. These files were ghost infrastructure. ### Why delete vs. fix Fixing the Dockerfile to actually work would require: COPYing 6+ files, verifying the container can write to `~/.openclaw/`, deciding on auth mounting (claude CLI binary + auth state), and adding documentation across README/AGENTS/CLAUDE. None of that is justified by a request. Easier to remove the dead files now and reintroduce a working Dockerfile when there's an actual demand for containerised OCP, with proper documentation alongside. Refs: audit (dead infrastructure cleanup). Co-authored-by: dtzp555 <dtzp555@gmail.com> |
||
|
|
d99534dc35 |
chore(repo): add .gitignore for runtime artifacts; commit scripts/heartbeat-field-check.sh (#55)
Two related changes — both repo-hygiene, no behavior impact. ### `.gitignore` (new) Ignores runtime artifacts that should never be tracked: - `logs/` and `*.log` — proxy.log, last_send.log, heartbeat-field-check log - `node_modules/` — dependency cache - `.env`, `.env.*` — local secrets/config - `.DS_Store`, `*.swp`, `*~` — editor/OS scratch `logs/` was previously untracked-but-present in working trees; the new ignore makes that intent explicit and prevents accidental commits. ### `scripts/heartbeat-field-check.sh` (commit existing untracked file) This script was authored as a one-shot field-evidence gatherer for the v3.12.0 SSE heartbeat work (PR #49 / issue #47). It fired successfully on 2026-05-02 09:00 Australia/Brisbane via launchd (`~/Library/LaunchAgents/dev.ocp.heartbeat-check.plist`) and posted a summary comment to issue #47. Useful tooling pattern (one-shot field check + launchd schedule + dry-run flag), worth keeping under version control rather than letting it die in an untracked working tree. Verification: `git status` clean after both adds. Co-authored-by: dtzp555 <dtzp555@gmail.com> |
||
|
|
39ca20536e |
docs(governance): correct CLAUDE.md + AGENTS.md to reflect actual alignment.yml blacklist (#56)
Both `CLAUDE.md` (line 25) and `AGENTS.md` (line 46) previously claimed the `alignment.yml` blacklist included two tokens — `api/oauth/usage` and `api/usage`. The actual workflow has a single token in `BLACKLIST`: `api.anthropic.com/api/oauth/usage`. Doc-vs-CI drift. Fix is doc-side only — `alignment.yml` itself is unchanged because adding a second blacklist token (`api/usage`) is a constitutional change that requires an `ALIGNMENT.md` amendment PR per ALIGNMENT.md Amendment Procedure. If `api/usage` should be added to the blacklist, that's a separate PR with ALIGNMENT.md amendment + reviewer sign-off. Refs: audit findings (governance doc accuracy). Co-authored-by: dtzp555 <dtzp555@gmail.com> |
||
|
|
8b3f50912e |
chore(repo): remove v2.x stale openclaw-claude-proxy/ folder + dead start.sh (#54)
Both removed: - `openclaw-claude-proxy/` — v2.4.0 stale duplicate of the proxy. Current proxy is `server.mjs` at repo root (v3.x); the nested folder was an early packaging artifact that never got deleted. Audit finding H1. - `start.sh` — hardcoded `/Users/taodeng/.openclaw/...` paths that only ever worked on the original maintainer's home directory. The real install is produced by `setup.mjs` (see setup.mjs:212-237 which writes the correct per-user start hook). Audit findings H5, H6 (path leak + dead script). Verification: `git grep "/Users/taodeng/"` returns 0 hits in tracked files. Refs: audit findings H1, H5, H6. Co-authored-by: dtzp555 <dtzp555@gmail.com> |
||
|
|
780462c763 |
fix(cli): set DISABLE_AUTOUPDATER=1 in manual restart fallback (#52)
When ocp restart falls back to nohup (no launchctl/systemd service registered), prepend DISABLE_AUTOUPDATER=1 so the spawned server.mjs and any claude subprocess it spawns skip the in-binary auto-updater check. Why: claude-code's native binary contains an auto-updater that fires after each successful invocation. Partial install.cjs failures during update leave bin/claude.exe as an ASCII shim → spawnSync ENOEXEC → OCP slot lockup (symptoms in #37, #40 forensics). Setting this env at the launch site stops the trigger. Note: only covers the manual nohup path. Users with launchctl plist or systemd unit must add the env there too (plist EnvironmentVariables or systemd Environment=). Authoritative settings location is ~/.claude/settings.json env key — see learnings/claude_code_disable_autoupdate.md in cc-rules for the full 4-layer guidance. Verified: 24h+ stable on Mac mini after applying full multi-layer fix on 2026-04-30 04:20 (claude.exe mtime unchanged, OCP uptime 22h54m, 0 errors over 3 real calls). No server.mjs change — ALIGNMENT.md unaffected. Co-authored-by: dtzp555 <dtzp555@gmail.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> |
||
|
|
ca2d23d230 |
chore(speckit): add compatibility/metadata frontmatter to SKILL.md files (refs #39) (#50)
Backfill the two YAML frontmatter blocks that upstream
`specify_cli/agents.py:build_skill_frontmatter()` emits:
compatibility: "Requires spec-kit project structure with .specify/ directory"
metadata:
author: github-spec-kit
source: claude:templates/commands/<cmd>.md
Applied uniformly across all 9 speckit-* SKILL.md files. Each file gets the
two blocks appended to its existing frontmatter (between the closing fields
and the closing `---`). No functional change — these fields are informational
only, per #39 body and PR #38 reviewer note.
Verified:
- All 9 files have `compatibility:` key (grep -lc, 9/9)
- All 9 files have `metadata:` block with `author: github-spec-kit` (9/9)
- All 9 files have `source: claude:templates/commands/<cmd>.md` matching the
command name (9/9)
- No existing fields modified or removed; pure additive +4 lines per file
Upstream reference (build_skill_frontmatter):
https://github.com/github/spec-kit/blob/main/src/specify_cli/agents.py
|
||
|
|
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> |