mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-22 05:25:08 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6394ca3265 | ||
|
|
c86e3d014f | ||
|
|
3322d7bdae | ||
|
|
79c1d61e1d | ||
|
|
a37ff713d9 | ||
|
|
6d4751f983 |
@@ -1,5 +1,32 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v3.20.0 — 2026-06-10
|
||||||
|
|
||||||
|
TUI-mode billing-safety hardening for the 2026-06-15 Anthropic billing split. A 5-dimension multi-agent audit (adversarial verification + live tests on all three hosts — PI231 / Oracle / Mac mini, claude 2.1.104 / 2.1.114 / 2.1.170) found the TUI subscription-pool path could silently bill the metered Agent SDK pool or poison the cache under realistic failure modes. Three PRs, each with a fresh-context reviewer (Iron Rule 10) and CI; the default path (`CLAUDE_TUI_MODE` unset) is byte-for-byte unchanged.
|
||||||
|
|
||||||
|
### TUI — honesty & cache correctness (#137)
|
||||||
|
|
||||||
|
- **C-1** — `callClaudeTui` now throws on a claude-CLI auth-failure banner (e.g. `Please run /login · API Error: 401 …`, `Failed to authenticate. API Error: 401 …`) instead of returning it as a real answer, so it is never cached, singleflight-shared, or counted as a model success. Conservative detector (whole trimmed text ≤100 chars + `API Error: 4xx` + auth keyword + no code/quote char); overridable via `CLAUDE_TUI_ERROR_PATTERNS`. Live-reproduced on PI231.
|
||||||
|
- **C-2** — `readTuiTranscript` distinguishes a complete turn from a wallclock-truncated partial (`truncated` flag); `callClaudeTui` throws `tui_wallclock_truncated` so a partial is never cached or counted as success.
|
||||||
|
- **C-3** — `verifyEntrypoint` reads the `entrypoint` field from any transcript line, not just `{system, turn_duration}` — some claude builds emit zero turn_duration lines (live-confirmed on Oracle's claude 2.1.114), which previously left the billing-drift assertion blind on those builds.
|
||||||
|
- **C-4 (paste)** — short prompts (e.g. `hi`) could never pass paste-landing detection; threshold lowered. Live-reproduced on PI231.
|
||||||
|
|
||||||
|
### TUI — concurrency & observability (#139)
|
||||||
|
|
||||||
|
- **Concurrency** — `OCP_TUI_MAX_CONCURRENT` (default 2) bounds concurrent interactive `claude` boots via a queuing semaphore (`lib/tui/semaphore.mjs`); the slot is released on throw so honesty-gate / spawn failures never leak it; bounded wait-queue → `tui_queue_full` (503). Independent of the global `MAX_CONCURRENT` (8) — a TUI turn is a heavy per-request cold-boot of tmux+claude + up to 120s wallclock.
|
||||||
|
- **Observability** — additive `/health` `tui` block (`enabled` / `entrypointMode` / `lastEntrypoint` / `entrypointMismatches` / `inflight` / `maxConcurrent`) so an operator can poll for a silent `sdk-cli` metered-pool drift (the audit's top risk) instead of grepping journald. Authorized by the ADR 0007 PR-B amendment under the ALIGNMENT grandfather provision (additive, behaviour-preserving — every pre-existing `/health` field unchanged).
|
||||||
|
|
||||||
|
### Operations (#138)
|
||||||
|
|
||||||
|
- `docs/runbooks/615-canary.md` — the 2026-06-15 credit-balance canary: quiesce, read the Agent SDK credit balance (manual — no programmatic API exists for that pool; OCP's `/usage` headers are subscription rate-limit data, not the credit pool), one TUI canary turn, confirm `entrypoint:cli` in the transcript, green/red decision tree, periodic auto-mode self-classification mini-canary.
|
||||||
|
- `docs/runbooks/tui-flip-rollback.md` — flip/rollback per deployment (systemd `daemon-reload`; launchd `bootout`/`bootstrap`, not `kickstart -k`).
|
||||||
|
- `setup.mjs` auth quick-test gated behind `OCP_SKIP_AUTH_TEST=1` (the `claude -p` probe draws from the metered Agent SDK pool after 6/15).
|
||||||
|
|
||||||
|
### New environment variables
|
||||||
|
|
||||||
|
- `OCP_TUI_MAX_CONCURRENT` — max concurrent interactive TUI turns (default 2) (#139).
|
||||||
|
- `OCP_SKIP_AUTH_TEST` — skip the `claude -p` auth probe in `setup.mjs` (default off) (#138).
|
||||||
|
|
||||||
## v3.19.0 — 2026-06-02
|
## 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-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.
|
||||||
|
|||||||
@@ -725,7 +725,7 @@ The canonical list lives in [`models.json`](./models.json) — the single source
|
|||||||
|----------|--------|-------------|
|
|----------|--------|-------------|
|
||||||
| `/v1/models` | GET | List available models |
|
| `/v1/models` | GET | List available models |
|
||||||
| `/v1/chat/completions` | POST | Chat completion (streaming + non-streaming) |
|
| `/v1/chat/completions` | POST | Chat completion (streaming + non-streaming) |
|
||||||
| `/health` | GET | Comprehensive health check |
|
| `/health` | GET | Comprehensive health check (includes a `tui` block for TUI-mode drift/concurrency monitoring) |
|
||||||
| `/usage` | GET | Plan usage limits + per-model stats |
|
| `/usage` | GET | Plan usage limits + per-model stats |
|
||||||
| `/status` | GET | Combined overview (usage + health) |
|
| `/status` | GET | Combined overview (usage + health) |
|
||||||
| `/settings` | GET/PATCH | View or update settings at runtime |
|
| `/settings` | GET/PATCH | View or update settings at runtime |
|
||||||
@@ -871,6 +871,21 @@ openclaw gateway restart # so OpenClaw re-reads the config
|
|||||||
|
|
||||||
Future `ocp update` invocations sync automatically.
|
Future `ocp update` invocations sync automatically.
|
||||||
|
|
||||||
|
### TUI-mode returns `Please run /login · API Error: 401` (re-login doesn't stick)
|
||||||
|
|
||||||
|
A long-running TUI-mode host can get stuck returning a permanent 401 that re-login cannot fix. Root cause: when `CLAUDE_CODE_OAUTH_TOKEN` is **unset**, the interactive `claude` authenticates via `~/.claude/.credentials.json`, whose single-use OAuth refresh token can be corrupted (ending up an empty string) by the per-request spawn + `kill-session` teardown racing claude's token rotation. Re-login writes a fresh token, but the next spawn re-corrupts it.
|
||||||
|
|
||||||
|
Fix: set `CLAUDE_CODE_OAUTH_TOKEN` on the OCP host (then restart — on systemd `daemon-reload`, on launchd `bootout`+`bootstrap`; `kickstart -k` does **not** reload env). The TUI `claude` then authenticates via the stable long-lived token and never touches credentials.json. Verify the env reached the process:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Linux (systemd): confirm the token is in the service env
|
||||||
|
tr '\0' '\n' < /proc/$(pgrep -f server.mjs | head -1)/environ | grep CLAUDE_CODE_OAUTH_TOKEN
|
||||||
|
# Re-login once to repair the credentials file (belt-and-braces), then it stays unused:
|
||||||
|
claude /login
|
||||||
|
```
|
||||||
|
|
||||||
|
See [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) and ADR 0007 PR-C amendment.
|
||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
@@ -894,10 +909,14 @@ Future `ocp update` invocations sync automatically.
|
|||||||
| `PROXY_ANONYMOUS_KEY` | *(unset)* | Well-known anonymous key allowlist (multi mode). When set, this exact string bypasses `validateKey()` and grants public access. Exposed via `/health.anonymousKey` only to localhost, or to all callers when `PROXY_ADVERTISE_ANON_KEY=1`. See [Anonymous Access](#anonymous-access-optional). |
|
| `PROXY_ANONYMOUS_KEY` | *(unset)* | Well-known anonymous key allowlist (multi mode). When set, this exact string bypasses `validateKey()` and grants public access. Exposed via `/health.anonymousKey` only to localhost, or to all callers when `PROXY_ADVERTISE_ANON_KEY=1`. See [Anonymous Access](#anonymous-access-optional). |
|
||||||
| `PROXY_ADVERTISE_ANON_KEY` | *(unset)* | When `=1`, advertise `PROXY_ANONYMOUS_KEY` in the public `/health` body for remote zero-config discovery. Default off — `/health` is unauthenticated, so this exposes the shared key to any LAN-reachable device (issue #109). Localhost always sees it regardless. |
|
| `PROXY_ADVERTISE_ANON_KEY` | *(unset)* | When `=1`, advertise `PROXY_ANONYMOUS_KEY` in the public `/health` body for remote zero-config discovery. Default off — `/health` is unauthenticated, so this exposes the shared key to any LAN-reachable device (issue #109). Localhost always sees it regardless. |
|
||||||
| `CLAUDE_TUI_MODE` | `false` | **Opt-in.** Set to `"true"` to serve requests via interactive `claude` (no `-p` / `--output-format` → `cc_entrypoint=cli`, subscription pool). **Single-user only** — see [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) for the security constraint. |
|
| `CLAUDE_TUI_MODE` | `false` | **Opt-in.** Set to `"true"` to serve requests via interactive `claude` (no `-p` / `--output-format` → `cc_entrypoint=cli`, subscription pool). **Single-user only** — see [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) for the security constraint. |
|
||||||
|
| `CLAUDE_CODE_OAUTH_TOKEN` | *(unset)* | OAuth bearer token (highest-precedence credential source). **Recommended for TUI-mode hosts:** when set, the interactive `claude` authenticates via this long-lived token and never touches `~/.claude/.credentials.json`, avoiding the refresh-token corruption that caused a permanent `Please run /login` 401 on a long-running TUI host (see [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) and ADR 0007). The token appears in the pane command (ps-visible) — acceptable for the single-user A-path; the multi-user B-path is refused at boot. |
|
||||||
| `CLAUDE_TUI_WALLCLOCK_MS` | `120000` | (TUI-mode) Maximum time in ms to wait for the native transcript to signal turn completion. Increase for long Opus thinking turns. |
|
| `CLAUDE_TUI_WALLCLOCK_MS` | `120000` | (TUI-mode) Maximum time in ms to wait for the native transcript to signal turn completion. Increase for long Opus thinking turns. |
|
||||||
| `OCP_TUI_CWD` | `$HOME/.ocp-tui/work` | (TUI-mode) Scratch working directory where interactive claude sessions run. Transcripts land under `<HOME>/.claude/projects/<encoded-cwd>/`. Created automatically. |
|
| `OCP_TUI_CWD` | `$HOME/.ocp-tui/work` | (TUI-mode) Scratch working directory where interactive claude sessions run. Transcripts land under `<HOME>/.claude/projects/<encoded-cwd>/`. Created automatically. |
|
||||||
| `OCP_TUI_HOME` | `$HOME` (real home) | (TUI-mode) `HOME` claude runs under. Default is the operator's real home (shared credentials, existing onboarding). Set to a separate path for scratch-home isolation — see ADR 0007 for the credential-fork caveat. |
|
| `OCP_TUI_HOME` | `$HOME` (real home) | (TUI-mode) `HOME` claude runs under. Default is the operator's real home (shared credentials, existing onboarding). Set to a separate path for scratch-home isolation — see ADR 0007 for the credential-fork caveat. |
|
||||||
| `OCP_TUI_ENTRYPOINT` | `cli` | (TUI-mode) Billing-classifier labeling: `cli` (default) pins `cc_entrypoint=cli` deterministically; `auto` lets claude self-classify via TTY detection; `off` leaves the inherited env untouched. Honest only when the spawn is a genuine interactive PTY — see ADR 0007. |
|
| `OCP_TUI_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
|
### Streaming heartbeat
|
||||||
|
|
||||||
@@ -943,6 +962,11 @@ mkdir -p ~/.ocp-tui/work # one-time scratch cwd setup
|
|||||||
|
|
||||||
# Enable
|
# Enable
|
||||||
export CLAUDE_TUI_MODE=true
|
export CLAUDE_TUI_MODE=true
|
||||||
|
# STRONGLY RECOMMENDED on a TUI host — authenticate via the long-lived OAuth token
|
||||||
|
# so the interactive claude never touches ~/.claude/.credentials.json (whose single-use
|
||||||
|
# refresh token can get corrupted by the per-request spawn/teardown cycle → permanent
|
||||||
|
# "Please run /login" 401). See the auth note below + ADR 0007.
|
||||||
|
export CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-...
|
||||||
# Optionally tune:
|
# Optionally tune:
|
||||||
export CLAUDE_TUI_WALLCLOCK_MS=180000 # 3 min cap for long Opus turns
|
export CLAUDE_TUI_WALLCLOCK_MS=180000 # 3 min cap for long Opus turns
|
||||||
export OCP_TUI_CWD=$HOME/.ocp-tui/work # default; override if needed
|
export OCP_TUI_CWD=$HOME/.ocp-tui/work # default; override if needed
|
||||||
@@ -962,7 +986,28 @@ Then restart OCP. At boot you will see:
|
|||||||
- **No real token streaming.** TUI-mode buffers the full response then replays it as chunked SSE. You will see a delay then the complete response rather than real-time tokens.
|
- **No real token streaming.** TUI-mode buffers the full response then replays it as chunked SSE. You will see a delay then the complete response rather than real-time tokens.
|
||||||
- **Cache and singleflight work normally.** TUI-mode writes the buffered response to the cache on success; cache-hits skip the interactive turn entirely.
|
- **Cache and singleflight work normally.** TUI-mode writes the buffered response to the cache on success; cache-hits skip the interactive turn entirely.
|
||||||
- **The host's `CLAUDE.md` / auto-memory is never injected.** OCP is a proxy — the proxied client (OpenClaw / your IDE) owns its own context and memory. TUI-mode always runs `claude` with `CLAUDE_CODE_DISABLE_CLAUDE_MDS` + `CLAUDE_CODE_DISABLE_AUTO_MEMORY`, so a `CLAUDE.md` on the OCP host can never leak into proxied turns (verified live; see #4). Built-in tool schemas + the interactive system prompt remain (the inherent ~20–35K context floor of interactive mode); MCP is hard-disabled.
|
- **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` (recommended).** tmux does not forward the parent process's env to the pane, so OCP sets the token explicitly on the spawned `claude` when `CLAUDE_CODE_OAUTH_TOKEN` is present. With the token set, the interactive `claude` authenticates via the stable long-lived token and **never touches `~/.claude/.credentials.json`**. Without it, claude falls back to credentials.json, whose single-use OAuth refresh token can be corrupted by the per-request spawn + `kill-session` teardown racing claude's token rotation — on a long-running host this produced a permanent `Please run /login · API Error: 401` that re-login could not fix (the next spawn re-corrupted it). Setting the token mirrors how the stable hosts already run. (The token is then visible in `ps` on the pane command — acceptable for the single-user A-path; the multi-user B-path is refused at boot.) See ADR 0007 PR-C amendment.
|
||||||
|
- **Stale tmux sessions are reaped.** The pane's `claude` is a child of the tmux server (not OCP), so OCP cannot reap it directly; `claude` zombies can otherwise accumulate as `<defunct>` over a long-running host. OCP reaps them at boot and on a 15-min idle sweep by issuing `tmux kill-server` — but **only when no foreign tmux session remains** (it never disrupts a co-hosted `olp-tui-*` instance). See ADR 0007 PR-C amendment.
|
||||||
- **Default path unchanged.** Unset `CLAUDE_TUI_MODE` and restart → `callClaude` / `callClaudeStreaming` are used again, byte-for-byte identical to today.
|
- **Default path unchanged.** Unset `CLAUDE_TUI_MODE` and restart → `callClaude` / `callClaudeStreaming` are used again, byte-for-byte identical to today.
|
||||||
|
- **Concurrency is bounded separately.** TUI turns are heavy (per-request cold-boot + long wallclock), so the TUI path has its own limiter — `OCP_TUI_MAX_CONCURRENT` (default `2`), independent of `CLAUDE_MAX_CONCURRENT`. Excess turns queue; a full queue returns a 503. Tune it up only on a host that can run more interactive `claude` sessions at once.
|
||||||
|
|
||||||
|
### Monitoring drift via `/health`
|
||||||
|
|
||||||
|
`GET /health` includes a `tui` block so you can poll for a silent billing-pool drift (the top risk after the 6/15 flip — a lost TTY flipping `cc_entrypoint` from `cli` to the metered `sdk-cli` pool would still return answers but burn metered credits). The block is **always present** (with `enabled:false` when TUI-mode is off):
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"tui": {
|
||||||
|
"enabled": true, // CLAUDE_TUI_MODE === "true"
|
||||||
|
"entrypointMode": "cli", // OCP_TUI_ENTRYPOINT (cli | auto | off)
|
||||||
|
"lastEntrypoint": "cli", // last cc_entrypoint observed in a transcript, or null
|
||||||
|
"entrypointMismatches": 0, // count of cli-expected-but-got-other turns — ALERT if this climbs
|
||||||
|
"inflight": 1, // TUI turns running right now
|
||||||
|
"queued": 0, // TUI turns waiting for a concurrency slot
|
||||||
|
"maxConcurrent": 2 // OCP_TUI_MAX_CONCURRENT
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Alert on `entrypointMismatches > 0` (or `lastEntrypoint !== "cli"`): it means a turn drew from the metered Agent SDK pool instead of the subscription. `inflight` / `queued` show how close the TUI path is to its concurrency cap.
|
||||||
|
|
||||||
### Kill-switch
|
### Kill-switch
|
||||||
|
|
||||||
@@ -973,6 +1018,13 @@ unset CLAUDE_TUI_MODE
|
|||||||
|
|
||||||
The stream-json path is restored immediately. No other change is needed.
|
The stream-json path is restored immediately. No other change is needed.
|
||||||
|
|
||||||
|
### 2026-06-15 operator checklist
|
||||||
|
|
||||||
|
Every host serving traffic must be flipped to TUI-mode **and** canary-verified before 2026-06-15, or it will bill the metered Agent SDK credit pool instead of the subscription.
|
||||||
|
|
||||||
|
- **[Flip/rollback runbook](docs/runbooks/tui-flip-rollback.md)** — how to set `CLAUDE_TUI_MODE=true` on systemd (Linux) and launchd (macOS) hosts. Covers the `daemon-reload` requirement (systemd) and the `bootout`+`bootstrap` cycle requirement (launchd — `launchctl kickstart -k` does not reload plist env).
|
||||||
|
- **[615-canary runbook](docs/runbooks/615-canary.md)** — after each flip, run one quiesced request and compare the Agent SDK credit balance before and after. `entrypoint:cli` in the transcript (the `cc_entrypoint` billing classifier) is necessary but not sufficient — only a stable credit balance confirms the subscription pool is being used. Balance check is a manual step (no known programmatic API for the Agent SDK credit pool balance).
|
||||||
|
|
||||||
### Architecture and design decisions
|
### Architecture and design decisions
|
||||||
|
|
||||||
See [`docs/adr/0007-tui-interactive-mode.md`](docs/adr/0007-tui-interactive-mode.md) for the full rationale, home-strategy options, MCP-disable mechanism, coexistence rules, and the B-path (multi-tenant isolation) roadmap.
|
See [`docs/adr/0007-tui-interactive-mode.md`](docs/adr/0007-tui-interactive-mode.md) for the full rationale, home-strategy options, MCP-disable mechanism, coexistence rules, and the B-path (multi-tenant isolation) roadmap.
|
||||||
|
|||||||
@@ -139,6 +139,136 @@ 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
### Positive
|
### 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
+91
-4
@@ -23,16 +23,44 @@ const defaultTmux = (args, opts = {}) =>
|
|||||||
|
|
||||||
// Kill ONLY our own stale sessions. Scoped to SESSION_PREFIX so a co-hosted
|
// Kill ONLY our own stale sessions. Scoped to SESSION_PREFIX so a co-hosted
|
||||||
// OLP test instance's `olp-tui-*` sessions are never touched.
|
// OLP test instance's `olp-tui-*` sessions are never touched.
|
||||||
|
//
|
||||||
|
// Defunct-reaping (PI231 incident): the pane's `claude` process is a child of the
|
||||||
|
// long-lived tmux SERVER daemon, NOT of the OCP node process — `tmux new-session -d`
|
||||||
|
// returns the instant the server forks the pane, so node never becomes its parent and
|
||||||
|
// therefore can NEVER waitpid()/reap it (a SIGKILL still needs the *parent* to reap, and
|
||||||
|
// here that parent is the tmux server). `kill-session` destroys the session but the server
|
||||||
|
// can leave the pane's `claude` (and any grandchildren claude spawned) as `<defunct>`
|
||||||
|
// zombies that only the server can reap. Over many per-request spawn+teardown cycles these
|
||||||
|
// accumulate (live evidence on PI231: 25 defunct `<claude>` over 30 days; `tmux kill-server`
|
||||||
|
// dropped it 25→3). The only node-reachable action that ACTUALLY reaps them — rather than
|
||||||
|
// merely re-signalling — is to stop the tmux server: when the server exits, the kernel
|
||||||
|
// reparents its surviving children to init (PID 1), which reaps them immediately.
|
||||||
|
//
|
||||||
|
// So after killing our own sessions, if the server has NO sessions left of ANY prefix
|
||||||
|
// (i.e. nothing we could disrupt — no co-hosted `olp-tui-*` or other instance), we
|
||||||
|
// `kill-server` to flush the defunct backlog. If ANY non-ocp session remains we leave the
|
||||||
|
// server running (coexistence rule, ADR 0007) and let the next boot/periodic sweep retry
|
||||||
|
// once the server is otherwise idle.
|
||||||
export function reapStaleTuiSessions({ tmux = defaultTmux } = {}) {
|
export function reapStaleTuiSessions({ tmux = defaultTmux } = {}) {
|
||||||
const r = tmux(["list-sessions", "-F", "#{session_name}"]);
|
const r = tmux(["list-sessions", "-F", "#{session_name}"]);
|
||||||
if (!r || r.status !== 0) return 0; // no tmux server / no sessions
|
if (!r || r.status !== 0) return 0; // no tmux server / no sessions
|
||||||
|
const names = String(r.stdout || "").split("\n").map((s) => s.trim()).filter(Boolean);
|
||||||
let killed = 0;
|
let killed = 0;
|
||||||
for (const name of String(r.stdout || "").split("\n").map((s) => s.trim()).filter(Boolean)) {
|
let othersRemain = false;
|
||||||
|
for (const name of names) {
|
||||||
if (name.startsWith(SESSION_PREFIX)) {
|
if (name.startsWith(SESSION_PREFIX)) {
|
||||||
tmux(["kill-session", "-t", name]);
|
tmux(["kill-session", "-t", name]);
|
||||||
killed++;
|
killed++;
|
||||||
|
} else {
|
||||||
|
othersRemain = true; // a session we do NOT own (e.g. olp-tui-*) — never kill-server
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Reap defunct `claude` zombies: safe ONLY when the server is now ours-only/empty.
|
||||||
|
// kill-server is what actually reaps (server exit reparents survivors to init); a
|
||||||
|
// per-session kill cannot, since node is not the zombies' parent.
|
||||||
|
if (!othersRemain) {
|
||||||
|
tmux(["kill-server"]);
|
||||||
|
}
|
||||||
return killed;
|
return killed;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +96,16 @@ function tuiPromptLanded(pane, prompt) {
|
|||||||
if (flatPane.includes("[Pasted text")) return true;
|
if (flatPane.includes("[Pasted text")) return true;
|
||||||
const firstLine = String(prompt).split("\n").map(s => s.trim()).find(Boolean) || "";
|
const firstLine = String(prompt).split("\n").map(s => s.trim()).find(Boolean) || "";
|
||||||
const needle = firstLine.replace(/\s+/g, " ").slice(0, 24);
|
const needle = firstLine.replace(/\s+/g, " ").slice(0, 24);
|
||||||
return needle.length >= 3 && flatPane.includes(needle);
|
// 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 }) {
|
async function pollUntil(fn, { timeoutMs, intervalMs }) {
|
||||||
@@ -208,17 +245,67 @@ export function buildTuiCmd(claudeBin, model, sessionId, ehome, entrypointMode)
|
|||||||
"CLAUDE_CODE_DISABLE_CLAUDE_MDS=1",
|
"CLAUDE_CODE_DISABLE_CLAUDE_MDS=1",
|
||||||
"CLAUDE_CODE_DISABLE_AUTO_MEMORY=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"];
|
const unset = ["CLAUDECODE", "ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN"];
|
||||||
if (entrypointMode === "cli") sets.push("CLAUDE_CODE_ENTRYPOINT=cli");
|
if (entrypointMode === "cli") sets.push("CLAUDE_CODE_ENTRYPOINT=cli");
|
||||||
else if (entrypointMode === "auto") unset.push("CLAUDE_CODE_ENTRYPOINT"); // let claude self-classify via TTY
|
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(" ");
|
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 [
|
return [
|
||||||
envPrefix,
|
envPrefix,
|
||||||
shq(claudeBin),
|
shq(claudeBin),
|
||||||
"--model", shq(model),
|
"--model", shq(model),
|
||||||
"--session-id", sessionId,
|
"--session-id", sessionId,
|
||||||
"--strict-mcp-config",
|
...toolArgs,
|
||||||
"--disallowedTools", shq("mcp__*"),
|
|
||||||
].join(" ");
|
].join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+161
-12
@@ -100,24 +100,169 @@ export function extractLatestAssistantText(events) {
|
|||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns the entrypoint string from the turn_duration line (e.g. "cli"),
|
// Returns the entrypoint string (e.g. "cli") used for the billing-pool assertion,
|
||||||
// or null if absent. Lets callers assert the subscription-classified path.
|
// or null if absent. Lets callers assert the subscription-classified path.
|
||||||
// Fixture-confirmed: entrypoint field lives directly on the turn_duration line.
|
//
|
||||||
|
// Resolution order (C-3, issue #133):
|
||||||
|
// 1. PREFER the turn_duration system line's `entrypoint` — the authoritative
|
||||||
|
// end-of-turn classifier emitted by builds that produce turn_duration
|
||||||
|
// (e.g. claude-2.1.104/2.1.157 on PI231).
|
||||||
|
// 2. FALL BACK to the `entrypoint` field on ANY ordinary transcript line
|
||||||
|
// (assistant / user / attachment / system) — present on BOTH emitting and
|
||||||
|
// non-emitting builds. Some claude builds (e.g. certain Mac mini transcripts)
|
||||||
|
// do NOT emit a turn_duration line at all; reading ONLY turn_duration made the
|
||||||
|
// caller's tui_entrypoint_mismatch assertion (server.mjs) get got:null every
|
||||||
|
// turn and go blind. The entrypoint value is identical across line types within
|
||||||
|
// a single interactive session (fixture-confirmed: every line in
|
||||||
|
// complete-haiku.jsonl carrying `entrypoint` reads "cli"), so the fallback
|
||||||
|
// yields the same classifier. Last-writer-wins on the fallback.
|
||||||
export function verifyEntrypoint(events) {
|
export function verifyEntrypoint(events) {
|
||||||
|
let fallback = null;
|
||||||
for (const ev of events) {
|
for (const ev of events) {
|
||||||
if (ev && ev.type === "system" && ev.subtype === "turn_duration") {
|
if (!ev || typeof ev !== "object") continue;
|
||||||
return ev.entrypoint != null ? ev.entrypoint : null;
|
if (ev.type === "system" && ev.subtype === "turn_duration" && ev.entrypoint != null) {
|
||||||
|
return ev.entrypoint; // authoritative — short-circuit
|
||||||
}
|
}
|
||||||
|
if (ev.entrypoint != null) fallback = ev.entrypoint;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── C-1: honest AUTH-FAILURE banner detection (issue #133) ───────────────
|
||||||
|
// When the interactive `claude` CLI hits an in-session error it does NOT crash —
|
||||||
|
// it renders the error as ordinary assistant text in the transcript. The specific
|
||||||
|
// failure C-1 exists to catch is R-1: EXPIRED / INVALID credentials, where every
|
||||||
|
// turn comes back as the same one-line auth-failure banner and OCP, none the wiser,
|
||||||
|
// caches that banner (server.mjs setCachedResponse), shares it via singleflight, and
|
||||||
|
// records a model SUCCESS — so a hard auth error is silently served (and cached for
|
||||||
|
// the 5-min TTL) as a real answer. The two live-reproduced banners on PI231
|
||||||
|
// (2026-06-10) are:
|
||||||
|
// "Please run /login · API Error: 401 Invalid authentication credentials" (69 chars)
|
||||||
|
// "Failed to authenticate. API Error: 401 Invalid authentication credentials" (73 chars)
|
||||||
|
//
|
||||||
|
// WHY THE SCOPE IS NARROW (conservatism — the load-bearing design choice).
|
||||||
|
// An earlier generalised rule (^<short-prefix>?API Error:\s*\d{3}\b.*$) was TOO
|
||||||
|
// BROAD: its unbounded `.*` tail let any short prefix + "API Error: NNN" + an
|
||||||
|
// arbitrarily long sentence match, so it KILLED legitimate long answers that merely
|
||||||
|
// DISCUSS an API error (e.g. "API Error: 500 happened because the server was
|
||||||
|
// overloaded. To fix this, retry with exponential backoff …"). That is the worst
|
||||||
|
// outcome: a false-positive costs the user a missing answer AND a double-burn retry,
|
||||||
|
// whereas the rare false-negative (caching one transient error for the 5-min TTL) is
|
||||||
|
// cheap and self-healing. So C-1 is reframed from "detect ANY API error" to "detect
|
||||||
|
// a claude-CLI AUTHENTICATION-FAILURE banner", and when unsure it PASSES (does not
|
||||||
|
// kill). Transient 5xx server errors are deliberately NOT detected — they are not the
|
||||||
|
// R-1 case and the conservative choice is to let them through.
|
||||||
|
//
|
||||||
|
// THE SIGNAL — a turn is an auth-failure banner only if ALL of these hold over the
|
||||||
|
// WHOLE trimmed assistant text (a conjunction; any one failing => PASS):
|
||||||
|
// 1. SHORT whole-message. Real banners are one short line (the two live samples are
|
||||||
|
// 69 and 73 chars). Cap = TUI_ERR_MAX_LEN (100) — headroom over 73 for a
|
||||||
|
// slightly longer future banner, while still rejecting multi-sentence prose. A
|
||||||
|
// long answer that happens to discuss auth (no code chars, e.g. 226 chars) is
|
||||||
|
// rejected on length alone.
|
||||||
|
// 2. Contains "API Error: 4\d{2}" — auth failures are 4xx (401/403). This rejects
|
||||||
|
// transient 5xx ("API Error: 500/503 …") and bare "HTTP 401 means unauthorized."
|
||||||
|
// (no "API Error:" core).
|
||||||
|
// 3. Contains an auth KEYWORD — authenticat | /login | credential (case-insensitive).
|
||||||
|
// This rejects answers that quote a 4xx but are not auth banners, e.g.
|
||||||
|
// "To debug a 401: the server returns API Error: 401 Unauthorized …"
|
||||||
|
// ("Unauthorized" is authoriz-, not authenticat-; no /login, no credential).
|
||||||
|
// 4. Contains NO backtick or quote char (` ' "). A real CLI banner is plain text;
|
||||||
|
// backticked/quoted text signals an answer that is QUOTING the error rather than
|
||||||
|
// being the banner, e.g. "You'll see `API Error: 401` … run /login to fix it."
|
||||||
|
// (75 chars — passes 1-3 but is excluded here). This is the conservative tie-
|
||||||
|
// breaker for short instructional answers.
|
||||||
|
//
|
||||||
|
// Worked matrix (all required cases pass — see test-features.mjs C-1 block):
|
||||||
|
// KILL: "Please run /login · API Error: 401 Invalid authentication credentials"
|
||||||
|
// KILL: "Failed to authenticate. API Error: 401 Invalid authentication credentials"
|
||||||
|
// PASS: "API Error: 500 happened because the server was overloaded. …" (not 4xx)
|
||||||
|
// PASS: "Failed to parse the config. Here are the API Error: 401 details …" (too long + no auth-kw)
|
||||||
|
// PASS: "To debug a 401: … API Error: 401 Unauthorized, then you refresh …" (no auth-kw)
|
||||||
|
// PASS: "Here is the handler … It logs the string API Error: 503 …" (not 4xx)
|
||||||
|
// PASS: "You'll see `API Error: 401` … run /login to fix it." (has backtick)
|
||||||
|
// PASS: "HTTP 401 means unauthorized." (no API Error core)
|
||||||
|
// PASS: "The capital of France is Paris." (nothing matches)
|
||||||
|
//
|
||||||
|
// OPERATOR OVERRIDE (unchanged): CLAUDE_TUI_ERROR_PATTERNS lets an operator REPLACE
|
||||||
|
// the default auth-banner detector with their own newline- or `||`-separated JS regex
|
||||||
|
// source strings (each auto-anchored ^…$ over the trimmed text, case-insensitive). A
|
||||||
|
// non-empty override uses ONLY those regexes (the narrowed default is bypassed); an
|
||||||
|
// empty / whitespace-only override DISABLES detection entirely (escape hatch).
|
||||||
|
|
||||||
|
// Whole-message length cap for the default auth-banner detector. Real banners are
|
||||||
|
// 69/73 chars; 100 gives headroom while still rejecting multi-sentence prose.
|
||||||
|
const TUI_ERR_MAX_LEN = 100;
|
||||||
|
// 4xx "API Error:" core — auth failures are 4xx (401/403), never 5xx.
|
||||||
|
const TUI_ERR_4XX = /API Error:\s*4\d{2}\b/i;
|
||||||
|
// Auth keyword — the message must be about authentication, not just quote a 4xx.
|
||||||
|
const TUI_ERR_AUTH_KW = /authenticat|\/login|credential/i;
|
||||||
|
// Code/quote chars — their presence signals prose QUOTING an error, not the banner.
|
||||||
|
const TUI_ERR_CODE_CHAR = /[`'"]/;
|
||||||
|
|
||||||
|
// Default detector: returns true iff `trimmed` IS a claude-CLI auth-failure banner
|
||||||
|
// (all four signals above). Conservative — any signal failing => false (PASS).
|
||||||
|
function isDefaultAuthFailureBanner(trimmed) {
|
||||||
|
if (trimmed.length > TUI_ERR_MAX_LEN) return false; // 1. short whole-message
|
||||||
|
if (!TUI_ERR_4XX.test(trimmed)) return false; // 2. 4xx API Error core
|
||||||
|
if (!TUI_ERR_AUTH_KW.test(trimmed)) return false; // 3. auth keyword
|
||||||
|
if (TUI_ERR_CODE_CHAR.test(trimmed)) return false; // 4. no code/quote chars
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compile an OPERATOR-SUPPLIED pattern set (override path only). Each source is
|
||||||
|
// anchored ^…$ over the trimmed text and matched case-insensitively (`s` so `.` spans
|
||||||
|
// a multi-line banner). A pattern that fails to compile is skipped (never throws into
|
||||||
|
// the request path).
|
||||||
|
function compileTuiErrorPatterns(raw) {
|
||||||
|
const sources = String(raw).split(/\r?\n|\|\|/).map((s) => s.trim()).filter(Boolean);
|
||||||
|
const out = [];
|
||||||
|
for (const src of sources) {
|
||||||
|
try { out.push(new RegExp(`^(?:${src})$`, "is")); } catch { /* skip bad pattern */ }
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the matched banner text (the trimmed assistant text) if `text` IS a claude-
|
||||||
|
// CLI auth-failure banner in its entirety, else null. `patternsRaw` defaults to
|
||||||
|
// process.env.CLAUDE_TUI_ERROR_PATTERNS:
|
||||||
|
// - undefined → narrowed default auth-banner detector (isDefaultAuthFailureBanner).
|
||||||
|
// - non-empty → operator regex override REPLACES the default.
|
||||||
|
// - empty/ws → detection disabled (escape hatch).
|
||||||
|
export function detectTuiUpstreamError(text, patternsRaw = process.env.CLAUDE_TUI_ERROR_PATTERNS) {
|
||||||
|
if (typeof text !== "string") return null;
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
if (patternsRaw == null) {
|
||||||
|
return isDefaultAuthFailureBanner(trimmed) ? trimmed : null;
|
||||||
|
}
|
||||||
|
// Operator override path: empty/whitespace disables; otherwise use only their regexes.
|
||||||
|
const patterns = compileTuiErrorPatterns(patternsRaw);
|
||||||
|
if (patterns.length === 0) return null;
|
||||||
|
for (const re of patterns) {
|
||||||
|
if (re.test(trimmed)) return trimmed;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Block until the session transcript is terminal (turn_duration) or
|
// Block until the session transcript is terminal (turn_duration / final
|
||||||
// the wall-clock cap elapses, polling the file (no fs.watch — robust over NFS /
|
// stop_reason) or the wall-clock cap elapses, polling the file (no fs.watch —
|
||||||
// editors). Returns { text, entrypoint } where text is the latest assistant text
|
// robust over NFS / editors). Returns { text, entrypoint, truncated }:
|
||||||
// and entrypoint is the billing-pool classifier from the turn_duration line (e.g.
|
// - text: latest assistant text.
|
||||||
// "cli"), or null if not yet present. On cap with text, returns the partial result;
|
// - entrypoint: billing-pool classifier (see verifyEntrypoint), or null.
|
||||||
// on cap with no text at all, throws.
|
// - truncated: FALSE when a terminal marker was reached (the turn completed);
|
||||||
|
// TRUE when the wall-clock cap was hit with partial text but NO
|
||||||
|
// terminal marker (the turn is INCOMPLETE — what we have is a
|
||||||
|
// cut-off prefix). (C-2, issue #133.)
|
||||||
|
//
|
||||||
|
// Why `truncated` matters: previously the terminal-marker path and the
|
||||||
|
// cap-with-partial-text path BOTH returned `{text, entrypoint}` identically, so
|
||||||
|
// callClaudeTui could not tell a complete answer from a truncated one and cached +
|
||||||
|
// returned the partial as finish_reason:stop (silent success). The caller now
|
||||||
|
// throws on `truncated` so a cut-off turn is neither cached nor counted as success.
|
||||||
|
// The field is additive — existing call sites that ignore it keep working.
|
||||||
|
//
|
||||||
|
// On cap with NO text at all, still throws (unchanged) — there is nothing to return.
|
||||||
//
|
//
|
||||||
// No quiescence heuristic by design: a long Opus thinking turn stalls transcript
|
// No quiescence heuristic by design: a long Opus thinking turn stalls transcript
|
||||||
// growth and a "file stable for N s" rule would false-abort it (spec §4.3).
|
// growth and a "file stable for N s" rule would false-abort it (spec §4.3).
|
||||||
@@ -135,10 +280,14 @@ export async function readTuiTranscript({ transcriptPath: p, home, sessionId, wa
|
|||||||
lastText = extractLatestAssistantText(events) || lastText;
|
lastText = extractLatestAssistantText(events) || lastText;
|
||||||
const ep = verifyEntrypoint(events);
|
const ep = verifyEntrypoint(events);
|
||||||
if (ep != null) lastEntrypoint = ep;
|
if (ep != null) lastEntrypoint = ep;
|
||||||
if (events.some(isTerminalLine)) return { text: lastText, entrypoint: lastEntrypoint };
|
// Terminal marker reached → the turn is COMPLETE.
|
||||||
|
if (events.some(isTerminalLine)) return { text: lastText, entrypoint: lastEntrypoint, truncated: false };
|
||||||
}
|
}
|
||||||
await sleep(pollMs);
|
await sleep(pollMs);
|
||||||
}
|
}
|
||||||
if (lastText) return { text: lastText, entrypoint: lastEntrypoint };
|
// Cap elapsed with no terminal marker. If we have partial text, flag it truncated
|
||||||
|
// so the caller rejects it (don't cache / don't count as success). No text at all
|
||||||
|
// → throw (nothing to return).
|
||||||
|
if (lastText) return { text: lastText, entrypoint: lastEntrypoint, truncated: true };
|
||||||
throw new Error("tui_transcript_timeout: no assistant text within wallclock cap");
|
throw new Error("tui_transcript_timeout: no assistant text within wallclock cap");
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "open-claude-proxy",
|
"name": "open-claude-proxy",
|
||||||
"version": "3.19.0",
|
"version": "3.20.0",
|
||||||
"description": "OCP (Open Claude Proxy) — use your Claude Pro/Max subscription as an OpenAI-compatible API for any IDE. Works with Cline, OpenCode, Aider, Continue.dev, OpenClaw, and more.",
|
"description": "OCP (Open Claude Proxy) — use your Claude Pro/Max subscription as an OpenAI-compatible API for any IDE. Works with Cline, OpenCode, Aider, Continue.dev, OpenClaw, and more.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
+91
-6
@@ -19,7 +19,8 @@
|
|||||||
* CLAUDE_SYSTEM_PROMPT — system prompt appended to all requests
|
* CLAUDE_SYSTEM_PROMPT — system prompt appended to all requests
|
||||||
* CLAUDE_MCP_CONFIG — path to MCP server config JSON file
|
* CLAUDE_MCP_CONFIG — path to MCP server config JSON file
|
||||||
* CLAUDE_SESSION_TTL — session TTL in ms (default: 3600000 = 1h)
|
* CLAUDE_SESSION_TTL — session TTL in ms (default: 3600000 = 1h)
|
||||||
* CLAUDE_MAX_CONCURRENT — max concurrent claude processes (default: 8)
|
* CLAUDE_MAX_CONCURRENT — max concurrent claude processes, -p/stream-json path (default: 8)
|
||||||
|
* OCP_TUI_MAX_CONCURRENT — max concurrent interactive TUI turns, TUI-mode path (default: 2)
|
||||||
* CLAUDE_BREAKER_THRESHOLD — failures in window before circuit opens (default: 6)
|
* CLAUDE_BREAKER_THRESHOLD — failures in window before circuit opens (default: 6)
|
||||||
* CLAUDE_BREAKER_COOLDOWN — base ms to wait before retrying after circuit opens (default: 120000)
|
* CLAUDE_BREAKER_COOLDOWN — base ms to wait before retrying after circuit opens (default: 120000)
|
||||||
* CLAUDE_BREAKER_WINDOW — sliding window duration in ms (default: 300000 = 5min)
|
* CLAUDE_BREAKER_WINDOW — sliding window duration in ms (default: 300000 = 5min)
|
||||||
@@ -38,6 +39,8 @@ import { validateKey, recordUsage, getUsageByKey, getUsageTimeline, getRecentUsa
|
|||||||
import { DEFAULT_PORT } from "./lib/constants.mjs";
|
import { DEFAULT_PORT } from "./lib/constants.mjs";
|
||||||
import { isLoopbackBind } from "./lib/net.mjs";
|
import { isLoopbackBind } from "./lib/net.mjs";
|
||||||
import { runTuiTurn, reapStaleTuiSessions } from "./lib/tui/session.mjs";
|
import { runTuiTurn, reapStaleTuiSessions } from "./lib/tui/session.mjs";
|
||||||
|
import { detectTuiUpstreamError } from "./lib/tui/transcript.mjs";
|
||||||
|
import { TuiSemaphore, recordTuiEntrypoint, buildTuiHealthBlock } from "./lib/tui/semaphore.mjs";
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const _pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8"));
|
const _pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8"));
|
||||||
@@ -299,6 +302,22 @@ const TUI_WALLCLOCK_MS = parseInt(process.env.CLAUDE_TUI_WALLCLOCK_MS || "120000
|
|||||||
const TUI_CWD = process.env.OCP_TUI_CWD || `${process.env.HOME}/.ocp-tui/work`;
|
const TUI_CWD = process.env.OCP_TUI_CWD || `${process.env.HOME}/.ocp-tui/work`;
|
||||||
const TUI_HOME = process.env.OCP_TUI_HOME || process.env.HOME;
|
const TUI_HOME = process.env.OCP_TUI_HOME || process.env.HOME;
|
||||||
const TUI_ENTRYPOINT = process.env.OCP_TUI_ENTRYPOINT || "cli"; // cli|auto|off — see ADR 0007
|
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
|
||||||
|
};
|
||||||
|
|
||||||
// SECURITY fail-loud: TUI-mode is incompatible with any configuration that allows
|
// SECURITY fail-loud: TUI-mode is incompatible with any configuration that allows
|
||||||
// non-operator prompts to reach the interactive claude session. Three cases:
|
// non-operator prompts to reach the interactive claude session. Three cases:
|
||||||
@@ -476,6 +495,28 @@ const cacheCleanupInterval = setInterval(() => {
|
|||||||
}
|
}
|
||||||
}, 600000);
|
}, 600000);
|
||||||
|
|
||||||
|
// TUI defunct-session reap (periodic): the boot reap (below) only fires once, but a
|
||||||
|
// long-lived host (PI231 ran 30 days without restart) accumulates defunct `<claude>`
|
||||||
|
// zombies between restarts — the pane's claude is a child of the tmux server, not node,
|
||||||
|
// so only the server can reap it (see reapStaleTuiSessions). We sweep every 15 min, but
|
||||||
|
// ONLY when the TUI path is fully idle: reapStaleTuiSessions may `kill-server`, which would
|
||||||
|
// tear down a live turn's pane, so we skip the sweep while any turn is inflight or queued.
|
||||||
|
// RESIDUAL (documented, accepted): a brand-new request whose pane is created in the narrow
|
||||||
|
// window between this idle-check and kill-server would have its pane torn down and fail the
|
||||||
|
// turn cleanly via runTuiTurn's existing honesty gates (rare; the boot reap is the primary
|
||||||
|
// mechanism and the 15-min cadence makes the window negligible).
|
||||||
|
// Gated on TUI_MODE — zero effect (no kill-server, no list-sessions) when TUI is off.
|
||||||
|
// cli.js does NOT perform this operation (Class B, OCP-owned TUI spawn) — see ADR 0007.
|
||||||
|
const TUI_REAP_INTERVAL_MS = 15 * 60 * 1000;
|
||||||
|
const tuiReapInterval = TUI_MODE ? setInterval(() => {
|
||||||
|
if (tuiSemaphore.inflight > 0 || tuiSemaphore.queued > 0) return; // a turn is live — defer
|
||||||
|
try {
|
||||||
|
const n = reapStaleTuiSessions();
|
||||||
|
if (n) logEvent("info", "tui_reaped_stale_sessions", { count: n, trigger: "periodic" });
|
||||||
|
} catch (e) { logEvent("error", "tui_periodic_reap_failed", { error: e.message }); }
|
||||||
|
}, TUI_REAP_INTERVAL_MS) : null;
|
||||||
|
if (tuiReapInterval && typeof tuiReapInterval.unref === "function") tuiReapInterval.unref();
|
||||||
|
|
||||||
// ── Active child process tracking ────────────────────────────────────────
|
// ── Active child process tracking ────────────────────────────────────────
|
||||||
const activeProcesses = new Set();
|
const activeProcesses = new Set();
|
||||||
|
|
||||||
@@ -917,7 +958,11 @@ function callClaudeTui(model, messages, _conversationId, _keyName) {
|
|||||||
const cliModel = MODEL_MAP[model] || model;
|
const cliModel = MODEL_MAP[model] || model;
|
||||||
const prompt = messagesToPrompt(messages); // includes system as [System] inline
|
const prompt = messagesToPrompt(messages); // includes system as [System] inline
|
||||||
recordModelRequest(cliModel, prompt.length);
|
recordModelRequest(cliModel, prompt.length);
|
||||||
return runTuiTurn({
|
// C-4: gate the heavy interactive boot behind the TUI semaphore. run() acquires a slot
|
||||||
|
// (queuing if all are busy, up to maxQueue), then releases in a finally so any throw from
|
||||||
|
// runTuiTurn (tmux spawn failure, paste-not-landed) OR from the honesty gates below
|
||||||
|
// (truncation / error banner) can NEVER leak a slot. tuiSemaphore.inflight feeds /health.
|
||||||
|
return tuiSemaphore.run(() => runTuiTurn({
|
||||||
prompt,
|
prompt,
|
||||||
model: cliModel,
|
model: cliModel,
|
||||||
claudeBin: CLAUDE,
|
claudeBin: CLAUDE,
|
||||||
@@ -926,19 +971,47 @@ function callClaudeTui(model, messages, _conversationId, _keyName) {
|
|||||||
cwd: TUI_CWD,
|
cwd: TUI_CWD,
|
||||||
wallclockMs: TUI_WALLCLOCK_MS,
|
wallclockMs: TUI_WALLCLOCK_MS,
|
||||||
entrypointMode: TUI_ENTRYPOINT,
|
entrypointMode: TUI_ENTRYPOINT,
|
||||||
}).then(({ text, entrypoint }) => {
|
}).then(({ text, entrypoint, truncated }) => {
|
||||||
|
// ── Honesty gates (issue #133) ─ run BEFORE recordModelSuccess / cache write-back.
|
||||||
|
// A throw here propagates to the .catch below (recordModelError + reject), so the
|
||||||
|
// result never reaches the downstream setCachedResponse / singleflight / SUCCESS path.
|
||||||
|
|
||||||
|
// C-2: the wall-clock cap hit with partial text and NO terminal marker — the turn
|
||||||
|
// is INCOMPLETE. Returning the cut-off prefix would cache it and report it as
|
||||||
|
// finish_reason:stop (a truncated answer served as a complete one). Reject instead.
|
||||||
|
if (truncated) {
|
||||||
|
logEvent("error", "tui_wallclock_truncated", { model: cliModel, chars: (text || "").length, wallclockMs: TUI_WALLCLOCK_MS });
|
||||||
|
throw new Error("tui_wallclock_truncated: turn hit the wall-clock cap before completing; partial text dropped");
|
||||||
|
}
|
||||||
|
|
||||||
|
// C-1: the interactive claude CLI renders in-session errors (expired/invalid
|
||||||
|
// credentials, transient API failure) as ordinary assistant text. Returning that
|
||||||
|
// banner would cache an error AS an answer and record a model SUCCESS. Detect a
|
||||||
|
// known error banner (anchored whole-text match — see detectTuiUpstreamError) and
|
||||||
|
// reject so it does NOT enter the cache and the client gets a 5xx.
|
||||||
|
const banner = detectTuiUpstreamError(text);
|
||||||
|
if (banner) {
|
||||||
|
logEvent("error", "tui_upstream_error", { model: cliModel, banner: banner.slice(0, 200) });
|
||||||
|
throw new Error("tui_upstream_error: claude CLI returned an in-session error banner instead of an answer");
|
||||||
|
}
|
||||||
|
|
||||||
recordModelSuccess(cliModel, 0); // elapsed not measurable here; wallclock at reader level
|
recordModelSuccess(cliModel, 0); // elapsed not measurable here; wallclock at reader level
|
||||||
// Assert the subscription-pool classification. TUI exists to keep cc_entrypoint=cli
|
// Assert the subscription-pool classification. TUI exists to keep cc_entrypoint=cli
|
||||||
// (subscription pool); a silent degrade to sdk-cli (metered Agent SDK pool) would still
|
// (subscription pool); a silent degrade to sdk-cli (metered Agent SDK pool) would still
|
||||||
// return text but cost money — warn loudly so it's visible. (issue #115)
|
// return text but cost money — warn loudly so it's visible. (issue #115)
|
||||||
if (TUI_ENTRYPOINT === "cli" && entrypoint !== "cli") {
|
// C-5: also surface the observation on /health. recordTuiEntrypoint sets lastEntrypoint
|
||||||
|
// unconditionally (operators can poll it to confirm cli) and increments
|
||||||
|
// entrypointMismatches when expected=cli but observed≠cli — the same condition the
|
||||||
|
// journald warning already covers — so a silent metered-pool drift is visible on /health
|
||||||
|
// without tailing logs.
|
||||||
|
if (recordTuiEntrypoint(tuiStats, entrypoint, TUI_ENTRYPOINT)) {
|
||||||
logEvent("warn", "tui_entrypoint_mismatch", { expected: "cli", got: entrypoint, model: cliModel });
|
logEvent("warn", "tui_entrypoint_mismatch", { expected: "cli", got: entrypoint, model: cliModel });
|
||||||
}
|
}
|
||||||
return text;
|
return text;
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
recordModelError(cliModel, false);
|
recordModelError(cliModel, false);
|
||||||
throw err;
|
throw err;
|
||||||
});
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── SSE heartbeat (opt-in idle watchdog) ────────────────────────────────
|
// ── SSE heartbeat (opt-in idle watchdog) ────────────────────────────────
|
||||||
@@ -1990,6 +2063,17 @@ const server = createServer(async (req, res) => {
|
|||||||
circuitBreaker: "disabled",
|
circuitBreaker: "disabled",
|
||||||
sessions: sessionList,
|
sessions: sessionList,
|
||||||
recentErrors: recentErrors.slice(-5),
|
recentErrors: recentErrors.slice(-5),
|
||||||
|
// ── 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 +2306,7 @@ function gracefulShutdown(signal) {
|
|||||||
clearInterval(sessionCleanupInterval);
|
clearInterval(sessionCleanupInterval);
|
||||||
clearInterval(authCheckInterval);
|
clearInterval(authCheckInterval);
|
||||||
clearInterval(cacheCleanupInterval);
|
clearInterval(cacheCleanupInterval);
|
||||||
|
if (tuiReapInterval) clearInterval(tuiReapInterval);
|
||||||
closeDb();
|
closeDb();
|
||||||
|
|
||||||
// 3. Kill all active child processes
|
// 3. Kill all active child processes
|
||||||
@@ -2280,7 +2365,7 @@ server.listen(PORT, BIND_ADDRESS, () => {
|
|||||||
else console.log(`Cache: disabled (set CLAUDE_CACHE_TTL to enable)`);
|
else console.log(`Cache: disabled (set CLAUDE_CACHE_TTL to enable)`);
|
||||||
if (TUI_MODE) {
|
if (TUI_MODE) {
|
||||||
console.warn(`⚠️ TUI-mode ON — single-user only; do NOT enable on a multi-user OCP (guest prompts would run claude with operator filesystem access). See ADR 0007.`);
|
console.warn(`⚠️ TUI-mode ON — single-user only; do NOT enable on a multi-user OCP (guest prompts would run claude with operator filesystem access). See ADR 0007.`);
|
||||||
console.log(` TUI-mode: ON home=${TUI_HOME} cwd=${TUI_CWD} wallclock=${TUI_WALLCLOCK_MS}ms`);
|
console.log(` TUI-mode: ON home=${TUI_HOME} cwd=${TUI_CWD} wallclock=${TUI_WALLCLOCK_MS}ms maxConcurrent=${TUI_MAX_CONCURRENT}`);
|
||||||
try {
|
try {
|
||||||
const n = reapStaleTuiSessions();
|
const n = reapStaleTuiSessions();
|
||||||
if (n) logEvent("info", "tui_reaped_stale_sessions", { count: n });
|
if (n) logEvent("info", "tui_reaped_stale_sessions", { count: n });
|
||||||
|
|||||||
@@ -141,18 +141,26 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check claude auth (quick test)
|
// Check claude auth (quick test)
|
||||||
try {
|
// NOTE: This probe uses `claude -p` (sdk-cli spawn). After the 2026-06-15 Anthropic billing
|
||||||
const out = execSync('claude -p --output-format text --no-session-persistence -- "ping"', {
|
// split, every `claude -p` call draws from the Agent SDK credit pool rather than the
|
||||||
encoding: "utf-8",
|
// Pro/Max subscription. Re-running setup after 6/15 will consume one metered credit.
|
||||||
timeout: 30000,
|
// Set OCP_SKIP_AUTH_TEST=1 to skip this probe (auth is still validated at first real request).
|
||||||
env: { ...process.env, CLAUDECODE: undefined, ANTHROPIC_API_KEY: undefined, ANTHROPIC_BASE_URL: undefined, ANTHROPIC_AUTH_TOKEN: undefined },
|
if (process.env.OCP_SKIP_AUTH_TEST === "1") {
|
||||||
}).trim();
|
warn("OCP_SKIP_AUTH_TEST=1 — skipping claude auth probe (will be validated at first request).");
|
||||||
if (out.length > 0) {
|
} else {
|
||||||
log(`Claude CLI authenticated (test response: "${out.slice(0, 40)}...")`);
|
try {
|
||||||
|
const out = execSync('claude -p --output-format text --no-session-persistence -- "ping"', {
|
||||||
|
encoding: "utf-8",
|
||||||
|
timeout: 30000,
|
||||||
|
env: { ...process.env, CLAUDECODE: undefined, ANTHROPIC_API_KEY: undefined, ANTHROPIC_BASE_URL: undefined, ANTHROPIC_AUTH_TOKEN: undefined },
|
||||||
|
}).trim();
|
||||||
|
if (out.length > 0) {
|
||||||
|
log(`Claude CLI authenticated (test response: "${out.slice(0, 40)}...")`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
warn(`Claude CLI auth test failed: ${e.message.slice(0, 100)}`);
|
||||||
|
warn("Make sure you're logged in: claude login");
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
warn(`Claude CLI auth test failed: ${e.message.slice(0, 100)}`);
|
|
||||||
warn("Make sure you're logged in: claude login");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check openclaw config (optional — OCP runs standalone without OpenClaw)
|
// Check openclaw config (optional — OCP runs standalone without OpenClaw)
|
||||||
|
|||||||
+466
-4
@@ -1340,7 +1340,7 @@ test("streamStringAsSSE empty content: role + stop + [DONE] only", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── Suite: TUI transcript reader ────────────────────────────────────────
|
// ── Suite: TUI transcript reader ────────────────────────────────────────
|
||||||
import { encodeCwd, transcriptPath, findTranscriptPath, parseTranscriptLines, isTerminalLine, extractLatestAssistantText, verifyEntrypoint } from "./lib/tui/transcript.mjs";
|
import { 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 { readFileSync as tuiReadFileSync, mkdtempSync as tuiMkdtemp0, mkdirSync as tuiMkdir0, writeFileSync as tuiWrite0 } from "node:fs";
|
||||||
import { tmpdir as tuiTmp0 } from "node:os";
|
import { tmpdir as tuiTmp0 } from "node:os";
|
||||||
|
|
||||||
@@ -1436,6 +1436,173 @@ test("real complete fixture: verifyEntrypoint returns 'cli'", () => {
|
|||||||
assert.equal(verifyEntrypoint(evs), "cli");
|
assert.equal(verifyEntrypoint(evs), "cli");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── C-3 (#133): verifyEntrypoint is version-robust ───────────────────────
|
||||||
|
// Some claude builds do NOT emit a turn_duration line; entrypoint lives on
|
||||||
|
// ordinary lines on BOTH emitting and non-emitting builds. Reading ONLY
|
||||||
|
// turn_duration made the server.mjs tui_entrypoint_mismatch assertion get null
|
||||||
|
// every turn on non-emitting builds. verifyEntrypoint must fall back to ANY line.
|
||||||
|
console.log("\nTUI transcript — verifyEntrypoint version-robustness (C-3, #133):");
|
||||||
|
|
||||||
|
test("verifyEntrypoint PREFERS the turn_duration line's entrypoint", () => {
|
||||||
|
// turn_duration says "cli"; an earlier ordinary line says "sdk-cli" — the
|
||||||
|
// authoritative turn_duration value must win, not last-writer-wins on the fallback.
|
||||||
|
const evs = [
|
||||||
|
{ type: "assistant", entrypoint: "sdk-cli", message: { content: [{ type: "text", text: "x" }] } },
|
||||||
|
{ type: "system", subtype: "turn_duration", entrypoint: "cli" },
|
||||||
|
];
|
||||||
|
assert.equal(verifyEntrypoint(evs), "cli");
|
||||||
|
});
|
||||||
|
test("verifyEntrypoint falls back to entrypoint on an ordinary assistant line when no turn_duration", () => {
|
||||||
|
const evs = [
|
||||||
|
{ type: "user", entrypoint: "cli", message: { content: "hi" } },
|
||||||
|
{ type: "assistant", entrypoint: "cli", message: { stop_reason: "end_turn", content: [{ type: "text", text: "ok" }] } },
|
||||||
|
];
|
||||||
|
assert.equal(verifyEntrypoint(evs), "cli");
|
||||||
|
});
|
||||||
|
test("verifyEntrypoint returns null when NO line carries an entrypoint", () => {
|
||||||
|
const evs = [
|
||||||
|
{ type: "assistant", message: { stop_reason: "end_turn", content: [{ type: "text", text: "ok" }] } },
|
||||||
|
];
|
||||||
|
assert.equal(verifyEntrypoint(evs), null);
|
||||||
|
});
|
||||||
|
test("real no-turn_duration fixture: verifyEntrypoint still resolves 'cli' (was null before C-3)", () => {
|
||||||
|
const evs = parseTranscriptLines(tuiReadFileSync("./lib/tui/fixtures/no-turn-duration.jsonl", "utf8"));
|
||||||
|
// Sanity: the fixture genuinely lacks a turn_duration line (so this exercises the fallback).
|
||||||
|
assert.ok(!evs.some((e) => e && e.type === "system" && e.subtype === "turn_duration"), "fixture must NOT emit turn_duration");
|
||||||
|
assert.equal(verifyEntrypoint(evs), "cli");
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── C-1 (#133): honest AUTH-FAILURE banner detection ─────────────────────
|
||||||
|
// The interactive claude CLI renders in-session errors as ordinary assistant text.
|
||||||
|
// C-1 catches the R-1 case: expired/invalid creds, where EVERY turn returns the same
|
||||||
|
// one-line auth-failure banner and OCP would cache it as a real answer. The detector
|
||||||
|
// is deliberately NARROW/conservative: a false-positive (killing a real long answer
|
||||||
|
// that merely DISCUSSES an API error) costs the user a missing answer + a double-burn
|
||||||
|
// retry, which is worse than the rare false-negative (caching one transient error for
|
||||||
|
// the 5-min TTL). Signal = ALL of: SHORT whole-message (≤100; live samples 69/73) AND
|
||||||
|
// "API Error: 4xx" AND an auth keyword (authenticat | /login | credential) AND NO
|
||||||
|
// backtick/quote char. When unsure → PASS. The earlier generalised rule
|
||||||
|
// (^<short-prefix>?API Error:\d{3}.*$) was TOO BROAD: its unbounded .* tail killed
|
||||||
|
// legit long answers; this block encodes the full narrowed matrix.
|
||||||
|
console.log("\nTUI transcript — auth-failure banner detection (C-1, #133):");
|
||||||
|
|
||||||
|
// ---- Required matrix: MUST detect (kill) ----
|
||||||
|
test("C-1 KILL: live /login 401 auth banner", () => {
|
||||||
|
const banner = "Please run /login · API Error: 401 Invalid authentication credentials";
|
||||||
|
assert.equal(detectTuiUpstreamError(banner), banner);
|
||||||
|
});
|
||||||
|
test("C-1 KILL: live 'Failed to authenticate.' 401 banner variant", () => {
|
||||||
|
// Second real PI231 banner: a different short auth-failure prefix before the same
|
||||||
|
// "API Error: 4xx" core. Still short, still 4xx, still has 'authenticate'/'credentials'.
|
||||||
|
const banner = "Failed to authenticate. API Error: 401 Invalid authentication credentials";
|
||||||
|
assert.equal(detectTuiUpstreamError(banner), banner);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Required matrix: MUST NOT kill (pass) ----
|
||||||
|
test("C-1 PASS: long answer discussing a 500 (not 4xx, too long)", () => {
|
||||||
|
// The exact false-positive the over-broad .* rule produced. 166 chars; 5xx.
|
||||||
|
const legit = "API Error: 500 happened because the server was overloaded. To fix this, retry with exponential backoff and verify your rate limits before resending the request again.";
|
||||||
|
assert.equal(detectTuiUpstreamError(legit), null);
|
||||||
|
});
|
||||||
|
test("C-1 PASS: long answer with 'API Error: 401 details' (too long, no auth keyword)", () => {
|
||||||
|
// 142 chars; the literal word 'authenticate'/'credential'/'/login' never appears, and
|
||||||
|
// it is far over the length cap — rejected on length AND keyword.
|
||||||
|
const legit = "Failed to parse the config. Here are the API Error: 401 details you asked about: the token expired and must be refreshed before the next call.";
|
||||||
|
assert.equal(detectTuiUpstreamError(legit), null);
|
||||||
|
});
|
||||||
|
test("C-1 PASS: 'To debug a 401 … API Error: 401 Unauthorized' (no auth keyword)", () => {
|
||||||
|
// 91 chars (short!) and 4xx, but 'Unauthorized' is authoriz-, not authenticat-, and
|
||||||
|
// there is no /login or credential — the auth-keyword signal rejects it.
|
||||||
|
const legit = "To debug a 401: the server returns API Error: 401 Unauthorized, then you refresh the token.";
|
||||||
|
assert.equal(detectTuiUpstreamError(legit), null);
|
||||||
|
});
|
||||||
|
test("C-1 PASS: handler answer logging 'API Error: 503' (not 4xx)", () => {
|
||||||
|
const legit = "Here is the handler you asked for. It logs the string API Error: 503 on failure and retries.";
|
||||||
|
assert.equal(detectTuiUpstreamError(legit), null);
|
||||||
|
});
|
||||||
|
test("C-1 PASS: short instructional answer quoting `API Error: 401` + /login (has backtick)", () => {
|
||||||
|
// 75 chars: short, 4xx, has '/login' — passes signals 1-3. Rejected ONLY by the
|
||||||
|
// backtick/quote constraint: it QUOTES the error in code formatting, it is not the banner.
|
||||||
|
const legit = "You'll see `API Error: 401` when your token expires — run /login to fix it.";
|
||||||
|
assert.equal(detectTuiUpstreamError(legit), null);
|
||||||
|
});
|
||||||
|
test("C-1 PASS: bare HTTP-status sentence (no 'API Error:' core)", () => {
|
||||||
|
assert.equal(detectTuiUpstreamError("HTTP 401 means unauthorized."), null);
|
||||||
|
});
|
||||||
|
test("C-1 PASS: plain unrelated answer", () => {
|
||||||
|
assert.equal(detectTuiUpstreamError("The capital of France is Paris."), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Supporting / regression coverage ----
|
||||||
|
test("C-1 PASS: transient 5xx banner is NOT detected (narrowed to 4xx auth only)", () => {
|
||||||
|
// The old rule flagged any 3-digit code; the narrowed detector is 4xx-only by design
|
||||||
|
// (5xx is transient/server-side, not the R-1 auth case). Accepted false-negative.
|
||||||
|
assert.equal(detectTuiUpstreamError("API Error: 500 Internal Server Error"), null);
|
||||||
|
});
|
||||||
|
test("C-1 PASS: bare 4xx with no auth keyword is NOT detected", () => {
|
||||||
|
// 'API Error: 403 Forbidden' alone — 4xx and short, but no authenticat/login/credential.
|
||||||
|
assert.equal(detectTuiUpstreamError("API Error: 403 Forbidden"), null);
|
||||||
|
});
|
||||||
|
test("detectTuiUpstreamError trims surrounding whitespace before matching", () => {
|
||||||
|
const out = detectTuiUpstreamError("\n\n Please run /login · API Error: 401 credential boom \n");
|
||||||
|
assert.equal(out, "Please run /login · API Error: 401 credential boom");
|
||||||
|
});
|
||||||
|
test("detectTuiUpstreamError is case-insensitive on the banner keywords", () => {
|
||||||
|
// lower-cased: /login + api error: 401 + 'credential' keyword, short, no code char.
|
||||||
|
assert.ok(detectTuiUpstreamError("please run /login · api error: 401 bad credential") !== null);
|
||||||
|
});
|
||||||
|
test("detectTuiUpstreamError does NOT match prose that mentions an API error mid-paragraph (#133 regression guard)", () => {
|
||||||
|
// A long, legit answer that merely discusses an API error — rejected on length alone.
|
||||||
|
const para = "When integrating with the upstream service you may occasionally hit an API Error: 401 response if the bearer token has lapsed; the recommended remediation is to re-run the login flow and retry the request with a fresh credential, after which the 401 should clear.";
|
||||||
|
assert.equal(detectTuiUpstreamError(para), null);
|
||||||
|
});
|
||||||
|
test("detectTuiUpstreamError does NOT match a long plain-text auth answer with NO code chars (length cap is load-bearing)", () => {
|
||||||
|
// 226 chars, no backtick/quote, has 4xx + /login + credential + authenticate — passes
|
||||||
|
// signals 2-4. ONLY the length cap rejects it. Guards against dropping the cap.
|
||||||
|
const para = "If you call the endpoint without a bearer token the API Error: 401 response tells you the credential is missing; just authenticate again with /login and the request will succeed on the next attempt without any further changes.";
|
||||||
|
assert.equal(detectTuiUpstreamError(para), null);
|
||||||
|
});
|
||||||
|
test("detectTuiUpstreamError returns null on empty / whitespace / non-string", () => {
|
||||||
|
assert.equal(detectTuiUpstreamError(""), null);
|
||||||
|
assert.equal(detectTuiUpstreamError(" \n "), null);
|
||||||
|
assert.equal(detectTuiUpstreamError(null), null);
|
||||||
|
assert.equal(detectTuiUpstreamError(undefined), null);
|
||||||
|
assert.equal(detectTuiUpstreamError(42), null);
|
||||||
|
});
|
||||||
|
test("detectTuiUpstreamError respects CLAUDE_TUI_ERROR_PATTERNS override (custom banner)", () => {
|
||||||
|
// Override with a single custom pattern; the default 401 banner no longer matches,
|
||||||
|
// but the custom one does (anchored whole-text).
|
||||||
|
assert.equal(detectTuiUpstreamError("Please run /login · API Error: 401 x", "Session expired, please re-auth"), null);
|
||||||
|
assert.equal(detectTuiUpstreamError("Session expired, please re-auth", "Session expired, please re-auth"), "Session expired, please re-auth");
|
||||||
|
});
|
||||||
|
test("detectTuiUpstreamError with an empty override disables detection (escape hatch)", () => {
|
||||||
|
assert.equal(detectTuiUpstreamError("API Error: 500 boom", ""), null);
|
||||||
|
assert.equal(detectTuiUpstreamError("API Error: 500 boom", " "), null);
|
||||||
|
});
|
||||||
|
test("detectTuiUpstreamError override accepts '||'-separated patterns", () => {
|
||||||
|
const raw = "First banner||Second banner";
|
||||||
|
assert.equal(detectTuiUpstreamError("First banner", raw), "First banner");
|
||||||
|
assert.equal(detectTuiUpstreamError("Second banner", raw), "Second banner");
|
||||||
|
assert.equal(detectTuiUpstreamError("Third", raw), null);
|
||||||
|
});
|
||||||
|
test("real error fixture: latest assistant text IS the banner and detectTuiUpstreamError flags it", () => {
|
||||||
|
const evs = parseTranscriptLines(tuiReadFileSync("./lib/tui/fixtures/error-401.jsonl", "utf8"));
|
||||||
|
const text = extractLatestAssistantText(evs);
|
||||||
|
assert.equal(text, "Please run /login · API Error: 401 Invalid authentication credentials");
|
||||||
|
assert.ok(detectTuiUpstreamError(text) !== null, "error fixture's final turn must be flagged as an upstream error");
|
||||||
|
});
|
||||||
|
test("real error fixture (Failed-to-authenticate variant): final turn is flagged (#133 runtime gap)", () => {
|
||||||
|
const evs = parseTranscriptLines(tuiReadFileSync("./lib/tui/fixtures/error-401-failauth.jsonl", "utf8"));
|
||||||
|
const text = extractLatestAssistantText(evs);
|
||||||
|
assert.equal(text, "Failed to authenticate. API Error: 401 Invalid authentication credentials");
|
||||||
|
assert.ok(detectTuiUpstreamError(text) !== null, "Failed-to-authenticate banner must be flagged as an upstream error");
|
||||||
|
});
|
||||||
|
test("real complete fixture: final answer is NOT flagged as an upstream error", () => {
|
||||||
|
const evs = parseTranscriptLines(tuiReadFileSync("./lib/tui/fixtures/complete-haiku.jsonl", "utf8"));
|
||||||
|
const text = extractLatestAssistantText(evs);
|
||||||
|
assert.equal(detectTuiUpstreamError(text), null);
|
||||||
|
});
|
||||||
|
|
||||||
// ── TUI transcript — polling reader (async) ──────────────────────────────
|
// ── TUI transcript — polling reader (async) ──────────────────────────────
|
||||||
import { readTuiTranscript } from "./lib/tui/transcript.mjs";
|
import { readTuiTranscript } from "./lib/tui/transcript.mjs";
|
||||||
import { mkdtempSync as tuiMkdtemp, writeFileSync as tuiWriteFile } from "node:fs";
|
import { mkdtempSync as tuiMkdtemp, writeFileSync as tuiWriteFile } from "node:fs";
|
||||||
@@ -1455,12 +1622,30 @@ await asyncTest("readTuiTranscript returns assistant text when terminal marker p
|
|||||||
assert.equal(out.entrypoint, "cli");
|
assert.equal(out.entrypoint, "cli");
|
||||||
});
|
});
|
||||||
|
|
||||||
await asyncTest("readTuiTranscript honours wall-clock cap and returns partial text", async () => {
|
// C-2 (#133): the terminal-marker path must signal a COMPLETE turn.
|
||||||
|
await asyncTest("readTuiTranscript signals truncated:false when a terminal marker is hit (complete turn)", async () => {
|
||||||
const dir = tuiMkdtemp(`${tuiTmpdir()}/tui-`);
|
const dir = tuiMkdtemp(`${tuiTmpdir()}/tui-`);
|
||||||
const p = `${dir}/s.jsonl`;
|
const p = `${dir}/s.jsonl`;
|
||||||
|
tuiWriteFile(p, [
|
||||||
|
JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "done" }] } }),
|
||||||
|
JSON.stringify({ type: "system", subtype: "turn_duration", durationMs: 1200, entrypoint: "cli" }),
|
||||||
|
].join("\n") + "\n");
|
||||||
|
const out = await readTuiTranscript({ transcriptPath: p, wallclockMs: 2000, pollMs: 50 });
|
||||||
|
assert.equal(out.truncated, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// C-2 (#133): cap-with-partial-text must be DISTINGUISHABLE from a complete turn.
|
||||||
|
// Previously both returned {text, entrypoint} identically and the partial was cached
|
||||||
|
// + returned as finish_reason:stop. The cap path now returns truncated:true so the
|
||||||
|
// caller (callClaudeTui) can throw instead of serving a cut-off answer.
|
||||||
|
await asyncTest("readTuiTranscript honours wall-clock cap and flags partial text truncated:true", async () => {
|
||||||
|
const dir = tuiMkdtemp(`${tuiTmpdir()}/tui-`);
|
||||||
|
const p = `${dir}/s.jsonl`;
|
||||||
|
// No terminal marker → reader will spin to the cap then return the partial.
|
||||||
tuiWriteFile(p, JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "partial" }] } }) + "\n");
|
tuiWriteFile(p, JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "partial" }] } }) + "\n");
|
||||||
const out = await readTuiTranscript({ transcriptPath: p, wallclockMs: 300, pollMs: 50 });
|
const out = await readTuiTranscript({ transcriptPath: p, wallclockMs: 300, pollMs: 50 });
|
||||||
assert.equal(out.text, "partial");
|
assert.equal(out.text, "partial");
|
||||||
|
assert.equal(out.truncated, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
await asyncTest("readTuiTranscript against real fixture: entrypoint is 'cli'", async () => {
|
await asyncTest("readTuiTranscript against real fixture: entrypoint is 'cli'", async () => {
|
||||||
@@ -1506,6 +1691,96 @@ test("buildTuiCmd keeps version pin + entrypoint label + MCP wall", () => {
|
|||||||
assert.ok(/-u CLAUDE_CODE_ENTRYPOINT/.test(auto), "auto mode unsets any inherited entrypoint");
|
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-", () => {
|
test("reaper kills ONLY ocp-tui- sessions, never olp-tui-", () => {
|
||||||
const killed = [];
|
const killed = [];
|
||||||
const fakeTmux = (args) => {
|
const fakeTmux = (args) => {
|
||||||
@@ -1537,6 +1812,41 @@ test("reaper returns 0 for empty session list", () => {
|
|||||||
assert.equal(killed.length, 0);
|
assert.equal(killed.length, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Defunct-zombie reaping (PI231 incident): the pane's claude is a child of the tmux server,
|
||||||
|
// so only kill-server actually reaps it. We kill-server ONLY when no foreign session remains.
|
||||||
|
console.log("\nTUI defunct-zombie reaping (kill-server):");
|
||||||
|
|
||||||
|
test("reaper kill-servers when the server is ours-only (flush defunct claude zombies)", () => {
|
||||||
|
const calls = [];
|
||||||
|
const fakeTmux = (args) => {
|
||||||
|
calls.push(args.join(" "));
|
||||||
|
if (args[0] === "list-sessions") return { status: 0, stdout: "ocp-tui-aaaa\nocp-tui-bbbb\n" };
|
||||||
|
return { status: 0, stdout: "" };
|
||||||
|
};
|
||||||
|
const n = reapStaleTuiSessions({ tmux: fakeTmux });
|
||||||
|
assert.equal(n, 2, "killed both of our sessions");
|
||||||
|
assert.ok(calls.includes("kill-server"), "kill-server fired — reaps the defunct backlog");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reaper does NOT kill-server when a foreign (non-ocp) session remains (coexistence)", () => {
|
||||||
|
const calls = [];
|
||||||
|
const fakeTmux = (args) => {
|
||||||
|
calls.push(args.join(" "));
|
||||||
|
if (args[0] === "list-sessions") return { status: 0, stdout: "ocp-tui-aaaa\nolp-tui-bbbb\n" };
|
||||||
|
return { status: 0, stdout: "" };
|
||||||
|
};
|
||||||
|
const n = reapStaleTuiSessions({ tmux: fakeTmux });
|
||||||
|
assert.equal(n, 1, "killed only our own session");
|
||||||
|
assert.ok(!calls.includes("kill-server"), "kill-server MUST NOT fire — would disrupt olp-tui-*");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reaper does NOT kill-server when there is no server (status !== 0)", () => {
|
||||||
|
const calls = [];
|
||||||
|
const fakeTmux = (args) => { calls.push(args.join(" ")); return { status: 1, stdout: "" }; };
|
||||||
|
reapStaleTuiSessions({ tmux: fakeTmux });
|
||||||
|
assert.ok(!calls.includes("kill-server"), "no server → no kill-server (early return)");
|
||||||
|
});
|
||||||
|
|
||||||
// ── TUI home preparation (scratch vs real) ───────────────────────────────
|
// ── TUI home preparation (scratch vs real) ───────────────────────────────
|
||||||
import { prepareTuiHome, ensureTuiCwdTrusted } from "./lib/tui/session.mjs";
|
import { prepareTuiHome, ensureTuiCwdTrusted } from "./lib/tui/session.mjs";
|
||||||
import { mkdtempSync as hMkdtemp, mkdirSync as hMkdir, writeFileSync as hWrite, readFileSync as hRead, existsSync as hExists, readlinkSync as hReadlink } from "node:fs";
|
import { mkdtempSync as hMkdtemp, mkdirSync as hMkdir, writeFileSync as hWrite, readFileSync as hRead, existsSync as hExists, readlinkSync as hReadlink } from "node:fs";
|
||||||
@@ -1622,6 +1932,147 @@ test("default mode (no second arg) behaves like 'cli'", () => {
|
|||||||
assert.equal(env.CLAUDE_CODE_ENTRYPOINT, "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("\nTUI drift observability (C-5):");
|
||||||
|
|
||||||
|
test("recordTuiEntrypoint: observed 'cli' is NOT a mismatch and sets lastEntrypoint", () => {
|
||||||
|
const ts = { lastEntrypoint: null, entrypointMismatches: 0 };
|
||||||
|
const mism = recordTuiEntrypoint(ts, "cli", "cli");
|
||||||
|
assert.equal(mism, false);
|
||||||
|
assert.equal(ts.lastEntrypoint, "cli");
|
||||||
|
assert.equal(ts.entrypointMismatches, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("recordTuiEntrypoint: expected cli but observed 'sdk-cli' increments the mismatch counter (drift)", () => {
|
||||||
|
const ts = { lastEntrypoint: null, entrypointMismatches: 0 };
|
||||||
|
assert.equal(recordTuiEntrypoint(ts, "sdk-cli", "cli"), true);
|
||||||
|
assert.equal(ts.lastEntrypoint, "sdk-cli");
|
||||||
|
assert.equal(ts.entrypointMismatches, 1);
|
||||||
|
// A second drift increments again (counter accumulates across turns).
|
||||||
|
assert.equal(recordTuiEntrypoint(ts, "sdk-cli", "cli"), true);
|
||||||
|
assert.equal(ts.entrypointMismatches, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("recordTuiEntrypoint: null observation → lastEntrypoint null, counts as mismatch when expected cli", () => {
|
||||||
|
const ts = { lastEntrypoint: "cli", entrypointMismatches: 0 };
|
||||||
|
assert.equal(recordTuiEntrypoint(ts, null, "cli"), true);
|
||||||
|
assert.equal(ts.lastEntrypoint, null);
|
||||||
|
assert.equal(ts.entrypointMismatches, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("recordTuiEntrypoint: non-cli expected mode (auto) never counts a mismatch", () => {
|
||||||
|
const ts = { lastEntrypoint: null, entrypointMismatches: 0 };
|
||||||
|
assert.equal(recordTuiEntrypoint(ts, "sdk-cli", "auto"), false);
|
||||||
|
assert.equal(ts.lastEntrypoint, "sdk-cli");
|
||||||
|
assert.equal(ts.entrypointMismatches, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildTuiHealthBlock: shape + live counters (the additive /health tui block)", () => {
|
||||||
|
const sem = new TuiSemaphore(2);
|
||||||
|
const ts = { lastEntrypoint: "cli", entrypointMismatches: 3 };
|
||||||
|
const block = buildTuiHealthBlock(
|
||||||
|
{ enabled: true, entrypointMode: "cli", maxConcurrent: 2 }, ts, sem);
|
||||||
|
assert.deepEqual(Object.keys(block).sort(),
|
||||||
|
["enabled", "entrypointMismatches", "entrypointMode", "inflight", "lastEntrypoint", "maxConcurrent", "queued"]);
|
||||||
|
assert.equal(block.enabled, true);
|
||||||
|
assert.equal(block.entrypointMode, "cli");
|
||||||
|
assert.equal(block.lastEntrypoint, "cli");
|
||||||
|
assert.equal(block.entrypointMismatches, 3);
|
||||||
|
assert.equal(block.inflight, 0);
|
||||||
|
assert.equal(block.queued, 0);
|
||||||
|
assert.equal(block.maxConcurrent, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildTuiHealthBlock: TUI off → enabled:false but block still present (stable shape)", () => {
|
||||||
|
const sem = new TuiSemaphore(2);
|
||||||
|
const ts = { lastEntrypoint: null, entrypointMismatches: 0 };
|
||||||
|
const block = buildTuiHealthBlock(
|
||||||
|
{ enabled: false, entrypointMode: "cli", maxConcurrent: 2 }, ts, sem);
|
||||||
|
assert.equal(block.enabled, false);
|
||||||
|
assert.equal(block.lastEntrypoint, null);
|
||||||
|
assert.equal(block.entrypointMismatches, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
await asyncTest("buildTuiHealthBlock reflects live inflight/queued while turns are in flight", async () => {
|
||||||
|
const sem = new TuiSemaphore(1);
|
||||||
|
const ts = { lastEntrypoint: null, entrypointMismatches: 0 };
|
||||||
|
const g1 = deferred();
|
||||||
|
const t1 = sem.run(async () => { await g1.p; });
|
||||||
|
const t2 = sem.run(async () => {}); // queued behind t1
|
||||||
|
await new Promise((r) => setImmediate(r));
|
||||||
|
const block = buildTuiHealthBlock({ enabled: true, entrypointMode: "cli", maxConcurrent: 1 }, ts, sem);
|
||||||
|
assert.equal(block.inflight, 1, "one turn in flight");
|
||||||
|
assert.equal(block.queued, 1, "one turn queued");
|
||||||
|
g1.resolve();
|
||||||
|
await t1; await t2;
|
||||||
|
});
|
||||||
|
|
||||||
// ── TUI session driver: runTuiTurn (live-only, guarded) ──────────────────
|
// ── TUI session driver: runTuiTurn (live-only, guarded) ──────────────────
|
||||||
console.log("\nTUI session driver:");
|
console.log("\nTUI session driver:");
|
||||||
|
|
||||||
@@ -1655,7 +2106,7 @@ function _tuiPromptLanded(pane, prompt) {
|
|||||||
if (flatPane.includes("[Pasted text")) return true;
|
if (flatPane.includes("[Pasted text")) return true;
|
||||||
const firstLine = String(prompt).split("\n").map(s => s.trim()).find(Boolean) || "";
|
const firstLine = String(prompt).split("\n").map(s => s.trim()).find(Boolean) || "";
|
||||||
const needle = firstLine.replace(/\s+/g, " ").slice(0, 24);
|
const needle = firstLine.replace(/\s+/g, " ").slice(0, 24);
|
||||||
return needle.length >= 3 && flatPane.includes(needle);
|
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,
|
// Real captured pane samples (empirically confirmed via live capture-pane on PI231,
|
||||||
@@ -1687,12 +2138,23 @@ test("tuiPromptLanded(READY_PANE, 'Reply with exactly: PONG_TEST') === false (s
|
|||||||
test("tuiPromptLanded(LANDED_PANE, 'Reply with exactly: PONG_TEST') === true (prompt prefix visible)", () => {
|
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);
|
assert.equal(_tuiPromptLanded(TUI_LANDED_PANE, "Reply with exactly: PONG_TEST"), true);
|
||||||
});
|
});
|
||||||
test("tuiPromptLanded(READY_PANE, 'ping') === false (needle <3 chars, placeholder present)", () => {
|
test("tuiPromptLanded(READY_PANE, 'ping') === false (prompt text absent from placeholder pane)", () => {
|
||||||
assert.equal(_tuiPromptLanded(TUI_READY_PANE, "ping"), false);
|
assert.equal(_tuiPromptLanded(TUI_READY_PANE, "ping"), false);
|
||||||
});
|
});
|
||||||
test("tuiPromptLanded('❯ ping\\n ? for shortcuts', 'ping') === true (needle present, no placeholder)", () => {
|
test("tuiPromptLanded('❯ ping\\n ? for shortcuts', 'ping') === true (needle present, no placeholder)", () => {
|
||||||
assert.equal(_tuiPromptLanded("❯ ping\n ? for shortcuts", "ping"), true);
|
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.
|
// 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", () => {
|
test("tuiPromptLanded(bracketed-paste pane, big prompt) === true", () => {
|
||||||
assert.equal(_tuiPromptLanded("❯ [Pasted text #1 +301 lines]\n ? for shortcuts", "[System] Context 0."), true);
|
assert.equal(_tuiPromptLanded("❯ [Pasted text #1 +301 lines]\n ? for shortcuts", "[System] Context 0."), true);
|
||||||
|
|||||||
Reference in New Issue
Block a user