Compare commits

...
Author SHA1 Message Date
taodengandClaude <claude-opus> <noreply@anthropic.com> 6394ca3265 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>
2026-06-13 12:19:51 +10:00
dtzp555-maxGitHubtaodengClaude <claude-fable-5> <noreply@anthropic.com>
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>
2026-06-10 21:56:56 +10:00
dtzp555-maxGitHubtaodengClaude <claude-opus> <noreply@anthropic.com>
3322d7bdae feat(tui): per-path concurrency limit + /health observability (PR-B) (#139)
Two P1 audit fixes for the TUI subscription-pool bridge (ADR 0007). The
default path (CLAUDE_TUI_MODE unset) is unchanged except the additive
/health `tui` block (enabled:false when off).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Closes #125.

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

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

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

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

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

Closes #115.

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

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

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

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

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

Closes #114.

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

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

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

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

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

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

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

Closes #112.

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

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

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

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

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

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

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

Closes #111.

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

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

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

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

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

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

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

Closes #110.

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

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

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

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

Closes #109.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 22:12:26 +10:00
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>
2026-05-31 15:44:25 +10:00
1b5a742711 chore(release): v3.17.1 — code-audit P1/P2 hardening (crash fixes + multi-tenant gates) (#107)
Co-authored-by: dtzp555 <dtzp555@gmail.com>
2026-05-31 13:19:10 +10:00
05a984df89 fix: OCP code-audit P1+P2 hardening (crash bugs + multi-tenant gates) (#106)
* fix(daemon): P1-1 guard proc.stdin against EPIPE crash

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

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

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

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

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

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

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

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

Hardening per OCP code audit; TUI path behaviour fix.

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

Co-authored-by: dtzp555 <dtzp555@gmail.com>
2026-05-13 06:42:15 +10:00
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>
2026-05-13 06:14:37 +10:00
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 593d0dc (initial
release) and never changed. The single observation of 3478 is the
maintainer's Mac mini, whose plist was rewritten with --port 3478
during a 2026-05-08 PR #71 dogfood smoke-test accident (see
~/.cc-rules/memory/learnings/subagent_setup_mjs_prod_host_collision.md).
The drift was never reconciled and v3.16.1 mistakenly canonised the
post-accident port as the new default.

This release reverts:
- ocp-plugin/index.js fallback → http://127.0.0.1:3456
- openclaw.plugin.json configSchema.proxyUrl.default → http://127.0.0.1:3456
- README §Environment Variables CLAUDE_PROXY_PORT default → 3456
- top-level package.json → 3.16.2

PR #95's env-reading path (OCP_PROXY_URL → CLAUDE_PROXY_PORT → fallback)
is preserved — that part was good design and stays. Only the hardcoded
fallback default changes.

Hosts whose OCP plist injects a non-default port must also inject the
same CLAUDE_PROXY_PORT into the OpenClaw plist for the plugin to follow
(documented in the new index.js comment block).

Mac mini's plist was reverted from 3478 to 3456 as part of this deploy
(per-host correction; no source code reflects host-specific state).

CHANGELOG includes an explicit erratum entry under v3.16.1 marking it
superseded.

Process note: this PR was triggered by maintainer asking "why was the
port changed?" — the answer revealed I (PM) wrote v3.16.1's CHANGELOG
without running `git log -G "3478" -- setup.mjs`. Iron Rule 2
(evidence-first) was violated. Future commits asserting historical
facts must include the grep that confirmed them.

No cli.js citation needed: OCP-internal plugin + docs, no server.mjs
change.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 12:17:13 +10:00
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>
2026-05-12 11:51:54 +10:00
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>
2026-05-11 07:33:13 +10:00
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>
2026-05-11 07:25:40 +10:00
37 changed files with 5863 additions and 249 deletions
+33 -7
View File
@@ -4,22 +4,46 @@
<!-- One or two sentences describing the change and why it is in scope for OCP. -->
## Endpoint Class (REQUIRED)
Per `ALIGNMENT.md` and ADR 0006, every PR that touches a network-facing endpoint must declare its class. Pick the most specific applicable class (Hybrid covers PRs that touch both A and B):
- [ ] **Class A** — forwards a `cli.js` operation (e.g., `/v1/messages`, `/api/oauth/*`, or the Anthropic-side wire call inside `/usage`)
- [ ] **Class B** — extends an OCP-owned compatibility endpoint (per ADR 0006). Sub-bucket:
- [ ] B.1 — OpenAI-compatibility surface (`/v1/chat/completions`, `/v1/models`)
- [ ] B.2 — OCP-administrative surface (`/health`, `/dashboard`, `/sessions`, `/logs`, `/status`, `/settings`, `/api/keys*`, `/api/usage`, `/cache*`)
- [ ] **Hybrid** — touches both classes (e.g., `/usage` if the PR modifies both the Anthropic wire call AND the local synthesis layer). Both evidence sections below must be filled.
- [ ] **Not endpoint-touching** — refactor / docs / tooling that does not modify any request handler. Skip both evidence sections; explain in Summary.
## Claude Code Alignment Evidence (REQUIRED)
Per `ALIGNMENT.md`, every PR that touches `server.mjs` or any network-facing surface must fill out this section. PRs with this section blank or unchecked will receive a `request changes` review and cannot be merged.
PRs with the relevant evidence section blank or unchecked will receive a `request changes` review and cannot be merged.
### If Class A
- [ ] **Corresponding `cli.js` reference.** I have identified the `cli.js` function and line range that performs the operation this PR forwards. Citation (format `cli.js:NNNN` or `cli.js vE4 <functionName>`):
<!-- e.g. cli.js:18423-18467 (function: sendUserMessage) -->
- [ ] **If `cli.js` does not perform this operation**, I have stated this explicitly below and justified the scope under `ALIGNMENT.md` Rule 2. (Note: in almost all cases this means the PR should be closed, not merged. Proxy layers do not invent endpoints.)
- [ ] **If `cli.js` does not perform this operation**, I have stated this explicitly below and justified the scope under `ALIGNMENT.md` Rule 2. (Note: in almost all cases this means the PR should be closed, not merged. Proxy layers do not invent endpoints. If the endpoint is in fact Class B, switch the class above and use the Class B section instead.)
<!-- Justification, if applicable. Empty is fine when cli.js does perform the operation. -->
- [ ] **Commit message citations.** Every "Claude Code uses X" or "cli.js uses X" assertion in every commit of this PR is immediately followed by a `cli.js:NNNN` or `cli.js vE4 <functionName>` citation. I have verified this by rereading each commit message.
### If Class B
- [ ] **Authorizing ADR.** Cite the ADR number that authorizes the endpoint this PR modifies (e.g., "ADR 0006 — OpenAI shim scope"). For B.1 endpoints (`/v1/chat/completions`, `/v1/models`), this is ADR 0006. For grandfathered B.2 endpoints, this is "ADR 0006 (grandfathered as of v3.16.4)." For new B.2 endpoints, cite the endpoint's own authorizing ADR; if none exists, the PR cannot proceed — the authorizing ADR must be drafted and merged first.
<!-- e.g., ADR 0006 -->
- [ ] **Specification citation.** For B.1 endpoints, link to the relevant section of OpenAI's `/v1/chat/completions` specification (https://platform.openai.com/docs/api-reference/chat/create), including the specific field or behaviour being implemented. For B.2 endpoints with their own ADR, cite the ADR section that specifies the behaviour. For grandfathered B.2 endpoints, the PR must be a behaviour-preserving refactor — link the existing handler code being modified.
<!-- B.1 example: OpenAI chat/completions, `response_format` parameter, https://platform.openai.com/docs/api-reference/chat/create#chat-create-response_format -->
<!-- B.2 example: ADR 00NN § "Behaviour" -->
- [ ] **No invention beyond the specification.** I confirm this PR does not introduce any field or behaviour not present in OpenAI's spec for the endpoint (B.1) or beyond the scope of the authorizing ADR (B.2). For grandfathered B.2 endpoints, I confirm the change is behaviour-preserving (no contract drift). If something the user actually wants is not in the spec, the right answer is to close this PR and propose an upstream spec change or a new ADR.
## Type of change
- [ ] Bug fix (alignment with existing `cli.js` behavior)
- [ ] Feature (new `cli.js` behavior now surfaced through OCP)
- [ ] Bug fix (alignment with existing `cli.js` behavior, or with the cited spec / ADR for Class B)
- [ ] Feature (new `cli.js` behavior now surfaced through OCP, or new field already in OpenAI's spec for Class B)
- [ ] Refactor (no wire-level behavior change)
- [ ] Deletion (unalignable feature removal per `ALIGNMENT.md` Unalignable Policy)
- [ ] Documentation / governance
@@ -28,14 +52,16 @@ Per `ALIGNMENT.md`, every PR that touches `server.mjs` or any network-facing sur
Reviewers: this section is for you, not the author. Do not approve until every box is checked.
- [ ] I opened `cli.js` at the cited line range and confirmed the operation matches.
- [ ] If Class A, I opened `cli.js` at the cited line range and confirmed the operation matches. If Class B, I opened the OpenAI spec at the cited section (B.1) or the authorizing ADR (B.2) and confirmed the behaviour described in this PR matches the cited reference.
- [ ] I ran (or confirmed CI ran) `.github/workflows/alignment.yml` and it passed.
- [ ] I am not the commit author of any commit in this PR (Iron Rule 10).
- [ ] If the PR asserts scope without a `cli.js` citation, I confirmed the justification is sound per `ALIGNMENT.md` Rule 2.
- [ ] If the PR asserts scope without a `cli.js` citation (Class A) or without an ADR (Class B), I confirmed the justification is sound per `ALIGNMENT.md` Rule 2 and ADR 0006.
- [ ] If the PR is Class B and adds a new endpoint or new method, I confirmed the authorizing ADR lands in the same merge or before this PR.
## Related
- `ALIGNMENT.md` Rule(s) invoked: <!-- e.g. Rule 3 -->
- `ALIGNMENT.md` Rule(s) invoked: <!-- e.g. Rule 3, or Rule 3 (Class B mapping) -->
- Authorizing ADR (Class B only): <!-- e.g. ADR 0006 -->
- Related issue / prior PR: <!-- #NNN -->
- Historical lesson reference (if relevant): <!-- e.g. 2026-04-11 drift, b87992f -->
+87 -4
View File
@@ -4,6 +4,11 @@ on:
pull_request:
paths:
- 'server.mjs'
- 'setup.mjs'
- 'scripts/**'
- 'lib/**'
- 'ocp'
- 'ocp-connect'
- '.github/workflows/alignment.yml'
jobs:
@@ -24,10 +29,14 @@ jobs:
exit 0
fi
# Known-hallucinated tokens. Extend only via an ALIGNMENT.md amendment PR.
# Each token is matched as a fixed string against server.mjs only.
# Blacklisted tokens — two kinds (see ALIGNMENT.md "OAuth token-host verification"):
# (1) known LLM hallucinations (e.g. the 2026-04-11 /api/oauth/usage drift), and
# (2) pinned wrong-host variants of a VERIFIED Class A endpoint (a hit means a
# drift to a known-wrong host, not necessarily a hallucination).
# Extend only via an ALIGNMENT.md amendment PR. Matched as fixed strings vs server.mjs.
BLACKLIST=(
"api.anthropic.com/api/oauth/usage"
"console.anthropic.com/v1/oauth/token"
)
FAIL=0
@@ -46,8 +55,8 @@ jobs:
============================================================
server.mjs contains a token on the OCP alignment blacklist.
These tokens were introduced by LLM hallucinations and do
not appear in cli.js at any shipped Claude Code version.
These tokens are either LLM hallucinations that never appeared in cli.js,
or pinned wrong-host variants of a verified Class A endpoint (a drift).
See ALIGNMENT.md -> "Historical Lesson: The 2026-04-11 Drift"
(commit b87992f) for the full incident record.
@@ -66,6 +75,80 @@ jobs:
echo "Blacklist scan clean."
port-spot:
name: port literal SPOT (hard fail)
# Background: from 2026-05-08 (PR #71 dogfood accident) through 2026-05-13
# a hardcoded "3478" in scripts/upgrade.mjs + scripts/doctor.mjs cascaded
# into wrong baseUrl writes for the OpenClaw "claude-local" provider,
# taking out the "大内总管" Telegram agent.
#
# Rule: the only places allowed to write a literal port number in source
# are (a) lib/constants.mjs (the SPOT), (b) bash scripts ocp / ocp-connect
# (which can't import .mjs and must keep the literal in sync — flagged
# with a `// keep in sync with lib/constants.mjs` style comment), and
# (c) test-features.mjs (intentionally pins historical ports for plist /
# systemd parser tests). Everything else MUST import from lib/constants.mjs.
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Scan for hardcoded port literals outside SPOT
shell: bash
run: |
set -euo pipefail
# Files/paths exempt from the SPOT requirement.
EXEMPT_REGEX='^(lib/constants\.mjs|test-features\.mjs|ocp|ocp-connect|CHANGELOG\.md|README\.md|docs/|\.github/workflows/alignment\.yml)'
# Hardcoded port literals to forbid in non-exempt source.
FORBIDDEN_PORTS=("3478" "3456")
FAIL=0
for port in "${FORBIDDEN_PORTS[@]}"; do
HITS="$(git ls-files | grep -E '\.(mjs|js|ts|json)$' \
| xargs grep -n -E "[^0-9]${port}[^0-9]" 2>/dev/null \
| grep -v -E "${EXEMPT_REGEX}" \
|| true)"
if [ -n "$HITS" ]; then
echo "::error::Hardcoded port literal '${port}' found outside lib/constants.mjs:"
echo "$HITS"
FAIL=1
fi
done
if [ "$FAIL" -ne 0 ]; then
cat <<'EOF'
============================================================
PORT LITERAL SPOT VIOLATION
============================================================
A hardcoded TCP port literal was found in a source file
that should import from lib/constants.mjs instead.
Background: this rule exists because between 2026-05-08 and
2026-05-13 a stray hardcoded "3478" in scripts/upgrade.mjs
and scripts/doctor.mjs cascaded into downstream OpenClaw
config writes, taking out the OpenClaw Telegram agent.
See v3.16.3 CHANGELOG and lib/constants.mjs header comment.
Required action:
1. Import DEFAULT_PORT (or related constant) from
lib/constants.mjs instead of hardcoding the literal.
2. If the file genuinely cannot import .mjs (e.g. bash
script), add it to EXEMPT_REGEX in this workflow and
add a `keep in sync with lib/constants.mjs` comment
at the reference.
3. For test files that intentionally pin historical ports
(test-features.mjs), the regex already exempts them.
============================================================
EOF
exit 1
fi
echo "Port SPOT scan clean."
commit-citation:
name: commit message citation (soft check)
runs-on: ubuntu-latest
+86 -4
View File
@@ -8,10 +8,14 @@
OCP (Open Claude Proxy) is a **proxy layer** for the Claude Code CLI. It forwards, observes, and multiplexes the traffic that `cli.js` already emits. It is **not** an extension layer. If `cli.js` does not perform a given operation, or performs it differently, OCP does not invent one.
This Core Principle applies in full to **Class A** endpoints (the `cli.js`-mirror surface). A second class of endpoint — **Class B**, the OCP-owned compatibility surface — has its own scope discipline anchored to its own specification authority. See "Scope Clarification: OCP-Owned Compatibility Endpoints (Class B)" below and ADR 0006.
---
## Rules
The following Rules apply to **Class A operations** (the `cli.js`-mirror surface — the inbound `/v1/messages` forwarding route, the outbound `/v1/messages` wire call used by `handleUsage()` for rate-limit-header extraction, the OAuth bearer machinery, and any future operations OCP forwards from `cli.js` to Anthropic). For the Class B mapping of each rule, see the Class B section below.
1. **Rule 1 (Grep First).** Before adding, renaming, or changing any endpoint, header, parameter, or response shape, the author must `grep` the reference `cli.js` and record the exact line numbers in the commit message and PR body. An absent grep hit is itself a finding and must be declared.
2. **Rule 2 (No Invention).** OCP must not introduce endpoints, headers, request fields, or response fields that are not present in `cli.js`. Speculative "Claude Code probably uses X" statements are prohibited. If the behavior is not observable in `cli.js`, the feature is out of scope.
@@ -48,6 +52,26 @@ OCP (Open Claude Proxy) is a **proxy layer** for the Claude Code CLI. It forward
The audit pin is updated once per year (see Annual Alignment Audit) and whenever a drift incident forces a re-verification.
### OAuth token-host verification (2026-05-31)
Motivating evidence: the 2026-05-31 code audit (issues #112 / #119 / #123). The OAuth bearer
machinery is a Class A surface (Rules 15). Because `cli.js` now ships as a
compiled binary, the token-refresh host was re-verified against `claude.exe` (Claude Code
`2.1.154`) on 2026-05-31 using the compiled-binary protocol — `strings` on the Mach-O, **no
live OAuth probe** (a `refresh_token` grant would rotate the operator's real credentials):
- **Verified host:** `https://platform.claude.com/v1/oauth/token` — present in the binary
byte-for-byte, paired with `OAUTH_CLIENT_ID` in the same `prod` config object (matches
`server.mjs` `OAUTH_TOKEN_URL` / `OAUTH_CLIENT_ID`). The legacy `console.anthropic.com/v1/oauth`
host is absent (0 hits).
- **Pinned wrong-host variant:** `console.anthropic.com/v1/oauth/token` is added to the
`alignment.yml` blacklist so a future accidental revert to the legacy host hard-fails CI.
The blacklist therefore now holds two kinds of token: (1) known hallucinations (e.g.
`api.anthropic.com/api/oauth/usage`, the 2026-04-11 drift), and (2) pinned wrong-host variants
of a *verified* Class A endpoint. A blacklist hit means either a re-introduced hallucination
**or** a drift to a known-wrong host — both are alignment failures under Rules 2 and 3.
---
## Historical Lesson: The 2026-04-11 Drift
@@ -68,20 +92,78 @@ On 2026-04-11, commit `b87992f` ("fix: use dedicated /api/oauth/usage endpoint f
## Unalignable Policy
A feature is **unalignable** if, after a good-faith search, it cannot be mapped to a specific `cli.js` line range or function.
A feature is **unalignable** if, after a good-faith search, it cannot be mapped to a specific `cli.js` line range or function (Class A) or to a specific OpenAI specification section AND an authorizing ADR (Class B).
- Unalignable features are **deleted**, not disabled, not feature-flagged, not deprecated.
- Deletion is the default outcome of an alignment audit finding. The burden of proof is on the feature, not on the auditor.
- A deletion PR does not require user-facing deprecation notice, because the feature was never legitimately in scope.
- If a user workflow depended on an unalignable feature, the correct remediation is to upstream the behavior into `cli.js` or to move it out of OCP into a separate tool. OCP does not retain it.
- If a user workflow depended on an unalignable feature, the correct remediation is to upstream the behavior into `cli.js` (Class A) or into OpenAI's spec (Class B) or to move it out of OCP into a separate tool. OCP does not retain it.
---
## Scope Clarification: OCP-Owned Compatibility Endpoints (Class B)
OCP has two classes of endpoint. Rules 15 above were drafted in the aftermath of the 2026-04-11 forwarding drift and are written in the language of a one-to-one proxy; they apply verbatim to **Class A** endpoints. **Class B** endpoints — the OCP-owned compatibility surface where `cli.js` is not the wire authority — have their own scope discipline, anchored to their own specification authority. The full rationale lives in **ADR 0006 (OpenAI Shim Scope)**.
**Class A**`cli.js`-mirror endpoints. The endpoint exists because `cli.js` performs the equivalent operation and OCP forwards, observes, or multiplexes that operation. Rules 15 above apply verbatim. Citation format: `cli.js:NNNN` or `cli.js vE4 <functionName>`.
**Class B** — OCP-owned compatibility endpoints. The endpoint exists because OCP itself surfaces it, with no `cli.js` analogue. Two sub-buckets: **B.1** (OpenAI-compatibility surface — protocol authority is OpenAI's `/v1/chat/completions` specification) and **B.2** (OCP-administrative surface — authority is the ADR that authorized the endpoint's existence).
### Grandfather provision for existing B.2 inventory
ADR 0006 retroactively authorizes the B.2 endpoints listed in the inventory table below, **frozen at their current behaviour as of v3.16.4**. This is a one-time provision; it does not extend to new B.2 endpoints or to B.1 endpoints. Any change to the contract (request shape, response shape, semantics) of a grandfathered B.2 endpoint is treated as a new authorization request and requires either a behaviour-preserving refactor PR or its own ADR. Any new B.2 endpoint, or any new method on a grandfathered B.2 endpoint, requires its own ADR before merge.
### Current Class B inventory
| Endpoint | Method | Sub-bucket | Authorizing ADR |
|---|---|---|---|
| `/v1/chat/completions` | POST | B.1 (OpenAI-compat) | ADR 0006 |
| `/v1/models` | GET | B.1 (OpenAI-compat) | ADR 0006; content sourced from `models.json` per ADR 0003 |
| `/health` | GET | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
| `/dashboard` | GET | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
| `/sessions` | GET, DELETE | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
| `/logs` | GET | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
| `/status` | GET | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
| `/settings` | GET, PATCH | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
| `/api/keys` | GET, POST | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
| `/api/keys/:id` | DELETE | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
| `/api/keys/:id/quota` | GET, PATCH | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
| `/api/usage` | GET | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
| `/cache/stats` | GET | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
| `/cache` | DELETE | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
**Hybrid note.** `/usage` is a hybrid endpoint: the underlying call to `api.anthropic.com/v1/messages` (used to extract `anthropic-ratelimit-unified-*` headers, per the in-file comment block at `server.mjs` line 845849) is Class A and requires the standard `cli.js` citation; the local synthesis layer that adds `proxy:` stats and `models:` snapshot is Class B and is authorized by ADR 0006. A PR touching only the wire-call layer is Class A; a PR touching only the synthesis layer is Class B; a PR touching both must satisfy both citation requirements.
### Class B citation requirement
Class B PRs cite **the relevant specification section + the authorizing ADR**, in place of `cli.js:NNNN`. Examples:
- B.1: "OpenAI `chat/completions` API, `response_format` parameter (https://platform.openai.com/docs/api-reference/chat/create), authorized by ADR 0006."
- B.2 (grandfathered): "Authorized by ADR 0006 (grandfathered as of v3.16.4)."
- B.2 (with its own ADR): "Authorized by ADR 00NN (the ADR that originally authorized the endpoint)."
### Rule mapping for Class B
| Class A rule | Class B mapping |
|---|---|
| Rule 1 (Grep First) | Read the cited OpenAI spec section (B.1) or the authorizing ADR (B.2) before writing code. Record the spec URL and ADR number in the PR body. |
| Rule 2 (No Invention) | OCP must not introduce fields or behaviour not present in OpenAI's spec for the endpoint (B.1) or outside the scope of the authorizing ADR (B.2). For grandfathered B.2 endpoints, "scope" is the v3.16.4 behaviour snapshot. |
| Rule 3 (Match the Implementation) | Match OpenAI's spec wire-format (B.1) or the ADR's specified behaviour (B.2). |
| Rule 4 (Unalignable Features Are Deleted) | A Class B endpoint that maps to nothing in OpenAI's spec **and** lacks an authorizing ADR (including not being in the grandfather inventory) is unalignable and is deleted on the same terms as a Class A unalignable feature. |
| Rule 5 (Cite Line Numbers in Commits) | Cite the OpenAI spec section URL + authorizing ADR number in the commit body (B.1) or the authorizing ADR number alone (B.2). |
### New Class B endpoint procedure
Any new Class B endpoint, or any new method on an existing Class B endpoint (including grandfathered ones), requires its own ADR before merge. An "ADR-less" new Class B endpoint is itself an alignment finding under Rule 4.
---
## Annual Alignment Audit
- **Date:** 11 April each year (the anniversary of the `b87992f` drift).
- **Scope:** Diff the current `cli.js` against the pinned SHA-256 in the Golden Reference section. For every network call in `server.mjs`, re-verify that the corresponding `cli.js` reference still exists at the cited line numbers (adjust citations if line numbers shifted across Claude Code versions).
- **Output:** A signed audit note committed to `docs/alignment-audits/YYYY-04-11.md`, updating the pin.
- **Scope (Class A):** Diff the current `cli.js` against the pinned SHA-256 in the Golden Reference section. For every network call in `server.mjs`, re-verify that the corresponding `cli.js` reference still exists at the cited line numbers (adjust citations if line numbers shifted across Claude Code versions).
- **Scope (Class B):** Audit B.1 endpoints against OpenAI's current `/v1/chat/completions` specification snapshot. Audit B.2 endpoints against their authorizing ADR — for grandfathered endpoints, verify the endpoint behaviour still matches its v3.16.4 snapshot; for ADR-specific endpoints, verify behaviour still matches the ADR. The B.1 specification pin lives in `docs/openai-compat-pin.md` (created alongside the first B.1 audit; not required for ADR 0006 to land).
- **Output:** A signed audit note committed to `docs/alignment-audits/YYYY-04-11.md`, updating the Class A pin and (once `docs/openai-compat-pin.md` exists) the B.1 pin.
- **Failure mode:** Any audit finding that cannot be reconciled triggers an immediate deletion PR per the Unalignable Policy.
---
+244
View File
@@ -1,5 +1,249 @@
# Changelog
## v3.20.0 — 2026-06-10
TUI-mode billing-safety hardening for the 2026-06-15 Anthropic billing split. A 5-dimension multi-agent audit (adversarial verification + live tests on all three hosts — PI231 / Oracle / Mac mini, claude 2.1.104 / 2.1.114 / 2.1.170) found the TUI subscription-pool path could silently bill the metered Agent SDK pool or poison the cache under realistic failure modes. Three PRs, each with a fresh-context reviewer (Iron Rule 10) and CI; the default path (`CLAUDE_TUI_MODE` unset) is byte-for-byte unchanged.
### TUI — honesty & cache correctness (#137)
- **C-1** — `callClaudeTui` now throws on a claude-CLI auth-failure banner (e.g. `Please run /login · API Error: 401 …`, `Failed to authenticate. API Error: 401 …`) instead of returning it as a real answer, so it is never cached, singleflight-shared, or counted as a model success. Conservative detector (whole trimmed text ≤100 chars + `API Error: 4xx` + auth keyword + no code/quote char); overridable via `CLAUDE_TUI_ERROR_PATTERNS`. Live-reproduced on PI231.
- **C-2** — `readTuiTranscript` distinguishes a complete turn from a wallclock-truncated partial (`truncated` flag); `callClaudeTui` throws `tui_wallclock_truncated` so a partial is never cached or counted as success.
- **C-3** — `verifyEntrypoint` reads the `entrypoint` field from any transcript line, not just `{system, turn_duration}` — some claude builds emit zero turn_duration lines (live-confirmed on Oracle's claude 2.1.114), which previously left the billing-drift assertion blind on those builds.
- **C-4 (paste)** — short prompts (e.g. `hi`) could never pass paste-landing detection; threshold lowered. Live-reproduced on PI231.
### TUI — concurrency & observability (#139)
- **Concurrency** — `OCP_TUI_MAX_CONCURRENT` (default 2) bounds concurrent interactive `claude` boots via a queuing semaphore (`lib/tui/semaphore.mjs`); the slot is released on throw so honesty-gate / spawn failures never leak it; bounded wait-queue → `tui_queue_full` (503). Independent of the global `MAX_CONCURRENT` (8) — a TUI turn is a heavy per-request cold-boot of tmux+claude + up to 120s wallclock.
- **Observability** — additive `/health` `tui` block (`enabled` / `entrypointMode` / `lastEntrypoint` / `entrypointMismatches` / `inflight` / `maxConcurrent`) so an operator can poll for a silent `sdk-cli` metered-pool drift (the audit's top risk) instead of grepping journald. Authorized by the ADR 0007 PR-B amendment under the ALIGNMENT grandfather provision (additive, behaviour-preserving — every pre-existing `/health` field unchanged).
### Operations (#138)
- `docs/runbooks/615-canary.md` — the 2026-06-15 credit-balance canary: quiesce, read the Agent SDK credit balance (manual — no programmatic API exists for that pool; OCP's `/usage` headers are subscription rate-limit data, not the credit pool), one TUI canary turn, confirm `entrypoint:cli` in the transcript, green/red decision tree, periodic auto-mode self-classification mini-canary.
- `docs/runbooks/tui-flip-rollback.md` — flip/rollback per deployment (systemd `daemon-reload`; launchd `bootout`/`bootstrap`, not `kickstart -k`).
- `setup.mjs` auth quick-test gated behind `OCP_SKIP_AUTH_TEST=1` (the `claude -p` probe draws from the metered Agent SDK pool after 6/15).
### New environment variables
- `OCP_TUI_MAX_CONCURRENT` — max concurrent interactive TUI turns (default 2) (#139).
- `OCP_SKIP_AUTH_TEST` — skip the `claude -p` auth probe in `setup.mjs` (default off) (#138).
## v3.19.0 — 2026-06-02
TUI-mode reliability + proxy-purity release. Two fixes diagnosed and verified live on both test hosts (PI231 / Oracle, claude 2.1.104 / 2.1.114), each its own PR with a fresh-context reviewer (Iron Rule 10), then an adversarial multi-host test battery (0 hangs / 0 crashes / 0 injection / 0 leaks). The default path (`CLAUDE_TUI_MODE` unset) is byte-for-byte unchanged.
### TUI
- **#130** — Fixed the "stuck typing" hang on large multi-line prompts. Three root causes: (1) terminal-turn detection only recognized `{system, turn_duration}`, which older claude builds (e.g. 2.1.114) don't emit → the reader ran to the wallclock and returned partial text; now also accepts an `assistant` line with a final `stop_reason` (`end_turn`/`stop_sequence`/`max_tokens`), while `tool_use` stays non-terminal. (2) Large prompts pasted via `send-keys -l` delivered embedded newlines as separate Enter events → the prompt never landed; now uses `tmux load-buffer` + `paste-buffer -p` (bracketed paste, atomic). (3) The paste-landed check false-positived on claude's empty curly-quote placeholder → Enter fired into an empty box; now positive-signal-only (`[Pasted text]` / prompt text) with a readiness/paste-verify poll + fast-fail (deterministic ~5s error instead of a 120s wallclock hang).
- **#4** — TUI-mode never injects the host's `CLAUDE.md` / auto-memory into proxied turns. OCP is a proxy: the proxied client (OpenClaw / an IDE) owns its own context and memory. `buildTuiCmd` now always sets `CLAUDE_CODE_DISABLE_CLAUDE_MDS` + `CLAUDE_CODE_DISABLE_AUTO_MEMORY` (unconditional — proxy purity is not an opt-in). Verified live with a marker `CLAUDE.md`: obeyed by the proxied turn before the fix, blocked after, on both hosts. Residual host-context vectors (managed-policy / `settings.json` / output-styles) tracked in #133. The env is delivered via an `env`-prefix on the tmux pane command (tmux does not forward the spawning process's environment, and `new-session -e` requires tmux ≥3.2 while the cloud host runs 2.7).
## v3.18.0 — 2026-06-01
Hardening release from a multi-agent code audit (1 P0 + 14 P2 + 2 P3 findings, each adversarially verified and independently reviewed) plus three follow-ups (#123#125). Every change shipped as its own PR with a fresh-context reviewer (Iron Rule 10). The single-user default path (`AUTH_MODE=none`, no TUI) is behavior-identical **except** the `/health` change in #109.
### Security
- **#109 (P0)** — `/health` no longer advertises `PROXY_ANONYMOUS_KEY` to remote callers by default. The `anonymousKey` field is gated behind a new `PROXY_ADVERTISE_ANON_KEY=1` opt-in env var; localhost callers are always exempt. Prevents any LAN-reachable device from harvesting a working, quota-spending bearer credential from the unauthenticated `/health` endpoint. **Behavior change:** `ocp-connect` zero-config Path A now requires the server to set `PROXY_ADVERTISE_ANON_KEY=1`; otherwise pass `--key` or use anonymous access.
- **#114** — Dashboard escapes all DB-sourced strings (key names, usage rows) before `innerHTML`; the revoke button uses a `data-` attribute + listener instead of an inline `onclick` a quote could break out of; `POST /api/keys` validates key names server-side (`[A-Za-z0-9 ._-]{1,64}`).
- **#124** — Dashboard status/plan summary cards escaped too (uniform defense-in-depth over all `innerHTML` sinks).
- **#111** — Streaming error paths strip filesystem paths from claude error text / stderr before sending them to clients (`sanitizeError`), matching the non-streaming path.
### Reliability / correctness
- **#110** — Non-array `messages` is rejected with a 400 (was silently hanging the connection until socket timeout); OpenAI array `content` is flattened into the prompt instead of dumped as raw JSON; a streamed upstream error now emits an SSE `error` frame instead of a success-looking `finish_reason:"stop"`.
- **#111** — `res.on("close")` escalates SIGTERM→SIGKILL on client disconnect (closes a narrow re-occurrence of the #37 concurrency-slot leak on the hottest exit path); `overallTimer` is cleared on semantic completion so a slow-exiting child can't record a spurious post-success timeout; per-key quota is documented as best-effort (bounded overshoot ≤ `MAX_CONCURRENT`, cache hits uncounted).
- **#113** — CLI/installer hardening: `ocp-plugin` restart uses the live uid + `dev.ocp.proxy`/`ocp-proxy` labels and drops the unsafe `pkill` fallback; `ocp-connect` quotes + `chmod 600`s the persisted key; `setup.mjs` XML-escapes and newline-validates injected service-unit secrets.
### Alignment / governance
- **#112** — OAuth token-refresh host (`platform.claude.com/v1/oauth/token`) re-verified against the compiled cli.js v2.1.154 (`strings`, no live probe) and recorded in `ALIGNMENT.md`; usage-probe and default request model now derive from `models.json` (ADR 0003 SPOT) instead of hardcoded IDs.
- **#123** — The legacy `console.anthropic.com/v1/oauth/token` host is pinned in the `alignment.yml` blacklist so a future OAuth-host drift hard-fails CI; the blacklist now documents its dual purpose (known hallucinations + pinned wrong-host variants of a verified Class A endpoint).
### TUI
- **#115** — The TUI LAN gate refuses any non-loopback bind (not just literal `0.0.0.0`); the achieved `cc_entrypoint` is asserted each turn and a `tui_entrypoint_mismatch` warning is logged on a silent degrade to the metered sdk-cli pool.
### Refactor
- **#125** — `isLoopbackBind` extracted to `lib/net.mjs`, shared by `server.mjs` and the test suite (was duplicated via a copy-paste mirror).
### New environment variables
- `PROXY_ADVERTISE_ANON_KEY` — opt-in (default off); advertise `PROXY_ANONYMOUS_KEY` on the public `/health` body for remote zero-config discovery (#109).
## v3.17.1 — 2026-05-31
### Fix — code-audit P1/P2 hardening
Fixes from a multi-agent code audit (3 P1 + 5 P2, adversarially verified). The single-user default path (`AUTH_MODE=none`, no TUI) is behavior-identical.
**Availability / correctness (P1):**
- Guard `proc.stdin` against EPIPE — a fast-failing spawned `claude` (auth error, bad model, large prompt) no longer crashes the single-process daemon.
- Add `unhandledRejection`/`uncaughtException`/`clientError` safety nets + wrap all request-body read loops — a client aborting mid-upload no longer crashes the daemon.
- TUI transcript reader: only `turn_duration` is terminal (was also `tool_use`), which silently truncated any TUI turn that used a built-in tool.
**Security gates / cache integrity (P2):**
- `AUTH_MODE=multi`: the default spawn now passes `--disallowedTools` (Bash/Read/Write/Edit/…) so a guest prompt cannot drive operator-filesystem tools. Single-user path unchanged.
- `/sessions` (DELETE), `/settings` (PATCH), `/logs`, `/usage`, `/status` are now admin-gated (were dispatched before the admin check).
- Streaming path no longer caches an `is_error` response as success (cache-poisoning fix).
- TUI fail-loud guard extended to `none`+`0.0.0.0` (unless `OCP_TUI_ALLOW_LAN=1`) and `+ PROXY_ANONYMOUS_KEY`.
- TUI `send-keys` paste uses `-l` (literal) so a prompt equal to a tmux key token (e.g. `C-c`) is typed, not interpreted.
---
## v3.17.0 — 2026-05-31
### Provider — default claude invocation ported to stream-json + `--system-prompt` (Phase 6c)
OCP's default (non-TUI) claude spawn moves from `claude -p --output-format text` to `claude --output-format stream-json --verbose --no-session-persistence --system-prompt <wrapper>` (no `-p`). The NDJSON event stream is parsed into the assembled response. Benefits: ~64% per-request cost reduction and anti-hallucination via `--system-prompt` tool-use suppression. Clients see no API change — the OpenAI-compatible request/response shapes are identical. Faithful port of OLP's production-verified implementation; covered by 17 new stream-json parser tests.
⚠️ **Billing note:** from 2026-06-15 this default path carries `cc_entrypoint=sdk-cli` and bills against the Agent SDK credit pool. Use the new opt-in `CLAUDE_TUI_MODE` (below) to keep traffic on the Pro/Max subscription pool.
---
### feat(tui): opt-in CLAUDE_TUI_MODE — serve via interactive claude (cc_entrypoint=cli / subscription pool), single-user only; default stream-json path unchanged
From 2026-06-15 Anthropic routes `claude -p` / `--output-format` invocations to the Agent SDK credit pool (`cc_entrypoint=sdk-cli`). This feature adds an opt-in bridge: when `CLAUDE_TUI_MODE=true`, OCP serves each request via a real interactive `claude` session (no `-p`, no `--output-format`) so it carries `cc_entrypoint=cli` and bills against the Pro/Max subscription.
The complete string response is read from claude's native JSONL session transcript and replayed to callers as a normal OpenAI completion or chunked SSE. Clients see no API change. The default stream-json path is byte-for-byte unchanged when `CLAUDE_TUI_MODE` is unset.
**Security:** single-user / single-operator only. Never enable on a multi-user OCP. See ADR 0007 and README § "Subscription-pool (TUI) mode".
New env vars: `CLAUDE_TUI_MODE`, `CLAUDE_TUI_WALLCLOCK_MS`, `OCP_TUI_CWD`, `OCP_TUI_HOME`.
New ADR: `docs/adr/0007-tui-interactive-mode.md`.
New modules: `lib/tui/transcript.mjs`, `lib/tui/session.mjs` (shipped in preceding commits on this branch).
---
### Model — add claude-opus-4-8
Add `claude-opus-4-8` as the newest Opus to `models.json` (index 0, newest first). Repoint `aliases.opus` from `claude-opus-4-7` to `claude-opus-4-8`. `claude-opus-4-7` remains in the list callable by literal id. `legacyAliases.claude-opus-4` left pointing at `claude-opus-4-7` (no change — legacy alias tracks the prior generation). README Available Models table and model-count references updated accordingly.
---
## v3.16.4 — 2026-05-13
### Refactor — port-literal SPOT + CI guardrail
Closes the structural side of the port-drift cascade addressed by v3.16.2
and v3.16.3. Those two releases reverted plist / plugin / scripts back to
3456 line-by-line, but the underlying invitation to drift — a hardcoded
port literal scattered across six source files — was still intact.
Changes:
- **New `lib/constants.mjs`** — single source of truth for shared literals.
Exports `DEFAULT_PORT = 3456`, `LOCAL_HOST = "127.0.0.1"`,
`OPENAI_API_BASE = "/v1"`, `LOCAL_PROXY_URL`.
- **`server.mjs:127`, `setup.mjs:36`, `scripts/upgrade.mjs:137`,
`scripts/doctor.mjs:84` + `:205`, `scripts/sync-openclaw.mjs:73`** —
all replaced with imports from `lib/constants.mjs`. Behavior is
identical; the literal `3456` now exists in exactly one place per
language (`lib/constants.mjs` for `.mjs`, `ocp` + `ocp-connect` for
bash, `test-features.mjs` for pinned historical-port tests).
- **`.github/workflows/alignment.yml`** — extended the path filter to
`setup.mjs`, `scripts/**`, `lib/**`, `ocp`, `ocp-connect`. Added a new
`port-spot` hard-fail job that greps for any hardcoded `3478` or `3456`
literal in `.mjs/.js/.ts/.json` outside the EXEMPT_REGEX (which lists
`lib/constants.mjs`, `test-features.mjs`, the bash CLIs, docs, and the
workflow itself). Any future PR re-introducing a hardcoded port
literal will be blocked at CI before it can cascade.
- Doc comments in `server.mjs` env-var summary and `setup.mjs` usage
banner reworded so the literal `3456` no longer appears as
documentation text (CI grep is intentionally aggressive — it does not
parse comments — so doc strings reference `DEFAULT_PORT from
lib/constants.mjs` instead).
No behavior change for any user. `CLAUDE_PROXY_PORT` env var remains
the runtime override; the only difference is the unset-env fallback
now flows through one shared constant.
ALIGNMENT.md hard-requirements: this PR modifies `server.mjs` (one-line
import + one literal swap, mechanical). No cli.js operation changed;
the citation requirement does not apply. SPOT principle (Rule 2 spirit)
is the entire motivation.
## v3.16.3 — 2026-05-13
### Fixes — completes v3.16.2 port-drift revert
v3.16.2 reverted the plugin / `openclaw.plugin.json` / README / Mac mini
plist back to `3456` (the historical source default since `593d0dc`), but
missed three places in `scripts/` that still defaulted to `3478`. Those
three lines were the residual cascade source: every time `ocp doctor` or
`ocp upgrade` ran without `CLAUDE_PROXY_PORT` in the env, they probed
`3478`, reported "OCP not responding" against a healthy 3456 instance,
and (in the case of OpenClaw sync follow-ups on the maintainer's host)
re-introduced 3478 into downstream config.
Changes:
- `scripts/upgrade.mjs:137` — default port `3478``3456`.
- `scripts/doctor.mjs:84` — default port `3478``3456`.
- `scripts/doctor.mjs:205` — default port `3478``3456`.
No behavior change for users who set `CLAUDE_PROXY_PORT` explicitly; env
still takes precedence. The fix only affects the unset-env fallback,
which now matches `server.mjs:126` and the rest of the codebase.
Test plan: existing `test-features.mjs` cases that pin
`CLAUDE_PROXY_PORT=3478` continue to pass — they use the env path, not
the default.
## v3.16.2 — 2026-05-12
### Fixes — corrects v3.16.1
The v3.16.1 fix was directionally correct (plugin now reads env first, falls back to a hardcoded default) but **the narrative and the hardcoded default were both wrong**.
What v3.16.1 said: "OCP server moved to 3478 default in v3.14+; plugin lagged at 3456."
What is actually true:
- **OCP server source default has been `3456` since `593d0dc` (initial release) and has never changed.** Every line in `server.mjs`, `setup.mjs`, and the `ocp` CLI still uses `3456` as the documented and code-level default.
- The single OCP installation observed on `3478` is the maintainer's Mac mini, whose plist was rewritten with `--port 3478` during a PR #71 dogfood smoke-test accident on 2026-05-08 (see `~/.cc-rules/memory/learnings/subagent_setup_mjs_prod_host_collision.md`). The plist drift was never reconciled back to source default, and v3.16.1 incorrectly canonised the post-accident value as if it had been a release decision.
This release:
- Restores the plugin fallback to `http://127.0.0.1:3456` to match server source default.
- Updates `openclaw.plugin.json` `configSchema.proxyUrl.default` back to `3456`.
- Restores README §"Environment Variables" `CLAUDE_PROXY_PORT` default to `3456`.
- Plugin reads `OCP_PROXY_URL` env (full URL) first, then `CLAUDE_PROXY_PORT` env (port only), then falls back to `3456`. Hosts whose OCP plist injects a non-default port must also inject the same `CLAUDE_PROXY_PORT` into the OpenClaw plist for the plugin to follow.
- Maintainer's Mac mini plist was reverted from `3478` to `3456` as part of this release deploy (no source change reflects this; it was a one-host correction).
### Governance
- No `cli.js` citation needed (no `server.mjs` change). ALIGNMENT.md Rule 2 not engaged.
## v3.16.1 — 2026-05-12 (superseded — narrative incorrect; see v3.16.2 erratum)
### Fixes (as shipped — note erratum above)
- **OCP plugin port lag** — `ocp-plugin/index.js` hard-coded `http://127.0.0.1:3456`. ~~While OCP server moved to 3478 in v3.14+,~~ **(corrected v3.16.2: no such move ever happened.)** The Mac mini's plist was on `3478` only as residue from a dogfood accident. Result: `/ocp` slash commands from the home Telegram bot returned "OCP error: fetch failed". v3.16.1 changed the plugin default to `3478` (wrong direction; v3.16.2 reverts to `3456`).
### Governance
- No `cli.js` citation needed (no `server.mjs` change). ALIGNMENT.md Rule 2 not engaged.
## v3.16.0 — 2026-05-10
### Features
- **`ocp doctor --check oauth`** (PR #93) — fast path that runs only the OAuth check, skipping
version detection / from-version / git operations / models endpoint. ~50ms vs. full doctor's
~200-500ms. Use cases: AI agent repair loops, post-`claude auth login` verify, quick health
gates. Help text in `cmd_doctor_help` now reflects working behaviour.
- **`ocp update --rollback --gc`** — manually garbage-collect old upgrade snapshots.
Retention policy: keep last 5 snapshots OR snapshots newer than 30 days OR the single most
recent (always-keep safety net). `--dry-run` previews. Successful `ocp update` runs auto-GC
at the end of the full path; light path does not (no snapshot created there).
### Behavior changes
- After a successful cross-minor `ocp update`, the auto-GC emits `[gc] removed N old snapshots`
to stderr if any were collected. Safe to ignore; manual gc is `ocp update --rollback --gc`.
### Governance
- No `cli.js` citation needed (no `server.mjs` change). ALIGNMENT.md Rule 2 not engaged.
- PR #93 (--check oauth) merged separately; this release bundles it with the GC feature.
## v3.15.1 — 2026-05-10
### Fixes
+163 -16
View File
@@ -50,6 +50,10 @@ OCP and the alternatives serve adjacent but distinct needs. Pick the one that fi
**Plain English**: `claude-code-router` is the routing-and-switching power tool — pick it if you want to mix Anthropic, OpenAI, Gemini, and local models behind one endpoint. `anthropic-proxy` is the minimal forwarder. **OCP focuses on disciplined `cli.js`-aligned forwarding plus subscription multiplexing** — pick it if you want to share one Claude Pro/Max subscription across IDEs, devices, and people, with LAN auth, quotas, and a governance contract that prevents endpoint drift.
### Related: OLP — Open LLM Proxy
OCP is Claude-only by design. If you want to spread across **multiple LLM providers** (not just Claude), see the sibling project **[OLP — Open LLM Proxy](https://github.com/dtzp555-max/olp)**: the same spawn-the-provider-CLI approach, but across several provider CLIs behind one OpenAI-compatible endpoint, with intelligent fallback chains. It grew out of OCP in response to Anthropic's 2026-06-15 billing split — the idea being to spread subscription/quota risk across more than one provider. OCP remains the focused, Claude-only option; OLP is the multi-provider one.
OCP is single-maintainer + LLM-assisted, currently pre-1.0. It runs the maintainer's daily Claude Code workflow. If something breaks, [open an issue](https://github.com/dtzp555-max/ocp/issues).
## Supported Tools
@@ -116,7 +120,7 @@ Please follow https://github.com/dtzp555-max/ocp/blob/main/README.md
installed and logged in (`claude auth status`). Install missing pieces
using my system's package manager.
2. git clone the repo, cd in, and run `node setup.mjs`.
3. Verify with `curl http://127.0.0.1:3456/v1/models` (should list 4 models).
3. Verify with `curl http://127.0.0.1:3456/v1/models` (should list 5 models).
4. Add `export OPENAI_BASE_URL=http://127.0.0.1:3456/v1` to my shell rc.
5. Tell me to reload my shell and try a tool like Cline / Continue / Cursor.
@@ -142,7 +146,7 @@ Please follow https://github.com/dtzp555-max/ocp/blob/main/README.md
5. Add OCP_ADMIN_KEY to my shell rc (~/.zshrc or ~/.bashrc).
6. Run `ocp lan` to show me the LAN IP and connect command.
7. Optionally create example keys: `ocp keys add laptop`, `ocp keys add tablet`.
8. Verify: `curl http://127.0.0.1:3456/v1/models` returns 4 models.
8. Verify: `curl http://127.0.0.1:3456/v1/models` returns 5 models.
Tell me each step before running it. On error, diagnose before retrying.
```
@@ -165,7 +169,7 @@ Please follow https://github.com/dtzp555-max/ocp/blob/main/README.md
chmod +x ocp-connect
2. Run `./ocp-connect <SERVER_IP>` (add `--key <KEY>` if you have one).
3. Follow any IDE-specific manual hints it prints.
4. Verify: `curl http://<SERVER_IP>:3456/v1/models` returns 4 models.
4. Verify: `curl http://<SERVER_IP>:3456/v1/models` returns 5 models.
5. Tell me to reload my shell + restart any IDE that was already running.
Don't auto-retry on error. Tell me the failure mode first.
@@ -235,7 +239,7 @@ Run `ocp lan` to see your IP and ready-to-share instructions.
**Verify:**
```bash
curl http://127.0.0.1:3456/v1/models
# Returns: claude-opus-4-7, claude-opus-4-6, claude-sonnet-4-6, claude-haiku-4-5-20251001
# Returns: claude-opus-4-8, claude-opus-4-7, claude-opus-4-6, claude-sonnet-4-6, claude-haiku-4-5-20251001
```
#### Headless install notes
@@ -281,7 +285,7 @@ chmod +x ocp-connect
./ocp-connect <server-ip>
```
**Zero-config** — when the server admin has set `PROXY_ANONYMOUS_KEY` (see [Anonymous Access](#anonymous-access-optional) below), just pass the server IP and nothing else. `ocp-connect` reads the anonymous key from `/health` and uses it automatically:
**Zero-config** — when the server admin has set `PROXY_ANONYMOUS_KEY` *and* opted in with `PROXY_ADVERTISE_ANON_KEY=1` (see [Anonymous Access](#anonymous-access-optional) below), just pass the server IP and nothing else. `ocp-connect` reads the anonymous key from `/health` and uses it automatically. Without the opt-in, `/health` does not expose the key (issue #109); pass `--key` or rely on anonymous access instead:
```bash
./ocp-connect <server-ip>
@@ -314,7 +318,7 @@ OCP Connect v1.3.0
(set by admin via PROXY_ANONYMOUS_KEY; see issue #12 §14 Path A)
Testing API access...
✓ API accessible (4 models available)
✓ API accessible (5 models available)
Shell config:
✓ .bashrc
@@ -344,6 +348,7 @@ OCP Connect v1.3.0
✓ OpenClaw configured
Provider: ocp
Models:
• ocp/claude-opus-4-8
• ocp/claude-opus-4-7
• ocp/claude-opus-4-6
• ocp/claude-sonnet-4-6
@@ -365,7 +370,7 @@ OCP Connect v1.3.0
The script automatically:
- Writes env vars to all relevant shell rc files (`.bashrc`, `.zshrc`)
- Sets system-level env vars (`launchctl setenv` on macOS, `environment.d` on Linux)
- **Auto-discovers anonymous key** from `/health.anonymousKey` when no `--key` given (v1.3.0+, requires server v3.10.0+)
- **Auto-discovers anonymous key** from `/health.anonymousKey` when no `--key` given (v1.3.0+, requires server v3.10.0+; server must also set `PROXY_ADVERTISE_ANON_KEY=1` — see [Anonymous Access](#anonymous-access-optional))
- Configures OpenClaw automatically (including per-agent `auth-profiles.json` for multi-agent setups)
- Detects Cline, Continue.dev, Cursor, and opencode, and prints setup hints (manual configuration required for these IDEs)
@@ -404,10 +409,22 @@ ocp keys revoke son-ipad # Revoke a key
|------|-----|----------|
| `none` | `CLAUDE_AUTH_MODE=none` | Trusted home network, no auth needed |
| `shared` | `CLAUDE_AUTH_MODE=shared` + `PROXY_API_KEY=xxx` | Everyone shares one key |
| `multi` | `CLAUDE_AUTH_MODE=multi` + `OCP_ADMIN_KEY=xxx` | Per-person keys with usage tracking (recommended) |
| `multi` | `CLAUDE_AUTH_MODE=multi` + `OCP_ADMIN_KEY=xxx` | Per-person keys for usage tracking + quotas (trusted users only — see Deployment model below) |
> **Usage scope (v3.14.0+):** `/api/usage` returns the caller's own rows by default. Admin callers must pass `?all=true` to retrieve data for all keys; doing so emits an audit log line.
### Deployment model & security (read this)
**What OCP is built for today: single-user, multi-IDE.** Run OCP as a server on one machine and point all of *your own* IDEs/devices at it — one Claude Pro/Max subscription, used everywhere. This is the primary, solid use case.
**Sharing with family / a team — honest limits.** You *can* share OCP on a LAN, but be clear about what the auth modes do and don't give you:
- The per-key modes (`shared` / `multi`) give per-key **usage tracking, quotas, and cache separation** — useful for seeing who used what and capping budgets.
- They do **not** give a **security isolation boundary**. The spawned `claude` runs with the **operator's filesystem access** and is *not* sandboxed per key. **Only share with people you fully trust, on a trusted network.**
- For simple trusted family sharing, the easiest setup is a single shared **anonymous key** (see [Anonymous Access](#anonymous-access-optional)) — no per-person separation, same trust assumption.
**Real per-user isolation (sandboxed, multi-tenant-safe) is planned for after 2026-06-15** — per-key ephemeral home + tool lockdown + an OS sandbox. Until then, treat a multi-user OCP as a *trusted-group convenience*, not a security boundary. (This is also why `CLAUDE_TUI_MODE` is single-user-only — see [Subscription-pool (TUI) mode](#subscription-pool-tui-mode).)
### Anonymous Access (optional)
In `multi` mode, the admin can designate a single well-known "anonymous" key that bypasses `validateKey()` and grants public read/write access. This is useful for letting LAN users (or clients like OpenClaw multi-agent setups) connect without individual per-user keys.
@@ -423,7 +440,7 @@ node setup.mjs --bind 0.0.0.0 --auth-mode multi
If OCP is already installed without it, re-export the env var and re-run `node setup.mjs` (the installer is idempotent — it refreshes the service unit). Then `ocp restart` so the running proxy picks up the new env. Setting `PROXY_ANONYMOUS_KEY` only in your interactive shell **does not** affect the auto-started proxy — the service unit is the source of truth for its environment.
**Client side**: the anonymous key value is exposed via `GET /health` as the field `anonymousKey` (null when not set). Clients like `ocp-connect` can auto-discover and use it, so the end user doesn't need to get a personal key from the admin.
**Client side**: the anonymous key value is exposed via `GET /health` as the field `anonymousKey` (null when not set) **only to localhost callers** or when the admin has also set `PROXY_ADVERTISE_ANON_KEY=1` (default off — see issue #109). With that opt-in, clients like `ocp-connect` can auto-discover and use it, so the end user doesn't need to get a personal key from the admin.
**Security note**: setting this env var is an **opt-in** to public access — anyone who can reach your OCP endpoint can use it, up to any rate limits you configure. Don't enable this on internet-exposed OCP instances without additional protection.
@@ -469,6 +486,8 @@ When a key exceeds its quota, OCP returns HTTP 429 with a structured error:
- Admin and anonymous users are never subject to quotas
- PATCH is a partial update — omitted fields are left unchanged
> **Note:** quotas are best-effort. Under concurrent bursts a key can exceed its cap by up to the server's max-concurrency (default 8), and cache hits are not counted toward quota. They cap budgets for cooperative family use, not adversarial abuse.
### Important Notes
- All users share your Claude Pro/Max **rate limits** (5h session + 7d weekly)
@@ -674,17 +693,18 @@ Cache is **disabled by default** (`CLAUDE_CACHE_TTL=0`). All data is stored loca
## How It Works
```
Your IDE → OCP (localhost:3456) → claude -p CLI → Anthropic (via subscription)
Your IDE → OCP (localhost:3456) → claude --output-format stream-json CLI → Anthropic (via subscription)
```
OCP translates OpenAI-compatible `/v1/chat/completions` requests into `claude -p` CLI calls. Anthropic sees normal Claude Code usage — no API billing, no separate key needed.
OCP translates OpenAI-compatible `/v1/chat/completions` requests into `claude --output-format stream-json` CLI calls. Anthropic sees normal Claude Code usage — no API billing, no separate key needed.
## Available Models
| Model ID | Notes |
|----------|-------|
| `claude-opus-4-7` | Most capable (default for `opus` alias) |
| `claude-opus-4-6` | Previous Opus, retained for pinning |
| `claude-opus-4-8` | Most capable (default for `opus` alias) |
| `claude-opus-4-7` | Previous Opus, retained for pinning |
| `claude-opus-4-6` | Older Opus, retained for pinning |
| `claude-sonnet-4-6` | Good balance of speed/quality (default for `sonnet` alias) |
| `claude-haiku-4-5-20251001` | Fastest, lightweight (default for `haiku` alias) |
@@ -705,7 +725,7 @@ The canonical list lives in [`models.json`](./models.json) — the single source
|----------|--------|-------------|
| `/v1/models` | GET | List available models |
| `/v1/chat/completions` | POST | Chat completion (streaming + non-streaming) |
| `/health` | GET | Comprehensive health check |
| `/health` | GET | Comprehensive health check (includes a `tui` block for TUI-mode drift/concurrency monitoring) |
| `/usage` | GET | Plan usage limits + per-model stats |
| `/status` | GET | Combined overview (usage + health) |
| `/settings` | GET/PATCH | View or update settings at runtime |
@@ -851,11 +871,27 @@ openclaw gateway restart # so OpenClaw re-reads the config
Future `ocp update` invocations sync automatically.
### TUI-mode returns `Please run /login · API Error: 401` (re-login doesn't stick)
A long-running TUI-mode host can get stuck returning a permanent 401 that re-login cannot fix. Root cause: when `CLAUDE_CODE_OAUTH_TOKEN` is **unset**, the interactive `claude` authenticates via `~/.claude/.credentials.json`, whose single-use OAuth refresh token can be corrupted (ending up an empty string) by the per-request spawn + `kill-session` teardown racing claude's token rotation. Re-login writes a fresh token, but the next spawn re-corrupts it.
Fix: set `CLAUDE_CODE_OAUTH_TOKEN` on the OCP host (then restart — on systemd `daemon-reload`, on launchd `bootout`+`bootstrap`; `kickstart -k` does **not** reload env). The TUI `claude` then authenticates via the stable long-lived token and never touches credentials.json. Verify the env reached the process:
```bash
# Linux (systemd): confirm the token is in the service env
tr '\0' '\n' < /proc/$(pgrep -f server.mjs | head -1)/environ | grep CLAUDE_CODE_OAUTH_TOKEN
# Re-login once to repair the credentials file (belt-and-braces), then it stays unused:
claude /login
```
See [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) and ADR 0007 PR-C amendment.
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `CLAUDE_PROXY_PORT` | `3456` | Listen port |
| `CLAUDE_PROXY_PORT` | `3456` | Listen port (server-side). Also consumed by the OpenClaw `ocp-plugin` to dial the local proxy. |
| `OCP_PROXY_URL` | *(unset)* | Plugin-side full URL override (e.g. `http://10.0.0.5:3456`). Wins over `CLAUDE_PROXY_PORT` when both are set. Read by `ocp-plugin/index.js` only — server ignores it. |
| `CLAUDE_BIND` | `127.0.0.1` | Bind address (`0.0.0.0` for LAN access) |
| `CLAUDE_AUTH_MODE` | `none` | Auth mode: `none`, `shared`, or `multi` |
| `OCP_ADMIN_KEY` | *(unset)* | Admin key for key management (multi mode) |
@@ -870,7 +906,17 @@ Future `ocp update` invocations sync automatically.
| `CLAUDE_SKIP_PERMISSIONS` | `false` | Bypass all permission checks |
| `CLAUDE_NO_CONTEXT` | `false` | Suppress CLAUDE.md and auto-memory injection (pure API mode) |
| `PROXY_API_KEY` | *(unset)* | Bearer token for shared-mode authentication |
| `PROXY_ANONYMOUS_KEY` | *(unset)* | Well-known anonymous key allowlist (multi mode). When set, this exact string bypasses `validateKey()` and grants public access. Exposed via `/health.anonymousKey` so clients auto-discover. See [Anonymous Access](#anonymous-access-optional). |
| `PROXY_ANONYMOUS_KEY` | *(unset)* | Well-known anonymous key allowlist (multi mode). When set, this exact string bypasses `validateKey()` and grants public access. Exposed via `/health.anonymousKey` only to localhost, or to all callers when `PROXY_ADVERTISE_ANON_KEY=1`. See [Anonymous Access](#anonymous-access-optional). |
| `PROXY_ADVERTISE_ANON_KEY` | *(unset)* | When `=1`, advertise `PROXY_ANONYMOUS_KEY` in the public `/health` body for remote zero-config discovery. Default off — `/health` is unauthenticated, so this exposes the shared key to any LAN-reachable device (issue #109). Localhost always sees it regardless. |
| `CLAUDE_TUI_MODE` | `false` | **Opt-in.** Set to `"true"` to serve requests via interactive `claude` (no `-p` / `--output-format` → `cc_entrypoint=cli`, subscription pool). **Single-user only** — see [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) for the security constraint. |
| `CLAUDE_CODE_OAUTH_TOKEN` | *(unset)* | OAuth bearer token (highest-precedence credential source). **Recommended for TUI-mode hosts:** when set, the interactive `claude` authenticates via this long-lived token and never touches `~/.claude/.credentials.json`, avoiding the refresh-token corruption that caused a permanent `Please run /login` 401 on a long-running TUI host (see [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) and ADR 0007). The token appears in the pane command (ps-visible) — acceptable for the single-user A-path; the multi-user B-path is refused at boot. |
| `CLAUDE_TUI_WALLCLOCK_MS` | `120000` | (TUI-mode) Maximum time in ms to wait for the native transcript to signal turn completion. Increase for long Opus thinking turns. |
| `OCP_TUI_CWD` | `$HOME/.ocp-tui/work` | (TUI-mode) Scratch working directory where interactive claude sessions run. Transcripts land under `<HOME>/.claude/projects/<encoded-cwd>/`. Created automatically. |
| `OCP_TUI_HOME` | `$HOME` (real home) | (TUI-mode) `HOME` claude runs under. Default is the operator's real home (shared credentials, existing onboarding). Set to a separate path for scratch-home isolation — see ADR 0007 for the credential-fork caveat. |
| `OCP_TUI_ENTRYPOINT` | `cli` | (TUI-mode) Billing-classifier labeling: `cli` (default) pins `cc_entrypoint=cli` deterministically; `auto` lets claude self-classify via TTY detection; `off` leaves the inherited env untouched. Honest only when the spawn is a genuine interactive PTY — see ADR 0007. |
| `OCP_TUI_MAX_CONCURRENT` | `2` | (TUI-mode) Max concurrent interactive TUI turns. **Independent** of `CLAUDE_MAX_CONCURRENT` (which bounds the `-p`/stream-json path; TUI never uses it). A TUI turn is heavy (per-request cold-boot of tmux+claude + up to `CLAUDE_TUI_WALLCLOCK_MS` wallclock), so the default is low to keep small hosts (e.g. a Pi 4) alive under a burst. Excess turns **queue** (bounded); a full queue yields a 503. See ADR 0007 PR-B amendment. |
| `OCP_SKIP_AUTH_TEST` | *(unset)* | When `=1`, skip the `claude -p` auth probe during `setup.mjs`. After 2026-06-15 this probe draws from the Agent SDK credit pool; set this to avoid burning a metered credit on re-installs or `ocp update` runs. Auth is validated at the first real request. |
| `OCP_TUI_FULL_TOOLS` | *(unset)* | (TUI-mode, **single-user only**) When `=1`, grant the interactive session the **same tool surface as the `-p` path** — `--allowedTools` (+ optional `--mcp-config` / `--dangerously-skip-permissions`, read from `CLAUDE_ALLOWED_TOOLS` / `CLAUDE_MCP_CONFIG` / `CLAUDE_SKIP_PERMISSIONS`) — instead of the default MCP-walled, built-in-tools-only set. Lets a trusted single-operator TUI deployment run a **tool-using / MCP agent** (e.g. an OpenClaw assistant) on the subscription pool. Safe because TUI **refuses to boot under `AUTH_MODE=multi`** (hard exit) — no guest key can ever reach the TUI path, so this gate cannot expose tools to an untrusted caller. (Under `AUTH_MODE=shared` + `OCP_TUI_ALLOW_LAN=1`, anyone holding the single shared key reaches it — that is the existing TUI trust model, unchanged.) See [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) and ADR 0007. |
### Streaming heartbeat
@@ -882,6 +928,107 @@ Heartbeats are inert SSE comment lines — conforming SSE clients ignore them. I
OCP also sends `X-Accel-Buffering: no` on SSE responses so nginx-default proxy buffering does not hold heartbeats in an upstream buffer.
## Subscription-pool (TUI) mode
> **SECURITY — read before enabling.**
> TUI-mode is **single-user / single-operator only**. `claude` runs with the OCP process owner's filesystem access regardless of `HOME` setting. If OCP serves multiple users or guest API keys, a guest prompt could exfiltrate files or exhaust the subscription. **Never enable `CLAUDE_TUI_MODE=true` on a multi-user OCP.**
### What it is and why
From 2026-06-15 Anthropic routes `claude` invocations by `cc_entrypoint`:
| Launch method | `cc_entrypoint` | Billing pool |
|---------------|-----------------|-------------|
| `claude -p` / `--output-format` (OCP default) | `sdk-cli` | Agent SDK credit pool (~$20/mo on Pro) |
| Interactive `claude` (no flags) | `cli` | Pro/Max subscription pool |
TUI-mode lets OCP serve requests via the interactive path so they bill against the subscription pool. The response is read from claude's native JSONL session transcript once the turn is complete, then replayed to the caller as a normal OpenAI completion or chunked SSE response.
### Billing-classifier labeling (`OCP_TUI_ENTRYPOINT`)
`OCP_TUI_ENTRYPOINT` (default `cli`) controls how `CLAUDE_CODE_ENTRYPOINT` is set on the spawn
environment. The default (`cli`) pins the value deterministically — immune to a stray inherited
env var or a future stdout-redirect bug silently flipping it to `sdk-cli`. This label is honest
**only** when the spawn is a genuine interactive PTY (tmux pane, no `-p`, stdout not redirected,
and `tmux new-session` verified to succeed). If you need to observe the raw TTY-derived value, set
`OCP_TUI_ENTRYPOINT=auto`. See ADR 0007 for the full rationale and governing rule.
### Enabling TUI-mode (opt-in)
```bash
# Prerequisites
mkdir -p ~/.ocp-tui/work # one-time scratch cwd setup
# tmux must be installed: brew install tmux / apt install tmux
# Enable
export CLAUDE_TUI_MODE=true
# STRONGLY RECOMMENDED on a TUI host — authenticate via the long-lived OAuth token
# so the interactive claude never touches ~/.claude/.credentials.json (whose single-use
# refresh token can get corrupted by the per-request spawn/teardown cycle → permanent
# "Please run /login" 401). See the auth note below + ADR 0007.
export CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-...
# Optionally tune:
export CLAUDE_TUI_WALLCLOCK_MS=180000 # 3 min cap for long Opus turns
export OCP_TUI_CWD=$HOME/.ocp-tui/work # default; override if needed
export OCP_TUI_ENTRYPOINT=cli # default; use 'auto' to observe TTY-derived value
```
Then restart OCP. At boot you will see:
```
⚠️ TUI-mode ON — single-user only; do NOT enable on a multi-user OCP ...
TUI-mode: ON home=/home/user cwd=/home/user/.ocp-tui/work wallclock=120000ms
```
### What changes / what doesn't
- **Callers see no API change.** The response is a normal OpenAI completion object or chunked SSE — identical wire format.
- **No real token streaming.** TUI-mode buffers the full response then replays it as chunked SSE. You will see a delay then the complete response rather than real-time tokens.
- **Cache and singleflight work normally.** TUI-mode writes the buffered response to the cache on success; cache-hits skip the interactive turn entirely.
- **The host's `CLAUDE.md` / auto-memory is never injected.** OCP is a proxy — the proxied client (OpenClaw / your IDE) owns its own context and memory. TUI-mode always runs `claude` with `CLAUDE_CODE_DISABLE_CLAUDE_MDS` + `CLAUDE_CODE_DISABLE_AUTO_MEMORY`, so a `CLAUDE.md` on the OCP host can never leak into proxied turns (verified live; see #4). Built-in tool schemas + the interactive system prompt remain (the inherent ~2035K context floor of interactive mode); MCP is hard-disabled.
- **Authenticate via `CLAUDE_CODE_OAUTH_TOKEN` (recommended).** tmux does not forward the parent process's env to the pane, so OCP sets the token explicitly on the spawned `claude` when `CLAUDE_CODE_OAUTH_TOKEN` is present. With the token set, the interactive `claude` authenticates via the stable long-lived token and **never touches `~/.claude/.credentials.json`**. Without it, claude falls back to credentials.json, whose single-use OAuth refresh token can be corrupted by the per-request spawn + `kill-session` teardown racing claude's token rotation — on a long-running host this produced a permanent `Please run /login · API Error: 401` that re-login could not fix (the next spawn re-corrupted it). Setting the token mirrors how the stable hosts already run. (The token is then visible in `ps` on the pane command — acceptable for the single-user A-path; the multi-user B-path is refused at boot.) See ADR 0007 PR-C amendment.
- **Stale tmux sessions are reaped.** The pane's `claude` is a child of the tmux server (not OCP), so OCP cannot reap it directly; `claude` zombies can otherwise accumulate as `<defunct>` over a long-running host. OCP reaps them at boot and on a 15-min idle sweep by issuing `tmux kill-server` — but **only when no foreign tmux session remains** (it never disrupts a co-hosted `olp-tui-*` instance). See ADR 0007 PR-C amendment.
- **Default path unchanged.** Unset `CLAUDE_TUI_MODE` and restart → `callClaude` / `callClaudeStreaming` are used again, byte-for-byte identical to today.
- **Concurrency is bounded separately.** TUI turns are heavy (per-request cold-boot + long wallclock), so the TUI path has its own limiter — `OCP_TUI_MAX_CONCURRENT` (default `2`), independent of `CLAUDE_MAX_CONCURRENT`. Excess turns queue; a full queue returns a 503. Tune it up only on a host that can run more interactive `claude` sessions at once.
### Monitoring drift via `/health`
`GET /health` includes a `tui` block so you can poll for a silent billing-pool drift (the top risk after the 6/15 flip — a lost TTY flipping `cc_entrypoint` from `cli` to the metered `sdk-cli` pool would still return answers but burn metered credits). The block is **always present** (with `enabled:false` when TUI-mode is off):
```jsonc
"tui": {
"enabled": true, // CLAUDE_TUI_MODE === "true"
"entrypointMode": "cli", // OCP_TUI_ENTRYPOINT (cli | auto | off)
"lastEntrypoint": "cli", // last cc_entrypoint observed in a transcript, or null
"entrypointMismatches": 0, // count of cli-expected-but-got-other turns — ALERT if this climbs
"inflight": 1, // TUI turns running right now
"queued": 0, // TUI turns waiting for a concurrency slot
"maxConcurrent": 2 // OCP_TUI_MAX_CONCURRENT
}
```
Alert on `entrypointMismatches > 0` (or `lastEntrypoint !== "cli"`): it means a turn drew from the metered Agent SDK pool instead of the subscription. `inflight` / `queued` show how close the TUI path is to its concurrency cap.
### Kill-switch
```bash
unset CLAUDE_TUI_MODE
# restart OCP
```
The stream-json path is restored immediately. No other change is needed.
### 2026-06-15 operator checklist
Every host serving traffic must be flipped to TUI-mode **and** canary-verified before 2026-06-15, or it will bill the metered Agent SDK credit pool instead of the subscription.
- **[Flip/rollback runbook](docs/runbooks/tui-flip-rollback.md)** — how to set `CLAUDE_TUI_MODE=true` on systemd (Linux) and launchd (macOS) hosts. Covers the `daemon-reload` requirement (systemd) and the `bootout`+`bootstrap` cycle requirement (launchd — `launchctl kickstart -k` does not reload plist env).
- **[615-canary runbook](docs/runbooks/615-canary.md)** — after each flip, run one quiesced request and compare the Agent SDK credit balance before and after. `entrypoint:cli` in the transcript (the `cc_entrypoint` billing classifier) is necessary but not sufficient — only a stable credit balance confirms the subscription pool is being used. Balance check is a manual step (no known programmatic API for the Agent SDK credit pool balance).
### Architecture and design decisions
See [`docs/adr/0007-tui-interactive-mode.md`](docs/adr/0007-tui-interactive-mode.md) for the full rationale, home-strategy options, MCP-disable mechanism, coexistence rules, and the B-path (multi-tenant isolation) roadmap.
## Repository Layout
Top-level files a contributor or operator may need to know:
+22 -15
View File
@@ -132,6 +132,10 @@ function fmtChars(n) {
return n > 1000 ? (n/1000).toFixed(0) + "K" : String(n);
}
function escapeHtml(s) {
return String(s ?? "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
function barColor(pct) {
if (pct >= 80) return "bar-red";
if (pct >= 50) return "bar-amber";
@@ -144,8 +148,8 @@ async function refreshStatus() {
const r = data.requests || {};
document.getElementById("status-cards").innerHTML = `
<div class="card"><div class="label">Status</div><div class="value"><span class="tag ${p.status === 'ok' ? 'tag-ok' : 'tag-err'}">${p.status || '?'}</span></div><div class="sub">v${p.version || '?'}</div></div>
<div class="card"><div class="label">Uptime</div><div class="value">${p.uptime || '?'}</div></div>
<div class="card"><div class="label">Status</div><div class="value"><span class="tag ${p.status === 'ok' ? 'tag-ok' : 'tag-err'}">${escapeHtml(p.status || '?')}</span></div><div class="sub">v${escapeHtml(p.version || '?')}</div></div>
<div class="card"><div class="label">Uptime</div><div class="value">${escapeHtml(p.uptime || '?')}</div></div>
<div class="card"><div class="label">Requests</div><div class="value">${r.total || 0}</div><div class="sub">${r.active || 0} active</div></div>
<div class="card"><div class="label">Errors</div><div class="value">${r.errors || 0}</div><div class="sub">${r.timeouts || 0} timeouts</div></div>
<div class="card"><div class="label">Sessions</div><div class="value">${p.activeSessions || 0}</div></div>
@@ -160,15 +164,15 @@ async function refreshStatus() {
document.getElementById("plan-cards").innerHTML = `
<div class="card">
<div class="label">Session (5h)</div>
<div class="value">${s.percent || '?'}</div>
<div class="value">${escapeHtml(s.percent || '?')}</div>
<div class="bar-bg"><div class="bar-fill ${barColor(sPct)}" style="width:${sPct}%"></div></div>
<div class="sub">Resets in ${s.resetsIn || '?'}</div>
<div class="sub">Resets in ${escapeHtml(s.resetsIn || '?')}</div>
</div>
<div class="card">
<div class="label">Weekly (7d)</div>
<div class="value">${w.percent || '?'}</div>
<div class="value">${escapeHtml(w.percent || '?')}</div>
<div class="bar-bg"><div class="bar-fill ${barColor(wPct)}" style="width:${wPct}%"></div></div>
<div class="sub">Resets in ${w.resetsIn || '?'}</div>
<div class="sub">Resets in ${escapeHtml(w.resetsIn || '?')}</div>
</div>
`;
}
@@ -181,21 +185,21 @@ async function refreshUsage() {
const tbody = document.querySelector("#key-usage-table tbody");
tbody.innerHTML = (data.byKey || []).map(k => `
<tr>
<td>${k.key_name}</td>
<td>${escapeHtml(k.key_name)}</td>
<td>${k.requests}</td>
<td>${k.successes}</td>
<td>${k.errors}</td>
<td>${fmtTime(k.avg_elapsed_ms)}</td>
<td class="mono">${k.last_request || '-'}</td>
<td class="mono">${escapeHtml(k.last_request || '-')}</td>
</tr>
`).join("") || '<tr><td colspan="6" style="color:#475569">No usage data yet</td></tr>';
const rtbody = document.querySelector("#recent-table tbody");
rtbody.innerHTML = (data.recent || []).slice(0, 20).map(r => `
<tr>
<td class="mono">${r.created_at?.slice(11, 19) || '?'}</td>
<td>${r.key_name}</td>
<td>${r.model}</td>
<td class="mono">${escapeHtml(r.created_at?.slice(11, 19) || '?')}</td>
<td>${escapeHtml(r.key_name)}</td>
<td>${escapeHtml(r.model)}</td>
<td>${fmtChars(r.prompt_chars)}</td>
<td>${fmtChars(r.response_chars)}</td>
<td>${fmtTime(r.elapsed_ms)}</td>
@@ -216,13 +220,16 @@ async function refreshKeys() {
const tbody = document.querySelector("#keys-table tbody");
tbody.innerHTML = (data.keys || []).map(k => `
<tr>
<td>${k.name}</td>
<td class="mono">${k.keyPreview}</td>
<td class="mono">${k.created_at}</td>
<td>${escapeHtml(k.name)}</td>
<td class="mono">${escapeHtml(k.keyPreview)}</td>
<td class="mono">${escapeHtml(k.created_at)}</td>
<td><span class="tag ${k.revoked ? 'tag-err' : 'tag-ok'}">${k.revoked ? 'revoked' : 'active'}</span></td>
<td>${k.revoked ? '' : `<button class="btn btn-sm btn-danger" onclick="revokeKeyUI('${k.name}')">Revoke</button>`}</td>
<td>${k.revoked ? '' : `<button class="btn btn-sm btn-danger" data-revoke="${escapeHtml(k.name)}">Revoke</button>`}</td>
</tr>
`).join("");
tbody.querySelectorAll("button[data-revoke]").forEach(btn =>
btn.addEventListener("click", () => revokeKeyUI(btn.getAttribute("data-revoke")))
);
} catch(e) { /* not admin */ }
}
+132
View File
@@ -0,0 +1,132 @@
# 0006 — OpenAI Shim Scope: Class A vs Class B Endpoints
- **Date**: 2026-05-20
- **Status**: Proposed — owner reviewing
- **Authors**: project maintainer (with AI drafting assistance)
- **Related**: `ALIGNMENT.md` (the constitution); ADR 0002 (Alignment Constitution provenance, PR #20, commit 2853088); PR #99 by external contributor (triggering incident — OpenAI `response_format` honoring on `/v1/chat/completions`)
## Context
`ALIGNMENT.md` was drafted in the aftermath of the 2026-04-11 drift (commit `b87992f` — fabricated `/api/oauth/usage` endpoint) and ratified in PR #20 / commit 2853088. Its five Rules are written in the language of a one-to-one proxy: Rule 1 (Grep First), Rule 2 (No Invention), Rule 3 (Match the Implementation), Rule 4 (Unalignable Features Are Deleted), Rule 5 (Cite Line Numbers in Commits). All five anchor explicitly on `cli.js` as the golden reference. This is correct and binding for the endpoints OCP was originally designed to forward — `/v1/messages`, `/api/oauth/*`, and the rate-limit-header extraction path that backs `/usage` — because for those, `cli.js` is the literal wire authority and any deviation is a drift risk.
OCP also exposes a second class of endpoint that the constitution does not currently distinguish: **OpenAI-compatible surface** that exists so non-Claude-Code clients (Honcho, OpenWebUI, OpenAI SDK consumers, BYO scripts) can talk to claude via OCP. The flagship is `/v1/chat/completions`, which translates between OpenAI's request/response schema and `cli.js`'s native protocol. `cli.js` never speaks OpenAI's wire format — by construction it cannot, because OpenAI and Anthropic are different vendors with different protocols. There is no `cli.js:NNNN` to cite for OpenAI's `messages[].role` field handling, OpenAI's streaming `delta` shape, OpenAI's `stop` event names, or OpenAI's `response_format` parameter. The protocol authority for these is OpenAI's published specification, not `cli.js`.
The structural gap surfaced when PR #99 (external contributor `jaekwon-park`) added support for the OpenAI `response_format` request field on `/v1/chat/completions`. A strict reading of Rule 2 ("OCP must not introduce request fields that are not present in `cli.js`") blocks the PR. But the same strict reading also blocks the existence of `/v1/chat/completions` itself — every OpenAI-shaped field on that endpoint is, by definition, not in `cli.js`. The endpoint has been in OCP since before the constitution was written and is used by real downstream consumers. The constitution and the endpoint cannot both be correct under the current reading.
The 2026-04-11 drift remains the cautionary tale that drove the constitution and remains binding. The drift was not "OCP exposed an endpoint that wasn't in `cli.js`" — it was specifically "OCP claimed to forward `cli.js`'s `/api/oauth/usage` call when no such call exists in `cli.js`." That is a Class A failure mode: a forwarding endpoint that lied about what it was forwarding. The fix to that failure mode (Rules 1, 2, 3, 5; CI blacklist; reviewer gate) was correct then and is correct now. This ADR does not relitigate that decision and does not soften Rules 15 for the class of endpoint they were designed to discipline.
What this ADR does is acknowledge that OCP has two classes of endpoint, and that the discipline that fits Class A does not fit Class B without distortion. Class B needs its own anchor (OpenAI's specification) and its own authorization gate (an ADR per endpoint), so contributors know exactly which rule set applies to their PR and so Class B never becomes a backdoor for "OCP can do anything OpenAI-shaped."
## Decision
Introduce an explicit two-class taxonomy of OCP endpoints:
- **Class A — `cli.js`-mirror endpoints.** Endpoints that exist because `cli.js` performs the equivalent operation and OCP forwards, observes, or multiplexes that operation. Rules 15 of `ALIGNMENT.md` apply verbatim. The citation requirement is `cli.js:NNNN` (or `cli.js vE4 <functionName>`).
- **Class B — OCP-owned compatibility endpoints.** Endpoints that exist because OCP itself surfaces them, with no `cli.js` analogue. They fall into two sub-buckets:
- **B.1 — OpenAI-compatibility surface.** Endpoints implementing OpenAI's published API contract so non-Anthropic clients can use OCP. The protocol authority is OpenAI's specification.
- **B.2 — OCP-administrative surface.** Endpoints that exist purely to operate the proxy itself (health, dashboard, key management, cache control). The authority for these is the ADR that authorized the endpoint's existence.
For Class B endpoints, the citation requirement shifts from `cli.js:NNNN` to **(a)** the relevant specification section (OpenAI spec section for B.1, or the authorizing ADR for B.2) **and (b)** the ADR that authorized the endpoint's existence in the first place.
### Grandfather provision for existing B.2 inventory
ADR 0006 retroactively authorizes the existing B.2 endpoints listed in the inventory table below, **frozen at their current behaviour as of v3.16.4**. This is a one-time grandfather provision intended to avoid a 12-ADR back-fill burden for endpoints that have existed in OCP since before any constitutional governance was written.
The grandfather provision is narrowly scoped:
- It covers only the B.2 endpoints enumerated in the inventory table as of this ADR's merge date.
- It freezes those endpoints at their **current behaviour**. Any change to the request shape, response shape, or semantics of a grandfathered B.2 endpoint is treated as a new authorization request and requires either (a) a behaviour-preserving refactor PR with no contract change, or (b) its own ADR.
- It does **not** authorize new B.2 endpoints. Any new B.2 endpoint, or any new method on a grandfathered B.2 endpoint, requires its own ADR before merge.
- It does **not** extend to B.1 (OpenAI-compat) endpoints. B.1 endpoints are bounded by OpenAI's published specification, not by a behaviour snapshot — there is no grandfather equivalent for them.
The structural intent is: take the one-time hit of declaring "current B.2 surface is authorized" cleanly, then make every future addition pay the ADR-per-endpoint cost. This prevents Class B from becoming a backdoor for general OCP-owned-surface invention while not blocking the present ADR on twelve back-fill PRs.
### Current Class B inventory (enumerated from `server.mjs`)
The following endpoints exist today in `server.mjs` and are Class B (no `cli.js` analogue):
| Endpoint | Method | Sub-bucket | Authorizing ADR |
|---|---|---|---|
| `/v1/chat/completions` | POST | B.1 (OpenAI-compat) | ADR 0006 |
| `/v1/models` | GET | B.1 (OpenAI-compat) | ADR 0006; content sourced from `models.json` per ADR 0003 |
| `/health` | GET | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
| `/dashboard` | GET | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
| `/sessions` | GET, DELETE | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
| `/logs` | GET | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
| `/status` | GET | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
| `/settings` | GET, PATCH | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
| `/api/keys` | GET, POST | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
| `/api/keys/:id` | DELETE | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
| `/api/keys/:id/quota` | GET, PATCH | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
| `/api/usage` | GET | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
| `/cache/stats` | GET | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
| `/cache` | DELETE | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
For Class A reference, the current Class A inventory is `/v1/messages` (forwarded directly to `api.anthropic.com/v1/messages`) and the OAuth bearer / rate-limit-header machinery used by `handleUsage()` (which calls `https://api.anthropic.com/v1/messages` to extract `anthropic-ratelimit-unified-*` headers, per the in-file comment at line 845849). The `GET /usage` endpoint surface itself is Class B (administrative augmentation: it adds `proxy:` and `models:` blocks not present in any upstream API), but the data fetch underlying it is Class A — see "Hybrid endpoints" below.
### Hybrid endpoints
`/usage` is a hybrid: the wire call out to `api.anthropic.com/v1/messages` is Class A and must continue to cite `cli.js`; the local synthesis on top (`proxy:` stats block, `models:` snapshot, response shape) is Class B and is authorized by this ADR. Any future change strictly to the wire-call layer is Class A; any change strictly to the synthesis layer is Class B. A PR touching both must satisfy both citation requirements.
## What does NOT change
The following continue to apply verbatim and are not weakened by this ADR:
- **Rules 1, 2, 3, 4, 5 of `ALIGNMENT.md`** for all Class A endpoints. The 2026-04-11 drift discipline is unchanged. Class A PRs still require `cli.js:NNNN` citations, still must match `cli.js`'s wire format byte-for-byte, and still face the Unalignable Policy if the citation cannot be produced.
- **CI blacklist** (`.github/workflows/alignment.yml`). The known-hallucinated token list (currently `api.anthropic.com/api/oauth/usage`) continues to be greppable-and-failable on every PR.
- **Reviewer gate** (CLAUDE.md hard requirements + Iron Rule 10). Implementation author may not self-approve; a fresh-context reviewer opens `cli.js` at the cited lines for Class A PRs.
- **Annual Alignment Audit** on 11 April. The Class A audit (re-verify each `server.mjs` Class A reference against the pinned `cli.js` SHA-256) continues unchanged.
- **Unalignable Policy.** A Class A endpoint that cannot be traced to a `cli.js` reference is still deleted, not deprecated.
- **Historical Lesson section in `ALIGNMENT.md`.** The 2026-04-11 drift remains the named cautionary incident, with commit SHAs intact.
## What additionally applies to Class B
The following are new and apply only to Class B endpoints:
1. **OpenAI specification as protocol authority (B.1).** The OpenAI compatibility surface follows OpenAI's published `/v1/chat/completions` specification (https://platform.openai.com/docs/api-reference/chat/create) — not OCP imagination, not "OpenAI probably does X," not generalization from adjacent OpenAI endpoints. The same anti-invention discipline that Rule 2 imposes for `cli.js` applies, with OpenAI's spec substituted as the reference.
2. **ADR-authorized endpoint existence.** Any new Class B endpoint, or any new Class B endpoint method, requires its own ADR before merge. The grandfather provision above covers existing B.2 inventory only. An "ADR-less" Class B endpoint added after this ADR merges is itself an alignment finding and is subject to deletion under a Class B equivalent of the Unalignable Policy (see Rule 4 mapping in `ALIGNMENT.md`'s new section).
3. **Class B citation format.** Class B PRs cite (a) the relevant specification section and (b) the authorizing ADR. Example for B.1: "OpenAI `chat/completions` API, `response_format` parameter (https://platform.openai.com/docs/api-reference/chat/create), authorized by ADR 0006." Example for B.2: "Authorized by ADR 0006 (grandfathered)" for grandfathered endpoints, or "Authorized by ADR 00NN" for endpoints with their own ADR.
4. **Class B audit cadence.** Class B endpoints are audited annually alongside the Class A audit. B.1 endpoints are audited against OpenAI's current `/v1/chat/completions` specification snapshot. B.2 endpoints (grandfathered or ADR-specific) are audited against their authorizing ADR — for grandfathered endpoints, the audit verifies the endpoint behaviour still matches its v3.16.4 snapshot; for ADR-specific endpoints, the audit verifies behaviour still matches the ADR. The B.1 specification pin lives in `docs/openai-compat-pin.md` (to be created alongside the first B.1 audit; not a prerequisite for this ADR to land).
5. **Reviewer expectation.** The fresh-context reviewer for a Class B PR opens the cited OpenAI spec section (B.1) or the authorizing ADR (B.2) instead of opening `cli.js`. The "I am not the commit author" rule and the "explicit approval comment naming the verified reference" rule continue.
## Consequences
**Positive**
- PR #99 becomes mergeable with a one-line scope declaration ("Class B — extends `/v1/chat/completions` per ADR 0006") plus the existing alignment-evidence section adapted to Class B citation format. The structural ambiguity that blocked it is removed.
- Future Class B contributors have a clear template and a defensible scope: "extend `/v1/chat/completions` for an OpenAI-spec field that's already in OpenAI's spec" is a well-formed PR; "add a new OCP-invented field that looks OpenAI-shaped" is not, and the same anti-invention discipline that protects Class A protects Class B.
- The Class A surface is structurally unchanged. Reviewers reading the new `ALIGNMENT.md` see a clean Class A regime with all five Rules intact, plus an enumerated and explicitly scoped Class B carve-out.
- The administrative endpoint surface (B.2) is no longer in a "is this even allowed under the constitution?" limbo. The grandfather provision cleanly authorizes the current inventory; new B.2 endpoints must earn their ADR.
**Negative**
- OCP now maintains a second alignment surface. OpenAI's `/v1/chat/completions` specification is also a moving target (OpenAI ships changes more than once per year, including breaking ones), so the B.1 audit has real work attached.
- The grandfather provision freezes the current B.2 behaviour. If a grandfathered B.2 endpoint has a latent bug or undesirable behaviour, "fixing" it is a contract change and now requires an ADR (or a behaviour-preserving refactor). This is intentional friction to prevent silent contract drift.
- Contributors must now choose Class A or Class B on every PR. Some will misclassify. The PR template's required Class A/B radio (see PR template update) and the reviewer's spec-or-cli verification step are the structural counter-measures.
**Mitigations**
- The Class B inventory is small (currently 14 endpoints) and is enumerated explicitly in `ALIGNMENT.md`. New entries require an ADR per item 2 above, so the inventory cannot grow silently.
- Anthropic-side change frequency (which drives Class A audit cost) is structurally higher than OpenAI's `chat/completions` shape, which has been stable across multiple OpenAI API versions. The marginal B.1 audit cost is low. The grandfathered B.2 audit cost is also low — most of those endpoints have not changed in months.
- The B.1 specification pin in `docs/openai-compat-pin.md` lets the audit anchor on a specific OpenAI spec snapshot, the same way the Class A pin anchors on a specific `cli.js` SHA-256. Drift detection then works the same way for both classes.
## Historical Lesson — explicit non-relitigation
This ADR does not relitigate the 2026-04-11 drift. The drift commit `b87992f` was Class A — it claimed `cli.js` forwarded a call that `cli.js` did not in fact make. The fix (constitution + CI blacklist + reviewer gate) was correct and remains binding for Class A. This ADR carves out Class B because the discipline that fits Class A does not fit a class of endpoint where `cli.js` is not the wire authority — not because the discipline was wrong, and not because the drift lesson is any less load-bearing.
A reviewer or future maintainer reading this ADR should not infer: "OCP relaxed its alignment rules." The Class A regime is structurally identical to the version that shipped in PR #20. What changed is that the constitution now names the scope of that regime precisely (the class of endpoint for which `cli.js` is the wire authority) instead of implicitly applying it to every endpoint, including ones the regime was never designed for.
## Alternatives considered
**(a) Refuse Class B as a category; close PR #99 and delete `/v1/chat/completions`.** This would resolve the structural ambiguity by enforcing Rule 2 maximally — if `cli.js` doesn't speak OpenAI's protocol, neither does OCP. Rejected: there is an existing user base on the OpenAI-compat surface, the surface is genuinely useful (it is OCP's bridge to non-Claude-Code agents), and deletion would be a load-bearing user-facing breakage in service of a doctrinal point that the constitution was never designed to make. The constitution was a response to the 2026-04-11 forwarding drift, not a charter against any OCP-owned surface.
**(b) Soften Rule 2 to "OCP must not introduce surface area not present in `cli.js` OR not authorized by an ADR."** This is the obvious diff and would unblock PR #99 with the smallest possible textual change. Rejected because it loses the precision that Class A needs. A combined Rule 2 means a Class A reviewer has to read the PR description twice to figure out which authority applies. The Class A/B split makes the question explicit at the PR-template level (the author picks the class) and at the reviewer level (the reviewer opens the appropriate reference). The cost of the split is one new section in `ALIGNMENT.md`; the benefit is no ambiguity in either class.
**(c) Move `/v1/chat/completions` and all OpenAI-compat surface out of OCP into a separate "ocp-openai-shim" repository.** This would cleanly resolve the scope question by moving Class B out of OCP entirely. Rejected as premature: the maintainer is one person, the OpenAI-compat surface today is a single endpoint plus its support, and the operational cost of two repositories (separate releases, separate CI, separate version coordination) exceeds the cost of one constitution with two named classes. If the OpenAI-compat surface ever grows to the size where a separate repo is justified, ADR 0006 is the natural pivot point — at that future date, the carve-out becomes a separation.
**(d) Twelve-ADR back-fill for the existing B.2 inventory before this ADR can merge.** Considered and rejected on cost grounds. Each back-fill ADR would be a short paragraph explaining what an existing endpoint does and why it's allowed; the educational value is low and the merge friction is high (12 PRs through the reviewer gate). The grandfather provision above achieves the same authorization outcome in one paragraph, while still requiring an ADR for any future B.2 endpoint. The trade-off: grandfathered endpoints are not individually documented to ADR-depth. Mitigation: the inventory table in `ALIGNMENT.md` lists every grandfathered endpoint by path and method, so the audit surface remains explicit.
+296
View File
@@ -0,0 +1,296 @@
# ADR 0007 — TUI Interactive Mode (subscription-pool bridge)
**Date:** 2026-05-31
**Status:** Accepted — amended by PR-4 (entrypoint hardening)
**Deciders:** project maintainer
**Authority:** claude CLI v2.1.158 interactive mode — verified live on the test host that sessions launched without `-p` / `--output-format` carry `cc_entrypoint=cli` (subscription pool), not `cc_entrypoint=sdk-cli` (Agent SDK credit pool). Mechanism verified on cli.js v2.1.104; live-confirmed on v2.1.158.
---
## Context
On 2026-05-14 Anthropic announced (effective 2026-06-15) a billing split that routes requests by `cc_entrypoint`:
| `cc_entrypoint` value | Billing pool |
|-----------------------|-------------|
| `cli` | Pro/Max subscription pool |
| `sdk-cli` | Agent SDK credit pool (~$20/mo on Pro = easily exhausted) |
OCP's existing path (`claude --output-format stream-json -p`) sets `cc_entrypoint=sdk-cli`. After 2026-06-15 every OCP request will draw from the Agent SDK pool rather than the subscription.
The structural response: add an opt-in mode that drives a real **interactive** `claude` session (no `-p`, no `--output-format`), which carries `cc_entrypoint=cli` and therefore bills against the subscription. The response text is read from claude's native JSONL transcript instead of from `stdout`.
This is a personal-use A-path feature (single-user, single-subscription host). It is **not** a multi-tenant isolation layer.
### Source-verified entrypoint mechanism (PR-4 amendment)
Claude CLI's `main()` calls a startup function (`t$A` in the compiled bundle) that sets
`process.env.CLAUDE_CODE_ENTRYPOINT` **only if unset** to:
```
(argv has -p/--print/--init-only/--sdk-url OR !process.stdout.isTTY) ? "sdk-cli" : "cli"
```
The billing header reads `cc_entrypoint = process.env.CLAUDE_CODE_ENTRYPOINT ?? "unknown"`.
The `"unknown"` branch is dead code for any real `main()` spawn — the startup function always
sets a value on unset env. The **real risk** is not `"unknown"`: it is a **lost TTY** (e.g. stdout
redirected or a non-PTY spawn) silently flipping the self-classification to `"sdk-cli"` and
drawing from the metered pool.
`cc_entrypoint` is one of ~6 upstream run-mode signals. The **dominant discriminator** is the
system-prompt identity block ("official CLI" vs "Claude Agent SDK"), which is driven by genuine
interactivity (no `-p`, no `--output-format`, real PTY) and is overridable by no env var. This
is the real reason the tmux/no-`-p` approach works: the spawn is genuinely interactive, not just
labelled as such.
---
## Decision
Add `CLAUDE_TUI_MODE=true` as an opt-in flag in `server.mjs`.
### How it works
1. Each request spawns a fresh tmux session running `claude --model <M> --session-id <UUID> --strict-mcp-config --disallowedTools 'mcp__*'` (no `-p`, no `--output-format`).
2. The spawn result is checked immediately: if `tmux new-session` returns a non-zero exit status (or a falsy result), the request is aborted with `tui_spawn_failed: tmux session not created` **before** the boot sleep. This is the spawn/PTY gate — OCP must not issue a billing request without a verified interactive session.
3. The serialized prompt (from `messagesToPrompt`) is pasted via `tmux send-keys … "$(cat file)"` + a separate `Enter` key event.
4. The answer is read from claude's native JSONL transcript at `<HOME>/.claude/projects/<encoded-cwd>/<session-id>.jsonl`, polling until a `turn_duration` system event or the wall-clock cap (`CLAUDE_TUI_WALLCLOCK_MS`, default 120 s).
5. The string answer is returned to OCP's existing downstream (singleflight → cache write-back → `completionResponse` / `streamStringAsSSE`) — **same contract as `callClaude`**.
6. Streaming requests are buffered then replayed as chunked SSE (no real token streaming — deliberate; "don't build fragile features").
### Billing-classifier labeling (`OCP_TUI_ENTRYPOINT`, PR-4)
`CLAUDE_CODE_ENTRYPOINT` on the spawn env is managed by `resolveTuiEntrypointEnv(env, mode)`
(exported from `lib/tui/session.mjs`, pure, testable). The function **always deletes any
inherited value first** so a stray env var from OCP's own parent process can never leak in and
mislabel the billing header. Then:
| `OCP_TUI_ENTRYPOINT` | Behaviour |
|----------------------|-----------|
| `cli` (default) | Sets `CLAUDE_CODE_ENTRYPOINT=cli` deterministically — subscription-pool classification. **Honest only because the spawn is a genuine interactive PTY** (tmux pane, no `-p`, stdout not redirected, `new-session` verified). |
| `auto` | Deletes the key → claude self-classifies via `t$A` (TTY → `cli`). Use to observe/diagnose the real TTY-derived value. |
| `off` | Leaves the env exactly as inherited — diagnostics / honesty audit only. |
**Governing rule (verbatim):** *OCP may make a true value deterministic; it may never assert a
value the spawn's real state contradicts. When it cannot make the claim true (e.g. cannot
guarantee a PTY), it fails/drops the request — it does not force the signal.*
This is why the spawn/PTY gate (step 2 above) is load-bearing for `mode="cli"`: if `new-session`
fails, there is no PTY, so asserting `cli` would be dishonest. Abort rather than lie.
OCP never suppresses the billing header (anti-fingerprinting: we do not mask the spawn).
### 2026-06-15 verification protocol
Run one quiesced canary request in TUI-mode and watch the **Agent SDK credit balance** (not the
request header). If the balance drops, the subscription pool is unreachable via spawn. Per the
constitution (`ALIGNMENT.md`), the response is to **drop the Anthropic provider** rather than
escalate spoofing.
Version caveat: mechanism verified on cli.js v2.1.104 + live on v2.1.158. Re-verify after any
major cli.js upgrade.
### Default behaviour is unchanged
When `CLAUDE_TUI_MODE` is unset (the default), no code path touches `callClaudeTui` or `runTuiTurn`. `upstreamCall === callClaude` and streaming uses `callClaudeStreaming` — byte-for-byte identical to the pre-TUI code path.
### Kill-switch
Unset `CLAUDE_TUI_MODE` (or set it to any value other than `"true"`) → stream-json path restored immediately on next restart.
### Home strategy (real-home default)
`TUI_HOME = OCP_TUI_HOME || HOME` (defaults to the operator's real home).
- **Real-home (default, `OCP_TUI_HOME` unset):** claude runs with the operator's own `~/.claude/` — shared credentials, existing onboarding, no OAuth fork risk. `ensureTuiCwdTrusted` seeds the trust record for the scratch cwd in the real `~/.claude.json` (atomic write).
- **Scratch-home opt-in (`OCP_TUI_HOME=<path>`):** a dedicated `HOME` that symlinks `~/.claude/.credentials.json` from the real home (token is never copied) and seeds a stripped `~/.claude.json` (no project history, trusts only the scratch cwd). **Caveat:** claude rewrites `.credentials.json` on OAuth token refresh, replacing the symlink with a regular file — this forks the credentials. Use scratch-home only with a dedicated OAuth or for ephemeral testing.
### Working directory
`TUI_CWD = OCP_TUI_CWD || $HOME/.ocp-tui/work` (dedicated scratch cwd). Transcripts land under `<HOME>/.claude/projects/<encoded-cwd>/` — a stable, single location separate from the operator's real project histories. The directory is created automatically on first request.
### MCP hard-disable
`--strict-mcp-config` (no `--mcp-config` argument) prevents account-attached managed MCP servers from connecting. Belt-and-braces: `--disallowedTools 'mcp__*'` blocks any MCP tool invocation even if a server were somehow loaded. Built-in tools (Bash, Read, etc.) are left enabled on the A-path (single-user, acceptable).
### Session namespace
All tmux sessions use the prefix `ocp-tui-`. The prefix-scoped reaper (`reapStaleTuiSessions`) kills only `ocp-tui-*` sessions, never `olp-tui-*` or any other prefix. A stale-session cleanup runs once at OCP boot when `TUI_MODE` is on.
---
## SECURITY — PROMINENT WARNING
**TUI-mode is SINGLE-USER / SINGLE-OPERATOR ONLY.**
`claude` runs as the OCP process owner with full filesystem access regardless of `HOME` setting. Home selection is **not** user isolation. If OCP is serving multiple users or guest API keys:
- A guest prompt would run `claude` with the **operator's** filesystem access.
- An adversarial prompt could exfiltrate files, run shell commands, or exhaust the subscription.
**Never enable `CLAUDE_TUI_MODE=true` on an OCP instance that serves untrusted callers or multiple users.**
The B-path (multi-tenant isolation) requires:
1. `--tools ""` (no built-in tools)
2. Per-key ephemeral `HOME` (isolated credentials + no cross-key project pollution)
3. Sandbox runtime (e.g. `@anthropic-ai/sandbox-runtime`)
B-path is **deferred** and is not implemented in this ADR. Until B-path lands, TUI-mode must only be enabled on a personal single-user OCP.
---
## Observability and concurrency (PR-B amendment)
**Date:** 2026-06-10
**Status:** Accepted — amends ADR 0007.
**Motivation:** the post-PR-A code audit, findings C-4 (P1) and C-5 (P1).
### C-4 — independent concurrency bound for the TUI path
The global `MAX_CONCURRENT` gate lives in `spawnClaudeProcess()` (the `-p` / stream-json
path). `callClaudeTui()` never calls `spawnClaudeProcess` — it calls `runTuiTurn()`, which
cold-boots a full interactive `claude` inside a fresh tmux session. So the TUI path had **no**
concurrency bound: N concurrent TUI requests spawned N simultaneous cold-boot tmux+claude
processes. On a small host (e.g. a Pi 4 serving a family) a burst of ~5 is an OOM risk and
also multiplies subscription rate-limit pressure.
PR-B adds an **independent** limiter for the TUI path (`lib/tui/semaphore.mjs`,
`TuiSemaphore`):
- **`OCP_TUI_MAX_CONCURRENT`, default `2`.** Rationale: a TUI turn is heavy — a per-request
cold-boot of tmux+claude plus up to `CLAUDE_TUI_WALLCLOCK_MS` (120 s) of wallclock — so a
small host cannot run many at once. `2` is the conservative default that keeps a Pi-class
host alive under a family burst while still allowing some overlap. It is deliberately **not**
the same knob as `MAX_CONCURRENT` (default 8): the two pools have different shapes (a
stream-json spawn is cheap and fast; a TUI turn is a heavy cold-boot + long wallclock), so
coupling them would mis-size one of the two paths.
- **Queue, don't reject.** The limiter **queues** (awaits a slot), mirroring the spirit of
`MAX_CONCURRENT` — requests are not dropped on contention. To bound memory against a runaway
client, the wait queue itself is capped (`maxQueue`, default 32× the limit); when the queue
is full `run()` rejects with `tui_queue_full`, surfaced as a 503 — deterministic backpressure
rather than silent OOM.
- **Slot released in a `finally`.** `TuiSemaphore.run(fn)` releases the slot in a `finally`, so
any throw — PR-A's honesty gates (`tui_wallclock_truncated`, `tui_upstream_error`), a
`tui_paste_not_landed`, or a `tui_spawn_failed` — can never leak a slot.
This limiter has **zero effect when `TUI_MODE` is off**: `callClaudeTui` is never reached, so
the semaphore is never entered. The default stream-json path is untouched.
### C-5 — operator-visible drift surface on `/health` (additive)
The `tui_entrypoint_mismatch` warning only reached journald. After the 2026-06-15 flip, a
silent `sdk-cli` drift (the documented top risk in this ADR — a lost TTY flipping the
self-classification to the metered Agent SDK pool) would drain metered credits **invisibly**.
PR-B adds a `tui` block to the `/health` JSON response so an operator can poll it:
```
tui: {
enabled: <TUI_MODE>,
entrypointMode: <OCP_TUI_ENTRYPOINT>, // cli | auto | off
lastEntrypoint: <last observed cc_entrypoint, e.g. "cli", or null>,
entrypointMismatches: <count of cli-expected-but-got-other turns>,
inflight: <current concurrent TUI turns>,
queued: <turns waiting for a slot>,
maxConcurrent: <OCP_TUI_MAX_CONCURRENT>
}
```
`lastEntrypoint` is recorded and `entrypointMismatches` incremented inside `callClaudeTui` in
the same mismatch branch that already emits the journald warning (via `recordTuiEntrypoint`).
`inflight` / `queued` / `maxConcurrent` come from the C-4 semaphore. When `TUI_MODE` is off the
block still appears with `enabled:false` (cheap, harmless) so the response shape is stable for
consumers regardless of mode.
### ALIGNMENT authorization for the `/health` change
`/health` is a **grandfathered B.2 endpoint** under ADR 0006, frozen at its v3.16.4 behaviour.
`ALIGNMENT.md`'s grandfather provision states: *"Any change to the contract (request shape,
response shape, semantics) of a grandfathered B.2 endpoint is treated as a new authorization
request and requires either a behaviour-preserving refactor PR or its own ADR."*
This amendment **is** that authorization. The argument:
- The change is **additive**: it adds one new top-level field (`tui`) containing only new
sub-fields. **No existing `/health` field is changed, renamed, removed, or re-typed**, and no
existing semantics change. Existing `/health` consumers (the dashboard, `ocp-connect`,
monitoring) read the fields they already read and are unaffected — the change is
**behaviour-preserving** for them, which is exactly the bar the grandfather provision sets for
a non-ADR contract change.
- The TUI observability surface is an **intrinsic part of the TUI feature** whose authorizing
authority is **this ADR (0007)**, not a brand-new B.2 endpoint. We are not adding a new B.2
endpoint or a new method (which would each require their own fresh ADR under the New Class B
endpoint procedure) — we are extending the response of an existing grandfathered endpoint with
fields that report state owned by an ADR-0007 feature. ADR 0007 is the natural home for that
authority, and this amendment records it explicitly.
- `cli.js` does not perform this operation — `/health` is OCP-owned (Class B), so no `cli.js`
citation applies; the citation is this ADR + ADR 0006 (grandfathered B.2) per
`ALIGNMENT.md`'s Class B citation requirement.
### `OCP_TUI_MAX_CONCURRENT` summary
| Env var | Default | Meaning |
|---|---|---|
| `OCP_TUI_MAX_CONCURRENT` | `2` | Max concurrent interactive TUI turns. Independent of `CLAUDE_MAX_CONCURRENT` (the stream-json path). Excess turns queue (bounded); a full queue yields a 503. |
---
## Authentication + defunct-reaping (PR-C amendment)
**Date:** 2026-06-13
**Status:** Accepted — amends ADR 0007.
**Motivation:** the PI231 production incident — TUI-mode returned `Please run /login · API Error: 401` for days; re-login never stuck.
### How the TUI `claude` authenticates
The spawned interactive `claude` obtains its OAuth bearer in one of two ways, in this order of preference:
1. **`CLAUDE_CODE_OAUTH_TOKEN` in env (PREFERRED).** If the env var is set on the OCP process, `buildTuiCmd` adds `CLAUDE_CODE_OAUTH_TOKEN=<shq-escaped token>` to the pane command's `env` prefix. claude then authenticates via this long-lived token and **never touches the credentials-refresh path**. This is the stable mode — it is exactly how the oracle and Mac-mini hosts already run (and how `server.mjs`'s own `getOAuthCredentials()` takes the same env at highest precedence). cli.js is **not** the authority here: this is a Class B, OCP-owned TUI spawn — see the Class B citation below.
2. **`<HOME>/.claude/.credentials.json` (FALLBACK).** When the env var is unset, claude falls back to the credentials file and its short-lived access token, renewing via the single-use refresh token.
The token MUST be set explicitly in `buildTuiCmd` because **tmux does not forward the parent process's environment to the pane** (verified live 2026-06-01 — the same reason the whole env is delivered as an `env` prefix). A token sitting in the OCP process env is invisible to the pane unless `buildTuiCmd` re-emits it.
### Why the fallback path corrupts (the PI231 incident)
When the env token is absent, every per-request spawn drives claude through the credentials.json refresh path. OAuth refresh tokens are **single-use / rotating**: a refresh consumes the old refresh token and writes a new one. The per-request `kill-session` teardown can race / interrupt claude mid-rotation, and over many spawn+kill cycles the refresh token ended up an **empty string** — at which point renewal is impossible and the host returns a permanent 401. Re-login writes a fresh token, but the next spawn re-corrupts it. **Proof the env-token fix works:** on the broken PI231 host, `CLAUDE_CODE_OAUTH_TOKEN=<oat01 token> claude -p ...` returned a real answer *despite* the corrupt credentials.json (control without the env token = 401).
**Operator guidance:** set `CLAUDE_CODE_OAUTH_TOKEN` on any TUI-mode host. The credentials.json fallback is retained only for hosts that intentionally rely on it; it is not recommended for a long-running TUI deployment.
**Security note:** with the token in the pane command, it is visible in `ps`. This is acceptable for the **single-user A-path** (it mirrors the existing plaintext-token practice for `server.mjs`), and the **multi-user B-path is already refused at boot** (`CLAUDE_TUI_MODE=true` + `AUTH_MODE=multi` is a hard FATAL), so a guest can never reach this spawn.
### Defunct `<claude>` reaping
The connected leak: the pane's `claude` process is a child of the long-lived **tmux server** daemon, not of the OCP node process (`tmux new-session -d` returns the instant the server forks the pane). Node can therefore never `waitpid()`/reap it — a SIGKILL still needs the *parent* (the tmux server) to reap. `kill-session` destroys the session but leaves the pane's `claude` (and its grandchildren) as `<defunct>` zombies that only the server reaps; over 30 days on PI231 this accumulated to **25 defunct `<claude>`** (a live `tmux kill-server` dropped it 25→3).
The node-reachable action that *actually reaps* — rather than merely re-signalling — is to stop the tmux server: on server exit the kernel reparents survivors to init (PID 1), which reaps them. `reapStaleTuiSessions` therefore, after killing our own `ocp-tui-*` sessions, issues `kill-server` **only when no foreign session of any prefix remains** (coexistence: never disrupt a co-hosted `olp-tui-*` instance). This runs at boot (existing) and now on a 15-min periodic interval gated on TUI-mode and on the TUI path being idle (`inflight === 0 && queued === 0`) so a live turn's pane is never torn down. Residual: a request whose pane is created in the narrow window between the idle-check and `kill-server` would fail cleanly via the existing honesty gates (rare; documented in the server comment).
### ALIGNMENT authorization (Class B)
Both changes are **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"; this surface is authorized by **this ADR (0007)** per `ALIGNMENT.md`'s Class B citation requirement. No Class A wire surface, no endpoint shape, no `alignment.yml` blacklist token, and no `models.json` entry is touched.
---
## Consequences
### Positive
- After 2026-06-15, requests in TUI-mode bill against the Pro/Max subscription pool (`cc_entrypoint=cli`) rather than the Agent SDK credit pool.
- Kill-switch is immediate (unset env var + restart); zero code change required.
- Default stream-json path is untouched — no regression risk for existing deployments.
### Negative / trade-offs
- **No token streaming:** responses are buffered then replayed as chunked SSE. Clients see a delay then the full response arrives; real-time token streaming is not available in TUI-mode.
- **Billing unmeasurable until 2026-06-15:** the `cc_entrypoint=cli` signal is verified, but the credit deduction from the correct pool cannot be confirmed until the billing split activates.
- **tmux dependency:** the host must have `tmux` installed. CI / Docker images that lack tmux cannot use TUI-mode (the default stream-json path is unaffected).
- **Wall-clock cap:** long Opus thinking turns may hit the 120 s cap. Increase `CLAUDE_TUI_WALLCLOCK_MS` if needed (no quiescence heuristic — the reader polls until terminal marker or cap).
- **Grey-area usage:** running an interactive `claude` session headlessly to serve HTTP requests is not an officially documented use case. If Anthropic policy changes to block this pattern, OCP must fall back to the stream-json path (unset `CLAUDE_TUI_MODE`).
### Coexistence
- tmux prefix `ocp-tui-` is registered. Any co-hosted OLP test instance must use `olp-tui-`. Never run two TUI proxies on the same OAuth concurrently — stop one instance during integration testing.
---
## Provenance
TUI-mode originated in a prototype contributed via PR #101 (see the PR for author attribution). The productionization design is in `docs/superpowers/specs/2026-05-30-tui-mode-production-design.md`. Spikes S1S6 / T1T6 were validated live on the test host against `claude v2.1.158`.
+1
View File
@@ -22,6 +22,7 @@ New ADRs increment from the highest existing number. Filenames are
| [0003](0003-models-json-spot.md) | `models.json` as SPOT | Why model IDs / aliases / context windows live in a single JSON file (not duplicated in `server.mjs` and `setup.mjs` arrays). v3.11.0 refactor. |
| [0004](0004-openclaw-auto-sync.md) | OpenClaw Auto-Sync | Why `scripts/sync-openclaw.mjs` runs on `ocp update`, what its scope boundary is (writes only `models.providers["claude-local"].models` and `agents.defaults.models["claude-local/*"]`), and the idempotency contract. |
| [0005](0005-no-multi-provider.md) | No Multi-Provider | Why OCP stays single-provider (Anthropic-via-cli.js) and does not extend to OpenAI / Gemini / OpenRouter. Cost estimate: ~7 weeks for a v1 that buys neither moat nor commercial readiness. Separate commercial work starts in a separate repo. |
| [0006](0006-openai-shim-scope.md) | OpenAI Shim Scope | The Class A / Class B taxonomy. Class A endpoints (`cli.js`-mirror) keep Rules 15 verbatim; Class B endpoints (OCP-owned compatibility surface — `/v1/chat/completions`, `/v1/models`, admin endpoints) are anchored to OpenAI's spec (B.1) or to an authorizing ADR (B.2). Triggered by PR #99 (external `response_format` honoring). Grandfathers the existing B.2 inventory at v3.16.4. |
## When to write a new ADR
+11
View File
@@ -0,0 +1,11 @@
# OpenAI Compatibility Pin (Class B.1)
**Status:** Placeholder — populated at first B.1 audit per ADR 0006 §"Class B audit cadence".
This file is the Class B.1 counterpart to the Class A `cli.js` audit pin in `ALIGNMENT.md` §"Golden Reference". When the first annual alignment audit covers Class B (per `ALIGNMENT.md` §"Annual Alignment Audit"), this file will be populated with:
- The OpenAI `/v1/chat/completions` specification snapshot date being audited against (and the source URL the snapshot was taken from).
- The list of B.1 endpoints (currently `/v1/chat/completions`, `/v1/models`) and, for each one, the specific OpenAI spec fields and behaviours it honors.
- Drift detection notes for any OpenAI spec changes since the previous audit, and any OCP code changes required to track those changes.
Until populated, this file's existence is only a forward reference so that the link in `ALIGNMENT.md` does not 404. The actual audit procedure is defined in `ALIGNMENT.md` §"Annual Alignment Audit" (Class B scope) and ADR 0006 §"Class B audit cadence".
+268
View File
@@ -0,0 +1,268 @@
# OCP Anthropic-Only Sandbox Strategy — Handoff Document
**Status:** Forward-looking planning doc (not yet a decision)
**Date:** 2026-05-29
**Audience:** future OCP maintainer / session picking up multi-tenant security work
**Provenance:** authored during OLP Phase 7 PR-B re-evaluation; OLP's parallel analysis (multi-provider) lives at `dtzp555-max/olp` `docs/adr/0014-sandbox-runtime-integration.md` Amendment 1 (pending). This OCP-side doc strips the multi-LLM generalization and keeps only what applies to OCP's single-provider (anthropic) deployment.
---
## 1. Why this doc exists
OCP is in maintenance mode (per OLP ADR 0001 supersession of OCP ADR 0005). It is not under active development for new features. However, two things may eventually drive sandbox work in OCP:
1. **Multi-key OCP deployments.** `OCP_OWNER_TOKEN` + per-key cache namespace already shipped (OCP `lib/keys.mjs`). If multiple human users share an OCP instance, the same multi-tenant filesystem-isolation gap that motivated OLP Phase 7 also exists here.
2. **Cloud or shared-host OCP deployments.** Any deployment beyond "single user on their own machine" inherits the threat surface.
If/when that work starts, this doc is the prior-art capture so the maintainer doesn't repeat OLP's PR-B path (which has a documented dead-end — see § 3.2 below).
This doc is anthropic-only by design — codex/mistral/etc. multi-LLM concerns are out of scope per OCP ADR 0005.
---
## 2. The multi-tenant gap (OCP-specific)
OCP spawns `claude -p` as the OCP-process user. Every spawned claude instance runs with the OCP user's filesystem permissions. Consequences for a multi-key OCP deployment:
1. **Cross-key lateral read.** A prompt-injected `cat ~/.ocp/keys/<other-key>.json` reads any other key's manifest (token hash, owner_tier, providers_enabled — not catastrophic since it's only the *hash*, but still identity-attribution surface).
2. **OAuth credential exposure.** `~/.claude/.credentials.json` is the Anthropic OAuth refresh token. A prompt-injected read of this file = stealing the subscription that OCP exists to pool.
3. **SSH identity exposure.** `~/.ssh/id_*` reachable for lateral movement to other hosts the OCP user can reach.
4. **Other host secrets.** Anything else under the OCP user's home is reachable.
OCP's `ALIGNMENT.md` Class A/B endpoint discipline does not address this — that discipline is wire-level honesty (`cli.js` mirror), not host-level isolation.
The threat model assumes prompt-injection capability — any caller with a valid OCP key + ability to craft a prompt that elicits a tool call. Default `claude -p` mode includes Read/Bash/etc. tool descriptions in the system prompt; the model is **eager** to use them.
---
## 3. Why OLP Phase 7 PR-B is the wrong path to copy
OLP attempted to wrap `claude -p` spawn in `@anthropic-ai/sandbox-runtime` (outer bubblewrap on Linux, sandbox-exec on macOS). This produced four binding problems documented during OLP's re-evaluation:
### 3.1 Anthropic's design doesn't expect external sandboxing
Per Anthropic's [engineering blog on Claude Code sandboxing](https://www.anthropic.com/engineering/claude-code-sandboxing), `sandbox-runtime` is designed to be invoked **by claude code itself** to sandbox **its own** Bash tool / MCP servers / spawn children. It is **not** designed to sandbox claude code as an externally-wrapped process.
Concretely: claude CLI assumes it can freely read+write its own `$HOME`-derived paths (`~/.claude.json`, `~/.claude/.credentials.json`, `~/.config/claude/`, future state files). When wrapped in `bwrap --ro-bind / /`, those writes hit `EROFS` and claude silently exits with no stdout.
### 3.2 `~/.claude.json` upstream status is "closed not planned"
claude CLI writes `~/.claude.json` non-atomically at startup. Upstream issues #28842, #29162, #29217, #28837, #29051, #29250, #7243 all document this. **#29250 is closed as "not planned / duplicate"** — Anthropic is not going to make this file atomic-write because their mental model is that claude runs in an environment that can write its `$HOME`.
For OCP, this means: any outer-sandbox approach that uses `--ro-bind` on `$HOME` will be a **permanent maintenance treadmill** — every new claude CLI version that adds a state file outside the patched mount paths breaks OCP. OLP's PR-B fold-in tried to patch this by promoting `~/.claude/` to rw, which was insufficient (the actual file is `~/.claude.json` at $HOME root, not inside `~/.claude/`).
### 3.3 The threat model doesn't justify the cost
OCP is, per ADR 0005, a personal-and-family-scale tool. The realistic threat surface is misbehaving prompts from family members or self-injected via dependent agents, not adversarial external attackers. The blast radius of a successful cross-key read is bounded (token *hash*, OAuth that's pooled-by-design across all OCP keys).
A maintenance-mode project investing weeks into outer-sandboxing for a hypothetical threat is a poor cost/benefit. There are cheaper architectures (§ 4 below) that get most of the protection.
### 3.4 OLP-specific reason that does NOT apply to OCP
OLP also hit a multi-provider conflict: codex CLI has its own inner bubblewrap that breaks when wrapped in an outer bwrap (openai/codex#16018). **This is not an OCP concern** — OCP only spawns claude. So the multi-provider forcing function for OLP doesn't apply here. The other three reasons (§ 3.13.3) are sufficient on their own.
---
## 4. Three viable approaches for OCP
Ranked by "engineering cost vs isolation strength" — pick by deployment context.
### 4.1 Approach A — Ephemeral `$HOME` via env var (recommended starting point)
Per-spawn setup:
```
ephemeralRoot=/tmp/ocp-spawn/<keyId>/<reqId>/home
mkdir -p $ephemeralRoot/.claude
ln -s ~/.claude/.credentials.json $ephemeralRoot/.claude/.credentials.json
HOME=$ephemeralRoot claude -p --output-format stream-json ...
```
Mechanics:
- claude CLI uses Node's `os.homedir()` which reads `$HOME` env first.
- `~/.claude.json` written by claude on startup → lands in `/tmp/ocp-spawn/<keyId>/<reqId>/home/.claude.json` (tmpfs, discarded after spawn).
- `~/.claude/.credentials.json` is the OAuth file claude needs — symlinked in read-only from the real one.
- Any new state file claude CLI introduces in a future version → also lands in the ephemeral home, no patch needed.
Threat coverage:
- ✅ Solves EROFS upgrade tax permanently — any claude state-file location works because they all land in tmpfs.
- ✅ Cross-key OAuth credential isolation — keyA's ephemeral home has only keyA's symlink, but here the symlink target is the SAME real file because OCP shares OAuth (this is fine: shared OAuth is OCP's design, the symlink just keeps the file inaccessible via `cat ~/.claude/.credentials.json` from a different keyId's ephemeral root).
- ❌ Does NOT solve cross-key lateral filesystem read via absolute paths. A prompt-injected `cat /home/<ocp-user>/.ocp/keys/<otherKey>.json` still works — `os.homedir()` override doesn't affect absolute-path reads.
5-minute spike before adopting:
```bash
HOME=/tmp/fake-home-spike claude --print "echo PONG" --no-session-persistence 2>&1
ls -la /tmp/fake-home-spike # expect: .claude.json + .claude/ created here
find ~/.claude ~/.claude.json -newer /tmp/spike-marker 2>/dev/null # expect: empty
```
If claude falls back to `os.userInfo().homedir` (uses getpwuid_r, ignores HOME env), this approach degrades — fall back to Approach B.
**Engineering cost:** ~50 LOC in OCP's spawn pipeline (mkdir + symlink + env merge + cleanup-on-exit). No new dependencies.
### 4.2 Approach B — Outer bubblewrap with `--tmpfs $HOME` + `--ro-bind` credentials
```
bwrap \
--ro-bind / / \
--tmpfs /home/<ocp-user> \
--ro-bind /home/<ocp-user>/.claude/.credentials.json /home/<ocp-user>/.claude/.credentials.json \
--ro-bind /home/<ocp-user>/.ocp/keys/<thisKeyId>.json /home/<ocp-user>/.ocp/keys/<thisKeyId>.json \
--dev /dev --proc /proc --tmpfs /tmp \
claude -p ...
```
This is the canonical bwrap pattern (Flatpak uses exactly this for every sandboxed app — see [Bubblewrap ArchWiki Examples](https://wiki.archlinux.org/title/Bubblewrap/Examples)).
Threat coverage:
- ✅ Solves EROFS upgrade tax (tmpfs accepts any write path).
- ✅ Cross-key lateral read prevention — only the current key's manifest is bind-mounted in, others are simply absent from the sandbox view.
-`~/.ssh` and similar identity material absent from sandbox.
Trade-offs:
- bwrap dependency: install `bubblewrap` apt package on host.
- Bypasses `@anthropic-ai/sandbox-runtime` library — direct bwrap arg composition. Worth it because sandbox-runtime's outer-wrap design is for short-lived claude-internal subprocesses, not long-running claude CLI itself (per § 3.1).
- macOS: not supported by bwrap (macOS would need separate `sandbox-exec` profile, ~50-100 LOC additional work). OCP cross-machine maintainer deploys mostly on Mac mini + Oracle ARM VM — both Linux on the cloud side, Mac mini side may remain unsandboxed if family-trust-zone.
**Engineering cost:** ~150 LOC for the spawn wrapper + deployment doc updates to require `apt install bubblewrap`. macOS support is a separate ~100 LOC if/when needed.
### 4.3 Approach C — OverlayFS lowerdir (read-only) + tmpfs upperdir (writable)
```
mount -t overlay overlay \
-o lowerdir=/home/<ocp-user>/.claude,upperdir=/tmp/ocp-spawn/<reqId>/upper,workdir=/tmp/ocp-spawn/<reqId>/work \
/tmp/ocp-spawn/<reqId>/merged-claude
HOME=/tmp/ocp-spawn/<reqId>/home claude -p ...
# After spawn: umount + rm -rf
```
Most elegant — claude sees a view identical to its real `~/.claude/`, all writes go to tmpfs upperdir, real `~/.claude/` is never touched.
Trade-offs:
- Requires `CAP_SYS_ADMIN` or rootless-overlayfs (kernel ≥5.11 + user-ns enabled). OCP currently runs as the maintainer's user — no SYS_ADMIN — so this would require either running OCP as root (bad) or rootless-overlayfs setup.
- More moving parts (mount/umount per spawn, work-dir lifetime, cleanup-on-crash).
Better fit if OCP ever moves to a dedicated `ocp` system user with `CAP_SYS_ADMIN` capability via systemd.
**Engineering cost:** ~120 LOC + kernel/permission preflight check.
---
## 5. Cross-key isolation orthogonal layer
The three approaches above all solve `~/.claude.json` EROFS + state-write isolation. None of them alone solve **cross-key lateral filesystem read via absolute paths** (e.g. prompt-injected `cat /home/<user>/.ocp/keys/<otherKey>.json`).
For that, two options compose with any of A/B/C:
### 5.1 Per-spawn `sandbox-runtime` customConfig with `denyRead`
`@anthropic-ai/sandbox-runtime`'s `wrapWithSandbox(command, binShell?, customConfig?, abortSignal?)` accepts per-call override:
```
const otherKeysWorkspaces = listAllKeyManifestsExcept(thisKeyId)
const wrapped = await SandboxManager.wrapWithSandbox(claudeCommand, undefined, {
filesystem: {
denyRead: [
...otherKeysWorkspaces, // all keys except current
'/home/<ocp-user>/.ssh',
'/home/<ocp-user>/.gnupg',
'/home/<ocp-user>/.aws',
],
allowWrite: [ephemeralRoot, '/tmp'],
},
})
```
This adds bwrap deny-paths per-spawn (after sandbox-runtime singleton init). Works in combination with Approach A (the `HOME` env-var override is independent of sandbox-runtime's restrictions).
Caveat: this re-introduces the outer-bwrap concern from § 3.1 — claude CLI is now wrapped after all. Mitigation: use this only for **cross-key isolation**, not for `$HOME` restriction. The `denyRead` paths are all outside `$HOME`, so claude's `~/.claude.json` write is unaffected.
### 5.2 Per-OS-user OCP spawning
Each OCP key gets a dedicated Linux user (`ocp-<keyId>`). Spawn claude as that user via `runuser` or `sudo -u`. OAuth credential shared via Linux group permissions or bind-mount.
True kernel-level uid isolation. Most robust answer for OCP-as-shared-host scenarios.
Trade-offs:
- Setup script complexity (one-time per key).
- Linux-only.
- Doesn't fit Mac mini deployment.
Best fit for a cloud OCP deployment where per-tenant trust isolation matters.
---
## 6. Trust model framing
OCP's authentication layer (`lib/keys.mjs`) provides **attribution** (per-key audit, per-key cache namespace). It does NOT, by itself, provide **isolation** (per-key trust boundary against prompt-injection lateral reads).
This distinction is worth making explicit in OCP's README "Security" section (it currently isn't). The three tiers:
| Tier | Trust Model | Sandbox requirement |
|---|---|---|
| **Single-user** | maintainer's own machine, single OCP token | None — system-user permissions are sufficient |
| **Family-trust-zone** | maintainer + family members on shared OCP instance, all parties trusted not to attack each other | Optional — Approach A (ephemeral $HOME) gives cleanup hygiene without changing trust assumptions |
| **Shared-host / cloud / external callers** | OCP keys handed to potentially-adversarial callers (CI runners, third-party agents, public demo) | Required — Approach B or C + § 5 cross-key isolation |
The current OCP deployment fits tier 1 or 2. The work in this doc applies only when promoting to tier 3.
---
## 7. Recommendation if/when this work starts
**Phase 1 — Approach A (ephemeral `$HOME`) only.**
- ~50 LOC, no apt deps, works on Mac mini + Linux
- Solves the EROFS upgrade tax structurally
- Closes cross-key OAuth-credential-file lateral read
- Cost-effective hygiene improvement
**Phase 2 — Approach B (outer bwrap) gated by deployment config.**
- Add `~/.ocp/config.json` field `security.sandbox: 'off' | 'tmpfs-home'`
- Default off (preserves Mac mini family deployment)
- Operator opts in on Linux cloud deployments
- Apt prereq documented in deployment guide
**Phase 3 — § 5 cross-key isolation (only if tier 3 deployment is planned).**
- Layer per-spawn customConfig denyRead OR per-OS-user spawning
- Treat as separate ADR amendment with its own threat-model evidence
**Skip Approach C** unless a future requirement forces overlay (low likelihood for OCP scope).
---
## 8. Authority citations
This doc claims findings about claude CLI / `@anthropic-ai/sandbox-runtime` behavior. Sources for verification:
- [Anthropic engineering — Claude Code sandboxing](https://www.anthropic.com/engineering/claude-code-sandboxing) (sandbox-runtime design intent)
- [Anthropic sandbox-runtime GitHub](https://github.com/anthropic-experimental/sandbox-runtime) (wrapWithSandbox API + customConfig per-call signature)
- [claude-code#29250 — `.claude.json` non-atomic-write closed-not-planned](https://github.com/anthropics/claude-code/issues/29250)
- [claude-code#29162 — read-only `~/.claude.json` startup hang](https://github.com/anthropics/claude-code/issues/29162)
- [claude-code#29217 — concurrent-write corruption](https://github.com/anthropics/claude-code/issues/29217)
- [claude-code#28842 — Windows startup race](https://github.com/anthropics/claude-code/issues/28842)
- [claude-code#7243 — "the .claude.json elephant in the room"](https://github.com/anthropics/claude-code/issues/7243)
- [Bubblewrap README](https://github.com/containers/bubblewrap)
- [Bubblewrap ArchWiki — Examples section, --tmpfs HOME pattern](https://wiki.archlinux.org/title/Bubblewrap/Examples)
- [Sandboxing CLI tools with Bubblewrap — botmonster](https://botmonster.com/self-hosting/sandbox-linux-apps-cli-tools-bubblewrap/)
- [OverlayFS kernel documentation](https://docs.kernel.org/filesystems/overlayfs.html)
- [OverlayFS ArchWiki](https://wiki.archlinux.org/title/Overlay_filesystem)
OLP's parallel work (multi-provider generalization of this strategy, including the codex inner-bwrap conflict that does not apply to OCP):
- `dtzp555-max/olp` `docs/adr/0014-sandbox-runtime-integration.md` (PR-B as-shipped) + Amendment 1 (pending — Solution 1 architecture)
- `dtzp555-max/olp` `docs/plans/cloud-deployment-family.md` § 5 (deployment-side trust tier mapping)
- archive branch `dtzp555-max/olp:phase-7-pr-b-outer-bwrap-snapshot` captures the outer-bwrap approach as snapshot if anyone wants to revisit it
---
## 9. What this doc is NOT
- Not an ADR. ADRs are decisions; this is a forward-facing strategy doc that becomes an ADR only when work starts and a decision is made.
- Not a binding spec. The three approaches are alternatives; the recommendation in § 7 is the maintainer's lean from prior-art analysis, not a constitution.
- Not authority for any code change. OCP `ALIGNMENT.md` still requires citation per Class A/B; no sandbox code lands without proper authority pinning when the work eventually starts.
- Not a security audit. The threat model is informal — based on prior-art search + incident memory from OLP's parallel session. A real cloud deployment should commission an independent threat model.
---
**Authors:** project maintainer (handoff prepared with AI drafting assistance during OLP Phase 7 PR-B re-evaluation, 2026-05-29).
+151
View File
@@ -0,0 +1,151 @@
# 2026-06-15 Canary Runbook
**Purpose:** Confirm that a TUI-mode turn is billed to the **Pro/Max subscription pool** (not the Agent SDK credit pool) after Anthropic's 2026-06-15 billing split activates.
The billing classifier reading `cli` is **necessary but NOT sufficient** proof. (Note the naming: the value is stored in the JSONL transcript under the field name `entrypoint`, and sent to Anthropic on the wire as the `cc_entrypoint` header — they carry the same value after claude's startup classification. The commands below grep the transcript, so they match `entrypoint`.) A `cli` label tells you OCP sent the right classification; it does not tell you Anthropic billed the right pool. The only authoritative test is to observe whether the **Agent SDK credit balance** moves or not before and after the canary turn.
---
## Prerequisites
- `CLAUDE_TUI_MODE=true` already set and OCP restarted (see [TUI-mode setup in README](../../README.md#enabling-tui-mode-opt-in))
- `tmux` installed on the host
- No other OCP traffic during the canary (quiesce — see below)
- Access to your Anthropic account billing page (manual step — see below)
---
## Step 1 — Quiesce the host
Stop any IDE or client that is actively sending requests through this OCP instance.
Confirm the proxy is idle:
```bash
curl -s http://127.0.0.1:3456/health | python3 -m json.tool | grep activeRequests
# Expected: "activeRequests": 0
```
Wait until `activeRequests` is `0` before proceeding. If you cannot quiesce (e.g. family members are actively using it), run the canary on a separate OCP instance or during a quiet window.
---
## Step 2 — Read the Agent SDK credit balance BEFORE the canary
> **Manual step — no programmatic API available.**
>
> OCP's `/usage` endpoint reads `anthropic-ratelimit-unified-*` response headers from the Pro/Max plan quota (5-hour and 7-day subscription windows). These headers report **subscription usage**, not the Agent SDK credit pool balance. There is no known programmatic API to query the Agent SDK credit pool balance from outside the Anthropic web app.
To read the balance:
1. Open [https://claude.ai/settings/billing](https://claude.ai/settings/billing) (or your Anthropic Console billing page) in a browser.
2. Find the **Agent SDK Credits** section (sometimes labeled "API Credits" or "Agent SDK usage").
3. Note the current balance (e.g. `$18.43 remaining of $20.00`).
Write the value down — you will compare it after the canary turn.
---
## Step 3 — Send the canary turn
With TUI-mode on and the host quiesced, send exactly one small request:
```bash
curl -s -X POST http://127.0.0.1:3456/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "claude-haiku-4-5-20251001",
"messages": [{"role": "user", "content": "Reply with the single word: pong"}],
"max_tokens": 10
}' | python3 -m json.tool
```
Use Haiku (the cheapest model) to minimize any hypothetical impact if the canary turns red.
Wait for the response to arrive completely (TUI-mode buffers the full response before returning — you will see a delay of several seconds, then the full reply).
---
## Step 4 — Confirm the transcript shows `entrypoint:"cli"`
After the canary turn completes, inspect the most recent JSONL transcript for the billing-classifier label:
```bash
# The canary was run quiesced (Step 1), so the most recent JSONL across ALL project
# dirs IS the canary turn. We glob every projects subdir instead of recomputing
# claude's cwd-encoding rule (it maps every "/" AND "." to "-", e.g. ~/.ocp-tui/work
# => projects/-home-<user>--ocp-tui-work/; see lib/tui/transcript.mjs encodeCwd) —
# a glob is robust even if that encoding changes in a future claude build.
LATEST=$(ls -t "$HOME"/.claude/projects/*/*.jsonl 2>/dev/null | head -1)
echo "Transcript: $LATEST"
grep -o '"entrypoint":"[^"]*"' "$LATEST" | tail -1
# Expected: "entrypoint":"cli"
```
If the output shows `"entrypoint":"cli"`, the billing-classifier label is correct. If it shows `"entrypoint":"sdk-cli"`, the spawn did not get a real PTY — stop immediately and do not re-enable TUI-mode without investigation. Check `tmux new-session` manually and review ADR 0007 § spawn/PTY gate. (If the grep returns nothing, the transcript may not yet be flushed — re-run after a second, or confirm the turn completed.)
**Reminder: an `entrypoint:cli` label (the `cc_entrypoint=cli` wire header) is necessary but not sufficient.** It tells you OCP sent the right label to Anthropic. You must still check the credit balance in Step 5.
---
## Step 5 — Re-read the Agent SDK credit balance AFTER the canary
Return to [https://claude.ai/settings/billing](https://claude.ai/settings/billing) and reload the page. Note the current balance again.
---
## Step 6 — Green/Red decision
### Green (balance unchanged)
The Agent SDK credit balance did not decrease. The turn billed against the Pro/Max subscription pool as expected. TUI-mode is working correctly.
**Actions:**
- Keep `CLAUDE_TUI_MODE=true` on this host.
- Monitor the balance periodically for the first week to catch any delayed attribution.
- Resume normal traffic.
### Red (Agent SDK credit balance decreased)
The Agent SDK credit balance decreased. The subscription pool is not being used for TUI-mode turns on this host, despite `cc_entrypoint=cli` being set. This may indicate a backend routing change on Anthropic's side, a TTY detection failure, or a policy change.
**Actions — immediate:**
1. Unset `CLAUDE_TUI_MODE` (or set to any value other than `"true"`) in the service unit:
- systemd: edit `/etc/ocp/ocp.env` (or the unit's `Environment=` line), then `sudo systemctl daemon-reload && sudo systemctl restart ocp.service`
- launchd: edit the plist `EnvironmentVariables` section, then `launchctl bootout gui/$(id -u)/dev.ocp.proxy && launchctl bootstrap gui/$(id -u) <plist-path>`
2. Restart OCP and confirm the `/health` response no longer shows TUI-mode active.
3. If you share this OCP with family or other Max users: freeze their access temporarily until you understand the billing impact.
4. Consider pivoting to OLP multi-provider (see [OLP](https://github.com/dtzp555-max/olp)) which can spread load across other providers to avoid the Agent SDK credit drain.
Per ALIGNMENT.md Rule 2 / ADR 0007 § Kill-switch: "Per the constitution, the response is to drop the Anthropic provider rather than escalate spoofing."
---
## Ongoing monitoring — self-classification mini-canary
To detect future drift (e.g. a claude CLI upgrade that changes TTY-detection behavior), you can run a periodic one-liner that sends a tiny TUI turn with `OCP_TUI_ENTRYPOINT=auto` (so claude self-classifies rather than having OCP pin the value) and alerts if the transcript self-classification is not `cli`:
```bash
# Run with OCP temporarily configured OCP_TUI_ENTRYPOINT=auto
# Then check the most recent transcript:
# Glob the most recent transcript across all project dirs (robust to claude's
# cwd-encoding rule; run this right after the auto-mode mini-canary turn).
LATEST=$(ls -t "$HOME"/.claude/projects/*/*.jsonl 2>/dev/null | head -1)
RESULT=$(grep -o '"entrypoint":"[^"]*"' "$LATEST" | tail -1)
echo "Self-classified entrypoint: $RESULT"
if echo "$RESULT" | grep -q '"entrypoint":"cli"'; then
echo "OK — subscription pool"
else
echo "ALERT — not cli; check TTY and billing"
fi
```
Run this after any major `claude` CLI upgrade. The `auto` mode lets the CLI's own `t$A` startup function determine the value from the actual TTY state (see ADR 0007 § Billing-classifier labeling).
---
## Related
- [Flip/rollback runbook](./tui-flip-rollback.md) — how to set and unset `CLAUDE_TUI_MODE` on systemd and launchd hosts
- [ADR 0007](../adr/0007-tui-interactive-mode.md) — TUI-mode architecture and governing rules
- README § [Subscription-pool (TUI) mode](../../README.md#subscription-pool-tui-mode)
+180
View File
@@ -0,0 +1,180 @@
# TUI-Mode Flip and Rollback Runbook
**Purpose:** Step-by-step instructions for enabling (`CLAUDE_TUI_MODE=true`) or disabling TUI-mode on real OCP deployments managed by **systemd** (Linux) or **launchd** (macOS).
Run the [615-canary](./615-canary.md) runbook after any flip to confirm billing pool routing is correct.
---
## Critical pitfalls — read first
### systemd: `daemon-reload` is required after editing the unit
Editing the unit file (or EnvironmentFile) and then doing `systemctl restart ocp.service` **without** `daemon-reload` will restart the process with the **old** environment from the cached unit. Always run `daemon-reload` after editing any unit file.
### launchd: `launchctl kickstart -k` does NOT reload plist env
`launchctl kickstart -k gui/$(id -u)/dev.ocp.proxy` kills the running process and re-launches it, but it **re-uses the launchd-cached environment** — not the current plist file. If you edited the plist's `EnvironmentVariables` section, you must do a full `bootout` + `bootstrap` cycle for the change to take effect. `kickstart` is not sufficient.
---
## Flip — enable TUI-mode
### systemd (Linux, e.g. Raspberry Pi, VPS)
**Option A — EnvironmentFile (recommended for clean separation)**
If your unit uses `EnvironmentFile=/etc/ocp/ocp.env` (or similar):
```bash
# 1. Edit the environment file
sudo nano /etc/ocp/ocp.env
# Add or update:
# CLAUDE_TUI_MODE=true
#
# If OCP binds to 0.0.0.0 AND you trust the network:
# OCP_TUI_ALLOW_LAN=1
# (WARNING: TUI-mode is single-user only — only enable OCP_TUI_ALLOW_LAN=1
# if you fully trust every caller that can reach the OCP port on your network)
# 2. Reload the unit definition and restart
sudo systemctl daemon-reload
sudo systemctl restart ocp.service
# 3. Verify
curl -s http://127.0.0.1:3456/health | python3 -m json.tool | grep -E "tui|version"
# Expected: "tuiMode": true (or similar TUI indicator in the health response)
```
**Option B — inline Environment= in the unit file**
```bash
# 1. Edit the unit file
sudo systemctl edit --full ocp.service
# Add or update in the [Service] section:
# Environment=CLAUDE_TUI_MODE=true
# 2. Reload and restart
sudo systemctl daemon-reload
sudo systemctl restart ocp.service
# 3. Verify
systemctl show ocp.service --property=Environment
# Expected: Environment=CLAUDE_TUI_MODE=true ...
```
### launchd (macOS)
Locate the OCP plist. The standard label is `dev.ocp.proxy`:
```bash
# Find the plist path
ls ~/Library/LaunchAgents/dev.ocp.proxy.plist
```
**Edit the plist:**
```bash
# 1. Stop the service first (bootout)
launchctl bootout gui/$(id -u)/dev.ocp.proxy
# 2. Edit the plist — add CLAUDE_TUI_MODE to EnvironmentVariables
# Use your editor of choice:
nano ~/Library/LaunchAgents/dev.ocp.proxy.plist
```
Inside the plist, in the `<key>EnvironmentVariables</key>` `<dict>` block, add:
```xml
<key>CLAUDE_TUI_MODE</key>
<string>true</string>
```
If `OCP_TUI_ALLOW_LAN=1` is also needed (only if OCP binds to `0.0.0.0` and you trust the network):
```xml
<key>OCP_TUI_ALLOW_LAN</key>
<string>1</string>
```
```bash
# 3. Bootstrap (reload from disk + start)
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/dev.ocp.proxy.plist
# 4. Verify
curl -s http://127.0.0.1:3456/health | python3 -m json.tool | grep -E "tui|version"
```
**Confirm env was actually loaded** (not just set in your shell):
```bash
ps aux | grep server.mjs | grep -v grep
# Get the PID, then:
# macOS: ps -E -p <PID> | tr ' ' '\n' | grep CLAUDE_TUI_MODE
# Expected: CLAUDE_TUI_MODE=true
```
---
## Rollback — disable TUI-mode
Rollback is the same procedure as flip, but you **remove** `CLAUDE_TUI_MODE` or set it to any value other than `"true"` (e.g. `false`, or simply omit it).
After rollback, OCP returns to the default `callClaude` / `callClaudeStreaming` stream-json path — byte-for-byte identical to the pre-TUI code path. No other change is required.
### systemd rollback
```bash
# Option A — EnvironmentFile
sudo nano /etc/ocp/ocp.env
# Remove or comment out:
# CLAUDE_TUI_MODE=true
# OCP_TUI_ALLOW_LAN=1 (if set)
sudo systemctl daemon-reload
sudo systemctl restart ocp.service
# Verify
curl -s http://127.0.0.1:3456/health | python3 -m json.tool | grep tui
# Expected: "tuiMode": false (or the field absent)
```
### launchd rollback
```bash
# 1. Stop
launchctl bootout gui/$(id -u)/dev.ocp.proxy
# 2. Edit plist — remove the CLAUDE_TUI_MODE and OCP_TUI_ALLOW_LAN entries from EnvironmentVariables
# 3. Bootstrap
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/dev.ocp.proxy.plist
# 4. Verify
curl -s http://127.0.0.1:3456/health | python3 -m json.tool | grep tui
```
---
## Billing impact of staying on the default (non-TUI) path after 2026-06-15
If you do NOT flip to TUI-mode and keep `CLAUDE_TUI_MODE` unset (the default), OCP continues using `claude -p --output-format stream-json`, which sets `cc_entrypoint=sdk-cli`. After 2026-06-15, every OCP request on the default path will draw from the Agent SDK credit pool (approximately $20/month on a Pro plan, or $100/month on a Max plan) rather than the Pro/Max subscription. The subscription pool usage (5-hour and 7-day windows) will be unaffected, but the Agent SDK credit balance will drain with each request.
If you want to continue using OCP without TUI-mode after 2026-06-15, budget for the Agent SDK credit cost accordingly — or switch to [OLP](https://github.com/dtzp555-max/olp) for multi-provider fallback.
---
## Verify after any flip
1. Check `/health` shows the expected `tuiMode` state.
2. Run the [615-canary](./615-canary.md) to confirm billing pool routing.
3. If TUI-mode is ON: check `ocp logs 10` for any TUI spawn errors (`tui_spawn_failed`, tmux errors).
---
## Related
- [615-canary runbook](./615-canary.md) — how to verify billing pool routing after a flip
- [ADR 0007](../adr/0007-tui-interactive-mode.md) — TUI-mode architecture; Kill-switch section
- README § [Subscription-pool (TUI) mode](../../README.md#subscription-pool-tui-mode)
- README § [Environment Variables](../../README.md#environment-variables) — `CLAUDE_TUI_MODE`, `OCP_TUI_ALLOW_LAN=1`
@@ -0,0 +1,737 @@
# TUI-mode (OCP-first) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add an opt-in `CLAUDE_TUI_MODE` to OCP that serves `/v1/chat/completions` by driving a *real interactive* `claude` session (no `-p`, no `--output-format`) so the request bills as `cc_entrypoint=cli` (subscription pool), reading the answer from claude's native JSONL transcript — while the default stream-json path stays byte-for-byte unchanged.
**Architecture:** Two new pure-ish modules under `lib/tui/` — a transcript **reader** (`transcript.mjs`, provider-agnostic, the shareable core) and a tmux **session driver** (`session.mjs`, OCP-specific). `server.mjs` gains a `callClaudeTui()` that returns `Promise<string>` and is gated into the existing dispatch by a single env flag; because OCP's entire downstream (singleflight → `setCachedResponse``completionResponse` / chunked-SSE-replay → `recordUsage`) already consumes a string from `callClaude`, TUI-mode is a drop-in. Streaming is buffered then replayed as chunked SSE (no token streaming — deliberately, "don't build fragile features").
**Tech Stack:** Node.js ESM (`.mjs`), `tmux` (interactive PTY host), `child_process` (`spawnSync`), `node:fs` polling (no `fs.watch`, no terminal-screen parsing). Test harness: `node test-features.mjs`.
**Source of truth for the TUI mechanism:** the OLP design spec `docs/superpowers/specs/2026-05-30-tui-mode-production-design.md` (CLI-level, applies to both projects) + its 6 validation spikes (S1S6, T1T6) run on PI231 against `claude v2.1.158`. This plan is the OCP-grounded execution of that spec.
---
## Why OCP-first / scope decisions (read before coding)
- **OCP-first** because OCP has the users and its compute path is `callClaude → Promise<string>`, a near-perfect impedance match for a reader that also returns a string. OLP would additionally need a string→IR-chunk-array adapter. OLP-sync is **deferred entirely until the post-2026-06-15 fork decision** — do not spend cycles keeping OLP's TUI in lockstep.
- **A-path only.** Single-user / multi-device on one subscription. No per-key ephemeral isolation, no multi-tenant. (That is the OLP B-path, deferred.)
- **A-path isolation = real `$HOME` + dedicated scratch cwd + `--strict-mcp-config`.** OCP has *no* ISOLATION contract and we do not build one. We run interactive `claude` in the operator's real home (OAuth + onboarding already valid) but in a **dedicated scratch working directory** (`OCP_TUI_CWD`, default `$HOME/.ocp-tui/work`) so transcripts land under one stable `projects/<cwd>` folder instead of polluting the operator's genuine project histories, and the trust-folder dialog is granted once.
- **One `claude` session per request.** OCP is stateless (full conversation re-serialized each request via `messagesToPrompt`). TUI-mode mirrors this: per request, start a fresh interactive session with a fresh `--session-id`, submit one serialized prompt, await turn completion, read the transcript, extract the latest assistant text, tear the session down. Warm-pool / large-paste optimizations are explicitly out of v1 scope.
- **Billing is unmeasurable until 2026-06-15.** Spike S1 proved the `cc_entrypoint=cli` *signal*, not the billed pool. The pre-6/15 deliverable is "a tested, working transport that emits `cli`"; 6/16 we flip the flag and measure with a documented kill-switch.
- **Coexistence rule (PI231 runs an OLP test instance too).** All tmux sessions use the prefix `ocp-tui-`; the reaper kills **only** `ocp-tui-*`, never `olp-tui-*`. Never run two TUI proxies on the same OAuth concurrently — stop the OLP test instance during OCP integration.
- **Provenance.** TUI-mode originated in OCP PR #101 (author courtesy: jaekwon-park <insainty21@gmail.com>). The PR #101 author should be credited + notified on the shipping PR.
---
## File Structure
| File | Responsibility | New/Modified |
|------|----------------|--------------|
| `lib/tui/transcript.mjs` | Pure transcript parsing + the polling reader. Returns the latest assistant text once the turn is terminal or the wall-clock cap elapses. Provider-agnostic — the shareable core. | **Create** |
| `lib/tui/session.mjs` | tmux session lifecycle: boot interactive `claude`, answer the trust dialog, submit the prompt (file → `"$(cat)"` paste → separate Enter), await the reader, tear down. Plus the prefix-scoped reaper. OCP-specific. | **Create** |
| `lib/tui/fixtures/` | Real transcript JSONL harvested from PI231 + a few hand-crafted edge cases, for the reader's unit tests. | **Create** |
| `server.mjs` | `callClaudeTui()` (`Promise<string>`); `streamStringAsSSE()` helper (DRY refactor of the cache-replay block); single-flag dispatch gates; reaper hook at boot; env consts. | **Modify** (`:258` env consts, `:1018``:1023` helpers, `:1467` dispatch, boot block) |
| `test-features.mjs` | Suite for the reader (fixtures, runs in CI) + a live-only guarded suite for the driver (`OCP_TUI_LIVE=1`, skipped in CI). | **Modify** |
| `docs/adr/0007-tui-interactive-mode.md` | OCP ADR 0007 (OCP's next number) — TUI mode rationale, billing-signal authority, scope, kill-switch. | **Create** |
| `README.md` | New env vars (`CLAUDE_TUI_MODE`, `CLAUDE_TUI_WALLCLOCK_MS`, `OCP_TUI_CWD`), a "Subscription-pool (TUI) mode" section, troubleshooting + kill-switch. | **Modify** |
| `CHANGELOG.md` | Unreleased entry. | **Modify** |
---
## PR-1 — Transcript reader (`lib/tui/transcript.mjs`)
The shareable core. Pure functions + a polling reader. Fully unit-testable from committed fixtures; needs PI231 only once, to harvest realistic fixtures.
### Task 0: Harvest real fixtures from PI231
**Files:**
- Create: `lib/tui/fixtures/complete-haiku.jsonl` (real, has `turn_duration`)
- Create: `lib/tui/fixtures/complete-sonnet-multiblock.jsonl` (real, multi content-block answer)
- [ ] **Step 1: Drive one real interactive turn on PI231 and copy its transcript**
On PI231 (the only box with an authenticated interactive `claude`), run a single interactive turn in a scratch cwd, then locate its transcript:
Run (on PI231):
```bash
SID=$(uuidgen)
mkdir -p ~/.ocp-tui/work
# drive one turn by hand in tmux OR reuse a transcript already produced by the S-spikes:
ls -t ~/.claude/projects/-home-*-.ocp-tui-work/*.jsonl 2>/dev/null | head
# pick one complete transcript (must contain a line with "subtype":"turn_duration")
```
Expected: at least one `.jsonl` file whose tail contains `{"type":"system","subtype":"turn_duration",...}`.
- [ ] **Step 2: Copy 2 real transcripts into the repo as fixtures, scrubbed**
Run (from the workstation):
```bash
scp pi231:'~/.claude/projects/<encoded-cwd>/<sid>.jsonl' lib/tui/fixtures/complete-haiku.jsonl
# Scrub: the transcript may contain the prompt/answer text only (no OAuth token — tokens
# live in ~/.claude/.credentials.json, NOT in projects/*.jsonl). Confirm no credential
# material before committing:
grep -iE "sk-ant|oat01|bearer|authorization" lib/tui/fixtures/*.jsonl && echo "STOP: scrub" || echo "clean"
```
Expected: `clean`. (Transcripts hold conversation content + metadata, never the bearer token. If a fixture's prompt text is sensitive, replace it with a benign hand-edited turn that keeps the JSON shape.)
- [ ] **Step 3: Commit the fixtures**
```bash
git add lib/tui/fixtures/complete-haiku.jsonl lib/tui/fixtures/complete-sonnet-multiblock.jsonl
git commit -m "test(tui): real claude transcript fixtures harvested from PI231 (v2.1.158)"
```
### Task 1: `encodeCwd` + `transcriptPath` (the path formula)
**Files:**
- Create: `lib/tui/transcript.mjs`
- Test: `test-features.mjs` (new Suite "TUI transcript")
- [ ] **Step 1: Write the failing test**
Add to `test-features.mjs`:
```js
// ── Suite: TUI transcript reader ────────────────────────────────────────
import { encodeCwd, transcriptPath } from "./lib/tui/transcript.mjs";
test("encodeCwd replaces every slash incl. leading", () => {
assertEqual(encodeCwd("/home/u/.ocp-tui/work"), "-home-u-.ocp-tui-work");
});
test("transcriptPath composes EHOME/.claude/projects/<enc>/<sid>.jsonl", () => {
assertEqual(
transcriptPath("/home/u", "/home/u/.ocp-tui/work", "abc-123"),
"/home/u/.claude/projects/-home-u-.ocp-tui-work/abc-123.jsonl"
);
});
```
- [ ] **Step 2: Run to verify it fails**
Run: `node test-features.mjs 2>&1 | grep -i "tui transcript\|Cannot find module"`
Expected: FAIL — `Cannot find module './lib/tui/transcript.mjs'`.
- [ ] **Step 3: Minimal implementation**
Create `lib/tui/transcript.mjs`:
```js
// Transcript reader for TUI-mode. Reads claude's native JSONL session transcript
// and returns the latest assistant turn's text once the turn is terminal.
//
// Authority: claude CLI v2.1.158 — interactive session transcript at
// <HOME>/.claude/projects/<CWD with every "/" -> "-">/<--session-id>.jsonl
// Completion marker: a line {"type":"system","subtype":"turn_duration",...}.
// See docs/superpowers/specs/2026-05-30-tui-mode-production-design.md §4.
import { readFileSync, existsSync } from "node:fs";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Project-dir encoding: every "/" -> "-" (including the leading slash).
export function encodeCwd(cwd) {
return cwd.replace(/\//g, "-");
}
export function transcriptPath(home, cwd, sessionId) {
return `${home}/.claude/projects/${encodeCwd(cwd)}/${sessionId}.jsonl`;
}
```
- [ ] **Step 4: Run to verify it passes**
Run: `node test-features.mjs 2>&1 | grep -i "tui transcript"`
Expected: PASS for both cases.
- [ ] **Step 5: Commit**
```bash
git add lib/tui/transcript.mjs test-features.mjs
git commit -m "feat(tui): transcript path formula (encodeCwd + transcriptPath)"
```
### Task 2: `parseTranscriptLines` + `isTerminalLine` + `extractLatestAssistantText`
**Files:**
- Modify: `lib/tui/transcript.mjs`
- Test: `test-features.mjs`
- [ ] **Step 1: Write the failing tests**
```js
import { parseTranscriptLines, isTerminalLine, extractLatestAssistantText } from "./lib/tui/transcript.mjs";
import { readFileSync } from "node:fs";
test("parseTranscriptLines skips blank + malformed/partial lines", () => {
const evs = parseTranscriptLines('{"a":1}\n\n{bad json\n{"b":2}\n');
assertEqual(evs.length, 2);
assertEqual(evs[1].b, 2);
});
test("isTerminalLine true on turn_duration", () => {
assertEqual(isTerminalLine({ type: "system", subtype: "turn_duration" }), true);
});
test("isTerminalLine true on stop_reason tool_use (message-wrapped + flat)", () => {
assertEqual(isTerminalLine({ type: "assistant", message: { stop_reason: "tool_use" } }), true);
assertEqual(isTerminalLine({ stop_reason: "tool_use" }), true);
});
test("isTerminalLine false on ordinary assistant/text lines", () => {
assertEqual(isTerminalLine({ type: "assistant", message: { content: [{ type: "text", text: "hi" }] } }), false);
});
test("extractLatestAssistantText concatenates text blocks of the LAST assistant turn", () => {
const evs = [
{ type: "assistant", message: { content: [{ type: "text", text: "first" }] } },
{ type: "user", message: { content: "..." } },
{ type: "assistant", message: { content: [{ type: "text", text: "A" }, { type: "thinking", thinking: "x" }, { type: "text", text: "B" }] } },
];
assertEqual(extractLatestAssistantText(evs), "AB");
});
test("real complete fixture yields non-empty text and is terminal", () => {
const evs = parseTranscriptLines(readFileSync("./lib/tui/fixtures/complete-haiku.jsonl", "utf8"));
assert(evs.some(isTerminalLine), "fixture must contain a terminal line");
assert(extractLatestAssistantText(evs).length > 0, "fixture must yield assistant text");
});
```
- [ ] **Step 2: Run to verify it fails**
Run: `node test-features.mjs 2>&1 | grep -i "parseTranscript\|isTerminal\|extractLatest\|real complete fixture"`
Expected: FAIL — exports not defined.
- [ ] **Step 3: Minimal implementation** (append to `lib/tui/transcript.mjs`)
```js
// Parse NDJSON text into objects; skip blank lines and partial/forming lines
// (the live transcript is read mid-write, so the last line may be incomplete).
export function parseTranscriptLines(text) {
const out = [];
for (const line of text.split("\n")) {
const t = line.trim();
if (!t) continue;
try { out.push(JSON.parse(t)); } catch { /* partial line being written */ }
}
return out;
}
// A line marks the assistant turn complete when it is the turn_duration system
// event, or an assistant message that stopped to hand off to a tool.
export function isTerminalLine(obj) {
if (!obj || typeof obj !== "object") return false;
if (obj.type === "system" && obj.subtype === "turn_duration") return true;
const sr = (obj.message && obj.message.stop_reason) || obj.stop_reason;
return sr === "tool_use";
}
// Text of the LAST assistant turn: concatenate its text content blocks
// (ignore thinking/tool_use blocks). Later assistant entries overwrite earlier.
export function extractLatestAssistantText(events) {
let text = "";
for (const ev of events) {
if (!ev || ev.type !== "assistant") continue;
const content = ev.message && ev.message.content;
if (!Array.isArray(content)) continue;
const parts = content
.filter((b) => b && b.type === "text" && typeof b.text === "string")
.map((b) => b.text);
if (parts.length) text = parts.join("");
}
return text;
}
```
- [ ] **Step 4: Run to verify it passes**
Run: `node test-features.mjs 2>&1 | grep -iE "parseTranscript|isTerminal|extractLatest|real complete fixture"`
Expected: all PASS.
- [ ] **Step 5: Commit**
```bash
git add lib/tui/transcript.mjs test-features.mjs
git commit -m "feat(tui): transcript parsing + terminal detection + assistant-text extraction"
```
### Task 3: `readTuiTranscript` (the polling reader with wall-clock cap)
**Files:**
- Modify: `lib/tui/transcript.mjs`
- Test: `test-features.mjs`
- [ ] **Step 1: Write the failing tests**
```js
import { readTuiTranscript } from "./lib/tui/transcript.mjs";
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
test("readTuiTranscript returns assistant text when terminal marker present", async () => {
const dir = mkdtempSync(`${tmpdir()}/tui-`);
const p = `${dir}/s.jsonl`;
writeFileSync(p, [
JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "hello world" }] } }),
JSON.stringify({ type: "system", subtype: "turn_duration", durationMs: 1200 }),
].join("\n") + "\n");
const out = await readTuiTranscript({ transcriptPath: p, wallclockMs: 2000, pollMs: 50 });
assertEqual(out, "hello world");
});
test("readTuiTranscript honours wall-clock cap and returns partial text", async () => {
const dir = mkdtempSync(`${tmpdir()}/tui-`);
const p = `${dir}/s.jsonl`;
writeFileSync(p, JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "partial" }] } }) + "\n");
const out = await readTuiTranscript({ transcriptPath: p, wallclockMs: 300, pollMs: 50 }); // never terminal
assertEqual(out, "partial");
});
test("readTuiTranscript throws when no text and cap elapses", async () => {
const dir = mkdtempSync(`${tmpdir()}/tui-`);
const p = `${dir}/missing.jsonl`; // file never appears
let threw = false;
try { await readTuiTranscript({ transcriptPath: p, wallclockMs: 200, pollMs: 50 }); }
catch { threw = true; }
assert(threw, "must throw on empty timeout");
});
```
- [ ] **Step 2: Run to verify it fails**
Run: `node test-features.mjs 2>&1 | grep -i "readTuiTranscript"`
Expected: FAIL — export not defined.
- [ ] **Step 3: Minimal implementation** (append)
```js
// Block until the session transcript is terminal (turn_duration / tool_use) or
// the wall-clock cap elapses, polling the file (no fs.watch — robust over NFS /
// editors). Returns the latest assistant text. On cap with text, returns the
// partial text; on cap with no text at all, throws.
//
// No quiescence heuristic by design: a long Opus thinking turn stalls transcript
// growth and a "file stable for N s" rule would false-abort it (spec §4.3).
export async function readTuiTranscript({ transcriptPath: p, wallclockMs = 120000, pollMs = 250 }) {
const deadline = Date.now() + wallclockMs;
let lastText = "";
while (Date.now() < deadline) {
if (existsSync(p)) {
const events = parseTranscriptLines(readFileSync(p, "utf8"));
lastText = extractLatestAssistantText(events) || lastText;
if (events.some(isTerminalLine)) return lastText;
}
await sleep(pollMs);
}
if (lastText) return lastText;
throw new Error("tui_transcript_timeout: no assistant text within wallclock cap");
}
```
- [ ] **Step 4: Run to verify it passes**
Run: `node test-features.mjs 2>&1 | grep -i "readTuiTranscript"`
Expected: all 3 PASS.
- [ ] **Step 5: Commit**
```bash
git add lib/tui/transcript.mjs test-features.mjs
git commit -m "feat(tui): polling transcript reader with wall-clock cap (no quiescence)"
```
---
## PR-2 — Session driver (`lib/tui/session.mjs`)
tmux lifecycle + the validated submission recipe. Cannot be unit-tested without a live authenticated `claude`; tested by a live-only guarded suite that runs on PI231.
### Task 4: `reapStaleTuiSessions` (prefix-scoped reaper)
**Files:**
- Create: `lib/tui/session.mjs`
- Test: `test-features.mjs`
- [ ] **Step 1: Write the failing test** (pure — no live claude; inject a fake tmux runner)
```js
import { reapStaleTuiSessions, SESSION_PREFIX } from "./lib/tui/session.mjs";
test("reaper kills ONLY ocp-tui- sessions, never olp-tui-", () => {
const killed = [];
const fakeTmux = (args) => {
if (args[0] === "list-sessions") return { status: 0, stdout: "ocp-tui-aaaa\nolp-tui-bbbb\nmisc\nocp-tui-cccc\n" };
if (args[0] === "kill-session") { killed.push(args[args.indexOf("-t") + 1]); return { status: 0 }; }
return { status: 0, stdout: "" };
};
const n = reapStaleTuiSessions({ tmux: fakeTmux });
assertEqual(SESSION_PREFIX, "ocp-tui-");
assertEqual(n, 2);
assertEqual(killed.join(","), "ocp-tui-aaaa,ocp-tui-cccc");
});
```
- [ ] **Step 2: Run to verify it fails**
Run: `node test-features.mjs 2>&1 | grep -i "reaper kills"`
Expected: FAIL — module/export missing.
- [ ] **Step 3: Minimal implementation**
Create `lib/tui/session.mjs`:
```js
// TUI-mode session driver: hosts an interactive `claude` in tmux, submits one
// serialized prompt, awaits the transcript reader, tears down. OCP-specific.
//
// Authority: claude CLI v2.1.158 interactive mode (no -p / no --output-format
// => cc_entrypoint=cli). Submission recipe + dialog handling validated by spikes
// T3/T6 on PI231. See docs/superpowers/specs/2026-05-30-tui-mode-production-design.md.
import { spawnSync } from "node:child_process";
import { mkdtempSync, writeFileSync, mkdirSync, existsSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
import { transcriptPath, readTuiTranscript } from "./transcript.mjs";
export const SESSION_PREFIX = "ocp-tui-"; // per-proxy namespace (coexistence rule)
const TMUX = process.env.OCP_TUI_TMUX_BIN || "tmux";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const defaultTmux = (args, opts = {}) => spawnSync(TMUX, args, { encoding: "utf8", ...opts });
// Kill ONLY our own stale sessions. Scoped to SESSION_PREFIX so a co-hosted
// OLP test instance's `olp-tui-*` sessions are never touched.
export function reapStaleTuiSessions({ tmux = defaultTmux } = {}) {
const r = tmux(["list-sessions", "-F", "#{session_name}"]);
if (!r || r.status !== 0) return 0; // no tmux server / no sessions
let killed = 0;
for (const name of String(r.stdout || "").split("\n").map((s) => s.trim()).filter(Boolean)) {
if (name.startsWith(SESSION_PREFIX)) { tmux(["kill-session", "-t", name]); killed++; }
}
return killed;
}
```
- [ ] **Step 4: Run to verify it passes**
Run: `node test-features.mjs 2>&1 | grep -i "reaper kills"`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add lib/tui/session.mjs test-features.mjs
git commit -m "feat(tui): prefix-scoped session reaper (ocp-tui-* only)"
```
### Task 5: `runTuiTurn` (boot → trust dialog → paste → Enter → read → teardown)
**Files:**
- Modify: `lib/tui/session.mjs`
- Test: `test-features.mjs` (live-only, guarded by `OCP_TUI_LIVE=1`)
- [ ] **Step 1: Write the live-only guarded test** (skipped in CI; run on PI231)
```js
// Live-only: requires an authenticated interactive `claude`. Skipped unless OCP_TUI_LIVE=1.
if (process.env.OCP_TUI_LIVE === "1") {
test("runTuiTurn drives a real interactive turn and returns text", async () => {
const { runTuiTurn } = await import("./lib/tui/session.mjs");
const out = await runTuiTurn({
prompt: "Reply with exactly the word PONG and nothing else.",
model: "claude-haiku-4-5-20251001",
claudeBin: process.env.OCP_TUI_CLAUDE_BIN || "claude",
home: process.env.HOME,
cwd: `${process.env.HOME}/.ocp-tui/work`,
wallclockMs: 120000,
});
assert(/PONG/i.test(out), `expected PONG, got: ${out.slice(0, 200)}`);
});
} else {
test("runTuiTurn (live) — SKIPPED (set OCP_TUI_LIVE=1 on PI231 to run)", () => { assert(true); });
}
```
- [ ] **Step 2: Run to verify it fails** (on a box, with the flag)
Run (PI231): `OCP_TUI_LIVE=1 node test-features.mjs 2>&1 | grep -i "runTuiTurn"`
Expected: FAIL — `runTuiTurn` not exported yet.
- [ ] **Step 3: Implementation** (append to `lib/tui/session.mjs`)
```js
// Boot wait + dialog timing. Conservative defaults validated on PI231; env-tunable.
const BOOT_MS = parseInt(process.env.OCP_TUI_BOOT_MS || "3500", 10);
const DIALOG_MS = parseInt(process.env.OCP_TUI_DIALOG_MS || "1200", 10);
const PASTE_SETTLE_MS = parseInt(process.env.OCP_TUI_PASTE_MS || "1800", 10);
const shq = (s) => `'${String(s).replace(/'/g, "'\\''")}'`; // single-quote for sh -c
// Build interactive claude argv: NO -p, NO --output-format (=> cc_entrypoint=cli).
// MCP hard-disabled: --strict-mcp-config (no --mcp-config) is the only mechanism
// that stops account-attached managed MCP from connecting (spec §5.2 / T6),
// belt-and-braces with --disallowedTools "mcp__*".
function buildTuiCmd(claudeBin, model, sessionId) {
return [
shq(claudeBin),
"--model", shq(model),
"--session-id", sessionId,
"--strict-mcp-config",
"--disallowedTools", shq("mcp__*"),
].join(" ");
}
export async function runTuiTurn({
prompt, model, claudeBin, home, cwd,
wallclockMs = 120000, tmux = defaultTmux,
}) {
const sessionId = randomUUID();
const tmuxName = SESSION_PREFIX + sessionId.slice(0, 8);
if (!existsSync(cwd)) mkdirSync(cwd, { recursive: true });
const tmpDir = mkdtempSync(`${tmpdir()}/ocp-tui-`);
const promptFile = `${tmpDir}/prompt.txt`;
writeFileSync(promptFile, prompt, { mode: 0o600 });
const env = { ...process.env, CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL: "1" };
delete env.CLAUDECODE; delete env.ANTHROPIC_API_KEY; delete env.ANTHROPIC_BASE_URL; delete env.ANTHROPIC_AUTH_TOKEN;
if (home) env.HOME = home;
try {
// 1. Boot the interactive session inside tmux, in the dedicated scratch cwd.
tmux(["new-session", "-d", "-s", tmuxName, "-x", "220", "-y", "50", "-c", cwd,
buildTuiCmd(claudeBin, model, sessionId)], { env });
await sleep(BOOT_MS);
// 2. Answer the trust-folder dialog defensively. The seeded bypass flag (if any)
// suppresses the *bypass-permissions* dialog but NOT the trust-folder dialog;
// "1" = "Yes, proceed". Harmless if the dialog is absent (cwd already trusted).
tmux(["send-keys", "-t", tmuxName, "1"]);
tmux(["send-keys", "-t", tmuxName, "Enter"]);
await sleep(DIALOG_MS);
// 3. Submit the prompt. Body is pasted via `"$(cat file)"` so the content never
// touches the command line (no shell injection from prompt text), then a
// SEPARATE Enter key event submits it (Ink #15553: literal "\n" in a paste
// does not submit; the Enter key event does).
spawnSync("sh", ["-c",
`${shq(TMUX)} send-keys -t ${shq(tmuxName)} -- "$(cat ${shq(promptFile)})"`],
{ env, encoding: "utf8" });
await sleep(PASTE_SETTLE_MS);
tmux(["send-keys", "-t", tmuxName, "Enter"]);
// 4. Read the answer from the native transcript.
const tpath = transcriptPath(home || process.env.HOME, cwd, sessionId);
return await readTuiTranscript({ transcriptPath: tpath, wallclockMs });
} finally {
// 5. Teardown — always. Kill the session, remove the temp prompt dir.
try { tmux(["kill-session", "-t", tmuxName]); } catch { /* already gone */ }
try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best effort */ }
}
}
```
- [ ] **Step 4: Run to verify it passes** (PI231, live)
Run (PI231): `OCP_TUI_LIVE=1 node test-features.mjs 2>&1 | grep -i "runTuiTurn"`
Expected: PASS — output contains `PONG`. Also confirm no orphan sessions: `tmux ls 2>/dev/null | grep ocp-tui- || echo "clean"``clean`.
- [ ] **Step 5: Commit**
```bash
git add lib/tui/session.mjs test-features.mjs
git commit -m "feat(tui): runTuiTurn — interactive session driver (boot/trust/paste/Enter/read/teardown)"
```
---
## PR-3 — Wiring into `server.mjs`
Gate TUI-mode behind one env flag. Default path (`CLAUDE_TUI_MODE` unset) stays byte-for-byte identical.
### Task 6: env consts + `streamStringAsSSE` DRY refactor
**Files:**
- Modify: `server.mjs` (env consts near `:275`; refactor cache-replay block `:1524``:1539` into a helper near `:1023`)
- [ ] **Step 1: Add TUI env consts + import** (near the other `const ... = process.env...` at `server.mjs:258``:275`)
```js
import { runTuiTurn, reapStaleTuiSessions } from "./lib/tui/session.mjs";
// TUI-mode (subscription-pool bridge). Opt-in; default OFF keeps stream-json path.
// Authority: docs/adr/0007-tui-interactive-mode.md.
const TUI_MODE = process.env.CLAUDE_TUI_MODE === "true";
const TUI_WALLCLOCK_MS = parseInt(process.env.CLAUDE_TUI_WALLCLOCK_MS || "120000", 10);
const TUI_CWD = process.env.OCP_TUI_CWD || `${process.env.HOME}/.ocp-tui/work`;
```
- [ ] **Step 2: Extract the chunked-SSE-replay into a reusable helper** (near `completionResponse` at `:1023`)
```js
// Replay a complete string as a chunked SSE stream (80 codepoints/chunk).
// Extracted from the cache-hit replay block so TUI-mode streaming reuses it.
function streamStringAsSSE(res, id, model, content) {
const created = Math.floor(Date.now() / 1000);
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no" });
sendSSE(res, { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }] });
const CHUNK = 80;
const codepoints = Array.from(content);
for (let i = 0; i < codepoints.length; i += CHUNK) {
sendSSE(res, { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { content: codepoints.slice(i, i + CHUNK).join("") }, finish_reason: null }] });
}
sendSSE(res, { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: {}, finish_reason: "stop" }] });
res.write("data: [DONE]\n\n");
res.end();
}
```
- [ ] **Step 3: Point the cache-hit streaming replay (`:1524``:1539`) at the helper** (DRY — behavior identical)
Replace the inline block inside `if (stream) { ... }` of the cache hit with:
```js
if (stream) {
const id = `chatcmpl-${randomUUID()}`;
streamStringAsSSE(res, id, model, cached.response);
return;
} else {
```
- [ ] **Step 4: Run the full suite to verify no regression**
Run: `node test-features.mjs 2>&1 | tail -3`
Expected: all existing tests PASS (the refactor is behavior-preserving; cache-replay covered by existing D3 tests).
- [ ] **Step 5: Commit**
```bash
git add server.mjs
git commit -m "refactor(server): extract streamStringAsSSE helper + add TUI env consts"
```
### Task 7: `callClaudeTui` + dispatch gates
**Files:**
- Modify: `server.mjs` (new `callClaudeTui` near `callClaude:735`; gates at the buffered dispatch `:1563`/`:1594` and streaming dispatch `:1551`)
- [ ] **Step 1: Add `callClaudeTui`** (near `callClaude`, after `:800`)
```js
// TUI-mode upstream: drive an interactive claude session, return the assistant
// text as a string — same contract as callClaude(), so all downstream
// (singleflight, cache write-back, completionResponse) is unchanged.
// System messages are rendered inline as [System] blocks by messagesToPrompt;
// we deliberately do NOT pass --system-prompt in interactive mode to avoid any
// flag that could perturb cc_entrypoint classification.
function callClaudeTui(model, messages, conversationId, keyName) {
const cliModel = MODEL_MAP[model] || model;
const prompt = messagesToPrompt(messages); // includes system as [System] inline
recordModelRequest(cliModel, prompt.length);
return runTuiTurn({
prompt, model: cliModel, claudeBin: CLAUDE,
home: process.env.HOME, cwd: TUI_CWD, wallclockMs: TUI_WALLCLOCK_MS,
}).then((text) => {
recordModelSuccess(cliModel, 0);
return text;
}).catch((err) => {
recordModelError(cliModel, false);
throw err;
});
}
```
- [ ] **Step 2: Gate the buffered dispatch** — at `server.mjs:1563``:1597`, replace the two `callClaude(...)` call sites (inside the singleflight closure and the cache-disabled fallback) with a selected upstream:
Add once, just before the `if (CACHE_TTL > 0 && req._cacheHash)` block (~`:1563`):
```js
const upstreamCall = TUI_MODE ? callClaudeTui : callClaude;
```
Then change `await callClaude(model, messages, conversationId, req._authKeyName)``await upstreamCall(model, messages, conversationId, req._authKeyName)` at **both** sites (`:1572` and `:1594`).
- [ ] **Step 3: Gate the streaming dispatch** — at `server.mjs:1551``:1553`, branch TUI streaming to buffer-then-replay:
```js
if (stream) {
if (TUI_MODE) {
// TUI has no token stream; buffer the turn, write-back to cache, replay as chunked SSE.
const t0Usage = Date.now();
try {
const content = await callClaudeTui(model, messages, conversationId, req._authKeyName);
if (CACHE_TTL > 0 && req._cacheHash) {
try { setCachedResponse(req._cacheHash, model, content); } catch (e) { logEvent("error", "cache_write_failed", { error: e.message }); }
}
const id = `chatcmpl-${randomUUID()}`;
streamStringAsSSE(res, id, model, content);
try { recordUsage({ keyId: req._authKeyId, keyName: req._authKeyName, model, promptChars: messages.reduce((a, m) => a + (typeof m.content === "string" ? m.content.length : JSON.stringify(m.content).length), 0), responseChars: content.length, elapsedMs: Date.now() - t0Usage, success: true }); } catch {}
return;
} catch (err) {
if (res.headersSent || res.writableEnded || res.destroyed) { try { res.end(); } catch {}; return; }
const safeMessage = (err.message || "Internal error").replace(/\/[\w/.\-]+/g, "[path]");
return jsonResponse(res, 500, { error: { message: safeMessage, type: "proxy_error" } });
}
}
// Default: real stream-json streaming, unchanged.
return callClaudeStreaming(model, messages, conversationId, res, { keyId: req._authKeyId, keyName: req._authKeyName, cacheHash: req._cacheHash });
}
```
- [ ] **Step 4: Verify default path is untouched + TUI path selected only by flag**
Run: `CLAUDE_TUI_MODE= node -e "process.env.CLAUDE_TUI_MODE; import('./server.mjs')" 2>&1 | head -1 || true`
Then the regression suite: `node test-features.mjs 2>&1 | tail -3`
Expected: all PASS (no test sets `CLAUDE_TUI_MODE`, so `upstreamCall === callClaude` and streaming uses `callClaudeStreaming` — identical to today).
Live end-to-end (PI231, after Task 8 setup): with `CLAUDE_TUI_MODE=true` start OCP and `curl` both `stream:false` and `stream:true`:
```bash
curl -s localhost:3456/v1/chat/completions -H "Authorization: Bearer <key>" \
-d '{"model":"claude-haiku-4-5-20251001","messages":[{"role":"user","content":"say PONG"}]}' | head
```
Expected: a normal OpenAI completion whose content contains `PONG`. Cross-check on PI231 that the spawned `claude` had no `-p`/`--output-format` (`ps -ef | grep claude`).
- [ ] **Step 5: Commit**
```bash
git add server.mjs
git commit -m "feat(tui): gate interactive TUI upstream behind CLAUDE_TUI_MODE (buffered + streaming)"
```
### Task 8: reaper hook at boot + ADR + README + CHANGELOG
**Files:**
- Modify: `server.mjs` (boot block — call `reapStaleTuiSessions()` once on startup when `TUI_MODE`)
- Create: `docs/adr/0007-tui-interactive-mode.md`
- Modify: `README.md`, `CHANGELOG.md`
- [ ] **Step 1: Reaper on boot** (in the server start/`listen` block)
```js
if (TUI_MODE) {
try { const n = reapStaleTuiSessions(); if (n) logEvent("info", "tui_reaped_stale_sessions", { count: n }); } catch {}
console.log(` TUI-mode: ON (interactive claude → cc_entrypoint=cli). cwd=${TUI_CWD} wallclock=${TUI_WALLCLOCK_MS}ms`);
}
```
- [ ] **Step 2: Write ADR 0007**`docs/adr/0007-tui-interactive-mode.md`
Context: 2026-06-15 billing split routes by `cc_entrypoint`; `-p`/`--output-format``sdk-cli` (Agent SDK credit pool, ~$20 on Pro = unusable). Decision: opt-in interactive driver ⇒ `cli` (subscription pool). Authority: spec §1/§4, claude v2.1.158. Scope: A-path single-user; MCP hard-disabled via `--strict-mcp-config`. Kill-switch: unset `CLAUDE_TUI_MODE` → stream-json path restored. Consequences: no token streaming (buffered+replayed); grey-area, billing unmeasurable until 6/15; reaper + tmux-prefix coexistence rules.
- [ ] **Step 3: README** — add `CLAUDE_TUI_MODE`, `CLAUDE_TUI_WALLCLOCK_MS`, `OCP_TUI_CWD` to the env-var table; add a "Subscription-pool (TUI) mode" section (what it is, opt-in, the 6/15 rationale, no-streaming caveat, the one-time `mkdir -p ~/.ocp-tui/work` + tmux dependency, and the `CLAUDE_TUI_MODE` unset kill-switch).
- [ ] **Step 4: CHANGELOG** — Unreleased: `feat(tui): opt-in CLAUDE_TUI_MODE — serve via interactive claude (cc_entrypoint=cli / subscription pool); default stream-json path unchanged.`
- [ ] **Step 5: Commit**
```bash
git add server.mjs docs/adr/0007-tui-interactive-mode.md README.md CHANGELOG.md
git commit -m "feat(tui): boot reaper + ADR 0007 + README + CHANGELOG (TUI-mode docs)"
```
---
## Integration & canary (post-implementation, on PI231)
1. Stop the OLP test instance (`:4567`) — clean shared OAuth + no tmux collision.
2. `git clone`/checkout this branch on PI231, `mkdir -p ~/.ocp-tui/work`, start OCP on `:3456` with `CLAUDE_TUI_MODE=true`.
3. Run the live driver suite: `OCP_TUI_LIVE=1 node test-features.mjs`.
4. End-to-end `curl` (buffered + streaming) through OCP; confirm spawned `claude` carries no `-p`/`--output-format`.
5. **Pre-6/15 deliverable = here.** Billing measurement waits for 6/15; document the kill-switch (unset `CLAUDE_TUI_MODE`).
---
## Self-Review (against spec + the OCP-first execution review)
- **Spec coverage:** transcript path formula (§4 → Task 1), parsing/terminal/extract (§4 → Task 2), polling reader + wall-clock cap + no-quiescence (§4.3 → Task 3), submission recipe file→paste→Enter (§5/T3 → Task 5), trust-dialog handling (§5.2 → Task 5), MCP disable `--strict-mcp-config` (§5.2/T6 → Tasks 5 & buildTuiCmd), string-contract drop-in (→ Tasks 67), kill-switch + default-path-sacred (→ Task 7 Step 4), coexistence prefix + reaper (→ Tasks 4 & 8). ✅
- **Review findings folded:** OCP-first string match (Task 7); no ephemeral-home, real-home + scratch cwd (scope §); reader-only sharing, driver forked (file table); tmux prefix + scoped reaper + never-both-on-OAuth (Task 4, Integration §1); `TIMEOUT=600000 > 120s` cap verified (no SIGKILL-mid-turn); `--strict-mcp-config` added (Task 5); provenance jaekwon-park (Why §). OLP-sync deferred. ✅
- **Placeholder scan:** none — every code step carries real code; every run step an exact command + expected output. ✅
- **Type consistency:** `runTuiTurn`/`reapStaleTuiSessions`/`SESSION_PREFIX` exported in Task 45 match imports in Task 68; `streamStringAsSSE(res, id, model, content)` defined Task 6, used Tasks 67; `callClaudeTui(model, messages, conversationId, keyName)` mirrors `callClaude`'s signature. ✅
- **Open item for integration:** confirm on PI231 that the seeded `~/.claude.json` is unnecessary for real-home A (onboarding already complete); if a bypass-permissions dialog *does* appear in real home, add a one-line seed step (`bypassPermissionsModeAccepted:true`) — but the driver already answers the trust dialog defensively, so the turn still completes.
+33
View File
@@ -0,0 +1,33 @@
/**
* OCP shared constants single source of truth.
*
* Any literal that appears in more than one place across server.mjs, setup.mjs,
* scripts/* belongs here so port-drift / URL-drift cascades cannot recur.
*
* Background: from 2026-05-08 (PR #71 dogfood accident) through 2026-05-13
* (v3.16.3) a single hardcoded "3478" in scripts/upgrade.mjs + scripts/doctor.mjs
* cascaded into every downstream config write, ultimately taking out the
* OpenClaw "大内总管" Telegram agent. See CHANGELOG v3.16.2 and v3.16.3.
*
* Adding a new constant: prefer ALL_CAPS_SNAKE_CASE. Document the consumers.
* If a literal is referenced from a shell script (ocp, ocp-connect, setup.sh)
* that can't import .mjs, add a `// keep in sync with lib/constants.mjs` note
* at the shell-script reference; CI grep prevents drift.
*/
// Default TCP port the OCP HTTP proxy listens on. Set by env CLAUDE_PROXY_PORT
// at runtime; this is the fallback when env is unset.
// Consumers: server.mjs, setup.mjs, scripts/upgrade.mjs, scripts/doctor.mjs,
// scripts/sync-openclaw.mjs. Shell scripts ocp / ocp-connect keep the literal
// "3456" in sync with this value (see CI gate in .github/workflows/alignment.yml).
export const DEFAULT_PORT = 3456;
// Localhost bind for client-side fetches (curl, health checks).
export const LOCAL_HOST = "127.0.0.1";
// OpenAI-compatible API base path appended to the proxy URL.
export const OPENAI_API_BASE = "/v1";
// Convenience: full local URL the OCP proxy listens on by default.
// scripts that want to probe locally can use this directly.
export const LOCAL_PROXY_URL = `http://${LOCAL_HOST}:${DEFAULT_PORT}`;
+9
View File
@@ -0,0 +1,9 @@
// OCP network helpers — shared so server.mjs and tests use one definition. (issue #125)
// A bind address is "loopback" only if it cannot be reached from another host.
// Any other address (0.0.0.0, ::, a concrete LAN/Tailscale IP, etc.) is
// network-exposed and must trigger the TUI LAN gate.
export function isLoopbackBind(addr) {
return addr === "127.0.0.1" || addr === "::1" || addr === "localhost" ||
addr === "::ffff:127.0.0.1" || /^127\./.test(addr);
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
{"type":"user","entrypoint":"cli","cwd":"/tmp/tui-test","sessionId":"bbbb2222-3333-4444-5555-666677778888","version":"2.1.104","message":{"role":"user","content":[{"type":"text","text":"What is 2 + 2?"}]}}
{"type":"assistant","entrypoint":"cli","cwd":"/tmp/tui-test","sessionId":"bbbb2222-3333-4444-5555-666677778888","version":"2.1.104","message":{"role":"assistant","model":"claude-haiku-4-5-20251001","stop_reason":"end_turn","content":[{"type":"text","text":"Failed to authenticate. API Error: 401 Invalid authentication credentials"}]}}
+2
View File
@@ -0,0 +1,2 @@
{"type":"user","entrypoint":"cli","cwd":"/tmp/tui-test","sessionId":"aaaa1111-2222-3333-4444-555566667777","version":"2.1.104","message":{"role":"user","content":[{"type":"text","text":"What is 2 + 2?"}]}}
{"type":"assistant","entrypoint":"cli","cwd":"/tmp/tui-test","sessionId":"aaaa1111-2222-3333-4444-555566667777","version":"2.1.104","message":{"role":"assistant","model":"claude-haiku-4-5-20251001","stop_reason":"end_turn","content":[{"type":"text","text":"Please run /login · API Error: 401 Invalid authentication credentials"}]}}
+2
View File
@@ -0,0 +1,2 @@
{"type":"user","entrypoint":"cli","cwd":"/tmp/tui-test","sessionId":"bbbb1111-2222-3333-4444-555566667777","version":"2.1.104","message":{"role":"user","content":[{"type":"text","text":"Say PONG and nothing else."}]}}
{"type":"assistant","entrypoint":"cli","cwd":"/tmp/tui-test","sessionId":"bbbb1111-2222-3333-4444-555566667777","version":"2.1.104","message":{"role":"assistant","model":"claude-haiku-4-5-20251001","stop_reason":"end_turn","content":[{"type":"text","text":"PONG"}]}}
+102
View File
@@ -0,0 +1,102 @@
// TUI-path concurrency limiter (audit finding C-4).
//
// WHY THIS EXISTS, SEPARATE FROM server.mjs's MAX_CONCURRENT:
// The global MAX_CONCURRENT gate lives in spawnClaudeProcess() (the -p / stream-json
// path). callClaudeTui() NEVER calls spawnClaudeProcess — it calls runTuiTurn(), which
// boots a full interactive `claude` inside a fresh tmux session. So nothing bounded the
// TUI path: N concurrent TUI requests spawned N simultaneous cold-boot tmux+claude
// processes. On a small host (a Pi 4 serving a family) a burst of ~5 is an OOM risk, and
// it also multiplies subscription rate-limit pressure. This is an INDEPENDENT limiter for
// the TUI path that mirrors MAX_CONCURRENT's intent without coupling to it (the two pools
// are different shapes: a stream-json spawn is cheap and fast; a TUI turn is a heavy
// cold-boot + up to 120s wallclock).
//
// QUEUE vs REJECT: we QUEUE (await a slot), mirroring the spirit of MAX_CONCURRENT's
// intent not to drop requests, rather than rejecting immediately. To avoid unbounded
// memory growth from a runaway client, the wait queue itself is bounded by maxQueue
// (default: a generous multiple of the concurrency limit). When the queue is full, run()
// rejects with a tui_queue_full error (the caller surfaces it as a 503) — a deterministic
// backpressure signal rather than silent OOM.
//
// Pure + importable so test-features.mjs can assert the bound directly (no server boot).
export class TuiSemaphore {
// limit: max concurrent slots. maxQueue: max waiters before run() rejects with backpressure.
constructor(limit, { maxQueue } = {}) {
this.limit = Math.max(1, parseInt(limit, 10) || 1);
// Default queue cap: 32× the limit. Large enough that real family-burst traffic never
// hits it, small enough that a pathological flood can't grow the queue without bound.
this.maxQueue = Number.isFinite(maxQueue) ? maxQueue : this.limit * 32;
this._inflight = 0;
this._waiters = []; // FIFO queue of resolve callbacks waiting for a slot
}
get inflight() { return this._inflight; }
get queued() { return this._waiters.length; }
// Acquire a slot. Resolves once a slot is free (immediately if under the limit, otherwise
// when an in-flight task releases). Rejects synchronously-ish if the wait queue is full.
acquire() {
if (this._inflight < this.limit) {
this._inflight++;
return Promise.resolve();
}
if (this._waiters.length >= this.maxQueue) {
return Promise.reject(new Error(
`tui_queue_full: TUI concurrency limit (${this.limit}) reached and wait queue ` +
`(${this.maxQueue}) is full`));
}
return new Promise((resolve) => { this._waiters.push(resolve); });
}
// Release a slot. If a waiter is queued, hand the slot directly to it (inflight stays
// constant across the handoff); otherwise decrement.
release() {
const next = this._waiters.shift();
if (next) {
next(); // the woken waiter already "owns" the slot — inflight unchanged
} else if (this._inflight > 0) {
this._inflight--;
}
}
// Run fn() under one slot. Releases in a finally so a throw (PR-A's honesty gates,
// wallclock truncation, paste-not-landed, tmux spawn failure) NEVER leaks a slot.
async run(fn) {
await this.acquire();
try {
return await fn();
} finally {
this.release();
}
}
}
// ── TUI drift observability (audit C-5) — pure helpers, importable for testing ──
// Record an observed cc_entrypoint into the (mutable) tuiStats counter. Sets lastEntrypoint
// unconditionally and increments entrypointMismatches when the spawn was supposed to be
// subscription-pool ("cli") but the transcript reported something else (a silent drift to
// the metered Agent SDK pool — the audit's top risk after the 6/15 billing flip).
// Returns true iff this observation was a mismatch (so the caller can also emit a log).
export function recordTuiEntrypoint(tuiStats, observed, expectedMode = "cli") {
tuiStats.lastEntrypoint = observed ?? null;
const mismatch = expectedMode === "cli" && observed !== "cli";
if (mismatch) tuiStats.entrypointMismatches++;
return mismatch;
}
// Build the additive /health `tui` block (ADR 0007 PR-B amendment). Pure: given the
// config + live counters, returns the exact object embedded in /health. New fields only —
// behaviour-preserving for existing /health consumers (grandfathered B.2 under ADR 0006).
export function buildTuiHealthBlock({ enabled, entrypointMode, maxConcurrent }, tuiStats, semaphore) {
return {
enabled,
entrypointMode, // cli | auto | off
lastEntrypoint: tuiStats.lastEntrypoint, // last observed cc_entrypoint, or null
entrypointMismatches: tuiStats.entrypointMismatches,
inflight: semaphore.inflight, // current concurrent TUI turns
queued: semaphore.queued, // turns waiting for a slot
maxConcurrent,
};
}
+422
View File
@@ -0,0 +1,422 @@
// TUI-mode session driver: hosts an interactive `claude` in tmux, submits one
// serialized prompt, awaits the transcript reader, tears down. OCP-specific.
//
// Authority: claude CLI v2.1.158 interactive mode (no -p / no --output-format
// => cc_entrypoint=cli). Submission recipe validated by spikes T3/T6 on PI231.
// See docs/superpowers/specs/2026-05-30-tui-mode-production-design.md.
//
// Trust handling: rather than answer the trust-folder dialog interactively (which
// only appears on a cwd's FIRST encounter — sending a defensive "1" to an already
// trusted cwd would inject a stray prompt turn), we PRE-TRUST the scratch cwd by
// seeding <home>/.claude.json. Every turn then boots dialog-free and identical.
import { spawnSync } from "node:child_process";
import { mkdtempSync, writeFileSync, readFileSync, mkdirSync, existsSync, rmSync, statSync, renameSync, symlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
import { readTuiTranscript } from "./transcript.mjs";
export const SESSION_PREFIX = "ocp-tui-"; // per-proxy namespace (coexistence rule)
const TMUX = process.env.OCP_TUI_TMUX_BIN || "tmux";
const defaultTmux = (args, opts = {}) =>
spawnSync(TMUX, args, { encoding: "utf8", ...opts });
// Kill ONLY our own stale sessions. Scoped to SESSION_PREFIX so a co-hosted
// OLP test instance's `olp-tui-*` sessions are never touched.
//
// Defunct-reaping (PI231 incident): the pane's `claude` process is a child of the
// long-lived tmux SERVER daemon, NOT of the OCP node process — `tmux new-session -d`
// returns the instant the server forks the pane, so node never becomes its parent and
// therefore can NEVER waitpid()/reap it (a SIGKILL still needs the *parent* to reap, and
// here that parent is the tmux server). `kill-session` destroys the session but the server
// can leave the pane's `claude` (and any grandchildren claude spawned) as `<defunct>`
// zombies that only the server can reap. Over many per-request spawn+teardown cycles these
// accumulate (live evidence on PI231: 25 defunct `<claude>` over 30 days; `tmux kill-server`
// dropped it 25→3). The only node-reachable action that ACTUALLY reaps them — rather than
// merely re-signalling — is to stop the tmux server: when the server exits, the kernel
// reparents its surviving children to init (PID 1), which reaps them immediately.
//
// So after killing our own sessions, if the server has NO sessions left of ANY prefix
// (i.e. nothing we could disrupt — no co-hosted `olp-tui-*` or other instance), we
// `kill-server` to flush the defunct backlog. If ANY non-ocp session remains we leave the
// server running (coexistence rule, ADR 0007) and let the next boot/periodic sweep retry
// once the server is otherwise idle.
export function reapStaleTuiSessions({ tmux = defaultTmux } = {}) {
const r = tmux(["list-sessions", "-F", "#{session_name}"]);
if (!r || r.status !== 0) return 0; // no tmux server / no sessions
const names = String(r.stdout || "").split("\n").map((s) => s.trim()).filter(Boolean);
let killed = 0;
let othersRemain = false;
for (const name of names) {
if (name.startsWith(SESSION_PREFIX)) {
tmux(["kill-session", "-t", name]);
killed++;
} else {
othersRemain = true; // a session we do NOT own (e.g. olp-tui-*) — never kill-server
}
}
// Reap defunct `claude` zombies: safe ONLY when the server is now ours-only/empty.
// kill-server is what actually reaps (server exit reparents survivors to init); a
// per-session kill cannot, since node is not the zombies' parent.
if (!othersRemain) {
tmux(["kill-server"]);
}
return killed;
}
// ── Task 5: runTuiTurn ───────────────────────────────────────────────────
// Boot + paste-settle timing. Conservative defaults validated on PI231; env-tunable.
const BOOT_MS = parseInt(process.env.OCP_TUI_BOOT_MS || "4000", 10); // max wait for input-ready
const READY_POLL_MS = parseInt(process.env.OCP_TUI_READY_POLL_MS || "400", 10); // readiness / paste-verify poll interval
const PASTE_VERIFY_MS = parseInt(process.env.OCP_TUI_PASTE_VERIFY_MS || "5000", 10); // max wait for pasted prompt to render
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Capture the visible tmux pane as plain text (for readiness / paste verification).
function tuiCapturePane(tmux, tmuxName) {
const r = tmux(["capture-pane", "-p", "-t", tmuxName]);
return (r && typeof r.stdout === "string") ? r.stdout : "";
}
// True once claude's input bar is rendered and ready for keystrokes.
function tuiInputReady(pane) {
return /\? for shortcuts/.test(pane);
}
// True once the pasted prompt has POSITIVELY landed in the input box. We only trust
// affirmative signals — NOT "the placeholder is gone", which is unreliable (claude's
// placeholder uses a curly quote `"`, randomized example text, and renders the big paste
// a beat after paste-buffer returns; a "placeholder-gone" heuristic false-positived on the
// still-empty box and made us submit Enter into nothing → issue #130 hang). Landed iff:
// (a) the bracketed-paste indicator "[Pasted text" is present (large/multi-line paste), OR
// (b) the prompt's own leading text appears in the pane (short/literal paste).
function tuiPromptLanded(pane, prompt) {
const flatPane = pane.replace(/\s+/g, " ");
if (flatPane.includes("[Pasted text")) return true;
const firstLine = String(prompt).split("\n").map(s => s.trim()).find(Boolean) || "";
const needle = firstLine.replace(/\s+/g, " ").slice(0, 24);
// C-4/#133: threshold lowered 3 → 2. A prompt whose first non-blank line is 12
// chars ("hi", "ok") previously NEVER matched (needle.length >= 3) and never
// surfaced "[Pasted text", so EVERY short prompt 5s-failed with tui_paste_not_landed
// (live-reproduced: "hi"). The input box starts EMPTY (the curly-quote placeholder
// is excluded by the affirmative-signal design above), so a >=2-char needle present
// in the pane is the pasted prompt, not placeholder noise — false-positive risk is
// low. We keep >=2 rather than >=1 because a single visible char is more likely to
// collide with incidental glyphs in claude's chrome (borders, the "" prompt mark);
// 2 chars is the floor that lands real prompts while staying conservative.
return needle.length >= 2 && flatPane.includes(needle);
}
async function pollUntil(fn, { timeoutMs, intervalMs }) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try { if (fn()) return true; } catch { /* ignore, keep polling */ }
await sleep(intervalMs);
}
return false;
}
// Single-quote escaper for sh -c arguments.
function shq(s) {
return `'${String(s).replace(/'/g, "'\\''")}'`;
}
// Pre-trust the scratch cwd by seeding the trust record in <home>/.claude.json so
// the trust-folder dialog never appears. Verified-live trust shape:
// projects["<cwd>"] = { hasTrustDialogAccepted: true, allowedTools: [], ... }
// Idempotent + best-effort: a missing/unreadable .claude.json must not abort a
// turn (a fresh cwd would then show the dialog once; the boot wait tolerates it).
// Must run BEFORE the session boots so claude reads the trusted record at startup.
export function ensureTuiCwdTrusted(home, cwd) {
if (!home || !cwd) return;
const path = `${home}/.claude.json`;
let j, mode;
try {
j = JSON.parse(readFileSync(path, "utf8"));
mode = statSync(path).mode & 0o777;
} catch { return; }
j.projects = j.projects || {};
const entry = j.projects[cwd] || {};
if (entry.hasTrustDialogAccepted === true) return; // already trusted, no rewrite
entry.hasTrustDialogAccepted = true;
if (!Array.isArray(entry.allowedTools)) entry.allowedTools = [];
j.projects[cwd] = entry;
// Atomic write (temp + rename on the same fs), preserving mode, so a crash
// mid-write can never truncate the user's real ~/.claude.json. We seed ONLY the
// per-project trust flag — NOT bypassPermissionsModeAccepted: the driver never
// passes --dangerously-skip-permissions, so the bypass dialog cannot appear, and
// onboarding completion is an A-path precondition (the host already runs claude).
// NOTE: when the A-path moves to a dedicated scratch HOME (task #26), this writes
// a file we fully own, removing the real-config-mutation concern entirely.
try {
const tmp = `${path}.ocp-tui.${process.pid}.tmp`;
writeFileSync(tmp, JSON.stringify(j, null, 2), { mode });
renameSync(tmp, path);
} catch { /* best effort */ }
}
// Prepare the HOME claude runs under. Two modes:
// - real-home (tuiHome === realHome OR falsy): no isolation; just trust the cwd
// in the real ~/.claude.json. Opt in by setting OCP_TUI_HOME=$HOME.
// - scratch-home: a dedicated HOME that reuses the real OAuth via a SYMLINKED
// .credentials.json, with a seeded .claude.json (onboarded real config minus
// the user's project history; trusts only the scratch cwd) and its own
// projects/ dir — so the real ~/.claude is never mutated or polluted.
//
// ⚠️ CREDENTIAL CAVEAT (verified live): claude rewrites .credentials.json on token
// refresh, REPLACING the symlink with a regular-file copy → the scratch home then
// FORKS the OAuth credentials. Because OAuth refresh tokens rotate (single-use), a
// refresh in the scratch home can invalidate the token the user's real-home claude
// relies on. Therefore scratch-home is safe only with a DEDICATED OAuth or for
// ephemeral use; for a shared subscription prefer real-home (tuiHome===realHome),
// which shares one .credentials.json — identical to how OCP already spawns claude.
// Idempotent + best-effort: any failure degrades toward the dialog/cap, never
// corrupts. Run BEFORE the session boots.
export function prepareTuiHome(realHome, tuiHome, cwd) {
if (!tuiHome || tuiHome === realHome) { ensureTuiCwdTrusted(realHome, cwd); return; }
try {
const claudeDir = `${tuiHome}/.claude`;
mkdirSync(`${claudeDir}/projects`, { recursive: true });
// Symlink the real credentials (never copy the OAuth token); refresh if missing.
const link = `${claudeDir}/.credentials.json`;
if (!existsSync(link)) {
try { symlinkSync(`${realHome}/.claude/.credentials.json`, link); } catch { /* best effort */ }
}
// Seed .claude.json ONCE (if absent): start from the onboarded real config,
// drop the user's project history, trust only the scratch cwd. mode 0600.
const seedPath = `${tuiHome}/.claude.json`;
if (!existsSync(seedPath)) {
let base = {};
try { base = JSON.parse(readFileSync(`${realHome}/.claude.json`, "utf8")); } catch { /* fresh */ }
base.hasCompletedOnboarding = true;
base.projects = { [cwd]: { hasTrustDialogAccepted: true, allowedTools: [] } };
writeFileSync(seedPath, JSON.stringify(base, null, 2), { mode: 0o600 });
}
} catch { /* best effort */ }
// Ensure the cwd is trusted in the scratch config (idempotent; atomic).
ensureTuiCwdTrusted(tuiHome, cwd);
}
// ── Billing-classifier labeling ─────────────────────────────────────────
// Resolve CLAUDE_CODE_ENTRYPOINT on the spawn env per mode. ALWAYS deletes any
// inherited value first (so a stray entrypoint from OCP's own parent env can never
// leak into / mislabel the billing header). Then:
// "cli" (default) → set "cli": deterministic subscription-pool classification.
// HONEST ONLY because OCP's spawn is a genuine interactive PTY (tmux pane,
// no -p, stdout not redirected). Never set "cli" on a non-interactive spawn.
// "auto" → leave unset → claude self-classifies via its t$A (TTY → cli). Use to
// observe/diagnose the real TTY-derived value.
// "off" → leave the env exactly as inherited (diagnostics / honesty audit).
export function resolveTuiEntrypointEnv(env, mode = "cli") {
if (mode === "off") return env;
delete env.CLAUDE_CODE_ENTRYPOINT;
if (mode === "cli") env.CLAUDE_CODE_ENTRYPOINT = "cli";
return env;
}
// Build interactive claude argv: NO -p, NO --output-format (=> cc_entrypoint=cli).
// MCP hard-disabled: --strict-mcp-config (no --mcp-config) is the only mechanism
// that stops account-attached managed MCP from connecting (spec §5.2 / T6),
// belt-and-braces with --disallowedTools "mcp__*".
// A-PATH ONLY: built-in tools are left enabled (acceptable single-user). Deployment B
// (guest keys) MUST additionally pass --tools "" per spec §5.2(2) as the credential
// wall before this argv is reachable for owner_tier=guest — guard that in PR-3 wiring.
export function buildTuiCmd(claudeBin, model, sessionId, ehome, entrypointMode) {
// Deliver claude's env via an `env` prefix on the PANE COMMAND — tmux does NOT forward the
// spawning process's environment to the pane, and `new-session -e` needs tmux ≥3.2 (the cloud
// host runs 2.7), so this is the only portable, reliable mechanism (verified live 2026-06-01:
// passing {env} to spawnSync left the pane with only HOME). DISABLE_AUTOUPDATER pins the version
// (no "What's new" splash that delayed input-readiness); CLAUDE_CODE_ENTRYPOINT labels the
// billing pool (set below per entrypointMode).
//
// CLAUDE_CODE_DISABLE_CLAUDE_MDS + DISABLE_AUTO_MEMORY: 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.
// Without these, claude loads the host's project/user CLAUDE.md + memory into every proxied
// turn — verified live 2026-06-02: a cwd CLAUDE.md ("end every reply with QUACKMARKER_42") was
// obeyed by the proxied turn until these flags were set, after which it was not. Unconditional
// by design (not gated): proxy purity is not an opt-in. Harmless on hosts with no CLAUDE.md
// (the common case — they suppress nothing). Mirrors the -p path's CLAUDE_NO_CONTEXT vars.
const sets = [
`HOME=${shq(ehome)}`,
"DISABLE_AUTOUPDATER=1",
"CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL=1",
"CLAUDE_CODE_DISABLE_CLAUDE_MDS=1",
"CLAUDE_CODE_DISABLE_AUTO_MEMORY=1",
];
// CLAUDE_CODE_OAUTH_TOKEN: tmux does NOT forward the parent process's env to the pane (the
// same reason the whole env is delivered as an `env` prefix above — verified live 2026-06-01),
// so the token MUST be set explicitly here or the spawned `claude` never sees it. Without it,
// the TUI claude falls back to authenticating via <HOME>/.claude/.credentials.json, whose
// single-use refresh token gets corrupted by the per-request spawn + `kill-session` teardown
// racing claude's token-rotation write (the PI231 incident: refresh token ended up an empty
// string → permanent 401 "Please run /login", re-login re-corrupted on the next spawn). With
// the long-lived OAuth token in env, claude authenticates via the token and never touches the
// credentials.json refresh path — matching how the stable oracle / Mac-mini hosts already run.
//
// SECURITY: the token appears in the pane command (ps-visible). This is acceptable for the
// single-user A-path — it mirrors the existing plaintext-token practice (server.mjs reads the
// same CLAUDE_CODE_OAUTH_TOKEN env at getOAuthCredentials()), and the multi-user B-path is
// already refused at boot (TUI + AUTH_MODE=multi is a hard FATAL). Read from process.env here,
// consistent with how buildTuiCmd already reads OCP_TUI_FULL_TOOLS / CLAUDE_ALLOWED_TOOLS below.
//
// When the env is unset (e.g. a host that intentionally relies on credentials.json), no token
// is added — behaviour is byte-for-byte unchanged from before this fix.
if (process.env.CLAUDE_CODE_OAUTH_TOKEN) {
sets.push(`CLAUDE_CODE_OAUTH_TOKEN=${shq(process.env.CLAUDE_CODE_OAUTH_TOKEN)}`);
}
const unset = ["CLAUDECODE", "ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN"];
if (entrypointMode === "cli") sets.push("CLAUDE_CODE_ENTRYPOINT=cli");
else if (entrypointMode === "auto") unset.push("CLAUDE_CODE_ENTRYPOINT"); // let claude self-classify via TTY
const envPrefix = ["env", ...unset.map((u) => `-u ${u}`), ...sets].join(" ");
// Tool surface.
// DEFAULT (safe): hard-disable MCP (--strict-mcp-config + --disallowedTools mcp__*);
// built-in tools stay on, acceptable for single-user A-path.
// OCP_TUI_FULL_TOOLS=1: grant the SAME tool surface as the -p A-path
// (--allowedTools [+ --mcp-config] [+ --dangerously-skip-permissions]), so a
// SINGLE-USER / trusted TUI deployment can run a tool-using agent (e.g. an OpenClaw
// assistant that needs Bash/Read/Write/MCP) on the subscription pool. This mirrors
// buildCliArgs() in server.mjs. Safe to gate ON only because TUI is hard-incompatible
// with AUTH_MODE=multi (server.mjs refuses to boot), so it can never widen a guest's
// surface. Env mirrors server.mjs's CLAUDE_ALLOWED_TOOLS / _SKIP_PERMISSIONS / _MCP_CONFIG.
let toolArgs;
if (process.env.OCP_TUI_FULL_TOOLS === "1") {
toolArgs = [];
if (process.env.CLAUDE_SKIP_PERMISSIONS === "true") {
toolArgs.push("--dangerously-skip-permissions");
} else {
const allowed = (process.env.CLAUDE_ALLOWED_TOOLS ||
"Bash,Read,Write,Edit,Glob,Grep,WebSearch,WebFetch,Agent")
.split(",").map((s) => s.trim()).filter(Boolean);
// shq EACH token: buildTuiCmd returns a SHELL STRING (run by tmux via sh -c), unlike
// buildCliArgs which returns an argv array to spawn(). claude accepts scoped specifiers
// like "Bash(npm run test:*)" / "Read(~/**)" whose ( ) * ~ would break/inject the shell
// command if pasted bare. (operator-self-injection only — guests can't reach TUI.)
if (allowed.length) toolArgs.push("--allowedTools", ...allowed.map(shq));
}
if (process.env.CLAUDE_MCP_CONFIG) toolArgs.push("--mcp-config", shq(process.env.CLAUDE_MCP_CONFIG));
} else {
toolArgs = ["--strict-mcp-config", "--disallowedTools", shq("mcp__*")];
}
return [
envPrefix,
shq(claudeBin),
"--model", shq(model),
"--session-id", sessionId,
...toolArgs,
].join(" ");
}
// Full per-request TUI lifecycle:
// 1. Pre-trust the scratch cwd (no trust dialog will appear).
// 2. Write prompt to a 0600 temp file (no shell injection from prompt content).
// 3. Boot an interactive `claude` in a fresh tmux session in the scratch cwd; poll
// capture-pane until the `? for shortcuts` input bar appears (readiness-poll
// replaces the old blind boot sleep). BOOT_MS is the max wait, not a fixed delay.
// 4. Paste the prompt via tmux load-buffer + paste-buffer -p (bracketed paste) —
// reliable for large multi-line prompts where send-keys -l is not (issue #130).
// Poll-verify the prompt landed in the input (placeholder gone / [Pasted text]);
// fast-fail with tui_paste_not_landed if it never lands (prevents the 120s
// wallclock "stuck typing" hang). Then submit with a SEPARATE Enter key event.
// 5. Block on the native JSONL transcript (located by session-id) until terminal
// marker or wall-clock cap.
// 6. Always teardown: kill session + rm temp dir (even on throw).
// Returns { text, entrypoint } from readTuiTranscript (entrypoint is the billing-pool
// classifier, e.g. "cli", or null if the transcript did not include a turn_duration).
export async function runTuiTurn({
prompt,
model,
claudeBin,
home,
realHome,
cwd,
wallclockMs = 120000,
entrypointMode = "cli",
tmux = defaultTmux,
}) {
const sessionId = randomUUID();
const tmuxName = SESSION_PREFIX + sessionId.slice(0, 8);
const ehome = home || process.env.HOME; // HOME claude runs under (scratch or real)
const rhome = realHome || process.env.HOME; // real home (OAuth + onboarded config source)
// Ensure scratch cwd exists, then prepare the (scratch or real) HOME + trust the
// cwd — before claude boots.
if (!existsSync(cwd)) mkdirSync(cwd, { recursive: true });
prepareTuiHome(rhome, ehome, cwd);
// Write prompt to a temp file (mode 0600) so the content never touches argv.
const tmpDir = mkdtempSync(`${tmpdir()}/ocp-tui-`);
const promptFile = `${tmpDir}/prompt.txt`;
writeFileSync(promptFile, prompt, { mode: 0o600 });
// Build the env: disable marketplace auto-install, strip any Anthropic / CC
// env vars that might interfere with interactive-mode classification.
const env = { ...process.env, CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL: "1" };
delete env.CLAUDECODE;
delete env.ANTHROPIC_API_KEY;
delete env.ANTHROPIC_BASE_URL;
delete env.ANTHROPIC_AUTH_TOKEN;
env.HOME = ehome; // claude reads credentials + writes the transcript under this HOME
resolveTuiEntrypointEnv(env, entrypointMode);
try {
// 1. Boot the interactive session inside tmux, rooted at the scratch cwd.
// Capture the result: if tmux new-session fails (status !== 0) there is no
// PTY, no interactive spawn — abort BEFORE the boot sleep rather than paste
// into a non-existent session or issue a billing request without a verified
// interactive context. The finally teardown is still harmless (kill-session
// is a no-op when the session never existed).
const spawnResult = tmux(
["new-session", "-d", "-s", tmuxName, "-x", "220", "-y", "50", "-c", cwd,
buildTuiCmd(claudeBin, model, sessionId, ehome, entrypointMode)],
{ env },
);
if (!spawnResult || spawnResult.status !== 0) {
throw new Error("tui_spawn_failed: tmux session not created");
}
// 2. Wait until claude's input bar is actually ready (was: blind sleep(BOOT_MS)).
// BOOT_MS is now the MAX readiness wait, not a fixed delay.
const ready = await pollUntil(() => tuiInputReady(tuiCapturePane(tmux, tmuxName)),
{ timeoutMs: BOOT_MS, intervalMs: READY_POLL_MS });
if (!ready) {
// (readiness timed out; relying on paste-verify)
console.error("[tui] input_not_ready", tmuxName);
}
// 3. Paste the prompt via a tmux PASTE BUFFER with bracketed paste (-p), NOT
// `send-keys -l`. send-keys of a large multi-line prompt is unreliable: the
// embedded newlines arrive as separate key events (effectively repeated Enter),
// so a big OpenClaw-style prompt never lands and the turn hangs to the wallclock
// (issue #130 — reproduced at ~300 lines; fixed by bracketed paste). load-buffer
// reads the file directly (no shell arg limit, no `"$(cat)"`), and paste-buffer -p
// wraps it in bracketed-paste markers so claude ingests it atomically as ONE paste
// ("[Pasted text #N +M lines]"). -d deletes the buffer afterward. Buffer name is the
// per-session tmuxName, so concurrent turns never collide.
tmux(["load-buffer", "-b", tmuxName, promptFile]);
tmux(["paste-buffer", "-b", tmuxName, "-t", tmuxName, "-p", "-d"]);
// Verify the prompt POSITIVELY landed before submitting; poll (a large bracketed paste
// takes a beat to render the "[Pasted text]" indicator). This is load-bearing: firing
// Enter before the paste renders submits an empty box → the turn hangs to the wallclock
// (issue #130). Fast-fail if it never lands → deterministic error in seconds.
const landed = await pollUntil(() => tuiPromptLanded(tuiCapturePane(tmux, tmuxName), prompt),
{ timeoutMs: PASTE_VERIFY_MS, intervalMs: READY_POLL_MS });
if (!landed) {
throw new Error("tui_paste_not_landed: prompt did not reach claude's input within " + PASTE_VERIFY_MS + "ms");
}
// Submit (separate Enter key event).
tmux(["send-keys", "-t", tmuxName, "Enter"]);
// 4. Block on the native transcript (resolved by session-id) until terminal.
// Returns { text, entrypoint } from readTuiTranscript.
return await readTuiTranscript({ home: ehome, sessionId, wallclockMs });
} finally {
// 5. Teardown — always, even on throw.
try { tmux(["kill-session", "-t", tmuxName]); } catch { /* already gone */ }
try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best effort */ }
}
}
+293
View File
@@ -0,0 +1,293 @@
// Transcript reader for TUI-mode. Reads claude's native JSONL session transcript
// and returns the latest assistant turn's text once the turn is terminal.
//
// Authority: claude CLI v2.1.157 — interactive session transcript at
// <HOME>/.claude/projects/<CWD with every "/" -> "-">/<--session-id>.jsonl
// Completion marker: a line {"type":"system","subtype":"turn_duration",...}.
// See docs/superpowers/specs/2026-05-30-tui-mode-production-design.md §4.
import { readFileSync, existsSync, readdirSync } from "node:fs";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Project-dir encoding: claude replaces every "/" AND every "." with "-".
// Verified live (claude v2.1.158): cwd /home/u/.ocp-tui/work is stored under
// projects/-home-u--ocp-tui-work/ (the "." in ".ocp-tui" becomes "-", yielding
// the double dash). The earlier "/"-only rule was wrong for dotted paths; the
// fixture cwd /tmp/tui-test happened to have no dots so it never surfaced.
// NOTE: prefer findTranscriptPath() (glob by session-id) for resolution — it is
// immune to the exact encoding rule. This helper is kept for the known-path case.
export function encodeCwd(cwd) {
return cwd.replace(/[/.]/g, "-");
}
export function transcriptPath(home, cwd, sessionId) {
return `${home}/.claude/projects/${encodeCwd(cwd)}/${sessionId}.jsonl`;
}
// Locate a session's transcript by its UUID across every projects subdir, without
// reconstructing the encoded cwd. Robust to whatever encoding claude applies.
// Returns the path, or null if not present yet (it appears once the turn starts).
export function findTranscriptPath(home, sessionId) {
if (!home || !sessionId) return null;
const root = `${home}/.claude/projects`;
let dirs;
try { dirs = readdirSync(root); } catch { return null; }
for (const d of dirs) {
const candidate = `${root}/${d}/${sessionId}.jsonl`;
if (existsSync(candidate)) return candidate;
}
return null;
}
// Parse NDJSON text into objects; skip blank lines and partial/forming lines
// (the live transcript is read mid-write, so the last line may be incomplete).
export function parseTranscriptLines(text) {
const out = [];
for (const line of text.split("\n")) {
const t = line.trim();
if (!t) continue;
try { out.push(JSON.parse(t)); } catch { /* partial line being written */ }
}
return out;
}
// A line marks the assistant turn complete when EITHER:
// (a) {type:"system", subtype:"turn_duration"} — emitted by newer claude builds
// (e.g. 2.1.159), OR
// (b) {type:"assistant"} whose message.stop_reason is a FINAL reason
// ("end_turn" / "stop_sequence" / "max_tokens"). This is the API-level
// end-of-turn signal, present across claude builds whose transcripts do NOT
// emit turn_duration (e.g. 2.1.114 — verified live on the cloud host). Without
// it OCP can't detect completion on those builds and hangs to the wallclock,
// then returns only partial text (issue #130, cloud/server-side symptom).
//
// stop_reason "tool_use" is deliberately NOT terminal: the model is mid-turn (it will
// run a tool and continue with a later assistant entry). Matching on a FINAL
// stop_reason — not on the mere presence of a tool_use — keeps tool-using turns intact.
// (The v3.17.1 narrowing dropped a buggy "tool_use is terminal" rule; this restores
// cross-version completion detection without bringing that bug back.)
const TERMINAL_STOP_REASONS = new Set(["end_turn", "stop_sequence", "max_tokens"]);
export function isTerminalLine(obj) {
if (!obj || typeof obj !== "object") return false;
if (obj.type === "system" && obj.subtype === "turn_duration") return true;
if (obj.type === "assistant" && obj.message && typeof obj.message === "object") {
return TERMINAL_STOP_REASONS.has(obj.message.stop_reason);
}
return false;
}
// Text of the LAST assistant turn: concatenate its text content blocks
// (ignore thinking/tool_use blocks). Later assistant entries overwrite earlier.
// Fixture-confirmed shape: top-level type:"assistant", message.content[] array.
//
// Scoping: this returns the FINAL text-bearing assistant entry in the whole file,
// not "text since the matching user line" (spec §4.2). Those are equivalent ONLY
// under OCP's one-session-per-request model (a fresh --session-id => a fresh
// transcript holding one logical exchange). If a future warm-pool ever reuses a
// session WITHOUT a fresh session-id / clear, earlier-turn text could leak — that
// author must add user-line scoping here. See spec §7.2.
export function extractLatestAssistantText(events) {
let text = "";
for (const ev of events) {
if (!ev || ev.type !== "assistant") continue;
const content = ev.message && ev.message.content;
if (!Array.isArray(content)) continue;
const parts = content
.filter((b) => b && b.type === "text" && typeof b.text === "string")
.map((b) => b.text);
if (parts.length) text = parts.join("");
}
return text;
}
// Returns the entrypoint string (e.g. "cli") used for the billing-pool assertion,
// or null if absent. Lets callers assert the subscription-classified path.
//
// Resolution order (C-3, issue #133):
// 1. PREFER the turn_duration system line's `entrypoint` — the authoritative
// end-of-turn classifier emitted by builds that produce turn_duration
// (e.g. claude-2.1.104/2.1.157 on PI231).
// 2. FALL BACK to the `entrypoint` field on ANY ordinary transcript line
// (assistant / user / attachment / system) — present on BOTH emitting and
// non-emitting builds. Some claude builds (e.g. certain Mac mini transcripts)
// do NOT emit a turn_duration line at all; reading ONLY turn_duration made the
// caller's tui_entrypoint_mismatch assertion (server.mjs) get got:null every
// turn and go blind. The entrypoint value is identical across line types within
// a single interactive session (fixture-confirmed: every line in
// complete-haiku.jsonl carrying `entrypoint` reads "cli"), so the fallback
// yields the same classifier. Last-writer-wins on the fallback.
export function verifyEntrypoint(events) {
let fallback = null;
for (const ev of events) {
if (!ev || typeof ev !== "object") continue;
if (ev.type === "system" && ev.subtype === "turn_duration" && ev.entrypoint != null) {
return ev.entrypoint; // authoritative — short-circuit
}
if (ev.entrypoint != null) fallback = ev.entrypoint;
}
return fallback;
}
// ── C-1: honest AUTH-FAILURE banner detection (issue #133) ───────────────
// When the interactive `claude` CLI hits an in-session error it does NOT crash —
// it renders the error as ordinary assistant text in the transcript. The specific
// failure C-1 exists to catch is R-1: EXPIRED / INVALID credentials, where every
// turn comes back as the same one-line auth-failure banner and OCP, none the wiser,
// caches that banner (server.mjs setCachedResponse), shares it via singleflight, and
// records a model SUCCESS — so a hard auth error is silently served (and cached for
// the 5-min TTL) as a real answer. The two live-reproduced banners on PI231
// (2026-06-10) are:
// "Please run /login · API Error: 401 Invalid authentication credentials" (69 chars)
// "Failed to authenticate. API Error: 401 Invalid authentication credentials" (73 chars)
//
// WHY THE SCOPE IS NARROW (conservatism — the load-bearing design choice).
// An earlier generalised rule (^<short-prefix>?API Error:\s*\d{3}\b.*$) was TOO
// BROAD: its unbounded `.*` 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 …"). That is the worst
// outcome: a false-positive costs the user a missing answer AND a double-burn retry,
// whereas the rare false-negative (caching one transient error for the 5-min TTL) is
// cheap and self-healing. So C-1 is reframed from "detect ANY API error" to "detect
// a claude-CLI AUTHENTICATION-FAILURE banner", and when unsure it PASSES (does not
// kill). Transient 5xx server errors are deliberately NOT detected — they are not the
// R-1 case and the conservative choice is to let them through.
//
// THE SIGNAL — a turn is an auth-failure banner only if ALL of these hold over the
// WHOLE trimmed assistant text (a conjunction; any one failing => PASS):
// 1. SHORT whole-message. Real banners are one short line (the two live samples are
// 69 and 73 chars). Cap = TUI_ERR_MAX_LEN (100) — headroom over 73 for a
// slightly longer future banner, while still rejecting multi-sentence prose. A
// long answer that happens to discuss auth (no code chars, e.g. 226 chars) is
// rejected on length alone.
// 2. Contains "API Error: 4\d{2}" — auth failures are 4xx (401/403). This rejects
// transient 5xx ("API Error: 500/503 …") and bare "HTTP 401 means unauthorized."
// (no "API Error:" core).
// 3. Contains an auth KEYWORD — authenticat | /login | credential (case-insensitive).
// This rejects answers that quote a 4xx but are not auth banners, e.g.
// "To debug a 401: the server returns API Error: 401 Unauthorized …"
// ("Unauthorized" is authoriz-, not authenticat-; no /login, no credential).
// 4. Contains NO backtick or quote char (` ' "). A real CLI banner is plain text;
// backticked/quoted text signals an answer that is QUOTING the error rather than
// being the banner, e.g. "You'll see `API Error: 401` … run /login to fix it."
// (75 chars — passes 1-3 but is excluded here). This is the conservative tie-
// breaker for short instructional answers.
//
// Worked matrix (all required cases pass — see test-features.mjs C-1 block):
// KILL: "Please run /login · API Error: 401 Invalid authentication credentials"
// KILL: "Failed to authenticate. API Error: 401 Invalid authentication credentials"
// PASS: "API Error: 500 happened because the server was overloaded. …" (not 4xx)
// PASS: "Failed to parse the config. Here are the API Error: 401 details …" (too long + no auth-kw)
// PASS: "To debug a 401: … API Error: 401 Unauthorized, then you refresh …" (no auth-kw)
// PASS: "Here is the handler … It logs the string API Error: 503 …" (not 4xx)
// PASS: "You'll see `API Error: 401` … run /login to fix it." (has backtick)
// PASS: "HTTP 401 means unauthorized." (no API Error core)
// PASS: "The capital of France is Paris." (nothing matches)
//
// OPERATOR OVERRIDE (unchanged): CLAUDE_TUI_ERROR_PATTERNS lets an operator REPLACE
// the default auth-banner detector with their own newline- or `||`-separated JS regex
// source strings (each auto-anchored ^…$ over the trimmed text, case-insensitive). A
// non-empty override uses ONLY those regexes (the narrowed default is bypassed); an
// empty / whitespace-only override DISABLES detection entirely (escape hatch).
// Whole-message length cap for the default auth-banner detector. Real banners are
// 69/73 chars; 100 gives headroom while still rejecting multi-sentence prose.
const TUI_ERR_MAX_LEN = 100;
// 4xx "API Error:" core — auth failures are 4xx (401/403), never 5xx.
const TUI_ERR_4XX = /API Error:\s*4\d{2}\b/i;
// Auth keyword — the message must be about authentication, not just quote a 4xx.
const TUI_ERR_AUTH_KW = /authenticat|\/login|credential/i;
// Code/quote chars — their presence signals prose QUOTING an error, not the banner.
const TUI_ERR_CODE_CHAR = /[`'"]/;
// Default detector: returns true iff `trimmed` IS a claude-CLI auth-failure banner
// (all four signals above). Conservative — any signal failing => false (PASS).
function isDefaultAuthFailureBanner(trimmed) {
if (trimmed.length > TUI_ERR_MAX_LEN) return false; // 1. short whole-message
if (!TUI_ERR_4XX.test(trimmed)) return false; // 2. 4xx API Error core
if (!TUI_ERR_AUTH_KW.test(trimmed)) return false; // 3. auth keyword
if (TUI_ERR_CODE_CHAR.test(trimmed)) return false; // 4. no code/quote chars
return true;
}
// Compile an OPERATOR-SUPPLIED pattern set (override path only). Each source is
// anchored ^…$ over the trimmed text and matched case-insensitively (`s` so `.` spans
// a multi-line banner). A pattern that fails to compile is skipped (never throws into
// the request path).
function compileTuiErrorPatterns(raw) {
const sources = String(raw).split(/\r?\n|\|\|/).map((s) => s.trim()).filter(Boolean);
const out = [];
for (const src of sources) {
try { out.push(new RegExp(`^(?:${src})$`, "is")); } catch { /* skip bad pattern */ }
}
return out;
}
// Returns the matched banner text (the trimmed assistant text) if `text` IS a claude-
// CLI auth-failure banner in its entirety, else null. `patternsRaw` defaults to
// process.env.CLAUDE_TUI_ERROR_PATTERNS:
// - undefined → narrowed default auth-banner detector (isDefaultAuthFailureBanner).
// - non-empty → operator regex override REPLACES the default.
// - empty/ws → detection disabled (escape hatch).
export function detectTuiUpstreamError(text, patternsRaw = process.env.CLAUDE_TUI_ERROR_PATTERNS) {
if (typeof text !== "string") return null;
const trimmed = text.trim();
if (!trimmed) return null;
if (patternsRaw == null) {
return isDefaultAuthFailureBanner(trimmed) ? trimmed : null;
}
// Operator override path: empty/whitespace disables; otherwise use only their regexes.
const patterns = compileTuiErrorPatterns(patternsRaw);
if (patterns.length === 0) return null;
for (const re of patterns) {
if (re.test(trimmed)) return trimmed;
}
return null;
}
// Block until the session transcript is terminal (turn_duration / final
// stop_reason) or the wall-clock cap elapses, polling the file (no fs.watch —
// robust over NFS / editors). Returns { text, entrypoint, truncated }:
// - text: latest assistant text.
// - entrypoint: billing-pool classifier (see verifyEntrypoint), or null.
// - truncated: FALSE when a terminal marker was reached (the turn completed);
// TRUE when the wall-clock cap was hit with partial text but NO
// terminal marker (the turn is INCOMPLETE — what we have is a
// cut-off prefix). (C-2, issue #133.)
//
// Why `truncated` matters: previously the terminal-marker path and the
// cap-with-partial-text path BOTH returned `{text, entrypoint}` identically, so
// callClaudeTui could not tell a complete answer from a truncated one and cached +
// returned the partial as finish_reason:stop (silent success). The caller now
// throws on `truncated` so a cut-off turn is neither cached nor counted as success.
// The field is additive — existing call sites that ignore it keep working.
//
// On cap with NO text at all, still throws (unchanged) — there is nothing to return.
//
// No quiescence heuristic by design: a long Opus thinking turn stalls transcript
// growth and a "file stable for N s" rule would false-abort it (spec §4.3).
// Resolution: pass an explicit `transcriptPath` (used by unit tests), OR pass
// `home` + `sessionId` to resolve by glob each poll (production) — the transcript
// file does not exist until the turn starts, so resolution happens inside the loop.
export async function readTuiTranscript({ transcriptPath: p, home, sessionId, wallclockMs = 120000, pollMs = 250 }) {
const deadline = Date.now() + wallclockMs;
let lastText = "";
let lastEntrypoint = null;
while (Date.now() < deadline) {
const resolved = p || findTranscriptPath(home, sessionId);
if (resolved && existsSync(resolved)) {
const events = parseTranscriptLines(readFileSync(resolved, "utf8"));
lastText = extractLatestAssistantText(events) || lastText;
const ep = verifyEntrypoint(events);
if (ep != null) lastEntrypoint = ep;
// Terminal marker reached → the turn is COMPLETE.
if (events.some(isTerminalLine)) return { text: lastText, entrypoint: lastEntrypoint, truncated: false };
}
await sleep(pollMs);
}
// Cap elapsed with no terminal marker. If we have partial text, flag it truncated
// so the caller rejects it (don't cache / don't count as success). No text at all
// → throw (nothing to return).
if (lastText) return { text: lastText, entrypoint: lastEntrypoint, truncated: true };
throw new Error("tui_transcript_timeout: no assistant text within wallclock cap");
}
+9 -1
View File
@@ -2,6 +2,14 @@
"$schema": "./models.schema.json",
"version": 1,
"models": [
{
"id": "claude-opus-4-8",
"displayName": "Claude Opus 4.8",
"openclawName": "Claude Opus 4.8 (via CLI)",
"reasoning": true,
"contextWindow": 200000,
"maxTokens": 16384
},
{
"id": "claude-opus-4-7",
"displayName": "Claude Opus 4.7",
@@ -36,7 +44,7 @@
}
],
"aliases": {
"opus": "claude-opus-4-7",
"opus": "claude-opus-4-8",
"sonnet": "claude-sonnet-4-6",
"haiku": "claude-haiku-4-5-20251001"
},
+2
View File
@@ -709,6 +709,8 @@ Usage:
ocp update --rollback --list List available snapshots
ocp update --rollback <path> Restore a specific snapshot
ocp update --rollback --dry-run Preview rollback plan
ocp update --rollback --gc Delete old snapshots (keep last 5, or <30 days)
ocp update --rollback --gc --dry-run Preview what would be deleted
EOF
}
+9 -5
View File
@@ -506,9 +506,11 @@ main() {
echo ""
# Step 2.5: auto-discover anonymous key from /health (issue #12 §14 Path A).
# When the OCP admin set PROXY_ANONYMOUS_KEY, the server advertises it via
# /health.anonymousKey. If the user didn't pass --key, use it automatically so
# `ocp-connect <host>` works zero-config for OpenClaw multi-agent setups.
# The server advertises anonymousKey in /health ONLY when the admin has set
# PROXY_ADVERTISE_ANON_KEY=1 (default off — /health is unauthenticated, so
# advertising exposes the shared key to any LAN-reachable device; issue #109).
# Localhost callers always receive it regardless. When the field is absent,
# ocp-connect falls back to anonymous access / interactive --key (step 3 below).
if [[ -z "$key" ]]; then
local anon_key
anon_key=$(echo "$health_json" | python3 -c "
@@ -632,11 +634,12 @@ PYEOF
{
echo ""
echo "# OCP LAN (added by ocp connect)"
echo "export OPENAI_BASE_URL=$base_url/v1"
echo "export OPENAI_BASE_URL='$base_url/v1'"
if [[ -n "$key" ]]; then
echo "export OPENAI_API_KEY=$key"
echo "export OPENAI_API_KEY='$key'"
fi
} >> "$rc_file"
chmod 600 "$rc_file" 2>/dev/null || true
done
echo " Shell config:"
@@ -669,6 +672,7 @@ PYEOF
echo "OPENAI_API_KEY=$key"
fi
} > "$env_dir/ocp.conf"
chmod 600 "$env_dir/ocp.conf" 2>/dev/null || true
echo ""
echo " System-level (systemd):"
echo " ✓ $env_dir/ocp.conf"
+23 -10
View File
@@ -1,9 +1,19 @@
/**
* OCP Plugin registers /ocp as a native slash command in OpenClaw gateway.
* Calls the local claude-proxy at http://127.0.0.1:3456 and formats the response.
* Calls the local claude-proxy and formats the response.
*
* Port resolution (in priority order):
* 1. OCP_PROXY_URL env (full URL, e.g. http://10.0.0.5:3456)
* 2. CLAUDE_PROXY_PORT env (port only; localhost assumed)
* 3. Fallback: http://127.0.0.1:3456 (OCP server source default since v1.0)
*
* If a particular host's OCP plist injects a non-default CLAUDE_PROXY_PORT,
* the OpenClaw launchd plist for that host must also inject the same
* CLAUDE_PROXY_PORT into the plugin's env, or the plugin will fall back to
* 3456 and miss the server.
*/
const PROXY = "http://127.0.0.1:3456";
const PROXY = process.env.OCP_PROXY_URL
|| (process.env.CLAUDE_PROXY_PORT ? `http://127.0.0.1:${process.env.CLAUDE_PROXY_PORT}` : "http://127.0.0.1:3456");
// Wrap output in monospace code block for Telegram/Discord alignment
function mono(text) { return "```\n" + text + "\n```"; }
@@ -198,31 +208,34 @@ async function cmdTest() {
async function cmdRestart(args) {
const target = (args || "").trim().toLowerCase();
const { execSync } = await import("node:child_process");
const uid = typeof process.getuid === "function" ? process.getuid() : 501;
const macProxy = `launchctl kickstart -k gui/${uid}/dev.ocp.proxy`;
const macGateway = `launchctl kickstart -k gui/${uid}/ai.openclaw.gateway`;
try {
if (target === "gateway") {
execSync("launchctl kickstart -k gui/501/ai.openclaw.gateway", { timeout: 15000 });
execSync(macGateway, { timeout: 15000 });
return "✓ Gateway restarted";
} else if (target === "all") {
execSync("launchctl kickstart -k gui/501/ai.openclaw.proxy", { timeout: 15000 });
execSync(macProxy, { timeout: 15000 });
// Gateway restart will kill this plugin too, so do it last
execSync("launchctl kickstart -k gui/501/ai.openclaw.gateway", { timeout: 15000 });
execSync(macGateway, { timeout: 15000 });
return "✓ Proxy + Gateway restarted";
} else {
execSync("launchctl kickstart -k gui/501/ai.openclaw.proxy", { timeout: 15000 });
execSync(macProxy, { timeout: 15000 });
return "✓ Proxy restarted";
}
} catch (e) {
// Try systemd for Linux
// Linux: systemd user services
try {
if (target === "gateway") {
execSync("systemctl --user restart openclaw-gateway", { timeout: 15000 });
return "✓ Gateway restarted";
} else {
execSync("systemctl --user restart openclaw-proxy 2>/dev/null || pkill -f 'node.*server.mjs' && sleep 2 && cd ~/.openclaw/projects/*/; node server.mjs &", { timeout: 15000, shell: true });
execSync("systemctl --user restart ocp-proxy", { timeout: 15000 });
return "✓ Proxy restarted";
}
} catch (e2) {
return `✗ Restart failed: ${e2.message?.slice(0, 100)}`;
return `✗ Restart failed: ${e2.message?.slice(0, 100)}. Run \`ocp restart\` on the server host manually.`;
}
}
}
+2 -2
View File
@@ -2,7 +2,7 @@
"id": "ocp",
"name": "OCP Commands",
"description": "Slash commands for the OpenClaw Proxy — /ocp usage, /ocp settings, /ocp health, etc.",
"version": "3.12.0",
"version": "3.16.2",
"configSchema": {
"type": "object",
"additionalProperties": false,
@@ -10,7 +10,7 @@
"proxyUrl": {
"type": "string",
"default": "http://127.0.0.1:3456",
"description": "URL of the Claude proxy"
"description": "URL of the Claude proxy. Overridable via OCP_PROXY_URL or CLAUDE_PROXY_PORT env."
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "open-claude-proxy",
"version": "3.15.1",
"version": "3.20.0",
"description": "OCP (Open Claude Proxy) — use your Claude Pro/Max subscription as an OpenAI-compatible API for any IDE. Works with Cline, OpenCode, Aider, Continue.dev, OpenClaw, and more.",
"type": "module",
"bin": {
+88 -2
View File
@@ -15,6 +15,7 @@ import { readFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
import { execSync } from "node:child_process";
import { DEFAULT_PORT } from "../lib/constants.mjs";
const SCHEMA_VERSION = "1";
@@ -37,6 +38,11 @@ export async function runDoctor(opts = {}) {
const push = (id, level, message, extra = {}) =>
checks.push({ id, level, message, ...extra });
// --- fast path: --check oauth ---
if (opts.checkOnly === "oauth") {
return runOauthOnly(opts, checks, push);
}
// --- version detection ---
const ocpDir = opts.ocpDir || join(homedir(), "ocp");
let currentVersion = opts.mockVersion;
@@ -76,7 +82,7 @@ export async function runDoctor(opts = {}) {
health = opts.mockHealth;
} else {
try {
const port = process.env.CLAUDE_PROXY_PORT || "3478";
const port = process.env.CLAUDE_PROXY_PORT || String(DEFAULT_PORT);
const out = execSync(`curl -sf --max-time 3 http://127.0.0.1:${port}/health`, { stdio: ["pipe", "pipe", "pipe"] }).toString();
health = { status: 200, body: JSON.parse(out) };
} catch (e) {
@@ -190,6 +196,84 @@ export async function runDoctor(opts = {}) {
};
}
function runOauthOnly(opts, checks, push) {
let healthOk = true, oauthOk = true;
let health;
if (opts.mockHealth !== undefined) {
health = opts.mockHealth;
} else {
try {
const port = process.env.CLAUDE_PROXY_PORT || String(DEFAULT_PORT);
const out = execSync(`curl -sf --max-time 3 http://127.0.0.1:${port}/health`, { stdio: ["pipe", "pipe", "pipe"] }).toString();
health = { status: 200, body: JSON.parse(out) };
} catch (e) {
health = { error: String(e.message || e) };
}
}
if (health.error || health.status !== 200) {
healthOk = false;
push("oauth_ok", "FAIL", `service unreachable: ${health.error || `status ${health.status}`}`);
} else if (!health.body || typeof health.body !== "object") {
healthOk = false;
push("oauth_ok", "FAIL", "service /health returned 200 but empty/non-JSON body");
} else if (!health.body?.auth?.ok) {
oauthOk = false;
push("oauth_ok", "FAIL", `auth.ok=false: ${health.body?.auth?.message || "unknown"}`);
} else {
push("oauth_ok", "PASS", "OAuth token valid");
}
const kind = !healthOk ? "fix_service" : !oauthOk ? "fix_oauth" : "noop";
let next_action;
const ocpDir = opts.ocpDir || join(homedir(), "ocp");
if (kind === "noop") {
next_action = { kind, human_required: [], ai_executable: [], verify: "OAuth healthy" };
} else if (kind === "fix_oauth") {
next_action = {
kind,
human_required: [],
ai_executable: [
`cd "$(npm root -g)/@anthropic-ai/claude-code" && node install.cjs`,
`launchctl bootout gui/$(id -u)/dev.ocp.proxy 2>/dev/null || true`,
`launchctl bootstrap gui/$(id -u) ${join(homedir(), "Library", "LaunchAgents", "dev.ocp.proxy.plist")}`,
`${ocpDir}/ocp doctor --check oauth`
],
verify: "ocp doctor --check oauth expects PASS",
reference: "~/.cc-rules/memory/learnings/ocp_claude_native_binary_postinstall.md"
};
} else {
next_action = {
kind,
human_required: [],
ai_executable: [
`launchctl bootout gui/$(id -u)/dev.ocp.proxy 2>/dev/null || true`,
`launchctl bootstrap gui/$(id -u) ${join(homedir(), "Library", "LaunchAgents", "dev.ocp.proxy.plist")}`,
`${ocpDir}/ocp doctor --check oauth`
],
verify: "ocp doctor --check oauth expects service_running=PASS"
};
}
const fail_count = checks.filter(c => c.level === "FAIL").length;
// "skipped" = --check oauth fast path intentionally omits version detection.
// AI agents should NOT semver-compare against current_version/latest_version when
// either equals "skipped"; the full path provides those fields when needed.
return {
schema_version: SCHEMA_VERSION,
timestamp: new Date().toISOString(),
ready_to_upgrade: fail_count === 0,
current_version: opts.mockVersion || "skipped",
latest_version: opts.mockLatest || "skipped",
from_version_supported: true,
fail_count,
warn_count: 0,
checks,
next_action
};
}
// CLI entrypoint — use fileURLToPath + realpath to handle symlinked install paths
// (e.g. /tmp/ → /private/tmp/ on macOS would otherwise miss the guard).
import { fileURLToPath } from "node:url";
@@ -202,7 +286,9 @@ function _isMain() {
}
if (_isMain()) {
const wantJson = process.argv.includes("--json");
const result = await runDoctor();
const checkIdx = process.argv.indexOf("--check");
const checkOnly = checkIdx !== -1 ? process.argv[checkIdx + 1] : undefined;
const result = await runDoctor({ checkOnly });
if (wantJson) {
console.log(JSON.stringify(result, null, 2));
} else {
+2
View File
@@ -9,6 +9,8 @@
// No new dependencies — regex-based, plist <key>X</key><string>Y</string> shape
// is stable enough for our hand-written templates in setup.mjs.
// Note: setup.mjs XML-escapes all injected values before writing (via xmlEscape()),
// so raw `<` / `>` / `&` never appear in plist <string> bodies — the [^<]* regex below is safe.
const PLIST_KV_RE = /<key>([^<]+)<\/key>\s*<string>([^<]*)<\/string>/g;
export function parsePlistEnv(plistContent) {
+64 -1
View File
@@ -1,4 +1,4 @@
import { mkdirSync, writeFileSync, readFileSync, copyFileSync, existsSync, readdirSync, statSync } from "node:fs";
import { mkdirSync, writeFileSync, readFileSync, copyFileSync, existsSync, readdirSync, statSync, rmSync } from "node:fs";
import { join } from "node:path";
export function writeSnapshot({ homeDir, fromCommit, fromVersion, toVersion, extraFiles = [] }) {
@@ -50,3 +50,66 @@ export function listSnapshots(homeDir) {
.map(name => ({ name, path: join(root, name), mtime: statSync(join(root, name)).mtimeMs }))
.sort((a, b) => a.name.localeCompare(b.name));
}
/**
* Garbage-collect old upgrade snapshots.
*
* Retention rule (a snapshot is KEPT if any of these is true):
* - It is among the last `keepCount` snapshots (sorted oldestnewest)
* - Its timestamp is within `keepDays` of `now`
* - It is the single most-recent snapshot (always-keep safety net)
*
* @param {string} homeDir - Root containing ~/.ocp/
* @param {object} opts
* @param {number} [opts.keepCount=5] - Minimum count to keep
* @param {number} [opts.keepDays=30] - Keep snapshots newer than N days
* @param {boolean} [opts.dryRun=false] - If true, report plan but don't delete
* @param {Date} [opts.now=new Date()] - Override clock for testing
* @returns {{kept: Array, removed: Array, dryRun: boolean}}
*/
export function gcSnapshots(homeDir, opts = {}) {
const keepCount = opts.keepCount ?? 5;
const keepDays = opts.keepDays ?? 30;
const dryRun = !!opts.dryRun;
const now = opts.now || new Date();
const all = listSnapshots(homeDir); // sorted oldest→newest
if (all.length === 0) return { kept: [], removed: [], dryRun };
if (all.length === 1) return { kept: all, removed: [], dryRun }; // always keep most recent
const cutoffMs = now.getTime() - keepDays * 24 * 60 * 60 * 1000;
const lastN = new Set(all.slice(-keepCount).map(s => s.path));
const kept = [], removed = [];
for (let i = 0; i < all.length; i++) {
const s = all[i];
const isMostRecent = i === all.length - 1;
const isInLastN = lastN.has(s.path);
const isWithinDays = parseSnapshotTimestamp(s.name) >= cutoffMs;
if (isMostRecent || isInLastN || isWithinDays) {
kept.push(s);
} else {
removed.push(s);
}
}
if (!dryRun) {
for (const s of removed) {
try {
rmSync(s.path, { recursive: true, force: true });
} catch (err) {
console.error(`[snapshot] warn: could not remove ${s.path} (${err.code || err.message})`);
}
}
}
return { kept, removed, dryRun };
}
function parseSnapshotTimestamp(name) {
// upgrade-snapshot-2026-05-11T08:30:00Z → epoch ms
const m = name.match(/upgrade-snapshot-(.+)$/);
if (!m) return 0;
const t = Date.parse(m[1]);
return Number.isFinite(t) ? t : 0;
}
+2 -1
View File
@@ -9,6 +9,7 @@ import { readFileSync, writeFileSync, existsSync, copyFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { homedir } from "node:os";
import { DEFAULT_PORT, LOCAL_HOST, OPENAI_API_BASE } from "../lib/constants.mjs";
const __dirname = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = join(__dirname, "..");
@@ -70,7 +71,7 @@ if (!config.models.providers) config.models.providers = {};
if (!config.models.providers[PROVIDER_NAME]) {
// First-time registration
config.models.providers[PROVIDER_NAME] = {
baseUrl: "http://127.0.0.1:3456/v1",
baseUrl: `http://${LOCAL_HOST}:${DEFAULT_PORT}${OPENAI_API_BASE}`,
api: "openai-completions",
authHeader: false,
models: desiredModels,
+24 -3
View File
@@ -14,7 +14,8 @@ import { execSync } from "node:child_process";
import { homedir } from "node:os";
import { join } from "node:path";
import { existsSync, copyFileSync } from "node:fs";
import { writeSnapshot, listSnapshots, readSnapshot } from "./lib/snapshot.mjs";
import { writeSnapshot, listSnapshots, readSnapshot, gcSnapshots } from "./lib/snapshot.mjs";
import { DEFAULT_PORT } from "../lib/constants.mjs";
export async function runUpgrade(opts = {}) {
const dryRun = !!opts.dryRun;
@@ -134,7 +135,7 @@ async function runFullUpgrade({ doctor, opts }) {
// phase 6: post-flight (10s budget; skipped under mockExec)
if (!opts.mockExec) {
const port = process.env.CLAUDE_PROXY_PORT || "3478";
const port = process.env.CLAUDE_PROXY_PORT || String(DEFAULT_PORT);
let ok = false;
for (let i = 0; i < 10; i++) {
try {
@@ -154,6 +155,16 @@ async function runFullUpgrade({ doctor, opts }) {
phases.push({ name: "post-flight", status: "skipped-mock" });
}
// Auto-GC old snapshots after successful upgrade (best-effort, never throws).
try {
const gc = gcSnapshots(homedir(), { keepCount: 5, keepDays: 30 });
if (gc.removed.length > 0) {
console.error(`[gc] removed ${gc.removed.length} old snapshots; kept ${gc.kept.length}`);
}
} catch (e) {
console.error(`[gc] warn: snapshot GC failed: ${e.message}`);
}
return { path: "upgrade", executed: true, changed: true, snapshotPath, phases };
} catch (err) {
if (snapshotPath && !err.snapshotPath) {
@@ -193,6 +204,11 @@ async function runRollback(opts) {
const homeDir = opts.homeDir || homedir();
const snapshots = opts.mockSnapshots ?? listSnapshots(homeDir);
if (opts.gc) {
const result = gcSnapshots(homeDir, { dryRun: opts.dryRun });
return { path: opts.dryRun ? "rollback-gc-dry-run" : "rollback-gc", ...result };
}
if (opts.list) {
return { path: "rollback-list", snapshots };
}
@@ -295,6 +311,7 @@ if (_isMain()) {
const yes = args.includes("--yes");
const rollback = args.includes("--rollback");
const list = args.includes("--list");
const gc = args.includes("--gc");
const targetIdx = args.indexOf("--target");
const target = targetIdx !== -1 ? args[targetIdx + 1] : undefined;
// First non-flag positional after --rollback is the snapshot path
@@ -305,7 +322,7 @@ if (_isMain()) {
if (cand && !cand.startsWith("--")) snapshotPath = cand;
}
try {
const result = await runUpgrade({ dryRun, yes, rollback, list, snapshotPath, target });
const result = await runUpgrade({ dryRun, yes, rollback, list, gc, snapshotPath, target });
if (result.plan) for (const line of result.plan) console.log(line);
if (result.phases) for (const p of result.phases) console.log(`[${p.name}] ${p.status}${p.cmd ? `: ${p.cmd}` : ""}`);
if (result.steps) for (const s of result.steps) console.log(` ${s.status === "ok" ? "✓" : s.status === "skipped-mock" ? "·" : "✗"} ${s.cmd}`);
@@ -313,6 +330,10 @@ if (_isMain()) {
console.log(`Found ${result.snapshots.length} snapshots:`);
for (const s of result.snapshots) console.log(` ${s.name}`);
}
if (result.removed && result.kept) {
console.log(`Snapshots: kept ${result.kept.length}, ${result.dryRun ? "would remove" : "removed"} ${result.removed.length}`);
for (const s of result.removed) console.log(` - ${s.name}`);
}
process.exit(0);
} catch (e) {
console.error(`${e.message}`);
+710 -156
View File
File diff suppressed because it is too large Load Diff
+51 -19
View File
@@ -3,7 +3,8 @@
* OCP (Open Claude Proxy) setup
*
* Automatically configures OpenClaw to use Claude CLI as a model provider.
* Run: node setup.mjs [--port 3456] [--default-model opus|sonnet|haiku] [--dry-run]
* Run: node setup.mjs [--port N] [--default-model opus|sonnet|haiku] [--dry-run]
* (default port = DEFAULT_PORT from lib/constants.mjs)
*
* What it does:
* 1. Verifies claude CLI is installed and authenticated
@@ -18,6 +19,7 @@ import { execSync } from "node:child_process";
import { join, dirname } from "node:path";
import { homedir } from "node:os";
import { fileURLToPath } from "node:url";
import { DEFAULT_PORT } from "./lib/constants.mjs";
const __dirname = dirname(fileURLToPath(import.meta.url));
const HOME = homedir();
@@ -32,7 +34,7 @@ const opt = (name, fallback) => {
return i >= 0 && args[i + 1] ? args[i + 1] : fallback;
};
const PORT = parseInt(opt("port", "3456"), 10);
const PORT = parseInt(opt("port", String(DEFAULT_PORT)), 10);
const DEFAULT_MODEL = opt("default-model", "opus"); // opus | sonnet | haiku
const DRY_RUN = flag("dry-run");
const SKIP_START = flag("no-start");
@@ -63,6 +65,28 @@ const OCP_ADMIN_KEY_INJECT = process.env.OCP_ADMIN_KEY || null;
// PROXY_ANONYMOUS_KEY — same pattern
const PROXY_ANON_KEY_INJECT = process.env.PROXY_ANONYMOUS_KEY || null;
// ── Inject-value helpers ─────────────────────────────────────────────────
// Escape a value for safe inclusion in a plist <string>…</string> body.
function xmlEscape(v) {
return String(v).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
}
// Validate an injected service value: no control chars (a newline would inject a
// rogue systemd Environment= directive; other control chars corrupt the unit/plist).
// Spaces are allowed — filesystem paths (CLAUDE_BIN) may legitimately contain them.
function assertSafeInjectValue(name, v) {
if (v == null) return v;
if (/[\x00-\x1f]/.test(String(v))) {
console.error(`FATAL: ${name} contains a newline or control character — refusing to write it into the service unit.`);
process.exit(1);
}
return v;
}
// Validate all three INJECT values before they are written into any service unit.
assertSafeInjectValue("CLAUDE_BIN", CLAUDE_BIN_INJECT);
assertSafeInjectValue("OCP_ADMIN_KEY", OCP_ADMIN_KEY_INJECT);
assertSafeInjectValue("PROXY_ANONYMOUS_KEY", PROXY_ANON_KEY_INJECT);
// ── Models: derived from models.json (single source of truth) ──────────
const modelsConfig = JSON.parse(readFileSync(join(__dirname, "models.json"), "utf-8"));
@@ -117,18 +141,26 @@ try {
}
// Check claude auth (quick test)
try {
const out = execSync('claude -p --output-format text --no-session-persistence -- "ping"', {
encoding: "utf-8",
timeout: 30000,
env: { ...process.env, CLAUDECODE: undefined, ANTHROPIC_API_KEY: undefined, ANTHROPIC_BASE_URL: undefined, ANTHROPIC_AUTH_TOKEN: undefined },
}).trim();
if (out.length > 0) {
log(`Claude CLI authenticated (test response: "${out.slice(0, 40)}...")`);
// NOTE: This probe uses `claude -p` (sdk-cli spawn). After the 2026-06-15 Anthropic billing
// split, every `claude -p` call draws from the Agent SDK credit pool rather than the
// Pro/Max subscription. Re-running setup after 6/15 will consume one metered credit.
// Set OCP_SKIP_AUTH_TEST=1 to skip this probe (auth is still validated at first real request).
if (process.env.OCP_SKIP_AUTH_TEST === "1") {
warn("OCP_SKIP_AUTH_TEST=1 — skipping claude auth probe (will be validated at first request).");
} else {
try {
const out = execSync('claude -p --output-format text --no-session-persistence -- "ping"', {
encoding: "utf-8",
timeout: 30000,
env: { ...process.env, CLAUDECODE: undefined, ANTHROPIC_API_KEY: undefined, ANTHROPIC_BASE_URL: undefined, ANTHROPIC_AUTH_TOKEN: undefined },
}).trim();
if (out.length > 0) {
log(`Claude CLI authenticated (test response: "${out.slice(0, 40)}...")`);
}
} catch (e) {
warn(`Claude CLI auth test failed: ${e.message.slice(0, 100)}`);
warn("Make sure you're logged in: claude login");
}
} catch (e) {
warn(`Claude CLI auth test failed: ${e.message.slice(0, 100)}`);
warn("Make sure you're logged in: claude login");
}
// Check openclaw config (optional — OCP runs standalone without OpenClaw)
@@ -401,17 +433,17 @@ if (!DRY_RUN) {
<key>EnvironmentVariables</key>
<dict>
<key>CLAUDE_PROXY_PORT</key>
<string>${PORT}</string>
<string>${xmlEscape(PORT)}</string>
<key>CLAUDE_BIND</key>
<string>${BIND_ADDRESS}</string>
<string>${xmlEscape(BIND_ADDRESS)}</string>
<key>CLAUDE_AUTH_MODE</key>
<string>${AUTH_MODE_CONFIG}</string>${CLAUDE_BIN_INJECT ? `
<string>${xmlEscape(AUTH_MODE_CONFIG)}</string>${CLAUDE_BIN_INJECT ? `
<key>CLAUDE_BIN</key>
<string>${CLAUDE_BIN_INJECT}</string>` : ""}${OCP_ADMIN_KEY_INJECT ? `
<string>${xmlEscape(CLAUDE_BIN_INJECT)}</string>` : ""}${OCP_ADMIN_KEY_INJECT ? `
<key>OCP_ADMIN_KEY</key>
<string>${OCP_ADMIN_KEY_INJECT}</string>` : ""}${PROXY_ANON_KEY_INJECT ? `
<string>${xmlEscape(OCP_ADMIN_KEY_INJECT)}</string>` : ""}${PROXY_ANON_KEY_INJECT ? `
<key>PROXY_ANONYMOUS_KEY</key>
<string>${PROXY_ANON_KEY_INJECT}</string>` : ""}
<string>${xmlEscape(PROXY_ANON_KEY_INJECT)}</string>` : ""}
</dict>
<key>RunAtLoad</key>
<true/>
+1538 -2
View File
File diff suppressed because it is too large Load Diff