Compare commits

...
Author SHA1 Message Date
taodeng 90797864d5 fix(server): re-resolve -p spawn OAuth token per-spawn, expiry-aware (③ regression)
The FIX-③ spawn-home isolation memoized the OAuth token at startup. The macOS
keychain access token rotates (~hourly, refreshed by the operator's real claude),
so the startup snapshot went stale and every isolated -p spawn returned upstream
401 'Invalid authentication credentials' — a ~31h Mac-mini outage (PI231/oracle use
static long-lived env tokens, unaffected).

Fix: getSpawnHomeMode() now caches only the isolation DECISION; the token is
re-resolved FRESH per spawn via resolveSpawnToken(), which also returns null when a
known expiry has passed (5-min buffer) so the caller falls back to real HOME — where
the spawned claude refreshes the credential natively and self-heals. OCP still never
refreshes the token itself (a refresh-token grant would consume the single-use token
and log out the operator's real claude — issue #112). Env-token hosts carry no
expiresAt and are never expiry-gated. Infra/process change; no cli.js surface, no new
endpoint/header. 238 tests pass; live-verified sonnet 200 on a temp instance.
2026-06-26 20:33:59 +10:00
38da104b97 chore(release): v3.21.0 — TUI cleanup + client-tools/ToS docs + promotion plan (#145)
* refactor(tui): dead-code / footgun cleanup (A1/A2/A3)

ALIGNMENT.md Rule 2: infra-only, no new cli.js wire behavior.
No protocol, no endpoint, no credential changes.

A1 (session.mjs): delete resolveTuiEntrypointEnv() and its call site
+ the redundant env-strip block around the spawnSync call. The function
mutated a {env} object passed to spawnSync (tmux itself), but tmux does
NOT forward that env to the pane; the pane's claude gets its env ONLY
from the `env` prefix string built inside buildTuiCmd (verified live
2026-06-01). The spawnSync {env} is intentionally minimal now; only
env.HOME is retained (tmux binary reads it). All claude-specific vars
go via the buildTuiCmd prefix string, unchanged. Tests for the now-
deleted function removed; test count drops by 7 (expected).

A2 (transcript.mjs): delete encodeCwd() and transcriptPath() exports.
Production resolves transcripts exclusively via findTranscriptPath()
(glob by session-id); these two helpers carried a fragile path-encoding
rule used only by their own tests. grep confirms zero non-test importers.
Added a TODO comment near findTranscriptPath() noting a CI fixture-
contract test would make claude-schema drift fail loudly. Tests removed;
count drops by 2.

A3 (session.mjs + README): remove the CLAUDE_SKIP_PERMISSIONS branch
that pushed --dangerously-skip-permissions when OCP_TUI_FULL_TOOLS=1.
OCP_TUI_FULL_TOOLS=1 now always takes the --allowedTools path. Rationale:
claude v2.1.x shows an interactive bypass-acceptance screen that a
headless tmux TUI cannot answer — bricks the turn (tui_paste_not_landed
/ wallclock cap), not recoverable without a human. The working path is
--allowedTools + scratch-home settings.json additionalDirectories.
README OCP_TUI_FULL_TOOLS row updated to document the removal and the
correct alternative; CLAUDE_SKIP_PERMISSIONS row for the -p path is
unchanged (still used in server.mjs). Test updated: skip-permissions
case replaced with an assertion that --dangerously-skip-permissions is
absent from the full-tools command.

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

* docs: client-tools boundary, ToS honesty, promotion plan (B1/B2/B3)

ALIGNMENT.md Rule 2: docs-only, no new cli.js wire behavior.
No protocol, endpoint, or credential changes.

B1 (README): add 'Client-tools boundary' subsection under 'How It
Works'. Documents that OCP is a text-prompt bridge only — it does not
pass OpenAI tools/functions or Anthropic tool_use blocks to the client.
Clients receive assistant TEXT only; client-local tool execution is
not supported by design (bypassing cli.js = out of scope per
ALIGNMENT.md).

B2 (README): two updates to 'Why OCP?' and the LAN-sharing section.
(a) New bullet: OCP drives the official claude CLI as-is — no OAuth
token extraction, no binary patching, no protocol invention — so
traffic looks like genuine Claude Code (cc_entrypoint=cli).
(b) LAN-sharing paragraph strengthened: pooling one Claude subscription
across multiple distinct people may violate Anthropic's Consumer ToS
and risk account suspension by the abuse classifier. The defensible
framing is 'one person, your own devices'; friends/team sharing is
not. Replaces the softer 'account terms are your call' language.
Feature and auth-mode docs are unchanged.

B3 (docs/PROMOTION.md): new promotion strategy doc. Covers: goal
(polish + low-key OSS visibility, NOT growth-hacking given the live
ToS/billing risk), pre-requisites (stability first), honest ToS
disclosure requirement, items explicitly skipped (multi-backend
routing, gateway model-discovery — delegated to OLP; raw API
passthrough — ALIGNMENT.md scope), TUI toggle as billing-split
insurance, and low-key visibility actions. Framed as a recommendation
for the maintainer to review, not a committed plan.

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

* chore(release): v3.21.0 — TUI cleanup + docs honesty + promotion plan

ALIGNMENT.md Rule 2: release prep; no new cli.js wire behavior.

Bump version 3.20.1 → 3.21.0. CHANGELOG entry covers:
- A1/A2/A3 TUI dead-code removals (inert entrypoint-env path,
  test-only transcript helpers, headless-unusable skip-permissions)
- B1/B2/B3 docs (client-tools boundary, ToS honesty, Why-OCP posture,
  promotion plan)
- Previously-shipped v3.20.x items documented for completeness:
  spawn-home isolation, bounded concurrency queue + 429, ocp restart,
  ocp-plugin OpenClaw compat.

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:26:57 +10:00
dtzp555-maxGitHubtaodengClaude <claude-opus> <noreply@anthropic.com>
3bd19956ff docs(readme): honest ToS framing for LAN sharing (#136) (#143)
External user (#136) reported the LAN setup prompt instructs Claude to "share
my Claude Pro/Max subscription with family" — which Claude Code refuses
(Anthropic Usage Policy: per-user accounts). Reword the LAN setup prompt to
"my own devices ... reach my subscription via a local OpenAI-compatible
endpoint" (no longer triggers the refusal), and add an honest "account terms
are your call" note to the existing sharing-limits section. Keeps the truthful
origin story and the existing honest security-limits framing.

Docs only. No server.mjs / Class A surface / models.json touched.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude <claude-opus> <noreply@anthropic.com>
2026-06-13 17:04:07 +10:00
dtzp555-maxGitHubtaodengClaude <claude-opus> <noreply@anthropic.com>
fe615cb0d3 chore(release): v3.20.1 — TUI credential-isolated auth (ends recurring 401) + defunct-session reaping (#141) (#142)
Bump 3.20.0 → 3.20.1 + CHANGELOG. Ships the already-merged, twice-reviewed #141
(credential-isolated env-token home + zombie reaping). README/docs updated in #141.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude <claude-opus> <noreply@anthropic.com>
2026-06-13 16:55:59 +10:00
dtzp555-maxGitHubtaodengClaude <claude-opus> <noreply@anthropic.com>
60930f0ba4 fix(tui): credential-isolated env-token auth (ends recurring 401) + reap defunct sessions (#141)
* fix(tui): pass CLAUDE_CODE_OAUTH_TOKEN to spawned claude + reap defunct sessions

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude <claude-opus> <noreply@anthropic.com>
2026-06-13 16:54:32 +10:00
dtzp555-maxGitHubtaodengClaude <claude-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
18 changed files with 2400 additions and 221 deletions
+73
View File
@@ -1,5 +1,78 @@
# Changelog # Changelog
## v3.21.0 — 2026-06-25
Cleanup + docs release: TUI dead-code removal, docs honesty, and release prep. No new `cli.js` wire behavior; the default path (`CLAUDE_TUI_MODE` unset) is byte-for-byte unchanged.
### TUI dead-code / footgun cleanup
- **A1 — removed inert entrypoint-env path** (`lib/tui/session.mjs`): deleted `resolveTuiEntrypointEnv()` and the redundant env-strip block in `runTuiTurn`. The `{env}` object passed to `spawnSync` (tmux itself) was the wrong target — tmux does NOT forward the spawning process's environment to the pane; the pane's `claude` gets its env exclusively from the `env` prefix string built inside `buildTuiCmd` (verified live 2026-06-01). The spawnSync env is now intentionally minimal (`HOME` only). Behavior is unchanged: `buildTuiCmd` already handled all claude-specific env vars via its prefix string.
- **A2 — removed test-only transcript helpers** (`lib/tui/transcript.mjs`): deleted `encodeCwd()` and `transcriptPath()` exports and the tests that pinned them. Production resolves transcripts exclusively via `findTranscriptPath()` (glob by session-id), which is immune to the exact path-encoding rule. No non-test importers existed (grep confirms). A `// TODO` comment near `findTranscriptPath()` notes that a CI fixture-contract test would make claude-schema drift fail loudly.
- **A3 — removed headless-unusable `--dangerously-skip-permissions` branch** (`lib/tui/session.mjs` + `README.md`): `OCP_TUI_FULL_TOOLS=1` now always takes the `--allowedTools` path. The removed branch pushed `--dangerously-skip-permissions` when `CLAUDE_SKIP_PERMISSIONS=true`; on claude v2.1.x this triggers an interactive bypass-acceptance screen that a headless tmux pane cannot answer → the turn hangs to the wallclock cap and bricks the pane. The working path is `--allowedTools` + scratch-home `settings.json` `additionalDirectories`. `CLAUDE_SKIP_PERMISSIONS` for the `-p` path is unchanged (still used in `server.mjs`).
### Docs
- **Client-tools boundary** (README `§ How It Works`): OCP is a text-prompt bridge only — it does not pass OpenAI `tools`/`functions` or Anthropic `tool_use` blocks to the client. Clients receive assistant TEXT only; client-local tool execution is not supported by design (bypassing `cli.js` = out of scope per `ALIGNMENT.md`).
- **ToS honesty** (README `§ Deployment model & security`): pooling one Claude subscription across multiple distinct people may violate Anthropic's Consumer ToS and risk account suspension by the abuse classifier. The defensible framing is "one person, your own devices" — friends/team sharing is not. The prior language ("account terms are your call") was accurate but understated the risk.
- **"Why OCP" posture** (README `§ Why OCP?`): new bullet making explicit that OCP drives the official `claude` CLI as-is — no OAuth token extraction, no binary patching, no protocol invention — so traffic looks like genuine Claude Code (`cc_entrypoint=cli`).
- **Promotion plan** (`docs/PROMOTION.md`): "stable & visible" strategy covering goal (polish + low-key OSS visibility, NOT growth-hacking given the live ToS/billing risk), pre-requisites (stability first), honest ToS disclosure requirement, items explicitly skipped (multi-backend routing → OLP; gateway model-discovery; raw API passthrough → ALIGNMENT.md scope), TUI toggle as billing-split insurance, and low-key visibility actions. Framed as a recommendation for the maintainer to review, not a committed plan.
### Previously shipped (v3.20.x) — documented here for completeness
- **Default `-p` spawn-home isolation** (v3.20.0 / PR-A): per-request `claude` spawns run in a credential-free minimal scratch HOME (`$HOME/.ocp/spawn-home`, no `.credentials.json`/`settings.json`/plugins) with a neutral cwd and the env token, cutting per-request latency (measured ~1028s → ~37s). Kill-switch: `OCP_SPAWN_REAL_HOME=1`. Active mode shown at startup and on `/health.spawn`.
- **Bounded concurrency wait-queue** (v3.20.0 / PR-B): excess `-p` requests queue (up to `CLAUDE_MAX_QUEUE`, default 16) instead of being rejected; a full queue returns `HTTP 429` + `Retry-After` (not an opaque 500). New env vars: `CLAUDE_MAX_QUEUE`, `CLAUDE_QUEUE_RETRY_AFTER`. Surfaced on `/health.concurrency` + `/health.stats.queueRejections`.
- **`ocp restart`** macOS `bootout`+`bootstrap` (v3.20.0 / PR-B): safe restart command that forces launchd to re-read the plist (unlike `kickstart -k` which reuses the cached env).
- **`/ocp` plugin OpenClaw-2026.5.27 compat** (v3.20.0 / PR-C): gateway plugin updated for the current OpenClaw API version.
## v3.20.1 — 2026-06-13
TUI-mode auth hardening: fixes the recurring `Please run /login · API Error: 401` (the PI231 incident) and reaps leaked defunct `claude` sessions. ([#141](https://github.com/dtzp555-max/ocp/pull/141))
### Fixed
- **TUI 401 / credential corruption (#141)** — interactive `claude` prefers `~/.claude/.credentials.json` over the `CLAUDE_CODE_OAUTH_TOKEN` env var (unlike `-p` mode, where the env token wins). OCP TUI's per-request spawn + `kill-session` cycle raced claude's single-use refresh-token rotation, corrupting the refresh token to an empty string → permanent 401 that `claude /login` couldn't fix (each new spawn re-corrupted it). This bit Linux/file-based hosts specifically (macOS reads credentials from the Keychain, so Mac mini was immune). **Fix:** when `CLAUDE_CODE_OAUTH_TOKEN` is set, the TUI claude now runs in a credential-free scratch HOME (`<HOME>/.ocp-tui/home`, overridable by `OCP_TUI_HOME`) seeded with onboarding + cwd-trust but **no `.credentials.json`**, so the env token is the only credential and claude never runs the refresh path. Recurrence-proof — a later `claude login` can no longer break TUI. Also: `buildTuiCmd` passes `CLAUDE_CODE_OAUTH_TOKEN` to the spawn, and `reapStaleTuiSessions` reaps defunct `claude` sessions (tmux-server-owned zombies) via `kill-server` when no foreign session remains, plus a 15-min idle-gated periodic reap. When the env token is unset, behaviour is byte-for-byte unchanged (real-home + credentials.json). Two independent fresh-context reviewers (Iron Rule 10) + a live PI231 portability test (works with a corrupt credentials.json present). Authorized by the ADR 0007 PR-D amendment (Class B).
### Environment variables
- `CLAUDE_CODE_OAUTH_TOKEN` — when set on a TUI host, TUI authenticates via this long-lived token in a credential-isolated home (recommended; immune to credentials.json corruption).
- `OCP_TUI_HOME` — overrides the TUI scratch home; if you previously pointed it at your real home, unset it to get the credential-isolated default.
## 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 ## 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. 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.
+106 -8
View File
@@ -31,6 +31,7 @@ There are several Claude proxy projects. OCP picks a specific lane: **align tigh
- **SSE heartbeat for long reasoning** ([v3.12.0](https://github.com/dtzp555-max/ocp/releases/tag/v3.12.0), opt-in). If you've ever watched your IDE die at the 60s idle mark during a long Claude tool-use pause — that's nginx/Cloudflare default behavior. OCP emits an SSE comment frame to keep the connection alive without polluting the response. ([PR #49](https://github.com/dtzp555-max/ocp/pull/49)) - **SSE heartbeat for long reasoning** ([v3.12.0](https://github.com/dtzp555-max/ocp/releases/tag/v3.12.0), opt-in). If you've ever watched your IDE die at the 60s idle mark during a long Claude tool-use pause — that's nginx/Cloudflare default behavior. OCP emits an SSE comment frame to keep the connection alive without polluting the response. ([PR #49](https://github.com/dtzp555-max/ocp/pull/49))
- **`cli.js` alignment + CI guardrail.** LLM-assisted code drifts easily — it's tempting to invent plausible-looking endpoints that `cli.js` doesn't actually use. [`ALIGNMENT.md`](./ALIGNMENT.md) is binding: every endpoint OCP exposes must cite a `cli.js` line. The [`alignment.yml`](./.github/workflows/alignment.yml) CI workflow blocks PRs that introduce known-hallucinated tokens. The payoff is boring: your setup keeps working when `cli.js` ships its next minor. - **`cli.js` alignment + CI guardrail.** LLM-assisted code drifts easily — it's tempting to invent plausible-looking endpoints that `cli.js` doesn't actually use. [`ALIGNMENT.md`](./ALIGNMENT.md) is binding: every endpoint OCP exposes must cite a `cli.js` line. The [`alignment.yml`](./.github/workflows/alignment.yml) CI workflow blocks PRs that introduce known-hallucinated tokens. The payoff is boring: your setup keeps working when `cli.js` ships its next minor.
- **`models.json` single source of truth** (v3.11.0). Adding a model is one file edit; both `/v1/models` and the OpenClaw bootstrap derive from it. ([PR #30](https://github.com/dtzp555-max/ocp/pull/30)) - **`models.json` single source of truth** (v3.11.0). Adding a model is one file edit; both `/v1/models` and the OpenClaw bootstrap derive from it. ([PR #30](https://github.com/dtzp555-max/ocp/pull/30))
- **Drives the official CLI as-is, no binary patching.** OCP spawns the official `claude` CLI (or hosts it in an interactive tmux pane for TUI mode) — it does not extract OAuth tokens from memory, patch the binary, or invent protocol extensions. Traffic therefore looks like genuine Claude Code to Anthropic's classifiers (`cc_entrypoint=cli`). See `ALIGNMENT.md` for why this constraint is load-bearing.
### Comparison ### Comparison
@@ -128,11 +129,12 @@ Before each step, tell me what you'll run and wait for confirmation.
On any error, diagnose first — don't auto-retry. On any error, diagnose first — don't auto-retry.
``` ```
**LAN mode (server)** — install OCP as a server so your family or multiple devices can share it: **LAN mode (server)** — install OCP as a server so your own devices on the LAN can reach it (Claude Pro/Max are per-user accounts — review Anthropic's Usage Policy before extending access to other people):
```text ```text
I want to install OCP on this device as a LAN server so my family and other I want to install OCP on this device as a LAN server so my own devices on the
devices on the network can share my Claude Pro/Max subscription. network can reach my Claude Pro/Max subscription through a local
OpenAI-compatible endpoint.
Please follow https://github.com/dtzp555-max/ocp/blob/main/README.md Please follow https://github.com/dtzp555-max/ocp/blob/main/README.md
"Server Setup" → "LAN mode" path: "Server Setup" → "LAN mode" path:
@@ -422,6 +424,7 @@ ocp keys revoke son-ipad # Revoke a key
- The per-key modes (`shared` / `multi`) give per-key **usage tracking, quotas, and cache separation** — useful for seeing who used what and capping budgets. - 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.** - 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. - 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.
- **Account terms and ToS — read before sharing with others.** Claude Pro/Max are *per-user* accounts. Pooling a single subscription across **multiple distinct people** may violate Anthropic's Consumer Terms of Service and risk account suspension by the abuse classifier. The defensible framing is **"one person, your own devices"** — sharing with friends or a team is not. OCP does not change your account terms, and whether any particular sharing setup complies with the ToS is the account holder's responsibility. Review Anthropic's Usage Policy before extending access to other people.
**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).) **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).)
@@ -698,6 +701,14 @@ Your IDE → OCP (localhost:3456) → claude --output-format stream-json CLI →
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. 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.
### Client-tools boundary
OCP is a **text-prompt bridge** to the official `claude` CLI. It does **not** pass through OpenAI `tools`/`functions` payloads or Anthropic `tool_use` blocks to the client. Clients (Cline, Cursor, OpenClaw, etc.) pointed at OCP receive **assistant TEXT only** — they never get `tool_calls` to execute locally.
Any tool use happens server-side, under the `--allowedTools` set configured on the OCP host. In default mode (no `CLAUDE_NO_CONTEXT`), the `claude` CLI's own built-in tools are available to the model; in TUI mode, the operator controls the tool surface via `OCP_TUI_FULL_TOOLS`. Either way, the tools run under the operator's credentials on the server, and the client sees only the final text output.
**Client-local tool execution is not supported by design.** Supporting it would require bypassing the `claude` CLI to call the raw Anthropic API directly — that is a different product, and is out of scope per `ALIGNMENT.md` (every OCP endpoint must correspond to something `cli.js` actually does).
## Available Models ## Available Models
| Model ID | Notes | | Model ID | Notes |
@@ -725,7 +736,7 @@ The canonical list lives in [`models.json`](./models.json) — the single source
|----------|--------|-------------| |----------|--------|-------------|
| `/v1/models` | GET | List available models | | `/v1/models` | GET | List available models |
| `/v1/chat/completions` | POST | Chat completion (streaming + non-streaming) | | `/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 | | `/usage` | GET | Plan usage limits + per-model stats |
| `/status` | GET | Combined overview (usage + health) | | `/status` | GET | Combined overview (usage + health) |
| `/settings` | GET/PATCH | View or update settings at runtime | | `/settings` | GET/PATCH | View or update settings at runtime |
@@ -842,6 +853,29 @@ ocp restart
openclaw gateway restart openclaw gateway restart
``` ```
### Env var change (e.g. `CLAUDE_BIND`, `CLAUDE_CODE_OAUTH_TOKEN`) doesn't take effect after restart
On **macOS**, `ocp restart` does a full `launchctl bootout` + `bootstrap` of the agent, which **re-reads the plist `EnvironmentVariables`** — so an env change you made (in `~/Library/LaunchAgents/dev.ocp.proxy.plist`) actually takes effect:
```bash
ocp restart
```
This is deliberate: the older `launchctl kickstart -k` only re-execs the process and **reuses launchd's cached environment**, so plist env edits would be silently ignored. If you ever restart the agent by hand, use bootout+bootstrap, not `kickstart -k`:
```bash
launchctl bootout gui/$(id -u)/dev.ocp.proxy 2>/dev/null
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/dev.ocp.proxy.plist
```
Verify the new value reached the running process:
```bash
ps -E -p "$(launchctl print gui/$(id -u)/dev.ocp.proxy 2>/dev/null | awk '/pid =/{print $3}')" | tr ' ' '\n' | grep CLAUDE_
```
On **Linux**, `systemctl --user restart` already re-reads the unit's `EnvironmentFile`, so no special handling is needed.
### Usage shows "unknown" ### Usage shows "unknown"
Usually caused by an expired Claude CLI session. Fix: Usually caused by an expired Claude CLI session. Fix:
@@ -871,6 +905,24 @@ openclaw gateway restart # so OpenClaw re-reads the config
Future `ocp update` invocations sync automatically. 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 (two layers):** interactive `claude` **prefers `~/.claude/.credentials.json` over the `CLAUDE_CODE_OAUTH_TOKEN` env var** (this is *unlike* the `-p` path, where the env token wins). So (a) a stale/corrupt `credentials.json` **shadows** the env token — passing the token is not enough on its own; and (b) when claude does use `credentials.json`, its 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. Proven live on PI231: *env token passed + broken `credentials.json` present → 401; env token passed + `credentials.json` moved aside → works.*
**Fix:** set `CLAUDE_CODE_OAUTH_TOKEN` on the OCP host and leave `OCP_TUI_HOME` **unset**. OCP then runs the TUI `claude` in a **credential-isolated home** (`$HOME/.ocp-tui/home`) that has **no `credentials.json`** at all, so the env token is the only credential (authoritative — nothing shadows it) and claude never runs the refresh path (so the single-use token can't be corrupted). Then restart — on systemd `daemon-reload`, on launchd `bootout`+`bootstrap`; `kickstart -k` does **not** reload env. Verify the env reached the process and the boot log shows the isolated home:
```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
# Boot log should read: TUI-mode: ON home=$HOME/.ocp-tui/home ... auth=env-token (credential-isolated home — no credentials.json)
```
> If you previously set `OCP_TUI_HOME` to the real home (or any home that contains a `credentials.json`), **unset it** so the credential-isolated default takes effect — otherwise the shadowing `credentials.json` remains in play.
See [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) and ADR 0007 PR-C / PR-D amendments.
## Environment Variables ## Environment Variables
| Variable | Default | Description | | Variable | Default | Description |
@@ -883,7 +935,9 @@ Future `ocp update` invocations sync automatically.
| `CLAUDE_BIN` | *(auto-detect)* | Path to claude binary | | `CLAUDE_BIN` | *(auto-detect)* | Path to claude binary |
| `CLAUDE_TIMEOUT` | `600000` | Request timeout (ms, default: 10 min) | | `CLAUDE_TIMEOUT` | `600000` | Request timeout (ms, default: 10 min) |
| `CLAUDE_HEARTBEAT_INTERVAL` | `0` | Streaming SSE keepalive interval (ms). `0` = disabled. See "Streaming heartbeat" section. | | `CLAUDE_HEARTBEAT_INTERVAL` | `0` | Streaming SSE keepalive interval (ms). `0` = disabled. See "Streaming heartbeat" section. |
| `CLAUDE_MAX_CONCURRENT` | `8` | Max concurrent claude processes | | `CLAUDE_MAX_CONCURRENT` | `8` | Max concurrent claude processes (`-p`/stream-json path) |
| `CLAUDE_MAX_QUEUE` | `16` | Max requests **waiting** for a `-p` concurrency slot. Beyond `CLAUDE_MAX_CONCURRENT`, requests queue (up to this cap) instead of being rejected; when the queue is **also** full, the request gets `HTTP 429` + `Retry-After` (not an opaque 500). Surfaced on `/health.concurrency` + `/health.stats.queueRejections`. |
| `CLAUDE_QUEUE_RETRY_AFTER` | `5` | Seconds advertised in the `Retry-After` header on a `-p` concurrency-overflow `429`. |
| `CLAUDE_MAX_PROMPT_CHARS` | `150000` | Prompt truncation limit (chars) | | `CLAUDE_MAX_PROMPT_CHARS` | `150000` | Prompt truncation limit (chars) |
| `CLAUDE_SESSION_TTL` | `3600000` | Session expiry (ms, default: 1 hour) | | `CLAUDE_SESSION_TTL` | `3600000` | Session expiry (ms, default: 1 hour) |
| `CLAUDE_CACHE_TTL` | `0` | Response cache TTL (ms, 0 = disabled). Set to e.g. `300000` for 5-min cache | | `CLAUDE_CACHE_TTL` | `0` | Response cache TTL (ms, 0 = disabled). Set to e.g. `300000` for 5-min cache |
@@ -894,10 +948,15 @@ Future `ocp update` invocations sync automatically.
| `PROXY_ANONYMOUS_KEY` | *(unset)* | Well-known anonymous key allowlist (multi mode). When set, this exact string bypasses `validateKey()` and grants public access. Exposed via `/health.anonymousKey` only to localhost, or to all callers when `PROXY_ADVERTISE_ANON_KEY=1`. See [Anonymous Access](#anonymous-access-optional). | | `PROXY_ANONYMOUS_KEY` | *(unset)* | Well-known anonymous key allowlist (multi mode). When set, this exact string bypasses `validateKey()` and grants public access. Exposed via `/health.anonymousKey` only to localhost, or to all callers when `PROXY_ADVERTISE_ANON_KEY=1`. See [Anonymous Access](#anonymous-access-optional). |
| `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. | | `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_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 for the `-p` path). **Recommended for TUI-mode hosts:** when set (and `OCP_TUI_HOME` unset), OCP runs the interactive `claude` in a **credential-isolated home** (`$HOME/.ocp-tui/home`, no `credentials.json`) so this long-lived token is the only credential and is authoritative — interactive `claude` otherwise *prefers* `~/.claude/.credentials.json` over the env var, so a stale one shadows the token and its single-use refresh token gets corrupted by the spawn/teardown cycle (the permanent `Please run /login` 401 — see [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) and ADR 0007 PR-D). 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. |
| `OCP_SPAWN_REAL_HOME` | *(unset)* | Kill-switch for the default `-p`/stream-json **spawn-home isolation** (latency fix). When unset and an OAuth token is resolvable, OCP runs the per-request `claude` spawn in a **credential-free minimal scratch home** (`$HOME/.ocp/spawn-home`, no `.credentials.json`/`settings.json`/plugins) with a neutral cwd and the env token — so it loads none of the operator's heavy global `~/.claude` (plugins/skills/hooks) or the project `CLAUDE.md`, cutting per-request latency (measured ~1028s → ~37s). Set to `"1"` to force the legacy real-`HOME` spawn (no cwd override) even when a token exists. With **no** resolvable token, OCP falls back to the real `HOME` automatically (zero regression). Active mode is shown at startup and on `/health.spawn`. |
| `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. | | `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_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_HOME` | *(auto)* | (TUI-mode) `HOME` claude runs under. **When unset, OCP picks it for you:** if `CLAUDE_CODE_OAUTH_TOKEN` is set → a **credential-isolated** scratch home `$HOME/.ocp-tui/home` (no `credentials.json`, env-token auth — **recommended**); if no env token → the operator's real home (legacy shared `credentials.json`). Setting this to an **explicit** path overrides the auto-default. The credential handling at that path still follows the env token: **with** the env token it is credential-free (env-token auth, no `credentials.json` written); **without** the env token (and the path ≠ real home) it uses the legacy symlinked-credentials scratch mode, which carries the credential-fork caveat — see ADR 0007. |
| `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_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`, read from `CLAUDE_ALLOWED_TOOLS` / `CLAUDE_MCP_CONFIG`) — 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.) Note: `--dangerously-skip-permissions` / `CLAUDE_SKIP_PERMISSIONS` is **not** supported for TUI — claude v2.1.x shows an interactive bypass-acceptance screen in headless tmux that cannot be answered, bricking the pane. Use scratch-home `settings.json` `additionalDirectories` instead. See [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) and ADR 0007. |
### Streaming heartbeat ### Streaming heartbeat
@@ -943,17 +1002,27 @@ mkdir -p ~/.ocp-tui/work # one-time scratch cwd setup
# Enable # Enable
export CLAUDE_TUI_MODE=true export CLAUDE_TUI_MODE=true
# STRONGLY RECOMMENDED on a TUI host — authenticate via the long-lived OAuth token.
# With this set (and OCP_TUI_HOME left UNSET), OCP runs the interactive claude in a
# credential-isolated home ($HOME/.ocp-tui/home, no credentials.json), so the env token
# is the only credential and is authoritative. This both stops a stale credentials.json
# from shadowing the token AND ends the refresh-token corruption that caused a permanent
# "Please run /login" 401 (no credentials file → claude never runs the refresh path).
# See the auth note below + ADR 0007 PR-D.
export CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-...
# Optionally tune: # Optionally tune:
export CLAUDE_TUI_WALLCLOCK_MS=180000 # 3 min cap for long Opus turns 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_CWD=$HOME/.ocp-tui/work # default; override if needed
export OCP_TUI_ENTRYPOINT=cli # default; use 'auto' to observe TTY-derived value export OCP_TUI_ENTRYPOINT=cli # default; use 'auto' to observe TTY-derived value
# Do NOT set OCP_TUI_HOME for the recommended setup — leaving it unset is what enables
# the credential-isolated home. Set it only to opt into the legacy symlinked-creds mode.
``` ```
Then restart OCP. At boot you will see: Then restart OCP. At boot you will see (with the env token set, isolated home auto-selected):
``` ```
⚠️ TUI-mode ON — single-user only; do NOT enable on a multi-user OCP ... ⚠️ 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 TUI-mode: ON home=/home/user/.ocp-tui/home cwd=/home/user/.ocp-tui/work auth=env-token (credential-isolated home — no credentials.json) wallclock=120000ms maxConcurrent=2
``` ```
### What changes / what doesn't ### What changes / what doesn't
@@ -961,7 +1030,29 @@ Then restart OCP. At boot you will see:
- **Callers see no API change.** The response is a normal OpenAI completion object or chunked SSE — identical wire format. - **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. - **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. - **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` in a credential-isolated home (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. But passing the token is **not enough on its own**: interactive `claude` *prefers* `~/.claude/.credentials.json` over the env var (unlike the `-p` path), so a stale `credentials.json` would shadow the token. With the env token set and `OCP_TUI_HOME` unset, OCP therefore runs claude in a **credential-isolated home** (`$HOME/.ocp-tui/home`) that has **no `credentials.json`** — so the env token is the only credential and is authoritative, and claude never runs the token-refresh path (so the single-use refresh token can't be corrupted by the spawn/teardown cycle). On a long-running host the credentials.json path produced a permanent `Please run /login · API Error: 401` that re-login could not fix (the next spawn re-corrupted it); the isolated home ends that at the root. Transcripts land under the same isolated home, so the answer-reader is unaffected. Without the env token, claude falls back to the real home's `credentials.json` (byte-for-byte the previous behaviour). (The token is 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 / PR-D amendments.
- **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. - **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 ### Kill-switch
@@ -972,6 +1063,13 @@ unset CLAUDE_TUI_MODE
The stream-json path is restored immediately. No other change is needed. 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 ### 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. 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.
+100
View File
@@ -0,0 +1,100 @@
# OCP Promotion Strategy — "Stable & Visible"
> **This document is a recommendation for the maintainer to review and adjust, not a committed plan.**
> It reflects the project's current posture (post-v3.21.0) and should be revisited whenever
> the Anthropic billing / ToS environment changes significantly.
---
## 1. Goal: Polish + Low-Key OSS Visibility
The goal is **stability and quiet discoverability**, not growth-hacking. OCP is a personal power tool
that has been open-sourced because others can benefit from it. The right audience finds it via GitHub
search, issue threads in related projects, and word of mouth — not viral posts.
**Explicitly avoid:**
- HN / Reddit front-page pushes, influencer outreach, or any campaign that would attract a large
influx of users before the ToS/billing situation has settled. Anthropic is actively tightening
billing and enforcement on subscription-sharing (the June-15 Agent-SDK billing split is
*paused*, not cancelled — and consumer-ToS enforcement on multi-person sharing is a live risk).
A high-traffic spotlight right now would draw scrutiny that a low-profile project avoids.
- Promising features that require bypassing the `claude` CLI (raw API calls, OAuth extraction, etc.)
— that would violate `ALIGNMENT.md` and the ToS simultaneously.
---
## 2. Pre-Requisite: Stability First
Do not promote until the house is in order:
- [x] The concurrency / latency perf fixes are shipped (v3.20.xv3.21.0).
- [x] Docs honesty is complete (client-tools boundary, ToS sharing disclosure, this doc).
- [ ] The June-15 Agent-SDK billing split is either confirmed cancelled or OCP has a confirmed
stable path (TUI toggle as insurance — see §5 below).
Promoting a project that has known rough edges in docs or stability only generates support burden
and negative first impressions.
---
## 3. Honest ToS Disclosure on Sharing
Any promotion materials must carry the same disclosure as `README.md § "Deployment model & security"`:
> Pooling a single Claude subscription across **multiple distinct people** may violate Anthropic's
> Consumer Terms of Service and risk account suspension. The defensible framing is "one person,
> your own devices". Friends/team sharing is not.
This framing should appear in any README badge, linked blog post, or issue comment that mentions
LAN sharing. It is not a disclaimer that discourages usage — it is honest positioning that protects
both the project and its users.
---
## 4. What to Explicitly Skip
These items are **not gaps in OCP** — they are deliberate stance decisions:
- **Multi-backend routing** (routing to OpenAI, Gemini, Llama, etc.) — that is the sibling [OLP
project](https://github.com/dtzp555-max/olp)'s role. OCP stays Claude-only by design.
- **Gateway model-discovery** (auto-detecting which models a remote server offers) — not needed
for OCP's single-provider, single-subscription model. `models.json` is the SPOT.
- **Raw Anthropic API passthrough** (bypassing the `claude` CLI) — out of scope per `ALIGNMENT.md`.
Do not add these to OCP roadmaps or respond to feature requests for them with "planned" — the
correct answer is "that's OLP territory" or "out of scope per ALIGNMENT.md".
---
## 5. TUI Toggle as Insurance
The `CLAUDE_TUI_MODE` opt-in is the primary mitigation if the June-15 billing split reactivates
and makes the default `-p` path draw from the metered Agent SDK credit pool.
Keep the TUI toggle:
- Functional and tested across the three deployment hosts.
- Documented in the README, including the security constraints (single-user only).
- Easily discoverable for users who get unexpectedly metered.
If the split reactivates, the recommended operator path is: set `CLAUDE_TUI_MODE=true` +
`CLAUDE_CODE_OAUTH_TOKEN` → credential-isolated scratch home → subscription pool. That path is
already shipped and documented.
---
## 6. Low-Key Visibility Actions (when §2 pre-requisites are met)
- Keep the GitHub README polished and honest — it is the primary landing page.
- Respond promptly to issues and PRs — the project's reputation is built on reliability, not
marketing.
- Add OCP to the `awesome-claude` / `awesome-llm-tools` lists if they exist and allow self-PRs
— low-effort, targeted, reaches the right audience.
- When related projects (Cline, OpenCode, OpenClaw, Continue.dev) post about local Claude proxies,
a short factual comment linking to OCP is appropriate — not spam.
- Maintain the `CHANGELOG.md` with clear, honest summaries — users who are already running OCP
are the best vector for word-of-mouth.
---
*Last updated: v3.21.0 cleanup cycle. Maintainer should re-read before any external promotion.*
+177 -5
View File
@@ -1,7 +1,7 @@
# ADR 0007 — TUI Interactive Mode (subscription-pool bridge) # ADR 0007 — TUI Interactive Mode (subscription-pool bridge)
**Date:** 2026-05-31 **Date:** 2026-05-31
**Status:** Accepted — amended by PR-4 (entrypoint hardening) **Status:** Accepted — amended by PR-4 (entrypoint hardening), PR-B (observability + concurrency), PR-C (env-token auth + defunct-reaping), PR-D (credential-isolated home — corrects PR-C)
**Deciders:** project maintainer **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. **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.
@@ -98,12 +98,16 @@ When `CLAUDE_TUI_MODE` is unset (the default), no code path touches `callClaudeT
Unset `CLAUDE_TUI_MODE` (or set it to any value other than `"true"`) → stream-json path restored immediately on next restart. 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) ### Home strategy
`TUI_HOME = OCP_TUI_HOME || HOME` (defaults to the operator's real home). > **Superseded by the PR-D amendment below for the env-token case.** As of PR-D, `TUI_HOME`
> is computed by `resolveTuiHome()`: when `CLAUDE_CODE_OAUTH_TOKEN` is set (and `OCP_TUI_HOME`
> is unset) the default is a **credential-free scratch home**, not the real home. The
> descriptions below remain accurate for the **no-env-token** case and the **explicit
> `OCP_TUI_HOME` override** case.
- **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). - **Real-home (default when NO env token, `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. - **Scratch-home opt-in (`OCP_TUI_HOME=<path>`, no env token):** 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 this legacy symlink mode only with a dedicated OAuth or for ephemeral testing. (The PR-D env-token mode avoids this caveat entirely — no credentials file to fork.)
### Working directory ### Working directory
@@ -139,6 +143,174 @@ B-path is **deferred** and is not implemented in this ADR. Until B-path lands, T
--- ---
## 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.
---
## Credential-isolated home for env-token auth (PR-D amendment)
**Date:** 2026-06-13
**Status:** Accepted — amends ADR 0007. **Corrects** the PR-C rationale and the original "Home strategy" section's scratch-home caveat.
**Motivation:** PR-C's env-token passing alone did **not** fix the PI231 401. Decisive live evidence (claude 2.1.104, PI231):
| Condition | Result |
|---|---|
| env token passed + a broken `~/.claude/.credentials.json` present | **401** (`Please run /login · API Error: 401`) |
| env token passed + `credentials.json` moved aside | **works** (real answer) |
### Corrected root cause
**Interactive `claude` PREFERS `~/.claude/.credentials.json` over the `CLAUDE_CODE_OAUTH_TOKEN` env var.** A stale/corrupt `credentials.json` therefore **shadows** the env token. (This is *unlike* `-p` mode, where the env token wins — which is why `server.mjs`'s own `getOAuthCredentials()` is unaffected and why PR-C's premise looked sufficient.) So passing the token (PR-C, `buildTuiCmd`) is **necessary but insufficient**: the TUI `claude` must additionally run in a HOME that has **no `credentials.json`**, so the env token is the only credential and is authoritative.
This also fixes the original incident at the **root**, more completely than PR-C claimed: with no `credentials.json` in the home, claude never runs the token-refresh path at all, so the single-use refresh token can never be rotated — and therefore never corrupted — by the spawn+`kill-session` cycle. The 25-zombie / empty-refresh-token failure mode becomes structurally impossible, not merely avoided.
### Decision
When `CLAUDE_CODE_OAUTH_TOKEN` is set, the TUI `claude` runs in a **credential-free scratch home** by default:
- `resolveTuiHome({ realHome, configuredHome, envTokenSet })` (exported from `lib/tui/session.mjs`, pure) decides the home:
- **`OCP_TUI_HOME` set** → that path (explicit override, back-compat — an operator who configured it keeps exactly that home).
- **else env token set** → `<realHome>/.ocp-tui/home` — a dedicated scratch home seeded with a minimal `.claude.json` (`hasCompletedOnboarding=true` + trust **only** the scratch cwd) and its own `projects/` dir, and **deliberately NO `.credentials.json`** (no symlink, no copy).
- **else (no env token)** → the operator's real home — **byte-for-byte the pre-fix behaviour** for hosts that intentionally rely on `credentials.json`.
- `prepareTuiHome(realHome, tuiHome, cwd, { envTokenMode })` gates the credential handling: in `envTokenMode` it creates the scratch `projects/` dir and seeds the minimal trusted `.claude.json` but **never** creates the credentials symlink. `runTuiTurn` sets `envTokenMode = !!CLAUDE_CODE_OAUTH_TOKEN && ehome !== realHome`.
- `readTuiTranscript` reads from the **same** home claude runs under (`ehome`), so transcripts land under `<scratch home>/.claude/projects/` and `findTranscriptPath` globs them there — the home is threaded through consistently. (We chose scratch-`HOME` over `CLAUDE_CONFIG_DIR`: the binary supports `CLAUDE_CONFIG_DIR`, but it relocates the transcript root to `<CONFIG_DIR>/projects/` rather than `<HOME>/.claude/projects/`, which would fork the transcript-resolution rule across modes for no benefit. The scratch-HOME lever reuses the existing, tested `prepareTuiHome`/`ehome` plumbing.)
### This RESOLVES — not reintroduces — the scratch-home caveat
The original "Home strategy" section and PR-C's `prepareTuiHome` comment warned that scratch-home is unsafe because *claude rewrites a **symlinked** `.credentials.json` on token refresh → forks/corrupts the OAuth credentials*. **That caveat does not apply to env-token mode**: there is no `credentials.json` in the home to fork, and claude never refreshes (it uses the long-lived env token), so there is no rotation and no corruption. The fork risk was inherent to the *symlink* approach; removing the credentials file entirely removes the risk. The legacy symlink mode is retained **only** for an operator who explicitly sets `OCP_TUI_HOME` without an env token, and its caveat is preserved for exactly that path.
### ALIGNMENT authorization (Class B)
**Class B** (OCP-owned TUI spawn). `cli.js` has no analogue for the TUI pane's auth/home strategy; authorized by **this ADR (0007)** per `ALIGNMENT.md`'s Class B citation requirement. `server.mjs` is touched only to compute `TUI_HOME` via `resolveTuiHome()` (TUI wiring) and to surface the auth mode in the boot log — no Class A wire surface, no endpoint shape, no `alignment.yml` blacklist token, and no `models.json` entry is touched.
---
## Consequences ## Consequences
### Positive ### Positive
+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,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,
};
}
+280 -78
View File
@@ -23,27 +23,100 @@ const defaultTmux = (args, opts = {}) =>
// Kill ONLY our own stale sessions. Scoped to SESSION_PREFIX so a co-hosted // Kill ONLY our own stale sessions. Scoped to SESSION_PREFIX so a co-hosted
// OLP test instance's `olp-tui-*` sessions are never touched. // 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 } = {}) { export function reapStaleTuiSessions({ tmux = defaultTmux } = {}) {
const r = tmux(["list-sessions", "-F", "#{session_name}"]); const r = tmux(["list-sessions", "-F", "#{session_name}"]);
if (!r || r.status !== 0) return 0; // no tmux server / no sessions 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 killed = 0;
for (const name of String(r.stdout || "").split("\n").map((s) => s.trim()).filter(Boolean)) { let othersRemain = false;
for (const name of names) {
if (name.startsWith(SESSION_PREFIX)) { if (name.startsWith(SESSION_PREFIX)) {
tmux(["kill-session", "-t", name]); tmux(["kill-session", "-t", name]);
killed++; 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; return killed;
} }
// ── Task 5: runTuiTurn ─────────────────────────────────────────────────── // ── Task 5: runTuiTurn ───────────────────────────────────────────────────
// Boot + paste-settle timing. Conservative defaults validated on PI231; env-tunable. // Boot + paste-settle timing. Conservative defaults validated on PI231; env-tunable.
const BOOT_MS = parseInt(process.env.OCP_TUI_BOOT_MS || "4000", 10); const BOOT_MS = parseInt(process.env.OCP_TUI_BOOT_MS || "4000", 10); // max wait for input-ready
const PASTE_SETTLE_MS = parseInt(process.env.OCP_TUI_PASTE_MS || "1800", 10); 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)); 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. // Single-quote escaper for sh -c arguments.
function shq(s) { function shq(s) {
return `'${String(s).replace(/'/g, "'\\''")}'`; return `'${String(s).replace(/'/g, "'\\''")}'`;
@@ -83,39 +156,85 @@ export function ensureTuiCwdTrusted(home, cwd) {
} catch { /* best effort */ } } catch { /* best effort */ }
} }
// Prepare the HOME claude runs under. Two modes: // Resolve the HOME the TUI `claude` runs under. Three intents, decided by the env
// - real-home (tuiHome === realHome OR falsy): no isolation; just trust the cwd // token + an explicit OCP_TUI_HOME override:
// 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 // - ENV-TOKEN MODE (default when CLAUDE_CODE_OAUTH_TOKEN is set AND OCP_TUI_HOME is
// refresh, REPLACING the symlink with a regular-file copy → the scratch home then // unset): a CREDENTIAL-FREE scratch home at `<realHome>/.ocp-tui/home`. There is
// FORKS the OAuth credentials. Because OAuth refresh tokens rotate (single-use), a // deliberately NO .credentials.json (no symlink, no copy), so the only credential
// refresh in the scratch home can invalidate the token the user's real-home claude // claude can find is the long-lived env token (passed by buildTuiCmd). This is what
// relies on. Therefore scratch-home is safe only with a DEDICATED OAuth or for // actually FORCES env-token auth — see the prepareTuiHome comment for why passing
// ephemeral use; for a shared subscription prefer real-home (tuiHome===realHome), // the token alone is insufficient.
// which shares one .credentials.json — identical to how OCP already spawns claude. // - EXPLICIT OVERRIDE: whatever OCP_TUI_HOME names (back-compat; an operator who set it
// Idempotent + best-effort: any failure degrades toward the dialog/cap, never // keeps exactly that home).
// corrupts. Run BEFORE the session boots. // - REAL-HOME (default when the env token is unset): the operator's real home, shared
export function prepareTuiHome(realHome, tuiHome, cwd) { // credentials.json — byte-for-byte the pre-fix behaviour for credentials.json hosts.
//
// Pure + deterministic so server.mjs and the tests share one decision. `configuredHome`
// is the raw OCP_TUI_HOME value (undefined/empty => unset).
export const DEFAULT_TUI_SCRATCH_HOME = (realHome) => `${realHome}/.ocp-tui/home`;
export function resolveTuiHome({ realHome, configuredHome, envTokenSet }) {
if (configuredHome) return configuredHome; // explicit override wins (back-compat)
if (envTokenSet) return DEFAULT_TUI_SCRATCH_HOME(realHome); // credential-free scratch
return realHome; // legacy real-home default
}
// Prepare the HOME claude runs under. Three modes:
// - real-home (tuiHome === realHome OR falsy): no isolation; just trust the cwd
// in the real ~/.claude.json. The legacy default when no env token is set.
// - ENV-TOKEN scratch-home (envTokenMode === true): a dedicated HOME with a seeded
// .claude.json (onboarded + trusts only the scratch cwd) and its own projects/ dir,
// and DELIBERATELY NO .credentials.json (no symlink, no copy). claude then has no
// credentials file to read, so it authenticates via CLAUDE_CODE_OAUTH_TOKEN (passed
// by buildTuiCmd) — which is authoritative precisely because nothing shadows it.
// - legacy scratch-home (envTokenMode falsy, tuiHome !== realHome): the historical
// mode that SYMLINKS the real .credentials.json. Retained only for an operator who
// explicitly set OCP_TUI_HOME without an env token; see the caveat below.
//
// WHY ENV-TOKEN MODE IS THE FIX (proven live on PI231, claude 2.1.104):
// env token passed + a broken ~/.claude/.credentials.json present → 401.
// env token passed + credentials.json moved aside → real answer.
// Interactive `claude` PREFERS .credentials.json over the env var (unlike `-p`, where the
// env token wins), so a stale/corrupt credentials.json SHADOWS the env token. Passing the
// token is necessary but insufficient; the TUI claude must run in a HOME with NO
// credentials.json so the env token is the only credential. This ALSO ends the refresh-
// corruption incident at the root: with no credentials file, claude never runs the token-
// refresh path, so the single-use refresh token can never be rotated (and corrupted) by the
// spawn+kill cycle. (This RESOLVES — not reintroduces — the ADR 0007 scratch-home concern:
// the old caveat was about a SYMLINKED credentials.json being forked on refresh; here there
// is no credentials file to fork and no refresh ever happens.)
//
// ⚠️ LEGACY SCRATCH-HOME CAVEAT (envTokenMode falsy, symlink path): claude rewrites
// .credentials.json on token refresh, REPLACING the symlink with a regular-file copy → the
// scratch home FORKS the OAuth credentials and a refresh can invalidate the real-home token.
// That path is therefore safe only with a DEDICATED OAuth or for ephemeral use. The env-token
// mode above avoids this entirely.
//
// Idempotent + best-effort: any failure degrades toward the dialog/cap, never corrupts.
// Run BEFORE the session boots.
export function prepareTuiHome(realHome, tuiHome, cwd, { envTokenMode = false } = {}) {
if (!tuiHome || tuiHome === realHome) { ensureTuiCwdTrusted(realHome, cwd); return; } if (!tuiHome || tuiHome === realHome) { ensureTuiCwdTrusted(realHome, cwd); return; }
try { try {
const claudeDir = `${tuiHome}/.claude`; const claudeDir = `${tuiHome}/.claude`;
mkdirSync(`${claudeDir}/projects`, { recursive: true }); mkdirSync(`${claudeDir}/projects`, { recursive: true });
// Symlink the real credentials (never copy the OAuth token); refresh if missing. if (!envTokenMode) {
const link = `${claudeDir}/.credentials.json`; // Legacy mode ONLY: symlink the real credentials (never copy the token); refresh if
if (!existsSync(link)) { // missing. Env-token mode deliberately skips this — no credentials file at all.
try { symlinkSync(`${realHome}/.claude/.credentials.json`, link); } catch { /* best effort */ } 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, // Seed .claude.json ONCE (if absent): onboarded + trust ONLY the scratch cwd.
// drop the user's project history, trust only the scratch cwd. mode 0600. // In env-token mode start from a MINIMAL config (do NOT copy the real ~/.claude.json —
// a credential-isolated home should not inherit the operator's account/config state);
// in legacy mode carry the onboarded real config minus the user's project history.
const seedPath = `${tuiHome}/.claude.json`; const seedPath = `${tuiHome}/.claude.json`;
if (!existsSync(seedPath)) { if (!existsSync(seedPath)) {
let base = {}; let base = {};
try { base = JSON.parse(readFileSync(`${realHome}/.claude.json`, "utf8")); } catch { /* fresh */ } if (!envTokenMode) {
try { base = JSON.parse(readFileSync(`${realHome}/.claude.json`, "utf8")); } catch { /* fresh */ }
}
base.hasCompletedOnboarding = true; base.hasCompletedOnboarding = true;
base.projects = { [cwd]: { hasTrustDialogAccepted: true, allowedTools: [] } }; base.projects = { [cwd]: { hasTrustDialogAccepted: true, allowedTools: [] } };
writeFileSync(seedPath, JSON.stringify(base, null, 2), { mode: 0o600 }); writeFileSync(seedPath, JSON.stringify(base, null, 2), { mode: 0o600 });
@@ -125,23 +244,6 @@ export function prepareTuiHome(realHome, tuiHome, cwd) {
ensureTuiCwdTrusted(tuiHome, cwd); 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). // 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 // 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), // that stops account-attached managed MCP from connecting (spec §5.2 / T6),
@@ -149,22 +251,101 @@ export function resolveTuiEntrypointEnv(env, mode = "cli") {
// A-PATH ONLY: built-in tools are left enabled (acceptable single-user). Deployment B // 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 // (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. // wall before this argv is reachable for owner_tier=guest — guard that in PR-3 wiring.
function buildTuiCmd(claudeBin, model, sessionId) { 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]), 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. ALWAYS uses --allowedTools (CLAUDE_SKIP_PERMISSIONS /
// --dangerously-skip-permissions is intentionally removed: claude v2.1.x shows an
// interactive bypass-acceptance screen in headless tmux that nothing can answer →
// the turn hangs until the wallclock cap, bricks the pane; not recoverable without a
// human at a keyboard). Use scratch-home settings.json additionalDirectories instead.
let toolArgs;
if (process.env.OCP_TUI_FULL_TOOLS === "1") {
toolArgs = [];
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 [ return [
envPrefix,
shq(claudeBin), shq(claudeBin),
"--model", shq(model), "--model", shq(model),
"--session-id", sessionId, "--session-id", sessionId,
"--strict-mcp-config", ...toolArgs,
"--disallowedTools", shq("mcp__*"),
].join(" "); ].join(" ");
} }
// Full per-request TUI lifecycle: // Full per-request TUI lifecycle:
// 1. Pre-trust the scratch cwd (no trust dialog will appear). // 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). // 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. // 3. Boot an interactive `claude` in a fresh tmux session in the scratch cwd; poll
// 4. Submit the prompt via `send-keys -- "$(cat file)"` + a SEPARATE Enter key // capture-pane until the `? for shortcuts` input bar appears (readiness-poll
// event (spec §5 / T3: literal "\n" in paste does NOT submit; Enter token does). // 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 // 5. Block on the native JSONL transcript (located by session-id) until terminal
// marker or wall-clock cap. // marker or wall-clock cap.
// 6. Always teardown: kill session + rm temp dir (even on throw). // 6. Always teardown: kill session + rm temp dir (even on throw).
@@ -186,25 +367,29 @@ export async function runTuiTurn({
const ehome = home || process.env.HOME; // HOME claude runs under (scratch or real) 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) const rhome = realHome || process.env.HOME; // real home (OAuth + onboarded config source)
// Env-token-only mode: the env token is set AND claude runs in an isolated home
// (ehome !== rhome). In that case the scratch home must be CREDENTIAL-FREE (no
// .credentials.json) so the env token — passed by buildTuiCmd — is the only credential
// and is therefore authoritative (interactive claude otherwise PREFERS a credentials.json,
// shadowing the env token; proven live on PI231). server.mjs derives TUI_HOME via
// resolveTuiHome() so this isolated home is the DEFAULT once CLAUDE_CODE_OAUTH_TOKEN is set.
const envTokenMode = !!process.env.CLAUDE_CODE_OAUTH_TOKEN && ehome !== rhome;
// Ensure scratch cwd exists, then prepare the (scratch or real) HOME + trust the // Ensure scratch cwd exists, then prepare the (scratch or real) HOME + trust the
// cwd — before claude boots. // cwd — before claude boots.
if (!existsSync(cwd)) mkdirSync(cwd, { recursive: true }); if (!existsSync(cwd)) mkdirSync(cwd, { recursive: true });
prepareTuiHome(rhome, ehome, cwd); prepareTuiHome(rhome, ehome, cwd, { envTokenMode });
// Write prompt to a temp file (mode 0600) so the content never touches argv. // Write prompt to a temp file (mode 0600) so the content never touches argv.
const tmpDir = mkdtempSync(`${tmpdir()}/ocp-tui-`); const tmpDir = mkdtempSync(`${tmpdir()}/ocp-tui-`);
const promptFile = `${tmpDir}/prompt.txt`; const promptFile = `${tmpDir}/prompt.txt`;
writeFileSync(promptFile, prompt, { mode: 0o600 }); writeFileSync(promptFile, prompt, { mode: 0o600 });
// Build the env: disable marketplace auto-install, strip any Anthropic / CC // Minimal env for spawnSync (tmux itself). The pane's claude env comes exclusively
// env vars that might interfere with interactive-mode classification. // from the `env` prefix string built inside buildTuiCmd — tmux does NOT forward the
const env = { ...process.env, CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL: "1" }; // spawning process's env to the pane, so the {env} here is intentionally minimal.
delete env.CLAUDECODE; const env = { ...process.env };
delete env.ANTHROPIC_API_KEY; env.HOME = ehome; // tmux needs HOME; all claude-specific vars go via buildTuiCmd prefix
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 { try {
// 1. Boot the interactive session inside tmux, rooted at the scratch cwd. // 1. Boot the interactive session inside tmux, rooted at the scratch cwd.
@@ -215,35 +400,52 @@ export async function runTuiTurn({
// is a no-op when the session never existed). // is a no-op when the session never existed).
const spawnResult = tmux( const spawnResult = tmux(
["new-session", "-d", "-s", tmuxName, "-x", "220", "-y", "50", "-c", cwd, ["new-session", "-d", "-s", tmuxName, "-x", "220", "-y", "50", "-c", cwd,
buildTuiCmd(claudeBin, model, sessionId)], buildTuiCmd(claudeBin, model, sessionId, ehome, entrypointMode)],
{ env }, { env },
); );
if (!spawnResult || spawnResult.status !== 0) { if (!spawnResult || spawnResult.status !== 0) {
throw new Error("tui_spawn_failed: tmux session not created"); throw new Error("tui_spawn_failed: tmux session not created");
} }
await sleep(BOOT_MS);
// 2. Submit prompt body via `"$(cat file)"` — byte-safe for any content — // 2. Wait until claude's input bar is actually ready (was: blind sleep(BOOT_MS)).
// then settle, then send a SEPARATE Enter key event to submit the line. // BOOT_MS is now the MAX readiness wait, not a fixed delay.
// const ready = await pollUntil(() => tuiInputReady(tuiCapturePane(tmux, tmuxName)),
// The `-l` (literal) flag is required on the paste send-keys call so that { timeoutMs: BOOT_MS, intervalMs: READY_POLL_MS });
// a prompt that happens to equal a tmux key token (e.g. "C-c", "Escape") if (!ready) {
// is typed literally as text rather than being interpreted as a key binding. // (readiness timed out; relying on paste-verify)
// The SEPARATE Enter event below deliberately omits -l so that tmux sends a console.error("[tui] input_not_ready", tmuxName);
// real keypress (carriage return) to submit the prompt line. }
spawnSync(
"sh", // 3. Paste the prompt via a tmux PASTE BUFFER with bracketed paste (-p), NOT
["-c", `${shq(TMUX)} send-keys -t ${shq(tmuxName)} -l -- "$(cat ${shq(promptFile)})"`], // `send-keys -l`. send-keys of a large multi-line prompt is unreliable: the
{ env, encoding: "utf8" }, // 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
await sleep(PASTE_SETTLE_MS); // (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"]); tmux(["send-keys", "-t", tmuxName, "Enter"]);
// 3. Block on the native transcript (resolved by session-id) until terminal. // 4. Block on the native transcript (resolved by session-id) until terminal.
// Returns { text, entrypoint } from readTuiTranscript. // Returns { text, entrypoint } from readTuiTranscript.
return await readTuiTranscript({ home: ehome, sessionId, wallclockMs }); return await readTuiTranscript({ home: ehome, sessionId, wallclockMs });
} finally { } finally {
// 4. Teardown — always, even on throw. // 5. Teardown — always, even on throw.
try { tmux(["kill-session", "-t", tmuxName]); } catch { /* already gone */ } try { tmux(["kill-session", "-t", tmuxName]); } catch { /* already gone */ }
try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best effort */ } try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best effort */ }
} }
+183 -37
View File
@@ -9,24 +9,11 @@ import { readFileSync, existsSync, readdirSync } from "node:fs";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); 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 // Locate a session's transcript by its UUID across every projects subdir, without
// reconstructing the encoded cwd. Robust to whatever encoding claude applies. // 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). // Returns the path, or null if not present yet (it appears once the turn starts).
// TODO: add a CI fixture-contract test (a captured real transcript) so schema drift
// in the claude JSONL format fails loudly rather than silently degrading.
export function findTranscriptPath(home, sessionId) { export function findTranscriptPath(home, sessionId) {
if (!home || !sessionId) return null; if (!home || !sessionId) return null;
const root = `${home}/.claude/projects`; const root = `${home}/.claude/projects`;
@@ -51,19 +38,29 @@ export function parseTranscriptLines(text) {
return out; return out;
} }
// A line marks the assistant turn complete when it is the turn_duration system // A line marks the assistant turn complete when EITHER:
// event. That is the ONLY reliable terminal marker in interactive TUI mode. // (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).
// //
// Why tool_use is NOT a terminal marker: // stop_reason "tool_use" is deliberately NOT terminal: the model is mid-turn (it will
// In interactive claude, when the model decides to call a tool (stop_reason= // run a tool and continue with a later assistant entry). Matching on a FINAL
// "tool_use"), claude handles the tool call internally and then continues // stop_reason — not on the mere presence of a tool_use — keeps tool-using turns intact.
// generating — the turn is NOT complete. The transcript advances to another // (The v3.17.1 narrowing dropped a buggy "tool_use is terminal" rule; this restores
// assistant entry after the tool result. Only {type:"system", // cross-version completion detection without bringing that bug back.)
// subtype:"turn_duration"} signals that claude has fully finished the turn. const TERMINAL_STOP_REASONS = new Set(["end_turn", "stop_sequence", "max_tokens"]);
// Treating tool_use as terminal would truncate tool-using turns mid-flight.
export function isTerminalLine(obj) { export function isTerminalLine(obj) {
if (!obj || typeof obj !== "object") return false; if (!obj || typeof obj !== "object") return false;
return obj.type === "system" && obj.subtype === "turn_duration"; 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 // Text of the LAST assistant turn: concatenate its text content blocks
@@ -90,24 +87,169 @@ export function extractLatestAssistantText(events) {
return text; return text;
} }
// Returns the entrypoint string from the turn_duration line (e.g. "cli"), // Returns the entrypoint string (e.g. "cli") used for the billing-pool assertion,
// or null if absent. Lets callers assert the subscription-classified path. // or null if absent. Lets callers assert the subscription-classified path.
// Fixture-confirmed: entrypoint field lives directly on the turn_duration line. //
// 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) { export function verifyEntrypoint(events) {
let fallback = null;
for (const ev of events) { for (const ev of events) {
if (ev && ev.type === "system" && ev.subtype === "turn_duration") { if (!ev || typeof ev !== "object") continue;
return ev.entrypoint != null ? ev.entrypoint : null; 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; return null;
} }
// Block until the session transcript is terminal (turn_duration) or // Block until the session transcript is terminal (turn_duration / final
// the wall-clock cap elapses, polling the file (no fs.watch — robust over NFS / // stop_reason) or the wall-clock cap elapses, polling the file (no fs.watch —
// editors). Returns { text, entrypoint } where text is the latest assistant text // robust over NFS / editors). Returns { text, entrypoint, truncated }:
// and entrypoint is the billing-pool classifier from the turn_duration line (e.g. // - text: latest assistant text.
// "cli"), or null if not yet present. On cap with text, returns the partial result; // - entrypoint: billing-pool classifier (see verifyEntrypoint), or null.
// on cap with no text at all, throws. // - 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 // 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). // growth and a "file stable for N s" rule would false-abort it (spec §4.3).
@@ -125,10 +267,14 @@ export async function readTuiTranscript({ transcriptPath: p, home, sessionId, wa
lastText = extractLatestAssistantText(events) || lastText; lastText = extractLatestAssistantText(events) || lastText;
const ep = verifyEntrypoint(events); const ep = verifyEntrypoint(events);
if (ep != null) lastEntrypoint = ep; if (ep != null) lastEntrypoint = ep;
if (events.some(isTerminalLine)) return { text: lastText, entrypoint: lastEntrypoint }; // Terminal marker reached → the turn is COMPLETE.
if (events.some(isTerminalLine)) return { text: lastText, entrypoint: lastEntrypoint, truncated: false };
} }
await sleep(pollMs); await sleep(pollMs);
} }
if (lastText) return { text: lastText, entrypoint: lastEntrypoint }; // 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"); throw new Error("tui_transcript_timeout: no assistant text within wallclock cap");
} }
+24 -3
View File
@@ -573,21 +573,42 @@ Usage:
ocp restart Restart the Claude proxy service ocp restart Restart the Claude proxy service
ocp restart gateway Restart the OpenClaw gateway ocp restart gateway Restart the OpenClaw gateway
(briefly disconnects all Telegram/Discord bots) (briefly disconnects all Telegram/Discord bots)
Note (macOS): restart does a full launchctl bootout + bootstrap, NOT
`kickstart -k`. bootout+bootstrap re-reads the plist's EnvironmentVariables,
so an env change you made (e.g. CLAUDE_BIND, CLAUDE_CODE_OAUTH_TOKEN) actually
takes effect. `kickstart -k` only re-execs the process and reuses launchd's
cached env, so env edits would be silently ignored. (Linux systemctl already
re-reads its EnvironmentFile on restart.)
EOF EOF
} }
# macOS only: reload a launchd agent via bootout + bootstrap so plist
# EnvironmentVariables are re-read (kickstart -k would reuse the cached env).
# Args: <uid> <label> <plist-path>. Returns 0 iff bootstrap succeeds.
_launchd_reload() {
local uid="$1" label="$2" plist="$3"
[[ -f "$plist" ]] || return 1
# bootout may legitimately fail if the agent is not currently loaded — that's fine,
# we only require the subsequent bootstrap to succeed (the load that re-reads env).
launchctl bootout "gui/$uid/$label" 2>/dev/null || true
launchctl bootstrap "gui/$uid" "$plist" 2>/dev/null
}
cmd_restart() { cmd_restart() {
if [[ "${1:-}" == "gateway" ]]; then if [[ "${1:-}" == "gateway" ]]; then
echo "Restarting gateway..." echo "Restarting gateway..."
openclaw gateway restart 2>&1 openclaw gateway restart 2>&1
else else
echo "Restarting proxy..." echo "Restarting proxy..."
# Try current service name, then legacy, then manual restart # Try current service name, then legacy, then manual restart.
# macOS: bootout+bootstrap (re-reads plist EnvironmentVariables — see cmd_restart_help).
# Linux: systemctl --user restart already re-reads its EnvironmentFile.
local uid local uid
uid=$(id -u) uid=$(id -u)
if launchctl kickstart -k "gui/$uid/dev.ocp.proxy" 2>/dev/null; then if _launchd_reload "$uid" "dev.ocp.proxy" "$HOME/Library/LaunchAgents/dev.ocp.proxy.plist"; then
true true
elif launchctl kickstart -k "gui/$uid/ai.openclaw.proxy" 2>/dev/null; then elif _launchd_reload "$uid" "ai.openclaw.proxy" "$HOME/Library/LaunchAgents/ai.openclaw.proxy.plist"; then
true true
elif systemctl --user restart ocp-proxy 2>/dev/null; then elif systemctl --user restart ocp-proxy 2>/dev/null; then
true true
+3 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "ocp", "name": "ocp",
"version": "3.12.0", "version": "3.16.2",
"description": "Slash commands for the OpenClaw Proxy", "description": "Slash commands for the OpenClaw Proxy",
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",
@@ -9,6 +9,7 @@
"openclaw": { "openclaw": {
"type": "plugin", "type": "plugin",
"id": "ocp", "id": "ocp",
"pluginManifest": "openclaw.plugin.json" "pluginManifest": "openclaw.plugin.json",
"extensions": ["./index.js"]
} }
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "open-claude-proxy", "name": "open-claude-proxy",
"version": "3.18.0", "version": "3.21.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.", "description": "OCP (Open Claude Proxy) — use your Claude Pro/Max subscription as an OpenAI-compatible API for any IDE. Works with Cline, OpenCode, Aider, Continue.dev, OpenClaw, and more.",
"type": "module", "type": "module",
"bin": { "bin": {
+375 -26
View File
@@ -19,7 +19,11 @@
* CLAUDE_SYSTEM_PROMPT — system prompt appended to all requests * CLAUDE_SYSTEM_PROMPT — system prompt appended to all requests
* CLAUDE_MCP_CONFIG — path to MCP server config JSON file * CLAUDE_MCP_CONFIG — path to MCP server config JSON file
* CLAUDE_SESSION_TTL — session TTL in ms (default: 3600000 = 1h) * CLAUDE_SESSION_TTL — session TTL in ms (default: 3600000 = 1h)
* CLAUDE_MAX_CONCURRENT — max concurrent claude processes (default: 8) * CLAUDE_MAX_CONCURRENT — max concurrent claude processes, -p/stream-json path (default: 8)
* CLAUDE_MAX_QUEUE — max requests waiting for a -p slot before HTTP 429 (default: 16)
* OCP_TUI_MAX_CONCURRENT — max concurrent interactive TUI turns, TUI-mode path (default: 2)
* OCP_SPAWN_REAL_HOME — "1" forces the -p spawn to use the real HOME (disables the
* latency spawn-home isolation; default: isolated when a token exists)
* CLAUDE_BREAKER_THRESHOLD — failures in window before circuit opens (default: 6) * CLAUDE_BREAKER_THRESHOLD — failures in window before circuit opens (default: 6)
* CLAUDE_BREAKER_COOLDOWN — base ms to wait before retrying after circuit opens (default: 120000) * CLAUDE_BREAKER_COOLDOWN — base ms to wait before retrying after circuit opens (default: 120000)
* CLAUDE_BREAKER_WINDOW — sliding window duration in ms (default: 300000 = 5min) * CLAUDE_BREAKER_WINDOW — sliding window duration in ms (default: 300000 = 5min)
@@ -30,14 +34,16 @@
import { createServer } from "node:http"; import { createServer } from "node:http";
import { spawn, execFileSync } from "node:child_process"; import { spawn, execFileSync } from "node:child_process";
import { randomUUID, timingSafeEqual } from "node:crypto"; import { randomUUID, timingSafeEqual } from "node:crypto";
import { readFileSync, readdirSync, accessSync, existsSync, constants, chmodSync, statSync } from "node:fs"; import { readFileSync, readdirSync, accessSync, existsSync, constants, chmodSync, statSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { homedir } from "node:os"; import { homedir } from "node:os";
import { validateKey, recordUsage, getUsageByKey, getUsageTimeline, getRecentUsage, createKey, listKeys, revokeKey, closeDb, checkQuota, updateKeyQuota, getKeyQuota, findKey, cacheHash, getCachedResponse, setCachedResponse, clearCache, getCacheStats, hasCacheControl, singleflight, getInflightStats } from "./keys.mjs"; import { validateKey, recordUsage, getUsageByKey, getUsageTimeline, getRecentUsage, createKey, listKeys, revokeKey, closeDb, checkQuota, updateKeyQuota, getKeyQuota, findKey, cacheHash, getCachedResponse, setCachedResponse, clearCache, getCacheStats, hasCacheControl, singleflight, getInflightStats } from "./keys.mjs";
import { DEFAULT_PORT } from "./lib/constants.mjs"; import { DEFAULT_PORT } from "./lib/constants.mjs";
import { isLoopbackBind } from "./lib/net.mjs"; import { isLoopbackBind } from "./lib/net.mjs";
import { runTuiTurn, reapStaleTuiSessions } from "./lib/tui/session.mjs"; import { runTuiTurn, reapStaleTuiSessions, resolveTuiHome } from "./lib/tui/session.mjs";
import { detectTuiUpstreamError } from "./lib/tui/transcript.mjs";
import { TuiSemaphore, recordTuiEntrypoint, buildTuiHealthBlock } from "./lib/tui/semaphore.mjs";
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
const _pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8")); const _pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8"));
@@ -269,6 +275,15 @@ const SYSTEM_PROMPT = process.env.CLAUDE_SYSTEM_PROMPT || "";
const MCP_CONFIG = process.env.CLAUDE_MCP_CONFIG || ""; const MCP_CONFIG = process.env.CLAUDE_MCP_CONFIG || "";
let SESSION_TTL = parseInt(process.env.CLAUDE_SESSION_TTL || "3600000", 10); let SESSION_TTL = parseInt(process.env.CLAUDE_SESSION_TTL || "3600000", 10);
let MAX_CONCURRENT = parseInt(process.env.CLAUDE_MAX_CONCURRENT || "8", 10); let MAX_CONCURRENT = parseInt(process.env.CLAUDE_MAX_CONCURRENT || "8", 10);
// FIX ⑥ (concurrency): bound on requests WAITING for a -p concurrency slot. Beyond
// MAX_CONCURRENT, requests queue (up to CLAUDE_MAX_QUEUE) instead of being rejected; when the
// queue is ALSO full, the request gets HTTP 429 + Retry-After (not an opaque 500). See
// claudeSemaphore / acquireClaudeSlot below.
const CLAUDE_MAX_QUEUE = parseInt(process.env.CLAUDE_MAX_QUEUE || "16", 10);
// Retry-After seconds advertised on a 429 backpressure response. A claude turn is typically a
// few seconds to tens of seconds; a small constant nudge keeps well-behaved clients from
// hammering while the queue drains.
const CLAUDE_QUEUE_RETRY_AFTER = parseInt(process.env.CLAUDE_QUEUE_RETRY_AFTER || "5", 10);
const BREAKER_THRESHOLD = parseInt(process.env.CLAUDE_BREAKER_THRESHOLD || "6", 10); const BREAKER_THRESHOLD = parseInt(process.env.CLAUDE_BREAKER_THRESHOLD || "6", 10);
const BREAKER_COOLDOWN = parseInt(process.env.CLAUDE_BREAKER_COOLDOWN || "120000", 10); const BREAKER_COOLDOWN = parseInt(process.env.CLAUDE_BREAKER_COOLDOWN || "120000", 10);
const BREAKER_WINDOW = parseInt(process.env.CLAUDE_BREAKER_WINDOW || "300000", 10); const BREAKER_WINDOW = parseInt(process.env.CLAUDE_BREAKER_WINDOW || "300000", 10);
@@ -276,6 +291,12 @@ const BREAKER_HALF_OPEN_MAX = parseInt(process.env.CLAUDE_BREAKER_HALF_OPEN_MAX
const HEARTBEAT_INTERVAL = parseInt(process.env.CLAUDE_HEARTBEAT_INTERVAL || "0", 10); const HEARTBEAT_INTERVAL = parseInt(process.env.CLAUDE_HEARTBEAT_INTERVAL || "0", 10);
const BIND_ADDRESS = process.env.CLAUDE_BIND || "127.0.0.1"; const BIND_ADDRESS = process.env.CLAUDE_BIND || "127.0.0.1";
const NO_CONTEXT = process.env.CLAUDE_NO_CONTEXT === "true"; const NO_CONTEXT = process.env.CLAUDE_NO_CONTEXT === "true";
// Kill-switch for the FIX-③ default-path spawn-home isolation (see resolveSpawnHome /
// spawnHomeMode below). When "1", the -p/stream-json spawn always runs in the operator's
// real HOME with no cwd override — byte-for-byte the pre-isolation behaviour — even if an
// OAuth token is resolvable. Provided as an escape hatch in case a host depends on the real
// HOME's claude config for the spawned process.
const SPAWN_REAL_HOME = process.env.OCP_SPAWN_REAL_HOME === "1";
const AUTH_MODE = process.env.CLAUDE_AUTH_MODE || (PROXY_API_KEY ? "shared" : "none"); const AUTH_MODE = process.env.CLAUDE_AUTH_MODE || (PROXY_API_KEY ? "shared" : "none");
const ADMIN_KEY = process.env.OCP_ADMIN_KEY || ""; const ADMIN_KEY = process.env.OCP_ADMIN_KEY || "";
const PROXY_ANONYMOUS_KEY = process.env.PROXY_ANONYMOUS_KEY || ""; const PROXY_ANONYMOUS_KEY = process.env.PROXY_ANONYMOUS_KEY || "";
@@ -297,8 +318,166 @@ let CACHE_TTL = parseInt(process.env.CLAUDE_CACHE_TTL || "0", 10); // 0 = disabl
const TUI_MODE = process.env.CLAUDE_TUI_MODE === "true"; const TUI_MODE = process.env.CLAUDE_TUI_MODE === "true";
const TUI_WALLCLOCK_MS = parseInt(process.env.CLAUDE_TUI_WALLCLOCK_MS || "120000", 10); 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`; const TUI_CWD = process.env.OCP_TUI_CWD || `${process.env.HOME}/.ocp-tui/work`;
const TUI_HOME = process.env.OCP_TUI_HOME || process.env.HOME; // HOME the interactive claude runs under. resolveTuiHome() decides:
// - OCP_TUI_HOME set → that path (explicit override, back-compat).
// - else CLAUDE_CODE_OAUTH_TOKEN set → a CREDENTIAL-FREE scratch home
// (<HOME>/.ocp-tui/home) with NO .credentials.json, so the env token is the only
// credential and is authoritative — interactive claude otherwise PREFERS a
// credentials.json over the env var, so a stale one shadows the token (proven live on
// PI231) and a refresh on it can corrupt the single-use token. See ADR 0007 PR-D.
// - else (no env token) → the operator's real home (legacy credentials.json path,
// byte-for-byte unchanged for hosts that intentionally rely on credentials.json).
const TUI_HOME = resolveTuiHome({
realHome: process.env.HOME,
configuredHome: process.env.OCP_TUI_HOME,
envTokenSet: !!process.env.CLAUDE_CODE_OAUTH_TOKEN,
});
const TUI_ENTRYPOINT = process.env.OCP_TUI_ENTRYPOINT || "cli"; // cli|auto|off — see ADR 0007 const TUI_ENTRYPOINT = process.env.OCP_TUI_ENTRYPOINT || "cli"; // cli|auto|off — see ADR 0007
// Independent concurrency bound for the TUI path (audit C-4). Default 2: a TUI turn is
// HEAVY (per-request cold-boot of a tmux+claude session + up to TUI_WALLCLOCK_MS=120s of
// wallclock), so a small host (e.g. a Pi 4 serving a family) cannot run many at once
// without OOM + multiplied subscription rate-limit pressure. This is NOT the global
// MAX_CONCURRENT gate (that lives in spawnClaudeProcess, the -p/stream-json path, which
// callClaudeTui never reaches). See ADR 0007 PR-B amendment + lib/tui/semaphore.mjs.
const TUI_MAX_CONCURRENT = parseInt(process.env.OCP_TUI_MAX_CONCURRENT || "2", 10);
const tuiSemaphore = new TuiSemaphore(TUI_MAX_CONCURRENT);
// Operator-visible TUI drift surface (audit C-5). lastEntrypoint + entrypointMismatches
// let the operator poll /health to catch a silent metered-pool drift (the audit's top
// risk: after the 6/15 flip a TTY-loss could flip cc_entrypoint cli→sdk-cli and drain
// metered credits invisibly — the warning currently only reaches journald).
const tuiStats = {
lastEntrypoint: null, // last observed cc_entrypoint from the transcript ("cli" | "sdk-cli" | null)
entrypointMismatches: 0, // count of cli-expected-but-got-other turns
};
// ── FIX ③ (latency): default-path (-p / stream-json) spawn-home isolation ──────────────
// PROBLEM (measured, not theoretical): OCP's default spawn inherits the operator's real HOME
// (loading the global ~/.claude — plugins, skills, hooks) and runs with cwd=~/ocp (loading the
// project CLAUDE.md / skills) on EVERY request. Pure Anthropic API floor for haiku "hi" ≈ 12s;
// the same claude CLI spawned in the operator's real HOME/cwd ≈ 1028s; a clean minimal HOME +
// CLAUDE_CODE_OAUTH_TOKEN ≈ 37s and authenticates fine. So the heavy global config is pure
// per-request latency tax with no proxy benefit (a proxy must NOT leak the host's context into
// the proxied turn — same rationale as NO_CONTEXT / the TUI path's CLAUDE_MDS suppression).
//
// FIX: when an OAuth token is resolvable, run the default spawn under a CREDENTIAL-FREE minimal
// scratch HOME (`<realHome>/.ocp/spawn-home`) with cwd = that same neutral dir, and pass the
// resolved token via CLAUDE_CODE_OAUTH_TOKEN so the env token is authoritative. This MIRRORS the
// TUI path's resolveTuiHome() env-token mode (lib/tui/session.mjs): for `-p`, the env token wins
// over a credentials.json (the opposite of interactive claude), so credential isolation is not
// even strictly required for auth here, but a credential-FREE home is still the right shape —
// nothing to refresh, nothing to corrupt, no heavy config to load.
//
// SAFETY: if NO token is resolvable → fall back to the real HOME with no cwd override (zero
// regression). OCP_SPAWN_REAL_HOME=1 forces that legacy behaviour even when a token exists.
// The scratch home holds NO .credentials.json / NO settings.json / NO plugins — it is created
// minimal and (re)cleaned of any settings.json on prepare.
const SPAWN_HOME_DIR = `${process.env.HOME}/.ocp/spawn-home`;
// Idempotently prepare the minimal scratch HOME. Creates the dir if missing and removes any
// settings.json that might have crept in, so the spawned claude loads no host settings/plugins.
// Best-effort: a failure here degrades toward "dir may be missing", which spawn() tolerates by
// erroring loudly — never a silent auth/credential corruption (there are no credentials here).
function prepareSpawnHome(dir = SPAWN_HOME_DIR) {
try {
mkdirSync(`${dir}/.claude`, { recursive: true });
// Belt-and-braces: ensure no settings.json/plugins leak in (this home is fully ours).
for (const f of [`${dir}/.claude/settings.json`, `${dir}/.claude/settings.local.json`]) {
try { if (existsSync(f)) rmSync(f, { force: true }); } catch { /* best effort */ }
}
} catch { /* best effort — spawn will surface a hard error if the dir is truly unusable */ }
}
// Resolve the default-spawn HOME-isolation decision ONCE, lazily + memoized (so it runs after
// getOAuthCredentials is defined regardless of source order, and the token probe happens at most
// once). Returns { isolated, home, token } where:
// - isolated:true → spawn under SPAWN_HOME_DIR with cwd=SPAWN_HOME_DIR + the env token.
// - isolated:false → legacy real-HOME spawn, no cwd override (no token, or kill-switch on).
// Caches only the isolation DECISION (isolated/home/reason), NOT the token — the token is
// re-resolved FRESH per spawn via resolveSpawnToken(). A memoized token goes stale when its
// source rotates: the macOS keychain access token rotates (~hourly, refreshed by the operator's
// real claude), so a startup snapshot 401s once it expires (caused a ~31h Mac-mini 401 outage,
// 2026-06-26). OCP deliberately does NOT refresh the token itself — a refresh-token grant would
// consume the single-use refresh token and log out the operator's real claude (issue #112).
let _spawnHomeMode = null;
function getSpawnHomeMode() {
if (_spawnHomeMode) return _spawnHomeMode;
if (SPAWN_REAL_HOME) {
_spawnHomeMode = { isolated: false, home: null, reason: "kill-switch (OCP_SPAWN_REAL_HOME=1)" };
return _spawnHomeMode;
}
let hasToken = false;
try { hasToken = !!(getOAuthCredentials()?.accessToken); } catch { hasToken = false; }
if (hasToken) {
prepareSpawnHome(SPAWN_HOME_DIR);
_spawnHomeMode = { isolated: true, home: SPAWN_HOME_DIR, reason: "oauth token resolved" };
} else {
_spawnHomeMode = { isolated: false, home: null, reason: "no oauth token resolvable" };
}
return _spawnHomeMode;
}
// Resolve a FRESH OAuth access token for an isolated spawn. Read-only (keychain / credentials.json
// / env) — NEVER refreshes/rotates (see getSpawnHomeMode note). Returns null if none resolvable OR
// if a known expiry has passed (5-min buffer): a null return makes the caller fall back to real
// HOME, where the spawned claude refreshes the credential natively and self-heals (the keychain
// token is then fresh again → next spawn is fast). The env-token path (Linux) carries no expiresAt
// → never expiry-gated (those tokens are long-lived).
function resolveSpawnToken() {
try {
const creds = getOAuthCredentials();
if (!creds?.accessToken) return null;
if (creds.expiresAt && Date.now() + 300000 >= creds.expiresAt) return null;
return creds.accessToken;
} catch { return null; }
}
// ── FIX ⑥ (concurrency): bounded wait-queue for the -p / stream-json path ──────────────
// PROBLEM (proven): spawnClaudeProcess used `if (activeRequests >= MAX_CONCURRENT) throw` →
// the client got an opaque 500 AND the rejection was NOT counted in stats (a 15-concurrent
// stress run returned 7×500 while /health stats.errors stayed 0). The TUI path already has a
// bounded-queue semaphore (TuiSemaphore); the -p path did not.
//
// FIX: requests beyond MAX_CONCURRENT WAIT on this semaphore (up to CLAUDE_MAX_QUEUE) instead of
// being rejected. Only when the queue is ALSO full do we reject — with HTTP 429 + Retry-After
// (deterministic backpressure), a distinct `concurrency_queue_full` log, and a stats.queueRejections
// counter that shows up on /health. The slot is released on EVERY exit path via the existing
// idempotent cleanup() (proc exit/close/error/timeout) — the #37/#40 slot-leak guard.
const claudeSemaphore = new TuiSemaphore(MAX_CONCURRENT, { maxQueue: CLAUDE_MAX_QUEUE });
// Tagged error so callers can map this single overflow case to HTTP 429 (every OTHER throw stays
// a 500). Carries retryAfter for the Retry-After header.
class ConcurrencyOverflowError extends Error {
constructor(message) { super(message); this.name = "ConcurrencyOverflowError"; this.httpStatus = 429; this.retryAfter = CLAUDE_QUEUE_RETRY_AFTER; }
}
// Acquire a -p concurrency slot, queuing if all are busy (up to CLAUDE_MAX_QUEUE). Resolves to a
// release() fn that MUST be called exactly once on every exit path (wired into ctx.cleanup()).
// Rejects with ConcurrencyOverflowError when the wait-queue is full. Increments stats.queued while
// waiting (decremented on acquire) and stats.queueRejections on overflow.
async function acquireClaudeSlot() {
stats.queued = claudeSemaphore.queued + 1; // reflect this waiter before we (maybe) block
try {
await claudeSemaphore.acquire();
} catch (e) {
stats.queued = claudeSemaphore.queued;
stats.queueRejections++;
logEvent("warn", "concurrency_queue_full", {
limit: claudeSemaphore.limit, maxQueue: claudeSemaphore.maxQueue,
inflight: claudeSemaphore.inflight, queued: claudeSemaphore.queued,
});
throw new ConcurrencyOverflowError(
`backpressure: concurrency limit (${claudeSemaphore.limit}) reached and wait queue ` +
`(${claudeSemaphore.maxQueue}) is full — retry shortly`);
}
stats.queued = claudeSemaphore.queued;
let released = false;
return function releaseClaudeSlot() {
if (released) return; // idempotent — cleanup() may be reached via multiple proc events
released = true;
claudeSemaphore.release();
stats.queued = claudeSemaphore.queued;
};
}
// SECURITY fail-loud: TUI-mode is incompatible with any configuration that allows // SECURITY fail-loud: TUI-mode is incompatible with any configuration that allows
// non-operator prompts to reach the interactive claude session. Three cases: // non-operator prompts to reach the interactive claude session. Three cases:
@@ -476,6 +655,28 @@ const cacheCleanupInterval = setInterval(() => {
} }
}, 600000); }, 600000);
// TUI defunct-session reap (periodic): the boot reap (below) only fires once, but a
// long-lived host (PI231 ran 30 days without restart) accumulates defunct `<claude>`
// zombies between restarts — the pane's claude is a child of the tmux server, not node,
// so only the server can reap it (see reapStaleTuiSessions). We sweep every 15 min, but
// ONLY when the TUI path is fully idle: reapStaleTuiSessions may `kill-server`, which would
// tear down a live turn's pane, so we skip the sweep while any turn is inflight or queued.
// RESIDUAL (documented, accepted): a brand-new request whose pane is created in the narrow
// window between this idle-check and kill-server would have its pane torn down and fail the
// turn cleanly via runTuiTurn's existing honesty gates (rare; the boot reap is the primary
// mechanism and the 15-min cadence makes the window negligible).
// Gated on TUI_MODE — zero effect (no kill-server, no list-sessions) when TUI is off.
// cli.js does NOT perform this operation (Class B, OCP-owned TUI spawn) — see ADR 0007.
const TUI_REAP_INTERVAL_MS = 15 * 60 * 1000;
const tuiReapInterval = TUI_MODE ? setInterval(() => {
if (tuiSemaphore.inflight > 0 || tuiSemaphore.queued > 0) return; // a turn is live — defer
try {
const n = reapStaleTuiSessions();
if (n) logEvent("info", "tui_reaped_stale_sessions", { count: n, trigger: "periodic" });
} catch (e) { logEvent("error", "tui_periodic_reap_failed", { error: e.message }); }
}, TUI_REAP_INTERVAL_MS) : null;
if (tuiReapInterval && typeof tuiReapInterval.unref === "function") tuiReapInterval.unref();
// ── Active child process tracking ──────────────────────────────────────── // ── Active child process tracking ────────────────────────────────────────
const activeProcesses = new Set(); const activeProcesses = new Set();
@@ -488,6 +689,8 @@ const stats = {
sessionHits: 0, sessionHits: 0,
sessionMisses: 0, sessionMisses: 0,
oneOffRequests: 0, oneOffRequests: 0,
queued: 0, // current requests waiting for a -p concurrency slot (FIX ⑥)
queueRejections: 0, // total requests rejected with HTTP 429 because the wait-queue was full (FIX ⑥)
}; };
const recentErrors = []; // last 20 errors const recentErrors = []; // last 20 errors
@@ -715,11 +918,14 @@ function getModelTier(cliModel) {
// (messagesToPrompt), so multi-turn correctness is preserved without sessions. // (messagesToPrompt), so multi-turn correctness is preserved without sessions.
// The sessions Map is retained for stats/logging but no longer drives --resume. // The sessions Map is retained for stats/logging but no longer drives --resume.
// Reference: OLP ADR 0009 Amendment 1 + commit 97e7d16. // Reference: OLP ADR 0009 Amendment 1 + commit 97e7d16.
function spawnClaudeProcess(model, messages, conversationId, keyName) { // FIX ⑥: concurrency is now bounded by the claudeSemaphore via acquireClaudeSlot(), which the
if (stats.activeRequests >= MAX_CONCURRENT) { // caller MUST await before calling this, passing the resulting release fn as `releaseSlot`. The
throw new Error(`concurrency limit reached (${stats.activeRequests}/${MAX_CONCURRENT})`); // old `if (activeRequests >= MAX_CONCURRENT) throw` gate (→ opaque 500, uncounted) is GONE: at
} // most MAX_CONCURRENT callers hold a slot when they reach here, so this spawn is always within
// budget. releaseSlot is wired into the idempotent cleanup() so the slot is freed on EVERY exit
// path (close/error/timeout/abort). Back-compat: releaseSlot defaults to a no-op so any future
// internal caller that does its own gating still works.
function spawnClaudeProcess(model, messages, conversationId, keyName, releaseSlot = () => {}) {
const cliModel = MODEL_MAP[model] || model; const cliModel = MODEL_MAP[model] || model;
// Circuit breaker: disabled (see comment at top of breaker section) // Circuit breaker: disabled (see comment at top of breaker section)
@@ -756,7 +962,31 @@ function spawnClaudeProcess(model, messages, conversationId, keyName) {
env.CLAUDE_CODE_DISABLE_AUTO_MEMORY = "1"; env.CLAUDE_CODE_DISABLE_AUTO_MEMORY = "1";
} }
const proc = spawn(CLAUDE, cliArgs, { env, stdio: ["pipe", "pipe", "pipe"] }); // FIX ③ (latency): default-path spawn-home isolation. When a token is resolvable (and the
// OCP_SPAWN_REAL_HOME kill-switch is off), run claude under a credential-free minimal HOME
// with cwd = that same neutral dir, so it loads NONE of the operator's global ~/.claude
// (plugins/skills/hooks) or the ~/ocp project CLAUDE.md/skills — the measured 1028s → 37s
// latency win. The env token is authoritative for `-p` (unlike interactive claude). When no
// token is resolvable, falls back to real HOME + inherited cwd (zero regression). See
// getSpawnHomeMode() / prepareSpawnHome() above. The DISABLE_CLAUDE_MDS / AUTO_MEMORY flags
// are set unconditionally in isolated mode (belt-and-braces; mirrors the TUI path).
const spawnHome = getSpawnHomeMode();
const spawnOpts = { env, stdio: ["pipe", "pipe", "pipe"] };
if (spawnHome.isolated) {
// Re-resolve the token FRESH per spawn (never a startup snapshot — keychain tokens rotate;
// a stale snapshot 401s). If unresolvable right now, fall through to real HOME so the spawned
// claude resolves + refreshes credentials natively instead of 401ing on a stale/null token.
const freshToken = resolveSpawnToken();
if (freshToken) {
env.HOME = spawnHome.home;
env.CLAUDE_CODE_OAUTH_TOKEN = freshToken; // env token is authoritative for -p
env.CLAUDE_CODE_DISABLE_CLAUDE_MDS = "1";
env.CLAUDE_CODE_DISABLE_AUTO_MEMORY = "1";
spawnOpts.cwd = spawnHome.home; // neutral cwd: no project CLAUDE.md/skills
}
}
const proc = spawn(CLAUDE, cliArgs, spawnOpts);
activeProcesses.add(proc); activeProcesses.add(proc);
const t0 = Date.now(); const t0 = Date.now();
@@ -768,6 +998,10 @@ function spawnClaudeProcess(model, messages, conversationId, keyName) {
cleaned = true; cleaned = true;
clearTimeout(overallTimer); clearTimeout(overallTimer);
stats.activeRequests--; stats.activeRequests--;
// FIX ⑥: free the concurrency slot for a queued waiter. releaseSlot is itself idempotent,
// and cleanup() is guarded by `cleaned`, so the slot is released exactly once on the first
// exit path reached (proc 'exit' fires before 'close'; 'error' covers spawn failure).
try { releaseSlot(); } catch { /* never let release throw out of cleanup */ }
} }
// Guarantee slot release on ANY exit path (normal close, error, timeout kill, // Guarantee slot release on ANY exit path (normal close, error, timeout kill,
@@ -837,12 +1071,18 @@ function spawnClaudeProcess(model, messages, conversationId, keyName) {
// We accumulate full text across all content_block_delta events plus the // We accumulate full text across all content_block_delta events plus the
// assistant-aggregate fallback, then resolve with the assembled string. // assistant-aggregate fallback, then resolve with the assembled string.
// Reference: OLP ADR 0009 Amendment 1 + commit 97e7d16. // Reference: OLP ADR 0009 Amendment 1 + commit 97e7d16.
function callClaude(model, messages, conversationId, keyName) { async function callClaude(model, messages, conversationId, keyName) {
// FIX ⑥: acquire a concurrency slot first (queues up to CLAUDE_MAX_QUEUE; rejects with a
// ConcurrencyOverflowError → 429 when the queue is full). The release fn is passed into the
// spawn so the idempotent cleanup() frees it on every exit path. If the spawn itself throws
// synchronously (before cleanup is wired), release here so the slot never leaks.
const releaseSlot = await acquireClaudeSlot();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let ctx; let ctx;
try { try {
ctx = spawnClaudeProcess(model, messages, conversationId, keyName); ctx = spawnClaudeProcess(model, messages, conversationId, keyName, releaseSlot);
} catch (err) { } catch (err) {
releaseSlot();
return reject(err); return reject(err);
} }
@@ -917,7 +1157,11 @@ function callClaudeTui(model, messages, _conversationId, _keyName) {
const cliModel = MODEL_MAP[model] || model; const cliModel = MODEL_MAP[model] || model;
const prompt = messagesToPrompt(messages); // includes system as [System] inline const prompt = messagesToPrompt(messages); // includes system as [System] inline
recordModelRequest(cliModel, prompt.length); recordModelRequest(cliModel, prompt.length);
return runTuiTurn({ // C-4: gate the heavy interactive boot behind the TUI semaphore. run() acquires a slot
// (queuing if all are busy, up to maxQueue), then releases in a finally so any throw from
// runTuiTurn (tmux spawn failure, paste-not-landed) OR from the honesty gates below
// (truncation / error banner) can NEVER leak a slot. tuiSemaphore.inflight feeds /health.
return tuiSemaphore.run(() => runTuiTurn({
prompt, prompt,
model: cliModel, model: cliModel,
claudeBin: CLAUDE, claudeBin: CLAUDE,
@@ -926,19 +1170,47 @@ function callClaudeTui(model, messages, _conversationId, _keyName) {
cwd: TUI_CWD, cwd: TUI_CWD,
wallclockMs: TUI_WALLCLOCK_MS, wallclockMs: TUI_WALLCLOCK_MS,
entrypointMode: TUI_ENTRYPOINT, entrypointMode: TUI_ENTRYPOINT,
}).then(({ text, entrypoint }) => { }).then(({ text, entrypoint, truncated }) => {
// ── Honesty gates (issue #133) ─ run BEFORE recordModelSuccess / cache write-back.
// A throw here propagates to the .catch below (recordModelError + reject), so the
// result never reaches the downstream setCachedResponse / singleflight / SUCCESS path.
// C-2: the wall-clock cap hit with partial text and NO terminal marker — the turn
// is INCOMPLETE. Returning the cut-off prefix would cache it and report it as
// finish_reason:stop (a truncated answer served as a complete one). Reject instead.
if (truncated) {
logEvent("error", "tui_wallclock_truncated", { model: cliModel, chars: (text || "").length, wallclockMs: TUI_WALLCLOCK_MS });
throw new Error("tui_wallclock_truncated: turn hit the wall-clock cap before completing; partial text dropped");
}
// C-1: the interactive claude CLI renders in-session errors (expired/invalid
// credentials, transient API failure) as ordinary assistant text. Returning that
// banner would cache an error AS an answer and record a model SUCCESS. Detect a
// known error banner (anchored whole-text match — see detectTuiUpstreamError) and
// reject so it does NOT enter the cache and the client gets a 5xx.
const banner = detectTuiUpstreamError(text);
if (banner) {
logEvent("error", "tui_upstream_error", { model: cliModel, banner: banner.slice(0, 200) });
throw new Error("tui_upstream_error: claude CLI returned an in-session error banner instead of an answer");
}
recordModelSuccess(cliModel, 0); // elapsed not measurable here; wallclock at reader level recordModelSuccess(cliModel, 0); // elapsed not measurable here; wallclock at reader level
// Assert the subscription-pool classification. TUI exists to keep cc_entrypoint=cli // Assert the subscription-pool classification. TUI exists to keep cc_entrypoint=cli
// (subscription pool); a silent degrade to sdk-cli (metered Agent SDK pool) would still // (subscription pool); a silent degrade to sdk-cli (metered Agent SDK pool) would still
// return text but cost money — warn loudly so it's visible. (issue #115) // return text but cost money — warn loudly so it's visible. (issue #115)
if (TUI_ENTRYPOINT === "cli" && entrypoint !== "cli") { // C-5: also surface the observation on /health. recordTuiEntrypoint sets lastEntrypoint
// unconditionally (operators can poll it to confirm cli) and increments
// entrypointMismatches when expected=cli but observed≠cli — the same condition the
// journald warning already covers — so a silent metered-pool drift is visible on /health
// without tailing logs.
if (recordTuiEntrypoint(tuiStats, entrypoint, TUI_ENTRYPOINT)) {
logEvent("warn", "tui_entrypoint_mismatch", { expected: "cli", got: entrypoint, model: cliModel }); logEvent("warn", "tui_entrypoint_mismatch", { expected: "cli", got: entrypoint, model: cliModel });
} }
return text; return text;
}).catch((err) => { }).catch((err) => {
recordModelError(cliModel, false); recordModelError(cliModel, false);
throw err; throw err;
}); }));
} }
// ── SSE heartbeat (opt-in idle watchdog) ──────────────────────────────── // ── SSE heartbeat (opt-in idle watchdog) ────────────────────────────────
@@ -977,14 +1249,28 @@ function startHeartbeat(res, intervalMs, sessionId) {
// We parse line-by-line and forward content_block_delta text events as SSE. // We parse line-by-line and forward content_block_delta text events as SSE.
// The result event triggers the stop/[DONE] sequence. // The result event triggers the stop/[DONE] sequence.
// Reference: OLP ADR 0009 Amendment 1 + commits 97e7d16, 65f945c. // Reference: OLP ADR 0009 Amendment 1 + commits 97e7d16, 65f945c.
function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}) { async function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}) {
const id = `chatcmpl-${randomUUID()}`; const id = `chatcmpl-${randomUUID()}`;
const created = Math.floor(Date.now() / 1000); const created = Math.floor(Date.now() / 1000);
// FIX ⑥: acquire a concurrency slot first (queues up to CLAUDE_MAX_QUEUE). On overflow, surface
// HTTP 429 + Retry-After (NOT 500). Release is wired into cleanup() for every exit path; if the
// spawn throws synchronously before cleanup is wired, release here.
let releaseSlot;
try {
releaseSlot = await acquireClaudeSlot();
} catch (err) {
if (err instanceof ConcurrencyOverflowError) {
return jsonResponse(res, 429, { error: { message: sanitizeError(err.message), type: "rate_limit_error" } }, { "Retry-After": String(err.retryAfter) });
}
return jsonResponse(res, 500, { error: { message: sanitizeError(err.message), type: "proxy_error" } });
}
let ctx; let ctx;
try { try {
ctx = spawnClaudeProcess(model, messages, conversationId, authInfo.keyName); ctx = spawnClaudeProcess(model, messages, conversationId, authInfo.keyName, releaseSlot);
} catch (err) { } catch (err) {
releaseSlot();
return jsonResponse(res, 500, { error: { message: sanitizeError(err.message), type: "proxy_error" } }); return jsonResponse(res, 500, { error: { message: sanitizeError(err.message), type: "proxy_error" } });
} }
@@ -1177,12 +1463,23 @@ function sanitizeError(msg) {
} }
// ── Response helpers ──────────────────────────────────────────────────── // ── Response helpers ────────────────────────────────────────────────────
function jsonResponse(res, status, data) { function jsonResponse(res, status, data, extraHeaders = null) {
if (res.headersSent || res.writableEnded || res.destroyed) return; if (res.headersSent || res.writableEnded || res.destroyed) return;
res.writeHead(status, { "Content-Type": "application/json" }); // extraHeaders is optional + additive (e.g. Retry-After on a 429); Content-Type always wins.
res.writeHead(status, { ...(extraHeaders || {}), "Content-Type": "application/json" });
res.end(JSON.stringify(data)); res.end(JSON.stringify(data));
} }
// FIX ⑥: map an upstream error to the right HTTP response. A ConcurrencyOverflowError (the
// wait-queue was full) becomes HTTP 429 + Retry-After + rate_limit_error; every other error
// stays a 500 proxy_error (byte-for-byte the pre-fix behaviour for non-overflow errors).
function respondUpstreamError(res, err) {
if (err instanceof ConcurrencyOverflowError) {
return jsonResponse(res, 429, { error: { message: sanitizeError(err.message), type: "rate_limit_error" } }, { "Retry-After": String(err.retryAfter) });
}
return jsonResponse(res, 500, { error: { message: sanitizeError(err.message), type: "proxy_error" } });
}
function sendSSE(res, data, hb) { function sendSSE(res, data, hb) {
hb?.reset(); hb?.reset();
res.write(`data: ${JSON.stringify(data)}\n\n`); res.write(`data: ${JSON.stringify(data)}\n\n`);
@@ -1602,7 +1899,9 @@ function applySettingUpdate(key, value) {
switch (key) { switch (key) {
case "timeout": TIMEOUT = value; break; case "timeout": TIMEOUT = value; break;
case "maxConcurrent": MAX_CONCURRENT = value; break; // FIX ⑥: keep the -p wait-queue semaphore's limit in sync with the runtime MAX_CONCURRENT
// so a /settings change to maxConcurrent actually changes how many claude procs run at once.
case "maxConcurrent": MAX_CONCURRENT = value; claudeSemaphore.limit = Math.max(1, value); break;
case "sessionTTL": SESSION_TTL = value; break; case "sessionTTL": SESSION_TTL = value; break;
case "maxPromptChars": MAX_PROMPT_CHARS = value; break; case "maxPromptChars": MAX_PROMPT_CHARS = value; break;
case "cacheTTL": CACHE_TTL = value; break; case "cacheTTL": CACHE_TTL = value; break;
@@ -1811,7 +2110,7 @@ async function handleChatCompletions(req, res) {
try { res.end(); } catch {} try { res.end(); } catch {}
return; return;
} }
return jsonResponse(res, 500, { error: { message: sanitizeError(err.message), type: "proxy_error" } }); return respondUpstreamError(res, err);
} }
} }
@@ -1828,8 +2127,9 @@ async function handleChatCompletions(req, res) {
try { res.end(); } catch {} try { res.end(); } catch {}
return; return;
} }
// Sanitize error: strip internal file paths before sending to client // Sanitize error: strip internal file paths before sending to client.
jsonResponse(res, 500, { error: { message: sanitizeError(err.message), type: "proxy_error" } }); // FIX ⑥: ConcurrencyOverflowError → 429 + Retry-After; all other errors → 500 (unchanged).
respondUpstreamError(res, err);
} }
} }
@@ -1990,6 +2290,42 @@ const server = createServer(async (req, res) => {
circuitBreaker: "disabled", circuitBreaker: "disabled",
sessions: sessionList, sessions: sessionList,
recentErrors: recentErrors.slice(-5), recentErrors: recentErrors.slice(-5),
// ── FIX ③ spawn-home isolation surface — ADDITIVE (default -p/stream-json path) ──
// Lets the operator confirm the latency-fix isolation is active without inspecting logs.
// NEVER includes the token. mode: "isolated-scratch-home" | "real-home". home is the
// scratch HOME path when isolated (null otherwise). For TUI_MODE the -p path is unused,
// so report it as disabled.
spawn: (() => {
if (TUI_MODE) return { mode: "tui (default -p path unused)", isolated: false, home: null };
const shm = getSpawnHomeMode();
return {
mode: shm.isolated ? "isolated-scratch-home" : "real-home",
isolated: shm.isolated,
home: shm.isolated ? shm.home : null,
reason: shm.reason,
};
})(),
// ── FIX ⑥ -p concurrency wait-queue surface — ADDITIVE ──
// inflight/queued are live; queueRejections is cumulative (also in stats.queueRejections).
// Lets the operator see backpressure instead of guessing from opaque 500s.
concurrency: {
maxConcurrent: MAX_CONCURRENT,
maxQueue: claudeSemaphore.maxQueue,
inflight: claudeSemaphore.inflight,
queued: claudeSemaphore.queued,
queueRejections: stats.queueRejections,
},
// ── TUI observability (audit C-5) — ADDITIVE block (ADR 0007 PR-B amendment) ──
// /health is a grandfathered B.2 endpoint (ADR 0006). This block is NEW fields only;
// every existing field above is byte-identical → behaviour-preserving for existing
// consumers per ALIGNMENT.md's grandfather provision. When TUI_MODE is off the block
// still appears with enabled:false (cheap, harmless) so the shape is stable.
// entrypointMismatches/lastEntrypoint exist so an operator can poll /health to catch a
// silent metered-pool drift (the audit's top risk after the 6/15 billing flip).
tui: buildTuiHealthBlock(
{ enabled: TUI_MODE, entrypointMode: TUI_ENTRYPOINT, maxConcurrent: TUI_MAX_CONCURRENT },
tuiStats, tuiSemaphore,
),
}); });
} }
@@ -2222,6 +2558,7 @@ function gracefulShutdown(signal) {
clearInterval(sessionCleanupInterval); clearInterval(sessionCleanupInterval);
clearInterval(authCheckInterval); clearInterval(authCheckInterval);
clearInterval(cacheCleanupInterval); clearInterval(cacheCleanupInterval);
if (tuiReapInterval) clearInterval(tuiReapInterval);
closeDb(); closeDb();
// 3. Kill all active child processes // 3. Kill all active child processes
@@ -2266,7 +2603,7 @@ server.listen(PORT, BIND_ADDRESS, () => {
console.log(`Architecture: on-demand spawning (no pool)`); console.log(`Architecture: on-demand spawning (no pool)`);
console.log(`Models: ${MODELS.map((m) => m.id).join(", ")}`); console.log(`Models: ${MODELS.map((m) => m.id).join(", ")}`);
console.log(`Claude binary: ${CLAUDE}`); console.log(`Claude binary: ${CLAUDE}`);
console.log(`Timeout: ${TIMEOUT / 1000}s | Max concurrent: ${MAX_CONCURRENT}`); console.log(`Timeout: ${TIMEOUT / 1000}s | Max concurrent: ${MAX_CONCURRENT} | Queue: ${CLAUDE_MAX_QUEUE} (429 on overflow)`);
console.log(`Circuit breaker: disabled`); console.log(`Circuit breaker: disabled`);
console.log(`Tools: ${SKIP_PERMISSIONS ? "all (skip-permissions)" : ALLOWED_TOOLS.join(", ")}`); console.log(`Tools: ${SKIP_PERMISSIONS ? "all (skip-permissions)" : ALLOWED_TOOLS.join(", ")}`);
console.log(`Sessions: TTL=${SESSION_TTL / 1000}s`); console.log(`Sessions: TTL=${SESSION_TTL / 1000}s`);
@@ -2278,9 +2615,21 @@ server.listen(PORT, BIND_ADDRESS, () => {
if (NO_CONTEXT) console.log(`Context: suppressed (CLAUDE_NO_CONTEXT=true — no CLAUDE.md, no auto-memory)`); if (NO_CONTEXT) console.log(`Context: suppressed (CLAUDE_NO_CONTEXT=true — no CLAUDE.md, no auto-memory)`);
if (CACHE_TTL > 0) console.log(`Cache: enabled (TTL=${CACHE_TTL / 1000}s)`); if (CACHE_TTL > 0) console.log(`Cache: enabled (TTL=${CACHE_TTL / 1000}s)`);
else console.log(`Cache: disabled (set CLAUDE_CACHE_TTL to enable)`); else console.log(`Cache: disabled (set CLAUDE_CACHE_TTL to enable)`);
// FIX ③: announce default-path (-p/stream-json) spawn-home isolation mode (never logs the token).
if (!TUI_MODE) {
const shm = getSpawnHomeMode();
if (shm.isolated) {
console.log(`Spawn home: isolated-scratch-home (${shm.home}, cwd-neutral, env-token auth) — fast path`);
} else {
console.log(`Spawn home: real-home (${shm.reason}) — set CLAUDE_CODE_OAUTH_TOKEN for the isolated fast path`);
}
}
if (TUI_MODE) { if (TUI_MODE) {
console.warn(`⚠️ TUI-mode ON — single-user only; do NOT enable on a multi-user OCP (guest prompts would run claude with operator filesystem access). See ADR 0007.`); console.warn(`⚠️ TUI-mode ON — single-user only; do NOT enable on a multi-user OCP (guest prompts would run claude with operator filesystem access). See ADR 0007.`);
console.log(` TUI-mode: ON home=${TUI_HOME} cwd=${TUI_CWD} wallclock=${TUI_WALLCLOCK_MS}ms`); const tuiAuth = process.env.CLAUDE_CODE_OAUTH_TOKEN
? (TUI_HOME === process.env.HOME ? "env-token (real home — unset OCP_TUI_HOME for credential isolation)" : "env-token (credential-isolated home — no credentials.json)")
: "credentials.json (no CLAUDE_CODE_OAUTH_TOKEN — see Troubleshooting #401)";
console.log(` TUI-mode: ON home=${TUI_HOME} cwd=${TUI_CWD} auth=${tuiAuth} wallclock=${TUI_WALLCLOCK_MS}ms maxConcurrent=${TUI_MAX_CONCURRENT}`);
try { try {
const n = reapStaleTuiSessions(); const n = reapStaleTuiSessions();
if (n) logEvent("info", "tui_reaped_stale_sessions", { count: n }); if (n) logEvent("info", "tui_reaped_stale_sessions", { count: n });
+19 -11
View File
@@ -141,18 +141,26 @@ try {
} }
// Check claude auth (quick test) // Check claude auth (quick test)
try { // NOTE: This probe uses `claude -p` (sdk-cli spawn). After the 2026-06-15 Anthropic billing
const out = execSync('claude -p --output-format text --no-session-persistence -- "ping"', { // split, every `claude -p` call draws from the Agent SDK credit pool rather than the
encoding: "utf-8", // Pro/Max subscription. Re-running setup after 6/15 will consume one metered credit.
timeout: 30000, // Set OCP_SKIP_AUTH_TEST=1 to skip this probe (auth is still validated at first real request).
env: { ...process.env, CLAUDECODE: undefined, ANTHROPIC_API_KEY: undefined, ANTHROPIC_BASE_URL: undefined, ANTHROPIC_AUTH_TOKEN: undefined }, if (process.env.OCP_SKIP_AUTH_TEST === "1") {
}).trim(); warn("OCP_SKIP_AUTH_TEST=1 — skipping claude auth probe (will be validated at first request).");
if (out.length > 0) { } else {
log(`Claude CLI authenticated (test response: "${out.slice(0, 40)}...")`); 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) // Check openclaw config (optional — OCP runs standalone without OpenClaw)
+620 -50
View File
@@ -1340,23 +1340,12 @@ test("streamStringAsSSE empty content: role + stop + [DONE] only", () => {
}); });
// ── Suite: TUI transcript reader ──────────────────────────────────────── // ── Suite: TUI transcript reader ────────────────────────────────────────
import { encodeCwd, transcriptPath, findTranscriptPath, parseTranscriptLines, isTerminalLine, extractLatestAssistantText, verifyEntrypoint } from "./lib/tui/transcript.mjs"; import { findTranscriptPath, parseTranscriptLines, isTerminalLine, extractLatestAssistantText, verifyEntrypoint, detectTuiUpstreamError } from "./lib/tui/transcript.mjs";
import { readFileSync as tuiReadFileSync, mkdtempSync as tuiMkdtemp0, mkdirSync as tuiMkdir0, writeFileSync as tuiWrite0 } from "node:fs"; import { readFileSync as tuiReadFileSync, mkdtempSync as tuiMkdtemp0, mkdirSync as tuiMkdir0, writeFileSync as tuiWrite0 } from "node:fs";
import { tmpdir as tuiTmp0 } from "node:os"; import { tmpdir as tuiTmp0 } from "node:os";
console.log("\nTUI transcript — path formula:"); console.log("\nTUI transcript — path formula:");
test("encodeCwd replaces every slash AND every dot with dash", () => {
// Verified live (claude v2.1.158): /home/u/.ocp-tui/work -> -home-u--ocp-tui-work
assert.equal(encodeCwd("/home/u/.ocp-tui/work"), "-home-u--ocp-tui-work");
assert.equal(encodeCwd("/tmp/tui-test"), "-tmp-tui-test"); // dot-free path still correct
});
test("transcriptPath composes HOME/.claude/projects/<enc>/<sid>.jsonl", () => {
assert.equal(
transcriptPath("/home/u", "/home/u/.ocp-tui/work", "abc-123"),
"/home/u/.claude/projects/-home-u--ocp-tui-work/abc-123.jsonl"
);
});
test("findTranscriptPath locates <sid>.jsonl across projects subdirs by UUID", () => { test("findTranscriptPath locates <sid>.jsonl across projects subdirs by UUID", () => {
const home = tuiMkdtemp0(`${tuiTmp0()}/tui-home-`); const home = tuiMkdtemp0(`${tuiTmp0()}/tui-home-`);
const sid = "11111111-2222-3333-4444-555555555555"; const sid = "11111111-2222-3333-4444-555555555555";
@@ -1387,6 +1376,15 @@ test("isTerminalLine false on stop_reason tool_use (flat) — claude continues a
test("isTerminalLine false on ordinary assistant text line", () => { test("isTerminalLine false on ordinary assistant text line", () => {
assert.equal(isTerminalLine({ type: "assistant", message: { content: [{ type: "text", text: "hi" }] } }), false); assert.equal(isTerminalLine({ type: "assistant", message: { content: [{ type: "text", text: "hi" }] } }), false);
}); });
// issue #130 cloud/server-side: claude builds (e.g. 2.1.114) that DON'T emit
// turn_duration mark turn-end via assistant message.stop_reason — must be terminal.
test("isTerminalLine true on assistant stop_reason end_turn (version-robust, e.g. 2.1.114)", () => {
assert.equal(isTerminalLine({ type: "assistant", message: { stop_reason: "end_turn", content: [{ type: "text", text: "ok" }] } }), true);
});
test("isTerminalLine true on assistant stop_reason stop_sequence / max_tokens", () => {
assert.equal(isTerminalLine({ type: "assistant", message: { stop_reason: "stop_sequence" } }), true);
assert.equal(isTerminalLine({ type: "assistant", message: { stop_reason: "max_tokens" } }), true);
});
test("extractLatestAssistantText concatenates text blocks of LAST assistant entry", () => { test("extractLatestAssistantText concatenates text blocks of LAST assistant entry", () => {
const evs = [ const evs = [
{ type: "assistant", message: { content: [{ type: "text", text: "first" }] } }, { type: "assistant", message: { content: [{ type: "text", text: "first" }] } },
@@ -1427,6 +1425,173 @@ test("real complete fixture: verifyEntrypoint returns 'cli'", () => {
assert.equal(verifyEntrypoint(evs), "cli"); assert.equal(verifyEntrypoint(evs), "cli");
}); });
// ── C-3 (#133): verifyEntrypoint is version-robust ───────────────────────
// Some claude builds do NOT emit a turn_duration line; entrypoint lives on
// ordinary lines on BOTH emitting and non-emitting builds. Reading ONLY
// turn_duration made the server.mjs tui_entrypoint_mismatch assertion get null
// every turn on non-emitting builds. verifyEntrypoint must fall back to ANY line.
console.log("\nTUI transcript — verifyEntrypoint version-robustness (C-3, #133):");
test("verifyEntrypoint PREFERS the turn_duration line's entrypoint", () => {
// turn_duration says "cli"; an earlier ordinary line says "sdk-cli" — the
// authoritative turn_duration value must win, not last-writer-wins on the fallback.
const evs = [
{ type: "assistant", entrypoint: "sdk-cli", message: { content: [{ type: "text", text: "x" }] } },
{ type: "system", subtype: "turn_duration", entrypoint: "cli" },
];
assert.equal(verifyEntrypoint(evs), "cli");
});
test("verifyEntrypoint falls back to entrypoint on an ordinary assistant line when no turn_duration", () => {
const evs = [
{ type: "user", entrypoint: "cli", message: { content: "hi" } },
{ type: "assistant", entrypoint: "cli", message: { stop_reason: "end_turn", content: [{ type: "text", text: "ok" }] } },
];
assert.equal(verifyEntrypoint(evs), "cli");
});
test("verifyEntrypoint returns null when NO line carries an entrypoint", () => {
const evs = [
{ type: "assistant", message: { stop_reason: "end_turn", content: [{ type: "text", text: "ok" }] } },
];
assert.equal(verifyEntrypoint(evs), null);
});
test("real no-turn_duration fixture: verifyEntrypoint still resolves 'cli' (was null before C-3)", () => {
const evs = parseTranscriptLines(tuiReadFileSync("./lib/tui/fixtures/no-turn-duration.jsonl", "utf8"));
// Sanity: the fixture genuinely lacks a turn_duration line (so this exercises the fallback).
assert.ok(!evs.some((e) => e && e.type === "system" && e.subtype === "turn_duration"), "fixture must NOT emit turn_duration");
assert.equal(verifyEntrypoint(evs), "cli");
});
// ── C-1 (#133): honest AUTH-FAILURE banner detection ─────────────────────
// The interactive claude CLI renders in-session errors as ordinary assistant text.
// C-1 catches the R-1 case: expired/invalid creds, where EVERY turn returns the same
// one-line auth-failure banner and OCP would cache it as a real answer. The detector
// is deliberately NARROW/conservative: a false-positive (killing a real long answer
// that merely DISCUSSES an API error) costs the user a missing answer + a double-burn
// retry, which is worse than the rare false-negative (caching one transient error for
// the 5-min TTL). Signal = ALL of: SHORT whole-message (≤100; live samples 69/73) AND
// "API Error: 4xx" AND an auth keyword (authenticat | /login | credential) AND NO
// backtick/quote char. When unsure → PASS. The earlier generalised rule
// (^<short-prefix>?API Error:\d{3}.*$) was TOO BROAD: its unbounded .* tail killed
// legit long answers; this block encodes the full narrowed matrix.
console.log("\nTUI transcript — auth-failure banner detection (C-1, #133):");
// ---- Required matrix: MUST detect (kill) ----
test("C-1 KILL: live /login 401 auth banner", () => {
const banner = "Please run /login · API Error: 401 Invalid authentication credentials";
assert.equal(detectTuiUpstreamError(banner), banner);
});
test("C-1 KILL: live 'Failed to authenticate.' 401 banner variant", () => {
// Second real PI231 banner: a different short auth-failure prefix before the same
// "API Error: 4xx" core. Still short, still 4xx, still has 'authenticate'/'credentials'.
const banner = "Failed to authenticate. API Error: 401 Invalid authentication credentials";
assert.equal(detectTuiUpstreamError(banner), banner);
});
// ---- Required matrix: MUST NOT kill (pass) ----
test("C-1 PASS: long answer discussing a 500 (not 4xx, too long)", () => {
// The exact false-positive the over-broad .* rule produced. 166 chars; 5xx.
const legit = "API Error: 500 happened because the server was overloaded. To fix this, retry with exponential backoff and verify your rate limits before resending the request again.";
assert.equal(detectTuiUpstreamError(legit), null);
});
test("C-1 PASS: long answer with 'API Error: 401 details' (too long, no auth keyword)", () => {
// 142 chars; the literal word 'authenticate'/'credential'/'/login' never appears, and
// it is far over the length cap — rejected on length AND keyword.
const legit = "Failed to parse the config. Here are the API Error: 401 details you asked about: the token expired and must be refreshed before the next call.";
assert.equal(detectTuiUpstreamError(legit), null);
});
test("C-1 PASS: 'To debug a 401 … API Error: 401 Unauthorized' (no auth keyword)", () => {
// 91 chars (short!) and 4xx, but 'Unauthorized' is authoriz-, not authenticat-, and
// there is no /login or credential — the auth-keyword signal rejects it.
const legit = "To debug a 401: the server returns API Error: 401 Unauthorized, then you refresh the token.";
assert.equal(detectTuiUpstreamError(legit), null);
});
test("C-1 PASS: handler answer logging 'API Error: 503' (not 4xx)", () => {
const legit = "Here is the handler you asked for. It logs the string API Error: 503 on failure and retries.";
assert.equal(detectTuiUpstreamError(legit), null);
});
test("C-1 PASS: short instructional answer quoting `API Error: 401` + /login (has backtick)", () => {
// 75 chars: short, 4xx, has '/login' — passes signals 1-3. Rejected ONLY by the
// backtick/quote constraint: it QUOTES the error in code formatting, it is not the banner.
const legit = "You'll see `API Error: 401` when your token expires — run /login to fix it.";
assert.equal(detectTuiUpstreamError(legit), null);
});
test("C-1 PASS: bare HTTP-status sentence (no 'API Error:' core)", () => {
assert.equal(detectTuiUpstreamError("HTTP 401 means unauthorized."), null);
});
test("C-1 PASS: plain unrelated answer", () => {
assert.equal(detectTuiUpstreamError("The capital of France is Paris."), null);
});
// ---- Supporting / regression coverage ----
test("C-1 PASS: transient 5xx banner is NOT detected (narrowed to 4xx auth only)", () => {
// The old rule flagged any 3-digit code; the narrowed detector is 4xx-only by design
// (5xx is transient/server-side, not the R-1 auth case). Accepted false-negative.
assert.equal(detectTuiUpstreamError("API Error: 500 Internal Server Error"), null);
});
test("C-1 PASS: bare 4xx with no auth keyword is NOT detected", () => {
// 'API Error: 403 Forbidden' alone — 4xx and short, but no authenticat/login/credential.
assert.equal(detectTuiUpstreamError("API Error: 403 Forbidden"), null);
});
test("detectTuiUpstreamError trims surrounding whitespace before matching", () => {
const out = detectTuiUpstreamError("\n\n Please run /login · API Error: 401 credential boom \n");
assert.equal(out, "Please run /login · API Error: 401 credential boom");
});
test("detectTuiUpstreamError is case-insensitive on the banner keywords", () => {
// lower-cased: /login + api error: 401 + 'credential' keyword, short, no code char.
assert.ok(detectTuiUpstreamError("please run /login · api error: 401 bad credential") !== null);
});
test("detectTuiUpstreamError does NOT match prose that mentions an API error mid-paragraph (#133 regression guard)", () => {
// A long, legit answer that merely discusses an API error — rejected on length alone.
const para = "When integrating with the upstream service you may occasionally hit an API Error: 401 response if the bearer token has lapsed; the recommended remediation is to re-run the login flow and retry the request with a fresh credential, after which the 401 should clear.";
assert.equal(detectTuiUpstreamError(para), null);
});
test("detectTuiUpstreamError does NOT match a long plain-text auth answer with NO code chars (length cap is load-bearing)", () => {
// 226 chars, no backtick/quote, has 4xx + /login + credential + authenticate — passes
// signals 2-4. ONLY the length cap rejects it. Guards against dropping the cap.
const para = "If you call the endpoint without a bearer token the API Error: 401 response tells you the credential is missing; just authenticate again with /login and the request will succeed on the next attempt without any further changes.";
assert.equal(detectTuiUpstreamError(para), null);
});
test("detectTuiUpstreamError returns null on empty / whitespace / non-string", () => {
assert.equal(detectTuiUpstreamError(""), null);
assert.equal(detectTuiUpstreamError(" \n "), null);
assert.equal(detectTuiUpstreamError(null), null);
assert.equal(detectTuiUpstreamError(undefined), null);
assert.equal(detectTuiUpstreamError(42), null);
});
test("detectTuiUpstreamError respects CLAUDE_TUI_ERROR_PATTERNS override (custom banner)", () => {
// Override with a single custom pattern; the default 401 banner no longer matches,
// but the custom one does (anchored whole-text).
assert.equal(detectTuiUpstreamError("Please run /login · API Error: 401 x", "Session expired, please re-auth"), null);
assert.equal(detectTuiUpstreamError("Session expired, please re-auth", "Session expired, please re-auth"), "Session expired, please re-auth");
});
test("detectTuiUpstreamError with an empty override disables detection (escape hatch)", () => {
assert.equal(detectTuiUpstreamError("API Error: 500 boom", ""), null);
assert.equal(detectTuiUpstreamError("API Error: 500 boom", " "), null);
});
test("detectTuiUpstreamError override accepts '||'-separated patterns", () => {
const raw = "First banner||Second banner";
assert.equal(detectTuiUpstreamError("First banner", raw), "First banner");
assert.equal(detectTuiUpstreamError("Second banner", raw), "Second banner");
assert.equal(detectTuiUpstreamError("Third", raw), null);
});
test("real error fixture: latest assistant text IS the banner and detectTuiUpstreamError flags it", () => {
const evs = parseTranscriptLines(tuiReadFileSync("./lib/tui/fixtures/error-401.jsonl", "utf8"));
const text = extractLatestAssistantText(evs);
assert.equal(text, "Please run /login · API Error: 401 Invalid authentication credentials");
assert.ok(detectTuiUpstreamError(text) !== null, "error fixture's final turn must be flagged as an upstream error");
});
test("real error fixture (Failed-to-authenticate variant): final turn is flagged (#133 runtime gap)", () => {
const evs = parseTranscriptLines(tuiReadFileSync("./lib/tui/fixtures/error-401-failauth.jsonl", "utf8"));
const text = extractLatestAssistantText(evs);
assert.equal(text, "Failed to authenticate. API Error: 401 Invalid authentication credentials");
assert.ok(detectTuiUpstreamError(text) !== null, "Failed-to-authenticate banner must be flagged as an upstream error");
});
test("real complete fixture: final answer is NOT flagged as an upstream error", () => {
const evs = parseTranscriptLines(tuiReadFileSync("./lib/tui/fixtures/complete-haiku.jsonl", "utf8"));
const text = extractLatestAssistantText(evs);
assert.equal(detectTuiUpstreamError(text), null);
});
// ── TUI transcript — polling reader (async) ────────────────────────────── // ── TUI transcript — polling reader (async) ──────────────────────────────
import { readTuiTranscript } from "./lib/tui/transcript.mjs"; import { readTuiTranscript } from "./lib/tui/transcript.mjs";
import { mkdtempSync as tuiMkdtemp, writeFileSync as tuiWriteFile } from "node:fs"; import { mkdtempSync as tuiMkdtemp, writeFileSync as tuiWriteFile } from "node:fs";
@@ -1446,12 +1611,30 @@ await asyncTest("readTuiTranscript returns assistant text when terminal marker p
assert.equal(out.entrypoint, "cli"); assert.equal(out.entrypoint, "cli");
}); });
await asyncTest("readTuiTranscript honours wall-clock cap and returns partial text", async () => { // C-2 (#133): the terminal-marker path must signal a COMPLETE turn.
await asyncTest("readTuiTranscript signals truncated:false when a terminal marker is hit (complete turn)", async () => {
const dir = tuiMkdtemp(`${tuiTmpdir()}/tui-`); const dir = tuiMkdtemp(`${tuiTmpdir()}/tui-`);
const p = `${dir}/s.jsonl`; const p = `${dir}/s.jsonl`;
tuiWriteFile(p, [
JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "done" }] } }),
JSON.stringify({ type: "system", subtype: "turn_duration", durationMs: 1200, entrypoint: "cli" }),
].join("\n") + "\n");
const out = await readTuiTranscript({ transcriptPath: p, wallclockMs: 2000, pollMs: 50 });
assert.equal(out.truncated, false);
});
// C-2 (#133): cap-with-partial-text must be DISTINGUISHABLE from a complete turn.
// Previously both returned {text, entrypoint} identically and the partial was cached
// + returned as finish_reason:stop. The cap path now returns truncated:true so the
// caller (callClaudeTui) can throw instead of serving a cut-off answer.
await asyncTest("readTuiTranscript honours wall-clock cap and flags partial text truncated:true", async () => {
const dir = tuiMkdtemp(`${tuiTmpdir()}/tui-`);
const p = `${dir}/s.jsonl`;
// No terminal marker → reader will spin to the cap then return the partial.
tuiWriteFile(p, JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "partial" }] } }) + "\n"); tuiWriteFile(p, JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "partial" }] } }) + "\n");
const out = await readTuiTranscript({ transcriptPath: p, wallclockMs: 300, pollMs: 50 }); const out = await readTuiTranscript({ transcriptPath: p, wallclockMs: 300, pollMs: 50 });
assert.equal(out.text, "partial"); assert.equal(out.text, "partial");
assert.equal(out.truncated, true);
}); });
await asyncTest("readTuiTranscript against real fixture: entrypoint is 'cli'", async () => { await asyncTest("readTuiTranscript against real fixture: entrypoint is 'cli'", async () => {
@@ -1469,7 +1652,7 @@ await asyncTest("readTuiTranscript throws when no text and cap elapses", async (
}); });
// ── TUI session reaper ─────────────────────────────────────────────────── // ── TUI session reaper ───────────────────────────────────────────────────
import { reapStaleTuiSessions, SESSION_PREFIX } from "./lib/tui/session.mjs"; import { reapStaleTuiSessions, SESSION_PREFIX, buildTuiCmd } from "./lib/tui/session.mjs";
console.log("\nTUI session reaper:"); console.log("\nTUI session reaper:");
@@ -1477,6 +1660,110 @@ test("SESSION_PREFIX is ocp-tui-", () => {
assert.equal(SESSION_PREFIX, "ocp-tui-"); assert.equal(SESSION_PREFIX, "ocp-tui-");
}); });
console.log("\nTUI command construction (proxy-purity / #4):");
test("buildTuiCmd suppresses host CLAUDE.md + auto-memory (proxy purity, #4)", () => {
const cmd = buildTuiCmd("/usr/bin/claude", "claude-haiku", "sid-1", "/home/u", "cli");
// OCP is a proxy: the host's CLAUDE.md / auto-memory must never leak into the proxied turn.
assert.ok(/(^| )CLAUDE_CODE_DISABLE_CLAUDE_MDS=1( |$)/.test(cmd), "must disable CLAUDE.md injection");
assert.ok(/(^| )CLAUDE_CODE_DISABLE_AUTO_MEMORY=1( |$)/.test(cmd), "must disable auto-memory injection");
});
test("buildTuiCmd keeps version pin + entrypoint label + MCP wall", () => {
const cli = buildTuiCmd("/usr/bin/claude", "m", "sid-2", "/home/u", "cli");
assert.ok(cli.includes("DISABLE_AUTOUPDATER=1"), "version pin retained");
assert.ok(cli.includes("CLAUDE_CODE_ENTRYPOINT=cli"), "cli mode labels the subscription pool");
assert.ok(cli.includes("--strict-mcp-config") && cli.includes('mcp__*'), "MCP wall retained");
// 'auto' mode must NOT pin the entrypoint (claude self-classifies via TTY).
const auto = buildTuiCmd("/usr/bin/claude", "m", "sid-3", "/home/u", "auto");
assert.ok(!/CLAUDE_CODE_ENTRYPOINT=/.test(auto), "auto mode leaves entrypoint unset");
assert.ok(/-u CLAUDE_CODE_ENTRYPOINT/.test(auto), "auto mode unsets any inherited entrypoint");
});
// CLAUDE_CODE_OAUTH_TOKEN passthrough (PI231 401 incident): tmux doesn't forward the parent
// env to the pane, so the token must be set explicitly on the pane command or the TUI claude
// falls back to credentials.json (whose refresh token gets corrupted by the spawn/kill cycle).
test("buildTuiCmd passes CLAUDE_CODE_OAUTH_TOKEN when the env is set (shq-escaped)", () => {
const save = process.env.CLAUDE_CODE_OAUTH_TOKEN;
try {
process.env.CLAUDE_CODE_OAUTH_TOKEN = "sk-ant-oat01-abc123";
const cmd = buildTuiCmd("/usr/bin/claude", "m", "sid-tok", "/home/u", "cli");
// shq wraps in single quotes; a plain token renders as 'token'.
assert.ok(cmd.includes("CLAUDE_CODE_OAUTH_TOKEN='sk-ant-oat01-abc123'"),
"token must be set on the pane command, shq-escaped");
} finally {
if (save === undefined) delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
else process.env.CLAUDE_CODE_OAUTH_TOKEN = save;
}
});
test("buildTuiCmd does NOT add CLAUDE_CODE_OAUTH_TOKEN when the env is unset", () => {
const save = process.env.CLAUDE_CODE_OAUTH_TOKEN;
try {
delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
const cmd = buildTuiCmd("/usr/bin/claude", "m", "sid-notok", "/home/u", "cli");
assert.ok(!/CLAUDE_CODE_OAUTH_TOKEN/.test(cmd),
"no token added when env unset (credentials.json-only hosts unaffected)");
} finally {
if (save === undefined) delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
else process.env.CLAUDE_CODE_OAUTH_TOKEN = save;
}
});
test("buildTuiCmd shq-escapes a token containing shell metacharacters (no injection)", () => {
const save = process.env.CLAUDE_CODE_OAUTH_TOKEN;
try {
// A token with a single quote must be escaped via the '\'' idiom so it can't break out
// of the shell string tmux runs via sh -c.
process.env.CLAUDE_CODE_OAUTH_TOKEN = "tok'; rm -rf /;'";
const cmd = buildTuiCmd("/usr/bin/claude", "m", "sid-inj", "/home/u", "cli");
assert.ok(cmd.includes(`CLAUDE_CODE_OAUTH_TOKEN='tok'\\''; rm -rf /;'\\'''`),
"single quote must be shq-escaped, not left bare");
assert.ok(!/CLAUDE_CODE_OAUTH_TOKEN=tok'; rm/.test(cmd), "raw unescaped token must NOT appear");
} finally {
if (save === undefined) delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
else process.env.CLAUDE_CODE_OAUTH_TOKEN = save;
}
});
test("buildTuiCmd OCP_TUI_FULL_TOOLS=1 grants -p-equivalent tool surface (single-user opt-in)", () => {
const save = { ...process.env };
const restore = () => {
for (const k of ["OCP_TUI_FULL_TOOLS", "CLAUDE_MCP_CONFIG", "CLAUDE_ALLOWED_TOOLS"]) {
if (k in save) process.env[k] = save[k]; else delete process.env[k];
}
};
try {
// default (gate off) keeps the MCP wall, no --allowedTools
delete process.env.OCP_TUI_FULL_TOOLS;
const off = buildTuiCmd("/usr/bin/claude", "m", "s", "/home/u", "cli");
assert.ok(off.includes("--strict-mcp-config") && !off.includes("--allowedTools"), "gate off = MCP wall");
// gate on: --allowedTools (default set incl Bash), MCP wall dropped
process.env.OCP_TUI_FULL_TOOLS = "1";
delete process.env.CLAUDE_MCP_CONFIG;
delete process.env.CLAUDE_ALLOWED_TOOLS;
const full = buildTuiCmd("/usr/bin/claude", "m", "s", "/home/u", "cli");
assert.ok(full.includes("--allowedTools") && full.includes("Bash"), "full-tools grants --allowedTools incl Bash");
assert.ok(!full.includes("--strict-mcp-config") && !/--disallowedTools/.test(full), "full-tools drops the MCP wall");
assert.ok(!full.includes("--dangerously-skip-permissions"), "skip-permissions branch is removed (bricks headless TUI)");
// mcp-config threaded through
process.env.CLAUDE_MCP_CONFIG = "/tmp/mcp.json";
const mcp = buildTuiCmd("/usr/bin/claude", "m", "s", "/home/u", "cli");
assert.ok(/--mcp-config '\/tmp\/mcp.json'/.test(mcp), "mcp-config passed through (shq'd)");
// operator-supplied scoped tool specifiers must be shell-quoted (no injection via ()*~)
delete process.env.CLAUDE_MCP_CONFIG;
process.env.CLAUDE_ALLOWED_TOOLS = "Bash(npm run test:*),Read";
const scoped = buildTuiCmd("/usr/bin/claude", "m", "s", "/home/u", "cli");
assert.ok(scoped.includes("'Bash(npm run test:*)'"), "scoped tool tokens are shq'd in the shell string");
assert.ok(!/--allowedTools Bash\(npm/.test(scoped), "scoped token must NOT appear unquoted");
} finally {
restore();
}
});
test("reaper kills ONLY ocp-tui- sessions, never olp-tui-", () => { test("reaper kills ONLY ocp-tui- sessions, never olp-tui-", () => {
const killed = []; const killed = [];
const fakeTmux = (args) => { const fakeTmux = (args) => {
@@ -1508,6 +1795,41 @@ test("reaper returns 0 for empty session list", () => {
assert.equal(killed.length, 0); assert.equal(killed.length, 0);
}); });
// Defunct-zombie reaping (PI231 incident): the pane's claude is a child of the tmux server,
// so only kill-server actually reaps it. We kill-server ONLY when no foreign session remains.
console.log("\nTUI defunct-zombie reaping (kill-server):");
test("reaper kill-servers when the server is ours-only (flush defunct claude zombies)", () => {
const calls = [];
const fakeTmux = (args) => {
calls.push(args.join(" "));
if (args[0] === "list-sessions") return { status: 0, stdout: "ocp-tui-aaaa\nocp-tui-bbbb\n" };
return { status: 0, stdout: "" };
};
const n = reapStaleTuiSessions({ tmux: fakeTmux });
assert.equal(n, 2, "killed both of our sessions");
assert.ok(calls.includes("kill-server"), "kill-server fired — reaps the defunct backlog");
});
test("reaper does NOT kill-server when a foreign (non-ocp) session remains (coexistence)", () => {
const calls = [];
const fakeTmux = (args) => {
calls.push(args.join(" "));
if (args[0] === "list-sessions") return { status: 0, stdout: "ocp-tui-aaaa\nolp-tui-bbbb\n" };
return { status: 0, stdout: "" };
};
const n = reapStaleTuiSessions({ tmux: fakeTmux });
assert.equal(n, 1, "killed only our own session");
assert.ok(!calls.includes("kill-server"), "kill-server MUST NOT fire — would disrupt olp-tui-*");
});
test("reaper does NOT kill-server when there is no server (status !== 0)", () => {
const calls = [];
const fakeTmux = (args) => { calls.push(args.join(" ")); return { status: 1, stdout: "" }; };
reapStaleTuiSessions({ tmux: fakeTmux });
assert.ok(!calls.includes("kill-server"), "no server → no kill-server (early return)");
});
// ── TUI home preparation (scratch vs real) ─────────────────────────────── // ── TUI home preparation (scratch vs real) ───────────────────────────────
import { prepareTuiHome, ensureTuiCwdTrusted } from "./lib/tui/session.mjs"; import { prepareTuiHome, ensureTuiCwdTrusted } from "./lib/tui/session.mjs";
import { mkdtempSync as hMkdtemp, mkdirSync as hMkdir, writeFileSync as hWrite, readFileSync as hRead, existsSync as hExists, readlinkSync as hReadlink } from "node:fs"; import { mkdtempSync as hMkdtemp, mkdirSync as hMkdir, writeFileSync as hWrite, readFileSync as hRead, existsSync as hExists, readlinkSync as hReadlink } from "node:fs";
@@ -1543,54 +1865,231 @@ test("prepareTuiHome real mode (tuiHome===realHome): no symlink, just trusts cwd
assert.equal(j.projects[cwd].hasTrustDialogAccepted, true); // cwd trusted in real config assert.equal(j.projects[cwd].hasTrustDialogAccepted, true); // cwd trusted in real config
}); });
// ── resolveTuiEntrypointEnv ─────────────────────────────────────────────── // ── PR-D: env-token-only credential-isolated home (PI231 401 root fix) ──────
import { resolveTuiEntrypointEnv } from "./lib/tui/session.mjs"; // Interactive claude PREFERS ~/.claude/.credentials.json over CLAUDE_CODE_OAUTH_TOKEN, so a
// stale/corrupt credentials.json SHADOWS the env token (proven live on PI231 — env token +
console.log("\nresolveTuiEntrypointEnv:"); // broken creds = 401; env token + creds moved aside = works). The fix runs the TUI claude in
// a home with NO credentials.json so the env token is authoritative (and no refresh ever
test("mode 'cli' sets CLAUDE_CODE_ENTRYPOINT=cli", () => { // happens → the single-use token can't be corrupted by the spawn+kill cycle).
const env = {}; test("prepareTuiHome env-token mode: NO credentials.json (no symlink, no copy), .claude.json seeded", () => {
resolveTuiEntrypointEnv(env, "cli"); const realHome = hMkdtemp(`${hTmp()}/realT-`);
assert.equal(env.CLAUDE_CODE_ENTRYPOINT, "cli"); hMkdir(`${realHome}/.claude`, { recursive: true });
hWrite(`${realHome}/.claude/.credentials.json`, '{"token":"real-oauth"}'); // real creds DO exist…
hWrite(`${realHome}/.claude.json`, JSON.stringify({ theme: "dark", oauthAccount: { uuid: "secret" }, projects: { "/old/secret": { hasTrustDialogAccepted: true } } }));
const tuiHome = hMkdtemp(`${hTmp()}/scratchT-`);
const cwd = `${tuiHome}/work`;
prepareTuiHome(realHome, tuiHome, cwd, { envTokenMode: true });
// …but the scratch home has NO credentials file at all — neither symlink nor copy.
assert.ok(!hExists(`${tuiHome}/.claude/.credentials.json`), "env-token home must have NO .credentials.json (the whole point — no shadowing, no refresh)");
// .claude.json IS seeded: onboarding complete + ONLY the scratch cwd trusted (no dialog hang).
const seed = JSON.parse(hRead(`${tuiHome}/.claude.json`, "utf8"));
assert.equal(seed.hasCompletedOnboarding, true, "onboarding pre-completed → no onboarding dialog");
assert.equal(seed.projects[cwd].hasTrustDialogAccepted, true, "scratch cwd pre-trusted → no trust dialog");
// Minimal config: the credential-isolated home does NOT inherit the operator's account state.
assert.equal(seed.theme, undefined, "env-token home is minimal — real config not copied in");
assert.equal(seed.oauthAccount, undefined, "real account state not carried into the isolated home");
assert.equal(seed.projects["/old/secret"], undefined, "operator project history not carried in");
assert.ok(hExists(`${tuiHome}/.claude/projects`), "own projects/ dir for transcripts under the same home");
}); });
test("mode 'cli' overwrites an inherited CLAUDE_CODE_ENTRYPOINT value", () => { console.log("\nresolveTuiHome (env-token credential isolation, PR-D):");
const env = { CLAUDE_CODE_ENTRYPOINT: "sdk-cli" }; import { resolveTuiHome, DEFAULT_TUI_SCRATCH_HOME } from "./lib/tui/session.mjs";
resolveTuiEntrypointEnv(env, "cli");
assert.equal(env.CLAUDE_CODE_ENTRYPOINT, "cli"); test("resolveTuiHome: env token set + OCP_TUI_HOME unset → credential-free scratch home", () => {
const h = resolveTuiHome({ realHome: "/home/u", configuredHome: undefined, envTokenSet: true });
assert.equal(h, DEFAULT_TUI_SCRATCH_HOME("/home/u"));
assert.equal(h, "/home/u/.ocp-tui/home");
assert.notEqual(h, "/home/u", "must NOT be the real home — real home has the shadowing credentials.json");
}); });
test("mode 'auto' deletes CLAUDE_CODE_ENTRYPOINT (leaves unset)", () => { test("resolveTuiHome: env token UNSET → real home (legacy credentials.json path, unchanged)", () => {
const env = {}; const h = resolveTuiHome({ realHome: "/home/u", configuredHome: undefined, envTokenSet: false });
resolveTuiEntrypointEnv(env, "auto"); assert.equal(h, "/home/u", "no env token → real home, byte-for-byte the pre-fix behaviour");
assert.equal(env.CLAUDE_CODE_ENTRYPOINT, undefined);
assert.ok(!Object.prototype.hasOwnProperty.call(env, "CLAUDE_CODE_ENTRYPOINT"));
}); });
test("mode 'auto' deletes an inherited CLAUDE_CODE_ENTRYPOINT value", () => { test("resolveTuiHome: explicit OCP_TUI_HOME wins regardless of env token (back-compat)", () => {
const env = { CLAUDE_CODE_ENTRYPOINT: "sdk-cli" }; assert.equal(resolveTuiHome({ realHome: "/home/u", configuredHome: "/custom/home", envTokenSet: true }), "/custom/home");
resolveTuiEntrypointEnv(env, "auto"); assert.equal(resolveTuiHome({ realHome: "/home/u", configuredHome: "/custom/home", envTokenSet: false }), "/custom/home");
assert.equal(env.CLAUDE_CODE_ENTRYPOINT, undefined);
assert.ok(!Object.prototype.hasOwnProperty.call(env, "CLAUDE_CODE_ENTRYPOINT"));
}); });
test("mode 'off' leaves an inherited CLAUDE_CODE_ENTRYPOINT value untouched", () => { // ── TUI concurrency limiter + drift observability (PR-B: audit C-4 / C-5) ──
const env = { CLAUDE_CODE_ENTRYPOINT: "sdk-cli" }; import { TuiSemaphore, recordTuiEntrypoint, buildTuiHealthBlock } from "./lib/tui/semaphore.mjs";
resolveTuiEntrypointEnv(env, "off");
assert.equal(env.CLAUDE_CODE_ENTRYPOINT, "sdk-cli"); console.log("\nTUI concurrency limiter (C-4):");
const deferred = () => { let resolve, reject; const p = new Promise((res, rej) => { resolve = res; reject = rej; }); return { p, resolve, reject }; };
await asyncTest("limit=1 serializes two overlapping calls (second waits for the first)", async () => {
const sem = new TuiSemaphore(1);
const order = [];
const g1 = deferred();
// First task acquires the only slot and blocks on g1.
const t1 = sem.run(async () => { order.push("t1-start"); await g1.p; order.push("t1-end"); });
await new Promise((r) => setImmediate(r)); // let t1 acquire
assert.equal(sem.inflight, 1, "t1 holds the only slot");
// Second task must QUEUE — it has not started yet.
const t2 = sem.run(async () => { order.push("t2-start"); });
await new Promise((r) => setImmediate(r));
assert.equal(sem.queued, 1, "t2 is queued, not running");
assert.deepEqual(order, ["t1-start"], "t2 has not started while t1 holds the slot");
// Release t1 → t2 runs.
g1.resolve();
await t1; await t2;
assert.deepEqual(order, ["t1-start", "t1-end", "t2-start"], "t2 ran only after t1 finished");
assert.equal(sem.inflight, 0, "all slots released");
assert.equal(sem.queued, 0, "queue drained");
}); });
test("mode 'off' with no inherited value leaves env unchanged", () => { await asyncTest("limit=2 allows two concurrent, queues the third", async () => {
const env = { OTHER: "x" }; const sem = new TuiSemaphore(2);
resolveTuiEntrypointEnv(env, "off"); const g = [deferred(), deferred(), deferred()];
assert.equal(env.CLAUDE_CODE_ENTRYPOINT, undefined); const started = [];
assert.equal(env.OTHER, "x"); const tasks = g.map((d, i) => sem.run(async () => { started.push(i); await d.p; }));
await new Promise((r) => setImmediate(r));
assert.equal(sem.inflight, 2, "exactly 2 run concurrently");
assert.equal(sem.queued, 1, "the third is queued");
assert.deepEqual(started.sort(), [0, 1], "only the first two started");
g.forEach((d) => d.resolve());
await Promise.all(tasks);
assert.equal(sem.inflight, 0);
}); });
test("default mode (no second arg) behaves like 'cli'", () => { await asyncTest("slot is RELEASED on throw (finally) — a rejecting task never leaks its slot", async () => {
const env = { CLAUDE_CODE_ENTRYPOINT: "sdk-cli" }; const sem = new TuiSemaphore(1);
resolveTuiEntrypointEnv(env); await assert.rejects(sem.run(async () => { throw new Error("boom"); }), /boom/);
assert.equal(env.CLAUDE_CODE_ENTRYPOINT, "cli"); assert.equal(sem.inflight, 0, "throwing task released its slot");
// Prove the slot is reusable: a subsequent task acquires immediately.
let ran = false;
await sem.run(async () => { ran = true; });
assert.equal(ran, true);
assert.equal(sem.inflight, 0);
});
await asyncTest("wait queue is bounded — run() rejects with tui_queue_full when full (backpressure, not OOM)", async () => {
const sem = new TuiSemaphore(1, { maxQueue: 1 });
const g1 = deferred();
const t1 = sem.run(async () => { await g1.p; }); // holds the slot
await new Promise((r) => setImmediate(r));
const t2 = sem.run(async () => {}); // fills the 1-deep queue
await new Promise((r) => setImmediate(r));
assert.equal(sem.queued, 1, "queue is full");
await assert.rejects(sem.run(async () => {}), /tui_queue_full/, "third request rejects");
g1.resolve();
await t1; await t2;
assert.equal(sem.inflight, 0);
});
console.log("\n-p concurrency wait-queue (FIX ⑥ — same TuiSemaphore reused for the -p path):");
// server.mjs reuses TuiSemaphore as `claudeSemaphore = new TuiSemaphore(MAX_CONCURRENT,
// { maxQueue: CLAUDE_MAX_QUEUE })` and wraps acquire()/release() in acquireClaudeSlot(). These
// tests assert the contract that the 429-mapping depends on: requests beyond the limit QUEUE
// (not reject), only an overflow past the queue rejects (→ HTTP 429 in server.mjs), and a
// released slot is reusable (the #37/#40 slot-leak guard — no leak on normal completion).
await asyncTest("FIX ⑥: requests beyond MAX_CONCURRENT queue, not reject (limit=1, queue=1)", async () => {
const sem = new TuiSemaphore(1, { maxQueue: 1 }); // mirrors CLAUDE_MAX_CONCURRENT=1, CLAUDE_MAX_QUEUE=1
const g1 = deferred();
const inflightP = sem.run(async () => { await g1.p; }); // request 1 — holds the only slot
await new Promise((r) => setImmediate(r));
assert.equal(sem.inflight, 1, "req1 inflight");
const queuedP = sem.run(async () => {}); // request 2 — WAITS (queued), does NOT reject
await new Promise((r) => setImmediate(r));
assert.equal(sem.queued, 1, "req2 queued (waits), not rejected → would be served, not 429");
// request 3 — queue full → reject (server.mjs maps this single case to 429 + Retry-After)
await assert.rejects(sem.run(async () => {}), /tui_queue_full|queue/, "req3 overflows → reject (→429)");
g1.resolve();
await inflightP; await queuedP;
assert.equal(sem.inflight, 0, "all slots released after drain (no leak)");
assert.equal(sem.queued, 0, "queue fully drained");
});
await asyncTest("FIX ⑥: slot released on normal completion is immediately reusable (no #37/#40 leak)", async () => {
const sem = new TuiSemaphore(1, { maxQueue: 16 }); // mirrors default CLAUDE_MAX_QUEUE=16
for (let i = 0; i < 5; i++) {
await sem.run(async () => { /* a normal, completing turn */ });
assert.equal(sem.inflight, 0, `slot released after turn ${i}`);
}
// Prove the limit still binds after many acquire/release cycles.
const g = deferred();
const held = sem.run(async () => { await g.p; });
await new Promise((r) => setImmediate(r));
assert.equal(sem.inflight, 1, "limit still enforced after reuse cycles");
g.resolve(); await held;
assert.equal(sem.inflight, 0);
});
console.log("\nTUI drift observability (C-5):");
test("recordTuiEntrypoint: observed 'cli' is NOT a mismatch and sets lastEntrypoint", () => {
const ts = { lastEntrypoint: null, entrypointMismatches: 0 };
const mism = recordTuiEntrypoint(ts, "cli", "cli");
assert.equal(mism, false);
assert.equal(ts.lastEntrypoint, "cli");
assert.equal(ts.entrypointMismatches, 0);
});
test("recordTuiEntrypoint: expected cli but observed 'sdk-cli' increments the mismatch counter (drift)", () => {
const ts = { lastEntrypoint: null, entrypointMismatches: 0 };
assert.equal(recordTuiEntrypoint(ts, "sdk-cli", "cli"), true);
assert.equal(ts.lastEntrypoint, "sdk-cli");
assert.equal(ts.entrypointMismatches, 1);
// A second drift increments again (counter accumulates across turns).
assert.equal(recordTuiEntrypoint(ts, "sdk-cli", "cli"), true);
assert.equal(ts.entrypointMismatches, 2);
});
test("recordTuiEntrypoint: null observation → lastEntrypoint null, counts as mismatch when expected cli", () => {
const ts = { lastEntrypoint: "cli", entrypointMismatches: 0 };
assert.equal(recordTuiEntrypoint(ts, null, "cli"), true);
assert.equal(ts.lastEntrypoint, null);
assert.equal(ts.entrypointMismatches, 1);
});
test("recordTuiEntrypoint: non-cli expected mode (auto) never counts a mismatch", () => {
const ts = { lastEntrypoint: null, entrypointMismatches: 0 };
assert.equal(recordTuiEntrypoint(ts, "sdk-cli", "auto"), false);
assert.equal(ts.lastEntrypoint, "sdk-cli");
assert.equal(ts.entrypointMismatches, 0);
});
test("buildTuiHealthBlock: shape + live counters (the additive /health tui block)", () => {
const sem = new TuiSemaphore(2);
const ts = { lastEntrypoint: "cli", entrypointMismatches: 3 };
const block = buildTuiHealthBlock(
{ enabled: true, entrypointMode: "cli", maxConcurrent: 2 }, ts, sem);
assert.deepEqual(Object.keys(block).sort(),
["enabled", "entrypointMismatches", "entrypointMode", "inflight", "lastEntrypoint", "maxConcurrent", "queued"]);
assert.equal(block.enabled, true);
assert.equal(block.entrypointMode, "cli");
assert.equal(block.lastEntrypoint, "cli");
assert.equal(block.entrypointMismatches, 3);
assert.equal(block.inflight, 0);
assert.equal(block.queued, 0);
assert.equal(block.maxConcurrent, 2);
});
test("buildTuiHealthBlock: TUI off → enabled:false but block still present (stable shape)", () => {
const sem = new TuiSemaphore(2);
const ts = { lastEntrypoint: null, entrypointMismatches: 0 };
const block = buildTuiHealthBlock(
{ enabled: false, entrypointMode: "cli", maxConcurrent: 2 }, ts, sem);
assert.equal(block.enabled, false);
assert.equal(block.lastEntrypoint, null);
assert.equal(block.entrypointMismatches, 0);
});
await asyncTest("buildTuiHealthBlock reflects live inflight/queued while turns are in flight", async () => {
const sem = new TuiSemaphore(1);
const ts = { lastEntrypoint: null, entrypointMismatches: 0 };
const g1 = deferred();
const t1 = sem.run(async () => { await g1.p; });
const t2 = sem.run(async () => {}); // queued behind t1
await new Promise((r) => setImmediate(r));
const block = buildTuiHealthBlock({ enabled: true, entrypointMode: "cli", maxConcurrent: 1 }, ts, sem);
assert.equal(block.inflight, 1, "one turn in flight");
assert.equal(block.queued, 1, "one turn queued");
g1.resolve();
await t1; await t2;
}); });
// ── TUI session driver: runTuiTurn (live-only, guarded) ────────────────── // ── TUI session driver: runTuiTurn (live-only, guarded) ──────────────────
@@ -1615,6 +2114,77 @@ if (process.env.OCP_TUI_LIVE === "1") {
}); });
} }
// ── TUI readiness / paste-verify predicates (issue #130) ────────────────────
// Replicates tuiInputReady, tuiPromptLanded verbatim from lib/tui/session.mjs.
// Keep in sync with the definitions there.
function _tuiInputReady(pane) {
return /\? for shortcuts/.test(pane);
}
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);
return needle.length >= 2 && flatPane.includes(needle); // C-4 (#133): 3 → 2 (see lib/tui/session.mjs)
}
// Real captured pane samples (empirically confirmed via live capture-pane on PI231,
// claude v2.1.114 and v2.1.159). Source: issue #130 spec.
const TUI_READY_PANE = ` Try "how does <filepath> work?"
? for shortcuts · ← for agents`;
const TUI_LANDED_PANE = ` Reply with exactly: PONG_TEST
? for shortcuts · ← for agents`;
// Welcome splash shown before input bar is rendered — no `? for shortcuts`.
const TUI_BOOT_PANE = `╭─ Claude Code v2.1.114 ─ Welcome back Tao! ─╮\n│ Tips for getting started │`;
console.log("\nTUI readiness + paste-verify predicates (issue #130):");
test("tuiInputReady(READY_PANE) === true (input bar rendered)", () => {
assert.equal(_tuiInputReady(TUI_READY_PANE), true);
});
test("tuiInputReady(LANDED_PANE) === true (input bar still present after paste)", () => {
assert.equal(_tuiInputReady(TUI_LANDED_PANE), true);
});
test("tuiInputReady(BOOT_PANE) === false (welcome splash, no input bar yet)", () => {
assert.equal(_tuiInputReady(TUI_BOOT_PANE), false);
});
test("tuiPromptLanded(READY_PANE, 'Reply with exactly: PONG_TEST') === false (still placeholder)", () => {
assert.equal(_tuiPromptLanded(TUI_READY_PANE, "Reply with exactly: PONG_TEST"), false);
});
test("tuiPromptLanded(LANDED_PANE, 'Reply with exactly: PONG_TEST') === true (prompt prefix visible)", () => {
assert.equal(_tuiPromptLanded(TUI_LANDED_PANE, "Reply with exactly: PONG_TEST"), true);
});
test("tuiPromptLanded(READY_PANE, 'ping') === false (prompt text absent from placeholder pane)", () => {
assert.equal(_tuiPromptLanded(TUI_READY_PANE, "ping"), false);
});
test("tuiPromptLanded(' ping\\n ? for shortcuts', 'ping') === true (needle present, no placeholder)", () => {
assert.equal(_tuiPromptLanded(" ping\n ? for shortcuts", "ping"), true);
});
// C-4 (#133): short prompts (12 char first line) MUST be able to land. Threshold
// lowered 3 → 2. A 2-char prompt ("hi") present in the pane now lands instead of
// 5s-failing with tui_paste_not_landed every time (live-reproduced: "hi").
test("tuiPromptLanded(' hi\\n ? for shortcuts', 'hi') === true (2-char prompt lands — C-4)", () => {
assert.equal(_tuiPromptLanded(" hi\n ? for shortcuts", "hi"), true);
});
// False-positive guard for the lowered threshold: a 2-char needle ABSENT from the
// still-empty placeholder pane must NOT land (no spurious Enter into an empty box).
test("tuiPromptLanded(READY_PANE, 'hi') === false (2-char prompt not yet visible — no false positive)", () => {
assert.equal(_tuiPromptLanded(TUI_READY_PANE, "hi"), false);
});
// issue #130 root cause: a big bracketed paste shows "[Pasted text #N +M lines]" — must be landed.
test("tuiPromptLanded(bracketed-paste pane, big prompt) === true", () => {
assert.equal(_tuiPromptLanded(" [Pasted text #1 +301 lines]\n ? for shortcuts", "[System] Context 0."), true);
});
// issue #130 false-positive guard: the EMPTY placeholder uses a CURLY quote (“) and randomized
// example text — the old placeholder-gone heuristic wrongly reported landed=true here, so Enter
// fired into an empty box. Must be FALSE (no positive signal: not [Pasted text], prompt not shown).
test("tuiPromptLanded(curly-quote placeholder, big prompt) === false (no false-positive)", () => {
assert.equal(_tuiPromptLanded(" Try “how do I log an error?”\n ? for shortcuts", "[System] Context 0."), false);
});
// ── /health anonymousKey gate (issue #109) ────────────────────────────────── // ── /health anonymousKey gate (issue #109) ──────────────────────────────────
// MIRRORS the predicate in server.mjs (search ADVERTISE_ANON_KEY) — copied // MIRRORS the predicate in server.mjs (search ADVERTISE_ANON_KEY) — copied
// verbatim to avoid importing server.mjs (top-level server.listen() would // verbatim to avoid importing server.mjs (top-level server.listen() would