mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-22 05:25:08 +00:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1978a375e3 | ||
|
|
d97cef7b30 | ||
|
|
66dc9949ed | ||
|
|
758c2d703f | ||
|
|
6cf5e950a5 | ||
|
|
3bd19956ff | ||
|
|
fe615cb0d3 | ||
|
|
60930f0ba4 | ||
|
|
c86e3d014f | ||
|
|
3322d7bdae | ||
|
|
79c1d61e1d | ||
|
|
a37ff713d9 | ||
|
|
6d4751f983 | ||
|
|
0dced52215 | ||
|
|
d291331998 | ||
|
|
9568411bcb |
@@ -1,5 +1,54 @@
|
||||
# Changelog
|
||||
|
||||
## v3.20.1 — 2026-06-13
|
||||
|
||||
TUI-mode auth hardening: fixes the recurring `Please run /login · API Error: 401` (the PI231 incident) and reaps leaked defunct `claude` sessions. ([#141](https://github.com/dtzp555-max/ocp/pull/141))
|
||||
|
||||
### Fixed
|
||||
|
||||
- **TUI 401 / credential corruption (#141)** — interactive `claude` prefers `~/.claude/.credentials.json` over the `CLAUDE_CODE_OAUTH_TOKEN` env var (unlike `-p` mode, where the env token wins). OCP TUI's per-request spawn + `kill-session` cycle raced claude's single-use refresh-token rotation, corrupting the refresh token to an empty string → permanent 401 that `claude /login` couldn't fix (each new spawn re-corrupted it). This bit Linux/file-based hosts specifically (macOS reads credentials from the Keychain, so Mac mini was immune). **Fix:** when `CLAUDE_CODE_OAUTH_TOKEN` is set, the TUI claude now runs in a credential-free scratch HOME (`<HOME>/.ocp-tui/home`, overridable by `OCP_TUI_HOME`) seeded with onboarding + cwd-trust but **no `.credentials.json`**, so the env token is the only credential and claude never runs the refresh path. Recurrence-proof — a later `claude login` can no longer break TUI. Also: `buildTuiCmd` passes `CLAUDE_CODE_OAUTH_TOKEN` to the spawn, and `reapStaleTuiSessions` reaps defunct `claude` sessions (tmux-server-owned zombies) via `kill-server` when no foreign session remains, plus a 15-min idle-gated periodic reap. When the env token is unset, behaviour is byte-for-byte unchanged (real-home + credentials.json). Two independent fresh-context reviewers (Iron Rule 10) + a live PI231 portability test (works with a corrupt credentials.json present). Authorized by the ADR 0007 PR-D amendment (Class B).
|
||||
|
||||
### Environment variables
|
||||
|
||||
- `CLAUDE_CODE_OAUTH_TOKEN` — when set on a TUI host, TUI authenticates via this long-lived token in a credential-isolated home (recommended; immune to credentials.json corruption).
|
||||
- `OCP_TUI_HOME` — overrides the TUI scratch home; if you previously pointed it at your real home, unset it to get the credential-isolated default.
|
||||
|
||||
## v3.20.0 — 2026-06-10
|
||||
|
||||
TUI-mode billing-safety hardening for the 2026-06-15 Anthropic billing split. A 5-dimension multi-agent audit (adversarial verification + live tests on all three hosts — PI231 / Oracle / Mac mini, claude 2.1.104 / 2.1.114 / 2.1.170) found the TUI subscription-pool path could silently bill the metered Agent SDK pool or poison the cache under realistic failure modes. Three PRs, each with a fresh-context reviewer (Iron Rule 10) and CI; the default path (`CLAUDE_TUI_MODE` unset) is byte-for-byte unchanged.
|
||||
|
||||
### TUI — honesty & cache correctness (#137)
|
||||
|
||||
- **C-1** — `callClaudeTui` now throws on a claude-CLI auth-failure banner (e.g. `Please run /login · API Error: 401 …`, `Failed to authenticate. API Error: 401 …`) instead of returning it as a real answer, so it is never cached, singleflight-shared, or counted as a model success. Conservative detector (whole trimmed text ≤100 chars + `API Error: 4xx` + auth keyword + no code/quote char); overridable via `CLAUDE_TUI_ERROR_PATTERNS`. Live-reproduced on PI231.
|
||||
- **C-2** — `readTuiTranscript` distinguishes a complete turn from a wallclock-truncated partial (`truncated` flag); `callClaudeTui` throws `tui_wallclock_truncated` so a partial is never cached or counted as success.
|
||||
- **C-3** — `verifyEntrypoint` reads the `entrypoint` field from any transcript line, not just `{system, turn_duration}` — some claude builds emit zero turn_duration lines (live-confirmed on Oracle's claude 2.1.114), which previously left the billing-drift assertion blind on those builds.
|
||||
- **C-4 (paste)** — short prompts (e.g. `hi`) could never pass paste-landing detection; threshold lowered. Live-reproduced on PI231.
|
||||
|
||||
### TUI — concurrency & observability (#139)
|
||||
|
||||
- **Concurrency** — `OCP_TUI_MAX_CONCURRENT` (default 2) bounds concurrent interactive `claude` boots via a queuing semaphore (`lib/tui/semaphore.mjs`); the slot is released on throw so honesty-gate / spawn failures never leak it; bounded wait-queue → `tui_queue_full` (503). Independent of the global `MAX_CONCURRENT` (8) — a TUI turn is a heavy per-request cold-boot of tmux+claude + up to 120s wallclock.
|
||||
- **Observability** — additive `/health` `tui` block (`enabled` / `entrypointMode` / `lastEntrypoint` / `entrypointMismatches` / `inflight` / `maxConcurrent`) so an operator can poll for a silent `sdk-cli` metered-pool drift (the audit's top risk) instead of grepping journald. Authorized by the ADR 0007 PR-B amendment under the ALIGNMENT grandfather provision (additive, behaviour-preserving — every pre-existing `/health` field unchanged).
|
||||
|
||||
### Operations (#138)
|
||||
|
||||
- `docs/runbooks/615-canary.md` — the 2026-06-15 credit-balance canary: quiesce, read the Agent SDK credit balance (manual — no programmatic API exists for that pool; OCP's `/usage` headers are subscription rate-limit data, not the credit pool), one TUI canary turn, confirm `entrypoint:cli` in the transcript, green/red decision tree, periodic auto-mode self-classification mini-canary.
|
||||
- `docs/runbooks/tui-flip-rollback.md` — flip/rollback per deployment (systemd `daemon-reload`; launchd `bootout`/`bootstrap`, not `kickstart -k`).
|
||||
- `setup.mjs` auth quick-test gated behind `OCP_SKIP_AUTH_TEST=1` (the `claude -p` probe draws from the metered Agent SDK pool after 6/15).
|
||||
|
||||
### New environment variables
|
||||
|
||||
- `OCP_TUI_MAX_CONCURRENT` — max concurrent interactive TUI turns (default 2) (#139).
|
||||
- `OCP_SKIP_AUTH_TEST` — skip the `claude -p` auth probe in `setup.mjs` (default off) (#138).
|
||||
|
||||
## v3.19.0 — 2026-06-02
|
||||
|
||||
TUI-mode reliability + proxy-purity release. Two fixes diagnosed and verified live on both test hosts (PI231 / Oracle, claude 2.1.104 / 2.1.114), each its own PR with a fresh-context reviewer (Iron Rule 10), then an adversarial multi-host test battery (0 hangs / 0 crashes / 0 injection / 0 leaks). The default path (`CLAUDE_TUI_MODE` unset) is byte-for-byte unchanged.
|
||||
|
||||
### TUI
|
||||
|
||||
- **#130** — Fixed the "stuck typing" hang on large multi-line prompts. Three root causes: (1) terminal-turn detection only recognized `{system, turn_duration}`, which older claude builds (e.g. 2.1.114) don't emit → the reader ran to the wallclock and returned partial text; now also accepts an `assistant` line with a final `stop_reason` (`end_turn`/`stop_sequence`/`max_tokens`), while `tool_use` stays non-terminal. (2) Large prompts pasted via `send-keys -l` delivered embedded newlines as separate Enter events → the prompt never landed; now uses `tmux load-buffer` + `paste-buffer -p` (bracketed paste, atomic). (3) The paste-landed check false-positived on claude's empty curly-quote placeholder → Enter fired into an empty box; now positive-signal-only (`[Pasted text]` / prompt text) with a readiness/paste-verify poll + fast-fail (deterministic ~5s error instead of a 120s wallclock hang).
|
||||
- **#4** — TUI-mode never injects the host's `CLAUDE.md` / auto-memory into proxied turns. OCP is a proxy: the proxied client (OpenClaw / an IDE) owns its own context and memory. `buildTuiCmd` now always sets `CLAUDE_CODE_DISABLE_CLAUDE_MDS` + `CLAUDE_CODE_DISABLE_AUTO_MEMORY` (unconditional — proxy purity is not an opt-in). Verified live with a marker `CLAUDE.md`: obeyed by the proxied turn before the fix, blocked after, on both hosts. Residual host-context vectors (managed-policy / `settings.json` / output-styles) tracked in #133. The env is delivered via an `env`-prefix on the tmux pane command (tmux does not forward the spawning process's environment, and `new-session -e` requires tmux ≥3.2 while the cloud host runs 2.7).
|
||||
|
||||
## v3.18.0 — 2026-06-01
|
||||
|
||||
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.
|
||||
|
||||
@@ -128,11 +128,12 @@ Before each step, tell me what you'll run and wait for confirmation.
|
||||
On any error, diagnose first — don't auto-retry.
|
||||
```
|
||||
|
||||
**LAN mode (server)** — install OCP as a server so your family or multiple devices can share it:
|
||||
**LAN mode (server)** — install OCP as a server so your own devices on the LAN can reach it (Claude Pro/Max are per-user accounts — review Anthropic's Usage Policy before extending access to other people):
|
||||
|
||||
```text
|
||||
I want to install OCP on this device as a LAN server so my family and other
|
||||
devices on the network can share my Claude Pro/Max subscription.
|
||||
I want to install OCP on this device as a LAN server so my own devices on the
|
||||
network can reach my Claude Pro/Max subscription through a local
|
||||
OpenAI-compatible endpoint.
|
||||
|
||||
Please follow https://github.com/dtzp555-max/ocp/blob/main/README.md
|
||||
"Server Setup" → "LAN mode" path:
|
||||
@@ -422,6 +423,7 @@ ocp keys revoke son-ipad # Revoke a key
|
||||
- The per-key modes (`shared` / `multi`) give per-key **usage tracking, quotas, and cache separation** — useful for seeing who used what and capping budgets.
|
||||
- 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.
|
||||
- **Account terms are your call.** Claude Pro/Max are *per-user* accounts, and Anthropic's Usage Policy governs who may use them. OCP is a localhost protocol adapter for your own tools and devices — it does not change your account terms, and whether any particular sharing setup complies with the Usage Policy is the account holder's responsibility. Review it before extending access to other people.
|
||||
|
||||
**Real per-user isolation (sandboxed, multi-tenant-safe) is planned for after 2026-06-15** — per-key ephemeral home + tool lockdown + an OS sandbox. Until then, treat a multi-user OCP as a *trusted-group convenience*, not a security boundary. (This is also why `CLAUDE_TUI_MODE` is single-user-only — see [Subscription-pool (TUI) mode](#subscription-pool-tui-mode).)
|
||||
|
||||
@@ -725,7 +727,7 @@ The canonical list lives in [`models.json`](./models.json) — the single source
|
||||
|----------|--------|-------------|
|
||||
| `/v1/models` | GET | List available models |
|
||||
| `/v1/chat/completions` | POST | Chat completion (streaming + non-streaming) |
|
||||
| `/health` | GET | Comprehensive health check |
|
||||
| `/health` | GET | Comprehensive health check (includes a `tui` block for TUI-mode drift/concurrency monitoring) |
|
||||
| `/usage` | GET | Plan usage limits + per-model stats |
|
||||
| `/status` | GET | Combined overview (usage + health) |
|
||||
| `/settings` | GET/PATCH | View or update settings at runtime |
|
||||
@@ -842,6 +844,29 @@ ocp restart
|
||||
openclaw gateway restart
|
||||
```
|
||||
|
||||
### Env var change (e.g. `CLAUDE_BIND`, `CLAUDE_CODE_OAUTH_TOKEN`) doesn't take effect after restart
|
||||
|
||||
On **macOS**, `ocp restart` does a full `launchctl bootout` + `bootstrap` of the agent, which **re-reads the plist `EnvironmentVariables`** — so an env change you made (in `~/Library/LaunchAgents/dev.ocp.proxy.plist`) actually takes effect:
|
||||
|
||||
```bash
|
||||
ocp restart
|
||||
```
|
||||
|
||||
This is deliberate: the older `launchctl kickstart -k` only re-execs the process and **reuses launchd's cached environment**, so plist env edits would be silently ignored. If you ever restart the agent by hand, use bootout+bootstrap, not `kickstart -k`:
|
||||
|
||||
```bash
|
||||
launchctl bootout gui/$(id -u)/dev.ocp.proxy 2>/dev/null
|
||||
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/dev.ocp.proxy.plist
|
||||
```
|
||||
|
||||
Verify the new value reached the running process:
|
||||
|
||||
```bash
|
||||
ps -E -p "$(launchctl print gui/$(id -u)/dev.ocp.proxy 2>/dev/null | awk '/pid =/{print $3}')" | tr ' ' '\n' | grep CLAUDE_
|
||||
```
|
||||
|
||||
On **Linux**, `systemctl --user restart` already re-reads the unit's `EnvironmentFile`, so no special handling is needed.
|
||||
|
||||
### Usage shows "unknown"
|
||||
|
||||
Usually caused by an expired Claude CLI session. Fix:
|
||||
@@ -871,6 +896,24 @@ openclaw gateway restart # so OpenClaw re-reads the config
|
||||
|
||||
Future `ocp update` invocations sync automatically.
|
||||
|
||||
### TUI-mode returns `Please run /login · API Error: 401` (re-login doesn't stick)
|
||||
|
||||
A long-running TUI-mode host can get stuck returning a permanent 401 that re-login cannot fix.
|
||||
|
||||
**Root cause (two layers):** interactive `claude` **prefers `~/.claude/.credentials.json` over the `CLAUDE_CODE_OAUTH_TOKEN` env var** (this is *unlike* the `-p` path, where the env token wins). So (a) a stale/corrupt `credentials.json` **shadows** the env token — passing the token is not enough on its own; and (b) when claude does use `credentials.json`, its single-use OAuth refresh token can be corrupted (ending up an empty string) by the per-request spawn + `kill-session` teardown racing claude's token rotation. Re-login writes a fresh token, but the next spawn re-corrupts it. Proven live on PI231: *env token passed + broken `credentials.json` present → 401; env token passed + `credentials.json` moved aside → works.*
|
||||
|
||||
**Fix:** set `CLAUDE_CODE_OAUTH_TOKEN` on the OCP host and leave `OCP_TUI_HOME` **unset**. OCP then runs the TUI `claude` in a **credential-isolated home** (`$HOME/.ocp-tui/home`) that has **no `credentials.json`** at all, so the env token is the only credential (authoritative — nothing shadows it) and claude never runs the refresh path (so the single-use token can't be corrupted). Then restart — on systemd `daemon-reload`, on launchd `bootout`+`bootstrap`; `kickstart -k` does **not** reload env. Verify the env reached the process and the boot log shows the isolated home:
|
||||
|
||||
```bash
|
||||
# Linux (systemd): confirm the token is in the service env
|
||||
tr '\0' '\n' < /proc/$(pgrep -f server.mjs | head -1)/environ | grep CLAUDE_CODE_OAUTH_TOKEN
|
||||
# Boot log should read: TUI-mode: ON home=$HOME/.ocp-tui/home ... auth=env-token (credential-isolated home — no credentials.json)
|
||||
```
|
||||
|
||||
> If you previously set `OCP_TUI_HOME` to the real home (or any home that contains a `credentials.json`), **unset it** so the credential-isolated default takes effect — otherwise the shadowing `credentials.json` remains in play.
|
||||
|
||||
See [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) and ADR 0007 PR-C / PR-D amendments.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
@@ -883,7 +926,9 @@ Future `ocp update` invocations sync automatically.
|
||||
| `CLAUDE_BIN` | *(auto-detect)* | Path to claude binary |
|
||||
| `CLAUDE_TIMEOUT` | `600000` | Request timeout (ms, default: 10 min) |
|
||||
| `CLAUDE_HEARTBEAT_INTERVAL` | `0` | Streaming SSE keepalive interval (ms). `0` = disabled. See "Streaming heartbeat" section. |
|
||||
| `CLAUDE_MAX_CONCURRENT` | `8` | Max concurrent claude processes |
|
||||
| `CLAUDE_MAX_CONCURRENT` | `8` | Max concurrent claude processes (`-p`/stream-json path) |
|
||||
| `CLAUDE_MAX_QUEUE` | `16` | Max requests **waiting** for a `-p` concurrency slot. Beyond `CLAUDE_MAX_CONCURRENT`, requests queue (up to this cap) instead of being rejected; when the queue is **also** full, the request gets `HTTP 429` + `Retry-After` (not an opaque 500). Surfaced on `/health.concurrency` + `/health.stats.queueRejections`. |
|
||||
| `CLAUDE_QUEUE_RETRY_AFTER` | `5` | Seconds advertised in the `Retry-After` header on a `-p` concurrency-overflow `429`. |
|
||||
| `CLAUDE_MAX_PROMPT_CHARS` | `150000` | Prompt truncation limit (chars) |
|
||||
| `CLAUDE_SESSION_TTL` | `3600000` | Session expiry (ms, default: 1 hour) |
|
||||
| `CLAUDE_CACHE_TTL` | `0` | Response cache TTL (ms, 0 = disabled). Set to e.g. `300000` for 5-min cache |
|
||||
@@ -894,10 +939,15 @@ Future `ocp update` invocations sync automatically.
|
||||
| `PROXY_ANONYMOUS_KEY` | *(unset)* | Well-known anonymous key allowlist (multi mode). When set, this exact string bypasses `validateKey()` and grants public access. Exposed via `/health.anonymousKey` only to localhost, or to all callers when `PROXY_ADVERTISE_ANON_KEY=1`. See [Anonymous Access](#anonymous-access-optional). |
|
||||
| `PROXY_ADVERTISE_ANON_KEY` | *(unset)* | When `=1`, advertise `PROXY_ANONYMOUS_KEY` in the public `/health` body for remote zero-config discovery. Default off — `/health` is unauthenticated, so this exposes the shared key to any LAN-reachable device (issue #109). Localhost always sees it regardless. |
|
||||
| `CLAUDE_TUI_MODE` | `false` | **Opt-in.** Set to `"true"` to serve requests via interactive `claude` (no `-p` / `--output-format` → `cc_entrypoint=cli`, subscription pool). **Single-user only** — see [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) for the security constraint. |
|
||||
| `CLAUDE_CODE_OAUTH_TOKEN` | *(unset)* | OAuth bearer token (highest-precedence credential source for the `-p` path). **Recommended for TUI-mode hosts:** when set (and `OCP_TUI_HOME` unset), OCP runs the interactive `claude` in a **credential-isolated home** (`$HOME/.ocp-tui/home`, no `credentials.json`) so this long-lived token is the only credential and is authoritative — interactive `claude` otherwise *prefers* `~/.claude/.credentials.json` over the env var, so a stale one shadows the token and its single-use refresh token gets corrupted by the spawn/teardown cycle (the permanent `Please run /login` 401 — see [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) and ADR 0007 PR-D). The token appears in the pane command (ps-visible) — acceptable for the single-user A-path; the multi-user B-path is refused at boot. |
|
||||
| `OCP_SPAWN_REAL_HOME` | *(unset)* | Kill-switch for the default `-p`/stream-json **spawn-home isolation** (latency fix). When unset and an OAuth token is resolvable, OCP runs the per-request `claude` spawn in a **credential-free minimal scratch home** (`$HOME/.ocp/spawn-home`, no `.credentials.json`/`settings.json`/plugins) with a neutral cwd and the env token — so it loads none of the operator's heavy global `~/.claude` (plugins/skills/hooks) or the project `CLAUDE.md`, cutting per-request latency (measured ~10–28s → ~3–7s). Set to `"1"` to force the legacy real-`HOME` spawn (no cwd override) even when a token exists. With **no** resolvable token, OCP falls back to the real `HOME` automatically (zero regression). Active mode is shown at startup and on `/health.spawn`. |
|
||||
| `CLAUDE_TUI_WALLCLOCK_MS` | `120000` | (TUI-mode) Maximum time in ms to wait for the native transcript to signal turn completion. Increase for long Opus thinking turns. |
|
||||
| `OCP_TUI_CWD` | `$HOME/.ocp-tui/work` | (TUI-mode) Scratch working directory where interactive claude sessions run. Transcripts land under `<HOME>/.claude/projects/<encoded-cwd>/`. Created automatically. |
|
||||
| `OCP_TUI_HOME` | `$HOME` (real home) | (TUI-mode) `HOME` claude runs under. Default is the operator's real home (shared credentials, existing onboarding). Set to a separate path for scratch-home isolation — see ADR 0007 for the credential-fork caveat. |
|
||||
| `OCP_TUI_HOME` | *(auto)* | (TUI-mode) `HOME` claude runs under. **When unset, OCP picks it for you:** if `CLAUDE_CODE_OAUTH_TOKEN` is set → a **credential-isolated** scratch home `$HOME/.ocp-tui/home` (no `credentials.json`, env-token auth — **recommended**); if no env token → the operator's real home (legacy shared `credentials.json`). Setting this to an **explicit** path overrides the auto-default. The credential handling at that path still follows the env token: **with** the env token it is credential-free (env-token auth, no `credentials.json` written); **without** the env token (and the path ≠ real home) it uses the legacy symlinked-credentials scratch mode, which carries the credential-fork caveat — see ADR 0007. |
|
||||
| `OCP_TUI_ENTRYPOINT` | `cli` | (TUI-mode) Billing-classifier labeling: `cli` (default) pins `cc_entrypoint=cli` deterministically; `auto` lets claude self-classify via TTY detection; `off` leaves the inherited env untouched. Honest only when the spawn is a genuine interactive PTY — see ADR 0007. |
|
||||
| `OCP_TUI_MAX_CONCURRENT` | `2` | (TUI-mode) Max concurrent interactive TUI turns. **Independent** of `CLAUDE_MAX_CONCURRENT` (which bounds the `-p`/stream-json path; TUI never uses it). A TUI turn is heavy (per-request cold-boot of tmux+claude + up to `CLAUDE_TUI_WALLCLOCK_MS` wallclock), so the default is low to keep small hosts (e.g. a Pi 4) alive under a burst. Excess turns **queue** (bounded); a full queue yields a 503. See ADR 0007 PR-B amendment. |
|
||||
| `OCP_SKIP_AUTH_TEST` | *(unset)* | When `=1`, skip the `claude -p` auth probe during `setup.mjs`. After 2026-06-15 this probe draws from the Agent SDK credit pool; set this to avoid burning a metered credit on re-installs or `ocp update` runs. Auth is validated at the first real request. |
|
||||
| `OCP_TUI_FULL_TOOLS` | *(unset)* | (TUI-mode, **single-user only**) When `=1`, grant the interactive session the **same tool surface as the `-p` path** — `--allowedTools` (+ optional `--mcp-config` / `--dangerously-skip-permissions`, read from `CLAUDE_ALLOWED_TOOLS` / `CLAUDE_MCP_CONFIG` / `CLAUDE_SKIP_PERMISSIONS`) — instead of the default MCP-walled, built-in-tools-only set. Lets a trusted single-operator TUI deployment run a **tool-using / MCP agent** (e.g. an OpenClaw assistant) on the subscription pool. Safe because TUI **refuses to boot under `AUTH_MODE=multi`** (hard exit) — no guest key can ever reach the TUI path, so this gate cannot expose tools to an untrusted caller. (Under `AUTH_MODE=shared` + `OCP_TUI_ALLOW_LAN=1`, anyone holding the single shared key reaches it — that is the existing TUI trust model, unchanged.) See [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) and ADR 0007. |
|
||||
|
||||
### Streaming heartbeat
|
||||
|
||||
@@ -943,17 +993,27 @@ mkdir -p ~/.ocp-tui/work # one-time scratch cwd setup
|
||||
|
||||
# Enable
|
||||
export CLAUDE_TUI_MODE=true
|
||||
# STRONGLY RECOMMENDED on a TUI host — authenticate via the long-lived OAuth token.
|
||||
# With this set (and OCP_TUI_HOME left UNSET), OCP runs the interactive claude in a
|
||||
# credential-isolated home ($HOME/.ocp-tui/home, no credentials.json), so the env token
|
||||
# is the only credential and is authoritative. This both stops a stale credentials.json
|
||||
# from shadowing the token AND ends the refresh-token corruption that caused a permanent
|
||||
# "Please run /login" 401 (no credentials file → claude never runs the refresh path).
|
||||
# See the auth note below + ADR 0007 PR-D.
|
||||
export CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-...
|
||||
# Optionally tune:
|
||||
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
|
||||
# Do NOT set OCP_TUI_HOME for the recommended setup — leaving it unset is what enables
|
||||
# the credential-isolated home. Set it only to opt into the legacy symlinked-creds mode.
|
||||
```
|
||||
|
||||
Then restart OCP. At boot you will see:
|
||||
Then restart OCP. At boot you will see (with the env token set, isolated home auto-selected):
|
||||
|
||||
```
|
||||
⚠️ TUI-mode ON — single-user only; do NOT enable on a multi-user OCP ...
|
||||
TUI-mode: ON home=/home/user cwd=/home/user/.ocp-tui/work wallclock=120000ms
|
||||
TUI-mode: ON home=/home/user/.ocp-tui/home cwd=/home/user/.ocp-tui/work auth=env-token (credential-isolated home — no credentials.json) wallclock=120000ms maxConcurrent=2
|
||||
```
|
||||
|
||||
### What changes / what doesn't
|
||||
@@ -961,7 +1021,29 @@ Then restart OCP. At boot you will see:
|
||||
- **Callers see no API change.** The response is a normal OpenAI completion object or chunked SSE — identical wire format.
|
||||
- **No real token streaming.** TUI-mode buffers the full response then replays it as chunked SSE. You will see a delay then the complete response rather than real-time tokens.
|
||||
- **Cache and singleflight work normally.** TUI-mode writes the buffered response to the cache on success; cache-hits skip the interactive turn entirely.
|
||||
- **The host's `CLAUDE.md` / auto-memory is never injected.** OCP is a proxy — the proxied client (OpenClaw / your IDE) owns its own context and memory. TUI-mode always runs `claude` with `CLAUDE_CODE_DISABLE_CLAUDE_MDS` + `CLAUDE_CODE_DISABLE_AUTO_MEMORY`, so a `CLAUDE.md` on the OCP host can never leak into proxied turns (verified live; see #4). Built-in tool schemas + the interactive system prompt remain (the inherent ~20–35K context floor of interactive mode); MCP is hard-disabled.
|
||||
- **Authenticate via `CLAUDE_CODE_OAUTH_TOKEN` in a credential-isolated home (recommended).** tmux does not forward the parent process's env to the pane, so OCP sets the token explicitly on the spawned `claude` when `CLAUDE_CODE_OAUTH_TOKEN` is present. But passing the token is **not enough on its own**: interactive `claude` *prefers* `~/.claude/.credentials.json` over the env var (unlike the `-p` path), so a stale `credentials.json` would shadow the token. With the env token set and `OCP_TUI_HOME` unset, OCP therefore runs claude in a **credential-isolated home** (`$HOME/.ocp-tui/home`) that has **no `credentials.json`** — so the env token is the only credential and is authoritative, and claude never runs the token-refresh path (so the single-use refresh token can't be corrupted by the spawn/teardown cycle). On a long-running host the credentials.json path produced a permanent `Please run /login · API Error: 401` that re-login could not fix (the next spawn re-corrupted it); the isolated home ends that at the root. Transcripts land under the same isolated home, so the answer-reader is unaffected. Without the env token, claude falls back to the real home's `credentials.json` (byte-for-byte the previous behaviour). (The token is visible in `ps` on the pane command — acceptable for the single-user A-path; the multi-user B-path is refused at boot.) See ADR 0007 PR-C / PR-D amendments.
|
||||
- **Stale tmux sessions are reaped.** The pane's `claude` is a child of the tmux server (not OCP), so OCP cannot reap it directly; `claude` zombies can otherwise accumulate as `<defunct>` over a long-running host. OCP reaps them at boot and on a 15-min idle sweep by issuing `tmux kill-server` — but **only when no foreign tmux session remains** (it never disrupts a co-hosted `olp-tui-*` instance). See ADR 0007 PR-C amendment.
|
||||
- **Default path unchanged.** Unset `CLAUDE_TUI_MODE` and restart → `callClaude` / `callClaudeStreaming` are used again, byte-for-byte identical to today.
|
||||
- **Concurrency is bounded separately.** TUI turns are heavy (per-request cold-boot + long wallclock), so the TUI path has its own limiter — `OCP_TUI_MAX_CONCURRENT` (default `2`), independent of `CLAUDE_MAX_CONCURRENT`. Excess turns queue; a full queue returns a 503. Tune it up only on a host that can run more interactive `claude` sessions at once.
|
||||
|
||||
### Monitoring drift via `/health`
|
||||
|
||||
`GET /health` includes a `tui` block so you can poll for a silent billing-pool drift (the top risk after the 6/15 flip — a lost TTY flipping `cc_entrypoint` from `cli` to the metered `sdk-cli` pool would still return answers but burn metered credits). The block is **always present** (with `enabled:false` when TUI-mode is off):
|
||||
|
||||
```jsonc
|
||||
"tui": {
|
||||
"enabled": true, // CLAUDE_TUI_MODE === "true"
|
||||
"entrypointMode": "cli", // OCP_TUI_ENTRYPOINT (cli | auto | off)
|
||||
"lastEntrypoint": "cli", // last cc_entrypoint observed in a transcript, or null
|
||||
"entrypointMismatches": 0, // count of cli-expected-but-got-other turns — ALERT if this climbs
|
||||
"inflight": 1, // TUI turns running right now
|
||||
"queued": 0, // TUI turns waiting for a concurrency slot
|
||||
"maxConcurrent": 2 // OCP_TUI_MAX_CONCURRENT
|
||||
}
|
||||
```
|
||||
|
||||
Alert on `entrypointMismatches > 0` (or `lastEntrypoint !== "cli"`): it means a turn drew from the metered Agent SDK pool instead of the subscription. `inflight` / `queued` show how close the TUI path is to its concurrency cap.
|
||||
|
||||
### Kill-switch
|
||||
|
||||
@@ -972,6 +1054,13 @@ unset CLAUDE_TUI_MODE
|
||||
|
||||
The stream-json path is restored immediately. No other change is needed.
|
||||
|
||||
### 2026-06-15 operator checklist
|
||||
|
||||
Every host serving traffic must be flipped to TUI-mode **and** canary-verified before 2026-06-15, or it will bill the metered Agent SDK credit pool instead of the subscription.
|
||||
|
||||
- **[Flip/rollback runbook](docs/runbooks/tui-flip-rollback.md)** — how to set `CLAUDE_TUI_MODE=true` on systemd (Linux) and launchd (macOS) hosts. Covers the `daemon-reload` requirement (systemd) and the `bootout`+`bootstrap` cycle requirement (launchd — `launchctl kickstart -k` does not reload plist env).
|
||||
- **[615-canary runbook](docs/runbooks/615-canary.md)** — after each flip, run one quiesced request and compare the Agent SDK credit balance before and after. `entrypoint:cli` in the transcript (the `cc_entrypoint` billing classifier) is necessary but not sufficient — only a stable credit balance confirms the subscription pool is being used. Balance check is a manual step (no known programmatic API for the Agent SDK credit pool balance).
|
||||
|
||||
### Architecture and design decisions
|
||||
|
||||
See [`docs/adr/0007-tui-interactive-mode.md`](docs/adr/0007-tui-interactive-mode.md) for the full rationale, home-strategy options, MCP-disable mechanism, coexistence rules, and the B-path (multi-tenant isolation) roadmap.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# ADR 0007 — TUI Interactive Mode (subscription-pool bridge)
|
||||
|
||||
**Date:** 2026-05-31
|
||||
**Status:** Accepted — amended by PR-4 (entrypoint hardening)
|
||||
**Status:** Accepted — amended by PR-4 (entrypoint hardening), PR-B (observability + concurrency), PR-C (env-token auth + defunct-reaping), PR-D (credential-isolated home — corrects PR-C)
|
||||
**Deciders:** project maintainer
|
||||
**Authority:** claude CLI v2.1.158 interactive mode — verified live on the test host that sessions launched without `-p` / `--output-format` carry `cc_entrypoint=cli` (subscription pool), not `cc_entrypoint=sdk-cli` (Agent SDK credit pool). Mechanism verified on cli.js v2.1.104; live-confirmed on v2.1.158.
|
||||
|
||||
@@ -98,12 +98,16 @@ When `CLAUDE_TUI_MODE` is unset (the default), no code path touches `callClaudeT
|
||||
|
||||
Unset `CLAUDE_TUI_MODE` (or set it to any value other than `"true"`) → stream-json path restored immediately on next restart.
|
||||
|
||||
### Home strategy (real-home default)
|
||||
### Home strategy
|
||||
|
||||
`TUI_HOME = OCP_TUI_HOME || HOME` (defaults to the operator's real home).
|
||||
> **Superseded by the PR-D amendment below for the env-token case.** As of PR-D, `TUI_HOME`
|
||||
> is computed by `resolveTuiHome()`: when `CLAUDE_CODE_OAUTH_TOKEN` is set (and `OCP_TUI_HOME`
|
||||
> is unset) the default is a **credential-free scratch home**, not the real home. The
|
||||
> descriptions below remain accurate for the **no-env-token** case and the **explicit
|
||||
> `OCP_TUI_HOME` override** case.
|
||||
|
||||
- **Real-home (default, `OCP_TUI_HOME` unset):** claude runs with the operator's own `~/.claude/` — shared credentials, existing onboarding, no OAuth fork risk. `ensureTuiCwdTrusted` seeds the trust record for the scratch cwd in the real `~/.claude.json` (atomic write).
|
||||
- **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.
|
||||
- **Real-home (default when NO env token, `OCP_TUI_HOME` unset):** claude runs with the operator's own `~/.claude/` — shared credentials, existing onboarding, no OAuth fork risk. `ensureTuiCwdTrusted` seeds the trust record for the scratch cwd in the real `~/.claude.json` (atomic write).
|
||||
- **Scratch-home opt-in (`OCP_TUI_HOME=<path>`, no env token):** a dedicated `HOME` that symlinks `~/.claude/.credentials.json` from the real home (token is never copied) and seeds a stripped `~/.claude.json` (no project history, trusts only the scratch cwd). **Caveat:** claude rewrites `.credentials.json` on OAuth token refresh, replacing the symlink with a regular file — this forks the credentials. Use this legacy symlink mode only with a dedicated OAuth or for ephemeral testing. (The PR-D env-token mode avoids this caveat entirely — no credentials file to fork.)
|
||||
|
||||
### Working directory
|
||||
|
||||
@@ -139,6 +143,174 @@ B-path is **deferred** and is not implemented in this ADR. Until B-path lands, T
|
||||
|
||||
---
|
||||
|
||||
## Observability and concurrency (PR-B amendment)
|
||||
|
||||
**Date:** 2026-06-10
|
||||
**Status:** Accepted — amends ADR 0007.
|
||||
**Motivation:** the post-PR-A code audit, findings C-4 (P1) and C-5 (P1).
|
||||
|
||||
### C-4 — independent concurrency bound for the TUI path
|
||||
|
||||
The global `MAX_CONCURRENT` gate lives in `spawnClaudeProcess()` (the `-p` / stream-json
|
||||
path). `callClaudeTui()` never calls `spawnClaudeProcess` — it calls `runTuiTurn()`, which
|
||||
cold-boots a full interactive `claude` inside a fresh tmux session. So the TUI path had **no**
|
||||
concurrency bound: N concurrent TUI requests spawned N simultaneous cold-boot tmux+claude
|
||||
processes. On a small host (e.g. a Pi 4 serving a family) a burst of ~5 is an OOM risk and
|
||||
also multiplies subscription rate-limit pressure.
|
||||
|
||||
PR-B adds an **independent** limiter for the TUI path (`lib/tui/semaphore.mjs`,
|
||||
`TuiSemaphore`):
|
||||
|
||||
- **`OCP_TUI_MAX_CONCURRENT`, default `2`.** Rationale: a TUI turn is heavy — a per-request
|
||||
cold-boot of tmux+claude plus up to `CLAUDE_TUI_WALLCLOCK_MS` (120 s) of wallclock — so a
|
||||
small host cannot run many at once. `2` is the conservative default that keeps a Pi-class
|
||||
host alive under a family burst while still allowing some overlap. It is deliberately **not**
|
||||
the same knob as `MAX_CONCURRENT` (default 8): the two pools have different shapes (a
|
||||
stream-json spawn is cheap and fast; a TUI turn is a heavy cold-boot + long wallclock), so
|
||||
coupling them would mis-size one of the two paths.
|
||||
- **Queue, don't reject.** The limiter **queues** (awaits a slot), mirroring the spirit of
|
||||
`MAX_CONCURRENT` — requests are not dropped on contention. To bound memory against a runaway
|
||||
client, the wait queue itself is capped (`maxQueue`, default 32× the limit); when the queue
|
||||
is full `run()` rejects with `tui_queue_full`, surfaced as a 503 — deterministic backpressure
|
||||
rather than silent OOM.
|
||||
- **Slot released in a `finally`.** `TuiSemaphore.run(fn)` releases the slot in a `finally`, so
|
||||
any throw — PR-A's honesty gates (`tui_wallclock_truncated`, `tui_upstream_error`), a
|
||||
`tui_paste_not_landed`, or a `tui_spawn_failed` — can never leak a slot.
|
||||
|
||||
This limiter has **zero effect when `TUI_MODE` is off**: `callClaudeTui` is never reached, so
|
||||
the semaphore is never entered. The default stream-json path is untouched.
|
||||
|
||||
### C-5 — operator-visible drift surface on `/health` (additive)
|
||||
|
||||
The `tui_entrypoint_mismatch` warning only reached journald. After the 2026-06-15 flip, a
|
||||
silent `sdk-cli` drift (the documented top risk in this ADR — a lost TTY flipping the
|
||||
self-classification to the metered Agent SDK pool) would drain metered credits **invisibly**.
|
||||
PR-B adds a `tui` block to the `/health` JSON response so an operator can poll it:
|
||||
|
||||
```
|
||||
tui: {
|
||||
enabled: <TUI_MODE>,
|
||||
entrypointMode: <OCP_TUI_ENTRYPOINT>, // cli | auto | off
|
||||
lastEntrypoint: <last observed cc_entrypoint, e.g. "cli", or null>,
|
||||
entrypointMismatches: <count of cli-expected-but-got-other turns>,
|
||||
inflight: <current concurrent TUI turns>,
|
||||
queued: <turns waiting for a slot>,
|
||||
maxConcurrent: <OCP_TUI_MAX_CONCURRENT>
|
||||
}
|
||||
```
|
||||
|
||||
`lastEntrypoint` is recorded and `entrypointMismatches` incremented inside `callClaudeTui` in
|
||||
the same mismatch branch that already emits the journald warning (via `recordTuiEntrypoint`).
|
||||
`inflight` / `queued` / `maxConcurrent` come from the C-4 semaphore. When `TUI_MODE` is off the
|
||||
block still appears with `enabled:false` (cheap, harmless) so the response shape is stable for
|
||||
consumers regardless of mode.
|
||||
|
||||
### ALIGNMENT authorization for the `/health` change
|
||||
|
||||
`/health` is a **grandfathered B.2 endpoint** under ADR 0006, frozen at its v3.16.4 behaviour.
|
||||
`ALIGNMENT.md`'s grandfather provision states: *"Any change to the contract (request shape,
|
||||
response shape, semantics) of a grandfathered B.2 endpoint is treated as a new authorization
|
||||
request and requires either a behaviour-preserving refactor PR or its own ADR."*
|
||||
|
||||
This amendment **is** that authorization. The argument:
|
||||
|
||||
- The change is **additive**: it adds one new top-level field (`tui`) containing only new
|
||||
sub-fields. **No existing `/health` field is changed, renamed, removed, or re-typed**, and no
|
||||
existing semantics change. Existing `/health` consumers (the dashboard, `ocp-connect`,
|
||||
monitoring) read the fields they already read and are unaffected — the change is
|
||||
**behaviour-preserving** for them, which is exactly the bar the grandfather provision sets for
|
||||
a non-ADR contract change.
|
||||
- The TUI observability surface is an **intrinsic part of the TUI feature** whose authorizing
|
||||
authority is **this ADR (0007)**, not a brand-new B.2 endpoint. We are not adding a new B.2
|
||||
endpoint or a new method (which would each require their own fresh ADR under the New Class B
|
||||
endpoint procedure) — we are extending the response of an existing grandfathered endpoint with
|
||||
fields that report state owned by an ADR-0007 feature. ADR 0007 is the natural home for that
|
||||
authority, and this amendment records it explicitly.
|
||||
- `cli.js` does not perform this operation — `/health` is OCP-owned (Class B), so no `cli.js`
|
||||
citation applies; the citation is this ADR + ADR 0006 (grandfathered B.2) per
|
||||
`ALIGNMENT.md`'s Class B citation requirement.
|
||||
|
||||
### `OCP_TUI_MAX_CONCURRENT` summary
|
||||
|
||||
| Env var | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `OCP_TUI_MAX_CONCURRENT` | `2` | Max concurrent interactive TUI turns. Independent of `CLAUDE_MAX_CONCURRENT` (the stream-json path). Excess turns queue (bounded); a full queue yields a 503. |
|
||||
|
||||
---
|
||||
|
||||
## Authentication + defunct-reaping (PR-C amendment)
|
||||
|
||||
**Date:** 2026-06-13
|
||||
**Status:** Accepted — amends ADR 0007.
|
||||
**Motivation:** the PI231 production incident — TUI-mode returned `Please run /login · API Error: 401` for days; re-login never stuck.
|
||||
|
||||
### How the TUI `claude` authenticates
|
||||
|
||||
The spawned interactive `claude` obtains its OAuth bearer in one of two ways, in this order of preference:
|
||||
|
||||
1. **`CLAUDE_CODE_OAUTH_TOKEN` in env (PREFERRED).** If the env var is set on the OCP process, `buildTuiCmd` adds `CLAUDE_CODE_OAUTH_TOKEN=<shq-escaped token>` to the pane command's `env` prefix. claude then authenticates via this long-lived token and **never touches the credentials-refresh path**. This is the stable mode — it is exactly how the oracle and Mac-mini hosts already run (and how `server.mjs`'s own `getOAuthCredentials()` takes the same env at highest precedence). cli.js is **not** the authority here: this is a Class B, OCP-owned TUI spawn — see the Class B citation below.
|
||||
2. **`<HOME>/.claude/.credentials.json` (FALLBACK).** When the env var is unset, claude falls back to the credentials file and its short-lived access token, renewing via the single-use refresh token.
|
||||
|
||||
The token MUST be set explicitly in `buildTuiCmd` because **tmux does not forward the parent process's environment to the pane** (verified live 2026-06-01 — the same reason the whole env is delivered as an `env` prefix). A token sitting in the OCP process env is invisible to the pane unless `buildTuiCmd` re-emits it.
|
||||
|
||||
### Why the fallback path corrupts (the PI231 incident)
|
||||
|
||||
When the env token is absent, every per-request spawn drives claude through the credentials.json refresh path. OAuth refresh tokens are **single-use / rotating**: a refresh consumes the old refresh token and writes a new one. The per-request `kill-session` teardown can race / interrupt claude mid-rotation, and over many spawn+kill cycles the refresh token ended up an **empty string** — at which point renewal is impossible and the host returns a permanent 401. Re-login writes a fresh token, but the next spawn re-corrupts it. **Proof the env-token fix works:** on the broken PI231 host, `CLAUDE_CODE_OAUTH_TOKEN=<oat01 token> claude -p ...` returned a real answer *despite* the corrupt credentials.json (control without the env token = 401).
|
||||
|
||||
**Operator guidance:** set `CLAUDE_CODE_OAUTH_TOKEN` on any TUI-mode host. The credentials.json fallback is retained only for hosts that intentionally rely on it; it is not recommended for a long-running TUI deployment.
|
||||
|
||||
**Security note:** with the token in the pane command, it is visible in `ps`. This is acceptable for the **single-user A-path** (it mirrors the existing plaintext-token practice for `server.mjs`), and the **multi-user B-path is already refused at boot** (`CLAUDE_TUI_MODE=true` + `AUTH_MODE=multi` is a hard FATAL), so a guest can never reach this spawn.
|
||||
|
||||
### Defunct `<claude>` reaping
|
||||
|
||||
The connected leak: the pane's `claude` process is a child of the long-lived **tmux server** daemon, not of the OCP node process (`tmux new-session -d` returns the instant the server forks the pane). Node can therefore never `waitpid()`/reap it — a SIGKILL still needs the *parent* (the tmux server) to reap. `kill-session` destroys the session but leaves the pane's `claude` (and its grandchildren) as `<defunct>` zombies that only the server reaps; over 30 days on PI231 this accumulated to **25 defunct `<claude>`** (a live `tmux kill-server` dropped it 25→3).
|
||||
|
||||
The node-reachable action that *actually reaps* — rather than merely re-signalling — is to stop the tmux server: on server exit the kernel reparents survivors to init (PID 1), which reaps them. `reapStaleTuiSessions` therefore, after killing our own `ocp-tui-*` sessions, issues `kill-server` **only when no foreign session of any prefix remains** (coexistence: never disrupt a co-hosted `olp-tui-*` instance). This runs at boot (existing) and now on a 15-min periodic interval gated on TUI-mode and on the TUI path being idle (`inflight === 0 && queued === 0`) so a live turn's pane is never torn down. Residual: a request whose pane is created in the narrow window between the idle-check and `kill-server` would fail cleanly via the existing honesty gates (rare; documented in the server comment).
|
||||
|
||||
### ALIGNMENT authorization (Class B)
|
||||
|
||||
Both changes are **Class B** (OCP-owned TUI spawn). `cli.js` does not perform either operation — there is no `cli.js` analogue for "how the TUI pane authenticates" or "reaping tmux-server-owned zombies"; this surface is authorized by **this ADR (0007)** per `ALIGNMENT.md`'s Class B citation requirement. No Class A wire surface, no endpoint shape, no `alignment.yml` blacklist token, and no `models.json` entry is touched.
|
||||
|
||||
---
|
||||
|
||||
## Credential-isolated home for env-token auth (PR-D amendment)
|
||||
|
||||
**Date:** 2026-06-13
|
||||
**Status:** Accepted — amends ADR 0007. **Corrects** the PR-C rationale and the original "Home strategy" section's scratch-home caveat.
|
||||
**Motivation:** PR-C's env-token passing alone did **not** fix the PI231 401. Decisive live evidence (claude 2.1.104, PI231):
|
||||
|
||||
| Condition | Result |
|
||||
|---|---|
|
||||
| env token passed + a broken `~/.claude/.credentials.json` present | **401** (`Please run /login · API Error: 401`) |
|
||||
| env token passed + `credentials.json` moved aside | **works** (real answer) |
|
||||
|
||||
### Corrected root cause
|
||||
|
||||
**Interactive `claude` PREFERS `~/.claude/.credentials.json` over the `CLAUDE_CODE_OAUTH_TOKEN` env var.** A stale/corrupt `credentials.json` therefore **shadows** the env token. (This is *unlike* `-p` mode, where the env token wins — which is why `server.mjs`'s own `getOAuthCredentials()` is unaffected and why PR-C's premise looked sufficient.) So passing the token (PR-C, `buildTuiCmd`) is **necessary but insufficient**: the TUI `claude` must additionally run in a HOME that has **no `credentials.json`**, so the env token is the only credential and is authoritative.
|
||||
|
||||
This also fixes the original incident at the **root**, more completely than PR-C claimed: with no `credentials.json` in the home, claude never runs the token-refresh path at all, so the single-use refresh token can never be rotated — and therefore never corrupted — by the spawn+`kill-session` cycle. The 25-zombie / empty-refresh-token failure mode becomes structurally impossible, not merely avoided.
|
||||
|
||||
### Decision
|
||||
|
||||
When `CLAUDE_CODE_OAUTH_TOKEN` is set, the TUI `claude` runs in a **credential-free scratch home** by default:
|
||||
|
||||
- `resolveTuiHome({ realHome, configuredHome, envTokenSet })` (exported from `lib/tui/session.mjs`, pure) decides the home:
|
||||
- **`OCP_TUI_HOME` set** → that path (explicit override, back-compat — an operator who configured it keeps exactly that home).
|
||||
- **else env token set** → `<realHome>/.ocp-tui/home` — a dedicated scratch home seeded with a minimal `.claude.json` (`hasCompletedOnboarding=true` + trust **only** the scratch cwd) and its own `projects/` dir, and **deliberately NO `.credentials.json`** (no symlink, no copy).
|
||||
- **else (no env token)** → the operator's real home — **byte-for-byte the pre-fix behaviour** for hosts that intentionally rely on `credentials.json`.
|
||||
- `prepareTuiHome(realHome, tuiHome, cwd, { envTokenMode })` gates the credential handling: in `envTokenMode` it creates the scratch `projects/` dir and seeds the minimal trusted `.claude.json` but **never** creates the credentials symlink. `runTuiTurn` sets `envTokenMode = !!CLAUDE_CODE_OAUTH_TOKEN && ehome !== realHome`.
|
||||
- `readTuiTranscript` reads from the **same** home claude runs under (`ehome`), so transcripts land under `<scratch home>/.claude/projects/` and `findTranscriptPath` globs them there — the home is threaded through consistently. (We chose scratch-`HOME` over `CLAUDE_CONFIG_DIR`: the binary supports `CLAUDE_CONFIG_DIR`, but it relocates the transcript root to `<CONFIG_DIR>/projects/` rather than `<HOME>/.claude/projects/`, which would fork the transcript-resolution rule across modes for no benefit. The scratch-HOME lever reuses the existing, tested `prepareTuiHome`/`ehome` plumbing.)
|
||||
|
||||
### This RESOLVES — not reintroduces — the scratch-home caveat
|
||||
|
||||
The original "Home strategy" section and PR-C's `prepareTuiHome` comment warned that scratch-home is unsafe because *claude rewrites a **symlinked** `.credentials.json` on token refresh → forks/corrupts the OAuth credentials*. **That caveat does not apply to env-token mode**: there is no `credentials.json` in the home to fork, and claude never refreshes (it uses the long-lived env token), so there is no rotation and no corruption. The fork risk was inherent to the *symlink* approach; removing the credentials file entirely removes the risk. The legacy symlink mode is retained **only** for an operator who explicitly sets `OCP_TUI_HOME` without an env token, and its caveat is preserved for exactly that path.
|
||||
|
||||
### ALIGNMENT authorization (Class B)
|
||||
|
||||
**Class B** (OCP-owned TUI spawn). `cli.js` has no analogue for the TUI pane's auth/home strategy; authorized by **this ADR (0007)** per `ALIGNMENT.md`'s Class B citation requirement. `server.mjs` is touched only to compute `TUI_HOME` via `resolveTuiHome()` (TUI wiring) and to surface the auth mode in the boot log — no Class A wire surface, no endpoint shape, no `alignment.yml` blacklist token, and no `models.json` entry is touched.
|
||||
|
||||
---
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
# 2026-06-15 Canary Runbook
|
||||
|
||||
**Purpose:** Confirm that a TUI-mode turn is billed to the **Pro/Max subscription pool** (not the Agent SDK credit pool) after Anthropic's 2026-06-15 billing split activates.
|
||||
|
||||
The billing classifier reading `cli` is **necessary but NOT sufficient** proof. (Note the naming: the value is stored in the JSONL transcript under the field name `entrypoint`, and sent to Anthropic on the wire as the `cc_entrypoint` header — they carry the same value after claude's startup classification. The commands below grep the transcript, so they match `entrypoint`.) A `cli` label tells you OCP sent the right classification; it does not tell you Anthropic billed the right pool. The only authoritative test is to observe whether the **Agent SDK credit balance** moves or not before and after the canary turn.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `CLAUDE_TUI_MODE=true` already set and OCP restarted (see [TUI-mode setup in README](../../README.md#enabling-tui-mode-opt-in))
|
||||
- `tmux` installed on the host
|
||||
- No other OCP traffic during the canary (quiesce — see below)
|
||||
- Access to your Anthropic account billing page (manual step — see below)
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Quiesce the host
|
||||
|
||||
Stop any IDE or client that is actively sending requests through this OCP instance.
|
||||
|
||||
Confirm the proxy is idle:
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:3456/health | python3 -m json.tool | grep activeRequests
|
||||
# Expected: "activeRequests": 0
|
||||
```
|
||||
|
||||
Wait until `activeRequests` is `0` before proceeding. If you cannot quiesce (e.g. family members are actively using it), run the canary on a separate OCP instance or during a quiet window.
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Read the Agent SDK credit balance BEFORE the canary
|
||||
|
||||
> **Manual step — no programmatic API available.**
|
||||
>
|
||||
> OCP's `/usage` endpoint reads `anthropic-ratelimit-unified-*` response headers from the Pro/Max plan quota (5-hour and 7-day subscription windows). These headers report **subscription usage**, not the Agent SDK credit pool balance. There is no known programmatic API to query the Agent SDK credit pool balance from outside the Anthropic web app.
|
||||
|
||||
To read the balance:
|
||||
|
||||
1. Open [https://claude.ai/settings/billing](https://claude.ai/settings/billing) (or your Anthropic Console billing page) in a browser.
|
||||
2. Find the **Agent SDK Credits** section (sometimes labeled "API Credits" or "Agent SDK usage").
|
||||
3. Note the current balance (e.g. `$18.43 remaining of $20.00`).
|
||||
|
||||
Write the value down — you will compare it after the canary turn.
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — Send the canary turn
|
||||
|
||||
With TUI-mode on and the host quiesced, send exactly one small request:
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://127.0.0.1:3456/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "claude-haiku-4-5-20251001",
|
||||
"messages": [{"role": "user", "content": "Reply with the single word: pong"}],
|
||||
"max_tokens": 10
|
||||
}' | python3 -m json.tool
|
||||
```
|
||||
|
||||
Use Haiku (the cheapest model) to minimize any hypothetical impact if the canary turns red.
|
||||
|
||||
Wait for the response to arrive completely (TUI-mode buffers the full response before returning — you will see a delay of several seconds, then the full reply).
|
||||
|
||||
---
|
||||
|
||||
## Step 4 — Confirm the transcript shows `entrypoint:"cli"`
|
||||
|
||||
After the canary turn completes, inspect the most recent JSONL transcript for the billing-classifier label:
|
||||
|
||||
```bash
|
||||
# The canary was run quiesced (Step 1), so the most recent JSONL across ALL project
|
||||
# dirs IS the canary turn. We glob every projects subdir instead of recomputing
|
||||
# claude's cwd-encoding rule (it maps every "/" AND "." to "-", e.g. ~/.ocp-tui/work
|
||||
# => projects/-home-<user>--ocp-tui-work/; see lib/tui/transcript.mjs encodeCwd) —
|
||||
# a glob is robust even if that encoding changes in a future claude build.
|
||||
LATEST=$(ls -t "$HOME"/.claude/projects/*/*.jsonl 2>/dev/null | head -1)
|
||||
echo "Transcript: $LATEST"
|
||||
grep -o '"entrypoint":"[^"]*"' "$LATEST" | tail -1
|
||||
# Expected: "entrypoint":"cli"
|
||||
```
|
||||
|
||||
If the output shows `"entrypoint":"cli"`, the billing-classifier label is correct. If it shows `"entrypoint":"sdk-cli"`, the spawn did not get a real PTY — stop immediately and do not re-enable TUI-mode without investigation. Check `tmux new-session` manually and review ADR 0007 § spawn/PTY gate. (If the grep returns nothing, the transcript may not yet be flushed — re-run after a second, or confirm the turn completed.)
|
||||
|
||||
**Reminder: an `entrypoint:cli` label (the `cc_entrypoint=cli` wire header) is necessary but not sufficient.** It tells you OCP sent the right label to Anthropic. You must still check the credit balance in Step 5.
|
||||
|
||||
---
|
||||
|
||||
## Step 5 — Re-read the Agent SDK credit balance AFTER the canary
|
||||
|
||||
Return to [https://claude.ai/settings/billing](https://claude.ai/settings/billing) and reload the page. Note the current balance again.
|
||||
|
||||
---
|
||||
|
||||
## Step 6 — Green/Red decision
|
||||
|
||||
### Green (balance unchanged)
|
||||
|
||||
The Agent SDK credit balance did not decrease. The turn billed against the Pro/Max subscription pool as expected. TUI-mode is working correctly.
|
||||
|
||||
**Actions:**
|
||||
- Keep `CLAUDE_TUI_MODE=true` on this host.
|
||||
- Monitor the balance periodically for the first week to catch any delayed attribution.
|
||||
- Resume normal traffic.
|
||||
|
||||
### Red (Agent SDK credit balance decreased)
|
||||
|
||||
The Agent SDK credit balance decreased. The subscription pool is not being used for TUI-mode turns on this host, despite `cc_entrypoint=cli` being set. This may indicate a backend routing change on Anthropic's side, a TTY detection failure, or a policy change.
|
||||
|
||||
**Actions — immediate:**
|
||||
1. Unset `CLAUDE_TUI_MODE` (or set to any value other than `"true"`) in the service unit:
|
||||
- systemd: edit `/etc/ocp/ocp.env` (or the unit's `Environment=` line), then `sudo systemctl daemon-reload && sudo systemctl restart ocp.service`
|
||||
- launchd: edit the plist `EnvironmentVariables` section, then `launchctl bootout gui/$(id -u)/dev.ocp.proxy && launchctl bootstrap gui/$(id -u) <plist-path>`
|
||||
2. Restart OCP and confirm the `/health` response no longer shows TUI-mode active.
|
||||
3. If you share this OCP with family or other Max users: freeze their access temporarily until you understand the billing impact.
|
||||
4. Consider pivoting to OLP multi-provider (see [OLP](https://github.com/dtzp555-max/olp)) which can spread load across other providers to avoid the Agent SDK credit drain.
|
||||
|
||||
Per ALIGNMENT.md Rule 2 / ADR 0007 § Kill-switch: "Per the constitution, the response is to drop the Anthropic provider rather than escalate spoofing."
|
||||
|
||||
---
|
||||
|
||||
## Ongoing monitoring — self-classification mini-canary
|
||||
|
||||
To detect future drift (e.g. a claude CLI upgrade that changes TTY-detection behavior), you can run a periodic one-liner that sends a tiny TUI turn with `OCP_TUI_ENTRYPOINT=auto` (so claude self-classifies rather than having OCP pin the value) and alerts if the transcript self-classification is not `cli`:
|
||||
|
||||
```bash
|
||||
# Run with OCP temporarily configured OCP_TUI_ENTRYPOINT=auto
|
||||
# Then check the most recent transcript:
|
||||
# Glob the most recent transcript across all project dirs (robust to claude's
|
||||
# cwd-encoding rule; run this right after the auto-mode mini-canary turn).
|
||||
LATEST=$(ls -t "$HOME"/.claude/projects/*/*.jsonl 2>/dev/null | head -1)
|
||||
RESULT=$(grep -o '"entrypoint":"[^"]*"' "$LATEST" | tail -1)
|
||||
echo "Self-classified entrypoint: $RESULT"
|
||||
if echo "$RESULT" | grep -q '"entrypoint":"cli"'; then
|
||||
echo "OK — subscription pool"
|
||||
else
|
||||
echo "ALERT — not cli; check TTY and billing"
|
||||
fi
|
||||
```
|
||||
|
||||
Run this after any major `claude` CLI upgrade. The `auto` mode lets the CLI's own `t$A` startup function determine the value from the actual TTY state (see ADR 0007 § Billing-classifier labeling).
|
||||
|
||||
---
|
||||
|
||||
## Related
|
||||
|
||||
- [Flip/rollback runbook](./tui-flip-rollback.md) — how to set and unset `CLAUDE_TUI_MODE` on systemd and launchd hosts
|
||||
- [ADR 0007](../adr/0007-tui-interactive-mode.md) — TUI-mode architecture and governing rules
|
||||
- README § [Subscription-pool (TUI) mode](../../README.md#subscription-pool-tui-mode)
|
||||
@@ -0,0 +1,180 @@
|
||||
# TUI-Mode Flip and Rollback Runbook
|
||||
|
||||
**Purpose:** Step-by-step instructions for enabling (`CLAUDE_TUI_MODE=true`) or disabling TUI-mode on real OCP deployments managed by **systemd** (Linux) or **launchd** (macOS).
|
||||
|
||||
Run the [615-canary](./615-canary.md) runbook after any flip to confirm billing pool routing is correct.
|
||||
|
||||
---
|
||||
|
||||
## Critical pitfalls — read first
|
||||
|
||||
### systemd: `daemon-reload` is required after editing the unit
|
||||
|
||||
Editing the unit file (or EnvironmentFile) and then doing `systemctl restart ocp.service` **without** `daemon-reload` will restart the process with the **old** environment from the cached unit. Always run `daemon-reload` after editing any unit file.
|
||||
|
||||
### launchd: `launchctl kickstart -k` does NOT reload plist env
|
||||
|
||||
`launchctl kickstart -k gui/$(id -u)/dev.ocp.proxy` kills the running process and re-launches it, but it **re-uses the launchd-cached environment** — not the current plist file. If you edited the plist's `EnvironmentVariables` section, you must do a full `bootout` + `bootstrap` cycle for the change to take effect. `kickstart` is not sufficient.
|
||||
|
||||
---
|
||||
|
||||
## Flip — enable TUI-mode
|
||||
|
||||
### systemd (Linux, e.g. Raspberry Pi, VPS)
|
||||
|
||||
**Option A — EnvironmentFile (recommended for clean separation)**
|
||||
|
||||
If your unit uses `EnvironmentFile=/etc/ocp/ocp.env` (or similar):
|
||||
|
||||
```bash
|
||||
# 1. Edit the environment file
|
||||
sudo nano /etc/ocp/ocp.env
|
||||
# Add or update:
|
||||
# CLAUDE_TUI_MODE=true
|
||||
#
|
||||
# If OCP binds to 0.0.0.0 AND you trust the network:
|
||||
# OCP_TUI_ALLOW_LAN=1
|
||||
# (WARNING: TUI-mode is single-user only — only enable OCP_TUI_ALLOW_LAN=1
|
||||
# if you fully trust every caller that can reach the OCP port on your network)
|
||||
|
||||
# 2. Reload the unit definition and restart
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl restart ocp.service
|
||||
|
||||
# 3. Verify
|
||||
curl -s http://127.0.0.1:3456/health | python3 -m json.tool | grep -E "tui|version"
|
||||
# Expected: "tuiMode": true (or similar TUI indicator in the health response)
|
||||
```
|
||||
|
||||
**Option B — inline Environment= in the unit file**
|
||||
|
||||
```bash
|
||||
# 1. Edit the unit file
|
||||
sudo systemctl edit --full ocp.service
|
||||
# Add or update in the [Service] section:
|
||||
# Environment=CLAUDE_TUI_MODE=true
|
||||
|
||||
# 2. Reload and restart
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl restart ocp.service
|
||||
|
||||
# 3. Verify
|
||||
systemctl show ocp.service --property=Environment
|
||||
# Expected: Environment=CLAUDE_TUI_MODE=true ...
|
||||
```
|
||||
|
||||
### launchd (macOS)
|
||||
|
||||
Locate the OCP plist. The standard label is `dev.ocp.proxy`:
|
||||
|
||||
```bash
|
||||
# Find the plist path
|
||||
ls ~/Library/LaunchAgents/dev.ocp.proxy.plist
|
||||
```
|
||||
|
||||
**Edit the plist:**
|
||||
|
||||
```bash
|
||||
# 1. Stop the service first (bootout)
|
||||
launchctl bootout gui/$(id -u)/dev.ocp.proxy
|
||||
|
||||
# 2. Edit the plist — add CLAUDE_TUI_MODE to EnvironmentVariables
|
||||
# Use your editor of choice:
|
||||
nano ~/Library/LaunchAgents/dev.ocp.proxy.plist
|
||||
```
|
||||
|
||||
Inside the plist, in the `<key>EnvironmentVariables</key>` `<dict>` block, add:
|
||||
|
||||
```xml
|
||||
<key>CLAUDE_TUI_MODE</key>
|
||||
<string>true</string>
|
||||
```
|
||||
|
||||
If `OCP_TUI_ALLOW_LAN=1` is also needed (only if OCP binds to `0.0.0.0` and you trust the network):
|
||||
|
||||
```xml
|
||||
<key>OCP_TUI_ALLOW_LAN</key>
|
||||
<string>1</string>
|
||||
```
|
||||
|
||||
```bash
|
||||
# 3. Bootstrap (reload from disk + start)
|
||||
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/dev.ocp.proxy.plist
|
||||
|
||||
# 4. Verify
|
||||
curl -s http://127.0.0.1:3456/health | python3 -m json.tool | grep -E "tui|version"
|
||||
```
|
||||
|
||||
**Confirm env was actually loaded** (not just set in your shell):
|
||||
|
||||
```bash
|
||||
ps aux | grep server.mjs | grep -v grep
|
||||
# Get the PID, then:
|
||||
# macOS: ps -E -p <PID> | tr ' ' '\n' | grep CLAUDE_TUI_MODE
|
||||
# Expected: CLAUDE_TUI_MODE=true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rollback — disable TUI-mode
|
||||
|
||||
Rollback is the same procedure as flip, but you **remove** `CLAUDE_TUI_MODE` or set it to any value other than `"true"` (e.g. `false`, or simply omit it).
|
||||
|
||||
After rollback, OCP returns to the default `callClaude` / `callClaudeStreaming` stream-json path — byte-for-byte identical to the pre-TUI code path. No other change is required.
|
||||
|
||||
### systemd rollback
|
||||
|
||||
```bash
|
||||
# Option A — EnvironmentFile
|
||||
sudo nano /etc/ocp/ocp.env
|
||||
# Remove or comment out:
|
||||
# CLAUDE_TUI_MODE=true
|
||||
# OCP_TUI_ALLOW_LAN=1 (if set)
|
||||
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl restart ocp.service
|
||||
|
||||
# Verify
|
||||
curl -s http://127.0.0.1:3456/health | python3 -m json.tool | grep tui
|
||||
# Expected: "tuiMode": false (or the field absent)
|
||||
```
|
||||
|
||||
### launchd rollback
|
||||
|
||||
```bash
|
||||
# 1. Stop
|
||||
launchctl bootout gui/$(id -u)/dev.ocp.proxy
|
||||
|
||||
# 2. Edit plist — remove the CLAUDE_TUI_MODE and OCP_TUI_ALLOW_LAN entries from EnvironmentVariables
|
||||
|
||||
# 3. Bootstrap
|
||||
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/dev.ocp.proxy.plist
|
||||
|
||||
# 4. Verify
|
||||
curl -s http://127.0.0.1:3456/health | python3 -m json.tool | grep tui
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Billing impact of staying on the default (non-TUI) path after 2026-06-15
|
||||
|
||||
If you do NOT flip to TUI-mode and keep `CLAUDE_TUI_MODE` unset (the default), OCP continues using `claude -p --output-format stream-json`, which sets `cc_entrypoint=sdk-cli`. After 2026-06-15, every OCP request on the default path will draw from the Agent SDK credit pool (approximately $20/month on a Pro plan, or $100/month on a Max plan) rather than the Pro/Max subscription. The subscription pool usage (5-hour and 7-day windows) will be unaffected, but the Agent SDK credit balance will drain with each request.
|
||||
|
||||
If you want to continue using OCP without TUI-mode after 2026-06-15, budget for the Agent SDK credit cost accordingly — or switch to [OLP](https://github.com/dtzp555-max/olp) for multi-provider fallback.
|
||||
|
||||
---
|
||||
|
||||
## Verify after any flip
|
||||
|
||||
1. Check `/health` shows the expected `tuiMode` state.
|
||||
2. Run the [615-canary](./615-canary.md) to confirm billing pool routing.
|
||||
3. If TUI-mode is ON: check `ocp logs 10` for any TUI spawn errors (`tui_spawn_failed`, tmux errors).
|
||||
|
||||
---
|
||||
|
||||
## Related
|
||||
|
||||
- [615-canary runbook](./615-canary.md) — how to verify billing pool routing after a flip
|
||||
- [ADR 0007](../adr/0007-tui-interactive-mode.md) — TUI-mode architecture; Kill-switch section
|
||||
- README § [Subscription-pool (TUI) mode](../../README.md#subscription-pool-tui-mode)
|
||||
- README § [Environment Variables](../../README.md#environment-variables) — `CLAUDE_TUI_MODE`, `OCP_TUI_ALLOW_LAN=1`
|
||||
@@ -0,0 +1,2 @@
|
||||
{"type":"user","entrypoint":"cli","cwd":"/tmp/tui-test","sessionId":"bbbb2222-3333-4444-5555-666677778888","version":"2.1.104","message":{"role":"user","content":[{"type":"text","text":"What is 2 + 2?"}]}}
|
||||
{"type":"assistant","entrypoint":"cli","cwd":"/tmp/tui-test","sessionId":"bbbb2222-3333-4444-5555-666677778888","version":"2.1.104","message":{"role":"assistant","model":"claude-haiku-4-5-20251001","stop_reason":"end_turn","content":[{"type":"text","text":"Failed to authenticate. API Error: 401 Invalid authentication credentials"}]}}
|
||||
@@ -0,0 +1,2 @@
|
||||
{"type":"user","entrypoint":"cli","cwd":"/tmp/tui-test","sessionId":"aaaa1111-2222-3333-4444-555566667777","version":"2.1.104","message":{"role":"user","content":[{"type":"text","text":"What is 2 + 2?"}]}}
|
||||
{"type":"assistant","entrypoint":"cli","cwd":"/tmp/tui-test","sessionId":"aaaa1111-2222-3333-4444-555566667777","version":"2.1.104","message":{"role":"assistant","model":"claude-haiku-4-5-20251001","stop_reason":"end_turn","content":[{"type":"text","text":"Please run /login · API Error: 401 Invalid authentication credentials"}]}}
|
||||
@@ -0,0 +1,2 @@
|
||||
{"type":"user","entrypoint":"cli","cwd":"/tmp/tui-test","sessionId":"bbbb1111-2222-3333-4444-555566667777","version":"2.1.104","message":{"role":"user","content":[{"type":"text","text":"Say PONG and nothing else."}]}}
|
||||
{"type":"assistant","entrypoint":"cli","cwd":"/tmp/tui-test","sessionId":"bbbb1111-2222-3333-4444-555566667777","version":"2.1.104","message":{"role":"assistant","model":"claude-haiku-4-5-20251001","stop_reason":"end_turn","content":[{"type":"text","text":"PONG"}]}}
|
||||
@@ -0,0 +1,102 @@
|
||||
// TUI-path concurrency limiter (audit finding C-4).
|
||||
//
|
||||
// WHY THIS EXISTS, SEPARATE FROM server.mjs's MAX_CONCURRENT:
|
||||
// The global MAX_CONCURRENT gate lives in spawnClaudeProcess() (the -p / stream-json
|
||||
// path). callClaudeTui() NEVER calls spawnClaudeProcess — it calls runTuiTurn(), which
|
||||
// boots a full interactive `claude` inside a fresh tmux session. So nothing bounded the
|
||||
// TUI path: N concurrent TUI requests spawned N simultaneous cold-boot tmux+claude
|
||||
// processes. On a small host (a Pi 4 serving a family) a burst of ~5 is an OOM risk, and
|
||||
// it also multiplies subscription rate-limit pressure. This is an INDEPENDENT limiter for
|
||||
// the TUI path that mirrors MAX_CONCURRENT's intent without coupling to it (the two pools
|
||||
// are different shapes: a stream-json spawn is cheap and fast; a TUI turn is a heavy
|
||||
// cold-boot + up to 120s wallclock).
|
||||
//
|
||||
// QUEUE vs REJECT: we QUEUE (await a slot), mirroring the spirit of MAX_CONCURRENT's
|
||||
// intent not to drop requests, rather than rejecting immediately. To avoid unbounded
|
||||
// memory growth from a runaway client, the wait queue itself is bounded by maxQueue
|
||||
// (default: a generous multiple of the concurrency limit). When the queue is full, run()
|
||||
// rejects with a tui_queue_full error (the caller surfaces it as a 503) — a deterministic
|
||||
// backpressure signal rather than silent OOM.
|
||||
//
|
||||
// Pure + importable so test-features.mjs can assert the bound directly (no server boot).
|
||||
|
||||
export class TuiSemaphore {
|
||||
// limit: max concurrent slots. maxQueue: max waiters before run() rejects with backpressure.
|
||||
constructor(limit, { maxQueue } = {}) {
|
||||
this.limit = Math.max(1, parseInt(limit, 10) || 1);
|
||||
// Default queue cap: 32× the limit. Large enough that real family-burst traffic never
|
||||
// hits it, small enough that a pathological flood can't grow the queue without bound.
|
||||
this.maxQueue = Number.isFinite(maxQueue) ? maxQueue : this.limit * 32;
|
||||
this._inflight = 0;
|
||||
this._waiters = []; // FIFO queue of resolve callbacks waiting for a slot
|
||||
}
|
||||
|
||||
get inflight() { return this._inflight; }
|
||||
get queued() { return this._waiters.length; }
|
||||
|
||||
// Acquire a slot. Resolves once a slot is free (immediately if under the limit, otherwise
|
||||
// when an in-flight task releases). Rejects synchronously-ish if the wait queue is full.
|
||||
acquire() {
|
||||
if (this._inflight < this.limit) {
|
||||
this._inflight++;
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (this._waiters.length >= this.maxQueue) {
|
||||
return Promise.reject(new Error(
|
||||
`tui_queue_full: TUI concurrency limit (${this.limit}) reached and wait queue ` +
|
||||
`(${this.maxQueue}) is full`));
|
||||
}
|
||||
return new Promise((resolve) => { this._waiters.push(resolve); });
|
||||
}
|
||||
|
||||
// Release a slot. If a waiter is queued, hand the slot directly to it (inflight stays
|
||||
// constant across the handoff); otherwise decrement.
|
||||
release() {
|
||||
const next = this._waiters.shift();
|
||||
if (next) {
|
||||
next(); // the woken waiter already "owns" the slot — inflight unchanged
|
||||
} else if (this._inflight > 0) {
|
||||
this._inflight--;
|
||||
}
|
||||
}
|
||||
|
||||
// Run fn() under one slot. Releases in a finally so a throw (PR-A's honesty gates,
|
||||
// wallclock truncation, paste-not-landed, tmux spawn failure) NEVER leaks a slot.
|
||||
async run(fn) {
|
||||
await this.acquire();
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
this.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── TUI drift observability (audit C-5) — pure helpers, importable for testing ──
|
||||
|
||||
// Record an observed cc_entrypoint into the (mutable) tuiStats counter. Sets lastEntrypoint
|
||||
// unconditionally and increments entrypointMismatches when the spawn was supposed to be
|
||||
// subscription-pool ("cli") but the transcript reported something else (a silent drift to
|
||||
// the metered Agent SDK pool — the audit's top risk after the 6/15 billing flip).
|
||||
// Returns true iff this observation was a mismatch (so the caller can also emit a log).
|
||||
export function recordTuiEntrypoint(tuiStats, observed, expectedMode = "cli") {
|
||||
tuiStats.lastEntrypoint = observed ?? null;
|
||||
const mismatch = expectedMode === "cli" && observed !== "cli";
|
||||
if (mismatch) tuiStats.entrypointMismatches++;
|
||||
return mismatch;
|
||||
}
|
||||
|
||||
// Build the additive /health `tui` block (ADR 0007 PR-B amendment). Pure: given the
|
||||
// config + live counters, returns the exact object embedded in /health. New fields only —
|
||||
// behaviour-preserving for existing /health consumers (grandfathered B.2 under ADR 0006).
|
||||
export function buildTuiHealthBlock({ enabled, entrypointMode, maxConcurrent }, tuiStats, semaphore) {
|
||||
return {
|
||||
enabled,
|
||||
entrypointMode, // cli | auto | off
|
||||
lastEntrypoint: tuiStats.lastEntrypoint, // last observed cc_entrypoint, or null
|
||||
entrypointMismatches: tuiStats.entrypointMismatches,
|
||||
inflight: semaphore.inflight, // current concurrent TUI turns
|
||||
queued: semaphore.queued, // turns waiting for a slot
|
||||
maxConcurrent,
|
||||
};
|
||||
}
|
||||
+278
-52
@@ -23,27 +23,100 @@ const defaultTmux = (args, opts = {}) =>
|
||||
|
||||
// Kill ONLY our own stale sessions. Scoped to SESSION_PREFIX so a co-hosted
|
||||
// OLP test instance's `olp-tui-*` sessions are never touched.
|
||||
//
|
||||
// Defunct-reaping (PI231 incident): the pane's `claude` process is a child of the
|
||||
// long-lived tmux SERVER daemon, NOT of the OCP node process — `tmux new-session -d`
|
||||
// returns the instant the server forks the pane, so node never becomes its parent and
|
||||
// therefore can NEVER waitpid()/reap it (a SIGKILL still needs the *parent* to reap, and
|
||||
// here that parent is the tmux server). `kill-session` destroys the session but the server
|
||||
// can leave the pane's `claude` (and any grandchildren claude spawned) as `<defunct>`
|
||||
// zombies that only the server can reap. Over many per-request spawn+teardown cycles these
|
||||
// accumulate (live evidence on PI231: 25 defunct `<claude>` over 30 days; `tmux kill-server`
|
||||
// dropped it 25→3). The only node-reachable action that ACTUALLY reaps them — rather than
|
||||
// merely re-signalling — is to stop the tmux server: when the server exits, the kernel
|
||||
// reparents its surviving children to init (PID 1), which reaps them immediately.
|
||||
//
|
||||
// So after killing our own sessions, if the server has NO sessions left of ANY prefix
|
||||
// (i.e. nothing we could disrupt — no co-hosted `olp-tui-*` or other instance), we
|
||||
// `kill-server` to flush the defunct backlog. If ANY non-ocp session remains we leave the
|
||||
// server running (coexistence rule, ADR 0007) and let the next boot/periodic sweep retry
|
||||
// once the server is otherwise idle.
|
||||
export function reapStaleTuiSessions({ tmux = defaultTmux } = {}) {
|
||||
const r = tmux(["list-sessions", "-F", "#{session_name}"]);
|
||||
if (!r || r.status !== 0) return 0; // no tmux server / no sessions
|
||||
const names = String(r.stdout || "").split("\n").map((s) => s.trim()).filter(Boolean);
|
||||
let killed = 0;
|
||||
for (const name of String(r.stdout || "").split("\n").map((s) => s.trim()).filter(Boolean)) {
|
||||
let othersRemain = false;
|
||||
for (const name of names) {
|
||||
if (name.startsWith(SESSION_PREFIX)) {
|
||||
tmux(["kill-session", "-t", name]);
|
||||
killed++;
|
||||
} else {
|
||||
othersRemain = true; // a session we do NOT own (e.g. olp-tui-*) — never kill-server
|
||||
}
|
||||
}
|
||||
// Reap defunct `claude` zombies: safe ONLY when the server is now ours-only/empty.
|
||||
// kill-server is what actually reaps (server exit reparents survivors to init); a
|
||||
// per-session kill cannot, since node is not the zombies' parent.
|
||||
if (!othersRemain) {
|
||||
tmux(["kill-server"]);
|
||||
}
|
||||
return killed;
|
||||
}
|
||||
|
||||
// ── Task 5: runTuiTurn ───────────────────────────────────────────────────
|
||||
|
||||
// Boot + paste-settle timing. Conservative defaults validated on PI231; env-tunable.
|
||||
const BOOT_MS = parseInt(process.env.OCP_TUI_BOOT_MS || "4000", 10);
|
||||
const PASTE_SETTLE_MS = parseInt(process.env.OCP_TUI_PASTE_MS || "1800", 10);
|
||||
const BOOT_MS = parseInt(process.env.OCP_TUI_BOOT_MS || "4000", 10); // max wait for input-ready
|
||||
const READY_POLL_MS = parseInt(process.env.OCP_TUI_READY_POLL_MS || "400", 10); // readiness / paste-verify poll interval
|
||||
const PASTE_VERIFY_MS = parseInt(process.env.OCP_TUI_PASTE_VERIFY_MS || "5000", 10); // max wait for pasted prompt to render
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// Capture the visible tmux pane as plain text (for readiness / paste verification).
|
||||
function tuiCapturePane(tmux, tmuxName) {
|
||||
const r = tmux(["capture-pane", "-p", "-t", tmuxName]);
|
||||
return (r && typeof r.stdout === "string") ? r.stdout : "";
|
||||
}
|
||||
|
||||
// True once claude's input bar is rendered and ready for keystrokes.
|
||||
function tuiInputReady(pane) {
|
||||
return /\? for shortcuts/.test(pane);
|
||||
}
|
||||
|
||||
// True once the pasted prompt has POSITIVELY landed in the input box. We only trust
|
||||
// affirmative signals — NOT "the placeholder is gone", which is unreliable (claude's
|
||||
// placeholder uses a curly quote `"`, randomized example text, and renders the big paste
|
||||
// a beat after paste-buffer returns; a "placeholder-gone" heuristic false-positived on the
|
||||
// still-empty box and made us submit Enter into nothing → issue #130 hang). Landed iff:
|
||||
// (a) the bracketed-paste indicator "[Pasted text" is present (large/multi-line paste), OR
|
||||
// (b) the prompt's own leading text appears in the pane (short/literal paste).
|
||||
function tuiPromptLanded(pane, prompt) {
|
||||
const flatPane = pane.replace(/\s+/g, " ");
|
||||
if (flatPane.includes("[Pasted text")) return true;
|
||||
const firstLine = String(prompt).split("\n").map(s => s.trim()).find(Boolean) || "";
|
||||
const needle = firstLine.replace(/\s+/g, " ").slice(0, 24);
|
||||
// C-4/#133: threshold lowered 3 → 2. A prompt whose first non-blank line is 1–2
|
||||
// chars ("hi", "ok") previously NEVER matched (needle.length >= 3) and never
|
||||
// surfaced "[Pasted text", so EVERY short prompt 5s-failed with tui_paste_not_landed
|
||||
// (live-reproduced: "hi"). The input box starts EMPTY (the curly-quote placeholder
|
||||
// is excluded by the affirmative-signal design above), so a >=2-char needle present
|
||||
// in the pane is the pasted prompt, not placeholder noise — false-positive risk is
|
||||
// low. We keep >=2 rather than >=1 because a single visible char is more likely to
|
||||
// collide with incidental glyphs in claude's chrome (borders, the "❯" prompt mark);
|
||||
// 2 chars is the floor that lands real prompts while staying conservative.
|
||||
return needle.length >= 2 && flatPane.includes(needle);
|
||||
}
|
||||
|
||||
async function pollUntil(fn, { timeoutMs, intervalMs }) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
try { if (fn()) return true; } catch { /* ignore, keep polling */ }
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Single-quote escaper for sh -c arguments.
|
||||
function shq(s) {
|
||||
return `'${String(s).replace(/'/g, "'\\''")}'`;
|
||||
@@ -83,39 +156,85 @@ export function ensureTuiCwdTrusted(home, cwd) {
|
||||
} 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.
|
||||
// Resolve the HOME the TUI `claude` runs under. Three intents, decided by the env
|
||||
// token + an explicit OCP_TUI_HOME override:
|
||||
//
|
||||
// ⚠️ 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) {
|
||||
// - ENV-TOKEN MODE (default when CLAUDE_CODE_OAUTH_TOKEN is set AND OCP_TUI_HOME is
|
||||
// unset): a CREDENTIAL-FREE scratch home at `<realHome>/.ocp-tui/home`. There is
|
||||
// deliberately NO .credentials.json (no symlink, no copy), so the only credential
|
||||
// claude can find is the long-lived env token (passed by buildTuiCmd). This is what
|
||||
// actually FORCES env-token auth — see the prepareTuiHome comment for why passing
|
||||
// the token alone is insufficient.
|
||||
// - EXPLICIT OVERRIDE: whatever OCP_TUI_HOME names (back-compat; an operator who set it
|
||||
// keeps exactly that home).
|
||||
// - REAL-HOME (default when the env token is unset): the operator's real home, shared
|
||||
// credentials.json — byte-for-byte the pre-fix behaviour for credentials.json hosts.
|
||||
//
|
||||
// Pure + deterministic so server.mjs and the tests share one decision. `configuredHome`
|
||||
// is the raw OCP_TUI_HOME value (undefined/empty => unset).
|
||||
export const DEFAULT_TUI_SCRATCH_HOME = (realHome) => `${realHome}/.ocp-tui/home`;
|
||||
export function resolveTuiHome({ realHome, configuredHome, envTokenSet }) {
|
||||
if (configuredHome) return configuredHome; // explicit override wins (back-compat)
|
||||
if (envTokenSet) return DEFAULT_TUI_SCRATCH_HOME(realHome); // credential-free scratch
|
||||
return realHome; // legacy real-home default
|
||||
}
|
||||
|
||||
// Prepare the HOME claude runs under. Three modes:
|
||||
// - real-home (tuiHome === realHome OR falsy): no isolation; just trust the cwd
|
||||
// in the real ~/.claude.json. The legacy default when no env token is set.
|
||||
// - ENV-TOKEN scratch-home (envTokenMode === true): a dedicated HOME with a seeded
|
||||
// .claude.json (onboarded + trusts only the scratch cwd) and its own projects/ dir,
|
||||
// and DELIBERATELY NO .credentials.json (no symlink, no copy). claude then has no
|
||||
// credentials file to read, so it authenticates via CLAUDE_CODE_OAUTH_TOKEN (passed
|
||||
// by buildTuiCmd) — which is authoritative precisely because nothing shadows it.
|
||||
// - legacy scratch-home (envTokenMode falsy, tuiHome !== realHome): the historical
|
||||
// mode that SYMLINKS the real .credentials.json. Retained only for an operator who
|
||||
// explicitly set OCP_TUI_HOME without an env token; see the caveat below.
|
||||
//
|
||||
// WHY ENV-TOKEN MODE IS THE FIX (proven live on PI231, claude 2.1.104):
|
||||
// env token passed + a broken ~/.claude/.credentials.json present → 401.
|
||||
// env token passed + credentials.json moved aside → real answer.
|
||||
// Interactive `claude` PREFERS .credentials.json over the env var (unlike `-p`, where the
|
||||
// env token wins), so a stale/corrupt credentials.json SHADOWS the env token. Passing the
|
||||
// token is necessary but insufficient; the TUI claude must run in a HOME with NO
|
||||
// credentials.json so the env token is the only credential. This ALSO ends the refresh-
|
||||
// corruption incident at the root: with no credentials file, claude never runs the token-
|
||||
// refresh path, so the single-use refresh token can never be rotated (and corrupted) by the
|
||||
// spawn+kill cycle. (This RESOLVES — not reintroduces — the ADR 0007 scratch-home concern:
|
||||
// the old caveat was about a SYMLINKED credentials.json being forked on refresh; here there
|
||||
// is no credentials file to fork and no refresh ever happens.)
|
||||
//
|
||||
// ⚠️ LEGACY SCRATCH-HOME CAVEAT (envTokenMode falsy, symlink path): claude rewrites
|
||||
// .credentials.json on token refresh, REPLACING the symlink with a regular-file copy → the
|
||||
// scratch home FORKS the OAuth credentials and a refresh can invalidate the real-home token.
|
||||
// That path is therefore safe only with a DEDICATED OAuth or for ephemeral use. The env-token
|
||||
// mode above avoids this entirely.
|
||||
//
|
||||
// Idempotent + best-effort: any failure degrades toward the dialog/cap, never corrupts.
|
||||
// Run BEFORE the session boots.
|
||||
export function prepareTuiHome(realHome, tuiHome, cwd, { envTokenMode = false } = {}) {
|
||||
if (!tuiHome || tuiHome === realHome) { ensureTuiCwdTrusted(realHome, cwd); return; }
|
||||
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 */ }
|
||||
if (!envTokenMode) {
|
||||
// Legacy mode ONLY: symlink the real credentials (never copy the token); refresh if
|
||||
// missing. Env-token mode deliberately skips this — no credentials file at all.
|
||||
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.
|
||||
// Seed .claude.json ONCE (if absent): onboarded + trust ONLY the scratch cwd.
|
||||
// In env-token mode start from a MINIMAL config (do NOT copy the real ~/.claude.json —
|
||||
// a credential-isolated home should not inherit the operator's account/config state);
|
||||
// in legacy mode carry the onboarded real config minus the user's project history.
|
||||
const seedPath = `${tuiHome}/.claude.json`;
|
||||
if (!existsSync(seedPath)) {
|
||||
let base = {};
|
||||
try { base = JSON.parse(readFileSync(`${realHome}/.claude.json`, "utf8")); } catch { /* fresh */ }
|
||||
if (!envTokenMode) {
|
||||
try { base = JSON.parse(readFileSync(`${realHome}/.claude.json`, "utf8")); } catch { /* fresh */ }
|
||||
}
|
||||
base.hasCompletedOnboarding = true;
|
||||
base.projects = { [cwd]: { hasTrustDialogAccepted: true, allowedTools: [] } };
|
||||
writeFileSync(seedPath, JSON.stringify(base, null, 2), { mode: 0o600 });
|
||||
@@ -149,22 +268,104 @@ export function resolveTuiEntrypointEnv(env, mode = "cli") {
|
||||
// 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) {
|
||||
export function buildTuiCmd(claudeBin, model, sessionId, ehome, entrypointMode) {
|
||||
// Deliver claude's env via an `env` prefix on the PANE COMMAND — tmux does NOT forward the
|
||||
// spawning process's environment to the pane, and `new-session -e` needs tmux ≥3.2 (the cloud
|
||||
// host runs 2.7), so this is the only portable, reliable mechanism (verified live 2026-06-01:
|
||||
// passing {env} to spawnSync left the pane with only HOME). DISABLE_AUTOUPDATER pins the version
|
||||
// (no "What's new" splash that delayed input-readiness); CLAUDE_CODE_ENTRYPOINT labels the
|
||||
// billing pool (set below per entrypointMode).
|
||||
//
|
||||
// CLAUDE_CODE_DISABLE_CLAUDE_MDS + DISABLE_AUTO_MEMORY: OCP is a PROXY, not a Claude Code
|
||||
// session. The proxied client (OpenClaw / an IDE) owns its own context and memory; the HOST's
|
||||
// CLAUDE.md and auto-memory must NEVER leak into the agent OCP runs on the user's behalf.
|
||||
// Without these, claude loads the host's project/user CLAUDE.md + memory into every proxied
|
||||
// turn — verified live 2026-06-02: a cwd CLAUDE.md ("end every reply with QUACKMARKER_42") was
|
||||
// obeyed by the proxied turn until these flags were set, after which it was not. Unconditional
|
||||
// by design (not gated): proxy purity is not an opt-in. Harmless on hosts with no CLAUDE.md
|
||||
// (the common case — they suppress nothing). Mirrors the -p path's CLAUDE_NO_CONTEXT vars.
|
||||
const sets = [
|
||||
`HOME=${shq(ehome)}`,
|
||||
"DISABLE_AUTOUPDATER=1",
|
||||
"CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL=1",
|
||||
"CLAUDE_CODE_DISABLE_CLAUDE_MDS=1",
|
||||
"CLAUDE_CODE_DISABLE_AUTO_MEMORY=1",
|
||||
];
|
||||
// CLAUDE_CODE_OAUTH_TOKEN: tmux does NOT forward the parent process's env to the pane (the
|
||||
// same reason the whole env is delivered as an `env` prefix above — verified live 2026-06-01),
|
||||
// so the token MUST be set explicitly here or the spawned `claude` never sees it. Without it,
|
||||
// the TUI claude falls back to authenticating via <HOME>/.claude/.credentials.json, whose
|
||||
// single-use refresh token gets corrupted by the per-request spawn + `kill-session` teardown
|
||||
// racing claude's token-rotation write (the PI231 incident: refresh token ended up an empty
|
||||
// string → permanent 401 "Please run /login", re-login re-corrupted on the next spawn). With
|
||||
// the long-lived OAuth token in env, claude authenticates via the token and never touches the
|
||||
// credentials.json refresh path — matching how the stable oracle / Mac-mini hosts already run.
|
||||
//
|
||||
// SECURITY: the token appears in the pane command (ps-visible). This is acceptable for the
|
||||
// single-user A-path — it mirrors the existing plaintext-token practice (server.mjs reads the
|
||||
// same CLAUDE_CODE_OAUTH_TOKEN env at getOAuthCredentials()), and the multi-user B-path is
|
||||
// already refused at boot (TUI + AUTH_MODE=multi is a hard FATAL). Read from process.env here,
|
||||
// consistent with how buildTuiCmd already reads OCP_TUI_FULL_TOOLS / CLAUDE_ALLOWED_TOOLS below.
|
||||
//
|
||||
// When the env is unset (e.g. a host that intentionally relies on credentials.json), no token
|
||||
// is added — behaviour is byte-for-byte unchanged from before this fix.
|
||||
if (process.env.CLAUDE_CODE_OAUTH_TOKEN) {
|
||||
sets.push(`CLAUDE_CODE_OAUTH_TOKEN=${shq(process.env.CLAUDE_CODE_OAUTH_TOKEN)}`);
|
||||
}
|
||||
const unset = ["CLAUDECODE", "ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN"];
|
||||
if (entrypointMode === "cli") sets.push("CLAUDE_CODE_ENTRYPOINT=cli");
|
||||
else if (entrypointMode === "auto") unset.push("CLAUDE_CODE_ENTRYPOINT"); // let claude self-classify via TTY
|
||||
const envPrefix = ["env", ...unset.map((u) => `-u ${u}`), ...sets].join(" ");
|
||||
|
||||
// Tool surface.
|
||||
// DEFAULT (safe): hard-disable MCP (--strict-mcp-config + --disallowedTools mcp__*);
|
||||
// built-in tools stay on, acceptable for single-user A-path.
|
||||
// OCP_TUI_FULL_TOOLS=1: grant the SAME tool surface as the -p A-path
|
||||
// (--allowedTools [+ --mcp-config] [+ --dangerously-skip-permissions]), so a
|
||||
// SINGLE-USER / trusted TUI deployment can run a tool-using agent (e.g. an OpenClaw
|
||||
// assistant that needs Bash/Read/Write/MCP) on the subscription pool. This mirrors
|
||||
// buildCliArgs() in server.mjs. Safe to gate ON only because TUI is hard-incompatible
|
||||
// with AUTH_MODE=multi (server.mjs refuses to boot), so it can never widen a guest's
|
||||
// surface. Env mirrors server.mjs's CLAUDE_ALLOWED_TOOLS / _SKIP_PERMISSIONS / _MCP_CONFIG.
|
||||
let toolArgs;
|
||||
if (process.env.OCP_TUI_FULL_TOOLS === "1") {
|
||||
toolArgs = [];
|
||||
if (process.env.CLAUDE_SKIP_PERMISSIONS === "true") {
|
||||
toolArgs.push("--dangerously-skip-permissions");
|
||||
} else {
|
||||
const allowed = (process.env.CLAUDE_ALLOWED_TOOLS ||
|
||||
"Bash,Read,Write,Edit,Glob,Grep,WebSearch,WebFetch,Agent")
|
||||
.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
// shq EACH token: buildTuiCmd returns a SHELL STRING (run by tmux via sh -c), unlike
|
||||
// buildCliArgs which returns an argv array to spawn(). claude accepts scoped specifiers
|
||||
// like "Bash(npm run test:*)" / "Read(~/**)" whose ( ) * ~ would break/inject the shell
|
||||
// command if pasted bare. (operator-self-injection only — guests can't reach TUI.)
|
||||
if (allowed.length) toolArgs.push("--allowedTools", ...allowed.map(shq));
|
||||
}
|
||||
if (process.env.CLAUDE_MCP_CONFIG) toolArgs.push("--mcp-config", shq(process.env.CLAUDE_MCP_CONFIG));
|
||||
} else {
|
||||
toolArgs = ["--strict-mcp-config", "--disallowedTools", shq("mcp__*")];
|
||||
}
|
||||
return [
|
||||
envPrefix,
|
||||
shq(claudeBin),
|
||||
"--model", shq(model),
|
||||
"--session-id", sessionId,
|
||||
"--strict-mcp-config",
|
||||
"--disallowedTools", shq("mcp__*"),
|
||||
...toolArgs,
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
// Full per-request TUI lifecycle:
|
||||
// 1. Pre-trust the scratch cwd (no trust dialog will appear).
|
||||
// 2. Write prompt to a 0600 temp file (no shell injection from prompt content).
|
||||
// 3. Boot an interactive `claude` in a fresh tmux session in the scratch cwd.
|
||||
// 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).
|
||||
// 3. Boot an interactive `claude` in a fresh tmux session in the scratch cwd; poll
|
||||
// capture-pane until the `? for shortcuts` input bar appears (readiness-poll
|
||||
// replaces the old blind boot sleep). BOOT_MS is the max wait, not a fixed delay.
|
||||
// 4. Paste the prompt via tmux load-buffer + paste-buffer -p (bracketed paste) —
|
||||
// reliable for large multi-line prompts where send-keys -l is not (issue #130).
|
||||
// Poll-verify the prompt landed in the input (placeholder gone / [Pasted text]);
|
||||
// fast-fail with tui_paste_not_landed if it never lands (prevents the 120s
|
||||
// wallclock "stuck typing" hang). Then submit with a SEPARATE Enter key event.
|
||||
// 5. Block on the native JSONL transcript (located by session-id) until terminal
|
||||
// marker or wall-clock cap.
|
||||
// 6. Always teardown: kill session + rm temp dir (even on throw).
|
||||
@@ -186,10 +387,18 @@ export async function runTuiTurn({
|
||||
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)
|
||||
|
||||
// Env-token-only mode: the env token is set AND claude runs in an isolated home
|
||||
// (ehome !== rhome). In that case the scratch home must be CREDENTIAL-FREE (no
|
||||
// .credentials.json) so the env token — passed by buildTuiCmd — is the only credential
|
||||
// and is therefore authoritative (interactive claude otherwise PREFERS a credentials.json,
|
||||
// shadowing the env token; proven live on PI231). server.mjs derives TUI_HOME via
|
||||
// resolveTuiHome() so this isolated home is the DEFAULT once CLAUDE_CODE_OAUTH_TOKEN is set.
|
||||
const envTokenMode = !!process.env.CLAUDE_CODE_OAUTH_TOKEN && ehome !== rhome;
|
||||
|
||||
// Ensure scratch cwd exists, then prepare the (scratch or real) HOME + trust the
|
||||
// cwd — before claude boots.
|
||||
if (!existsSync(cwd)) mkdirSync(cwd, { recursive: true });
|
||||
prepareTuiHome(rhome, ehome, cwd);
|
||||
prepareTuiHome(rhome, ehome, cwd, { envTokenMode });
|
||||
|
||||
// Write prompt to a temp file (mode 0600) so the content never touches argv.
|
||||
const tmpDir = mkdtempSync(`${tmpdir()}/ocp-tui-`);
|
||||
@@ -215,35 +424,52 @@ export async function runTuiTurn({
|
||||
// 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)],
|
||||
buildTuiCmd(claudeBin, model, sessionId, ehome, entrypointMode)],
|
||||
{ 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);
|
||||
// 2. Wait until claude's input bar is actually ready (was: blind sleep(BOOT_MS)).
|
||||
// BOOT_MS is now the MAX readiness wait, not a fixed delay.
|
||||
const ready = await pollUntil(() => tuiInputReady(tuiCapturePane(tmux, tmuxName)),
|
||||
{ timeoutMs: BOOT_MS, intervalMs: READY_POLL_MS });
|
||||
if (!ready) {
|
||||
// (readiness timed out; relying on paste-verify)
|
||||
console.error("[tui] input_not_ready", tmuxName);
|
||||
}
|
||||
|
||||
// 3. Paste the prompt via a tmux PASTE BUFFER with bracketed paste (-p), NOT
|
||||
// `send-keys -l`. send-keys of a large multi-line prompt is unreliable: the
|
||||
// embedded newlines arrive as separate key events (effectively repeated Enter),
|
||||
// so a big OpenClaw-style prompt never lands and the turn hangs to the wallclock
|
||||
// (issue #130 — reproduced at ~300 lines; fixed by bracketed paste). load-buffer
|
||||
// reads the file directly (no shell arg limit, no `"$(cat)"`), and paste-buffer -p
|
||||
// wraps it in bracketed-paste markers so claude ingests it atomically as ONE paste
|
||||
// ("[Pasted text #N +M lines]"). -d deletes the buffer afterward. Buffer name is the
|
||||
// per-session tmuxName, so concurrent turns never collide.
|
||||
tmux(["load-buffer", "-b", tmuxName, promptFile]);
|
||||
tmux(["paste-buffer", "-b", tmuxName, "-t", tmuxName, "-p", "-d"]);
|
||||
|
||||
// Verify the prompt POSITIVELY landed before submitting; poll (a large bracketed paste
|
||||
// takes a beat to render the "[Pasted text]" indicator). This is load-bearing: firing
|
||||
// Enter before the paste renders submits an empty box → the turn hangs to the wallclock
|
||||
// (issue #130). Fast-fail if it never lands → deterministic error in seconds.
|
||||
const landed = await pollUntil(() => tuiPromptLanded(tuiCapturePane(tmux, tmuxName), prompt),
|
||||
{ timeoutMs: PASTE_VERIFY_MS, intervalMs: READY_POLL_MS });
|
||||
if (!landed) {
|
||||
throw new Error("tui_paste_not_landed: prompt did not reach claude's input within " + PASTE_VERIFY_MS + "ms");
|
||||
}
|
||||
|
||||
// Submit (separate Enter key event).
|
||||
tmux(["send-keys", "-t", tmuxName, "Enter"]);
|
||||
|
||||
// 3. Block on the native transcript (resolved by session-id) until terminal.
|
||||
// 4. Block on the native transcript (resolved by session-id) until terminal.
|
||||
// Returns { text, entrypoint } from readTuiTranscript.
|
||||
return await readTuiTranscript({ home: ehome, sessionId, wallclockMs });
|
||||
} finally {
|
||||
// 4. Teardown — always, even on throw.
|
||||
// 5. Teardown — always, even on throw.
|
||||
try { tmux(["kill-session", "-t", tmuxName]); } catch { /* already gone */ }
|
||||
try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best effort */ }
|
||||
}
|
||||
|
||||
+181
-22
@@ -51,19 +51,29 @@ export function parseTranscriptLines(text) {
|
||||
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.
|
||||
// A line marks the assistant turn complete when EITHER:
|
||||
// (a) {type:"system", subtype:"turn_duration"} — emitted by newer claude builds
|
||||
// (e.g. 2.1.159), OR
|
||||
// (b) {type:"assistant"} whose message.stop_reason is a FINAL reason
|
||||
// ("end_turn" / "stop_sequence" / "max_tokens"). This is the API-level
|
||||
// end-of-turn signal, present across claude builds whose transcripts do NOT
|
||||
// emit turn_duration (e.g. 2.1.114 — verified live on the cloud host). Without
|
||||
// it OCP can't detect completion on those builds and hangs to the wallclock,
|
||||
// then returns only partial text (issue #130, cloud/server-side symptom).
|
||||
//
|
||||
// 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.
|
||||
// stop_reason "tool_use" is deliberately NOT terminal: the model is mid-turn (it will
|
||||
// run a tool and continue with a later assistant entry). Matching on a FINAL
|
||||
// stop_reason — not on the mere presence of a tool_use — keeps tool-using turns intact.
|
||||
// (The v3.17.1 narrowing dropped a buggy "tool_use is terminal" rule; this restores
|
||||
// cross-version completion detection without bringing that bug back.)
|
||||
const TERMINAL_STOP_REASONS = new Set(["end_turn", "stop_sequence", "max_tokens"]);
|
||||
export function isTerminalLine(obj) {
|
||||
if (!obj || typeof obj !== "object") return false;
|
||||
return obj.type === "system" && obj.subtype === "turn_duration";
|
||||
if (obj.type === "system" && obj.subtype === "turn_duration") return true;
|
||||
if (obj.type === "assistant" && obj.message && typeof obj.message === "object") {
|
||||
return TERMINAL_STOP_REASONS.has(obj.message.stop_reason);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Text of the LAST assistant turn: concatenate its text content blocks
|
||||
@@ -90,24 +100,169 @@ export function extractLatestAssistantText(events) {
|
||||
return text;
|
||||
}
|
||||
|
||||
// Returns the entrypoint string from the turn_duration line (e.g. "cli"),
|
||||
// Returns the entrypoint string (e.g. "cli") used for the billing-pool assertion,
|
||||
// or null if absent. Lets callers assert the subscription-classified path.
|
||||
// Fixture-confirmed: entrypoint field lives directly on the turn_duration line.
|
||||
//
|
||||
// Resolution order (C-3, issue #133):
|
||||
// 1. PREFER the turn_duration system line's `entrypoint` — the authoritative
|
||||
// end-of-turn classifier emitted by builds that produce turn_duration
|
||||
// (e.g. claude-2.1.104/2.1.157 on PI231).
|
||||
// 2. FALL BACK to the `entrypoint` field on ANY ordinary transcript line
|
||||
// (assistant / user / attachment / system) — present on BOTH emitting and
|
||||
// non-emitting builds. Some claude builds (e.g. certain Mac mini transcripts)
|
||||
// do NOT emit a turn_duration line at all; reading ONLY turn_duration made the
|
||||
// caller's tui_entrypoint_mismatch assertion (server.mjs) get got:null every
|
||||
// turn and go blind. The entrypoint value is identical across line types within
|
||||
// a single interactive session (fixture-confirmed: every line in
|
||||
// complete-haiku.jsonl carrying `entrypoint` reads "cli"), so the fallback
|
||||
// yields the same classifier. Last-writer-wins on the fallback.
|
||||
export function verifyEntrypoint(events) {
|
||||
let fallback = null;
|
||||
for (const ev of events) {
|
||||
if (ev && ev.type === "system" && ev.subtype === "turn_duration") {
|
||||
return ev.entrypoint != null ? ev.entrypoint : null;
|
||||
if (!ev || typeof ev !== "object") continue;
|
||||
if (ev.type === "system" && ev.subtype === "turn_duration" && ev.entrypoint != null) {
|
||||
return ev.entrypoint; // authoritative — short-circuit
|
||||
}
|
||||
if (ev.entrypoint != null) fallback = ev.entrypoint;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
// ── C-1: honest AUTH-FAILURE banner detection (issue #133) ───────────────
|
||||
// When the interactive `claude` CLI hits an in-session error it does NOT crash —
|
||||
// it renders the error as ordinary assistant text in the transcript. The specific
|
||||
// failure C-1 exists to catch is R-1: EXPIRED / INVALID credentials, where every
|
||||
// turn comes back as the same one-line auth-failure banner and OCP, none the wiser,
|
||||
// caches that banner (server.mjs setCachedResponse), shares it via singleflight, and
|
||||
// records a model SUCCESS — so a hard auth error is silently served (and cached for
|
||||
// the 5-min TTL) as a real answer. The two live-reproduced banners on PI231
|
||||
// (2026-06-10) are:
|
||||
// "Please run /login · API Error: 401 Invalid authentication credentials" (69 chars)
|
||||
// "Failed to authenticate. API Error: 401 Invalid authentication credentials" (73 chars)
|
||||
//
|
||||
// WHY THE SCOPE IS NARROW (conservatism — the load-bearing design choice).
|
||||
// An earlier generalised rule (^<short-prefix>?API Error:\s*\d{3}\b.*$) was TOO
|
||||
// BROAD: its unbounded `.*` tail let any short prefix + "API Error: NNN" + an
|
||||
// arbitrarily long sentence match, so it KILLED legitimate long answers that merely
|
||||
// DISCUSS an API error (e.g. "API Error: 500 happened because the server was
|
||||
// overloaded. To fix this, retry with exponential backoff …"). That is the worst
|
||||
// outcome: a false-positive costs the user a missing answer AND a double-burn retry,
|
||||
// whereas the rare false-negative (caching one transient error for the 5-min TTL) is
|
||||
// cheap and self-healing. So C-1 is reframed from "detect ANY API error" to "detect
|
||||
// a claude-CLI AUTHENTICATION-FAILURE banner", and when unsure it PASSES (does not
|
||||
// kill). Transient 5xx server errors are deliberately NOT detected — they are not the
|
||||
// R-1 case and the conservative choice is to let them through.
|
||||
//
|
||||
// THE SIGNAL — a turn is an auth-failure banner only if ALL of these hold over the
|
||||
// WHOLE trimmed assistant text (a conjunction; any one failing => PASS):
|
||||
// 1. SHORT whole-message. Real banners are one short line (the two live samples are
|
||||
// 69 and 73 chars). Cap = TUI_ERR_MAX_LEN (100) — headroom over 73 for a
|
||||
// slightly longer future banner, while still rejecting multi-sentence prose. A
|
||||
// long answer that happens to discuss auth (no code chars, e.g. 226 chars) is
|
||||
// rejected on length alone.
|
||||
// 2. Contains "API Error: 4\d{2}" — auth failures are 4xx (401/403). This rejects
|
||||
// transient 5xx ("API Error: 500/503 …") and bare "HTTP 401 means unauthorized."
|
||||
// (no "API Error:" core).
|
||||
// 3. Contains an auth KEYWORD — authenticat | /login | credential (case-insensitive).
|
||||
// This rejects answers that quote a 4xx but are not auth banners, e.g.
|
||||
// "To debug a 401: the server returns API Error: 401 Unauthorized …"
|
||||
// ("Unauthorized" is authoriz-, not authenticat-; no /login, no credential).
|
||||
// 4. Contains NO backtick or quote char (` ' "). A real CLI banner is plain text;
|
||||
// backticked/quoted text signals an answer that is QUOTING the error rather than
|
||||
// being the banner, e.g. "You'll see `API Error: 401` … run /login to fix it."
|
||||
// (75 chars — passes 1-3 but is excluded here). This is the conservative tie-
|
||||
// breaker for short instructional answers.
|
||||
//
|
||||
// Worked matrix (all required cases pass — see test-features.mjs C-1 block):
|
||||
// KILL: "Please run /login · API Error: 401 Invalid authentication credentials"
|
||||
// KILL: "Failed to authenticate. API Error: 401 Invalid authentication credentials"
|
||||
// PASS: "API Error: 500 happened because the server was overloaded. …" (not 4xx)
|
||||
// PASS: "Failed to parse the config. Here are the API Error: 401 details …" (too long + no auth-kw)
|
||||
// PASS: "To debug a 401: … API Error: 401 Unauthorized, then you refresh …" (no auth-kw)
|
||||
// PASS: "Here is the handler … It logs the string API Error: 503 …" (not 4xx)
|
||||
// PASS: "You'll see `API Error: 401` … run /login to fix it." (has backtick)
|
||||
// PASS: "HTTP 401 means unauthorized." (no API Error core)
|
||||
// PASS: "The capital of France is Paris." (nothing matches)
|
||||
//
|
||||
// OPERATOR OVERRIDE (unchanged): CLAUDE_TUI_ERROR_PATTERNS lets an operator REPLACE
|
||||
// the default auth-banner detector with their own newline- or `||`-separated JS regex
|
||||
// source strings (each auto-anchored ^…$ over the trimmed text, case-insensitive). A
|
||||
// non-empty override uses ONLY those regexes (the narrowed default is bypassed); an
|
||||
// empty / whitespace-only override DISABLES detection entirely (escape hatch).
|
||||
|
||||
// Whole-message length cap for the default auth-banner detector. Real banners are
|
||||
// 69/73 chars; 100 gives headroom while still rejecting multi-sentence prose.
|
||||
const TUI_ERR_MAX_LEN = 100;
|
||||
// 4xx "API Error:" core — auth failures are 4xx (401/403), never 5xx.
|
||||
const TUI_ERR_4XX = /API Error:\s*4\d{2}\b/i;
|
||||
// Auth keyword — the message must be about authentication, not just quote a 4xx.
|
||||
const TUI_ERR_AUTH_KW = /authenticat|\/login|credential/i;
|
||||
// Code/quote chars — their presence signals prose QUOTING an error, not the banner.
|
||||
const TUI_ERR_CODE_CHAR = /[`'"]/;
|
||||
|
||||
// Default detector: returns true iff `trimmed` IS a claude-CLI auth-failure banner
|
||||
// (all four signals above). Conservative — any signal failing => false (PASS).
|
||||
function isDefaultAuthFailureBanner(trimmed) {
|
||||
if (trimmed.length > TUI_ERR_MAX_LEN) return false; // 1. short whole-message
|
||||
if (!TUI_ERR_4XX.test(trimmed)) return false; // 2. 4xx API Error core
|
||||
if (!TUI_ERR_AUTH_KW.test(trimmed)) return false; // 3. auth keyword
|
||||
if (TUI_ERR_CODE_CHAR.test(trimmed)) return false; // 4. no code/quote chars
|
||||
return true;
|
||||
}
|
||||
|
||||
// Compile an OPERATOR-SUPPLIED pattern set (override path only). Each source is
|
||||
// anchored ^…$ over the trimmed text and matched case-insensitively (`s` so `.` spans
|
||||
// a multi-line banner). A pattern that fails to compile is skipped (never throws into
|
||||
// the request path).
|
||||
function compileTuiErrorPatterns(raw) {
|
||||
const sources = String(raw).split(/\r?\n|\|\|/).map((s) => s.trim()).filter(Boolean);
|
||||
const out = [];
|
||||
for (const src of sources) {
|
||||
try { out.push(new RegExp(`^(?:${src})$`, "is")); } catch { /* skip bad pattern */ }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Returns the matched banner text (the trimmed assistant text) if `text` IS a claude-
|
||||
// CLI auth-failure banner in its entirety, else null. `patternsRaw` defaults to
|
||||
// process.env.CLAUDE_TUI_ERROR_PATTERNS:
|
||||
// - undefined → narrowed default auth-banner detector (isDefaultAuthFailureBanner).
|
||||
// - non-empty → operator regex override REPLACES the default.
|
||||
// - empty/ws → detection disabled (escape hatch).
|
||||
export function detectTuiUpstreamError(text, patternsRaw = process.env.CLAUDE_TUI_ERROR_PATTERNS) {
|
||||
if (typeof text !== "string") return null;
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return null;
|
||||
if (patternsRaw == null) {
|
||||
return isDefaultAuthFailureBanner(trimmed) ? trimmed : null;
|
||||
}
|
||||
// Operator override path: empty/whitespace disables; otherwise use only their regexes.
|
||||
const patterns = compileTuiErrorPatterns(patternsRaw);
|
||||
if (patterns.length === 0) return null;
|
||||
for (const re of patterns) {
|
||||
if (re.test(trimmed)) return trimmed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Block until the session transcript is terminal (turn_duration) 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.
|
||||
// Block until the session transcript is terminal (turn_duration / final
|
||||
// stop_reason) or the wall-clock cap elapses, polling the file (no fs.watch —
|
||||
// robust over NFS / editors). Returns { text, entrypoint, truncated }:
|
||||
// - text: latest assistant text.
|
||||
// - entrypoint: billing-pool classifier (see verifyEntrypoint), or null.
|
||||
// - truncated: FALSE when a terminal marker was reached (the turn completed);
|
||||
// TRUE when the wall-clock cap was hit with partial text but NO
|
||||
// terminal marker (the turn is INCOMPLETE — what we have is a
|
||||
// cut-off prefix). (C-2, issue #133.)
|
||||
//
|
||||
// Why `truncated` matters: previously the terminal-marker path and the
|
||||
// cap-with-partial-text path BOTH returned `{text, entrypoint}` identically, so
|
||||
// callClaudeTui could not tell a complete answer from a truncated one and cached +
|
||||
// returned the partial as finish_reason:stop (silent success). The caller now
|
||||
// throws on `truncated` so a cut-off turn is neither cached nor counted as success.
|
||||
// The field is additive — existing call sites that ignore it keep working.
|
||||
//
|
||||
// On cap with NO text at all, still throws (unchanged) — there is nothing to return.
|
||||
//
|
||||
// No quiescence heuristic by design: a long Opus thinking turn stalls transcript
|
||||
// growth and a "file stable for N s" rule would false-abort it (spec §4.3).
|
||||
@@ -125,10 +280,14 @@ export async function readTuiTranscript({ transcriptPath: p, home, sessionId, wa
|
||||
lastText = extractLatestAssistantText(events) || lastText;
|
||||
const ep = verifyEntrypoint(events);
|
||||
if (ep != null) lastEntrypoint = ep;
|
||||
if (events.some(isTerminalLine)) return { text: lastText, entrypoint: lastEntrypoint };
|
||||
// Terminal marker reached → the turn is COMPLETE.
|
||||
if (events.some(isTerminalLine)) return { text: lastText, entrypoint: lastEntrypoint, truncated: false };
|
||||
}
|
||||
await sleep(pollMs);
|
||||
}
|
||||
if (lastText) return { text: lastText, entrypoint: lastEntrypoint };
|
||||
// Cap elapsed with no terminal marker. If we have partial text, flag it truncated
|
||||
// so the caller rejects it (don't cache / don't count as success). No text at all
|
||||
// → throw (nothing to return).
|
||||
if (lastText) return { text: lastText, entrypoint: lastEntrypoint, truncated: true };
|
||||
throw new Error("tui_transcript_timeout: no assistant text within wallclock cap");
|
||||
}
|
||||
|
||||
@@ -573,21 +573,42 @@ Usage:
|
||||
ocp restart Restart the Claude proxy service
|
||||
ocp restart gateway Restart the OpenClaw gateway
|
||||
(briefly disconnects all Telegram/Discord bots)
|
||||
|
||||
Note (macOS): restart does a full launchctl bootout + bootstrap, NOT
|
||||
`kickstart -k`. bootout+bootstrap re-reads the plist's EnvironmentVariables,
|
||||
so an env change you made (e.g. CLAUDE_BIND, CLAUDE_CODE_OAUTH_TOKEN) actually
|
||||
takes effect. `kickstart -k` only re-execs the process and reuses launchd's
|
||||
cached env, so env edits would be silently ignored. (Linux systemctl already
|
||||
re-reads its EnvironmentFile on restart.)
|
||||
EOF
|
||||
}
|
||||
|
||||
# macOS only: reload a launchd agent via bootout + bootstrap so plist
|
||||
# EnvironmentVariables are re-read (kickstart -k would reuse the cached env).
|
||||
# Args: <uid> <label> <plist-path>. Returns 0 iff bootstrap succeeds.
|
||||
_launchd_reload() {
|
||||
local uid="$1" label="$2" plist="$3"
|
||||
[[ -f "$plist" ]] || return 1
|
||||
# bootout may legitimately fail if the agent is not currently loaded — that's fine,
|
||||
# we only require the subsequent bootstrap to succeed (the load that re-reads env).
|
||||
launchctl bootout "gui/$uid/$label" 2>/dev/null || true
|
||||
launchctl bootstrap "gui/$uid" "$plist" 2>/dev/null
|
||||
}
|
||||
|
||||
cmd_restart() {
|
||||
if [[ "${1:-}" == "gateway" ]]; then
|
||||
echo "Restarting gateway..."
|
||||
openclaw gateway restart 2>&1
|
||||
else
|
||||
echo "Restarting proxy..."
|
||||
# Try current service name, then legacy, then manual restart
|
||||
# Try current service name, then legacy, then manual restart.
|
||||
# macOS: bootout+bootstrap (re-reads plist EnvironmentVariables — see cmd_restart_help).
|
||||
# Linux: systemctl --user restart already re-reads its EnvironmentFile.
|
||||
local uid
|
||||
uid=$(id -u)
|
||||
if launchctl kickstart -k "gui/$uid/dev.ocp.proxy" 2>/dev/null; then
|
||||
if _launchd_reload "$uid" "dev.ocp.proxy" "$HOME/Library/LaunchAgents/dev.ocp.proxy.plist"; then
|
||||
true
|
||||
elif launchctl kickstart -k "gui/$uid/ai.openclaw.proxy" 2>/dev/null; then
|
||||
elif _launchd_reload "$uid" "ai.openclaw.proxy" "$HOME/Library/LaunchAgents/ai.openclaw.proxy.plist"; then
|
||||
true
|
||||
elif systemctl --user restart ocp-proxy 2>/dev/null; then
|
||||
true
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ocp",
|
||||
"version": "3.12.0",
|
||||
"version": "3.16.2",
|
||||
"description": "Slash commands for the OpenClaw Proxy",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
@@ -9,6 +9,7 @@
|
||||
"openclaw": {
|
||||
"type": "plugin",
|
||||
"id": "ocp",
|
||||
"pluginManifest": "openclaw.plugin.json"
|
||||
"pluginManifest": "openclaw.plugin.json",
|
||||
"extensions": ["./index.js"]
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "open-claude-proxy",
|
||||
"version": "3.18.0",
|
||||
"version": "3.20.1",
|
||||
"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": {
|
||||
|
||||
+350
-26
@@ -19,7 +19,11 @@
|
||||
* CLAUDE_SYSTEM_PROMPT — system prompt appended to all requests
|
||||
* CLAUDE_MCP_CONFIG — path to MCP server config JSON file
|
||||
* CLAUDE_SESSION_TTL — session TTL in ms (default: 3600000 = 1h)
|
||||
* CLAUDE_MAX_CONCURRENT — max concurrent claude processes (default: 8)
|
||||
* CLAUDE_MAX_CONCURRENT — max concurrent claude processes, -p/stream-json path (default: 8)
|
||||
* CLAUDE_MAX_QUEUE — max requests waiting for a -p slot before HTTP 429 (default: 16)
|
||||
* OCP_TUI_MAX_CONCURRENT — max concurrent interactive TUI turns, TUI-mode path (default: 2)
|
||||
* OCP_SPAWN_REAL_HOME — "1" forces the -p spawn to use the real HOME (disables the
|
||||
* latency spawn-home isolation; default: isolated when a token exists)
|
||||
* CLAUDE_BREAKER_THRESHOLD — failures in window before circuit opens (default: 6)
|
||||
* CLAUDE_BREAKER_COOLDOWN — base ms to wait before retrying after circuit opens (default: 120000)
|
||||
* CLAUDE_BREAKER_WINDOW — sliding window duration in ms (default: 300000 = 5min)
|
||||
@@ -30,14 +34,16 @@
|
||||
import { createServer } from "node:http";
|
||||
import { spawn, execFileSync } from "node:child_process";
|
||||
import { randomUUID, timingSafeEqual } from "node:crypto";
|
||||
import { readFileSync, readdirSync, accessSync, existsSync, constants, chmodSync, statSync } from "node:fs";
|
||||
import { readFileSync, readdirSync, accessSync, existsSync, constants, chmodSync, statSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { 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";
|
||||
import { runTuiTurn, reapStaleTuiSessions, resolveTuiHome } from "./lib/tui/session.mjs";
|
||||
import { detectTuiUpstreamError } from "./lib/tui/transcript.mjs";
|
||||
import { TuiSemaphore, recordTuiEntrypoint, buildTuiHealthBlock } from "./lib/tui/semaphore.mjs";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const _pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8"));
|
||||
@@ -269,6 +275,15 @@ const SYSTEM_PROMPT = process.env.CLAUDE_SYSTEM_PROMPT || "";
|
||||
const MCP_CONFIG = process.env.CLAUDE_MCP_CONFIG || "";
|
||||
let SESSION_TTL = parseInt(process.env.CLAUDE_SESSION_TTL || "3600000", 10);
|
||||
let MAX_CONCURRENT = parseInt(process.env.CLAUDE_MAX_CONCURRENT || "8", 10);
|
||||
// FIX ⑥ (concurrency): bound on requests WAITING for a -p concurrency slot. Beyond
|
||||
// MAX_CONCURRENT, requests queue (up to CLAUDE_MAX_QUEUE) instead of being rejected; when the
|
||||
// queue is ALSO full, the request gets HTTP 429 + Retry-After (not an opaque 500). See
|
||||
// claudeSemaphore / acquireClaudeSlot below.
|
||||
const CLAUDE_MAX_QUEUE = parseInt(process.env.CLAUDE_MAX_QUEUE || "16", 10);
|
||||
// Retry-After seconds advertised on a 429 backpressure response. A claude turn is typically a
|
||||
// few seconds to tens of seconds; a small constant nudge keeps well-behaved clients from
|
||||
// hammering while the queue drains.
|
||||
const CLAUDE_QUEUE_RETRY_AFTER = parseInt(process.env.CLAUDE_QUEUE_RETRY_AFTER || "5", 10);
|
||||
const BREAKER_THRESHOLD = parseInt(process.env.CLAUDE_BREAKER_THRESHOLD || "6", 10);
|
||||
const BREAKER_COOLDOWN = parseInt(process.env.CLAUDE_BREAKER_COOLDOWN || "120000", 10);
|
||||
const BREAKER_WINDOW = parseInt(process.env.CLAUDE_BREAKER_WINDOW || "300000", 10);
|
||||
@@ -276,6 +291,12 @@ const BREAKER_HALF_OPEN_MAX = parseInt(process.env.CLAUDE_BREAKER_HALF_OPEN_MAX
|
||||
const HEARTBEAT_INTERVAL = parseInt(process.env.CLAUDE_HEARTBEAT_INTERVAL || "0", 10);
|
||||
const BIND_ADDRESS = process.env.CLAUDE_BIND || "127.0.0.1";
|
||||
const NO_CONTEXT = process.env.CLAUDE_NO_CONTEXT === "true";
|
||||
// Kill-switch for the FIX-③ default-path spawn-home isolation (see resolveSpawnHome /
|
||||
// spawnHomeMode below). When "1", the -p/stream-json spawn always runs in the operator's
|
||||
// real HOME with no cwd override — byte-for-byte the pre-isolation behaviour — even if an
|
||||
// OAuth token is resolvable. Provided as an escape hatch in case a host depends on the real
|
||||
// HOME's claude config for the spawned process.
|
||||
const SPAWN_REAL_HOME = process.env.OCP_SPAWN_REAL_HOME === "1";
|
||||
const AUTH_MODE = process.env.CLAUDE_AUTH_MODE || (PROXY_API_KEY ? "shared" : "none");
|
||||
const ADMIN_KEY = process.env.OCP_ADMIN_KEY || "";
|
||||
const PROXY_ANONYMOUS_KEY = process.env.PROXY_ANONYMOUS_KEY || "";
|
||||
@@ -297,8 +318,147 @@ let CACHE_TTL = parseInt(process.env.CLAUDE_CACHE_TTL || "0", 10); // 0 = disabl
|
||||
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;
|
||||
// HOME the interactive claude runs under. resolveTuiHome() decides:
|
||||
// - OCP_TUI_HOME set → that path (explicit override, back-compat).
|
||||
// - else CLAUDE_CODE_OAUTH_TOKEN set → a CREDENTIAL-FREE scratch home
|
||||
// (<HOME>/.ocp-tui/home) with NO .credentials.json, so the env token is the only
|
||||
// credential and is authoritative — interactive claude otherwise PREFERS a
|
||||
// credentials.json over the env var, so a stale one shadows the token (proven live on
|
||||
// PI231) and a refresh on it can corrupt the single-use token. See ADR 0007 PR-D.
|
||||
// - else (no env token) → the operator's real home (legacy credentials.json path,
|
||||
// byte-for-byte unchanged for hosts that intentionally rely on credentials.json).
|
||||
const TUI_HOME = resolveTuiHome({
|
||||
realHome: process.env.HOME,
|
||||
configuredHome: process.env.OCP_TUI_HOME,
|
||||
envTokenSet: !!process.env.CLAUDE_CODE_OAUTH_TOKEN,
|
||||
});
|
||||
const TUI_ENTRYPOINT = process.env.OCP_TUI_ENTRYPOINT || "cli"; // cli|auto|off — see ADR 0007
|
||||
// Independent concurrency bound for the TUI path (audit C-4). Default 2: a TUI turn is
|
||||
// HEAVY (per-request cold-boot of a tmux+claude session + up to TUI_WALLCLOCK_MS=120s of
|
||||
// wallclock), so a small host (e.g. a Pi 4 serving a family) cannot run many at once
|
||||
// without OOM + multiplied subscription rate-limit pressure. This is NOT the global
|
||||
// MAX_CONCURRENT gate (that lives in spawnClaudeProcess, the -p/stream-json path, which
|
||||
// callClaudeTui never reaches). See ADR 0007 PR-B amendment + lib/tui/semaphore.mjs.
|
||||
const TUI_MAX_CONCURRENT = parseInt(process.env.OCP_TUI_MAX_CONCURRENT || "2", 10);
|
||||
const tuiSemaphore = new TuiSemaphore(TUI_MAX_CONCURRENT);
|
||||
// Operator-visible TUI drift surface (audit C-5). lastEntrypoint + entrypointMismatches
|
||||
// let the operator poll /health to catch a silent metered-pool drift (the audit's top
|
||||
// risk: after the 6/15 flip a TTY-loss could flip cc_entrypoint cli→sdk-cli and drain
|
||||
// metered credits invisibly — the warning currently only reaches journald).
|
||||
const tuiStats = {
|
||||
lastEntrypoint: null, // last observed cc_entrypoint from the transcript ("cli" | "sdk-cli" | null)
|
||||
entrypointMismatches: 0, // count of cli-expected-but-got-other turns
|
||||
};
|
||||
|
||||
// ── FIX ③ (latency): default-path (-p / stream-json) spawn-home isolation ──────────────
|
||||
// PROBLEM (measured, not theoretical): OCP's default spawn inherits the operator's real HOME
|
||||
// (loading the global ~/.claude — plugins, skills, hooks) and runs with cwd=~/ocp (loading the
|
||||
// project CLAUDE.md / skills) on EVERY request. Pure Anthropic API floor for haiku "hi" ≈ 1–2s;
|
||||
// the same claude CLI spawned in the operator's real HOME/cwd ≈ 10–28s; a clean minimal HOME +
|
||||
// CLAUDE_CODE_OAUTH_TOKEN ≈ 3–7s and authenticates fine. So the heavy global config is pure
|
||||
// per-request latency tax with no proxy benefit (a proxy must NOT leak the host's context into
|
||||
// the proxied turn — same rationale as NO_CONTEXT / the TUI path's CLAUDE_MDS suppression).
|
||||
//
|
||||
// FIX: when an OAuth token is resolvable, run the default spawn under a CREDENTIAL-FREE minimal
|
||||
// scratch HOME (`<realHome>/.ocp/spawn-home`) with cwd = that same neutral dir, and pass the
|
||||
// resolved token via CLAUDE_CODE_OAUTH_TOKEN so the env token is authoritative. This MIRRORS the
|
||||
// TUI path's resolveTuiHome() env-token mode (lib/tui/session.mjs): for `-p`, the env token wins
|
||||
// over a credentials.json (the opposite of interactive claude), so credential isolation is not
|
||||
// even strictly required for auth here, but a credential-FREE home is still the right shape —
|
||||
// nothing to refresh, nothing to corrupt, no heavy config to load.
|
||||
//
|
||||
// SAFETY: if NO token is resolvable → fall back to the real HOME with no cwd override (zero
|
||||
// regression). OCP_SPAWN_REAL_HOME=1 forces that legacy behaviour even when a token exists.
|
||||
// The scratch home holds NO .credentials.json / NO settings.json / NO plugins — it is created
|
||||
// minimal and (re)cleaned of any settings.json on prepare.
|
||||
const SPAWN_HOME_DIR = `${process.env.HOME}/.ocp/spawn-home`;
|
||||
|
||||
// Idempotently prepare the minimal scratch HOME. Creates the dir if missing and removes any
|
||||
// settings.json that might have crept in, so the spawned claude loads no host settings/plugins.
|
||||
// Best-effort: a failure here degrades toward "dir may be missing", which spawn() tolerates by
|
||||
// erroring loudly — never a silent auth/credential corruption (there are no credentials here).
|
||||
function prepareSpawnHome(dir = SPAWN_HOME_DIR) {
|
||||
try {
|
||||
mkdirSync(`${dir}/.claude`, { recursive: true });
|
||||
// Belt-and-braces: ensure no settings.json/plugins leak in (this home is fully ours).
|
||||
for (const f of [`${dir}/.claude/settings.json`, `${dir}/.claude/settings.local.json`]) {
|
||||
try { if (existsSync(f)) rmSync(f, { force: true }); } catch { /* best effort */ }
|
||||
}
|
||||
} catch { /* best effort — spawn will surface a hard error if the dir is truly unusable */ }
|
||||
}
|
||||
|
||||
// Resolve the default-spawn HOME-isolation decision ONCE, lazily + memoized (so it runs after
|
||||
// getOAuthCredentials is defined regardless of source order, and the token probe happens at most
|
||||
// once). Returns { isolated, home, token } where:
|
||||
// - isolated:true → spawn under SPAWN_HOME_DIR with cwd=SPAWN_HOME_DIR + the env token.
|
||||
// - isolated:false → legacy real-HOME spawn, no cwd override (no token, or kill-switch on).
|
||||
// NEVER logs/returns the token verbatim to any caller that logs; spawnClaudeProcess uses it only
|
||||
// to populate the spawn env. token is null when isolation is off.
|
||||
let _spawnHomeMode = null;
|
||||
function getSpawnHomeMode() {
|
||||
if (_spawnHomeMode) return _spawnHomeMode;
|
||||
if (SPAWN_REAL_HOME) {
|
||||
_spawnHomeMode = { isolated: false, home: null, token: null, reason: "kill-switch (OCP_SPAWN_REAL_HOME=1)" };
|
||||
return _spawnHomeMode;
|
||||
}
|
||||
let token = null;
|
||||
try { token = getOAuthCredentials()?.accessToken || null; } catch { token = null; }
|
||||
if (token) {
|
||||
prepareSpawnHome(SPAWN_HOME_DIR);
|
||||
_spawnHomeMode = { isolated: true, home: SPAWN_HOME_DIR, token, reason: "oauth token resolved" };
|
||||
} else {
|
||||
_spawnHomeMode = { isolated: false, home: null, token: null, reason: "no oauth token resolvable" };
|
||||
}
|
||||
return _spawnHomeMode;
|
||||
}
|
||||
|
||||
// ── FIX ⑥ (concurrency): bounded wait-queue for the -p / stream-json path ──────────────
|
||||
// PROBLEM (proven): spawnClaudeProcess used `if (activeRequests >= MAX_CONCURRENT) throw` →
|
||||
// the client got an opaque 500 AND the rejection was NOT counted in stats (a 15-concurrent
|
||||
// stress run returned 7×500 while /health stats.errors stayed 0). The TUI path already has a
|
||||
// bounded-queue semaphore (TuiSemaphore); the -p path did not.
|
||||
//
|
||||
// FIX: requests beyond MAX_CONCURRENT WAIT on this semaphore (up to CLAUDE_MAX_QUEUE) instead of
|
||||
// being rejected. Only when the queue is ALSO full do we reject — with HTTP 429 + Retry-After
|
||||
// (deterministic backpressure), a distinct `concurrency_queue_full` log, and a stats.queueRejections
|
||||
// counter that shows up on /health. The slot is released on EVERY exit path via the existing
|
||||
// idempotent cleanup() (proc exit/close/error/timeout) — the #37/#40 slot-leak guard.
|
||||
const claudeSemaphore = new TuiSemaphore(MAX_CONCURRENT, { maxQueue: CLAUDE_MAX_QUEUE });
|
||||
|
||||
// Tagged error so callers can map this single overflow case to HTTP 429 (every OTHER throw stays
|
||||
// a 500). Carries retryAfter for the Retry-After header.
|
||||
class ConcurrencyOverflowError extends Error {
|
||||
constructor(message) { super(message); this.name = "ConcurrencyOverflowError"; this.httpStatus = 429; this.retryAfter = CLAUDE_QUEUE_RETRY_AFTER; }
|
||||
}
|
||||
|
||||
// Acquire a -p concurrency slot, queuing if all are busy (up to CLAUDE_MAX_QUEUE). Resolves to a
|
||||
// release() fn that MUST be called exactly once on every exit path (wired into ctx.cleanup()).
|
||||
// Rejects with ConcurrencyOverflowError when the wait-queue is full. Increments stats.queued while
|
||||
// waiting (decremented on acquire) and stats.queueRejections on overflow.
|
||||
async function acquireClaudeSlot() {
|
||||
stats.queued = claudeSemaphore.queued + 1; // reflect this waiter before we (maybe) block
|
||||
try {
|
||||
await claudeSemaphore.acquire();
|
||||
} catch (e) {
|
||||
stats.queued = claudeSemaphore.queued;
|
||||
stats.queueRejections++;
|
||||
logEvent("warn", "concurrency_queue_full", {
|
||||
limit: claudeSemaphore.limit, maxQueue: claudeSemaphore.maxQueue,
|
||||
inflight: claudeSemaphore.inflight, queued: claudeSemaphore.queued,
|
||||
});
|
||||
throw new ConcurrencyOverflowError(
|
||||
`backpressure: concurrency limit (${claudeSemaphore.limit}) reached and wait queue ` +
|
||||
`(${claudeSemaphore.maxQueue}) is full — retry shortly`);
|
||||
}
|
||||
stats.queued = claudeSemaphore.queued;
|
||||
let released = false;
|
||||
return function releaseClaudeSlot() {
|
||||
if (released) return; // idempotent — cleanup() may be reached via multiple proc events
|
||||
released = true;
|
||||
claudeSemaphore.release();
|
||||
stats.queued = claudeSemaphore.queued;
|
||||
};
|
||||
}
|
||||
|
||||
// SECURITY fail-loud: TUI-mode is incompatible with any configuration that allows
|
||||
// non-operator prompts to reach the interactive claude session. Three cases:
|
||||
@@ -476,6 +636,28 @@ const cacheCleanupInterval = setInterval(() => {
|
||||
}
|
||||
}, 600000);
|
||||
|
||||
// TUI defunct-session reap (periodic): the boot reap (below) only fires once, but a
|
||||
// long-lived host (PI231 ran 30 days without restart) accumulates defunct `<claude>`
|
||||
// zombies between restarts — the pane's claude is a child of the tmux server, not node,
|
||||
// so only the server can reap it (see reapStaleTuiSessions). We sweep every 15 min, but
|
||||
// ONLY when the TUI path is fully idle: reapStaleTuiSessions may `kill-server`, which would
|
||||
// tear down a live turn's pane, so we skip the sweep while any turn is inflight or queued.
|
||||
// RESIDUAL (documented, accepted): a brand-new request whose pane is created in the narrow
|
||||
// window between this idle-check and kill-server would have its pane torn down and fail the
|
||||
// turn cleanly via runTuiTurn's existing honesty gates (rare; the boot reap is the primary
|
||||
// mechanism and the 15-min cadence makes the window negligible).
|
||||
// Gated on TUI_MODE — zero effect (no kill-server, no list-sessions) when TUI is off.
|
||||
// cli.js does NOT perform this operation (Class B, OCP-owned TUI spawn) — see ADR 0007.
|
||||
const TUI_REAP_INTERVAL_MS = 15 * 60 * 1000;
|
||||
const tuiReapInterval = TUI_MODE ? setInterval(() => {
|
||||
if (tuiSemaphore.inflight > 0 || tuiSemaphore.queued > 0) return; // a turn is live — defer
|
||||
try {
|
||||
const n = reapStaleTuiSessions();
|
||||
if (n) logEvent("info", "tui_reaped_stale_sessions", { count: n, trigger: "periodic" });
|
||||
} catch (e) { logEvent("error", "tui_periodic_reap_failed", { error: e.message }); }
|
||||
}, TUI_REAP_INTERVAL_MS) : null;
|
||||
if (tuiReapInterval && typeof tuiReapInterval.unref === "function") tuiReapInterval.unref();
|
||||
|
||||
// ── Active child process tracking ────────────────────────────────────────
|
||||
const activeProcesses = new Set();
|
||||
|
||||
@@ -488,6 +670,8 @@ const stats = {
|
||||
sessionHits: 0,
|
||||
sessionMisses: 0,
|
||||
oneOffRequests: 0,
|
||||
queued: 0, // current requests waiting for a -p concurrency slot (FIX ⑥)
|
||||
queueRejections: 0, // total requests rejected with HTTP 429 because the wait-queue was full (FIX ⑥)
|
||||
};
|
||||
const recentErrors = []; // last 20 errors
|
||||
|
||||
@@ -715,11 +899,14 @@ function getModelTier(cliModel) {
|
||||
// (messagesToPrompt), so multi-turn correctness is preserved without sessions.
|
||||
// The sessions Map is retained for stats/logging but no longer drives --resume.
|
||||
// Reference: OLP ADR 0009 Amendment 1 + commit 97e7d16.
|
||||
function spawnClaudeProcess(model, messages, conversationId, keyName) {
|
||||
if (stats.activeRequests >= MAX_CONCURRENT) {
|
||||
throw new Error(`concurrency limit reached (${stats.activeRequests}/${MAX_CONCURRENT})`);
|
||||
}
|
||||
|
||||
// FIX ⑥: concurrency is now bounded by the claudeSemaphore via acquireClaudeSlot(), which the
|
||||
// caller MUST await before calling this, passing the resulting release fn as `releaseSlot`. The
|
||||
// old `if (activeRequests >= MAX_CONCURRENT) throw` gate (→ opaque 500, uncounted) is GONE: at
|
||||
// most MAX_CONCURRENT callers hold a slot when they reach here, so this spawn is always within
|
||||
// budget. releaseSlot is wired into the idempotent cleanup() so the slot is freed on EVERY exit
|
||||
// path (close/error/timeout/abort). Back-compat: releaseSlot defaults to a no-op so any future
|
||||
// internal caller that does its own gating still works.
|
||||
function spawnClaudeProcess(model, messages, conversationId, keyName, releaseSlot = () => {}) {
|
||||
const cliModel = MODEL_MAP[model] || model;
|
||||
|
||||
// Circuit breaker: disabled (see comment at top of breaker section)
|
||||
@@ -756,7 +943,25 @@ function spawnClaudeProcess(model, messages, conversationId, keyName) {
|
||||
env.CLAUDE_CODE_DISABLE_AUTO_MEMORY = "1";
|
||||
}
|
||||
|
||||
const proc = spawn(CLAUDE, cliArgs, { env, stdio: ["pipe", "pipe", "pipe"] });
|
||||
// FIX ③ (latency): default-path spawn-home isolation. When a token is resolvable (and the
|
||||
// OCP_SPAWN_REAL_HOME kill-switch is off), run claude under a credential-free minimal HOME
|
||||
// with cwd = that same neutral dir, so it loads NONE of the operator's global ~/.claude
|
||||
// (plugins/skills/hooks) or the ~/ocp project CLAUDE.md/skills — the measured 10–28s → 3–7s
|
||||
// latency win. The env token is authoritative for `-p` (unlike interactive claude). When no
|
||||
// token is resolvable, falls back to real HOME + inherited cwd (zero regression). See
|
||||
// getSpawnHomeMode() / prepareSpawnHome() above. The DISABLE_CLAUDE_MDS / AUTO_MEMORY flags
|
||||
// are set unconditionally in isolated mode (belt-and-braces; mirrors the TUI path).
|
||||
const spawnHome = getSpawnHomeMode();
|
||||
const spawnOpts = { env, stdio: ["pipe", "pipe", "pipe"] };
|
||||
if (spawnHome.isolated) {
|
||||
env.HOME = spawnHome.home;
|
||||
env.CLAUDE_CODE_OAUTH_TOKEN = spawnHome.token; // env token is authoritative for -p
|
||||
env.CLAUDE_CODE_DISABLE_CLAUDE_MDS = "1";
|
||||
env.CLAUDE_CODE_DISABLE_AUTO_MEMORY = "1";
|
||||
spawnOpts.cwd = spawnHome.home; // neutral cwd: no project CLAUDE.md/skills
|
||||
}
|
||||
|
||||
const proc = spawn(CLAUDE, cliArgs, spawnOpts);
|
||||
activeProcesses.add(proc);
|
||||
|
||||
const t0 = Date.now();
|
||||
@@ -768,6 +973,10 @@ function spawnClaudeProcess(model, messages, conversationId, keyName) {
|
||||
cleaned = true;
|
||||
clearTimeout(overallTimer);
|
||||
stats.activeRequests--;
|
||||
// FIX ⑥: free the concurrency slot for a queued waiter. releaseSlot is itself idempotent,
|
||||
// and cleanup() is guarded by `cleaned`, so the slot is released exactly once on the first
|
||||
// exit path reached (proc 'exit' fires before 'close'; 'error' covers spawn failure).
|
||||
try { releaseSlot(); } catch { /* never let release throw out of cleanup */ }
|
||||
}
|
||||
|
||||
// Guarantee slot release on ANY exit path (normal close, error, timeout kill,
|
||||
@@ -837,12 +1046,18 @@ function spawnClaudeProcess(model, messages, conversationId, keyName) {
|
||||
// We accumulate full text across all content_block_delta events plus the
|
||||
// assistant-aggregate fallback, then resolve with the assembled string.
|
||||
// Reference: OLP ADR 0009 Amendment 1 + commit 97e7d16.
|
||||
function callClaude(model, messages, conversationId, keyName) {
|
||||
async function callClaude(model, messages, conversationId, keyName) {
|
||||
// FIX ⑥: acquire a concurrency slot first (queues up to CLAUDE_MAX_QUEUE; rejects with a
|
||||
// ConcurrencyOverflowError → 429 when the queue is full). The release fn is passed into the
|
||||
// spawn so the idempotent cleanup() frees it on every exit path. If the spawn itself throws
|
||||
// synchronously (before cleanup is wired), release here so the slot never leaks.
|
||||
const releaseSlot = await acquireClaudeSlot();
|
||||
return new Promise((resolve, reject) => {
|
||||
let ctx;
|
||||
try {
|
||||
ctx = spawnClaudeProcess(model, messages, conversationId, keyName);
|
||||
ctx = spawnClaudeProcess(model, messages, conversationId, keyName, releaseSlot);
|
||||
} catch (err) {
|
||||
releaseSlot();
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
@@ -917,7 +1132,11 @@ 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({
|
||||
// C-4: gate the heavy interactive boot behind the TUI semaphore. run() acquires a slot
|
||||
// (queuing if all are busy, up to maxQueue), then releases in a finally so any throw from
|
||||
// runTuiTurn (tmux spawn failure, paste-not-landed) OR from the honesty gates below
|
||||
// (truncation / error banner) can NEVER leak a slot. tuiSemaphore.inflight feeds /health.
|
||||
return tuiSemaphore.run(() => runTuiTurn({
|
||||
prompt,
|
||||
model: cliModel,
|
||||
claudeBin: CLAUDE,
|
||||
@@ -926,19 +1145,47 @@ function callClaudeTui(model, messages, _conversationId, _keyName) {
|
||||
cwd: TUI_CWD,
|
||||
wallclockMs: TUI_WALLCLOCK_MS,
|
||||
entrypointMode: TUI_ENTRYPOINT,
|
||||
}).then(({ text, entrypoint }) => {
|
||||
}).then(({ text, entrypoint, truncated }) => {
|
||||
// ── Honesty gates (issue #133) ─ run BEFORE recordModelSuccess / cache write-back.
|
||||
// A throw here propagates to the .catch below (recordModelError + reject), so the
|
||||
// result never reaches the downstream setCachedResponse / singleflight / SUCCESS path.
|
||||
|
||||
// C-2: the wall-clock cap hit with partial text and NO terminal marker — the turn
|
||||
// is INCOMPLETE. Returning the cut-off prefix would cache it and report it as
|
||||
// finish_reason:stop (a truncated answer served as a complete one). Reject instead.
|
||||
if (truncated) {
|
||||
logEvent("error", "tui_wallclock_truncated", { model: cliModel, chars: (text || "").length, wallclockMs: TUI_WALLCLOCK_MS });
|
||||
throw new Error("tui_wallclock_truncated: turn hit the wall-clock cap before completing; partial text dropped");
|
||||
}
|
||||
|
||||
// C-1: the interactive claude CLI renders in-session errors (expired/invalid
|
||||
// credentials, transient API failure) as ordinary assistant text. Returning that
|
||||
// banner would cache an error AS an answer and record a model SUCCESS. Detect a
|
||||
// known error banner (anchored whole-text match — see detectTuiUpstreamError) and
|
||||
// reject so it does NOT enter the cache and the client gets a 5xx.
|
||||
const banner = detectTuiUpstreamError(text);
|
||||
if (banner) {
|
||||
logEvent("error", "tui_upstream_error", { model: cliModel, banner: banner.slice(0, 200) });
|
||||
throw new Error("tui_upstream_error: claude CLI returned an in-session error banner instead of an answer");
|
||||
}
|
||||
|
||||
recordModelSuccess(cliModel, 0); // elapsed not measurable here; wallclock at reader level
|
||||
// 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") {
|
||||
// C-5: also surface the observation on /health. recordTuiEntrypoint sets lastEntrypoint
|
||||
// unconditionally (operators can poll it to confirm cli) and increments
|
||||
// entrypointMismatches when expected=cli but observed≠cli — the same condition the
|
||||
// journald warning already covers — so a silent metered-pool drift is visible on /health
|
||||
// without tailing logs.
|
||||
if (recordTuiEntrypoint(tuiStats, entrypoint, TUI_ENTRYPOINT)) {
|
||||
logEvent("warn", "tui_entrypoint_mismatch", { expected: "cli", got: entrypoint, model: cliModel });
|
||||
}
|
||||
return text;
|
||||
}).catch((err) => {
|
||||
recordModelError(cliModel, false);
|
||||
throw err;
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
// ── SSE heartbeat (opt-in idle watchdog) ────────────────────────────────
|
||||
@@ -977,14 +1224,28 @@ function startHeartbeat(res, intervalMs, sessionId) {
|
||||
// We parse line-by-line and forward content_block_delta text events as SSE.
|
||||
// The result event triggers the stop/[DONE] sequence.
|
||||
// Reference: OLP ADR 0009 Amendment 1 + commits 97e7d16, 65f945c.
|
||||
function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}) {
|
||||
async function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}) {
|
||||
const id = `chatcmpl-${randomUUID()}`;
|
||||
const created = Math.floor(Date.now() / 1000);
|
||||
|
||||
// FIX ⑥: acquire a concurrency slot first (queues up to CLAUDE_MAX_QUEUE). On overflow, surface
|
||||
// HTTP 429 + Retry-After (NOT 500). Release is wired into cleanup() for every exit path; if the
|
||||
// spawn throws synchronously before cleanup is wired, release here.
|
||||
let releaseSlot;
|
||||
try {
|
||||
releaseSlot = await acquireClaudeSlot();
|
||||
} catch (err) {
|
||||
if (err instanceof ConcurrencyOverflowError) {
|
||||
return jsonResponse(res, 429, { error: { message: sanitizeError(err.message), type: "rate_limit_error" } }, { "Retry-After": String(err.retryAfter) });
|
||||
}
|
||||
return jsonResponse(res, 500, { error: { message: sanitizeError(err.message), type: "proxy_error" } });
|
||||
}
|
||||
|
||||
let ctx;
|
||||
try {
|
||||
ctx = spawnClaudeProcess(model, messages, conversationId, authInfo.keyName);
|
||||
ctx = spawnClaudeProcess(model, messages, conversationId, authInfo.keyName, releaseSlot);
|
||||
} catch (err) {
|
||||
releaseSlot();
|
||||
return jsonResponse(res, 500, { error: { message: sanitizeError(err.message), type: "proxy_error" } });
|
||||
}
|
||||
|
||||
@@ -1177,12 +1438,23 @@ function sanitizeError(msg) {
|
||||
}
|
||||
|
||||
// ── Response helpers ────────────────────────────────────────────────────
|
||||
function jsonResponse(res, status, data) {
|
||||
function jsonResponse(res, status, data, extraHeaders = null) {
|
||||
if (res.headersSent || res.writableEnded || res.destroyed) return;
|
||||
res.writeHead(status, { "Content-Type": "application/json" });
|
||||
// extraHeaders is optional + additive (e.g. Retry-After on a 429); Content-Type always wins.
|
||||
res.writeHead(status, { ...(extraHeaders || {}), "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(data));
|
||||
}
|
||||
|
||||
// FIX ⑥: map an upstream error to the right HTTP response. A ConcurrencyOverflowError (the
|
||||
// wait-queue was full) becomes HTTP 429 + Retry-After + rate_limit_error; every other error
|
||||
// stays a 500 proxy_error (byte-for-byte the pre-fix behaviour for non-overflow errors).
|
||||
function respondUpstreamError(res, err) {
|
||||
if (err instanceof ConcurrencyOverflowError) {
|
||||
return jsonResponse(res, 429, { error: { message: sanitizeError(err.message), type: "rate_limit_error" } }, { "Retry-After": String(err.retryAfter) });
|
||||
}
|
||||
return jsonResponse(res, 500, { error: { message: sanitizeError(err.message), type: "proxy_error" } });
|
||||
}
|
||||
|
||||
function sendSSE(res, data, hb) {
|
||||
hb?.reset();
|
||||
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
||||
@@ -1602,7 +1874,9 @@ function applySettingUpdate(key, value) {
|
||||
|
||||
switch (key) {
|
||||
case "timeout": TIMEOUT = value; break;
|
||||
case "maxConcurrent": MAX_CONCURRENT = value; break;
|
||||
// FIX ⑥: keep the -p wait-queue semaphore's limit in sync with the runtime MAX_CONCURRENT
|
||||
// so a /settings change to maxConcurrent actually changes how many claude procs run at once.
|
||||
case "maxConcurrent": MAX_CONCURRENT = value; claudeSemaphore.limit = Math.max(1, value); break;
|
||||
case "sessionTTL": SESSION_TTL = value; break;
|
||||
case "maxPromptChars": MAX_PROMPT_CHARS = value; break;
|
||||
case "cacheTTL": CACHE_TTL = value; break;
|
||||
@@ -1811,7 +2085,7 @@ async function handleChatCompletions(req, res) {
|
||||
try { res.end(); } catch {}
|
||||
return;
|
||||
}
|
||||
return jsonResponse(res, 500, { error: { message: sanitizeError(err.message), type: "proxy_error" } });
|
||||
return respondUpstreamError(res, err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1828,8 +2102,9 @@ async function handleChatCompletions(req, res) {
|
||||
try { res.end(); } catch {}
|
||||
return;
|
||||
}
|
||||
// Sanitize error: strip internal file paths before sending to client
|
||||
jsonResponse(res, 500, { error: { message: sanitizeError(err.message), type: "proxy_error" } });
|
||||
// Sanitize error: strip internal file paths before sending to client.
|
||||
// FIX ⑥: ConcurrencyOverflowError → 429 + Retry-After; all other errors → 500 (unchanged).
|
||||
respondUpstreamError(res, err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1990,6 +2265,42 @@ const server = createServer(async (req, res) => {
|
||||
circuitBreaker: "disabled",
|
||||
sessions: sessionList,
|
||||
recentErrors: recentErrors.slice(-5),
|
||||
// ── FIX ③ spawn-home isolation surface — ADDITIVE (default -p/stream-json path) ──
|
||||
// Lets the operator confirm the latency-fix isolation is active without inspecting logs.
|
||||
// NEVER includes the token. mode: "isolated-scratch-home" | "real-home". home is the
|
||||
// scratch HOME path when isolated (null otherwise). For TUI_MODE the -p path is unused,
|
||||
// so report it as disabled.
|
||||
spawn: (() => {
|
||||
if (TUI_MODE) return { mode: "tui (default -p path unused)", isolated: false, home: null };
|
||||
const shm = getSpawnHomeMode();
|
||||
return {
|
||||
mode: shm.isolated ? "isolated-scratch-home" : "real-home",
|
||||
isolated: shm.isolated,
|
||||
home: shm.isolated ? shm.home : null,
|
||||
reason: shm.reason,
|
||||
};
|
||||
})(),
|
||||
// ── FIX ⑥ -p concurrency wait-queue surface — ADDITIVE ──
|
||||
// inflight/queued are live; queueRejections is cumulative (also in stats.queueRejections).
|
||||
// Lets the operator see backpressure instead of guessing from opaque 500s.
|
||||
concurrency: {
|
||||
maxConcurrent: MAX_CONCURRENT,
|
||||
maxQueue: claudeSemaphore.maxQueue,
|
||||
inflight: claudeSemaphore.inflight,
|
||||
queued: claudeSemaphore.queued,
|
||||
queueRejections: stats.queueRejections,
|
||||
},
|
||||
// ── TUI observability (audit C-5) — ADDITIVE block (ADR 0007 PR-B amendment) ──
|
||||
// /health is a grandfathered B.2 endpoint (ADR 0006). This block is NEW fields only;
|
||||
// every existing field above is byte-identical → behaviour-preserving for existing
|
||||
// consumers per ALIGNMENT.md's grandfather provision. When TUI_MODE is off the block
|
||||
// still appears with enabled:false (cheap, harmless) so the shape is stable.
|
||||
// entrypointMismatches/lastEntrypoint exist so an operator can poll /health to catch a
|
||||
// silent metered-pool drift (the audit's top risk after the 6/15 billing flip).
|
||||
tui: buildTuiHealthBlock(
|
||||
{ enabled: TUI_MODE, entrypointMode: TUI_ENTRYPOINT, maxConcurrent: TUI_MAX_CONCURRENT },
|
||||
tuiStats, tuiSemaphore,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2222,6 +2533,7 @@ function gracefulShutdown(signal) {
|
||||
clearInterval(sessionCleanupInterval);
|
||||
clearInterval(authCheckInterval);
|
||||
clearInterval(cacheCleanupInterval);
|
||||
if (tuiReapInterval) clearInterval(tuiReapInterval);
|
||||
closeDb();
|
||||
|
||||
// 3. Kill all active child processes
|
||||
@@ -2266,7 +2578,7 @@ server.listen(PORT, BIND_ADDRESS, () => {
|
||||
console.log(`Architecture: on-demand spawning (no pool)`);
|
||||
console.log(`Models: ${MODELS.map((m) => m.id).join(", ")}`);
|
||||
console.log(`Claude binary: ${CLAUDE}`);
|
||||
console.log(`Timeout: ${TIMEOUT / 1000}s | Max concurrent: ${MAX_CONCURRENT}`);
|
||||
console.log(`Timeout: ${TIMEOUT / 1000}s | Max concurrent: ${MAX_CONCURRENT} | Queue: ${CLAUDE_MAX_QUEUE} (429 on overflow)`);
|
||||
console.log(`Circuit breaker: disabled`);
|
||||
console.log(`Tools: ${SKIP_PERMISSIONS ? "all (skip-permissions)" : ALLOWED_TOOLS.join(", ")}`);
|
||||
console.log(`Sessions: TTL=${SESSION_TTL / 1000}s`);
|
||||
@@ -2278,9 +2590,21 @@ 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)`);
|
||||
// FIX ③: announce default-path (-p/stream-json) spawn-home isolation mode (never logs the token).
|
||||
if (!TUI_MODE) {
|
||||
const shm = getSpawnHomeMode();
|
||||
if (shm.isolated) {
|
||||
console.log(`Spawn home: isolated-scratch-home (${shm.home}, cwd-neutral, env-token auth) — fast path`);
|
||||
} else {
|
||||
console.log(`Spawn home: real-home (${shm.reason}) — set CLAUDE_CODE_OAUTH_TOKEN for the isolated fast path`);
|
||||
}
|
||||
}
|
||||
if (TUI_MODE) {
|
||||
console.warn(`⚠️ TUI-mode ON — single-user only; do NOT enable on a multi-user OCP (guest prompts would run claude with operator filesystem access). See ADR 0007.`);
|
||||
console.log(` TUI-mode: ON home=${TUI_HOME} cwd=${TUI_CWD} wallclock=${TUI_WALLCLOCK_MS}ms`);
|
||||
const tuiAuth = process.env.CLAUDE_CODE_OAUTH_TOKEN
|
||||
? (TUI_HOME === process.env.HOME ? "env-token (real home — unset OCP_TUI_HOME for credential isolation)" : "env-token (credential-isolated home — no credentials.json)")
|
||||
: "credentials.json (no CLAUDE_CODE_OAUTH_TOKEN — see Troubleshooting #401)";
|
||||
console.log(` TUI-mode: ON home=${TUI_HOME} cwd=${TUI_CWD} auth=${tuiAuth} wallclock=${TUI_WALLCLOCK_MS}ms maxConcurrent=${TUI_MAX_CONCURRENT}`);
|
||||
try {
|
||||
const n = reapStaleTuiSessions();
|
||||
if (n) logEvent("info", "tui_reaped_stale_sessions", { count: n });
|
||||
|
||||
@@ -141,18 +141,26 @@ try {
|
||||
}
|
||||
|
||||
// Check claude auth (quick test)
|
||||
try {
|
||||
const out = execSync('claude -p --output-format text --no-session-persistence -- "ping"', {
|
||||
encoding: "utf-8",
|
||||
timeout: 30000,
|
||||
env: { ...process.env, CLAUDECODE: undefined, ANTHROPIC_API_KEY: undefined, ANTHROPIC_BASE_URL: undefined, ANTHROPIC_AUTH_TOKEN: undefined },
|
||||
}).trim();
|
||||
if (out.length > 0) {
|
||||
log(`Claude CLI authenticated (test response: "${out.slice(0, 40)}...")`);
|
||||
// NOTE: This probe uses `claude -p` (sdk-cli spawn). After the 2026-06-15 Anthropic billing
|
||||
// split, every `claude -p` call draws from the Agent SDK credit pool rather than the
|
||||
// Pro/Max subscription. Re-running setup after 6/15 will consume one metered credit.
|
||||
// Set OCP_SKIP_AUTH_TEST=1 to skip this probe (auth is still validated at first real request).
|
||||
if (process.env.OCP_SKIP_AUTH_TEST === "1") {
|
||||
warn("OCP_SKIP_AUTH_TEST=1 — skipping claude auth probe (will be validated at first request).");
|
||||
} else {
|
||||
try {
|
||||
const out = execSync('claude -p --output-format text --no-session-persistence -- "ping"', {
|
||||
encoding: "utf-8",
|
||||
timeout: 30000,
|
||||
env: { ...process.env, CLAUDECODE: undefined, ANTHROPIC_API_KEY: undefined, ANTHROPIC_BASE_URL: undefined, ANTHROPIC_AUTH_TOKEN: undefined },
|
||||
}).trim();
|
||||
if (out.length > 0) {
|
||||
log(`Claude CLI authenticated (test response: "${out.slice(0, 40)}...")`);
|
||||
}
|
||||
} catch (e) {
|
||||
warn(`Claude CLI auth test failed: ${e.message.slice(0, 100)}`);
|
||||
warn("Make sure you're logged in: claude login");
|
||||
}
|
||||
} catch (e) {
|
||||
warn(`Claude CLI auth test failed: ${e.message.slice(0, 100)}`);
|
||||
warn("Make sure you're logged in: claude login");
|
||||
}
|
||||
|
||||
// Check openclaw config (optional — OCP runs standalone without OpenClaw)
|
||||
|
||||
+640
-3
@@ -1340,7 +1340,7 @@ test("streamStringAsSSE empty content: role + stop + [DONE] only", () => {
|
||||
});
|
||||
|
||||
// ── Suite: TUI transcript reader ────────────────────────────────────────
|
||||
import { encodeCwd, transcriptPath, findTranscriptPath, parseTranscriptLines, isTerminalLine, extractLatestAssistantText, verifyEntrypoint } from "./lib/tui/transcript.mjs";
|
||||
import { encodeCwd, transcriptPath, findTranscriptPath, parseTranscriptLines, isTerminalLine, extractLatestAssistantText, verifyEntrypoint, detectTuiUpstreamError } from "./lib/tui/transcript.mjs";
|
||||
import { readFileSync as tuiReadFileSync, mkdtempSync as tuiMkdtemp0, mkdirSync as tuiMkdir0, writeFileSync as tuiWrite0 } from "node:fs";
|
||||
import { tmpdir as tuiTmp0 } from "node:os";
|
||||
|
||||
@@ -1387,6 +1387,15 @@ test("isTerminalLine false on stop_reason tool_use (flat) — claude continues a
|
||||
test("isTerminalLine false on ordinary assistant text line", () => {
|
||||
assert.equal(isTerminalLine({ type: "assistant", message: { content: [{ type: "text", text: "hi" }] } }), false);
|
||||
});
|
||||
// issue #130 cloud/server-side: claude builds (e.g. 2.1.114) that DON'T emit
|
||||
// turn_duration mark turn-end via assistant message.stop_reason — must be terminal.
|
||||
test("isTerminalLine true on assistant stop_reason end_turn (version-robust, e.g. 2.1.114)", () => {
|
||||
assert.equal(isTerminalLine({ type: "assistant", message: { stop_reason: "end_turn", content: [{ type: "text", text: "ok" }] } }), true);
|
||||
});
|
||||
test("isTerminalLine true on assistant stop_reason stop_sequence / max_tokens", () => {
|
||||
assert.equal(isTerminalLine({ type: "assistant", message: { stop_reason: "stop_sequence" } }), true);
|
||||
assert.equal(isTerminalLine({ type: "assistant", message: { stop_reason: "max_tokens" } }), true);
|
||||
});
|
||||
test("extractLatestAssistantText concatenates text blocks of LAST assistant entry", () => {
|
||||
const evs = [
|
||||
{ type: "assistant", message: { content: [{ type: "text", text: "first" }] } },
|
||||
@@ -1427,6 +1436,173 @@ test("real complete fixture: verifyEntrypoint returns 'cli'", () => {
|
||||
assert.equal(verifyEntrypoint(evs), "cli");
|
||||
});
|
||||
|
||||
// ── C-3 (#133): verifyEntrypoint is version-robust ───────────────────────
|
||||
// Some claude builds do NOT emit a turn_duration line; entrypoint lives on
|
||||
// ordinary lines on BOTH emitting and non-emitting builds. Reading ONLY
|
||||
// turn_duration made the server.mjs tui_entrypoint_mismatch assertion get null
|
||||
// every turn on non-emitting builds. verifyEntrypoint must fall back to ANY line.
|
||||
console.log("\nTUI transcript — verifyEntrypoint version-robustness (C-3, #133):");
|
||||
|
||||
test("verifyEntrypoint PREFERS the turn_duration line's entrypoint", () => {
|
||||
// turn_duration says "cli"; an earlier ordinary line says "sdk-cli" — the
|
||||
// authoritative turn_duration value must win, not last-writer-wins on the fallback.
|
||||
const evs = [
|
||||
{ type: "assistant", entrypoint: "sdk-cli", message: { content: [{ type: "text", text: "x" }] } },
|
||||
{ type: "system", subtype: "turn_duration", entrypoint: "cli" },
|
||||
];
|
||||
assert.equal(verifyEntrypoint(evs), "cli");
|
||||
});
|
||||
test("verifyEntrypoint falls back to entrypoint on an ordinary assistant line when no turn_duration", () => {
|
||||
const evs = [
|
||||
{ type: "user", entrypoint: "cli", message: { content: "hi" } },
|
||||
{ type: "assistant", entrypoint: "cli", message: { stop_reason: "end_turn", content: [{ type: "text", text: "ok" }] } },
|
||||
];
|
||||
assert.equal(verifyEntrypoint(evs), "cli");
|
||||
});
|
||||
test("verifyEntrypoint returns null when NO line carries an entrypoint", () => {
|
||||
const evs = [
|
||||
{ type: "assistant", message: { stop_reason: "end_turn", content: [{ type: "text", text: "ok" }] } },
|
||||
];
|
||||
assert.equal(verifyEntrypoint(evs), null);
|
||||
});
|
||||
test("real no-turn_duration fixture: verifyEntrypoint still resolves 'cli' (was null before C-3)", () => {
|
||||
const evs = parseTranscriptLines(tuiReadFileSync("./lib/tui/fixtures/no-turn-duration.jsonl", "utf8"));
|
||||
// Sanity: the fixture genuinely lacks a turn_duration line (so this exercises the fallback).
|
||||
assert.ok(!evs.some((e) => e && e.type === "system" && e.subtype === "turn_duration"), "fixture must NOT emit turn_duration");
|
||||
assert.equal(verifyEntrypoint(evs), "cli");
|
||||
});
|
||||
|
||||
// ── C-1 (#133): honest AUTH-FAILURE banner detection ─────────────────────
|
||||
// The interactive claude CLI renders in-session errors as ordinary assistant text.
|
||||
// C-1 catches the R-1 case: expired/invalid creds, where EVERY turn returns the same
|
||||
// one-line auth-failure banner and OCP would cache it as a real answer. The detector
|
||||
// is deliberately NARROW/conservative: a false-positive (killing a real long answer
|
||||
// that merely DISCUSSES an API error) costs the user a missing answer + a double-burn
|
||||
// retry, which is worse than the rare false-negative (caching one transient error for
|
||||
// the 5-min TTL). Signal = ALL of: SHORT whole-message (≤100; live samples 69/73) AND
|
||||
// "API Error: 4xx" AND an auth keyword (authenticat | /login | credential) AND NO
|
||||
// backtick/quote char. When unsure → PASS. The earlier generalised rule
|
||||
// (^<short-prefix>?API Error:\d{3}.*$) was TOO BROAD: its unbounded .* tail killed
|
||||
// legit long answers; this block encodes the full narrowed matrix.
|
||||
console.log("\nTUI transcript — auth-failure banner detection (C-1, #133):");
|
||||
|
||||
// ---- Required matrix: MUST detect (kill) ----
|
||||
test("C-1 KILL: live /login 401 auth banner", () => {
|
||||
const banner = "Please run /login · API Error: 401 Invalid authentication credentials";
|
||||
assert.equal(detectTuiUpstreamError(banner), banner);
|
||||
});
|
||||
test("C-1 KILL: live 'Failed to authenticate.' 401 banner variant", () => {
|
||||
// Second real PI231 banner: a different short auth-failure prefix before the same
|
||||
// "API Error: 4xx" core. Still short, still 4xx, still has 'authenticate'/'credentials'.
|
||||
const banner = "Failed to authenticate. API Error: 401 Invalid authentication credentials";
|
||||
assert.equal(detectTuiUpstreamError(banner), banner);
|
||||
});
|
||||
|
||||
// ---- Required matrix: MUST NOT kill (pass) ----
|
||||
test("C-1 PASS: long answer discussing a 500 (not 4xx, too long)", () => {
|
||||
// The exact false-positive the over-broad .* rule produced. 166 chars; 5xx.
|
||||
const legit = "API Error: 500 happened because the server was overloaded. To fix this, retry with exponential backoff and verify your rate limits before resending the request again.";
|
||||
assert.equal(detectTuiUpstreamError(legit), null);
|
||||
});
|
||||
test("C-1 PASS: long answer with 'API Error: 401 details' (too long, no auth keyword)", () => {
|
||||
// 142 chars; the literal word 'authenticate'/'credential'/'/login' never appears, and
|
||||
// it is far over the length cap — rejected on length AND keyword.
|
||||
const legit = "Failed to parse the config. Here are the API Error: 401 details you asked about: the token expired and must be refreshed before the next call.";
|
||||
assert.equal(detectTuiUpstreamError(legit), null);
|
||||
});
|
||||
test("C-1 PASS: 'To debug a 401 … API Error: 401 Unauthorized' (no auth keyword)", () => {
|
||||
// 91 chars (short!) and 4xx, but 'Unauthorized' is authoriz-, not authenticat-, and
|
||||
// there is no /login or credential — the auth-keyword signal rejects it.
|
||||
const legit = "To debug a 401: the server returns API Error: 401 Unauthorized, then you refresh the token.";
|
||||
assert.equal(detectTuiUpstreamError(legit), null);
|
||||
});
|
||||
test("C-1 PASS: handler answer logging 'API Error: 503' (not 4xx)", () => {
|
||||
const legit = "Here is the handler you asked for. It logs the string API Error: 503 on failure and retries.";
|
||||
assert.equal(detectTuiUpstreamError(legit), null);
|
||||
});
|
||||
test("C-1 PASS: short instructional answer quoting `API Error: 401` + /login (has backtick)", () => {
|
||||
// 75 chars: short, 4xx, has '/login' — passes signals 1-3. Rejected ONLY by the
|
||||
// backtick/quote constraint: it QUOTES the error in code formatting, it is not the banner.
|
||||
const legit = "You'll see `API Error: 401` when your token expires — run /login to fix it.";
|
||||
assert.equal(detectTuiUpstreamError(legit), null);
|
||||
});
|
||||
test("C-1 PASS: bare HTTP-status sentence (no 'API Error:' core)", () => {
|
||||
assert.equal(detectTuiUpstreamError("HTTP 401 means unauthorized."), null);
|
||||
});
|
||||
test("C-1 PASS: plain unrelated answer", () => {
|
||||
assert.equal(detectTuiUpstreamError("The capital of France is Paris."), null);
|
||||
});
|
||||
|
||||
// ---- Supporting / regression coverage ----
|
||||
test("C-1 PASS: transient 5xx banner is NOT detected (narrowed to 4xx auth only)", () => {
|
||||
// The old rule flagged any 3-digit code; the narrowed detector is 4xx-only by design
|
||||
// (5xx is transient/server-side, not the R-1 auth case). Accepted false-negative.
|
||||
assert.equal(detectTuiUpstreamError("API Error: 500 Internal Server Error"), null);
|
||||
});
|
||||
test("C-1 PASS: bare 4xx with no auth keyword is NOT detected", () => {
|
||||
// 'API Error: 403 Forbidden' alone — 4xx and short, but no authenticat/login/credential.
|
||||
assert.equal(detectTuiUpstreamError("API Error: 403 Forbidden"), null);
|
||||
});
|
||||
test("detectTuiUpstreamError trims surrounding whitespace before matching", () => {
|
||||
const out = detectTuiUpstreamError("\n\n Please run /login · API Error: 401 credential boom \n");
|
||||
assert.equal(out, "Please run /login · API Error: 401 credential boom");
|
||||
});
|
||||
test("detectTuiUpstreamError is case-insensitive on the banner keywords", () => {
|
||||
// lower-cased: /login + api error: 401 + 'credential' keyword, short, no code char.
|
||||
assert.ok(detectTuiUpstreamError("please run /login · api error: 401 bad credential") !== null);
|
||||
});
|
||||
test("detectTuiUpstreamError does NOT match prose that mentions an API error mid-paragraph (#133 regression guard)", () => {
|
||||
// A long, legit answer that merely discusses an API error — rejected on length alone.
|
||||
const para = "When integrating with the upstream service you may occasionally hit an API Error: 401 response if the bearer token has lapsed; the recommended remediation is to re-run the login flow and retry the request with a fresh credential, after which the 401 should clear.";
|
||||
assert.equal(detectTuiUpstreamError(para), null);
|
||||
});
|
||||
test("detectTuiUpstreamError does NOT match a long plain-text auth answer with NO code chars (length cap is load-bearing)", () => {
|
||||
// 226 chars, no backtick/quote, has 4xx + /login + credential + authenticate — passes
|
||||
// signals 2-4. ONLY the length cap rejects it. Guards against dropping the cap.
|
||||
const para = "If you call the endpoint without a bearer token the API Error: 401 response tells you the credential is missing; just authenticate again with /login and the request will succeed on the next attempt without any further changes.";
|
||||
assert.equal(detectTuiUpstreamError(para), null);
|
||||
});
|
||||
test("detectTuiUpstreamError returns null on empty / whitespace / non-string", () => {
|
||||
assert.equal(detectTuiUpstreamError(""), null);
|
||||
assert.equal(detectTuiUpstreamError(" \n "), null);
|
||||
assert.equal(detectTuiUpstreamError(null), null);
|
||||
assert.equal(detectTuiUpstreamError(undefined), null);
|
||||
assert.equal(detectTuiUpstreamError(42), null);
|
||||
});
|
||||
test("detectTuiUpstreamError respects CLAUDE_TUI_ERROR_PATTERNS override (custom banner)", () => {
|
||||
// Override with a single custom pattern; the default 401 banner no longer matches,
|
||||
// but the custom one does (anchored whole-text).
|
||||
assert.equal(detectTuiUpstreamError("Please run /login · API Error: 401 x", "Session expired, please re-auth"), null);
|
||||
assert.equal(detectTuiUpstreamError("Session expired, please re-auth", "Session expired, please re-auth"), "Session expired, please re-auth");
|
||||
});
|
||||
test("detectTuiUpstreamError with an empty override disables detection (escape hatch)", () => {
|
||||
assert.equal(detectTuiUpstreamError("API Error: 500 boom", ""), null);
|
||||
assert.equal(detectTuiUpstreamError("API Error: 500 boom", " "), null);
|
||||
});
|
||||
test("detectTuiUpstreamError override accepts '||'-separated patterns", () => {
|
||||
const raw = "First banner||Second banner";
|
||||
assert.equal(detectTuiUpstreamError("First banner", raw), "First banner");
|
||||
assert.equal(detectTuiUpstreamError("Second banner", raw), "Second banner");
|
||||
assert.equal(detectTuiUpstreamError("Third", raw), null);
|
||||
});
|
||||
test("real error fixture: latest assistant text IS the banner and detectTuiUpstreamError flags it", () => {
|
||||
const evs = parseTranscriptLines(tuiReadFileSync("./lib/tui/fixtures/error-401.jsonl", "utf8"));
|
||||
const text = extractLatestAssistantText(evs);
|
||||
assert.equal(text, "Please run /login · API Error: 401 Invalid authentication credentials");
|
||||
assert.ok(detectTuiUpstreamError(text) !== null, "error fixture's final turn must be flagged as an upstream error");
|
||||
});
|
||||
test("real error fixture (Failed-to-authenticate variant): final turn is flagged (#133 runtime gap)", () => {
|
||||
const evs = parseTranscriptLines(tuiReadFileSync("./lib/tui/fixtures/error-401-failauth.jsonl", "utf8"));
|
||||
const text = extractLatestAssistantText(evs);
|
||||
assert.equal(text, "Failed to authenticate. API Error: 401 Invalid authentication credentials");
|
||||
assert.ok(detectTuiUpstreamError(text) !== null, "Failed-to-authenticate banner must be flagged as an upstream error");
|
||||
});
|
||||
test("real complete fixture: final answer is NOT flagged as an upstream error", () => {
|
||||
const evs = parseTranscriptLines(tuiReadFileSync("./lib/tui/fixtures/complete-haiku.jsonl", "utf8"));
|
||||
const text = extractLatestAssistantText(evs);
|
||||
assert.equal(detectTuiUpstreamError(text), null);
|
||||
});
|
||||
|
||||
// ── TUI transcript — polling reader (async) ──────────────────────────────
|
||||
import { readTuiTranscript } from "./lib/tui/transcript.mjs";
|
||||
import { mkdtempSync as tuiMkdtemp, writeFileSync as tuiWriteFile } from "node:fs";
|
||||
@@ -1446,12 +1622,30 @@ await asyncTest("readTuiTranscript returns assistant text when terminal marker p
|
||||
assert.equal(out.entrypoint, "cli");
|
||||
});
|
||||
|
||||
await asyncTest("readTuiTranscript honours wall-clock cap and returns partial text", async () => {
|
||||
// C-2 (#133): the terminal-marker path must signal a COMPLETE turn.
|
||||
await asyncTest("readTuiTranscript signals truncated:false when a terminal marker is hit (complete turn)", async () => {
|
||||
const dir = tuiMkdtemp(`${tuiTmpdir()}/tui-`);
|
||||
const p = `${dir}/s.jsonl`;
|
||||
tuiWriteFile(p, [
|
||||
JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "done" }] } }),
|
||||
JSON.stringify({ type: "system", subtype: "turn_duration", durationMs: 1200, entrypoint: "cli" }),
|
||||
].join("\n") + "\n");
|
||||
const out = await readTuiTranscript({ transcriptPath: p, wallclockMs: 2000, pollMs: 50 });
|
||||
assert.equal(out.truncated, false);
|
||||
});
|
||||
|
||||
// C-2 (#133): cap-with-partial-text must be DISTINGUISHABLE from a complete turn.
|
||||
// Previously both returned {text, entrypoint} identically and the partial was cached
|
||||
// + returned as finish_reason:stop. The cap path now returns truncated:true so the
|
||||
// caller (callClaudeTui) can throw instead of serving a cut-off answer.
|
||||
await asyncTest("readTuiTranscript honours wall-clock cap and flags partial text truncated:true", async () => {
|
||||
const dir = tuiMkdtemp(`${tuiTmpdir()}/tui-`);
|
||||
const p = `${dir}/s.jsonl`;
|
||||
// No terminal marker → reader will spin to the cap then return the partial.
|
||||
tuiWriteFile(p, JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "partial" }] } }) + "\n");
|
||||
const out = await readTuiTranscript({ transcriptPath: p, wallclockMs: 300, pollMs: 50 });
|
||||
assert.equal(out.text, "partial");
|
||||
assert.equal(out.truncated, true);
|
||||
});
|
||||
|
||||
await asyncTest("readTuiTranscript against real fixture: entrypoint is 'cli'", async () => {
|
||||
@@ -1469,7 +1663,7 @@ await asyncTest("readTuiTranscript throws when no text and cap elapses", async (
|
||||
});
|
||||
|
||||
// ── TUI session reaper ───────────────────────────────────────────────────
|
||||
import { reapStaleTuiSessions, SESSION_PREFIX } from "./lib/tui/session.mjs";
|
||||
import { reapStaleTuiSessions, SESSION_PREFIX, buildTuiCmd } from "./lib/tui/session.mjs";
|
||||
|
||||
console.log("\nTUI session reaper:");
|
||||
|
||||
@@ -1477,6 +1671,116 @@ test("SESSION_PREFIX is ocp-tui-", () => {
|
||||
assert.equal(SESSION_PREFIX, "ocp-tui-");
|
||||
});
|
||||
|
||||
console.log("\nTUI command construction (proxy-purity / #4):");
|
||||
|
||||
test("buildTuiCmd suppresses host CLAUDE.md + auto-memory (proxy purity, #4)", () => {
|
||||
const cmd = buildTuiCmd("/usr/bin/claude", "claude-haiku", "sid-1", "/home/u", "cli");
|
||||
// OCP is a proxy: the host's CLAUDE.md / auto-memory must never leak into the proxied turn.
|
||||
assert.ok(/(^| )CLAUDE_CODE_DISABLE_CLAUDE_MDS=1( |$)/.test(cmd), "must disable CLAUDE.md injection");
|
||||
assert.ok(/(^| )CLAUDE_CODE_DISABLE_AUTO_MEMORY=1( |$)/.test(cmd), "must disable auto-memory injection");
|
||||
});
|
||||
|
||||
test("buildTuiCmd keeps version pin + entrypoint label + MCP wall", () => {
|
||||
const cli = buildTuiCmd("/usr/bin/claude", "m", "sid-2", "/home/u", "cli");
|
||||
assert.ok(cli.includes("DISABLE_AUTOUPDATER=1"), "version pin retained");
|
||||
assert.ok(cli.includes("CLAUDE_CODE_ENTRYPOINT=cli"), "cli mode labels the subscription pool");
|
||||
assert.ok(cli.includes("--strict-mcp-config") && cli.includes('mcp__*'), "MCP wall retained");
|
||||
// 'auto' mode must NOT pin the entrypoint (claude self-classifies via TTY).
|
||||
const auto = buildTuiCmd("/usr/bin/claude", "m", "sid-3", "/home/u", "auto");
|
||||
assert.ok(!/CLAUDE_CODE_ENTRYPOINT=/.test(auto), "auto mode leaves entrypoint unset");
|
||||
assert.ok(/-u CLAUDE_CODE_ENTRYPOINT/.test(auto), "auto mode unsets any inherited entrypoint");
|
||||
});
|
||||
|
||||
// CLAUDE_CODE_OAUTH_TOKEN passthrough (PI231 401 incident): tmux doesn't forward the parent
|
||||
// env to the pane, so the token must be set explicitly on the pane command or the TUI claude
|
||||
// falls back to credentials.json (whose refresh token gets corrupted by the spawn/kill cycle).
|
||||
test("buildTuiCmd passes CLAUDE_CODE_OAUTH_TOKEN when the env is set (shq-escaped)", () => {
|
||||
const save = process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||
try {
|
||||
process.env.CLAUDE_CODE_OAUTH_TOKEN = "sk-ant-oat01-abc123";
|
||||
const cmd = buildTuiCmd("/usr/bin/claude", "m", "sid-tok", "/home/u", "cli");
|
||||
// shq wraps in single quotes; a plain token renders as 'token'.
|
||||
assert.ok(cmd.includes("CLAUDE_CODE_OAUTH_TOKEN='sk-ant-oat01-abc123'"),
|
||||
"token must be set on the pane command, shq-escaped");
|
||||
} finally {
|
||||
if (save === undefined) delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||
else process.env.CLAUDE_CODE_OAUTH_TOKEN = save;
|
||||
}
|
||||
});
|
||||
|
||||
test("buildTuiCmd does NOT add CLAUDE_CODE_OAUTH_TOKEN when the env is unset", () => {
|
||||
const save = process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||
try {
|
||||
delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||
const cmd = buildTuiCmd("/usr/bin/claude", "m", "sid-notok", "/home/u", "cli");
|
||||
assert.ok(!/CLAUDE_CODE_OAUTH_TOKEN/.test(cmd),
|
||||
"no token added when env unset (credentials.json-only hosts unaffected)");
|
||||
} finally {
|
||||
if (save === undefined) delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||
else process.env.CLAUDE_CODE_OAUTH_TOKEN = save;
|
||||
}
|
||||
});
|
||||
|
||||
test("buildTuiCmd shq-escapes a token containing shell metacharacters (no injection)", () => {
|
||||
const save = process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||
try {
|
||||
// A token with a single quote must be escaped via the '\'' idiom so it can't break out
|
||||
// of the shell string tmux runs via sh -c.
|
||||
process.env.CLAUDE_CODE_OAUTH_TOKEN = "tok'; rm -rf /;'";
|
||||
const cmd = buildTuiCmd("/usr/bin/claude", "m", "sid-inj", "/home/u", "cli");
|
||||
assert.ok(cmd.includes(`CLAUDE_CODE_OAUTH_TOKEN='tok'\\''; rm -rf /;'\\'''`),
|
||||
"single quote must be shq-escaped, not left bare");
|
||||
assert.ok(!/CLAUDE_CODE_OAUTH_TOKEN=tok'; rm/.test(cmd), "raw unescaped token must NOT appear");
|
||||
} finally {
|
||||
if (save === undefined) delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||
else process.env.CLAUDE_CODE_OAUTH_TOKEN = save;
|
||||
}
|
||||
});
|
||||
|
||||
test("buildTuiCmd OCP_TUI_FULL_TOOLS=1 grants -p-equivalent tool surface (single-user opt-in)", () => {
|
||||
const save = { ...process.env };
|
||||
const restore = () => {
|
||||
for (const k of ["OCP_TUI_FULL_TOOLS", "CLAUDE_SKIP_PERMISSIONS", "CLAUDE_MCP_CONFIG", "CLAUDE_ALLOWED_TOOLS"]) {
|
||||
if (k in save) process.env[k] = save[k]; else delete process.env[k];
|
||||
}
|
||||
};
|
||||
try {
|
||||
// default (gate off) keeps the MCP wall, no --allowedTools
|
||||
delete process.env.OCP_TUI_FULL_TOOLS;
|
||||
const off = buildTuiCmd("/usr/bin/claude", "m", "s", "/home/u", "cli");
|
||||
assert.ok(off.includes("--strict-mcp-config") && !off.includes("--allowedTools"), "gate off = MCP wall");
|
||||
|
||||
// gate on: --allowedTools (default set incl Bash), MCP wall dropped
|
||||
process.env.OCP_TUI_FULL_TOOLS = "1";
|
||||
delete process.env.CLAUDE_SKIP_PERMISSIONS;
|
||||
delete process.env.CLAUDE_MCP_CONFIG;
|
||||
delete process.env.CLAUDE_ALLOWED_TOOLS;
|
||||
const full = buildTuiCmd("/usr/bin/claude", "m", "s", "/home/u", "cli");
|
||||
assert.ok(full.includes("--allowedTools") && full.includes("Bash"), "full-tools grants --allowedTools incl Bash");
|
||||
assert.ok(!full.includes("--strict-mcp-config") && !/--disallowedTools/.test(full), "full-tools drops the MCP wall");
|
||||
|
||||
// skip-permissions supersedes --allowedTools
|
||||
process.env.CLAUDE_SKIP_PERMISSIONS = "true";
|
||||
const skip = buildTuiCmd("/usr/bin/claude", "m", "s", "/home/u", "cli");
|
||||
assert.ok(skip.includes("--dangerously-skip-permissions") && !skip.includes("--allowedTools"), "skip-permissions honored");
|
||||
|
||||
// mcp-config threaded through
|
||||
delete process.env.CLAUDE_SKIP_PERMISSIONS;
|
||||
process.env.CLAUDE_MCP_CONFIG = "/tmp/mcp.json";
|
||||
const mcp = buildTuiCmd("/usr/bin/claude", "m", "s", "/home/u", "cli");
|
||||
assert.ok(/--mcp-config '\/tmp\/mcp.json'/.test(mcp), "mcp-config passed through (shq'd)");
|
||||
|
||||
// operator-supplied scoped tool specifiers must be shell-quoted (no injection via ()*~)
|
||||
delete process.env.CLAUDE_MCP_CONFIG;
|
||||
process.env.CLAUDE_ALLOWED_TOOLS = "Bash(npm run test:*),Read";
|
||||
const scoped = buildTuiCmd("/usr/bin/claude", "m", "s", "/home/u", "cli");
|
||||
assert.ok(scoped.includes("'Bash(npm run test:*)'"), "scoped tool tokens are shq'd in the shell string");
|
||||
assert.ok(!/--allowedTools Bash\(npm/.test(scoped), "scoped token must NOT appear unquoted");
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("reaper kills ONLY ocp-tui- sessions, never olp-tui-", () => {
|
||||
const killed = [];
|
||||
const fakeTmux = (args) => {
|
||||
@@ -1508,6 +1812,41 @@ test("reaper returns 0 for empty session list", () => {
|
||||
assert.equal(killed.length, 0);
|
||||
});
|
||||
|
||||
// Defunct-zombie reaping (PI231 incident): the pane's claude is a child of the tmux server,
|
||||
// so only kill-server actually reaps it. We kill-server ONLY when no foreign session remains.
|
||||
console.log("\nTUI defunct-zombie reaping (kill-server):");
|
||||
|
||||
test("reaper kill-servers when the server is ours-only (flush defunct claude zombies)", () => {
|
||||
const calls = [];
|
||||
const fakeTmux = (args) => {
|
||||
calls.push(args.join(" "));
|
||||
if (args[0] === "list-sessions") return { status: 0, stdout: "ocp-tui-aaaa\nocp-tui-bbbb\n" };
|
||||
return { status: 0, stdout: "" };
|
||||
};
|
||||
const n = reapStaleTuiSessions({ tmux: fakeTmux });
|
||||
assert.equal(n, 2, "killed both of our sessions");
|
||||
assert.ok(calls.includes("kill-server"), "kill-server fired — reaps the defunct backlog");
|
||||
});
|
||||
|
||||
test("reaper does NOT kill-server when a foreign (non-ocp) session remains (coexistence)", () => {
|
||||
const calls = [];
|
||||
const fakeTmux = (args) => {
|
||||
calls.push(args.join(" "));
|
||||
if (args[0] === "list-sessions") return { status: 0, stdout: "ocp-tui-aaaa\nolp-tui-bbbb\n" };
|
||||
return { status: 0, stdout: "" };
|
||||
};
|
||||
const n = reapStaleTuiSessions({ tmux: fakeTmux });
|
||||
assert.equal(n, 1, "killed only our own session");
|
||||
assert.ok(!calls.includes("kill-server"), "kill-server MUST NOT fire — would disrupt olp-tui-*");
|
||||
});
|
||||
|
||||
test("reaper does NOT kill-server when there is no server (status !== 0)", () => {
|
||||
const calls = [];
|
||||
const fakeTmux = (args) => { calls.push(args.join(" ")); return { status: 1, stdout: "" }; };
|
||||
reapStaleTuiSessions({ tmux: fakeTmux });
|
||||
assert.ok(!calls.includes("kill-server"), "no server → no kill-server (early return)");
|
||||
});
|
||||
|
||||
// ── TUI home preparation (scratch vs real) ───────────────────────────────
|
||||
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";
|
||||
@@ -1543,6 +1882,53 @@ test("prepareTuiHome real mode (tuiHome===realHome): no symlink, just trusts cwd
|
||||
assert.equal(j.projects[cwd].hasTrustDialogAccepted, true); // cwd trusted in real config
|
||||
});
|
||||
|
||||
// ── PR-D: env-token-only credential-isolated home (PI231 401 root fix) ──────
|
||||
// Interactive claude PREFERS ~/.claude/.credentials.json over CLAUDE_CODE_OAUTH_TOKEN, so a
|
||||
// stale/corrupt credentials.json SHADOWS the env token (proven live on PI231 — env token +
|
||||
// broken creds = 401; env token + creds moved aside = works). The fix runs the TUI claude in
|
||||
// a home with NO credentials.json so the env token is authoritative (and no refresh ever
|
||||
// happens → the single-use token can't be corrupted by the spawn+kill cycle).
|
||||
test("prepareTuiHome env-token mode: NO credentials.json (no symlink, no copy), .claude.json seeded", () => {
|
||||
const realHome = hMkdtemp(`${hTmp()}/realT-`);
|
||||
hMkdir(`${realHome}/.claude`, { recursive: true });
|
||||
hWrite(`${realHome}/.claude/.credentials.json`, '{"token":"real-oauth"}'); // real creds DO exist…
|
||||
hWrite(`${realHome}/.claude.json`, JSON.stringify({ theme: "dark", oauthAccount: { uuid: "secret" }, projects: { "/old/secret": { hasTrustDialogAccepted: true } } }));
|
||||
const tuiHome = hMkdtemp(`${hTmp()}/scratchT-`);
|
||||
const cwd = `${tuiHome}/work`;
|
||||
prepareTuiHome(realHome, tuiHome, cwd, { envTokenMode: true });
|
||||
// …but the scratch home has NO credentials file at all — neither symlink nor copy.
|
||||
assert.ok(!hExists(`${tuiHome}/.claude/.credentials.json`), "env-token home must have NO .credentials.json (the whole point — no shadowing, no refresh)");
|
||||
// .claude.json IS seeded: onboarding complete + ONLY the scratch cwd trusted (no dialog hang).
|
||||
const seed = JSON.parse(hRead(`${tuiHome}/.claude.json`, "utf8"));
|
||||
assert.equal(seed.hasCompletedOnboarding, true, "onboarding pre-completed → no onboarding dialog");
|
||||
assert.equal(seed.projects[cwd].hasTrustDialogAccepted, true, "scratch cwd pre-trusted → no trust dialog");
|
||||
// Minimal config: the credential-isolated home does NOT inherit the operator's account state.
|
||||
assert.equal(seed.theme, undefined, "env-token home is minimal — real config not copied in");
|
||||
assert.equal(seed.oauthAccount, undefined, "real account state not carried into the isolated home");
|
||||
assert.equal(seed.projects["/old/secret"], undefined, "operator project history not carried in");
|
||||
assert.ok(hExists(`${tuiHome}/.claude/projects`), "own projects/ dir for transcripts under the same home");
|
||||
});
|
||||
|
||||
console.log("\nresolveTuiHome (env-token credential isolation, PR-D):");
|
||||
import { resolveTuiHome, DEFAULT_TUI_SCRATCH_HOME } from "./lib/tui/session.mjs";
|
||||
|
||||
test("resolveTuiHome: env token set + OCP_TUI_HOME unset → credential-free scratch home", () => {
|
||||
const h = resolveTuiHome({ realHome: "/home/u", configuredHome: undefined, envTokenSet: true });
|
||||
assert.equal(h, DEFAULT_TUI_SCRATCH_HOME("/home/u"));
|
||||
assert.equal(h, "/home/u/.ocp-tui/home");
|
||||
assert.notEqual(h, "/home/u", "must NOT be the real home — real home has the shadowing credentials.json");
|
||||
});
|
||||
|
||||
test("resolveTuiHome: env token UNSET → real home (legacy credentials.json path, unchanged)", () => {
|
||||
const h = resolveTuiHome({ realHome: "/home/u", configuredHome: undefined, envTokenSet: false });
|
||||
assert.equal(h, "/home/u", "no env token → real home, byte-for-byte the pre-fix behaviour");
|
||||
});
|
||||
|
||||
test("resolveTuiHome: explicit OCP_TUI_HOME wins regardless of env token (back-compat)", () => {
|
||||
assert.equal(resolveTuiHome({ realHome: "/home/u", configuredHome: "/custom/home", envTokenSet: true }), "/custom/home");
|
||||
assert.equal(resolveTuiHome({ realHome: "/home/u", configuredHome: "/custom/home", envTokenSet: false }), "/custom/home");
|
||||
});
|
||||
|
||||
// ── resolveTuiEntrypointEnv ───────────────────────────────────────────────
|
||||
import { resolveTuiEntrypointEnv } from "./lib/tui/session.mjs";
|
||||
|
||||
@@ -1593,6 +1979,186 @@ test("default mode (no second arg) behaves like 'cli'", () => {
|
||||
assert.equal(env.CLAUDE_CODE_ENTRYPOINT, "cli");
|
||||
});
|
||||
|
||||
// ── TUI concurrency limiter + drift observability (PR-B: audit C-4 / C-5) ──
|
||||
import { TuiSemaphore, recordTuiEntrypoint, buildTuiHealthBlock } from "./lib/tui/semaphore.mjs";
|
||||
|
||||
console.log("\nTUI concurrency limiter (C-4):");
|
||||
|
||||
const deferred = () => { let resolve, reject; const p = new Promise((res, rej) => { resolve = res; reject = rej; }); return { p, resolve, reject }; };
|
||||
|
||||
await asyncTest("limit=1 serializes two overlapping calls (second waits for the first)", async () => {
|
||||
const sem = new TuiSemaphore(1);
|
||||
const order = [];
|
||||
const g1 = deferred();
|
||||
// First task acquires the only slot and blocks on g1.
|
||||
const t1 = sem.run(async () => { order.push("t1-start"); await g1.p; order.push("t1-end"); });
|
||||
await new Promise((r) => setImmediate(r)); // let t1 acquire
|
||||
assert.equal(sem.inflight, 1, "t1 holds the only slot");
|
||||
// Second task must QUEUE — it has not started yet.
|
||||
const t2 = sem.run(async () => { order.push("t2-start"); });
|
||||
await new Promise((r) => setImmediate(r));
|
||||
assert.equal(sem.queued, 1, "t2 is queued, not running");
|
||||
assert.deepEqual(order, ["t1-start"], "t2 has not started while t1 holds the slot");
|
||||
// Release t1 → t2 runs.
|
||||
g1.resolve();
|
||||
await t1; await t2;
|
||||
assert.deepEqual(order, ["t1-start", "t1-end", "t2-start"], "t2 ran only after t1 finished");
|
||||
assert.equal(sem.inflight, 0, "all slots released");
|
||||
assert.equal(sem.queued, 0, "queue drained");
|
||||
});
|
||||
|
||||
await asyncTest("limit=2 allows two concurrent, queues the third", async () => {
|
||||
const sem = new TuiSemaphore(2);
|
||||
const g = [deferred(), deferred(), deferred()];
|
||||
const started = [];
|
||||
const tasks = g.map((d, i) => sem.run(async () => { started.push(i); await d.p; }));
|
||||
await new Promise((r) => setImmediate(r));
|
||||
assert.equal(sem.inflight, 2, "exactly 2 run concurrently");
|
||||
assert.equal(sem.queued, 1, "the third is queued");
|
||||
assert.deepEqual(started.sort(), [0, 1], "only the first two started");
|
||||
g.forEach((d) => d.resolve());
|
||||
await Promise.all(tasks);
|
||||
assert.equal(sem.inflight, 0);
|
||||
});
|
||||
|
||||
await asyncTest("slot is RELEASED on throw (finally) — a rejecting task never leaks its slot", async () => {
|
||||
const sem = new TuiSemaphore(1);
|
||||
await assert.rejects(sem.run(async () => { throw new Error("boom"); }), /boom/);
|
||||
assert.equal(sem.inflight, 0, "throwing task released its slot");
|
||||
// Prove the slot is reusable: a subsequent task acquires immediately.
|
||||
let ran = false;
|
||||
await sem.run(async () => { ran = true; });
|
||||
assert.equal(ran, true);
|
||||
assert.equal(sem.inflight, 0);
|
||||
});
|
||||
|
||||
await asyncTest("wait queue is bounded — run() rejects with tui_queue_full when full (backpressure, not OOM)", async () => {
|
||||
const sem = new TuiSemaphore(1, { maxQueue: 1 });
|
||||
const g1 = deferred();
|
||||
const t1 = sem.run(async () => { await g1.p; }); // holds the slot
|
||||
await new Promise((r) => setImmediate(r));
|
||||
const t2 = sem.run(async () => {}); // fills the 1-deep queue
|
||||
await new Promise((r) => setImmediate(r));
|
||||
assert.equal(sem.queued, 1, "queue is full");
|
||||
await assert.rejects(sem.run(async () => {}), /tui_queue_full/, "third request rejects");
|
||||
g1.resolve();
|
||||
await t1; await t2;
|
||||
assert.equal(sem.inflight, 0);
|
||||
});
|
||||
|
||||
console.log("\n-p concurrency wait-queue (FIX ⑥ — same TuiSemaphore reused for the -p path):");
|
||||
|
||||
// server.mjs reuses TuiSemaphore as `claudeSemaphore = new TuiSemaphore(MAX_CONCURRENT,
|
||||
// { maxQueue: CLAUDE_MAX_QUEUE })` and wraps acquire()/release() in acquireClaudeSlot(). These
|
||||
// tests assert the contract that the 429-mapping depends on: requests beyond the limit QUEUE
|
||||
// (not reject), only an overflow past the queue rejects (→ HTTP 429 in server.mjs), and a
|
||||
// released slot is reusable (the #37/#40 slot-leak guard — no leak on normal completion).
|
||||
await asyncTest("FIX ⑥: requests beyond MAX_CONCURRENT queue, not reject (limit=1, queue=1)", async () => {
|
||||
const sem = new TuiSemaphore(1, { maxQueue: 1 }); // mirrors CLAUDE_MAX_CONCURRENT=1, CLAUDE_MAX_QUEUE=1
|
||||
const g1 = deferred();
|
||||
const inflightP = sem.run(async () => { await g1.p; }); // request 1 — holds the only slot
|
||||
await new Promise((r) => setImmediate(r));
|
||||
assert.equal(sem.inflight, 1, "req1 inflight");
|
||||
const queuedP = sem.run(async () => {}); // request 2 — WAITS (queued), does NOT reject
|
||||
await new Promise((r) => setImmediate(r));
|
||||
assert.equal(sem.queued, 1, "req2 queued (waits), not rejected → would be served, not 429");
|
||||
// request 3 — queue full → reject (server.mjs maps this single case to 429 + Retry-After)
|
||||
await assert.rejects(sem.run(async () => {}), /tui_queue_full|queue/, "req3 overflows → reject (→429)");
|
||||
g1.resolve();
|
||||
await inflightP; await queuedP;
|
||||
assert.equal(sem.inflight, 0, "all slots released after drain (no leak)");
|
||||
assert.equal(sem.queued, 0, "queue fully drained");
|
||||
});
|
||||
|
||||
await asyncTest("FIX ⑥: slot released on normal completion is immediately reusable (no #37/#40 leak)", async () => {
|
||||
const sem = new TuiSemaphore(1, { maxQueue: 16 }); // mirrors default CLAUDE_MAX_QUEUE=16
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await sem.run(async () => { /* a normal, completing turn */ });
|
||||
assert.equal(sem.inflight, 0, `slot released after turn ${i}`);
|
||||
}
|
||||
// Prove the limit still binds after many acquire/release cycles.
|
||||
const g = deferred();
|
||||
const held = sem.run(async () => { await g.p; });
|
||||
await new Promise((r) => setImmediate(r));
|
||||
assert.equal(sem.inflight, 1, "limit still enforced after reuse cycles");
|
||||
g.resolve(); await held;
|
||||
assert.equal(sem.inflight, 0);
|
||||
});
|
||||
|
||||
console.log("\nTUI drift observability (C-5):");
|
||||
|
||||
test("recordTuiEntrypoint: observed 'cli' is NOT a mismatch and sets lastEntrypoint", () => {
|
||||
const ts = { lastEntrypoint: null, entrypointMismatches: 0 };
|
||||
const mism = recordTuiEntrypoint(ts, "cli", "cli");
|
||||
assert.equal(mism, false);
|
||||
assert.equal(ts.lastEntrypoint, "cli");
|
||||
assert.equal(ts.entrypointMismatches, 0);
|
||||
});
|
||||
|
||||
test("recordTuiEntrypoint: expected cli but observed 'sdk-cli' increments the mismatch counter (drift)", () => {
|
||||
const ts = { lastEntrypoint: null, entrypointMismatches: 0 };
|
||||
assert.equal(recordTuiEntrypoint(ts, "sdk-cli", "cli"), true);
|
||||
assert.equal(ts.lastEntrypoint, "sdk-cli");
|
||||
assert.equal(ts.entrypointMismatches, 1);
|
||||
// A second drift increments again (counter accumulates across turns).
|
||||
assert.equal(recordTuiEntrypoint(ts, "sdk-cli", "cli"), true);
|
||||
assert.equal(ts.entrypointMismatches, 2);
|
||||
});
|
||||
|
||||
test("recordTuiEntrypoint: null observation → lastEntrypoint null, counts as mismatch when expected cli", () => {
|
||||
const ts = { lastEntrypoint: "cli", entrypointMismatches: 0 };
|
||||
assert.equal(recordTuiEntrypoint(ts, null, "cli"), true);
|
||||
assert.equal(ts.lastEntrypoint, null);
|
||||
assert.equal(ts.entrypointMismatches, 1);
|
||||
});
|
||||
|
||||
test("recordTuiEntrypoint: non-cli expected mode (auto) never counts a mismatch", () => {
|
||||
const ts = { lastEntrypoint: null, entrypointMismatches: 0 };
|
||||
assert.equal(recordTuiEntrypoint(ts, "sdk-cli", "auto"), false);
|
||||
assert.equal(ts.lastEntrypoint, "sdk-cli");
|
||||
assert.equal(ts.entrypointMismatches, 0);
|
||||
});
|
||||
|
||||
test("buildTuiHealthBlock: shape + live counters (the additive /health tui block)", () => {
|
||||
const sem = new TuiSemaphore(2);
|
||||
const ts = { lastEntrypoint: "cli", entrypointMismatches: 3 };
|
||||
const block = buildTuiHealthBlock(
|
||||
{ enabled: true, entrypointMode: "cli", maxConcurrent: 2 }, ts, sem);
|
||||
assert.deepEqual(Object.keys(block).sort(),
|
||||
["enabled", "entrypointMismatches", "entrypointMode", "inflight", "lastEntrypoint", "maxConcurrent", "queued"]);
|
||||
assert.equal(block.enabled, true);
|
||||
assert.equal(block.entrypointMode, "cli");
|
||||
assert.equal(block.lastEntrypoint, "cli");
|
||||
assert.equal(block.entrypointMismatches, 3);
|
||||
assert.equal(block.inflight, 0);
|
||||
assert.equal(block.queued, 0);
|
||||
assert.equal(block.maxConcurrent, 2);
|
||||
});
|
||||
|
||||
test("buildTuiHealthBlock: TUI off → enabled:false but block still present (stable shape)", () => {
|
||||
const sem = new TuiSemaphore(2);
|
||||
const ts = { lastEntrypoint: null, entrypointMismatches: 0 };
|
||||
const block = buildTuiHealthBlock(
|
||||
{ enabled: false, entrypointMode: "cli", maxConcurrent: 2 }, ts, sem);
|
||||
assert.equal(block.enabled, false);
|
||||
assert.equal(block.lastEntrypoint, null);
|
||||
assert.equal(block.entrypointMismatches, 0);
|
||||
});
|
||||
|
||||
await asyncTest("buildTuiHealthBlock reflects live inflight/queued while turns are in flight", async () => {
|
||||
const sem = new TuiSemaphore(1);
|
||||
const ts = { lastEntrypoint: null, entrypointMismatches: 0 };
|
||||
const g1 = deferred();
|
||||
const t1 = sem.run(async () => { await g1.p; });
|
||||
const t2 = sem.run(async () => {}); // queued behind t1
|
||||
await new Promise((r) => setImmediate(r));
|
||||
const block = buildTuiHealthBlock({ enabled: true, entrypointMode: "cli", maxConcurrent: 1 }, ts, sem);
|
||||
assert.equal(block.inflight, 1, "one turn in flight");
|
||||
assert.equal(block.queued, 1, "one turn queued");
|
||||
g1.resolve();
|
||||
await t1; await t2;
|
||||
});
|
||||
|
||||
// ── TUI session driver: runTuiTurn (live-only, guarded) ──────────────────
|
||||
console.log("\nTUI session driver:");
|
||||
|
||||
@@ -1615,6 +2181,77 @@ if (process.env.OCP_TUI_LIVE === "1") {
|
||||
});
|
||||
}
|
||||
|
||||
// ── TUI readiness / paste-verify predicates (issue #130) ────────────────────
|
||||
// Replicates tuiInputReady, tuiPromptLanded verbatim from lib/tui/session.mjs.
|
||||
// Keep in sync with the definitions there.
|
||||
function _tuiInputReady(pane) {
|
||||
return /\? for shortcuts/.test(pane);
|
||||
}
|
||||
function _tuiPromptLanded(pane, prompt) {
|
||||
const flatPane = pane.replace(/\s+/g, " ");
|
||||
if (flatPane.includes("[Pasted text")) return true;
|
||||
const firstLine = String(prompt).split("\n").map(s => s.trim()).find(Boolean) || "";
|
||||
const needle = firstLine.replace(/\s+/g, " ").slice(0, 24);
|
||||
return needle.length >= 2 && flatPane.includes(needle); // C-4 (#133): 3 → 2 (see lib/tui/session.mjs)
|
||||
}
|
||||
|
||||
// Real captured pane samples (empirically confirmed via live capture-pane on PI231,
|
||||
// claude v2.1.114 and v2.1.159). Source: issue #130 spec.
|
||||
const TUI_READY_PANE = `❯ Try "how does <filepath> work?"
|
||||
? for shortcuts · ← for agents`;
|
||||
|
||||
const TUI_LANDED_PANE = `❯ Reply with exactly: PONG_TEST
|
||||
? for shortcuts · ← for agents`;
|
||||
|
||||
// Welcome splash shown before input bar is rendered — no `? for shortcuts`.
|
||||
const TUI_BOOT_PANE = `╭─ Claude Code v2.1.114 ─ Welcome back Tao! ─╮\n│ Tips for getting started │`;
|
||||
|
||||
console.log("\nTUI readiness + paste-verify predicates (issue #130):");
|
||||
|
||||
test("tuiInputReady(READY_PANE) === true (input bar rendered)", () => {
|
||||
assert.equal(_tuiInputReady(TUI_READY_PANE), true);
|
||||
});
|
||||
test("tuiInputReady(LANDED_PANE) === true (input bar still present after paste)", () => {
|
||||
assert.equal(_tuiInputReady(TUI_LANDED_PANE), true);
|
||||
});
|
||||
test("tuiInputReady(BOOT_PANE) === false (welcome splash, no input bar yet)", () => {
|
||||
assert.equal(_tuiInputReady(TUI_BOOT_PANE), false);
|
||||
});
|
||||
|
||||
test("tuiPromptLanded(READY_PANE, 'Reply with exactly: PONG_TEST') === false (still placeholder)", () => {
|
||||
assert.equal(_tuiPromptLanded(TUI_READY_PANE, "Reply with exactly: PONG_TEST"), false);
|
||||
});
|
||||
test("tuiPromptLanded(LANDED_PANE, 'Reply with exactly: PONG_TEST') === true (prompt prefix visible)", () => {
|
||||
assert.equal(_tuiPromptLanded(TUI_LANDED_PANE, "Reply with exactly: PONG_TEST"), true);
|
||||
});
|
||||
test("tuiPromptLanded(READY_PANE, 'ping') === false (prompt text absent from placeholder pane)", () => {
|
||||
assert.equal(_tuiPromptLanded(TUI_READY_PANE, "ping"), false);
|
||||
});
|
||||
test("tuiPromptLanded('❯ ping\\n ? for shortcuts', 'ping') === true (needle present, no placeholder)", () => {
|
||||
assert.equal(_tuiPromptLanded("❯ ping\n ? for shortcuts", "ping"), true);
|
||||
});
|
||||
// C-4 (#133): short prompts (1–2 char first line) MUST be able to land. Threshold
|
||||
// lowered 3 → 2. A 2-char prompt ("hi") present in the pane now lands instead of
|
||||
// 5s-failing with tui_paste_not_landed every time (live-reproduced: "hi").
|
||||
test("tuiPromptLanded('❯ hi\\n ? for shortcuts', 'hi') === true (2-char prompt lands — C-4)", () => {
|
||||
assert.equal(_tuiPromptLanded("❯ hi\n ? for shortcuts", "hi"), true);
|
||||
});
|
||||
// False-positive guard for the lowered threshold: a 2-char needle ABSENT from the
|
||||
// still-empty placeholder pane must NOT land (no spurious Enter into an empty box).
|
||||
test("tuiPromptLanded(READY_PANE, 'hi') === false (2-char prompt not yet visible — no false positive)", () => {
|
||||
assert.equal(_tuiPromptLanded(TUI_READY_PANE, "hi"), false);
|
||||
});
|
||||
// issue #130 root cause: a big bracketed paste shows "[Pasted text #N +M lines]" — must be landed.
|
||||
test("tuiPromptLanded(bracketed-paste pane, big prompt) === true", () => {
|
||||
assert.equal(_tuiPromptLanded("❯ [Pasted text #1 +301 lines]\n ? for shortcuts", "[System] Context 0."), true);
|
||||
});
|
||||
// issue #130 false-positive guard: the EMPTY placeholder uses a CURLY quote (“) and randomized
|
||||
// example text — the old placeholder-gone heuristic wrongly reported landed=true here, so Enter
|
||||
// fired into an empty box. Must be FALSE (no positive signal: not [Pasted text], prompt not shown).
|
||||
test("tuiPromptLanded(curly-quote placeholder, big prompt) === false (no false-positive)", () => {
|
||||
assert.equal(_tuiPromptLanded("❯ Try “how do I log an error?”\n ? for shortcuts", "[System] Context 0."), false);
|
||||
});
|
||||
|
||||
// ── /health anonymousKey gate (issue #109) ──────────────────────────────────
|
||||
// MIRRORS the predicate in server.mjs (search ADVERTISE_ANON_KEY) — copied
|
||||
// verbatim to avoid importing server.mjs (top-level server.listen() would
|
||||
|
||||
Reference in New Issue
Block a user