Compare commits

...
Author SHA1 Message Date
1f577c075f chore(release): v3.18.0 — code-audit hardening (P0 /health + P2/P3 batch) + 3 follow-ups (#129)
Bundles the 2026-05-31 multi-agent code audit remediation and its follow-ups:

- #109 (P0): /health no longer leaks the anonymous quota key to LAN (opt-in PROXY_ADVERTISE_ANON_KEY)
- #110: request-validation + OpenAI-compat correctness
- #111: error-output sanitization + process-lifecycle hardening
- #112: OAuth-host verification + models.json SPOT
- #113: CLI/installer hardening
- #114: dashboard XSS escaping + key-name validation
- #115: TUI non-loopback LAN gate + cc_entrypoint assertion
- #123: alignment.yml wrong-host pin + ALIGNMENT.md amendment
- #124: dashboard status/plan card escaping
- #125: isLoopbackBind extracted to lib/net.mjs

Each landed as its own PR with a fresh-context independent reviewer (Iron Rule 10) and
green alignment CI. Version bumped 3.17.1 → 3.18.0; CHANGELOG finalized. 181 tests pass.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 08:47:10 +10:00
6dff36959a chore(governance): pin legacy OAuth host in alignment.yml + ALIGNMENT.md amendment (#123) (#128)
Follow-up to the 2026-05-31 audit (deferred from #112). The OAuth token host
platform.claude.com/v1/oauth/token was verified against the compiled cli.js in #119;
this pins the legacy WRONG host so a future accidental revert hard-fails CI.

- .github/workflows/alignment.yml: add "console.anthropic.com/v1/oauth/token" to the
  BLACKLIST; rewrite the comment + failure message so the blacklist now documents TWO
  kinds of token — known hallucinations AND pinned wrong-host variants of a verified
  Class A endpoint (a hit means a drift, not necessarily a hallucination). The pinned
  token is absent from server.mjs (which uses platform.claude.com), so CI stays green.
- ALIGNMENT.md: new "OAuth token-host verification (2026-05-31)" subsection recording the
  binary verification (claude.exe 2.1.154, strings, no live probe) and the dual-purpose
  blacklist policy. Purely additive; Rules / audit pin / Historical Lesson untouched.

Per ALIGNMENT.md Amendment Procedure: (a) motivating evidence cited (issues #112/#119/#123),
(b) independent fresh-context opus reviewer APPROVE — verified the pinned token does not
trip the build (absent from server.mjs; live host not blacklisted), YAML valid, amendment
consistent with the server.mjs verification comment, purely additive scope. (c) not
incident-driven (a confirming verification, not a new drift) so Historical Lesson unchanged.

Closes #123.

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

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

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

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

Closes #125.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 07:03:55 +10:00
0000926358 fix: escape dashboard status/plan cards (#124) (#126)
Follow-up defense-in-depth from #114's review. The status-cards and plan-cards in
dashboard.html rendered string values into innerHTML without escaping. These are
trusted-but-external (p.version/p.uptime are server-local; s.percent/s.resetsIn/
w.percent/w.resetsIn come from Anthropic's upstream plan API), so not the stored-XSS
vector #114 fixed — but wrapping them in the existing escapeHtml() helper gives uniform
defense-in-depth across all innerHTML sinks. Numeric/computed fields (request counts,
sPct/wPct, barColor) are left unescaped (not injectable).

dashboard.html is a client-side static asset, not server.mjs → cli.js citation N/A.

Independent fresh-context reviewer (opus): APPROVE (Iron Rule 10) — confirmed every
string sink wrapped, numbers correctly untouched, escapeHtml in scope, template literals
intact, 181 tests pass (no regression).

Closes #124.

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

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

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

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

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

Closes #115.

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

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

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

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

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

Closes #114.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 23:03:50 +10:00
68d58e7df4 fix: CLI/installer hardening — restart labels, key permissions, unit-secret escaping (#113) (#120)
Three CLI/installer findings from the 2026-05-31 audit (no server.mjs):

1. ocp-plugin `cmdRestart` hardcoded uid 501 + the legacy `ai.openclaw.proxy`
   label, and fell through to a dangerous `pkill -f 'node.*server.mjs' && cd
   ~/.openclaw/projects/*/; node server.mjs &` that could kill an unrelated node
   process and relaunch an env-less ghost proxy from a glob-ambiguous dir. Now uses
   process.getuid() + the live labels (dev.ocp.proxy on macOS, ocp-proxy on Linux
   systemd; the OpenClaw gateway label is unchanged) and drops the pkill fallback
   entirely (returns a manual `ocp restart` message on failure).

2. ocp-connect wrote the quota key unquoted into rc files and a world-readable
   environment.d/ocp.conf. Now single-quotes the value and chmod 600s the rc files
   and ocp.conf (matching the existing auth-profiles.json 0o600 convention).

3. setup.mjs interpolated the injected service-unit secrets (CLAUDE_BIN,
   OCP_ADMIN_KEY, PROXY_ANONYMOUS_KEY) raw into plist <string> and systemd
   Environment= lines. Added xmlEscape() for all plist <string> values and
   assertSafeInjectValue() which rejects control characters (\x00-\x1f — newline,
   CR, tab) before any unit is written, blocking a newline-injected rogue
   Environment= directive. Spaces are intentionally allowed (CLAUDE_BIN paths may
   contain them). XML-escaping on write also resolves plist-merge.mjs's [^<]* regex
   concern transitively (no raw < reaches it) — comment added, logic unchanged.

ALIGNMENT.md: CLI/installer scripts only, no Anthropic operation forwarded → cli.js
citation N/A. No blacklisted tokens or port literals introduced; alignment.yml passes.

Independent fresh-context reviewer (opus): APPROVE (Iron Rule 10) — verified the
control-char regex byte-exact via od (no space-rejection regression), the pkill
fallback fully removed, restart labels match setup.mjs ground truth, OCP keys
(base64url) cannot break the single-quoting, and the validator runs before any write.

Closes #113.

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

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

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

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

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

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

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

Closes #112.

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

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

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

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

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

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

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

Closes #111.

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

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

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

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

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

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

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

Closes #110.

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

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

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

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

Closes #109.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 22:12:26 +10:00
7b065600aa docs(readme): honest multi-user/security positioning + 6/15 framing + OLP cross-link (#108)
- Deployment model & security section: single-user multi-IDE is the supported
  model; shared/multi keys give usage/quota tracking but NOT a security isolation
  boundary (claude runs with operator FS, not sandboxed) — trusted users only;
  real per-user sandboxed isolation is planned post-2026-06-15.
- Soften the multi-mode 'recommended' label accordingly.
- Add a 'Related: OLP' section linking the multi-provider sibling project.

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

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

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

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

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

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

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

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

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

Hardening per OCP code audit; TUI path behaviour fix.

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 13:17:36 +10:00
a30b20978c chore(release): v3.17.0 — Phase 6c stream-json default + opus 4.8 + opt-in TUI-mode (#105)
- Phase 6c: default claude spawn → stream-json + --system-prompt (~64% cost cut)
- opus 4.8 model
- opt-in CLAUDE_TUI_MODE interactive subscription-pool bridge (single-user)

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 09:33:27 +10:00
cd98b51b96 docs(plans): add anthropic-only sandbox strategy handoff (#102)
Forward-looking planning doc capturing prior-art analysis from OLP's
Phase 7 PR-B re-evaluation, scoped down to OCP's single-provider
(anthropic) deployment.

Documents:
- Multi-tenant gap for OCP (cross-key lateral read, OAuth exposure)
- Why OLP's outer-bwrap PR-B approach is the wrong path to copy
  (Anthropic design intent, ~/.claude.json upstream not-planned)
- Three viable alternatives:
  A. Ephemeral $HOME via env var (~50 LOC, recommended Phase 1)
  B. bwrap --tmpfs $HOME + ro-bind credentials (apt dep, Linux only)
  C. OverlayFS lowerdir+upperdir (needs CAP_SYS_ADMIN)
- Orthogonal cross-key isolation layer (per-spawn customConfig denyRead
  or per-OS-user spawning)
- Trust-tier framing (single-user / family-zone / shared-host)

Not an ADR — becomes one only when work actually starts. Not binding;
ALIGNMENT.md authority requirements still apply when sandbox code lands.

Cross-references OLP's parallel multi-provider work at
dtzp555-max/olp ADR 0014 Amendment 1 (pending) and archive branch
phase-7-pr-b-outer-bwrap-snapshot.

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

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 09:21:11 +10:00
19 changed files with 2878 additions and 114 deletions
+8 -4
View File
@@ -29,10 +29,14 @@ jobs:
exit 0
fi
# Known-hallucinated tokens. Extend only via an ALIGNMENT.md amendment PR.
# Each token is matched as a fixed string against server.mjs only.
# Blacklisted tokens — two kinds (see ALIGNMENT.md "OAuth token-host verification"):
# (1) known LLM hallucinations (e.g. the 2026-04-11 /api/oauth/usage drift), and
# (2) pinned wrong-host variants of a VERIFIED Class A endpoint (a hit means a
# drift to a known-wrong host, not necessarily a hallucination).
# Extend only via an ALIGNMENT.md amendment PR. Matched as fixed strings vs server.mjs.
BLACKLIST=(
"api.anthropic.com/api/oauth/usage"
"console.anthropic.com/v1/oauth/token"
)
FAIL=0
@@ -51,8 +55,8 @@ jobs:
============================================================
server.mjs contains a token on the OCP alignment blacklist.
These tokens were introduced by LLM hallucinations and do
not appear in cli.js at any shipped Claude Code version.
These tokens are either LLM hallucinations that never appeared in cli.js,
or pinned wrong-host variants of a verified Class A endpoint (a drift).
See ALIGNMENT.md -> "Historical Lesson: The 2026-04-11 Drift"
(commit b87992f) for the full incident record.
+20
View File
@@ -52,6 +52,26 @@ The following Rules apply to **Class A operations** (the `cli.js`-mirror surface
The audit pin is updated once per year (see Annual Alignment Audit) and whenever a drift incident forces a re-verification.
### OAuth token-host verification (2026-05-31)
Motivating evidence: the 2026-05-31 code audit (issues #112 / #119 / #123). The OAuth bearer
machinery is a Class A surface (Rules 15). Because `cli.js` now ships as a
compiled binary, the token-refresh host was re-verified against `claude.exe` (Claude Code
`2.1.154`) on 2026-05-31 using the compiled-binary protocol — `strings` on the Mach-O, **no
live OAuth probe** (a `refresh_token` grant would rotate the operator's real credentials):
- **Verified host:** `https://platform.claude.com/v1/oauth/token` — present in the binary
byte-for-byte, paired with `OAUTH_CLIENT_ID` in the same `prod` config object (matches
`server.mjs` `OAUTH_TOKEN_URL` / `OAUTH_CLIENT_ID`). The legacy `console.anthropic.com/v1/oauth`
host is absent (0 hits).
- **Pinned wrong-host variant:** `console.anthropic.com/v1/oauth/token` is added to the
`alignment.yml` blacklist so a future accidental revert to the legacy host hard-fails CI.
The blacklist therefore now holds two kinds of token: (1) known hallucinations (e.g.
`api.anthropic.com/api/oauth/usage`, the 2026-04-11 drift), and (2) pinned wrong-host variants
of a *verified* Class A endpoint. A blacklist hit means either a re-introduced hallucination
**or** a drift to a known-wrong host — both are alignment failures under Rules 2 and 3.
---
## Historical Lesson: The 2026-04-11 Drift
+77 -1
View File
@@ -1,6 +1,82 @@
# Changelog
## Unreleased
## v3.18.0 — 2026-06-01
Hardening release from a multi-agent code audit (1 P0 + 14 P2 + 2 P3 findings, each adversarially verified and independently reviewed) plus three follow-ups (#123#125). Every change shipped as its own PR with a fresh-context reviewer (Iron Rule 10). The single-user default path (`AUTH_MODE=none`, no TUI) is behavior-identical **except** the `/health` change in #109.
### Security
- **#109 (P0)** — `/health` no longer advertises `PROXY_ANONYMOUS_KEY` to remote callers by default. The `anonymousKey` field is gated behind a new `PROXY_ADVERTISE_ANON_KEY=1` opt-in env var; localhost callers are always exempt. Prevents any LAN-reachable device from harvesting a working, quota-spending bearer credential from the unauthenticated `/health` endpoint. **Behavior change:** `ocp-connect` zero-config Path A now requires the server to set `PROXY_ADVERTISE_ANON_KEY=1`; otherwise pass `--key` or use anonymous access.
- **#114** — Dashboard escapes all DB-sourced strings (key names, usage rows) before `innerHTML`; the revoke button uses a `data-` attribute + listener instead of an inline `onclick` a quote could break out of; `POST /api/keys` validates key names server-side (`[A-Za-z0-9 ._-]{1,64}`).
- **#124** — Dashboard status/plan summary cards escaped too (uniform defense-in-depth over all `innerHTML` sinks).
- **#111** — Streaming error paths strip filesystem paths from claude error text / stderr before sending them to clients (`sanitizeError`), matching the non-streaming path.
### Reliability / correctness
- **#110** — Non-array `messages` is rejected with a 400 (was silently hanging the connection until socket timeout); OpenAI array `content` is flattened into the prompt instead of dumped as raw JSON; a streamed upstream error now emits an SSE `error` frame instead of a success-looking `finish_reason:"stop"`.
- **#111** — `res.on("close")` escalates SIGTERM→SIGKILL on client disconnect (closes a narrow re-occurrence of the #37 concurrency-slot leak on the hottest exit path); `overallTimer` is cleared on semantic completion so a slow-exiting child can't record a spurious post-success timeout; per-key quota is documented as best-effort (bounded overshoot ≤ `MAX_CONCURRENT`, cache hits uncounted).
- **#113** — CLI/installer hardening: `ocp-plugin` restart uses the live uid + `dev.ocp.proxy`/`ocp-proxy` labels and drops the unsafe `pkill` fallback; `ocp-connect` quotes + `chmod 600`s the persisted key; `setup.mjs` XML-escapes and newline-validates injected service-unit secrets.
### Alignment / governance
- **#112** — OAuth token-refresh host (`platform.claude.com/v1/oauth/token`) re-verified against the compiled cli.js v2.1.154 (`strings`, no live probe) and recorded in `ALIGNMENT.md`; usage-probe and default request model now derive from `models.json` (ADR 0003 SPOT) instead of hardcoded IDs.
- **#123** — The legacy `console.anthropic.com/v1/oauth/token` host is pinned in the `alignment.yml` blacklist so a future OAuth-host drift hard-fails CI; the blacklist now documents its dual purpose (known hallucinations + pinned wrong-host variants of a verified Class A endpoint).
### TUI
- **#115** — The TUI LAN gate refuses any non-loopback bind (not just literal `0.0.0.0`); the achieved `cc_entrypoint` is asserted each turn and a `tui_entrypoint_mismatch` warning is logged on a silent degrade to the metered sdk-cli pool.
### Refactor
- **#125** — `isLoopbackBind` extracted to `lib/net.mjs`, shared by `server.mjs` and the test suite (was duplicated via a copy-paste mirror).
### New environment variables
- `PROXY_ADVERTISE_ANON_KEY` — opt-in (default off); advertise `PROXY_ANONYMOUS_KEY` on the public `/health` body for remote zero-config discovery (#109).
## v3.17.1 — 2026-05-31
### Fix — code-audit P1/P2 hardening
Fixes from a multi-agent code audit (3 P1 + 5 P2, adversarially verified). The single-user default path (`AUTH_MODE=none`, no TUI) is behavior-identical.
**Availability / correctness (P1):**
- Guard `proc.stdin` against EPIPE — a fast-failing spawned `claude` (auth error, bad model, large prompt) no longer crashes the single-process daemon.
- Add `unhandledRejection`/`uncaughtException`/`clientError` safety nets + wrap all request-body read loops — a client aborting mid-upload no longer crashes the daemon.
- TUI transcript reader: only `turn_duration` is terminal (was also `tool_use`), which silently truncated any TUI turn that used a built-in tool.
**Security gates / cache integrity (P2):**
- `AUTH_MODE=multi`: the default spawn now passes `--disallowedTools` (Bash/Read/Write/Edit/…) so a guest prompt cannot drive operator-filesystem tools. Single-user path unchanged.
- `/sessions` (DELETE), `/settings` (PATCH), `/logs`, `/usage`, `/status` are now admin-gated (were dispatched before the admin check).
- Streaming path no longer caches an `is_error` response as success (cache-poisoning fix).
- TUI fail-loud guard extended to `none`+`0.0.0.0` (unless `OCP_TUI_ALLOW_LAN=1`) and `+ PROXY_ANONYMOUS_KEY`.
- TUI `send-keys` paste uses `-l` (literal) so a prompt equal to a tmux key token (e.g. `C-c`) is typed, not interpreted.
---
## v3.17.0 — 2026-05-31
### Provider — default claude invocation ported to stream-json + `--system-prompt` (Phase 6c)
OCP's default (non-TUI) claude spawn moves from `claude -p --output-format text` to `claude --output-format stream-json --verbose --no-session-persistence --system-prompt <wrapper>` (no `-p`). The NDJSON event stream is parsed into the assembled response. Benefits: ~64% per-request cost reduction and anti-hallucination via `--system-prompt` tool-use suppression. Clients see no API change — the OpenAI-compatible request/response shapes are identical. Faithful port of OLP's production-verified implementation; covered by 17 new stream-json parser tests.
⚠️ **Billing note:** from 2026-06-15 this default path carries `cc_entrypoint=sdk-cli` and bills against the Agent SDK credit pool. Use the new opt-in `CLAUDE_TUI_MODE` (below) to keep traffic on the Pro/Max subscription pool.
---
### feat(tui): opt-in CLAUDE_TUI_MODE — serve via interactive claude (cc_entrypoint=cli / subscription pool), single-user only; default stream-json path unchanged
From 2026-06-15 Anthropic routes `claude -p` / `--output-format` invocations to the Agent SDK credit pool (`cc_entrypoint=sdk-cli`). This feature adds an opt-in bridge: when `CLAUDE_TUI_MODE=true`, OCP serves each request via a real interactive `claude` session (no `-p`, no `--output-format`) so it carries `cc_entrypoint=cli` and bills against the Pro/Max subscription.
The complete string response is read from claude's native JSONL session transcript and replayed to callers as a normal OpenAI completion or chunked SSE. Clients see no API change. The default stream-json path is byte-for-byte unchanged when `CLAUDE_TUI_MODE` is unset.
**Security:** single-user / single-operator only. Never enable on a multi-user OCP. See ADR 0007 and README § "Subscription-pool (TUI) mode".
New env vars: `CLAUDE_TUI_MODE`, `CLAUDE_TUI_WALLCLOCK_MS`, `OCP_TUI_CWD`, `OCP_TUI_HOME`.
New ADR: `docs/adr/0007-tui-interactive-mode.md`.
New modules: `lib/tui/transcript.mjs`, `lib/tui/session.mjs` (shipped in preceding commits on this branch).
---
### Model — add claude-opus-4-8
+96 -5
View File
@@ -50,6 +50,10 @@ OCP and the alternatives serve adjacent but distinct needs. Pick the one that fi
**Plain English**: `claude-code-router` is the routing-and-switching power tool — pick it if you want to mix Anthropic, OpenAI, Gemini, and local models behind one endpoint. `anthropic-proxy` is the minimal forwarder. **OCP focuses on disciplined `cli.js`-aligned forwarding plus subscription multiplexing** — pick it if you want to share one Claude Pro/Max subscription across IDEs, devices, and people, with LAN auth, quotas, and a governance contract that prevents endpoint drift.
### Related: OLP — Open LLM Proxy
OCP is Claude-only by design. If you want to spread across **multiple LLM providers** (not just Claude), see the sibling project **[OLP — Open LLM Proxy](https://github.com/dtzp555-max/olp)**: the same spawn-the-provider-CLI approach, but across several provider CLIs behind one OpenAI-compatible endpoint, with intelligent fallback chains. It grew out of OCP in response to Anthropic's 2026-06-15 billing split — the idea being to spread subscription/quota risk across more than one provider. OCP remains the focused, Claude-only option; OLP is the multi-provider one.
OCP is single-maintainer + LLM-assisted, currently pre-1.0. It runs the maintainer's daily Claude Code workflow. If something breaks, [open an issue](https://github.com/dtzp555-max/ocp/issues).
## Supported Tools
@@ -281,7 +285,7 @@ chmod +x ocp-connect
./ocp-connect <server-ip>
```
**Zero-config** — when the server admin has set `PROXY_ANONYMOUS_KEY` (see [Anonymous Access](#anonymous-access-optional) below), just pass the server IP and nothing else. `ocp-connect` reads the anonymous key from `/health` and uses it automatically:
**Zero-config** — when the server admin has set `PROXY_ANONYMOUS_KEY` *and* opted in with `PROXY_ADVERTISE_ANON_KEY=1` (see [Anonymous Access](#anonymous-access-optional) below), just pass the server IP and nothing else. `ocp-connect` reads the anonymous key from `/health` and uses it automatically. Without the opt-in, `/health` does not expose the key (issue #109); pass `--key` or rely on anonymous access instead:
```bash
./ocp-connect <server-ip>
@@ -366,7 +370,7 @@ OCP Connect v1.3.0
The script automatically:
- Writes env vars to all relevant shell rc files (`.bashrc`, `.zshrc`)
- Sets system-level env vars (`launchctl setenv` on macOS, `environment.d` on Linux)
- **Auto-discovers anonymous key** from `/health.anonymousKey` when no `--key` given (v1.3.0+, requires server v3.10.0+)
- **Auto-discovers anonymous key** from `/health.anonymousKey` when no `--key` given (v1.3.0+, requires server v3.10.0+; server must also set `PROXY_ADVERTISE_ANON_KEY=1` — see [Anonymous Access](#anonymous-access-optional))
- Configures OpenClaw automatically (including per-agent `auth-profiles.json` for multi-agent setups)
- Detects Cline, Continue.dev, Cursor, and opencode, and prints setup hints (manual configuration required for these IDEs)
@@ -405,10 +409,22 @@ ocp keys revoke son-ipad # Revoke a key
|------|-----|----------|
| `none` | `CLAUDE_AUTH_MODE=none` | Trusted home network, no auth needed |
| `shared` | `CLAUDE_AUTH_MODE=shared` + `PROXY_API_KEY=xxx` | Everyone shares one key |
| `multi` | `CLAUDE_AUTH_MODE=multi` + `OCP_ADMIN_KEY=xxx` | Per-person keys with usage tracking (recommended) |
| `multi` | `CLAUDE_AUTH_MODE=multi` + `OCP_ADMIN_KEY=xxx` | Per-person keys for usage tracking + quotas (trusted users only — see Deployment model below) |
> **Usage scope (v3.14.0+):** `/api/usage` returns the caller's own rows by default. Admin callers must pass `?all=true` to retrieve data for all keys; doing so emits an audit log line.
### Deployment model & security (read this)
**What OCP is built for today: single-user, multi-IDE.** Run OCP as a server on one machine and point all of *your own* IDEs/devices at it — one Claude Pro/Max subscription, used everywhere. This is the primary, solid use case.
**Sharing with family / a team — honest limits.** You *can* share OCP on a LAN, but be clear about what the auth modes do and don't give you:
- The per-key modes (`shared` / `multi`) give per-key **usage tracking, quotas, and cache separation** — useful for seeing who used what and capping budgets.
- They do **not** give a **security isolation boundary**. The spawned `claude` runs with the **operator's filesystem access** and is *not* sandboxed per key. **Only share with people you fully trust, on a trusted network.**
- For simple trusted family sharing, the easiest setup is a single shared **anonymous key** (see [Anonymous Access](#anonymous-access-optional)) — no per-person separation, same trust assumption.
**Real per-user isolation (sandboxed, multi-tenant-safe) is planned for after 2026-06-15** — per-key ephemeral home + tool lockdown + an OS sandbox. Until then, treat a multi-user OCP as a *trusted-group convenience*, not a security boundary. (This is also why `CLAUDE_TUI_MODE` is single-user-only — see [Subscription-pool (TUI) mode](#subscription-pool-tui-mode).)
### Anonymous Access (optional)
In `multi` mode, the admin can designate a single well-known "anonymous" key that bypasses `validateKey()` and grants public read/write access. This is useful for letting LAN users (or clients like OpenClaw multi-agent setups) connect without individual per-user keys.
@@ -424,7 +440,7 @@ node setup.mjs --bind 0.0.0.0 --auth-mode multi
If OCP is already installed without it, re-export the env var and re-run `node setup.mjs` (the installer is idempotent — it refreshes the service unit). Then `ocp restart` so the running proxy picks up the new env. Setting `PROXY_ANONYMOUS_KEY` only in your interactive shell **does not** affect the auto-started proxy — the service unit is the source of truth for its environment.
**Client side**: the anonymous key value is exposed via `GET /health` as the field `anonymousKey` (null when not set). Clients like `ocp-connect` can auto-discover and use it, so the end user doesn't need to get a personal key from the admin.
**Client side**: the anonymous key value is exposed via `GET /health` as the field `anonymousKey` (null when not set) **only to localhost callers** or when the admin has also set `PROXY_ADVERTISE_ANON_KEY=1` (default off — see issue #109). With that opt-in, clients like `ocp-connect` can auto-discover and use it, so the end user doesn't need to get a personal key from the admin.
**Security note**: setting this env var is an **opt-in** to public access — anyone who can reach your OCP endpoint can use it, up to any rate limits you configure. Don't enable this on internet-exposed OCP instances without additional protection.
@@ -470,6 +486,8 @@ When a key exceeds its quota, OCP returns HTTP 429 with a structured error:
- Admin and anonymous users are never subject to quotas
- PATCH is a partial update — omitted fields are left unchanged
> **Note:** quotas are best-effort. Under concurrent bursts a key can exceed its cap by up to the server's max-concurrency (default 8), and cache hits are not counted toward quota. They cap budgets for cooperative family use, not adversarial abuse.
### Important Notes
- All users share your Claude Pro/Max **rate limits** (5h session + 7d weekly)
@@ -873,7 +891,13 @@ Future `ocp update` invocations sync automatically.
| `CLAUDE_SKIP_PERMISSIONS` | `false` | Bypass all permission checks |
| `CLAUDE_NO_CONTEXT` | `false` | Suppress CLAUDE.md and auto-memory injection (pure API mode) |
| `PROXY_API_KEY` | *(unset)* | Bearer token for shared-mode authentication |
| `PROXY_ANONYMOUS_KEY` | *(unset)* | Well-known anonymous key allowlist (multi mode). When set, this exact string bypasses `validateKey()` and grants public access. Exposed via `/health.anonymousKey` so clients auto-discover. See [Anonymous Access](#anonymous-access-optional). |
| `PROXY_ANONYMOUS_KEY` | *(unset)* | Well-known anonymous key allowlist (multi mode). When set, this exact string bypasses `validateKey()` and grants public access. Exposed via `/health.anonymousKey` only to localhost, or to all callers when `PROXY_ADVERTISE_ANON_KEY=1`. See [Anonymous Access](#anonymous-access-optional). |
| `PROXY_ADVERTISE_ANON_KEY` | *(unset)* | When `=1`, advertise `PROXY_ANONYMOUS_KEY` in the public `/health` body for remote zero-config discovery. Default off — `/health` is unauthenticated, so this exposes the shared key to any LAN-reachable device (issue #109). Localhost always sees it regardless. |
| `CLAUDE_TUI_MODE` | `false` | **Opt-in.** Set to `"true"` to serve requests via interactive `claude` (no `-p` / `--output-format` → `cc_entrypoint=cli`, subscription pool). **Single-user only** — see [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) for the security constraint. |
| `CLAUDE_TUI_WALLCLOCK_MS` | `120000` | (TUI-mode) Maximum time in ms to wait for the native transcript to signal turn completion. Increase for long Opus thinking turns. |
| `OCP_TUI_CWD` | `$HOME/.ocp-tui/work` | (TUI-mode) Scratch working directory where interactive claude sessions run. Transcripts land under `<HOME>/.claude/projects/<encoded-cwd>/`. Created automatically. |
| `OCP_TUI_HOME` | `$HOME` (real home) | (TUI-mode) `HOME` claude runs under. Default is the operator's real home (shared credentials, existing onboarding). Set to a separate path for scratch-home isolation — see ADR 0007 for the credential-fork caveat. |
| `OCP_TUI_ENTRYPOINT` | `cli` | (TUI-mode) Billing-classifier labeling: `cli` (default) pins `cc_entrypoint=cli` deterministically; `auto` lets claude self-classify via TTY detection; `off` leaves the inherited env untouched. Honest only when the spawn is a genuine interactive PTY — see ADR 0007. |
### Streaming heartbeat
@@ -885,6 +909,73 @@ Heartbeats are inert SSE comment lines — conforming SSE clients ignore them. I
OCP also sends `X-Accel-Buffering: no` on SSE responses so nginx-default proxy buffering does not hold heartbeats in an upstream buffer.
## Subscription-pool (TUI) mode
> **SECURITY — read before enabling.**
> TUI-mode is **single-user / single-operator only**. `claude` runs with the OCP process owner's filesystem access regardless of `HOME` setting. If OCP serves multiple users or guest API keys, a guest prompt could exfiltrate files or exhaust the subscription. **Never enable `CLAUDE_TUI_MODE=true` on a multi-user OCP.**
### What it is and why
From 2026-06-15 Anthropic routes `claude` invocations by `cc_entrypoint`:
| Launch method | `cc_entrypoint` | Billing pool |
|---------------|-----------------|-------------|
| `claude -p` / `--output-format` (OCP default) | `sdk-cli` | Agent SDK credit pool (~$20/mo on Pro) |
| Interactive `claude` (no flags) | `cli` | Pro/Max subscription pool |
TUI-mode lets OCP serve requests via the interactive path so they bill against the subscription pool. The response is read from claude's native JSONL session transcript once the turn is complete, then replayed to the caller as a normal OpenAI completion or chunked SSE response.
### Billing-classifier labeling (`OCP_TUI_ENTRYPOINT`)
`OCP_TUI_ENTRYPOINT` (default `cli`) controls how `CLAUDE_CODE_ENTRYPOINT` is set on the spawn
environment. The default (`cli`) pins the value deterministically — immune to a stray inherited
env var or a future stdout-redirect bug silently flipping it to `sdk-cli`. This label is honest
**only** when the spawn is a genuine interactive PTY (tmux pane, no `-p`, stdout not redirected,
and `tmux new-session` verified to succeed). If you need to observe the raw TTY-derived value, set
`OCP_TUI_ENTRYPOINT=auto`. See ADR 0007 for the full rationale and governing rule.
### Enabling TUI-mode (opt-in)
```bash
# Prerequisites
mkdir -p ~/.ocp-tui/work # one-time scratch cwd setup
# tmux must be installed: brew install tmux / apt install tmux
# Enable
export CLAUDE_TUI_MODE=true
# Optionally tune:
export CLAUDE_TUI_WALLCLOCK_MS=180000 # 3 min cap for long Opus turns
export OCP_TUI_CWD=$HOME/.ocp-tui/work # default; override if needed
export OCP_TUI_ENTRYPOINT=cli # default; use 'auto' to observe TTY-derived value
```
Then restart OCP. At boot you will see:
```
⚠️ TUI-mode ON — single-user only; do NOT enable on a multi-user OCP ...
TUI-mode: ON home=/home/user cwd=/home/user/.ocp-tui/work wallclock=120000ms
```
### What changes / what doesn't
- **Callers see no API change.** The response is a normal OpenAI completion object or chunked SSE — identical wire format.
- **No real token streaming.** TUI-mode buffers the full response then replays it as chunked SSE. You will see a delay then the complete response rather than real-time tokens.
- **Cache and singleflight work normally.** TUI-mode writes the buffered response to the cache on success; cache-hits skip the interactive turn entirely.
- **Default path unchanged.** Unset `CLAUDE_TUI_MODE` and restart → `callClaude` / `callClaudeStreaming` are used again, byte-for-byte identical to today.
### Kill-switch
```bash
unset CLAUDE_TUI_MODE
# restart OCP
```
The stream-json path is restored immediately. No other change is needed.
### Architecture and design decisions
See [`docs/adr/0007-tui-interactive-mode.md`](docs/adr/0007-tui-interactive-mode.md) for the full rationale, home-strategy options, MCP-disable mechanism, coexistence rules, and the B-path (multi-tenant isolation) roadmap.
## Repository Layout
Top-level files a contributor or operator may need to know:
+22 -15
View File
@@ -132,6 +132,10 @@ function fmtChars(n) {
return n > 1000 ? (n/1000).toFixed(0) + "K" : String(n);
}
function escapeHtml(s) {
return String(s ?? "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
function barColor(pct) {
if (pct >= 80) return "bar-red";
if (pct >= 50) return "bar-amber";
@@ -144,8 +148,8 @@ async function refreshStatus() {
const r = data.requests || {};
document.getElementById("status-cards").innerHTML = `
<div class="card"><div class="label">Status</div><div class="value"><span class="tag ${p.status === 'ok' ? 'tag-ok' : 'tag-err'}">${p.status || '?'}</span></div><div class="sub">v${p.version || '?'}</div></div>
<div class="card"><div class="label">Uptime</div><div class="value">${p.uptime || '?'}</div></div>
<div class="card"><div class="label">Status</div><div class="value"><span class="tag ${p.status === 'ok' ? 'tag-ok' : 'tag-err'}">${escapeHtml(p.status || '?')}</span></div><div class="sub">v${escapeHtml(p.version || '?')}</div></div>
<div class="card"><div class="label">Uptime</div><div class="value">${escapeHtml(p.uptime || '?')}</div></div>
<div class="card"><div class="label">Requests</div><div class="value">${r.total || 0}</div><div class="sub">${r.active || 0} active</div></div>
<div class="card"><div class="label">Errors</div><div class="value">${r.errors || 0}</div><div class="sub">${r.timeouts || 0} timeouts</div></div>
<div class="card"><div class="label">Sessions</div><div class="value">${p.activeSessions || 0}</div></div>
@@ -160,15 +164,15 @@ async function refreshStatus() {
document.getElementById("plan-cards").innerHTML = `
<div class="card">
<div class="label">Session (5h)</div>
<div class="value">${s.percent || '?'}</div>
<div class="value">${escapeHtml(s.percent || '?')}</div>
<div class="bar-bg"><div class="bar-fill ${barColor(sPct)}" style="width:${sPct}%"></div></div>
<div class="sub">Resets in ${s.resetsIn || '?'}</div>
<div class="sub">Resets in ${escapeHtml(s.resetsIn || '?')}</div>
</div>
<div class="card">
<div class="label">Weekly (7d)</div>
<div class="value">${w.percent || '?'}</div>
<div class="value">${escapeHtml(w.percent || '?')}</div>
<div class="bar-bg"><div class="bar-fill ${barColor(wPct)}" style="width:${wPct}%"></div></div>
<div class="sub">Resets in ${w.resetsIn || '?'}</div>
<div class="sub">Resets in ${escapeHtml(w.resetsIn || '?')}</div>
</div>
`;
}
@@ -181,21 +185,21 @@ async function refreshUsage() {
const tbody = document.querySelector("#key-usage-table tbody");
tbody.innerHTML = (data.byKey || []).map(k => `
<tr>
<td>${k.key_name}</td>
<td>${escapeHtml(k.key_name)}</td>
<td>${k.requests}</td>
<td>${k.successes}</td>
<td>${k.errors}</td>
<td>${fmtTime(k.avg_elapsed_ms)}</td>
<td class="mono">${k.last_request || '-'}</td>
<td class="mono">${escapeHtml(k.last_request || '-')}</td>
</tr>
`).join("") || '<tr><td colspan="6" style="color:#475569">No usage data yet</td></tr>';
const rtbody = document.querySelector("#recent-table tbody");
rtbody.innerHTML = (data.recent || []).slice(0, 20).map(r => `
<tr>
<td class="mono">${r.created_at?.slice(11, 19) || '?'}</td>
<td>${r.key_name}</td>
<td>${r.model}</td>
<td class="mono">${escapeHtml(r.created_at?.slice(11, 19) || '?')}</td>
<td>${escapeHtml(r.key_name)}</td>
<td>${escapeHtml(r.model)}</td>
<td>${fmtChars(r.prompt_chars)}</td>
<td>${fmtChars(r.response_chars)}</td>
<td>${fmtTime(r.elapsed_ms)}</td>
@@ -216,13 +220,16 @@ async function refreshKeys() {
const tbody = document.querySelector("#keys-table tbody");
tbody.innerHTML = (data.keys || []).map(k => `
<tr>
<td>${k.name}</td>
<td class="mono">${k.keyPreview}</td>
<td class="mono">${k.created_at}</td>
<td>${escapeHtml(k.name)}</td>
<td class="mono">${escapeHtml(k.keyPreview)}</td>
<td class="mono">${escapeHtml(k.created_at)}</td>
<td><span class="tag ${k.revoked ? 'tag-err' : 'tag-ok'}">${k.revoked ? 'revoked' : 'active'}</span></td>
<td>${k.revoked ? '' : `<button class="btn btn-sm btn-danger" onclick="revokeKeyUI('${k.name}')">Revoke</button>`}</td>
<td>${k.revoked ? '' : `<button class="btn btn-sm btn-danger" data-revoke="${escapeHtml(k.name)}">Revoke</button>`}</td>
</tr>
`).join("");
tbody.querySelectorAll("button[data-revoke]").forEach(btn =>
btn.addEventListener("click", () => revokeKeyUI(btn.getAttribute("data-revoke")))
);
} catch(e) { /* not admin */ }
}
+166
View File
@@ -0,0 +1,166 @@
# ADR 0007 — TUI Interactive Mode (subscription-pool bridge)
**Date:** 2026-05-31
**Status:** Accepted — amended by PR-4 (entrypoint hardening)
**Deciders:** project maintainer
**Authority:** claude CLI v2.1.158 interactive mode — verified live on the test host that sessions launched without `-p` / `--output-format` carry `cc_entrypoint=cli` (subscription pool), not `cc_entrypoint=sdk-cli` (Agent SDK credit pool). Mechanism verified on cli.js v2.1.104; live-confirmed on v2.1.158.
---
## Context
On 2026-05-14 Anthropic announced (effective 2026-06-15) a billing split that routes requests by `cc_entrypoint`:
| `cc_entrypoint` value | Billing pool |
|-----------------------|-------------|
| `cli` | Pro/Max subscription pool |
| `sdk-cli` | Agent SDK credit pool (~$20/mo on Pro = easily exhausted) |
OCP's existing path (`claude --output-format stream-json -p`) sets `cc_entrypoint=sdk-cli`. After 2026-06-15 every OCP request will draw from the Agent SDK pool rather than the subscription.
The structural response: add an opt-in mode that drives a real **interactive** `claude` session (no `-p`, no `--output-format`), which carries `cc_entrypoint=cli` and therefore bills against the subscription. The response text is read from claude's native JSONL transcript instead of from `stdout`.
This is a personal-use A-path feature (single-user, single-subscription host). It is **not** a multi-tenant isolation layer.
### Source-verified entrypoint mechanism (PR-4 amendment)
Claude CLI's `main()` calls a startup function (`t$A` in the compiled bundle) that sets
`process.env.CLAUDE_CODE_ENTRYPOINT` **only if unset** to:
```
(argv has -p/--print/--init-only/--sdk-url OR !process.stdout.isTTY) ? "sdk-cli" : "cli"
```
The billing header reads `cc_entrypoint = process.env.CLAUDE_CODE_ENTRYPOINT ?? "unknown"`.
The `"unknown"` branch is dead code for any real `main()` spawn — the startup function always
sets a value on unset env. The **real risk** is not `"unknown"`: it is a **lost TTY** (e.g. stdout
redirected or a non-PTY spawn) silently flipping the self-classification to `"sdk-cli"` and
drawing from the metered pool.
`cc_entrypoint` is one of ~6 upstream run-mode signals. The **dominant discriminator** is the
system-prompt identity block ("official CLI" vs "Claude Agent SDK"), which is driven by genuine
interactivity (no `-p`, no `--output-format`, real PTY) and is overridable by no env var. This
is the real reason the tmux/no-`-p` approach works: the spawn is genuinely interactive, not just
labelled as such.
---
## Decision
Add `CLAUDE_TUI_MODE=true` as an opt-in flag in `server.mjs`.
### How it works
1. Each request spawns a fresh tmux session running `claude --model <M> --session-id <UUID> --strict-mcp-config --disallowedTools 'mcp__*'` (no `-p`, no `--output-format`).
2. The spawn result is checked immediately: if `tmux new-session` returns a non-zero exit status (or a falsy result), the request is aborted with `tui_spawn_failed: tmux session not created` **before** the boot sleep. This is the spawn/PTY gate — OCP must not issue a billing request without a verified interactive session.
3. The serialized prompt (from `messagesToPrompt`) is pasted via `tmux send-keys … "$(cat file)"` + a separate `Enter` key event.
4. The answer is read from claude's native JSONL transcript at `<HOME>/.claude/projects/<encoded-cwd>/<session-id>.jsonl`, polling until a `turn_duration` system event or the wall-clock cap (`CLAUDE_TUI_WALLCLOCK_MS`, default 120 s).
5. The string answer is returned to OCP's existing downstream (singleflight → cache write-back → `completionResponse` / `streamStringAsSSE`) — **same contract as `callClaude`**.
6. Streaming requests are buffered then replayed as chunked SSE (no real token streaming — deliberate; "don't build fragile features").
### Billing-classifier labeling (`OCP_TUI_ENTRYPOINT`, PR-4)
`CLAUDE_CODE_ENTRYPOINT` on the spawn env is managed by `resolveTuiEntrypointEnv(env, mode)`
(exported from `lib/tui/session.mjs`, pure, testable). The function **always deletes any
inherited value first** so a stray env var from OCP's own parent process can never leak in and
mislabel the billing header. Then:
| `OCP_TUI_ENTRYPOINT` | Behaviour |
|----------------------|-----------|
| `cli` (default) | Sets `CLAUDE_CODE_ENTRYPOINT=cli` deterministically — subscription-pool classification. **Honest only because the spawn is a genuine interactive PTY** (tmux pane, no `-p`, stdout not redirected, `new-session` verified). |
| `auto` | Deletes the key → claude self-classifies via `t$A` (TTY → `cli`). Use to observe/diagnose the real TTY-derived value. |
| `off` | Leaves the env exactly as inherited — diagnostics / honesty audit only. |
**Governing rule (verbatim):** *OCP may make a true value deterministic; it may never assert a
value the spawn's real state contradicts. When it cannot make the claim true (e.g. cannot
guarantee a PTY), it fails/drops the request — it does not force the signal.*
This is why the spawn/PTY gate (step 2 above) is load-bearing for `mode="cli"`: if `new-session`
fails, there is no PTY, so asserting `cli` would be dishonest. Abort rather than lie.
OCP never suppresses the billing header (anti-fingerprinting: we do not mask the spawn).
### 2026-06-15 verification protocol
Run one quiesced canary request in TUI-mode and watch the **Agent SDK credit balance** (not the
request header). If the balance drops, the subscription pool is unreachable via spawn. Per the
constitution (`ALIGNMENT.md`), the response is to **drop the Anthropic provider** rather than
escalate spoofing.
Version caveat: mechanism verified on cli.js v2.1.104 + live on v2.1.158. Re-verify after any
major cli.js upgrade.
### Default behaviour is unchanged
When `CLAUDE_TUI_MODE` is unset (the default), no code path touches `callClaudeTui` or `runTuiTurn`. `upstreamCall === callClaude` and streaming uses `callClaudeStreaming` — byte-for-byte identical to the pre-TUI code path.
### Kill-switch
Unset `CLAUDE_TUI_MODE` (or set it to any value other than `"true"`) → stream-json path restored immediately on next restart.
### Home strategy (real-home default)
`TUI_HOME = OCP_TUI_HOME || HOME` (defaults to the operator's real home).
- **Real-home (default, `OCP_TUI_HOME` unset):** claude runs with the operator's own `~/.claude/` — shared credentials, existing onboarding, no OAuth fork risk. `ensureTuiCwdTrusted` seeds the trust record for the scratch cwd in the real `~/.claude.json` (atomic write).
- **Scratch-home opt-in (`OCP_TUI_HOME=<path>`):** a dedicated `HOME` that symlinks `~/.claude/.credentials.json` from the real home (token is never copied) and seeds a stripped `~/.claude.json` (no project history, trusts only the scratch cwd). **Caveat:** claude rewrites `.credentials.json` on OAuth token refresh, replacing the symlink with a regular file — this forks the credentials. Use scratch-home only with a dedicated OAuth or for ephemeral testing.
### Working directory
`TUI_CWD = OCP_TUI_CWD || $HOME/.ocp-tui/work` (dedicated scratch cwd). Transcripts land under `<HOME>/.claude/projects/<encoded-cwd>/` — a stable, single location separate from the operator's real project histories. The directory is created automatically on first request.
### MCP hard-disable
`--strict-mcp-config` (no `--mcp-config` argument) prevents account-attached managed MCP servers from connecting. Belt-and-braces: `--disallowedTools 'mcp__*'` blocks any MCP tool invocation even if a server were somehow loaded. Built-in tools (Bash, Read, etc.) are left enabled on the A-path (single-user, acceptable).
### Session namespace
All tmux sessions use the prefix `ocp-tui-`. The prefix-scoped reaper (`reapStaleTuiSessions`) kills only `ocp-tui-*` sessions, never `olp-tui-*` or any other prefix. A stale-session cleanup runs once at OCP boot when `TUI_MODE` is on.
---
## SECURITY — PROMINENT WARNING
**TUI-mode is SINGLE-USER / SINGLE-OPERATOR ONLY.**
`claude` runs as the OCP process owner with full filesystem access regardless of `HOME` setting. Home selection is **not** user isolation. If OCP is serving multiple users or guest API keys:
- A guest prompt would run `claude` with the **operator's** filesystem access.
- An adversarial prompt could exfiltrate files, run shell commands, or exhaust the subscription.
**Never enable `CLAUDE_TUI_MODE=true` on an OCP instance that serves untrusted callers or multiple users.**
The B-path (multi-tenant isolation) requires:
1. `--tools ""` (no built-in tools)
2. Per-key ephemeral `HOME` (isolated credentials + no cross-key project pollution)
3. Sandbox runtime (e.g. `@anthropic-ai/sandbox-runtime`)
B-path is **deferred** and is not implemented in this ADR. Until B-path lands, TUI-mode must only be enabled on a personal single-user OCP.
---
## Consequences
### Positive
- After 2026-06-15, requests in TUI-mode bill against the Pro/Max subscription pool (`cc_entrypoint=cli`) rather than the Agent SDK credit pool.
- Kill-switch is immediate (unset env var + restart); zero code change required.
- Default stream-json path is untouched — no regression risk for existing deployments.
### Negative / trade-offs
- **No token streaming:** responses are buffered then replayed as chunked SSE. Clients see a delay then the full response arrives; real-time token streaming is not available in TUI-mode.
- **Billing unmeasurable until 2026-06-15:** the `cc_entrypoint=cli` signal is verified, but the credit deduction from the correct pool cannot be confirmed until the billing split activates.
- **tmux dependency:** the host must have `tmux` installed. CI / Docker images that lack tmux cannot use TUI-mode (the default stream-json path is unaffected).
- **Wall-clock cap:** long Opus thinking turns may hit the 120 s cap. Increase `CLAUDE_TUI_WALLCLOCK_MS` if needed (no quiescence heuristic — the reader polls until terminal marker or cap).
- **Grey-area usage:** running an interactive `claude` session headlessly to serve HTTP requests is not an officially documented use case. If Anthropic policy changes to block this pattern, OCP must fall back to the stream-json path (unset `CLAUDE_TUI_MODE`).
### Coexistence
- tmux prefix `ocp-tui-` is registered. Any co-hosted OLP test instance must use `olp-tui-`. Never run two TUI proxies on the same OAuth concurrently — stop one instance during integration testing.
---
## Provenance
TUI-mode originated in a prototype contributed via PR #101 (see the PR for author attribution). The productionization design is in `docs/superpowers/specs/2026-05-30-tui-mode-production-design.md`. Spikes S1S6 / T1T6 were validated live on the test host against `claude v2.1.158`.
+268
View File
@@ -0,0 +1,268 @@
# OCP Anthropic-Only Sandbox Strategy — Handoff Document
**Status:** Forward-looking planning doc (not yet a decision)
**Date:** 2026-05-29
**Audience:** future OCP maintainer / session picking up multi-tenant security work
**Provenance:** authored during OLP Phase 7 PR-B re-evaluation; OLP's parallel analysis (multi-provider) lives at `dtzp555-max/olp` `docs/adr/0014-sandbox-runtime-integration.md` Amendment 1 (pending). This OCP-side doc strips the multi-LLM generalization and keeps only what applies to OCP's single-provider (anthropic) deployment.
---
## 1. Why this doc exists
OCP is in maintenance mode (per OLP ADR 0001 supersession of OCP ADR 0005). It is not under active development for new features. However, two things may eventually drive sandbox work in OCP:
1. **Multi-key OCP deployments.** `OCP_OWNER_TOKEN` + per-key cache namespace already shipped (OCP `lib/keys.mjs`). If multiple human users share an OCP instance, the same multi-tenant filesystem-isolation gap that motivated OLP Phase 7 also exists here.
2. **Cloud or shared-host OCP deployments.** Any deployment beyond "single user on their own machine" inherits the threat surface.
If/when that work starts, this doc is the prior-art capture so the maintainer doesn't repeat OLP's PR-B path (which has a documented dead-end — see § 3.2 below).
This doc is anthropic-only by design — codex/mistral/etc. multi-LLM concerns are out of scope per OCP ADR 0005.
---
## 2. The multi-tenant gap (OCP-specific)
OCP spawns `claude -p` as the OCP-process user. Every spawned claude instance runs with the OCP user's filesystem permissions. Consequences for a multi-key OCP deployment:
1. **Cross-key lateral read.** A prompt-injected `cat ~/.ocp/keys/<other-key>.json` reads any other key's manifest (token hash, owner_tier, providers_enabled — not catastrophic since it's only the *hash*, but still identity-attribution surface).
2. **OAuth credential exposure.** `~/.claude/.credentials.json` is the Anthropic OAuth refresh token. A prompt-injected read of this file = stealing the subscription that OCP exists to pool.
3. **SSH identity exposure.** `~/.ssh/id_*` reachable for lateral movement to other hosts the OCP user can reach.
4. **Other host secrets.** Anything else under the OCP user's home is reachable.
OCP's `ALIGNMENT.md` Class A/B endpoint discipline does not address this — that discipline is wire-level honesty (`cli.js` mirror), not host-level isolation.
The threat model assumes prompt-injection capability — any caller with a valid OCP key + ability to craft a prompt that elicits a tool call. Default `claude -p` mode includes Read/Bash/etc. tool descriptions in the system prompt; the model is **eager** to use them.
---
## 3. Why OLP Phase 7 PR-B is the wrong path to copy
OLP attempted to wrap `claude -p` spawn in `@anthropic-ai/sandbox-runtime` (outer bubblewrap on Linux, sandbox-exec on macOS). This produced four binding problems documented during OLP's re-evaluation:
### 3.1 Anthropic's design doesn't expect external sandboxing
Per Anthropic's [engineering blog on Claude Code sandboxing](https://www.anthropic.com/engineering/claude-code-sandboxing), `sandbox-runtime` is designed to be invoked **by claude code itself** to sandbox **its own** Bash tool / MCP servers / spawn children. It is **not** designed to sandbox claude code as an externally-wrapped process.
Concretely: claude CLI assumes it can freely read+write its own `$HOME`-derived paths (`~/.claude.json`, `~/.claude/.credentials.json`, `~/.config/claude/`, future state files). When wrapped in `bwrap --ro-bind / /`, those writes hit `EROFS` and claude silently exits with no stdout.
### 3.2 `~/.claude.json` upstream status is "closed not planned"
claude CLI writes `~/.claude.json` non-atomically at startup. Upstream issues #28842, #29162, #29217, #28837, #29051, #29250, #7243 all document this. **#29250 is closed as "not planned / duplicate"** — Anthropic is not going to make this file atomic-write because their mental model is that claude runs in an environment that can write its `$HOME`.
For OCP, this means: any outer-sandbox approach that uses `--ro-bind` on `$HOME` will be a **permanent maintenance treadmill** — every new claude CLI version that adds a state file outside the patched mount paths breaks OCP. OLP's PR-B fold-in tried to patch this by promoting `~/.claude/` to rw, which was insufficient (the actual file is `~/.claude.json` at $HOME root, not inside `~/.claude/`).
### 3.3 The threat model doesn't justify the cost
OCP is, per ADR 0005, a personal-and-family-scale tool. The realistic threat surface is misbehaving prompts from family members or self-injected via dependent agents, not adversarial external attackers. The blast radius of a successful cross-key read is bounded (token *hash*, OAuth that's pooled-by-design across all OCP keys).
A maintenance-mode project investing weeks into outer-sandboxing for a hypothetical threat is a poor cost/benefit. There are cheaper architectures (§ 4 below) that get most of the protection.
### 3.4 OLP-specific reason that does NOT apply to OCP
OLP also hit a multi-provider conflict: codex CLI has its own inner bubblewrap that breaks when wrapped in an outer bwrap (openai/codex#16018). **This is not an OCP concern** — OCP only spawns claude. So the multi-provider forcing function for OLP doesn't apply here. The other three reasons (§ 3.13.3) are sufficient on their own.
---
## 4. Three viable approaches for OCP
Ranked by "engineering cost vs isolation strength" — pick by deployment context.
### 4.1 Approach A — Ephemeral `$HOME` via env var (recommended starting point)
Per-spawn setup:
```
ephemeralRoot=/tmp/ocp-spawn/<keyId>/<reqId>/home
mkdir -p $ephemeralRoot/.claude
ln -s ~/.claude/.credentials.json $ephemeralRoot/.claude/.credentials.json
HOME=$ephemeralRoot claude -p --output-format stream-json ...
```
Mechanics:
- claude CLI uses Node's `os.homedir()` which reads `$HOME` env first.
- `~/.claude.json` written by claude on startup → lands in `/tmp/ocp-spawn/<keyId>/<reqId>/home/.claude.json` (tmpfs, discarded after spawn).
- `~/.claude/.credentials.json` is the OAuth file claude needs — symlinked in read-only from the real one.
- Any new state file claude CLI introduces in a future version → also lands in the ephemeral home, no patch needed.
Threat coverage:
- ✅ Solves EROFS upgrade tax permanently — any claude state-file location works because they all land in tmpfs.
- ✅ Cross-key OAuth credential isolation — keyA's ephemeral home has only keyA's symlink, but here the symlink target is the SAME real file because OCP shares OAuth (this is fine: shared OAuth is OCP's design, the symlink just keeps the file inaccessible via `cat ~/.claude/.credentials.json` from a different keyId's ephemeral root).
- ❌ Does NOT solve cross-key lateral filesystem read via absolute paths. A prompt-injected `cat /home/<ocp-user>/.ocp/keys/<otherKey>.json` still works — `os.homedir()` override doesn't affect absolute-path reads.
5-minute spike before adopting:
```bash
HOME=/tmp/fake-home-spike claude --print "echo PONG" --no-session-persistence 2>&1
ls -la /tmp/fake-home-spike # expect: .claude.json + .claude/ created here
find ~/.claude ~/.claude.json -newer /tmp/spike-marker 2>/dev/null # expect: empty
```
If claude falls back to `os.userInfo().homedir` (uses getpwuid_r, ignores HOME env), this approach degrades — fall back to Approach B.
**Engineering cost:** ~50 LOC in OCP's spawn pipeline (mkdir + symlink + env merge + cleanup-on-exit). No new dependencies.
### 4.2 Approach B — Outer bubblewrap with `--tmpfs $HOME` + `--ro-bind` credentials
```
bwrap \
--ro-bind / / \
--tmpfs /home/<ocp-user> \
--ro-bind /home/<ocp-user>/.claude/.credentials.json /home/<ocp-user>/.claude/.credentials.json \
--ro-bind /home/<ocp-user>/.ocp/keys/<thisKeyId>.json /home/<ocp-user>/.ocp/keys/<thisKeyId>.json \
--dev /dev --proc /proc --tmpfs /tmp \
claude -p ...
```
This is the canonical bwrap pattern (Flatpak uses exactly this for every sandboxed app — see [Bubblewrap ArchWiki Examples](https://wiki.archlinux.org/title/Bubblewrap/Examples)).
Threat coverage:
- ✅ Solves EROFS upgrade tax (tmpfs accepts any write path).
- ✅ Cross-key lateral read prevention — only the current key's manifest is bind-mounted in, others are simply absent from the sandbox view.
-`~/.ssh` and similar identity material absent from sandbox.
Trade-offs:
- bwrap dependency: install `bubblewrap` apt package on host.
- Bypasses `@anthropic-ai/sandbox-runtime` library — direct bwrap arg composition. Worth it because sandbox-runtime's outer-wrap design is for short-lived claude-internal subprocesses, not long-running claude CLI itself (per § 3.1).
- macOS: not supported by bwrap (macOS would need separate `sandbox-exec` profile, ~50-100 LOC additional work). OCP cross-machine maintainer deploys mostly on Mac mini + Oracle ARM VM — both Linux on the cloud side, Mac mini side may remain unsandboxed if family-trust-zone.
**Engineering cost:** ~150 LOC for the spawn wrapper + deployment doc updates to require `apt install bubblewrap`. macOS support is a separate ~100 LOC if/when needed.
### 4.3 Approach C — OverlayFS lowerdir (read-only) + tmpfs upperdir (writable)
```
mount -t overlay overlay \
-o lowerdir=/home/<ocp-user>/.claude,upperdir=/tmp/ocp-spawn/<reqId>/upper,workdir=/tmp/ocp-spawn/<reqId>/work \
/tmp/ocp-spawn/<reqId>/merged-claude
HOME=/tmp/ocp-spawn/<reqId>/home claude -p ...
# After spawn: umount + rm -rf
```
Most elegant — claude sees a view identical to its real `~/.claude/`, all writes go to tmpfs upperdir, real `~/.claude/` is never touched.
Trade-offs:
- Requires `CAP_SYS_ADMIN` or rootless-overlayfs (kernel ≥5.11 + user-ns enabled). OCP currently runs as the maintainer's user — no SYS_ADMIN — so this would require either running OCP as root (bad) or rootless-overlayfs setup.
- More moving parts (mount/umount per spawn, work-dir lifetime, cleanup-on-crash).
Better fit if OCP ever moves to a dedicated `ocp` system user with `CAP_SYS_ADMIN` capability via systemd.
**Engineering cost:** ~120 LOC + kernel/permission preflight check.
---
## 5. Cross-key isolation orthogonal layer
The three approaches above all solve `~/.claude.json` EROFS + state-write isolation. None of them alone solve **cross-key lateral filesystem read via absolute paths** (e.g. prompt-injected `cat /home/<user>/.ocp/keys/<otherKey>.json`).
For that, two options compose with any of A/B/C:
### 5.1 Per-spawn `sandbox-runtime` customConfig with `denyRead`
`@anthropic-ai/sandbox-runtime`'s `wrapWithSandbox(command, binShell?, customConfig?, abortSignal?)` accepts per-call override:
```
const otherKeysWorkspaces = listAllKeyManifestsExcept(thisKeyId)
const wrapped = await SandboxManager.wrapWithSandbox(claudeCommand, undefined, {
filesystem: {
denyRead: [
...otherKeysWorkspaces, // all keys except current
'/home/<ocp-user>/.ssh',
'/home/<ocp-user>/.gnupg',
'/home/<ocp-user>/.aws',
],
allowWrite: [ephemeralRoot, '/tmp'],
},
})
```
This adds bwrap deny-paths per-spawn (after sandbox-runtime singleton init). Works in combination with Approach A (the `HOME` env-var override is independent of sandbox-runtime's restrictions).
Caveat: this re-introduces the outer-bwrap concern from § 3.1 — claude CLI is now wrapped after all. Mitigation: use this only for **cross-key isolation**, not for `$HOME` restriction. The `denyRead` paths are all outside `$HOME`, so claude's `~/.claude.json` write is unaffected.
### 5.2 Per-OS-user OCP spawning
Each OCP key gets a dedicated Linux user (`ocp-<keyId>`). Spawn claude as that user via `runuser` or `sudo -u`. OAuth credential shared via Linux group permissions or bind-mount.
True kernel-level uid isolation. Most robust answer for OCP-as-shared-host scenarios.
Trade-offs:
- Setup script complexity (one-time per key).
- Linux-only.
- Doesn't fit Mac mini deployment.
Best fit for a cloud OCP deployment where per-tenant trust isolation matters.
---
## 6. Trust model framing
OCP's authentication layer (`lib/keys.mjs`) provides **attribution** (per-key audit, per-key cache namespace). It does NOT, by itself, provide **isolation** (per-key trust boundary against prompt-injection lateral reads).
This distinction is worth making explicit in OCP's README "Security" section (it currently isn't). The three tiers:
| Tier | Trust Model | Sandbox requirement |
|---|---|---|
| **Single-user** | maintainer's own machine, single OCP token | None — system-user permissions are sufficient |
| **Family-trust-zone** | maintainer + family members on shared OCP instance, all parties trusted not to attack each other | Optional — Approach A (ephemeral $HOME) gives cleanup hygiene without changing trust assumptions |
| **Shared-host / cloud / external callers** | OCP keys handed to potentially-adversarial callers (CI runners, third-party agents, public demo) | Required — Approach B or C + § 5 cross-key isolation |
The current OCP deployment fits tier 1 or 2. The work in this doc applies only when promoting to tier 3.
---
## 7. Recommendation if/when this work starts
**Phase 1 — Approach A (ephemeral `$HOME`) only.**
- ~50 LOC, no apt deps, works on Mac mini + Linux
- Solves the EROFS upgrade tax structurally
- Closes cross-key OAuth-credential-file lateral read
- Cost-effective hygiene improvement
**Phase 2 — Approach B (outer bwrap) gated by deployment config.**
- Add `~/.ocp/config.json` field `security.sandbox: 'off' | 'tmpfs-home'`
- Default off (preserves Mac mini family deployment)
- Operator opts in on Linux cloud deployments
- Apt prereq documented in deployment guide
**Phase 3 — § 5 cross-key isolation (only if tier 3 deployment is planned).**
- Layer per-spawn customConfig denyRead OR per-OS-user spawning
- Treat as separate ADR amendment with its own threat-model evidence
**Skip Approach C** unless a future requirement forces overlay (low likelihood for OCP scope).
---
## 8. Authority citations
This doc claims findings about claude CLI / `@anthropic-ai/sandbox-runtime` behavior. Sources for verification:
- [Anthropic engineering — Claude Code sandboxing](https://www.anthropic.com/engineering/claude-code-sandboxing) (sandbox-runtime design intent)
- [Anthropic sandbox-runtime GitHub](https://github.com/anthropic-experimental/sandbox-runtime) (wrapWithSandbox API + customConfig per-call signature)
- [claude-code#29250 — `.claude.json` non-atomic-write closed-not-planned](https://github.com/anthropics/claude-code/issues/29250)
- [claude-code#29162 — read-only `~/.claude.json` startup hang](https://github.com/anthropics/claude-code/issues/29162)
- [claude-code#29217 — concurrent-write corruption](https://github.com/anthropics/claude-code/issues/29217)
- [claude-code#28842 — Windows startup race](https://github.com/anthropics/claude-code/issues/28842)
- [claude-code#7243 — "the .claude.json elephant in the room"](https://github.com/anthropics/claude-code/issues/7243)
- [Bubblewrap README](https://github.com/containers/bubblewrap)
- [Bubblewrap ArchWiki — Examples section, --tmpfs HOME pattern](https://wiki.archlinux.org/title/Bubblewrap/Examples)
- [Sandboxing CLI tools with Bubblewrap — botmonster](https://botmonster.com/self-hosting/sandbox-linux-apps-cli-tools-bubblewrap/)
- [OverlayFS kernel documentation](https://docs.kernel.org/filesystems/overlayfs.html)
- [OverlayFS ArchWiki](https://wiki.archlinux.org/title/Overlay_filesystem)
OLP's parallel work (multi-provider generalization of this strategy, including the codex inner-bwrap conflict that does not apply to OCP):
- `dtzp555-max/olp` `docs/adr/0014-sandbox-runtime-integration.md` (PR-B as-shipped) + Amendment 1 (pending — Solution 1 architecture)
- `dtzp555-max/olp` `docs/plans/cloud-deployment-family.md` § 5 (deployment-side trust tier mapping)
- archive branch `dtzp555-max/olp:phase-7-pr-b-outer-bwrap-snapshot` captures the outer-bwrap approach as snapshot if anyone wants to revisit it
---
## 9. What this doc is NOT
- Not an ADR. ADRs are decisions; this is a forward-facing strategy doc that becomes an ADR only when work starts and a decision is made.
- Not a binding spec. The three approaches are alternatives; the recommendation in § 7 is the maintainer's lean from prior-art analysis, not a constitution.
- Not authority for any code change. OCP `ALIGNMENT.md` still requires citation per Class A/B; no sandbox code lands without proper authority pinning when the work eventually starts.
- Not a security audit. The threat model is informal — based on prior-art search + incident memory from OLP's parallel session. A real cloud deployment should commission an independent threat model.
---
**Authors:** project maintainer (handoff prepared with AI drafting assistance during OLP Phase 7 PR-B re-evaluation, 2026-05-29).
@@ -0,0 +1,737 @@
# TUI-mode (OCP-first) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add an opt-in `CLAUDE_TUI_MODE` to OCP that serves `/v1/chat/completions` by driving a *real interactive* `claude` session (no `-p`, no `--output-format`) so the request bills as `cc_entrypoint=cli` (subscription pool), reading the answer from claude's native JSONL transcript — while the default stream-json path stays byte-for-byte unchanged.
**Architecture:** Two new pure-ish modules under `lib/tui/` — a transcript **reader** (`transcript.mjs`, provider-agnostic, the shareable core) and a tmux **session driver** (`session.mjs`, OCP-specific). `server.mjs` gains a `callClaudeTui()` that returns `Promise<string>` and is gated into the existing dispatch by a single env flag; because OCP's entire downstream (singleflight → `setCachedResponse``completionResponse` / chunked-SSE-replay → `recordUsage`) already consumes a string from `callClaude`, TUI-mode is a drop-in. Streaming is buffered then replayed as chunked SSE (no token streaming — deliberately, "don't build fragile features").
**Tech Stack:** Node.js ESM (`.mjs`), `tmux` (interactive PTY host), `child_process` (`spawnSync`), `node:fs` polling (no `fs.watch`, no terminal-screen parsing). Test harness: `node test-features.mjs`.
**Source of truth for the TUI mechanism:** the OLP design spec `docs/superpowers/specs/2026-05-30-tui-mode-production-design.md` (CLI-level, applies to both projects) + its 6 validation spikes (S1S6, T1T6) run on PI231 against `claude v2.1.158`. This plan is the OCP-grounded execution of that spec.
---
## Why OCP-first / scope decisions (read before coding)
- **OCP-first** because OCP has the users and its compute path is `callClaude → Promise<string>`, a near-perfect impedance match for a reader that also returns a string. OLP would additionally need a string→IR-chunk-array adapter. OLP-sync is **deferred entirely until the post-2026-06-15 fork decision** — do not spend cycles keeping OLP's TUI in lockstep.
- **A-path only.** Single-user / multi-device on one subscription. No per-key ephemeral isolation, no multi-tenant. (That is the OLP B-path, deferred.)
- **A-path isolation = real `$HOME` + dedicated scratch cwd + `--strict-mcp-config`.** OCP has *no* ISOLATION contract and we do not build one. We run interactive `claude` in the operator's real home (OAuth + onboarding already valid) but in a **dedicated scratch working directory** (`OCP_TUI_CWD`, default `$HOME/.ocp-tui/work`) so transcripts land under one stable `projects/<cwd>` folder instead of polluting the operator's genuine project histories, and the trust-folder dialog is granted once.
- **One `claude` session per request.** OCP is stateless (full conversation re-serialized each request via `messagesToPrompt`). TUI-mode mirrors this: per request, start a fresh interactive session with a fresh `--session-id`, submit one serialized prompt, await turn completion, read the transcript, extract the latest assistant text, tear the session down. Warm-pool / large-paste optimizations are explicitly out of v1 scope.
- **Billing is unmeasurable until 2026-06-15.** Spike S1 proved the `cc_entrypoint=cli` *signal*, not the billed pool. The pre-6/15 deliverable is "a tested, working transport that emits `cli`"; 6/16 we flip the flag and measure with a documented kill-switch.
- **Coexistence rule (PI231 runs an OLP test instance too).** All tmux sessions use the prefix `ocp-tui-`; the reaper kills **only** `ocp-tui-*`, never `olp-tui-*`. Never run two TUI proxies on the same OAuth concurrently — stop the OLP test instance during OCP integration.
- **Provenance.** TUI-mode originated in OCP PR #101 (author courtesy: jaekwon-park <insainty21@gmail.com>). The PR #101 author should be credited + notified on the shipping PR.
---
## File Structure
| File | Responsibility | New/Modified |
|------|----------------|--------------|
| `lib/tui/transcript.mjs` | Pure transcript parsing + the polling reader. Returns the latest assistant text once the turn is terminal or the wall-clock cap elapses. Provider-agnostic — the shareable core. | **Create** |
| `lib/tui/session.mjs` | tmux session lifecycle: boot interactive `claude`, answer the trust dialog, submit the prompt (file → `"$(cat)"` paste → separate Enter), await the reader, tear down. Plus the prefix-scoped reaper. OCP-specific. | **Create** |
| `lib/tui/fixtures/` | Real transcript JSONL harvested from PI231 + a few hand-crafted edge cases, for the reader's unit tests. | **Create** |
| `server.mjs` | `callClaudeTui()` (`Promise<string>`); `streamStringAsSSE()` helper (DRY refactor of the cache-replay block); single-flag dispatch gates; reaper hook at boot; env consts. | **Modify** (`:258` env consts, `:1018``:1023` helpers, `:1467` dispatch, boot block) |
| `test-features.mjs` | Suite for the reader (fixtures, runs in CI) + a live-only guarded suite for the driver (`OCP_TUI_LIVE=1`, skipped in CI). | **Modify** |
| `docs/adr/0007-tui-interactive-mode.md` | OCP ADR 0007 (OCP's next number) — TUI mode rationale, billing-signal authority, scope, kill-switch. | **Create** |
| `README.md` | New env vars (`CLAUDE_TUI_MODE`, `CLAUDE_TUI_WALLCLOCK_MS`, `OCP_TUI_CWD`), a "Subscription-pool (TUI) mode" section, troubleshooting + kill-switch. | **Modify** |
| `CHANGELOG.md` | Unreleased entry. | **Modify** |
---
## PR-1 — Transcript reader (`lib/tui/transcript.mjs`)
The shareable core. Pure functions + a polling reader. Fully unit-testable from committed fixtures; needs PI231 only once, to harvest realistic fixtures.
### Task 0: Harvest real fixtures from PI231
**Files:**
- Create: `lib/tui/fixtures/complete-haiku.jsonl` (real, has `turn_duration`)
- Create: `lib/tui/fixtures/complete-sonnet-multiblock.jsonl` (real, multi content-block answer)
- [ ] **Step 1: Drive one real interactive turn on PI231 and copy its transcript**
On PI231 (the only box with an authenticated interactive `claude`), run a single interactive turn in a scratch cwd, then locate its transcript:
Run (on PI231):
```bash
SID=$(uuidgen)
mkdir -p ~/.ocp-tui/work
# drive one turn by hand in tmux OR reuse a transcript already produced by the S-spikes:
ls -t ~/.claude/projects/-home-*-.ocp-tui-work/*.jsonl 2>/dev/null | head
# pick one complete transcript (must contain a line with "subtype":"turn_duration")
```
Expected: at least one `.jsonl` file whose tail contains `{"type":"system","subtype":"turn_duration",...}`.
- [ ] **Step 2: Copy 2 real transcripts into the repo as fixtures, scrubbed**
Run (from the workstation):
```bash
scp pi231:'~/.claude/projects/<encoded-cwd>/<sid>.jsonl' lib/tui/fixtures/complete-haiku.jsonl
# Scrub: the transcript may contain the prompt/answer text only (no OAuth token — tokens
# live in ~/.claude/.credentials.json, NOT in projects/*.jsonl). Confirm no credential
# material before committing:
grep -iE "sk-ant|oat01|bearer|authorization" lib/tui/fixtures/*.jsonl && echo "STOP: scrub" || echo "clean"
```
Expected: `clean`. (Transcripts hold conversation content + metadata, never the bearer token. If a fixture's prompt text is sensitive, replace it with a benign hand-edited turn that keeps the JSON shape.)
- [ ] **Step 3: Commit the fixtures**
```bash
git add lib/tui/fixtures/complete-haiku.jsonl lib/tui/fixtures/complete-sonnet-multiblock.jsonl
git commit -m "test(tui): real claude transcript fixtures harvested from PI231 (v2.1.158)"
```
### Task 1: `encodeCwd` + `transcriptPath` (the path formula)
**Files:**
- Create: `lib/tui/transcript.mjs`
- Test: `test-features.mjs` (new Suite "TUI transcript")
- [ ] **Step 1: Write the failing test**
Add to `test-features.mjs`:
```js
// ── Suite: TUI transcript reader ────────────────────────────────────────
import { encodeCwd, transcriptPath } from "./lib/tui/transcript.mjs";
test("encodeCwd replaces every slash incl. leading", () => {
assertEqual(encodeCwd("/home/u/.ocp-tui/work"), "-home-u-.ocp-tui-work");
});
test("transcriptPath composes EHOME/.claude/projects/<enc>/<sid>.jsonl", () => {
assertEqual(
transcriptPath("/home/u", "/home/u/.ocp-tui/work", "abc-123"),
"/home/u/.claude/projects/-home-u-.ocp-tui-work/abc-123.jsonl"
);
});
```
- [ ] **Step 2: Run to verify it fails**
Run: `node test-features.mjs 2>&1 | grep -i "tui transcript\|Cannot find module"`
Expected: FAIL — `Cannot find module './lib/tui/transcript.mjs'`.
- [ ] **Step 3: Minimal implementation**
Create `lib/tui/transcript.mjs`:
```js
// Transcript reader for TUI-mode. Reads claude's native JSONL session transcript
// and returns the latest assistant turn's text once the turn is terminal.
//
// Authority: claude CLI v2.1.158 — interactive session transcript at
// <HOME>/.claude/projects/<CWD with every "/" -> "-">/<--session-id>.jsonl
// Completion marker: a line {"type":"system","subtype":"turn_duration",...}.
// See docs/superpowers/specs/2026-05-30-tui-mode-production-design.md §4.
import { readFileSync, existsSync } from "node:fs";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Project-dir encoding: every "/" -> "-" (including the leading slash).
export function encodeCwd(cwd) {
return cwd.replace(/\//g, "-");
}
export function transcriptPath(home, cwd, sessionId) {
return `${home}/.claude/projects/${encodeCwd(cwd)}/${sessionId}.jsonl`;
}
```
- [ ] **Step 4: Run to verify it passes**
Run: `node test-features.mjs 2>&1 | grep -i "tui transcript"`
Expected: PASS for both cases.
- [ ] **Step 5: Commit**
```bash
git add lib/tui/transcript.mjs test-features.mjs
git commit -m "feat(tui): transcript path formula (encodeCwd + transcriptPath)"
```
### Task 2: `parseTranscriptLines` + `isTerminalLine` + `extractLatestAssistantText`
**Files:**
- Modify: `lib/tui/transcript.mjs`
- Test: `test-features.mjs`
- [ ] **Step 1: Write the failing tests**
```js
import { parseTranscriptLines, isTerminalLine, extractLatestAssistantText } from "./lib/tui/transcript.mjs";
import { readFileSync } from "node:fs";
test("parseTranscriptLines skips blank + malformed/partial lines", () => {
const evs = parseTranscriptLines('{"a":1}\n\n{bad json\n{"b":2}\n');
assertEqual(evs.length, 2);
assertEqual(evs[1].b, 2);
});
test("isTerminalLine true on turn_duration", () => {
assertEqual(isTerminalLine({ type: "system", subtype: "turn_duration" }), true);
});
test("isTerminalLine true on stop_reason tool_use (message-wrapped + flat)", () => {
assertEqual(isTerminalLine({ type: "assistant", message: { stop_reason: "tool_use" } }), true);
assertEqual(isTerminalLine({ stop_reason: "tool_use" }), true);
});
test("isTerminalLine false on ordinary assistant/text lines", () => {
assertEqual(isTerminalLine({ type: "assistant", message: { content: [{ type: "text", text: "hi" }] } }), false);
});
test("extractLatestAssistantText concatenates text blocks of the LAST assistant turn", () => {
const evs = [
{ type: "assistant", message: { content: [{ type: "text", text: "first" }] } },
{ type: "user", message: { content: "..." } },
{ type: "assistant", message: { content: [{ type: "text", text: "A" }, { type: "thinking", thinking: "x" }, { type: "text", text: "B" }] } },
];
assertEqual(extractLatestAssistantText(evs), "AB");
});
test("real complete fixture yields non-empty text and is terminal", () => {
const evs = parseTranscriptLines(readFileSync("./lib/tui/fixtures/complete-haiku.jsonl", "utf8"));
assert(evs.some(isTerminalLine), "fixture must contain a terminal line");
assert(extractLatestAssistantText(evs).length > 0, "fixture must yield assistant text");
});
```
- [ ] **Step 2: Run to verify it fails**
Run: `node test-features.mjs 2>&1 | grep -i "parseTranscript\|isTerminal\|extractLatest\|real complete fixture"`
Expected: FAIL — exports not defined.
- [ ] **Step 3: Minimal implementation** (append to `lib/tui/transcript.mjs`)
```js
// Parse NDJSON text into objects; skip blank lines and partial/forming lines
// (the live transcript is read mid-write, so the last line may be incomplete).
export function parseTranscriptLines(text) {
const out = [];
for (const line of text.split("\n")) {
const t = line.trim();
if (!t) continue;
try { out.push(JSON.parse(t)); } catch { /* partial line being written */ }
}
return out;
}
// A line marks the assistant turn complete when it is the turn_duration system
// event, or an assistant message that stopped to hand off to a tool.
export function isTerminalLine(obj) {
if (!obj || typeof obj !== "object") return false;
if (obj.type === "system" && obj.subtype === "turn_duration") return true;
const sr = (obj.message && obj.message.stop_reason) || obj.stop_reason;
return sr === "tool_use";
}
// Text of the LAST assistant turn: concatenate its text content blocks
// (ignore thinking/tool_use blocks). Later assistant entries overwrite earlier.
export function extractLatestAssistantText(events) {
let text = "";
for (const ev of events) {
if (!ev || ev.type !== "assistant") continue;
const content = ev.message && ev.message.content;
if (!Array.isArray(content)) continue;
const parts = content
.filter((b) => b && b.type === "text" && typeof b.text === "string")
.map((b) => b.text);
if (parts.length) text = parts.join("");
}
return text;
}
```
- [ ] **Step 4: Run to verify it passes**
Run: `node test-features.mjs 2>&1 | grep -iE "parseTranscript|isTerminal|extractLatest|real complete fixture"`
Expected: all PASS.
- [ ] **Step 5: Commit**
```bash
git add lib/tui/transcript.mjs test-features.mjs
git commit -m "feat(tui): transcript parsing + terminal detection + assistant-text extraction"
```
### Task 3: `readTuiTranscript` (the polling reader with wall-clock cap)
**Files:**
- Modify: `lib/tui/transcript.mjs`
- Test: `test-features.mjs`
- [ ] **Step 1: Write the failing tests**
```js
import { readTuiTranscript } from "./lib/tui/transcript.mjs";
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
test("readTuiTranscript returns assistant text when terminal marker present", async () => {
const dir = mkdtempSync(`${tmpdir()}/tui-`);
const p = `${dir}/s.jsonl`;
writeFileSync(p, [
JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "hello world" }] } }),
JSON.stringify({ type: "system", subtype: "turn_duration", durationMs: 1200 }),
].join("\n") + "\n");
const out = await readTuiTranscript({ transcriptPath: p, wallclockMs: 2000, pollMs: 50 });
assertEqual(out, "hello world");
});
test("readTuiTranscript honours wall-clock cap and returns partial text", async () => {
const dir = mkdtempSync(`${tmpdir()}/tui-`);
const p = `${dir}/s.jsonl`;
writeFileSync(p, JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "partial" }] } }) + "\n");
const out = await readTuiTranscript({ transcriptPath: p, wallclockMs: 300, pollMs: 50 }); // never terminal
assertEqual(out, "partial");
});
test("readTuiTranscript throws when no text and cap elapses", async () => {
const dir = mkdtempSync(`${tmpdir()}/tui-`);
const p = `${dir}/missing.jsonl`; // file never appears
let threw = false;
try { await readTuiTranscript({ transcriptPath: p, wallclockMs: 200, pollMs: 50 }); }
catch { threw = true; }
assert(threw, "must throw on empty timeout");
});
```
- [ ] **Step 2: Run to verify it fails**
Run: `node test-features.mjs 2>&1 | grep -i "readTuiTranscript"`
Expected: FAIL — export not defined.
- [ ] **Step 3: Minimal implementation** (append)
```js
// Block until the session transcript is terminal (turn_duration / tool_use) or
// the wall-clock cap elapses, polling the file (no fs.watch — robust over NFS /
// editors). Returns the latest assistant text. On cap with text, returns the
// partial text; on cap with no text at all, throws.
//
// No quiescence heuristic by design: a long Opus thinking turn stalls transcript
// growth and a "file stable for N s" rule would false-abort it (spec §4.3).
export async function readTuiTranscript({ transcriptPath: p, wallclockMs = 120000, pollMs = 250 }) {
const deadline = Date.now() + wallclockMs;
let lastText = "";
while (Date.now() < deadline) {
if (existsSync(p)) {
const events = parseTranscriptLines(readFileSync(p, "utf8"));
lastText = extractLatestAssistantText(events) || lastText;
if (events.some(isTerminalLine)) return lastText;
}
await sleep(pollMs);
}
if (lastText) return lastText;
throw new Error("tui_transcript_timeout: no assistant text within wallclock cap");
}
```
- [ ] **Step 4: Run to verify it passes**
Run: `node test-features.mjs 2>&1 | grep -i "readTuiTranscript"`
Expected: all 3 PASS.
- [ ] **Step 5: Commit**
```bash
git add lib/tui/transcript.mjs test-features.mjs
git commit -m "feat(tui): polling transcript reader with wall-clock cap (no quiescence)"
```
---
## PR-2 — Session driver (`lib/tui/session.mjs`)
tmux lifecycle + the validated submission recipe. Cannot be unit-tested without a live authenticated `claude`; tested by a live-only guarded suite that runs on PI231.
### Task 4: `reapStaleTuiSessions` (prefix-scoped reaper)
**Files:**
- Create: `lib/tui/session.mjs`
- Test: `test-features.mjs`
- [ ] **Step 1: Write the failing test** (pure — no live claude; inject a fake tmux runner)
```js
import { reapStaleTuiSessions, SESSION_PREFIX } from "./lib/tui/session.mjs";
test("reaper kills ONLY ocp-tui- sessions, never olp-tui-", () => {
const killed = [];
const fakeTmux = (args) => {
if (args[0] === "list-sessions") return { status: 0, stdout: "ocp-tui-aaaa\nolp-tui-bbbb\nmisc\nocp-tui-cccc\n" };
if (args[0] === "kill-session") { killed.push(args[args.indexOf("-t") + 1]); return { status: 0 }; }
return { status: 0, stdout: "" };
};
const n = reapStaleTuiSessions({ tmux: fakeTmux });
assertEqual(SESSION_PREFIX, "ocp-tui-");
assertEqual(n, 2);
assertEqual(killed.join(","), "ocp-tui-aaaa,ocp-tui-cccc");
});
```
- [ ] **Step 2: Run to verify it fails**
Run: `node test-features.mjs 2>&1 | grep -i "reaper kills"`
Expected: FAIL — module/export missing.
- [ ] **Step 3: Minimal implementation**
Create `lib/tui/session.mjs`:
```js
// TUI-mode session driver: hosts an interactive `claude` in tmux, submits one
// serialized prompt, awaits the transcript reader, tears down. OCP-specific.
//
// Authority: claude CLI v2.1.158 interactive mode (no -p / no --output-format
// => cc_entrypoint=cli). Submission recipe + dialog handling validated by spikes
// T3/T6 on PI231. See docs/superpowers/specs/2026-05-30-tui-mode-production-design.md.
import { spawnSync } from "node:child_process";
import { mkdtempSync, writeFileSync, mkdirSync, existsSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
import { transcriptPath, readTuiTranscript } from "./transcript.mjs";
export const SESSION_PREFIX = "ocp-tui-"; // per-proxy namespace (coexistence rule)
const TMUX = process.env.OCP_TUI_TMUX_BIN || "tmux";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const defaultTmux = (args, opts = {}) => spawnSync(TMUX, args, { encoding: "utf8", ...opts });
// Kill ONLY our own stale sessions. Scoped to SESSION_PREFIX so a co-hosted
// OLP test instance's `olp-tui-*` sessions are never touched.
export function reapStaleTuiSessions({ tmux = defaultTmux } = {}) {
const r = tmux(["list-sessions", "-F", "#{session_name}"]);
if (!r || r.status !== 0) return 0; // no tmux server / no sessions
let killed = 0;
for (const name of String(r.stdout || "").split("\n").map((s) => s.trim()).filter(Boolean)) {
if (name.startsWith(SESSION_PREFIX)) { tmux(["kill-session", "-t", name]); killed++; }
}
return killed;
}
```
- [ ] **Step 4: Run to verify it passes**
Run: `node test-features.mjs 2>&1 | grep -i "reaper kills"`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add lib/tui/session.mjs test-features.mjs
git commit -m "feat(tui): prefix-scoped session reaper (ocp-tui-* only)"
```
### Task 5: `runTuiTurn` (boot → trust dialog → paste → Enter → read → teardown)
**Files:**
- Modify: `lib/tui/session.mjs`
- Test: `test-features.mjs` (live-only, guarded by `OCP_TUI_LIVE=1`)
- [ ] **Step 1: Write the live-only guarded test** (skipped in CI; run on PI231)
```js
// Live-only: requires an authenticated interactive `claude`. Skipped unless OCP_TUI_LIVE=1.
if (process.env.OCP_TUI_LIVE === "1") {
test("runTuiTurn drives a real interactive turn and returns text", async () => {
const { runTuiTurn } = await import("./lib/tui/session.mjs");
const out = await runTuiTurn({
prompt: "Reply with exactly the word PONG and nothing else.",
model: "claude-haiku-4-5-20251001",
claudeBin: process.env.OCP_TUI_CLAUDE_BIN || "claude",
home: process.env.HOME,
cwd: `${process.env.HOME}/.ocp-tui/work`,
wallclockMs: 120000,
});
assert(/PONG/i.test(out), `expected PONG, got: ${out.slice(0, 200)}`);
});
} else {
test("runTuiTurn (live) — SKIPPED (set OCP_TUI_LIVE=1 on PI231 to run)", () => { assert(true); });
}
```
- [ ] **Step 2: Run to verify it fails** (on a box, with the flag)
Run (PI231): `OCP_TUI_LIVE=1 node test-features.mjs 2>&1 | grep -i "runTuiTurn"`
Expected: FAIL — `runTuiTurn` not exported yet.
- [ ] **Step 3: Implementation** (append to `lib/tui/session.mjs`)
```js
// Boot wait + dialog timing. Conservative defaults validated on PI231; env-tunable.
const BOOT_MS = parseInt(process.env.OCP_TUI_BOOT_MS || "3500", 10);
const DIALOG_MS = parseInt(process.env.OCP_TUI_DIALOG_MS || "1200", 10);
const PASTE_SETTLE_MS = parseInt(process.env.OCP_TUI_PASTE_MS || "1800", 10);
const shq = (s) => `'${String(s).replace(/'/g, "'\\''")}'`; // single-quote for sh -c
// Build interactive claude argv: NO -p, NO --output-format (=> cc_entrypoint=cli).
// MCP hard-disabled: --strict-mcp-config (no --mcp-config) is the only mechanism
// that stops account-attached managed MCP from connecting (spec §5.2 / T6),
// belt-and-braces with --disallowedTools "mcp__*".
function buildTuiCmd(claudeBin, model, sessionId) {
return [
shq(claudeBin),
"--model", shq(model),
"--session-id", sessionId,
"--strict-mcp-config",
"--disallowedTools", shq("mcp__*"),
].join(" ");
}
export async function runTuiTurn({
prompt, model, claudeBin, home, cwd,
wallclockMs = 120000, tmux = defaultTmux,
}) {
const sessionId = randomUUID();
const tmuxName = SESSION_PREFIX + sessionId.slice(0, 8);
if (!existsSync(cwd)) mkdirSync(cwd, { recursive: true });
const tmpDir = mkdtempSync(`${tmpdir()}/ocp-tui-`);
const promptFile = `${tmpDir}/prompt.txt`;
writeFileSync(promptFile, prompt, { mode: 0o600 });
const env = { ...process.env, CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL: "1" };
delete env.CLAUDECODE; delete env.ANTHROPIC_API_KEY; delete env.ANTHROPIC_BASE_URL; delete env.ANTHROPIC_AUTH_TOKEN;
if (home) env.HOME = home;
try {
// 1. Boot the interactive session inside tmux, in the dedicated scratch cwd.
tmux(["new-session", "-d", "-s", tmuxName, "-x", "220", "-y", "50", "-c", cwd,
buildTuiCmd(claudeBin, model, sessionId)], { env });
await sleep(BOOT_MS);
// 2. Answer the trust-folder dialog defensively. The seeded bypass flag (if any)
// suppresses the *bypass-permissions* dialog but NOT the trust-folder dialog;
// "1" = "Yes, proceed". Harmless if the dialog is absent (cwd already trusted).
tmux(["send-keys", "-t", tmuxName, "1"]);
tmux(["send-keys", "-t", tmuxName, "Enter"]);
await sleep(DIALOG_MS);
// 3. Submit the prompt. Body is pasted via `"$(cat file)"` so the content never
// touches the command line (no shell injection from prompt text), then a
// SEPARATE Enter key event submits it (Ink #15553: literal "\n" in a paste
// does not submit; the Enter key event does).
spawnSync("sh", ["-c",
`${shq(TMUX)} send-keys -t ${shq(tmuxName)} -- "$(cat ${shq(promptFile)})"`],
{ env, encoding: "utf8" });
await sleep(PASTE_SETTLE_MS);
tmux(["send-keys", "-t", tmuxName, "Enter"]);
// 4. Read the answer from the native transcript.
const tpath = transcriptPath(home || process.env.HOME, cwd, sessionId);
return await readTuiTranscript({ transcriptPath: tpath, wallclockMs });
} finally {
// 5. Teardown — always. Kill the session, remove the temp prompt dir.
try { tmux(["kill-session", "-t", tmuxName]); } catch { /* already gone */ }
try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best effort */ }
}
}
```
- [ ] **Step 4: Run to verify it passes** (PI231, live)
Run (PI231): `OCP_TUI_LIVE=1 node test-features.mjs 2>&1 | grep -i "runTuiTurn"`
Expected: PASS — output contains `PONG`. Also confirm no orphan sessions: `tmux ls 2>/dev/null | grep ocp-tui- || echo "clean"``clean`.
- [ ] **Step 5: Commit**
```bash
git add lib/tui/session.mjs test-features.mjs
git commit -m "feat(tui): runTuiTurn — interactive session driver (boot/trust/paste/Enter/read/teardown)"
```
---
## PR-3 — Wiring into `server.mjs`
Gate TUI-mode behind one env flag. Default path (`CLAUDE_TUI_MODE` unset) stays byte-for-byte identical.
### Task 6: env consts + `streamStringAsSSE` DRY refactor
**Files:**
- Modify: `server.mjs` (env consts near `:275`; refactor cache-replay block `:1524``:1539` into a helper near `:1023`)
- [ ] **Step 1: Add TUI env consts + import** (near the other `const ... = process.env...` at `server.mjs:258``:275`)
```js
import { runTuiTurn, reapStaleTuiSessions } from "./lib/tui/session.mjs";
// TUI-mode (subscription-pool bridge). Opt-in; default OFF keeps stream-json path.
// Authority: docs/adr/0007-tui-interactive-mode.md.
const TUI_MODE = process.env.CLAUDE_TUI_MODE === "true";
const TUI_WALLCLOCK_MS = parseInt(process.env.CLAUDE_TUI_WALLCLOCK_MS || "120000", 10);
const TUI_CWD = process.env.OCP_TUI_CWD || `${process.env.HOME}/.ocp-tui/work`;
```
- [ ] **Step 2: Extract the chunked-SSE-replay into a reusable helper** (near `completionResponse` at `:1023`)
```js
// Replay a complete string as a chunked SSE stream (80 codepoints/chunk).
// Extracted from the cache-hit replay block so TUI-mode streaming reuses it.
function streamStringAsSSE(res, id, model, content) {
const created = Math.floor(Date.now() / 1000);
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no" });
sendSSE(res, { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }] });
const CHUNK = 80;
const codepoints = Array.from(content);
for (let i = 0; i < codepoints.length; i += CHUNK) {
sendSSE(res, { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { content: codepoints.slice(i, i + CHUNK).join("") }, finish_reason: null }] });
}
sendSSE(res, { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: {}, finish_reason: "stop" }] });
res.write("data: [DONE]\n\n");
res.end();
}
```
- [ ] **Step 3: Point the cache-hit streaming replay (`:1524``:1539`) at the helper** (DRY — behavior identical)
Replace the inline block inside `if (stream) { ... }` of the cache hit with:
```js
if (stream) {
const id = `chatcmpl-${randomUUID()}`;
streamStringAsSSE(res, id, model, cached.response);
return;
} else {
```
- [ ] **Step 4: Run the full suite to verify no regression**
Run: `node test-features.mjs 2>&1 | tail -3`
Expected: all existing tests PASS (the refactor is behavior-preserving; cache-replay covered by existing D3 tests).
- [ ] **Step 5: Commit**
```bash
git add server.mjs
git commit -m "refactor(server): extract streamStringAsSSE helper + add TUI env consts"
```
### Task 7: `callClaudeTui` + dispatch gates
**Files:**
- Modify: `server.mjs` (new `callClaudeTui` near `callClaude:735`; gates at the buffered dispatch `:1563`/`:1594` and streaming dispatch `:1551`)
- [ ] **Step 1: Add `callClaudeTui`** (near `callClaude`, after `:800`)
```js
// TUI-mode upstream: drive an interactive claude session, return the assistant
// text as a string — same contract as callClaude(), so all downstream
// (singleflight, cache write-back, completionResponse) is unchanged.
// System messages are rendered inline as [System] blocks by messagesToPrompt;
// we deliberately do NOT pass --system-prompt in interactive mode to avoid any
// flag that could perturb cc_entrypoint classification.
function callClaudeTui(model, messages, conversationId, keyName) {
const cliModel = MODEL_MAP[model] || model;
const prompt = messagesToPrompt(messages); // includes system as [System] inline
recordModelRequest(cliModel, prompt.length);
return runTuiTurn({
prompt, model: cliModel, claudeBin: CLAUDE,
home: process.env.HOME, cwd: TUI_CWD, wallclockMs: TUI_WALLCLOCK_MS,
}).then((text) => {
recordModelSuccess(cliModel, 0);
return text;
}).catch((err) => {
recordModelError(cliModel, false);
throw err;
});
}
```
- [ ] **Step 2: Gate the buffered dispatch** — at `server.mjs:1563``:1597`, replace the two `callClaude(...)` call sites (inside the singleflight closure and the cache-disabled fallback) with a selected upstream:
Add once, just before the `if (CACHE_TTL > 0 && req._cacheHash)` block (~`:1563`):
```js
const upstreamCall = TUI_MODE ? callClaudeTui : callClaude;
```
Then change `await callClaude(model, messages, conversationId, req._authKeyName)``await upstreamCall(model, messages, conversationId, req._authKeyName)` at **both** sites (`:1572` and `:1594`).
- [ ] **Step 3: Gate the streaming dispatch** — at `server.mjs:1551``:1553`, branch TUI streaming to buffer-then-replay:
```js
if (stream) {
if (TUI_MODE) {
// TUI has no token stream; buffer the turn, write-back to cache, replay as chunked SSE.
const t0Usage = Date.now();
try {
const content = await callClaudeTui(model, messages, conversationId, req._authKeyName);
if (CACHE_TTL > 0 && req._cacheHash) {
try { setCachedResponse(req._cacheHash, model, content); } catch (e) { logEvent("error", "cache_write_failed", { error: e.message }); }
}
const id = `chatcmpl-${randomUUID()}`;
streamStringAsSSE(res, id, model, content);
try { recordUsage({ keyId: req._authKeyId, keyName: req._authKeyName, model, promptChars: messages.reduce((a, m) => a + (typeof m.content === "string" ? m.content.length : JSON.stringify(m.content).length), 0), responseChars: content.length, elapsedMs: Date.now() - t0Usage, success: true }); } catch {}
return;
} catch (err) {
if (res.headersSent || res.writableEnded || res.destroyed) { try { res.end(); } catch {}; return; }
const safeMessage = (err.message || "Internal error").replace(/\/[\w/.\-]+/g, "[path]");
return jsonResponse(res, 500, { error: { message: safeMessage, type: "proxy_error" } });
}
}
// Default: real stream-json streaming, unchanged.
return callClaudeStreaming(model, messages, conversationId, res, { keyId: req._authKeyId, keyName: req._authKeyName, cacheHash: req._cacheHash });
}
```
- [ ] **Step 4: Verify default path is untouched + TUI path selected only by flag**
Run: `CLAUDE_TUI_MODE= node -e "process.env.CLAUDE_TUI_MODE; import('./server.mjs')" 2>&1 | head -1 || true`
Then the regression suite: `node test-features.mjs 2>&1 | tail -3`
Expected: all PASS (no test sets `CLAUDE_TUI_MODE`, so `upstreamCall === callClaude` and streaming uses `callClaudeStreaming` — identical to today).
Live end-to-end (PI231, after Task 8 setup): with `CLAUDE_TUI_MODE=true` start OCP and `curl` both `stream:false` and `stream:true`:
```bash
curl -s localhost:3456/v1/chat/completions -H "Authorization: Bearer <key>" \
-d '{"model":"claude-haiku-4-5-20251001","messages":[{"role":"user","content":"say PONG"}]}' | head
```
Expected: a normal OpenAI completion whose content contains `PONG`. Cross-check on PI231 that the spawned `claude` had no `-p`/`--output-format` (`ps -ef | grep claude`).
- [ ] **Step 5: Commit**
```bash
git add server.mjs
git commit -m "feat(tui): gate interactive TUI upstream behind CLAUDE_TUI_MODE (buffered + streaming)"
```
### Task 8: reaper hook at boot + ADR + README + CHANGELOG
**Files:**
- Modify: `server.mjs` (boot block — call `reapStaleTuiSessions()` once on startup when `TUI_MODE`)
- Create: `docs/adr/0007-tui-interactive-mode.md`
- Modify: `README.md`, `CHANGELOG.md`
- [ ] **Step 1: Reaper on boot** (in the server start/`listen` block)
```js
if (TUI_MODE) {
try { const n = reapStaleTuiSessions(); if (n) logEvent("info", "tui_reaped_stale_sessions", { count: n }); } catch {}
console.log(` TUI-mode: ON (interactive claude → cc_entrypoint=cli). cwd=${TUI_CWD} wallclock=${TUI_WALLCLOCK_MS}ms`);
}
```
- [ ] **Step 2: Write ADR 0007**`docs/adr/0007-tui-interactive-mode.md`
Context: 2026-06-15 billing split routes by `cc_entrypoint`; `-p`/`--output-format``sdk-cli` (Agent SDK credit pool, ~$20 on Pro = unusable). Decision: opt-in interactive driver ⇒ `cli` (subscription pool). Authority: spec §1/§4, claude v2.1.158. Scope: A-path single-user; MCP hard-disabled via `--strict-mcp-config`. Kill-switch: unset `CLAUDE_TUI_MODE` → stream-json path restored. Consequences: no token streaming (buffered+replayed); grey-area, billing unmeasurable until 6/15; reaper + tmux-prefix coexistence rules.
- [ ] **Step 3: README** — add `CLAUDE_TUI_MODE`, `CLAUDE_TUI_WALLCLOCK_MS`, `OCP_TUI_CWD` to the env-var table; add a "Subscription-pool (TUI) mode" section (what it is, opt-in, the 6/15 rationale, no-streaming caveat, the one-time `mkdir -p ~/.ocp-tui/work` + tmux dependency, and the `CLAUDE_TUI_MODE` unset kill-switch).
- [ ] **Step 4: CHANGELOG** — Unreleased: `feat(tui): opt-in CLAUDE_TUI_MODE — serve via interactive claude (cc_entrypoint=cli / subscription pool); default stream-json path unchanged.`
- [ ] **Step 5: Commit**
```bash
git add server.mjs docs/adr/0007-tui-interactive-mode.md README.md CHANGELOG.md
git commit -m "feat(tui): boot reaper + ADR 0007 + README + CHANGELOG (TUI-mode docs)"
```
---
## Integration & canary (post-implementation, on PI231)
1. Stop the OLP test instance (`:4567`) — clean shared OAuth + no tmux collision.
2. `git clone`/checkout this branch on PI231, `mkdir -p ~/.ocp-tui/work`, start OCP on `:3456` with `CLAUDE_TUI_MODE=true`.
3. Run the live driver suite: `OCP_TUI_LIVE=1 node test-features.mjs`.
4. End-to-end `curl` (buffered + streaming) through OCP; confirm spawned `claude` carries no `-p`/`--output-format`.
5. **Pre-6/15 deliverable = here.** Billing measurement waits for 6/15; document the kill-switch (unset `CLAUDE_TUI_MODE`).
---
## Self-Review (against spec + the OCP-first execution review)
- **Spec coverage:** transcript path formula (§4 → Task 1), parsing/terminal/extract (§4 → Task 2), polling reader + wall-clock cap + no-quiescence (§4.3 → Task 3), submission recipe file→paste→Enter (§5/T3 → Task 5), trust-dialog handling (§5.2 → Task 5), MCP disable `--strict-mcp-config` (§5.2/T6 → Tasks 5 & buildTuiCmd), string-contract drop-in (→ Tasks 67), kill-switch + default-path-sacred (→ Task 7 Step 4), coexistence prefix + reaper (→ Tasks 4 & 8). ✅
- **Review findings folded:** OCP-first string match (Task 7); no ephemeral-home, real-home + scratch cwd (scope §); reader-only sharing, driver forked (file table); tmux prefix + scoped reaper + never-both-on-OAuth (Task 4, Integration §1); `TIMEOUT=600000 > 120s` cap verified (no SIGKILL-mid-turn); `--strict-mcp-config` added (Task 5); provenance jaekwon-park (Why §). OLP-sync deferred. ✅
- **Placeholder scan:** none — every code step carries real code; every run step an exact command + expected output. ✅
- **Type consistency:** `runTuiTurn`/`reapStaleTuiSessions`/`SESSION_PREFIX` exported in Task 45 match imports in Task 68; `streamStringAsSSE(res, id, model, content)` defined Task 6, used Tasks 67; `callClaudeTui(model, messages, conversationId, keyName)` mirrors `callClaude`'s signature. ✅
- **Open item for integration:** confirm on PI231 that the seeded `~/.claude.json` is unnecessary for real-home A (onboarding already complete); if a bypass-permissions dialog *does* appear in real home, add a one-line seed step (`bypassPermissionsModeAccepted:true`) — but the driver already answers the trust dialog defensively, so the turn still completes.
+9
View File
@@ -0,0 +1,9 @@
// OCP network helpers — shared so server.mjs and tests use one definition. (issue #125)
// A bind address is "loopback" only if it cannot be reached from another host.
// Any other address (0.0.0.0, ::, a concrete LAN/Tailscale IP, etc.) is
// network-exposed and must trigger the TUI LAN gate.
export function isLoopbackBind(addr) {
return addr === "127.0.0.1" || addr === "::1" || addr === "localhost" ||
addr === "::ffff:127.0.0.1" || /^127\./.test(addr);
}
File diff suppressed because one or more lines are too long
+250
View File
@@ -0,0 +1,250 @@
// TUI-mode session driver: hosts an interactive `claude` in tmux, submits one
// serialized prompt, awaits the transcript reader, tears down. OCP-specific.
//
// Authority: claude CLI v2.1.158 interactive mode (no -p / no --output-format
// => cc_entrypoint=cli). Submission recipe validated by spikes T3/T6 on PI231.
// See docs/superpowers/specs/2026-05-30-tui-mode-production-design.md.
//
// Trust handling: rather than answer the trust-folder dialog interactively (which
// only appears on a cwd's FIRST encounter — sending a defensive "1" to an already
// trusted cwd would inject a stray prompt turn), we PRE-TRUST the scratch cwd by
// seeding <home>/.claude.json. Every turn then boots dialog-free and identical.
import { spawnSync } from "node:child_process";
import { mkdtempSync, writeFileSync, readFileSync, mkdirSync, existsSync, rmSync, statSync, renameSync, symlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
import { readTuiTranscript } from "./transcript.mjs";
export const SESSION_PREFIX = "ocp-tui-"; // per-proxy namespace (coexistence rule)
const TMUX = process.env.OCP_TUI_TMUX_BIN || "tmux";
const defaultTmux = (args, opts = {}) =>
spawnSync(TMUX, args, { encoding: "utf8", ...opts });
// Kill ONLY our own stale sessions. Scoped to SESSION_PREFIX so a co-hosted
// OLP test instance's `olp-tui-*` sessions are never touched.
export function reapStaleTuiSessions({ tmux = defaultTmux } = {}) {
const r = tmux(["list-sessions", "-F", "#{session_name}"]);
if (!r || r.status !== 0) return 0; // no tmux server / no sessions
let killed = 0;
for (const name of String(r.stdout || "").split("\n").map((s) => s.trim()).filter(Boolean)) {
if (name.startsWith(SESSION_PREFIX)) {
tmux(["kill-session", "-t", name]);
killed++;
}
}
return killed;
}
// ── Task 5: runTuiTurn ───────────────────────────────────────────────────
// Boot + paste-settle timing. Conservative defaults validated on PI231; env-tunable.
const BOOT_MS = parseInt(process.env.OCP_TUI_BOOT_MS || "4000", 10);
const PASTE_SETTLE_MS = parseInt(process.env.OCP_TUI_PASTE_MS || "1800", 10);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Single-quote escaper for sh -c arguments.
function shq(s) {
return `'${String(s).replace(/'/g, "'\\''")}'`;
}
// Pre-trust the scratch cwd by seeding the trust record in <home>/.claude.json so
// the trust-folder dialog never appears. Verified-live trust shape:
// projects["<cwd>"] = { hasTrustDialogAccepted: true, allowedTools: [], ... }
// Idempotent + best-effort: a missing/unreadable .claude.json must not abort a
// turn (a fresh cwd would then show the dialog once; the boot wait tolerates it).
// Must run BEFORE the session boots so claude reads the trusted record at startup.
export function ensureTuiCwdTrusted(home, cwd) {
if (!home || !cwd) return;
const path = `${home}/.claude.json`;
let j, mode;
try {
j = JSON.parse(readFileSync(path, "utf8"));
mode = statSync(path).mode & 0o777;
} catch { return; }
j.projects = j.projects || {};
const entry = j.projects[cwd] || {};
if (entry.hasTrustDialogAccepted === true) return; // already trusted, no rewrite
entry.hasTrustDialogAccepted = true;
if (!Array.isArray(entry.allowedTools)) entry.allowedTools = [];
j.projects[cwd] = entry;
// Atomic write (temp + rename on the same fs), preserving mode, so a crash
// mid-write can never truncate the user's real ~/.claude.json. We seed ONLY the
// per-project trust flag — NOT bypassPermissionsModeAccepted: the driver never
// passes --dangerously-skip-permissions, so the bypass dialog cannot appear, and
// onboarding completion is an A-path precondition (the host already runs claude).
// NOTE: when the A-path moves to a dedicated scratch HOME (task #26), this writes
// a file we fully own, removing the real-config-mutation concern entirely.
try {
const tmp = `${path}.ocp-tui.${process.pid}.tmp`;
writeFileSync(tmp, JSON.stringify(j, null, 2), { mode });
renameSync(tmp, path);
} catch { /* best effort */ }
}
// Prepare the HOME claude runs under. Two modes:
// - real-home (tuiHome === realHome OR falsy): no isolation; just trust the cwd
// in the real ~/.claude.json. Opt in by setting OCP_TUI_HOME=$HOME.
// - scratch-home: a dedicated HOME that reuses the real OAuth via a SYMLINKED
// .credentials.json, with a seeded .claude.json (onboarded real config minus
// the user's project history; trusts only the scratch cwd) and its own
// projects/ dir — so the real ~/.claude is never mutated or polluted.
//
// ⚠️ CREDENTIAL CAVEAT (verified live): claude rewrites .credentials.json on token
// refresh, REPLACING the symlink with a regular-file copy → the scratch home then
// FORKS the OAuth credentials. Because OAuth refresh tokens rotate (single-use), a
// refresh in the scratch home can invalidate the token the user's real-home claude
// relies on. Therefore scratch-home is safe only with a DEDICATED OAuth or for
// ephemeral use; for a shared subscription prefer real-home (tuiHome===realHome),
// which shares one .credentials.json — identical to how OCP already spawns claude.
// Idempotent + best-effort: any failure degrades toward the dialog/cap, never
// corrupts. Run BEFORE the session boots.
export function prepareTuiHome(realHome, tuiHome, cwd) {
if (!tuiHome || tuiHome === realHome) { ensureTuiCwdTrusted(realHome, cwd); return; }
try {
const claudeDir = `${tuiHome}/.claude`;
mkdirSync(`${claudeDir}/projects`, { recursive: true });
// Symlink the real credentials (never copy the OAuth token); refresh if missing.
const link = `${claudeDir}/.credentials.json`;
if (!existsSync(link)) {
try { symlinkSync(`${realHome}/.claude/.credentials.json`, link); } catch { /* best effort */ }
}
// Seed .claude.json ONCE (if absent): start from the onboarded real config,
// drop the user's project history, trust only the scratch cwd. mode 0600.
const seedPath = `${tuiHome}/.claude.json`;
if (!existsSync(seedPath)) {
let base = {};
try { base = JSON.parse(readFileSync(`${realHome}/.claude.json`, "utf8")); } catch { /* fresh */ }
base.hasCompletedOnboarding = true;
base.projects = { [cwd]: { hasTrustDialogAccepted: true, allowedTools: [] } };
writeFileSync(seedPath, JSON.stringify(base, null, 2), { mode: 0o600 });
}
} catch { /* best effort */ }
// Ensure the cwd is trusted in the scratch config (idempotent; atomic).
ensureTuiCwdTrusted(tuiHome, cwd);
}
// ── Billing-classifier labeling ─────────────────────────────────────────
// Resolve CLAUDE_CODE_ENTRYPOINT on the spawn env per mode. ALWAYS deletes any
// inherited value first (so a stray entrypoint from OCP's own parent env can never
// leak into / mislabel the billing header). Then:
// "cli" (default) → set "cli": deterministic subscription-pool classification.
// HONEST ONLY because OCP's spawn is a genuine interactive PTY (tmux pane,
// no -p, stdout not redirected). Never set "cli" on a non-interactive spawn.
// "auto" → leave unset → claude self-classifies via its t$A (TTY → cli). Use to
// observe/diagnose the real TTY-derived value.
// "off" → leave the env exactly as inherited (diagnostics / honesty audit).
export function resolveTuiEntrypointEnv(env, mode = "cli") {
if (mode === "off") return env;
delete env.CLAUDE_CODE_ENTRYPOINT;
if (mode === "cli") env.CLAUDE_CODE_ENTRYPOINT = "cli";
return env;
}
// Build interactive claude argv: NO -p, NO --output-format (=> cc_entrypoint=cli).
// MCP hard-disabled: --strict-mcp-config (no --mcp-config) is the only mechanism
// that stops account-attached managed MCP from connecting (spec §5.2 / T6),
// belt-and-braces with --disallowedTools "mcp__*".
// A-PATH ONLY: built-in tools are left enabled (acceptable single-user). Deployment B
// (guest keys) MUST additionally pass --tools "" per spec §5.2(2) as the credential
// wall before this argv is reachable for owner_tier=guest — guard that in PR-3 wiring.
function buildTuiCmd(claudeBin, model, sessionId) {
return [
shq(claudeBin),
"--model", shq(model),
"--session-id", sessionId,
"--strict-mcp-config",
"--disallowedTools", shq("mcp__*"),
].join(" ");
}
// Full per-request TUI lifecycle:
// 1. Pre-trust the scratch cwd (no trust dialog will appear).
// 2. Write prompt to a 0600 temp file (no shell injection from prompt content).
// 3. Boot an interactive `claude` in a fresh tmux session in the scratch cwd.
// 4. Submit the prompt via `send-keys -- "$(cat file)"` + a SEPARATE Enter key
// event (spec §5 / T3: literal "\n" in paste does NOT submit; Enter token does).
// 5. Block on the native JSONL transcript (located by session-id) until terminal
// marker or wall-clock cap.
// 6. Always teardown: kill session + rm temp dir (even on throw).
// Returns { text, entrypoint } from readTuiTranscript (entrypoint is the billing-pool
// classifier, e.g. "cli", or null if the transcript did not include a turn_duration).
export async function runTuiTurn({
prompt,
model,
claudeBin,
home,
realHome,
cwd,
wallclockMs = 120000,
entrypointMode = "cli",
tmux = defaultTmux,
}) {
const sessionId = randomUUID();
const tmuxName = SESSION_PREFIX + sessionId.slice(0, 8);
const ehome = home || process.env.HOME; // HOME claude runs under (scratch or real)
const rhome = realHome || process.env.HOME; // real home (OAuth + onboarded config source)
// Ensure scratch cwd exists, then prepare the (scratch or real) HOME + trust the
// cwd — before claude boots.
if (!existsSync(cwd)) mkdirSync(cwd, { recursive: true });
prepareTuiHome(rhome, ehome, cwd);
// Write prompt to a temp file (mode 0600) so the content never touches argv.
const tmpDir = mkdtempSync(`${tmpdir()}/ocp-tui-`);
const promptFile = `${tmpDir}/prompt.txt`;
writeFileSync(promptFile, prompt, { mode: 0o600 });
// Build the env: disable marketplace auto-install, strip any Anthropic / CC
// env vars that might interfere with interactive-mode classification.
const env = { ...process.env, CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL: "1" };
delete env.CLAUDECODE;
delete env.ANTHROPIC_API_KEY;
delete env.ANTHROPIC_BASE_URL;
delete env.ANTHROPIC_AUTH_TOKEN;
env.HOME = ehome; // claude reads credentials + writes the transcript under this HOME
resolveTuiEntrypointEnv(env, entrypointMode);
try {
// 1. Boot the interactive session inside tmux, rooted at the scratch cwd.
// Capture the result: if tmux new-session fails (status !== 0) there is no
// PTY, no interactive spawn — abort BEFORE the boot sleep rather than paste
// into a non-existent session or issue a billing request without a verified
// interactive context. The finally teardown is still harmless (kill-session
// is a no-op when the session never existed).
const spawnResult = tmux(
["new-session", "-d", "-s", tmuxName, "-x", "220", "-y", "50", "-c", cwd,
buildTuiCmd(claudeBin, model, sessionId)],
{ env },
);
if (!spawnResult || spawnResult.status !== 0) {
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 —
// then settle, then send a SEPARATE Enter key event to submit the line.
//
// The `-l` (literal) flag is required on the paste send-keys call so that
// a prompt that happens to equal a tmux key token (e.g. "C-c", "Escape")
// is typed literally as text rather than being interpreted as a key binding.
// The SEPARATE Enter event below deliberately omits -l so that tmux sends a
// real keypress (carriage return) to submit the prompt line.
spawnSync(
"sh",
["-c", `${shq(TMUX)} send-keys -t ${shq(tmuxName)} -l -- "$(cat ${shq(promptFile)})"`],
{ env, encoding: "utf8" },
);
await sleep(PASTE_SETTLE_MS);
tmux(["send-keys", "-t", tmuxName, "Enter"]);
// 3. Block on the native transcript (resolved by session-id) until terminal.
// Returns { text, entrypoint } from readTuiTranscript.
return await readTuiTranscript({ home: ehome, sessionId, wallclockMs });
} finally {
// 4. Teardown — always, even on throw.
try { tmux(["kill-session", "-t", tmuxName]); } catch { /* already gone */ }
try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best effort */ }
}
}
+134
View File
@@ -0,0 +1,134 @@
// Transcript reader for TUI-mode. Reads claude's native JSONL session transcript
// and returns the latest assistant turn's text once the turn is terminal.
//
// Authority: claude CLI v2.1.157 — interactive session transcript at
// <HOME>/.claude/projects/<CWD with every "/" -> "-">/<--session-id>.jsonl
// Completion marker: a line {"type":"system","subtype":"turn_duration",...}.
// See docs/superpowers/specs/2026-05-30-tui-mode-production-design.md §4.
import { readFileSync, existsSync, readdirSync } from "node:fs";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Project-dir encoding: claude replaces every "/" AND every "." with "-".
// Verified live (claude v2.1.158): cwd /home/u/.ocp-tui/work is stored under
// projects/-home-u--ocp-tui-work/ (the "." in ".ocp-tui" becomes "-", yielding
// the double dash). The earlier "/"-only rule was wrong for dotted paths; the
// fixture cwd /tmp/tui-test happened to have no dots so it never surfaced.
// NOTE: prefer findTranscriptPath() (glob by session-id) for resolution — it is
// immune to the exact encoding rule. This helper is kept for the known-path case.
export function encodeCwd(cwd) {
return cwd.replace(/[/.]/g, "-");
}
export function transcriptPath(home, cwd, sessionId) {
return `${home}/.claude/projects/${encodeCwd(cwd)}/${sessionId}.jsonl`;
}
// Locate a session's transcript by its UUID across every projects subdir, without
// reconstructing the encoded cwd. Robust to whatever encoding claude applies.
// Returns the path, or null if not present yet (it appears once the turn starts).
export function findTranscriptPath(home, sessionId) {
if (!home || !sessionId) return null;
const root = `${home}/.claude/projects`;
let dirs;
try { dirs = readdirSync(root); } catch { return null; }
for (const d of dirs) {
const candidate = `${root}/${d}/${sessionId}.jsonl`;
if (existsSync(candidate)) return candidate;
}
return null;
}
// Parse NDJSON text into objects; skip blank lines and partial/forming lines
// (the live transcript is read mid-write, so the last line may be incomplete).
export function parseTranscriptLines(text) {
const out = [];
for (const line of text.split("\n")) {
const t = line.trim();
if (!t) continue;
try { out.push(JSON.parse(t)); } catch { /* partial line being written */ }
}
return out;
}
// A line marks the assistant turn complete when it is the turn_duration system
// event. That is the ONLY reliable terminal marker in interactive TUI mode.
//
// Why tool_use is NOT a terminal marker:
// In interactive claude, when the model decides to call a tool (stop_reason=
// "tool_use"), claude handles the tool call internally and then continues
// generating — the turn is NOT complete. The transcript advances to another
// assistant entry after the tool result. Only {type:"system",
// subtype:"turn_duration"} signals that claude has fully finished the turn.
// Treating tool_use as terminal would truncate tool-using turns mid-flight.
export function isTerminalLine(obj) {
if (!obj || typeof obj !== "object") return false;
return obj.type === "system" && obj.subtype === "turn_duration";
}
// Text of the LAST assistant turn: concatenate its text content blocks
// (ignore thinking/tool_use blocks). Later assistant entries overwrite earlier.
// Fixture-confirmed shape: top-level type:"assistant", message.content[] array.
//
// Scoping: this returns the FINAL text-bearing assistant entry in the whole file,
// not "text since the matching user line" (spec §4.2). Those are equivalent ONLY
// under OCP's one-session-per-request model (a fresh --session-id => a fresh
// transcript holding one logical exchange). If a future warm-pool ever reuses a
// session WITHOUT a fresh session-id / clear, earlier-turn text could leak — that
// author must add user-line scoping here. See spec §7.2.
export function extractLatestAssistantText(events) {
let text = "";
for (const ev of events) {
if (!ev || ev.type !== "assistant") continue;
const content = ev.message && ev.message.content;
if (!Array.isArray(content)) continue;
const parts = content
.filter((b) => b && b.type === "text" && typeof b.text === "string")
.map((b) => b.text);
if (parts.length) text = parts.join("");
}
return text;
}
// Returns the entrypoint string from the turn_duration line (e.g. "cli"),
// or null if absent. Lets callers assert the subscription-classified path.
// Fixture-confirmed: entrypoint field lives directly on the turn_duration line.
export function verifyEntrypoint(events) {
for (const ev of events) {
if (ev && ev.type === "system" && ev.subtype === "turn_duration") {
return ev.entrypoint != null ? ev.entrypoint : null;
}
}
return null;
}
// Block until the session transcript is terminal (turn_duration) or
// the wall-clock cap elapses, polling the file (no fs.watch — robust over NFS /
// editors). Returns { text, entrypoint } where text is the latest assistant text
// and entrypoint is the billing-pool classifier from the turn_duration line (e.g.
// "cli"), or null if not yet present. On cap with text, returns the partial result;
// on cap with no text at all, throws.
//
// No quiescence heuristic by design: a long Opus thinking turn stalls transcript
// growth and a "file stable for N s" rule would false-abort it (spec §4.3).
// Resolution: pass an explicit `transcriptPath` (used by unit tests), OR pass
// `home` + `sessionId` to resolve by glob each poll (production) — the transcript
// file does not exist until the turn starts, so resolution happens inside the loop.
export async function readTuiTranscript({ transcriptPath: p, home, sessionId, wallclockMs = 120000, pollMs = 250 }) {
const deadline = Date.now() + wallclockMs;
let lastText = "";
let lastEntrypoint = null;
while (Date.now() < deadline) {
const resolved = p || findTranscriptPath(home, sessionId);
if (resolved && existsSync(resolved)) {
const events = parseTranscriptLines(readFileSync(resolved, "utf8"));
lastText = extractLatestAssistantText(events) || lastText;
const ep = verifyEntrypoint(events);
if (ep != null) lastEntrypoint = ep;
if (events.some(isTerminalLine)) return { text: lastText, entrypoint: lastEntrypoint };
}
await sleep(pollMs);
}
if (lastText) return { text: lastText, entrypoint: lastEntrypoint };
throw new Error("tui_transcript_timeout: no assistant text within wallclock cap");
}
+9 -5
View File
@@ -506,9 +506,11 @@ main() {
echo ""
# Step 2.5: auto-discover anonymous key from /health (issue #12 §14 Path A).
# When the OCP admin set PROXY_ANONYMOUS_KEY, the server advertises it via
# /health.anonymousKey. If the user didn't pass --key, use it automatically so
# `ocp-connect <host>` works zero-config for OpenClaw multi-agent setups.
# The server advertises anonymousKey in /health ONLY when the admin has set
# PROXY_ADVERTISE_ANON_KEY=1 (default off — /health is unauthenticated, so
# advertising exposes the shared key to any LAN-reachable device; issue #109).
# Localhost callers always receive it regardless. When the field is absent,
# ocp-connect falls back to anonymous access / interactive --key (step 3 below).
if [[ -z "$key" ]]; then
local anon_key
anon_key=$(echo "$health_json" | python3 -c "
@@ -632,11 +634,12 @@ PYEOF
{
echo ""
echo "# OCP LAN (added by ocp connect)"
echo "export OPENAI_BASE_URL=$base_url/v1"
echo "export OPENAI_BASE_URL='$base_url/v1'"
if [[ -n "$key" ]]; then
echo "export OPENAI_API_KEY=$key"
echo "export OPENAI_API_KEY='$key'"
fi
} >> "$rc_file"
chmod 600 "$rc_file" 2>/dev/null || true
done
echo " Shell config:"
@@ -669,6 +672,7 @@ PYEOF
echo "OPENAI_API_KEY=$key"
fi
} > "$env_dir/ocp.conf"
chmod 600 "$env_dir/ocp.conf" 2>/dev/null || true
echo ""
echo " System-level (systemd):"
echo " ✓ $env_dir/ocp.conf"
+10 -7
View File
@@ -208,31 +208,34 @@ async function cmdTest() {
async function cmdRestart(args) {
const target = (args || "").trim().toLowerCase();
const { execSync } = await import("node:child_process");
const uid = typeof process.getuid === "function" ? process.getuid() : 501;
const macProxy = `launchctl kickstart -k gui/${uid}/dev.ocp.proxy`;
const macGateway = `launchctl kickstart -k gui/${uid}/ai.openclaw.gateway`;
try {
if (target === "gateway") {
execSync("launchctl kickstart -k gui/501/ai.openclaw.gateway", { timeout: 15000 });
execSync(macGateway, { timeout: 15000 });
return "✓ Gateway restarted";
} else if (target === "all") {
execSync("launchctl kickstart -k gui/501/ai.openclaw.proxy", { timeout: 15000 });
execSync(macProxy, { timeout: 15000 });
// Gateway restart will kill this plugin too, so do it last
execSync("launchctl kickstart -k gui/501/ai.openclaw.gateway", { timeout: 15000 });
execSync(macGateway, { timeout: 15000 });
return "✓ Proxy + Gateway restarted";
} else {
execSync("launchctl kickstart -k gui/501/ai.openclaw.proxy", { timeout: 15000 });
execSync(macProxy, { timeout: 15000 });
return "✓ Proxy restarted";
}
} catch (e) {
// Try systemd for Linux
// Linux: systemd user services
try {
if (target === "gateway") {
execSync("systemctl --user restart openclaw-gateway", { timeout: 15000 });
return "✓ Gateway restarted";
} else {
execSync("systemctl --user restart openclaw-proxy 2>/dev/null || pkill -f 'node.*server.mjs' && sleep 2 && cd ~/.openclaw/projects/*/; node server.mjs &", { timeout: 15000, shell: true });
execSync("systemctl --user restart ocp-proxy", { timeout: 15000 });
return "✓ Proxy restarted";
}
} catch (e2) {
return `✗ Restart failed: ${e2.message?.slice(0, 100)}`;
return `✗ Restart failed: ${e2.message?.slice(0, 100)}. Run \`ocp restart\` on the server host manually.`;
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "open-claude-proxy",
"version": "3.16.4",
"version": "3.18.0",
"description": "OCP (Open Claude Proxy) — use your Claude Pro/Max subscription as an OpenAI-compatible API for any IDE. Works with Cline, OpenCode, Aider, Continue.dev, OpenClaw, and more.",
"type": "module",
"bin": {
+2
View File
@@ -9,6 +9,8 @@
// No new dependencies — regex-based, plist <key>X</key><string>Y</string> shape
// is stable enough for our hand-written templates in setup.mjs.
// Note: setup.mjs XML-escapes all injected values before writing (via xmlEscape()),
// so raw `<` / `>` / `&` never appear in plist <string> bodies — the [^<]* regex below is safe.
const PLIST_KV_RE = /<key>([^<]+)<\/key>\s*<string>([^<]*)<\/string>/g;
export function parsePlistEnv(plistContent) {
+344 -70
View File
@@ -36,6 +36,8 @@ import { dirname, join } from "node:path";
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 { DEFAULT_PORT } from "./lib/constants.mjs";
import { isLoopbackBind } from "./lib/net.mjs";
import { runTuiTurn, reapStaleTuiSessions } from "./lib/tui/session.mjs";
const __dirname = dirname(fileURLToPath(import.meta.url));
const _pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8"));
@@ -143,7 +145,7 @@ function extractSystemPrompt(messages) {
return OCP_SYSTEM_PROMPT_WRAPPER;
}
const clientContent = systemMessages.map(m =>
typeof m.content === "string" ? m.content : JSON.stringify(m.content)
contentToText(m.content)
).join("\n\n");
return `${OCP_SYSTEM_PROMPT_WRAPPER}\n\n${clientContent}`;
}
@@ -277,7 +279,65 @@ const NO_CONTEXT = process.env.CLAUDE_NO_CONTEXT === "true";
const AUTH_MODE = process.env.CLAUDE_AUTH_MODE || (PROXY_API_KEY ? "shared" : "none");
const ADMIN_KEY = process.env.OCP_ADMIN_KEY || "";
const PROXY_ANONYMOUS_KEY = process.env.PROXY_ANONYMOUS_KEY || "";
// When set to "1", advertise PROXY_ANONYMOUS_KEY in the public /health body so
// remote `ocp-connect` devices can zero-config auto-discover it (issue #12 §14 Path A).
// Default OFF: /health is unauthenticated, so advertising hands the shared key to any
// LAN-reachable device (issue #109 P0). Localhost callers always see it regardless,
// since localhost is already fully trusted by the auth path.
const ADVERTISE_ANON_KEY = process.env.PROXY_ADVERTISE_ANON_KEY === "1";
let CACHE_TTL = parseInt(process.env.CLAUDE_CACHE_TTL || "0", 10); // 0 = disabled, value in ms
// ── TUI-mode (subscription-pool bridge) — opt-in; default OFF ───────────
// When ON: requests are served by spawning interactive `claude` (no -p / no
// --output-format) so cc_entrypoint=cli (subscription pool). Responses are
// buffered then replayed as chunked SSE. Streaming is always buffered here.
// Authority: docs/adr/0007-tui-interactive-mode.md
// SECURITY: TUI-mode is SINGLE-USER ONLY. Never enable on a multi-user OCP
// (guest prompts would run claude with operator filesystem access).
const TUI_MODE = process.env.CLAUDE_TUI_MODE === "true";
const TUI_WALLCLOCK_MS = parseInt(process.env.CLAUDE_TUI_WALLCLOCK_MS || "120000", 10);
const TUI_CWD = process.env.OCP_TUI_CWD || `${process.env.HOME}/.ocp-tui/work`;
const TUI_HOME = process.env.OCP_TUI_HOME || process.env.HOME;
const TUI_ENTRYPOINT = process.env.OCP_TUI_ENTRYPOINT || "cli"; // cli|auto|off — see ADR 0007
// SECURITY fail-loud: TUI-mode is incompatible with any configuration that allows
// non-operator prompts to reach the interactive claude session. Three cases:
// 1. AUTH_MODE=multi — guest/anonymous keys can submit prompts.
// 2. a non-loopback BIND_ADDRESS — server is network-exposed; any reachable peer
// can send prompts unless per-request trust is in place. Override with
// OCP_TUI_ALLOW_LAN=1 ONLY if you have a separate network-layer trust (firewall, VPN).
// 3. PROXY_ANONYMOUS_KEY set — anonymous callers can submit prompts without a key.
// In all three cases TUI runs interactive claude with the OPERATOR's full filesystem
// access — home is NOT isolation. Refuse to boot. See ADR 0007.
if (TUI_MODE && AUTH_MODE === "multi") {
console.error(
"FATAL: CLAUDE_TUI_MODE=true is incompatible with CLAUDE_AUTH_MODE=multi.\n" +
" TUI runs interactive claude with the operator's filesystem access, so a guest/anonymous\n" +
" prompt could read operator data. TUI-mode is single-user only until B-path isolation lands.\n" +
" See docs/adr/0007-tui-interactive-mode.md. Refusing to start."
);
process.exit(1);
}
if (TUI_MODE && !isLoopbackBind(BIND_ADDRESS) && process.env.OCP_TUI_ALLOW_LAN !== "1") {
console.error(
`FATAL: CLAUDE_TUI_MODE=true with a non-loopback CLAUDE_BIND (${BIND_ADDRESS}) is unsafe.\n` +
" TUI runs interactive claude with operator filesystem access; network-exposed without\n" +
" per-request isolation means any reachable peer could drive the operator's claude session.\n" +
" Either bind to 127.0.0.1 (default) or set OCP_TUI_ALLOW_LAN=1 if you have a\n" +
" separate network-layer trust (firewall/VPN). See docs/adr/0007-tui-interactive-mode.md."
);
process.exit(1);
}
if (TUI_MODE && PROXY_ANONYMOUS_KEY) {
console.error(
"FATAL: CLAUDE_TUI_MODE=true with PROXY_ANONYMOUS_KEY set is unsafe.\n" +
" TUI runs interactive claude with operator filesystem access; anonymous callers\n" +
" could drive the operator's claude session without a named key.\n" +
" Remove PROXY_ANONYMOUS_KEY or disable TUI-mode. See docs/adr/0007-tui-interactive-mode.md."
);
process.exit(1);
}
if (PROXY_ANONYMOUS_KEY && AUTH_MODE !== "multi") {
console.warn("WARNING: PROXY_ANONYMOUS_KEY is set but AUTH_MODE is not 'multi' — anonymous key will be ignored");
}
@@ -537,7 +597,27 @@ function buildCliArgs(cliModel, systemPrompt) {
];
// Permissions
if (SKIP_PERMISSIONS) {
// ADR 0007 B-path: in multi-tenant mode, suppress operator-FS tools so a guest
// prompt cannot drive Bash/Read/Write/Edit/etc. on the operator's filesystem.
// For AUTH_MODE !== "multi" (none/shared — single-operator/trusted), preserve
// existing behaviour unchanged.
if (AUTH_MODE === "multi") {
// Disallow the full operator-FS + web + agent surface. "--disallowedTools" may
// be repeated; claude accepts multiple occurrences (TUI path already uses it).
args.push(
"--disallowedTools", "Bash",
"--disallowedTools", "Read",
"--disallowedTools", "Write",
"--disallowedTools", "Edit",
"--disallowedTools", "Glob",
"--disallowedTools", "Grep",
"--disallowedTools", "WebFetch",
"--disallowedTools", "WebSearch",
"--disallowedTools", "Agent",
"--disallowedTools", "mcp__*",
);
// Do NOT push --allowedTools in multi mode.
} else if (SKIP_PERMISSIONS) {
args.push("--dangerously-skip-permissions");
} else if (ALLOWED_TOOLS.length > 0) {
args.push("--allowedTools", ...ALLOWED_TOOLS);
@@ -557,9 +637,22 @@ function buildCliArgs(cliModel, systemPrompt) {
// This prevents runaway context from gateway-side conversation accumulation.
let MAX_PROMPT_CHARS = parseInt(process.env.CLAUDE_MAX_PROMPT_CHARS || "150000", 10);
// Flatten OpenAI content (string | array of parts) to plain text for the prompt.
// Array content: concatenate text parts; replace non-text parts (e.g. image_url)
// with a placeholder rather than dumping raw JSON. (issue #110)
function contentToText(content) {
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content.map(p =>
p && p.type === "text" && typeof p.text === "string" ? p.text : "[non-text content omitted]"
).join("");
}
return content == null ? "" : JSON.stringify(content);
}
function messagesToPrompt(messages) {
const full = messages.map((m) => {
const text = typeof m.content === "string" ? m.content : JSON.stringify(m.content);
const text = contentToText(m.content);
if (m.role === "system") return `[System] ${text}`;
if (m.role === "assistant") return `[Assistant] ${text}`;
return text;
@@ -701,6 +794,11 @@ function spawnClaudeProcess(model, messages, conversationId, keyName) {
}
}
// Guard stdin writes against EPIPE (child may close stdin before we finish
// writing, e.g. early exit on bad model). The ChildProcess "error" event is on
// the spawned process, NOT on the stdin Writable — it does not catch this.
proc.stdin.on("error", (e) => logEvent("warn", "stdin_write_error", { error: e.message }));
// Write prompt to stdin immediately
proc.stdin.write(prompt);
proc.stdin.end();
@@ -722,7 +820,12 @@ function spawnClaudeProcess(model, messages, conversationId, keyName) {
}
}, TIMEOUT);
return { proc, cliModel, conversationId, t0, cleanup, handleSessionFailure, markFirstByte };
// Clear ONLY the request timer (not the slot accounting) when the response has
// semantically completed (result/[DONE]) but the child hasn't exited yet — prevents
// a spurious post-success timeout. cleanup() (on exit) still clears it idempotently. (issue #111)
function clearOverallTimer() { clearTimeout(overallTimer); }
return { proc, cliModel, conversationId, t0, cleanup, clearOverallTimer, handleSessionFailure, markFirstByte };
}
// ── Call claude CLI (non-streaming) ─────────────────────────────────────
@@ -765,7 +868,7 @@ function callClaude(model, messages, conversationId, keyName) {
resultEventSeen = true;
} else if (parsed.error) {
// is_error result — treat as process error
reject(new Error(parsed.error));
reject(new Error(String(parsed.error)));
}
}
});
@@ -801,6 +904,43 @@ function callClaude(model, messages, conversationId, keyName) {
});
}
// ── TUI-mode upstream (interactive claude, cc_entrypoint=cli) ───────────
// Drop-in replacement for callClaude when TUI_MODE is ON.
// Same signature and Promise<string> contract so all downstream
// (singleflight → setCachedResponse → completionResponse) is unchanged.
// System messages are rendered inline as [System] blocks by messagesToPrompt;
// we deliberately do NOT pass --system-prompt in interactive mode to avoid any
// flag that could perturb cc_entrypoint classification.
// Authority: claude CLI v2.1.158 interactive mode (cc_entrypoint=cli).
// SECURITY: A-path single-user ONLY — home is NOT isolation (see ADR 0007).
function callClaudeTui(model, messages, _conversationId, _keyName) {
const cliModel = MODEL_MAP[model] || model;
const prompt = messagesToPrompt(messages); // includes system as [System] inline
recordModelRequest(cliModel, prompt.length);
return runTuiTurn({
prompt,
model: cliModel,
claudeBin: CLAUDE,
home: TUI_HOME,
realHome: process.env.HOME,
cwd: TUI_CWD,
wallclockMs: TUI_WALLCLOCK_MS,
entrypointMode: TUI_ENTRYPOINT,
}).then(({ text, entrypoint }) => {
recordModelSuccess(cliModel, 0); // elapsed not measurable here; wallclock at reader level
// 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
// return text but cost money — warn loudly so it's visible. (issue #115)
if (TUI_ENTRYPOINT === "cli" && entrypoint !== "cli") {
logEvent("warn", "tui_entrypoint_mismatch", { expected: "cli", got: entrypoint, model: cliModel });
}
return text;
}).catch((err) => {
recordModelError(cliModel, false);
throw err;
});
}
// ── SSE heartbeat (opt-in idle watchdog) ────────────────────────────────
// Emits `: keepalive\n\n` SSE comment frames during silent windows on the
// streaming response. Design: docs/superpowers/specs/2026-04-25-47-sse-heartbeat-design.md
@@ -845,10 +985,10 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}
try {
ctx = spawnClaudeProcess(model, messages, conversationId, authInfo.keyName);
} catch (err) {
return jsonResponse(res, 500, { error: { message: err.message, type: "proxy_error" } });
return jsonResponse(res, 500, { error: { message: sanitizeError(err.message), type: "proxy_error" } });
}
const { proc, cliModel, conversationId: convId, t0, cleanup, handleSessionFailure, markFirstByte } = ctx;
const { proc, cliModel, conversationId: convId, t0, cleanup, clearOverallTimer, handleSessionFailure, markFirstByte } = ctx;
let stderr = "";
let headersSent = false;
let totalChars = 0;
@@ -856,6 +996,10 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}
let lineBuffer = "";
let isFirstDelta = true;
let resultEventSeen = false;
// Separate flag for is_error result — must NOT be conflated with resultEventSeen.
// If errored===true the close handler must not cache the response or record success
// (mirrors callClaude which rejects and never caches on is_error).
let errored = false;
function ensureHeaders() {
if (res.writableEnded || res.destroyed) return false;
@@ -915,19 +1059,22 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}
res.write("data: [DONE]\n\n");
res.end();
}
clearOverallTimer();
} else if (parsed.error) {
// is_error result — emit error stop
resultEventSeen = true;
logEvent("error", "claude_result_error", { model: cliModel, error: parsed.error.slice(0, 200) });
trackError(parsed.error.slice(0, 200));
// is_error result — emit error stop; do NOT set resultEventSeen (that would
// cause the close handler to record success + write cache). Set errored instead.
errored = true;
const errStr = String(parsed.error);
logEvent("error", "claude_result_error", { model: cliModel, error: errStr.slice(0, 200) });
trackError(errStr.slice(0, 200));
if (!headersSent && !res.writableEnded && !res.destroyed) {
jsonResponse(res, 500, { error: { message: parsed.error, type: "provider_error" } });
jsonResponse(res, 500, { error: { message: sanitizeError(errStr), type: "provider_error" } });
} else if (!res.writableEnded && !res.destroyed) {
sendSSE(res, {
id, object: "chat.completion.chunk", created, model,
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
}, hb);
// Headers already sent (eager ensureHeaders) — can't send a JSON 500. Surface the
// failure as an SSE error frame so the client can distinguish an upstream error
// from a legitimately empty completion, instead of a success-looking finish_reason:"stop". (issue #110)
sendSSE(res, { error: { message: sanitizeError(errStr), type: "provider_error" } }, hb);
res.write("data: [DONE]\n\n");
res.end();
}
@@ -945,29 +1092,33 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}
// Tolerate null exit code when result event was seen (sandbox-wrap noise, same
// as OLP commit 2864275 — bwrap shell exits null after model completes).
if (code !== 0 && !resultEventSeen) {
// Also route to the error path when errored===true (is_error result received):
// never record success or write cache for an errored response.
if ((code !== 0 && !resultEventSeen) || errored) {
recordModelError(cliModel, false);
try { recordUsage({ keyId: authInfo.keyId, keyName: authInfo.keyName, model, promptChars: messages.reduce((a, m) => a + (typeof m.content === "string" ? m.content.length : JSON.stringify(m.content).length), 0), responseChars: 0, elapsedMs: elapsed, success: false }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
logEvent("error", "claude_exit", { model: cliModel, code, signal: signal || "none", elapsed, stderr: stderr.slice(0, 300) });
try { recordUsage({ keyId: authInfo.keyId, keyName: authInfo.keyName, model, promptChars: messages.reduce((a, m) => a + contentToText(m.content).length, 0), responseChars: 0, elapsedMs: elapsed, success: false }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
logEvent("error", "claude_exit", { model: cliModel, code, signal: signal || "none", elapsed, errored, stderr: stderr.slice(0, 300) });
trackError(stderr.slice(0, 300) || `claude exit ${code}`);
handleSessionFailure();
// If the error was already sent inline (parsed.error branch above), the
// response may be writableEnded — nothing more to send.
if (!headersSent && !res.writableEnded && !res.destroyed) {
jsonResponse(res, 500, { error: { message: stderr.slice(0, 300) || `claude exit ${code}`, type: "proxy_error" } });
jsonResponse(res, 500, { error: { message: sanitizeError(stderr.slice(0, 300) || `claude exit ${code}`), type: "proxy_error" } });
} else if (!res.writableEnded && !res.destroyed) {
sendSSE(res, {
id, object: "chat.completion.chunk", created, model,
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
}, hb);
// Headers already sent — surface the failure as an SSE error frame instead of a
// success-looking finish_reason:"stop", so the client can tell the upstream crashed
// rather than returned empty. (issue #110 — sibling of the parsed.error branch above.)
sendSSE(res, { error: { message: sanitizeError(stderr.slice(0, 300) || `claude exit ${code}`), type: "proxy_error" } }, hb);
res.write("data: [DONE]\n\n");
res.end();
}
} else {
recordModelSuccess(cliModel, elapsed);
breakerRecordSuccess(cliModel);
try { recordUsage({ keyId: authInfo.keyId, keyName: authInfo.keyName, model, promptChars: messages.reduce((a, m) => a + (typeof m.content === "string" ? m.content.length : JSON.stringify(m.content).length), 0), responseChars: totalChars, elapsedMs: elapsed, success: true }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
try { recordUsage({ keyId: authInfo.keyId, keyName: authInfo.keyName, model, promptChars: messages.reduce((a, m) => a + contentToText(m.content).length, 0), responseChars: totalChars, elapsedMs: elapsed, success: true }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
logEvent("info", "claude_ok", { model: cliModel, chars: totalChars, elapsed, session: convId ? convId.slice(0, 12) + "..." : "none" });
// Cache write-back for streaming
// Cache write-back for streaming — only on true success (not errored)
if (CACHE_TTL > 0 && authInfo.cacheHash) {
try { setCachedResponse(authInfo.cacheHash, model, cachedContent); } catch (e) { logEvent("error", "cache_write_failed", { error: e.message }); }
}
@@ -995,7 +1146,7 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}
trackError(err.message);
handleSessionFailure();
if (!headersSent && !res.writableEnded && !res.destroyed) {
jsonResponse(res, 500, { error: { message: err.message, type: "proxy_error" } });
jsonResponse(res, 500, { error: { message: sanitizeError(err.message), type: "proxy_error" } });
} else if (!res.writableEnded && !res.destroyed) {
res.end();
}
@@ -1004,12 +1155,27 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}
// If client disconnects, kill the process to free resources
res.on("close", () => {
hb.stop();
if (!proc.killed) {
// Only escalate when the child is still alive. On the normal-success path res.end()
// also fires "close", but the child has usually already exited — skip the spurious
// SIGTERM and the 5s kill-timer entirely (a post-exit proc.once("exit") never fires,
// so the timer would otherwise leak a closure over proc for 5s per request). (issue #111)
if (!proc.killed && proc.exitCode === null && proc.signalCode === null) {
try { proc.kill("SIGTERM"); } catch {}
// Mirror the overallTimer escalation (server.mjs ~818): a SIGTERM-resistant child would
// otherwise hold its concurrency slot until the request timeout — #37 on the disconnect path. (issue #111)
const killTimer = setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, 5000);
killTimer.unref();
proc.once("exit", () => clearTimeout(killTimer));
}
});
}
// Strip absolute filesystem paths from an error message before sending it to a client.
// claude error_message / stderr routinely embed home-dir / credential-file paths. (issue #111)
function sanitizeError(msg) {
return String(msg || "Internal error").replace(/\/[\w/.\-]+/g, "[path]");
}
// ── Response helpers ────────────────────────────────────────────────────
function jsonResponse(res, status, data) {
if (res.headersSent || res.writableEnded || res.destroyed) return;
@@ -1032,6 +1198,24 @@ function completionResponse(res, id, model, content) {
});
}
// Replay a complete string as a chunked SSE stream (80 codepoints/chunk).
// Used by: (a) cache-hit replay on the streaming path; (b) TUI-mode streaming
// (buffered response replayed as SSE so clients get the same wire format).
// Behaviour is byte-for-byte identical to the original inline cache-replay block.
function streamStringAsSSE(res, id, model, content) {
const created = Math.floor(Date.now() / 1000);
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no" });
sendSSE(res, { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }] });
const CHUNK = 80;
const codepoints = Array.from(content);
for (let i = 0; i < codepoints.length; i += CHUNK) {
sendSSE(res, { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { content: codepoints.slice(i, i + CHUNK).join("") }, finish_reason: null }] });
}
sendSSE(res, { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: {}, finish_reason: "stop" }] });
res.write("data: [DONE]\n\n");
res.end();
}
// ── Plan usage probe ────────────────────────────────────────────────────
// ── Plan usage probe ────────────────────────────────────────────────────
// ALIGNMENT: mirrors Claude Code cli.js vE4 rate-limit header extraction.
@@ -1045,6 +1229,12 @@ function completionResponse(res, id, model, content) {
let usageCache = { data: null, fetchedAt: 0 };
const USAGE_CACHE_TTL = 5 * 60 * 1000; // 5 min
// ALIGNMENT (Class A — OAuth bearer machinery). Verified against the compiled cli.js
// (claude.exe v2.1.154) on 2026-05-31 via `strings`: both OAUTH_CLIENT_ID and
// OAUTH_TOKEN_URL appear in the binary byte-for-byte; the legacy host
// console.anthropic.com/v1/oauth is absent (0 hits). Re-verify on cli.js major bumps
// using the compiled-binary protocol (strings on the Mach-O/ELF; no live OAuth probe —
// a refresh-token grant would rotate the operator's real credentials). (issue #112)
const OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
const OAUTH_TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
@@ -1152,7 +1342,7 @@ async function fetchUsageFromApi() {
// Minimal /v1/messages request — we only need the response headers.
// Mirrors Claude Code cli.js vE4: headers anthropic-ratelimit-unified-{5h,7d}-{utilization,reset}.
const body = JSON.stringify({
model: "claude-haiku-4-5-20251001",
model: modelsConfig.aliases.haiku,
max_tokens: 1,
messages: [{ role: "user", content: "." }],
});
@@ -1429,9 +1619,16 @@ async function handleSettings(req, res) {
// PATCH
let body = "";
for await (const chunk of req) {
body += chunk;
if (body.length > 10000) return jsonResponse(res, 413, { error: "Body too large" });
try {
for await (const chunk of req) {
body += chunk;
if (body.length > 10000) return jsonResponse(res, 413, { error: "Body too large" });
}
} catch (e) {
if (!res.headersSent && !res.writableEnded) {
try { return jsonResponse(res, 400, { error: { message: "request aborted", type: "invalid_request_error" } }); } catch {}
}
return;
}
let updates;
try { updates = JSON.parse(body); } catch { return jsonResponse(res, 400, { error: "Invalid JSON" }); }
@@ -1468,18 +1665,25 @@ const VALID_MODELS = new Set(Object.keys(MODEL_MAP));
async function handleChatCompletions(req, res) {
let body = "";
for await (const chunk of req) {
body += chunk;
if (body.length > MAX_BODY_SIZE) {
return jsonResponse(res, 413, { error: { message: "Request body too large (max 5MB)", type: "invalid_request_error" } });
try {
for await (const chunk of req) {
body += chunk;
if (body.length > MAX_BODY_SIZE) {
return jsonResponse(res, 413, { error: { message: "Request body too large (max 5MB)", type: "invalid_request_error" } });
}
}
} catch (e) {
if (!res.headersSent && !res.writableEnded) {
try { return jsonResponse(res, 400, { error: { message: "request aborted", type: "invalid_request_error" } }); } catch {}
}
return;
}
let parsed;
try { parsed = JSON.parse(body); } catch { return jsonResponse(res, 400, { error: "Invalid JSON" }); }
const messages = parsed.messages || parsed.input || [{ role: "user", content: parsed.prompt || "" }];
const model = parsed.model || "claude-sonnet-4-6";
const model = parsed.model || modelsConfig.aliases.sonnet;
const stream = parsed.stream;
// Validate model against known models
@@ -1490,8 +1694,15 @@ async function handleChatCompletions(req, res) {
// Session ID: from request body, header, or null (one-off)
const conversationId = parsed.session_id || parsed.conversation_id || req.headers["x-session-id"] || req.headers["x-conversation-id"] || null;
if (!messages?.length) return jsonResponse(res, 400, { error: "messages required" });
if (!Array.isArray(messages) || messages.length === 0) {
return jsonResponse(res, 400, { error: { message: "'messages' must be a non-empty array", type: "invalid_request_error" } });
}
// NOTE: quota is best-effort / eventually-consistent. The gate reads the recorded count
// at entry and records only after the upstream completes, so concurrent requests at the
// boundary can overshoot the cap by up to MAX_CONCURRENT, and cache hits (served before
// recordUsage) are not counted. This is internal family rate-limiting, not a payment
// boundary — bounded overshoot is acceptable. (issue #111)
// Quota check — only for identified per-key users (not anonymous/admin/local)
if (req._authKeyId) {
let exceeded;
@@ -1524,20 +1735,9 @@ async function handleChatCompletions(req, res) {
if (cached) {
logEvent("info", "cache_hit", { model, hash: hash.slice(0, 12), hits: cached.hits });
if (stream) {
// D3: replay cached content as chunked SSE stream (80 codepoints/chunk)
const CACHE_REPLAY_CHUNK_SIZE = 80;
// D3: replay cached content as chunked SSE stream — delegated to streamStringAsSSE (DRY).
const id = `chatcmpl-${randomUUID()}`;
const created = Math.floor(Date.now() / 1000);
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no" });
sendSSE(res, { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }] });
const codepoints = Array.from(cached.response);
for (let i = 0; i < codepoints.length; i += CACHE_REPLAY_CHUNK_SIZE) {
const chunk = codepoints.slice(i, i + CACHE_REPLAY_CHUNK_SIZE).join("");
sendSSE(res, { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { content: chunk }, finish_reason: null }] });
}
sendSSE(res, { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: {}, finish_reason: "stop" }] });
res.write("data: [DONE]\n\n");
res.end();
streamStringAsSSE(res, id, model, cached.response);
return;
} else {
const id = `chatcmpl-${randomUUID()}`;
@@ -1551,12 +1751,37 @@ async function handleChatCompletions(req, res) {
}
if (stream) {
// Real streaming: pipe stdout from claude process directly as SSE chunks
if (TUI_MODE) {
// TUI-mode: no real token stream — buffer the full turn via callClaudeTui,
// optionally write-back to cache, then replay as chunked SSE.
// Default path (TUI_MODE===false) falls through to callClaudeStreaming below,
// which is byte-for-byte unchanged from before this gate was added.
const t0TuiStream = Date.now();
const promptCharsTuiStream = messages.reduce((a, m) => a + contentToText(m.content).length, 0);
try {
const content = await callClaudeTui(model, messages, conversationId, req._authKeyName);
if (CACHE_TTL > 0 && req._cacheHash) {
try { setCachedResponse(req._cacheHash, model, content); } catch (e) { logEvent("error", "cache_write_failed", { error: e.message }); }
}
const id = `chatcmpl-${randomUUID()}`;
streamStringAsSSE(res, id, model, content);
try { recordUsage({ keyId: req._authKeyId, keyName: req._authKeyName, model, promptChars: promptCharsTuiStream, responseChars: content.length, elapsedMs: Date.now() - t0TuiStream, success: true }); } catch {}
return;
} catch (err) {
if (res.headersSent || res.writableEnded || res.destroyed) { try { res.end(); } catch {} return; }
return jsonResponse(res, 500, { error: { message: sanitizeError(err.message), type: "proxy_error" } });
}
}
// Default: real stream-json streaming, unchanged.
return callClaudeStreaming(model, messages, conversationId, res, { keyId: req._authKeyId, keyName: req._authKeyName, cacheHash: req._cacheHash });
}
const t0Usage = Date.now();
const promptChars = messages.reduce((a, m) => a + (typeof m.content === "string" ? m.content.length : JSON.stringify(m.content).length), 0);
const promptChars = messages.reduce((a, m) => a + contentToText(m.content).length, 0);
// Select upstream based on TUI_MODE flag. With TUI_MODE===false (default),
// upstreamCall===callClaude — identical to the pre-TUI code path.
const upstreamCall = TUI_MODE ? callClaudeTui : callClaude;
// Non-streaming path with stampede protection: wrap the upstream call in singleflight
// when cache is enabled and a hash is present. Concurrent identical requests share
@@ -1571,7 +1796,7 @@ async function handleChatCompletions(req, res) {
// will re-read the freshly-populated cache entry here rather than spawning.
const recheck = getCachedResponse(req._cacheHash, CACHE_TTL);
if (recheck) return recheck.response;
const c = await callClaude(model, messages, conversationId, req._authKeyName);
const c = await upstreamCall(model, messages, conversationId, req._authKeyName);
try { setCachedResponse(req._cacheHash, model, c); } catch (e) { logEvent("error", "cache_write_failed", { error: e.message }); }
return c;
});
@@ -1586,14 +1811,13 @@ async function handleChatCompletions(req, res) {
try { res.end(); } catch {}
return;
}
const safeMessage = (err.message || "Internal error").replace(/\/[\w/.\-]+/g, "[path]");
return jsonResponse(res, 500, { error: { message: safeMessage, type: "proxy_error" } });
return jsonResponse(res, 500, { error: { message: sanitizeError(err.message), type: "proxy_error" } });
}
}
// Fallback: cache disabled (CACHE_TTL=0) or no _cacheHash — original path untouched.
try {
const content = await callClaude(model, messages, conversationId, req._authKeyName);
const content = await upstreamCall(model, messages, conversationId, req._authKeyName);
const id = `chatcmpl-${randomUUID()}`;
completionResponse(res, id, model, content);
try { recordUsage({ keyId: req._authKeyId, keyName: req._authKeyName, model, promptChars, responseChars: content.length, elapsedMs: Date.now() - t0Usage, success: true }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
@@ -1605,8 +1829,7 @@ async function handleChatCompletions(req, res) {
return;
}
// Sanitize error: strip internal file paths before sending to client
const safeMessage = (err.message || "Internal error").replace(/\/[\w/.\-]+/g, "[path]");
jsonResponse(res, 500, { error: { message: safeMessage, type: "proxy_error" } });
jsonResponse(res, 500, { error: { message: sanitizeError(err.message), type: "proxy_error" } });
}
}
@@ -1703,6 +1926,12 @@ const server = createServer(async (req, res) => {
req._authKeyName = authKeyName;
req._authKeyId = authKeyId;
// isAdmin computed here (early, before any admin-gated handler) so that
// DELETE /sessions, GET /logs, GET /usage, GET /status, PATCH /settings
// can all gate on it. Localhost and explicit admin key are always admin;
// in multi-tenant mode only the "admin" named key qualifies.
const isAdmin = AUTH_MODE !== "multi" || authKeyName === "admin" || isLocalhost;
// GET /v1/models
if (req.url === "/v1/models" && req.method === "GET") {
return jsonResponse(res, 200, {
@@ -1746,7 +1975,7 @@ const server = createServer(async (req, res) => {
claudeBinary: CLAUDE,
claudeBinaryOk: binaryOk,
authMode: AUTH_MODE,
anonymousKey: PROXY_ANONYMOUS_KEY || null,
...((isLocalhost || ADVERTISE_ANON_KEY) ? { anonymousKey: PROXY_ANONYMOUS_KEY || null } : {}),
auth: authStatus,
config: {
timeout: TIMEOUT,
@@ -1764,15 +1993,17 @@ const server = createServer(async (req, res) => {
});
}
// DELETE /sessions — clear all sessions
// DELETE /sessions — clear all sessions (mutating; admin only)
if (req.url === "/sessions" && req.method === "DELETE") {
if (!isAdmin) return jsonResponse(res, 403, { error: { message: "admin only", type: "auth_error" } });
const count = sessions.size;
sessions.clear();
return jsonResponse(res, 200, { cleared: count });
}
// GET /sessions — list active sessions
// GET /sessions — list active sessions (operator data; admin only)
if (req.url === "/sessions" && req.method === "GET") {
if (!isAdmin) return jsonResponse(res, 403, { error: { message: "admin only", type: "auth_error" } });
const list = [];
for (const [id, s] of sessions) {
// id is "${keyName}|${conversationId}"; expose only the public-facing conversationId
@@ -1782,37 +2013,51 @@ const server = createServer(async (req, res) => {
return jsonResponse(res, 200, { sessions: list });
}
// GET /usage — fetch plan usage limits from Anthropic API
// GET /usage — fetches plan usage from Anthropic API with operator token; admin only
if (req.url === "/usage" && req.method === "GET") {
if (!isAdmin) return jsonResponse(res, 403, { error: { message: "admin only", type: "auth_error" } });
return handleUsage(req, res);
}
// GET /logs — recent proxy log entries (errors and key events)
// GET /logs — recent proxy log entries (errors and key events); admin only
if (req.url?.startsWith("/logs") && req.method === "GET") {
if (!isAdmin) return jsonResponse(res, 403, { error: { message: "admin only", type: "auth_error" } });
return handleLogs(req, res);
}
// GET /status — combined usage + health summary
// GET /status — combined usage + health summary; uses operator token; admin only
if (req.url === "/status" && req.method === "GET") {
if (!isAdmin) return jsonResponse(res, 403, { error: { message: "admin only", type: "auth_error" } });
return handleStatus(req, res);
}
// GET /settings — view current tunable settings
// PATCH /settings — update settings at runtime (JSON body)
// GET /settings — view current tunable settings (admin only)
// PATCH /settings — update settings at runtime (JSON body; admin only, mutating)
if (req.url === "/settings" && (req.method === "GET" || req.method === "PATCH")) {
if (!isAdmin) return jsonResponse(res, 403, { error: { message: "admin only", type: "auth_error" } });
return handleSettings(req, res);
}
// ── Key management API ──
const isAdmin = AUTH_MODE !== "multi" || authKeyName === "admin" || isLocalhost;
// (isAdmin is computed early in the request handler, before the admin-gated routes)
if (req.url === "/api/keys" && req.method === "POST") {
if (!isAdmin) return jsonResponse(res, 403, { error: "Admin access required" });
let body = "";
for await (const chunk of req) body += chunk;
try {
for await (const chunk of req) { body += chunk; if (body.length > 10000) return jsonResponse(res, 413, { error: "Body too large" }); }
} catch (e) {
if (!res.headersSent && !res.writableEnded) {
try { return jsonResponse(res, 400, { error: { message: "request aborted", type: "invalid_request_error" } }); } catch {}
}
return;
}
let parsed;
try { parsed = JSON.parse(body); } catch { return jsonResponse(res, 400, { error: "Invalid JSON" }); }
const name = parsed.name || `key-${Date.now()}`;
if (!/^[A-Za-z0-9 ._-]{1,64}$/.test(name)) {
return jsonResponse(res, 400, { error: { message: "Invalid key name: 1-64 chars of letters, digits, space, dot, underscore, hyphen", type: "invalid_request_error" } });
}
const newKey = createKey(name);
return jsonResponse(res, 201, newKey);
}
@@ -1835,7 +2080,14 @@ const server = createServer(async (req, res) => {
if (!isAdmin) return jsonResponse(res, 403, { error: "Admin access required" });
const idOrName = decodeURIComponent(req.url.split("/api/keys/")[1].replace("/quota", ""));
let body = "";
for await (const chunk of req) { body += chunk; if (body.length > 10000) return jsonResponse(res, 413, { error: "Body too large" }); }
try {
for await (const chunk of req) { body += chunk; if (body.length > 10000) return jsonResponse(res, 413, { error: "Body too large" }); }
} catch (e) {
if (!res.headersSent && !res.writableEnded) {
try { return jsonResponse(res, 400, { error: { message: "request aborted", type: "invalid_request_error" } }); } catch {}
}
return;
}
let quotaBody;
try { quotaBody = JSON.parse(body); } catch { return jsonResponse(res, 400, { error: "Invalid JSON" }); }
// Validate quota values: must be positive integers or null
@@ -1939,6 +2191,20 @@ const server = createServer(async (req, res) => {
});
// ── Process-level safety nets ────────────────────────────────────────────
// Prevent unhandled async rejections and synchronous exceptions from crashing
// the daemon. Each registers once at module level so they are installed before
// the first request arrives. These are global no-ops on the happy path.
process.on("unhandledRejection", (e) =>
logEvent("error", "unhandled_rejection", { error: e && e.message ? e.message : String(e) })
);
process.on("uncaughtException", (e) =>
logEvent("error", "uncaught_exception", { error: e && e.message ? e.message : String(e) })
);
// Destroy the socket on low-level HTTP parse errors so broken connections
// don't accumulate as open file descriptors.
server.on("clientError", (err, socket) => { try { socket.destroy(); } catch {} });
// ── Graceful shutdown ────────────────────────────────────────────────────
let shuttingDown = false;
@@ -2012,6 +2278,14 @@ server.listen(PORT, BIND_ADDRESS, () => {
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)`);
else console.log(`Cache: disabled (set CLAUDE_CACHE_TTL to enable)`);
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.log(` TUI-mode: ON home=${TUI_HOME} cwd=${TUI_CWD} wallclock=${TUI_WALLCLOCK_MS}ms`);
try {
const n = reapStaleTuiSessions();
if (n) logEvent("info", "tui_reaped_stale_sessions", { count: n });
} catch {}
}
console.log(`---`);
console.log(`Coexistence: This proxy does NOT conflict with Claude Code interactive mode.`);
console.log(` OCP uses: localhost:${PORT} (HTTP) → claude --output-format stream-json (per-request process)`);
+28 -6
View File
@@ -65,6 +65,28 @@ const OCP_ADMIN_KEY_INJECT = process.env.OCP_ADMIN_KEY || null;
// PROXY_ANONYMOUS_KEY — same pattern
const PROXY_ANON_KEY_INJECT = process.env.PROXY_ANONYMOUS_KEY || null;
// ── Inject-value helpers ─────────────────────────────────────────────────
// Escape a value for safe inclusion in a plist <string>…</string> body.
function xmlEscape(v) {
return String(v).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
}
// Validate an injected service value: no control chars (a newline would inject a
// rogue systemd Environment= directive; other control chars corrupt the unit/plist).
// Spaces are allowed — filesystem paths (CLAUDE_BIN) may legitimately contain them.
function assertSafeInjectValue(name, v) {
if (v == null) return v;
if (/[\x00-\x1f]/.test(String(v))) {
console.error(`FATAL: ${name} contains a newline or control character — refusing to write it into the service unit.`);
process.exit(1);
}
return v;
}
// Validate all three INJECT values before they are written into any service unit.
assertSafeInjectValue("CLAUDE_BIN", CLAUDE_BIN_INJECT);
assertSafeInjectValue("OCP_ADMIN_KEY", OCP_ADMIN_KEY_INJECT);
assertSafeInjectValue("PROXY_ANONYMOUS_KEY", PROXY_ANON_KEY_INJECT);
// ── Models: derived from models.json (single source of truth) ──────────
const modelsConfig = JSON.parse(readFileSync(join(__dirname, "models.json"), "utf-8"));
@@ -403,17 +425,17 @@ if (!DRY_RUN) {
<key>EnvironmentVariables</key>
<dict>
<key>CLAUDE_PROXY_PORT</key>
<string>${PORT}</string>
<string>${xmlEscape(PORT)}</string>
<key>CLAUDE_BIND</key>
<string>${BIND_ADDRESS}</string>
<string>${xmlEscape(BIND_ADDRESS)}</string>
<key>CLAUDE_AUTH_MODE</key>
<string>${AUTH_MODE_CONFIG}</string>${CLAUDE_BIN_INJECT ? `
<string>${xmlEscape(AUTH_MODE_CONFIG)}</string>${CLAUDE_BIN_INJECT ? `
<key>CLAUDE_BIN</key>
<string>${CLAUDE_BIN_INJECT}</string>` : ""}${OCP_ADMIN_KEY_INJECT ? `
<string>${xmlEscape(CLAUDE_BIN_INJECT)}</string>` : ""}${OCP_ADMIN_KEY_INJECT ? `
<key>OCP_ADMIN_KEY</key>
<string>${OCP_ADMIN_KEY_INJECT}</string>` : ""}${PROXY_ANON_KEY_INJECT ? `
<string>${xmlEscape(OCP_ADMIN_KEY_INJECT)}</string>` : ""}${PROXY_ANON_KEY_INJECT ? `
<key>PROXY_ANONYMOUS_KEY</key>
<string>${PROXY_ANON_KEY_INJECT}</string>` : ""}
<string>${xmlEscape(PROXY_ANON_KEY_INJECT)}</string>` : ""}
</dict>
<key>RunAtLoad</key>
<true/>
+635
View File
@@ -4,6 +4,7 @@
* Tests database layer functions directly — no server needed.
*/
import { getDb, createKey, listKeys, validateKey, recordUsage, checkQuota, updateKeyQuota, getKeyQuota, findKey, cacheHash, getCachedResponse, setCachedResponse, clearCache, getCacheStats, closeDb, hasCacheControl, singleflight, getInflightStats } from "./keys.mjs";
import { isLoopbackBind } from "./lib/net.mjs";
import { createHash } from "node:crypto";
import { strict as assert } from "node:assert";
import { unlinkSync } from "node:fs";
@@ -858,6 +859,74 @@ test("gcSnapshots keeps last N regardless of age", () => {
rmSync(root, { recursive: true, force: true });
});
// ── setup.mjs helpers: xmlEscape + assertSafeInjectValue ──
// setup.mjs cannot be imported (top-level side effects run the installer).
// Replicated verbatim from setup.mjs for unit-testing — keep in sync with source.
console.log("\nsetup.mjs inject helpers:");
function xmlEscape(v) {
return String(v).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
}
function assertSafeInjectValueTest(name, v) {
if (v == null) return v;
// eslint-disable-next-line no-control-regex
if (/[\x00-\x1f]/.test(String(v))) {
throw new Error(`FATAL: ${name} contains a newline or control character`);
}
return v;
}
test("xmlEscape encodes all five special XML chars", () => {
assert.equal(xmlEscape('a<b>&"\''), "a&lt;b&gt;&amp;&quot;&apos;");
});
test("xmlEscape leaves normal ocp_ token untouched", () => {
assert.equal(xmlEscape("ocp_abc123"), "ocp_abc123");
});
test("assertSafeInjectValue rejects value with newline", () => {
assert.throws(() => assertSafeInjectValueTest("OCP_ADMIN_KEY", "a\nb"), /FATAL/);
});
test("assertSafeInjectValue rejects value with carriage return", () => {
assert.throws(() => assertSafeInjectValueTest("OCP_ADMIN_KEY", "a\rb"), /FATAL/);
});
test("assertSafeInjectValue rejects value with a tab (control char)", () => {
assert.throws(() => assertSafeInjectValueTest("OCP_ADMIN_KEY", "a\tb"), /FATAL/);
});
test("assertSafeInjectValue ACCEPTS a path with a space (CLAUDE_BIN may legitimately contain one)", () => {
assert.equal(assertSafeInjectValueTest("CLAUDE_BIN", "/Users/x/My Apps/node"), "/Users/x/My Apps/node");
});
test("assertSafeInjectValue accepts normal ocp_ token", () => {
assert.doesNotThrow(() => assertSafeInjectValueTest("OCP_ADMIN_KEY", "ocp_abc123"));
});
test("assertSafeInjectValue accepts null (omit path)", () => {
assert.doesNotThrow(() => assertSafeInjectValueTest("OCP_ADMIN_KEY", null));
});
test("plist-merge round-trips XML-escaped value correctly via mergePlistEnv", () => {
// A value written with xmlEscape must survive a merge cycle — the [^<]* regex in
// parsePlistEnv only sees the escaped form (no raw < reaches it), so round-trip is safe.
const escaped = xmlEscape("a<b>&\"'"); // "a&lt;b&gt;&amp;&quot;&apos;"
const template = `<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0">
<dict>
<key>EnvironmentVariables</key>
<dict>
<key>CLAUDE_AUTH_MODE</key>
<string>${escaped}</string>
</dict>
</dict>
</plist>`;
// mergePlistEnv with no existing plist returns template unchanged.
const merged = mergePlistEnv(null, template);
assert.ok(merged.includes(escaped), "escaped value should survive unchanged through plist merge");
});
test("gcSnapshots keeps snapshots newer than keepDays regardless of count", () => {
const root = mkdtempSync(testJoin(tmpdir(), "ocp-gc-days-"));
const dotOcp = testJoin(root, ".ocp");
@@ -1192,6 +1261,572 @@ test("parseStreamJsonEvent: unknown event type returns null", () => {
const result = parseStreamJsonEvent({ type: "future_event_type" }, false);
assert.equal(result, null);
});
// ── Suite: streamStringAsSSE wire-format ────────────────────────────────
// streamStringAsSSE is not exported from server.mjs (internal helper), so we
// test the wire format contract using a local implementation with the same
// logic. This validates the protocol shape (role chunk → content chunks →
// stop → [DONE]) that both the cache-hit replay and TUI streaming paths rely on.
console.log("\nstreamStringAsSSE wire-format:");
function _testSendSSE(res, data) { res.write(`data: ${JSON.stringify(data)}\n\n`); }
function _testStreamStringAsSSE(res, id, model, content) {
const created = Math.floor(Date.now() / 1000);
res.writeHead(200, { "Content-Type": "text/event-stream" });
_testSendSSE(res, { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }] });
const CHUNK = 80;
const codepoints = Array.from(content);
for (let i = 0; i < codepoints.length; i += CHUNK) {
_testSendSSE(res, { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { content: codepoints.slice(i, i + CHUNK).join("") }, finish_reason: null }] });
}
_testSendSSE(res, { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: {}, finish_reason: "stop" }] });
res.write("data: [DONE]\n\n");
res.end();
}
function _makeFakeRes() {
const writes = [];
let headsSent = false;
return {
writes,
writeHead(status, headers) { headsSent = true; this._status = status; this._headers = headers; },
write(s) { writes.push(s); },
end() { this._ended = true; },
};
}
test("streamStringAsSSE emits role chunk + content chunks + stop + [DONE]", () => {
const res = _makeFakeRes();
const content = "hello world";
_testStreamStringAsSSE(res, "test-id", "claude-haiku", content);
assert.ok(res._status === 200, "writeHead(200) called");
assert.ok(res._ended, "res.end() called");
// First write: role delta
const firstEvent = JSON.parse(res.writes[0].replace(/^data: /, "").trim());
assert.equal(firstEvent.choices[0].delta.role, "assistant");
assert.equal(firstEvent.choices[0].finish_reason, null);
// Since content < 80 chars it fits in one chunk
const secondEvent = JSON.parse(res.writes[1].replace(/^data: /, "").trim());
assert.equal(secondEvent.choices[0].delta.content, content);
// Second-to-last: stop chunk
const stopEvent = JSON.parse(res.writes[res.writes.length - 2].replace(/^data: /, "").trim());
assert.equal(stopEvent.choices[0].finish_reason, "stop");
// Last: [DONE]
assert.equal(res.writes[res.writes.length - 1], "data: [DONE]\n\n");
});
test("streamStringAsSSE splits content at 80 codepoints per chunk", () => {
const res = _makeFakeRes();
const content = "x".repeat(200); // 3 chunks: 80+80+40
_testStreamStringAsSSE(res, "test-id-2", "claude-haiku", content);
// writes: [role_chunk, content_chunk_1, content_chunk_2, content_chunk_3, stop_chunk, [DONE]]
assert.equal(res.writes.length, 6);
const c1 = JSON.parse(res.writes[1].replace(/^data: /, "").trim());
assert.equal(c1.choices[0].delta.content.length, 80);
const c2 = JSON.parse(res.writes[2].replace(/^data: /, "").trim());
assert.equal(c2.choices[0].delta.content.length, 80);
const c3 = JSON.parse(res.writes[3].replace(/^data: /, "").trim());
assert.equal(c3.choices[0].delta.content.length, 40);
});
test("streamStringAsSSE empty content: role + stop + [DONE] only", () => {
const res = _makeFakeRes();
_testStreamStringAsSSE(res, "test-id-3", "claude-haiku", "");
// writes: [role_chunk, stop_chunk, [DONE]]
assert.equal(res.writes.length, 3);
const stop = JSON.parse(res.writes[1].replace(/^data: /, "").trim());
assert.equal(stop.choices[0].finish_reason, "stop");
assert.equal(res.writes[2], "data: [DONE]\n\n");
});
// ── Suite: TUI transcript reader ────────────────────────────────────────
import { encodeCwd, transcriptPath, findTranscriptPath, parseTranscriptLines, isTerminalLine, extractLatestAssistantText, verifyEntrypoint } from "./lib/tui/transcript.mjs";
import { readFileSync as tuiReadFileSync, mkdtempSync as tuiMkdtemp0, mkdirSync as tuiMkdir0, writeFileSync as tuiWrite0 } from "node:fs";
import { tmpdir as tuiTmp0 } from "node:os";
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", () => {
const home = tuiMkdtemp0(`${tuiTmp0()}/tui-home-`);
const sid = "11111111-2222-3333-4444-555555555555";
const proj = `${home}/.claude/projects/-some--weird-encoding`;
tuiMkdir0(proj, { recursive: true });
tuiWrite0(`${proj}/${sid}.jsonl`, "{}\n");
assert.equal(findTranscriptPath(home, sid), `${proj}/${sid}.jsonl`);
assert.equal(findTranscriptPath(home, "no-such-uuid"), null);
assert.equal(findTranscriptPath(null, sid), null);
});
console.log("\nTUI transcript — parsing + terminal detection:");
test("parseTranscriptLines skips blank + malformed/partial lines", () => {
const evs = parseTranscriptLines('{"a":1}\n\n{bad json\n{"b":2}\n');
assert.equal(evs.length, 2);
assert.equal(evs[1].b, 2);
});
test("isTerminalLine true on turn_duration", () => {
assert.equal(isTerminalLine({ type: "system", subtype: "turn_duration" }), true);
});
test("isTerminalLine false on stop_reason tool_use (message-wrapped) — tool_use is mid-turn in TUI mode", () => {
assert.equal(isTerminalLine({ type: "assistant", message: { stop_reason: "tool_use" } }), false);
});
test("isTerminalLine false on stop_reason tool_use (flat) — claude continues after tool, turn not done", () => {
assert.equal(isTerminalLine({ stop_reason: "tool_use" }), false);
});
test("isTerminalLine false on ordinary assistant text line", () => {
assert.equal(isTerminalLine({ type: "assistant", message: { content: [{ type: "text", text: "hi" }] } }), false);
});
test("extractLatestAssistantText concatenates text blocks of LAST assistant entry", () => {
const evs = [
{ type: "assistant", message: { content: [{ type: "text", text: "first" }] } },
{ type: "user", message: { content: "..." } },
{ type: "assistant", message: { content: [{ type: "text", text: "A" }, { type: "thinking", thinking: "x" }, { type: "text", text: "B" }] } },
];
assert.equal(extractLatestAssistantText(evs), "AB");
});
test("extractLatestAssistantText ignores thinking-only assistant entries", () => {
// Fixture shape: thinking block and text block are SEPARATE top-level entries sharing same msg id
const evs = [
{ type: "assistant", message: { content: [{ type: "thinking", thinking: "hmm" }] } },
{ type: "assistant", message: { content: [{ type: "text", text: "PONG" }] } },
];
assert.equal(extractLatestAssistantText(evs), "PONG");
});
test("real complete fixture: parseTranscriptLines yields >0 events", () => {
const evs = parseTranscriptLines(tuiReadFileSync("./lib/tui/fixtures/complete-haiku.jsonl", "utf8"));
assert.ok(evs.length > 0, "fixture must parse to events");
});
test("real complete fixture: at least one isTerminalLine", () => {
const evs = parseTranscriptLines(tuiReadFileSync("./lib/tui/fixtures/complete-haiku.jsonl", "utf8"));
assert.ok(evs.some(isTerminalLine), "fixture must contain a terminal line");
});
test("real complete fixture: extractLatestAssistantText returns non-empty text", () => {
const evs = parseTranscriptLines(tuiReadFileSync("./lib/tui/fixtures/complete-haiku.jsonl", "utf8"));
assert.ok(extractLatestAssistantText(evs).length > 0, "fixture must yield assistant text");
});
test("real complete fixture: extractLatestAssistantText returns the FINAL text, not the first", () => {
// The fixture's first assistant text is "PONG"; it is followed by 8 later refusal
// turns. Pinning the exact FINAL string guards the overwrite-to-last semantic —
// a regression that returned the first text block would still pass a length check.
const evs = parseTranscriptLines(tuiReadFileSync("./lib/tui/fixtures/complete-haiku.jsonl", "utf8"));
assert.equal(extractLatestAssistantText(evs), "I'm moving on. If you have a genuine task, let me know.");
});
test("real complete fixture: verifyEntrypoint returns 'cli'", () => {
const evs = parseTranscriptLines(tuiReadFileSync("./lib/tui/fixtures/complete-haiku.jsonl", "utf8"));
assert.equal(verifyEntrypoint(evs), "cli");
});
// ── TUI transcript — polling reader (async) ──────────────────────────────
import { readTuiTranscript } from "./lib/tui/transcript.mjs";
import { mkdtempSync as tuiMkdtemp, writeFileSync as tuiWriteFile } from "node:fs";
import { tmpdir as tuiTmpdir } from "node:os";
console.log("\nTUI transcript — polling reader:");
await asyncTest("readTuiTranscript returns assistant text when terminal marker present", async () => {
const dir = tuiMkdtemp(`${tuiTmpdir()}/tui-`);
const p = `${dir}/s.jsonl`;
tuiWriteFile(p, [
JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "hello world" }] } }),
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.text, "hello world");
assert.equal(out.entrypoint, "cli");
});
await asyncTest("readTuiTranscript honours wall-clock cap and returns partial text", async () => {
const dir = tuiMkdtemp(`${tuiTmpdir()}/tui-`);
const p = `${dir}/s.jsonl`;
tuiWriteFile(p, JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "partial" }] } }) + "\n");
const out = await readTuiTranscript({ transcriptPath: p, wallclockMs: 300, pollMs: 50 });
assert.equal(out.text, "partial");
});
await asyncTest("readTuiTranscript against real fixture: entrypoint is 'cli'", async () => {
const out = await readTuiTranscript({ transcriptPath: "./lib/tui/fixtures/complete-haiku.jsonl", wallclockMs: 2000, pollMs: 50 });
assert.equal(out.entrypoint, "cli");
});
await asyncTest("readTuiTranscript throws when no text and cap elapses", async () => {
const dir = tuiMkdtemp(`${tuiTmpdir()}/tui-`);
const p = `${dir}/missing.jsonl`;
let threw = false;
try { await readTuiTranscript({ transcriptPath: p, wallclockMs: 200, pollMs: 50 }); }
catch { threw = true; }
assert.ok(threw, "must throw on empty timeout");
});
// ── TUI session reaper ───────────────────────────────────────────────────
import { reapStaleTuiSessions, SESSION_PREFIX } from "./lib/tui/session.mjs";
console.log("\nTUI session reaper:");
test("SESSION_PREFIX is ocp-tui-", () => {
assert.equal(SESSION_PREFIX, "ocp-tui-");
});
test("reaper kills ONLY ocp-tui- sessions, never olp-tui-", () => {
const killed = [];
const fakeTmux = (args) => {
if (args[0] === "list-sessions") return { status: 0, stdout: "ocp-tui-aaaa\nolp-tui-bbbb\nmisc\nocp-tui-cccc\n" };
if (args[0] === "kill-session") { killed.push(args[args.indexOf("-t") + 1]); return { status: 0 }; }
return { status: 0, stdout: "" };
};
const n = reapStaleTuiSessions({ tmux: fakeTmux });
assert.equal(n, 2);
assert.equal(killed.join(","), "ocp-tui-aaaa,ocp-tui-cccc");
assert.ok(!killed.includes("olp-tui-bbbb"), "olp-tui-bbbb must never be killed");
});
test("reaper returns 0 when tmux status !== 0 (no server)", () => {
const fakeTmux = (_args) => ({ status: 1, stdout: "" });
const n = reapStaleTuiSessions({ tmux: fakeTmux });
assert.equal(n, 0);
});
test("reaper returns 0 for empty session list", () => {
const killed = [];
const fakeTmux = (args) => {
if (args[0] === "list-sessions") return { status: 0, stdout: "" };
if (args[0] === "kill-session") { killed.push(args[args.indexOf("-t") + 1]); return { status: 0 }; }
return { status: 0, stdout: "" };
};
const n = reapStaleTuiSessions({ tmux: fakeTmux });
assert.equal(n, 0);
assert.equal(killed.length, 0);
});
// ── TUI home preparation (scratch vs real) ───────────────────────────────
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 { tmpdir as hTmp } from "node:os";
console.log("\nTUI home preparation:");
test("prepareTuiHome scratch mode: symlinks creds, seeds onboarded config, trusts cwd, strips history", () => {
const realHome = hMkdtemp(`${hTmp()}/real-`);
hMkdir(`${realHome}/.claude`, { recursive: true });
hWrite(`${realHome}/.claude/.credentials.json`, '{"token":"x"}');
hWrite(`${realHome}/.claude.json`, JSON.stringify({ theme: "dark", projects: { "/old/secret/project": { hasTrustDialogAccepted: true } } }));
const tuiHome = hMkdtemp(`${hTmp()}/tui-`);
const cwd = `${tuiHome}/work`;
prepareTuiHome(realHome, tuiHome, cwd);
// credentials symlinked (token never copied)
assert.equal(hReadlink(`${tuiHome}/.claude/.credentials.json`), `${realHome}/.claude/.credentials.json`);
const seed = JSON.parse(hRead(`${tuiHome}/.claude.json`, "utf8"));
assert.equal(seed.hasCompletedOnboarding, true);
assert.equal(seed.theme, "dark"); // onboarded config carried over
assert.equal(seed.projects[cwd].hasTrustDialogAccepted, true); // scratch cwd trusted
assert.equal(seed.projects["/old/secret/project"], undefined); // user project history stripped
assert.ok(hExists(`${tuiHome}/.claude/projects`)); // own projects dir
});
test("prepareTuiHome real mode (tuiHome===realHome): no symlink, just trusts cwd in real config", () => {
const realHome = hMkdtemp(`${hTmp()}/real2-`);
hWrite(`${realHome}/.claude.json`, JSON.stringify({ projects: {} }));
const cwd = `${realHome}/work`;
prepareTuiHome(realHome, realHome, cwd);
assert.ok(!hExists(`${realHome}/.claude/.credentials.json`)); // no scratch symlink created
const j = JSON.parse(hRead(`${realHome}/.claude.json`, "utf8"));
assert.equal(j.projects[cwd].hasTrustDialogAccepted, true); // cwd trusted in real config
});
// ── resolveTuiEntrypointEnv ───────────────────────────────────────────────
import { resolveTuiEntrypointEnv } from "./lib/tui/session.mjs";
console.log("\nresolveTuiEntrypointEnv:");
test("mode 'cli' sets CLAUDE_CODE_ENTRYPOINT=cli", () => {
const env = {};
resolveTuiEntrypointEnv(env, "cli");
assert.equal(env.CLAUDE_CODE_ENTRYPOINT, "cli");
});
test("mode 'cli' overwrites an inherited CLAUDE_CODE_ENTRYPOINT value", () => {
const env = { CLAUDE_CODE_ENTRYPOINT: "sdk-cli" };
resolveTuiEntrypointEnv(env, "cli");
assert.equal(env.CLAUDE_CODE_ENTRYPOINT, "cli");
});
test("mode 'auto' deletes CLAUDE_CODE_ENTRYPOINT (leaves unset)", () => {
const env = {};
resolveTuiEntrypointEnv(env, "auto");
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", () => {
const env = { CLAUDE_CODE_ENTRYPOINT: "sdk-cli" };
resolveTuiEntrypointEnv(env, "auto");
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", () => {
const env = { CLAUDE_CODE_ENTRYPOINT: "sdk-cli" };
resolveTuiEntrypointEnv(env, "off");
assert.equal(env.CLAUDE_CODE_ENTRYPOINT, "sdk-cli");
});
test("mode 'off' with no inherited value leaves env unchanged", () => {
const env = { OTHER: "x" };
resolveTuiEntrypointEnv(env, "off");
assert.equal(env.CLAUDE_CODE_ENTRYPOINT, undefined);
assert.equal(env.OTHER, "x");
});
test("default mode (no second arg) behaves like 'cli'", () => {
const env = { CLAUDE_CODE_ENTRYPOINT: "sdk-cli" };
resolveTuiEntrypointEnv(env);
assert.equal(env.CLAUDE_CODE_ENTRYPOINT, "cli");
});
// ── TUI session driver: runTuiTurn (live-only, guarded) ──────────────────
console.log("\nTUI session driver:");
if (process.env.OCP_TUI_LIVE === "1") {
await asyncTest("runTuiTurn drives a real interactive turn and returns text", async () => {
const { runTuiTurn } = await import("./lib/tui/session.mjs");
const out = await runTuiTurn({
prompt: "Reply with exactly the word PONG and nothing else.",
model: "claude-haiku-4-5-20251001",
claudeBin: process.env.OCP_TUI_CLAUDE_BIN || "claude",
home: process.env.HOME,
cwd: `${process.env.HOME}/.ocp-tui/work`,
wallclockMs: 120000,
});
assert.ok(/PONG/i.test(out.text), `expected PONG, got: ${out.text.slice(0, 200)}`);
});
} else {
test("runTuiTurn (live) — SKIPPED (set OCP_TUI_LIVE=1 on PI231 to run)", () => {
assert.ok(true);
});
}
// ── /health anonymousKey gate (issue #109) ──────────────────────────────────
// MIRRORS the predicate in server.mjs (search ADVERTISE_ANON_KEY) — copied
// verbatim to avoid importing server.mjs (top-level server.listen() would
// start a live HTTP server, per the stream-JSON parser tests convention above).
console.log("\n/health anonymousKey gate (issue #109):");
// Replicate the gating predicate from server.mjs line ~286/1927:
// ...((isLocalhost || ADVERTISE_ANON_KEY) ? { anonymousKey: ... } : {})
function shouldAdvertiseAnonKey(isLocalhost, advertise) { return isLocalhost || advertise; }
test("(localhost=false, flag=false) → omit key", () => {
assert.equal(shouldAdvertiseAnonKey(false, false), false);
});
test("(localhost=true, flag=false) → include key (localhost always exempt)", () => {
assert.equal(shouldAdvertiseAnonKey(true, false), true);
});
test("(localhost=false, flag=true) → include key (opt-in set)", () => {
assert.equal(shouldAdvertiseAnonKey(false, true), true);
});
test("(localhost=true, flag=true) → include key (both true)", () => {
assert.equal(shouldAdvertiseAnonKey(true, true), true);
});
// ── contentToText helper tests (issue #110) ──────────────────────────────────
// MIRRORS server.mjs contentToText — copied verbatim to avoid importing server.mjs
// (top-level server.listen() would start a live HTTP server).
// Keep in sync with the definition in server.mjs above messagesToPrompt.
console.log("\ncontentToText helper (issue #110):");
function contentToText(content) {
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content.map(p =>
p && p.type === "text" && typeof p.text === "string" ? p.text : "[non-text content omitted]"
).join("");
}
return content == null ? "" : JSON.stringify(content);
}
test("contentToText: string input returned unchanged", () => {
assert.equal(contentToText("hello"), "hello");
});
test("contentToText: array of text parts concatenated", () => {
assert.equal(
contentToText([{ type: "text", text: "hello" }, { type: "text", text: " world" }]),
"hello world"
);
});
test("contentToText: non-text part (image_url) replaced with placeholder", () => {
assert.equal(
contentToText([{ type: "image_url", image_url: { url: "https://example.com/img.png" } }]),
"[non-text content omitted]"
);
});
test("contentToText: empty array returns empty string", () => {
assert.equal(contentToText([]), "");
});
test("contentToText: null returns empty string", () => {
assert.equal(contentToText(null), "");
});
// ── messages guard predicate truth-table (issue #110) ────────────────────────
// Mirrors the guard at server.mjs line ~1650: Array.isArray(x) && x.length > 0
console.log("\nmessages guard predicate (issue #110):");
function isValidMessages(x) { return Array.isArray(x) && x.length > 0; }
test("messages guard: string 'x' → invalid (non-array)", () => {
assert.equal(isValidMessages("x"), false);
});
test("messages guard: empty array [] → invalid", () => {
assert.equal(isValidMessages([]), false);
});
test("messages guard: [{role:'user',content:'hi'}] → valid", () => {
assert.equal(isValidMessages([{ role: "user", content: "hi" }]), true);
});
// ── sanitizeError helper (issue #111) ────────────────────────────────────
// Replicated verbatim from server.mjs (cannot import server.mjs).
// The SIGKILL-escalation and timer changes are process-lifecycle and are not
// unit-testable here (no live-server harness).
console.log("\nsanitizeError (issue #111):");
function sanitizeError(msg) {
return String(msg || "Internal error").replace(/\/[\w/.\-]+/g, "[path]");
}
test("sanitizeError: strips home-dir path from message", () => {
const result = sanitizeError("failed at /Users/foo/.claude/creds.json");
assert.ok(result.includes("[path]"), `expected [path] in: ${result}`);
assert.ok(!result.includes("/Users/foo"), `expected /Users/foo stripped, got: ${result}`);
});
test("sanitizeError: null input returns 'Internal error'", () => {
assert.equal(sanitizeError(null), "Internal error");
});
test("sanitizeError: message with no path passes through unchanged", () => {
assert.equal(sanitizeError("no path here"), "no path here");
});
test("sanitizeError: multiple paths all stripped", () => {
const result = sanitizeError("err /a/b and /c/d");
assert.ok(!result.includes("/a/b"), `expected /a/b stripped, got: ${result}`);
assert.ok(!result.includes("/c/d"), `expected /c/d stripped, got: ${result}`);
assert.ok(result.includes("[path]"), `expected [path] in: ${result}`);
});
// ── models.json SPOT wiring (issue #112) ────────────────────────────────────
// Asserts that the alias values used by server.mjs (usage probe + default model)
// match the expected IDs. A future alias rename that silently breaks these
// code paths is caught here.
import { readFileSync as spotReadFileSync } from "node:fs";
import { fileURLToPath as spotFileURLToPath } from "node:url";
import { dirname as spotDirname, join as spotJoin } from "node:path";
console.log("\nmodels.json SPOT aliases (issue #112):");
const _spotDir = spotDirname(spotFileURLToPath(import.meta.url));
const _spotModels = JSON.parse(spotReadFileSync(spotJoin(_spotDir, "models.json"), "utf8"));
test("models.json aliases.haiku === 'claude-haiku-4-5-20251001' (usage-probe SPOT)", () => {
assert.equal(_spotModels.aliases.haiku, "claude-haiku-4-5-20251001");
});
test("models.json aliases.sonnet === 'claude-sonnet-4-6' (default-request-model SPOT)", () => {
assert.equal(_spotModels.aliases.sonnet, "claude-sonnet-4-6");
});
// ── escapeHtml + key-name validator (issue #114) ────────────────────────────
// Replicated verbatim from dashboard.html so tests run without a browser.
function escapeHtml(s) {
return String(s ?? "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
const KEY_NAME_RE = /^[A-Za-z0-9 ._-]{1,64}$/;
console.log("\nescapeHtml (issue #114):");
test("escapeHtml: XSS payload → &lt;img not <img", () => {
const out = escapeHtml('<img src=x onerror=alert(1)>');
assert.ok(out.includes("&lt;img"), `expected &lt;img in: ${out}`);
assert.ok(!out.includes("<img"), `expected no raw <img in: ${out}`);
});
test("escapeHtml: single-quote, double-quote, ampersand all escaped", () => {
assert.equal(escapeHtml("a'b\"c&d"), "a&#39;b&quot;c&amp;d");
});
test("escapeHtml: null → empty string", () => {
assert.equal(escapeHtml(null), "");
});
console.log("\nKey-name validator (issue #114):");
test("KEY_NAME_RE: 'wife-laptop' → valid", () => {
assert.ok(KEY_NAME_RE.test("wife-laptop"));
});
test("KEY_NAME_RE: 'key-1700000000000' → valid", () => {
assert.ok(KEY_NAME_RE.test("key-1700000000000"));
});
test("KEY_NAME_RE: '<script>' → invalid", () => {
assert.ok(!KEY_NAME_RE.test("<script>"));
});
test("KEY_NAME_RE: \"a'); DROP\" → invalid", () => {
assert.ok(!KEY_NAME_RE.test("a'); DROP"));
});
test("KEY_NAME_RE: empty string → invalid", () => {
assert.ok(!KEY_NAME_RE.test(""));
});
test("KEY_NAME_RE: 65-char string → invalid", () => {
assert.ok(!KEY_NAME_RE.test("x".repeat(65)));
});
// ── isLoopbackBind helper (issue #115, extracted to lib/net.mjs via #125) ──────
// Tests the imported lib/net.mjs helper — the real shared definition used by server.mjs.
console.log("\nisLoopbackBind helper (issue #115):");
test("isLoopbackBind: '127.0.0.1' → true", () => {
assert.equal(isLoopbackBind("127.0.0.1"), true);
});
test("isLoopbackBind: '::1' → true", () => {
assert.equal(isLoopbackBind("::1"), true);
});
test("isLoopbackBind: 'localhost' → true", () => {
assert.equal(isLoopbackBind("localhost"), true);
});
test("isLoopbackBind: '127.0.0.5' → true (127.x.x.x range)", () => {
assert.equal(isLoopbackBind("127.0.0.5"), true);
});
test("isLoopbackBind: '0.0.0.0' → false (any-interface)", () => {
assert.equal(isLoopbackBind("0.0.0.0"), false);
});
test("isLoopbackBind: '192.168.1.5' → false (LAN IP)", () => {
assert.equal(isLoopbackBind("192.168.1.5"), false);
});
test("isLoopbackBind: '::' → false (IPv6 any-interface)", () => {
assert.equal(isLoopbackBind("::"), false);
});
test("isLoopbackBind: '100.64.0.1' → false (Tailscale IP)", () => {
assert.equal(isLoopbackBind("100.64.0.1"), false);
});
// ── Cleanup ──
closeDb();