diff --git a/README.md b/README.md index 81e3f94..cc0af8f 100644 --- a/README.md +++ b/README.md @@ -871,6 +871,24 @@ openclaw gateway restart # so OpenClaw re-reads the config Future `ocp update` invocations sync automatically. +### TUI-mode returns `Please run /login · API Error: 401` (re-login doesn't stick) + +A long-running TUI-mode host can get stuck returning a permanent 401 that re-login cannot fix. + +**Root cause (two layers):** interactive `claude` **prefers `~/.claude/.credentials.json` over the `CLAUDE_CODE_OAUTH_TOKEN` env var** (this is *unlike* the `-p` path, where the env token wins). So (a) a stale/corrupt `credentials.json` **shadows** the env token — passing the token is not enough on its own; and (b) when claude does use `credentials.json`, its single-use OAuth refresh token can be corrupted (ending up an empty string) by the per-request spawn + `kill-session` teardown racing claude's token rotation. Re-login writes a fresh token, but the next spawn re-corrupts it. Proven live on PI231: *env token passed + broken `credentials.json` present → 401; env token passed + `credentials.json` moved aside → works.* + +**Fix:** set `CLAUDE_CODE_OAUTH_TOKEN` on the OCP host and leave `OCP_TUI_HOME` **unset**. OCP then runs the TUI `claude` in a **credential-isolated home** (`$HOME/.ocp-tui/home`) that has **no `credentials.json`** at all, so the env token is the only credential (authoritative — nothing shadows it) and claude never runs the refresh path (so the single-use token can't be corrupted). Then restart — on systemd `daemon-reload`, on launchd `bootout`+`bootstrap`; `kickstart -k` does **not** reload env. Verify the env reached the process and the boot log shows the isolated home: + +```bash +# Linux (systemd): confirm the token is in the service env +tr '\0' '\n' < /proc/$(pgrep -f server.mjs | head -1)/environ | grep CLAUDE_CODE_OAUTH_TOKEN +# Boot log should read: TUI-mode: ON home=$HOME/.ocp-tui/home ... auth=env-token (credential-isolated home — no credentials.json) +``` + +> If you previously set `OCP_TUI_HOME` to the real home (or any home that contains a `credentials.json`), **unset it** so the credential-isolated default takes effect — otherwise the shadowing `credentials.json` remains in play. + +See [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) and ADR 0007 PR-C / PR-D amendments. + ## Environment Variables | Variable | Default | Description | @@ -894,9 +912,10 @@ Future `ocp update` invocations sync automatically. | `PROXY_ANONYMOUS_KEY` | *(unset)* | Well-known anonymous key allowlist (multi mode). When set, this exact string bypasses `validateKey()` and grants public access. Exposed via `/health.anonymousKey` only to localhost, or to all callers when `PROXY_ADVERTISE_ANON_KEY=1`. See [Anonymous Access](#anonymous-access-optional). | | `PROXY_ADVERTISE_ANON_KEY` | *(unset)* | When `=1`, advertise `PROXY_ANONYMOUS_KEY` in the public `/health` body for remote zero-config discovery. Default off — `/health` is unauthenticated, so this exposes the shared key to any LAN-reachable device (issue #109). Localhost always sees it regardless. | | `CLAUDE_TUI_MODE` | `false` | **Opt-in.** Set to `"true"` to serve requests via interactive `claude` (no `-p` / `--output-format` → `cc_entrypoint=cli`, subscription pool). **Single-user only** — see [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) for the security constraint. | +| `CLAUDE_CODE_OAUTH_TOKEN` | *(unset)* | OAuth bearer token (highest-precedence credential source for the `-p` path). **Recommended for TUI-mode hosts:** when set (and `OCP_TUI_HOME` unset), OCP runs the interactive `claude` in a **credential-isolated home** (`$HOME/.ocp-tui/home`, no `credentials.json`) so this long-lived token is the only credential and is authoritative — interactive `claude` otherwise *prefers* `~/.claude/.credentials.json` over the env var, so a stale one shadows the token and its single-use refresh token gets corrupted by the spawn/teardown cycle (the permanent `Please run /login` 401 — see [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) and ADR 0007 PR-D). The token appears in the pane command (ps-visible) — acceptable for the single-user A-path; the multi-user B-path is refused at boot. | | `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 `/.claude/projects//`. Created automatically. | -| `OCP_TUI_HOME` | `$HOME` (real home) | (TUI-mode) `HOME` claude runs under. Default is the operator's real home (shared credentials, existing onboarding). Set to a separate path for scratch-home isolation — see ADR 0007 for the credential-fork caveat. | +| `OCP_TUI_HOME` | *(auto)* | (TUI-mode) `HOME` claude runs under. **When unset, OCP picks it for you:** if `CLAUDE_CODE_OAUTH_TOKEN` is set → a **credential-isolated** scratch home `$HOME/.ocp-tui/home` (no `credentials.json`, env-token auth — **recommended**); if no env token → the operator's real home (legacy shared `credentials.json`). Setting this to an **explicit** path overrides the auto-default. The credential handling at that path still follows the env token: **with** the env token it is credential-free (env-token auth, no `credentials.json` written); **without** the env token (and the path ≠ real home) it uses the legacy symlinked-credentials scratch mode, which carries the credential-fork caveat — see ADR 0007. | | `OCP_TUI_ENTRYPOINT` | `cli` | (TUI-mode) Billing-classifier labeling: `cli` (default) pins `cc_entrypoint=cli` deterministically; `auto` lets claude self-classify via TTY detection; `off` leaves the inherited env untouched. Honest only when the spawn is a genuine interactive PTY — see ADR 0007. | | `OCP_TUI_MAX_CONCURRENT` | `2` | (TUI-mode) Max concurrent interactive TUI turns. **Independent** of `CLAUDE_MAX_CONCURRENT` (which bounds the `-p`/stream-json path; TUI never uses it). A TUI turn is heavy (per-request cold-boot of tmux+claude + up to `CLAUDE_TUI_WALLCLOCK_MS` wallclock), so the default is low to keep small hosts (e.g. a Pi 4) alive under a burst. Excess turns **queue** (bounded); a full queue yields a 503. See ADR 0007 PR-B amendment. | | `OCP_SKIP_AUTH_TEST` | *(unset)* | When `=1`, skip the `claude -p` auth probe during `setup.mjs`. After 2026-06-15 this probe draws from the Agent SDK credit pool; set this to avoid burning a metered credit on re-installs or `ocp update` runs. Auth is validated at the first real request. | @@ -946,17 +965,27 @@ mkdir -p ~/.ocp-tui/work # one-time scratch cwd setup # Enable export CLAUDE_TUI_MODE=true +# STRONGLY RECOMMENDED on a TUI host — authenticate via the long-lived OAuth token. +# With this set (and OCP_TUI_HOME left UNSET), OCP runs the interactive claude in a +# credential-isolated home ($HOME/.ocp-tui/home, no credentials.json), so the env token +# is the only credential and is authoritative. This both stops a stale credentials.json +# from shadowing the token AND ends the refresh-token corruption that caused a permanent +# "Please run /login" 401 (no credentials file → claude never runs the refresh path). +# See the auth note below + ADR 0007 PR-D. +export CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-... # Optionally tune: export CLAUDE_TUI_WALLCLOCK_MS=180000 # 3 min cap for long Opus turns export OCP_TUI_CWD=$HOME/.ocp-tui/work # default; override if needed export OCP_TUI_ENTRYPOINT=cli # default; use 'auto' to observe TTY-derived value +# Do NOT set OCP_TUI_HOME for the recommended setup — leaving it unset is what enables +# the credential-isolated home. Set it only to opt into the legacy symlinked-creds mode. ``` -Then restart OCP. At boot you will see: +Then restart OCP. At boot you will see (with the env token set, isolated home auto-selected): ``` ⚠️ TUI-mode ON — single-user only; do NOT enable on a multi-user OCP ... - TUI-mode: ON home=/home/user cwd=/home/user/.ocp-tui/work wallclock=120000ms + TUI-mode: ON home=/home/user/.ocp-tui/home cwd=/home/user/.ocp-tui/work auth=env-token (credential-isolated home — no credentials.json) wallclock=120000ms maxConcurrent=2 ``` ### What changes / what doesn't @@ -965,6 +994,8 @@ 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. - **Cache and singleflight work normally.** TUI-mode writes the buffered response to the cache on success; cache-hits skip the interactive turn entirely. - **The host's `CLAUDE.md` / auto-memory is never injected.** OCP is a proxy — the proxied client (OpenClaw / your IDE) owns its own context and memory. TUI-mode always runs `claude` with `CLAUDE_CODE_DISABLE_CLAUDE_MDS` + `CLAUDE_CODE_DISABLE_AUTO_MEMORY`, so a `CLAUDE.md` on the OCP host can never leak into proxied turns (verified live; see #4). Built-in tool schemas + the interactive system prompt remain (the inherent ~20–35K context floor of interactive mode); MCP is hard-disabled. +- **Authenticate via `CLAUDE_CODE_OAUTH_TOKEN` in a credential-isolated home (recommended).** tmux does not forward the parent process's env to the pane, so OCP sets the token explicitly on the spawned `claude` when `CLAUDE_CODE_OAUTH_TOKEN` is present. But passing the token is **not enough on its own**: interactive `claude` *prefers* `~/.claude/.credentials.json` over the env var (unlike the `-p` path), so a stale `credentials.json` would shadow the token. With the env token set and `OCP_TUI_HOME` unset, OCP therefore runs claude in a **credential-isolated home** (`$HOME/.ocp-tui/home`) that has **no `credentials.json`** — so the env token is the only credential and is authoritative, and claude never runs the token-refresh path (so the single-use refresh token can't be corrupted by the spawn/teardown cycle). On a long-running host the credentials.json path produced a permanent `Please run /login · API Error: 401` that re-login could not fix (the next spawn re-corrupted it); the isolated home ends that at the root. Transcripts land under the same isolated home, so the answer-reader is unaffected. Without the env token, claude falls back to the real home's `credentials.json` (byte-for-byte the previous behaviour). (The token is visible in `ps` on the pane command — acceptable for the single-user A-path; the multi-user B-path is refused at boot.) See ADR 0007 PR-C / PR-D amendments. +- **Stale tmux sessions are reaped.** The pane's `claude` is a child of the tmux server (not OCP), so OCP cannot reap it directly; `claude` zombies can otherwise accumulate as `` over a long-running host. OCP reaps them at boot and on a 15-min idle sweep by issuing `tmux kill-server` — but **only when no foreign tmux session remains** (it never disrupts a co-hosted `olp-tui-*` instance). See ADR 0007 PR-C amendment. - **Default path unchanged.** Unset `CLAUDE_TUI_MODE` and restart → `callClaude` / `callClaudeStreaming` are used again, byte-for-byte identical to today. - **Concurrency is bounded separately.** TUI turns are heavy (per-request cold-boot + long wallclock), so the TUI path has its own limiter — `OCP_TUI_MAX_CONCURRENT` (default `2`), independent of `CLAUDE_MAX_CONCURRENT`. Excess turns queue; a full queue returns a 503. Tune it up only on a host that can run more interactive `claude` sessions at once. diff --git a/docs/adr/0007-tui-interactive-mode.md b/docs/adr/0007-tui-interactive-mode.md index ce12e67..79b35de 100644 --- a/docs/adr/0007-tui-interactive-mode.md +++ b/docs/adr/0007-tui-interactive-mode.md @@ -1,7 +1,7 @@ # ADR 0007 — TUI Interactive Mode (subscription-pool bridge) **Date:** 2026-05-31 -**Status:** Accepted — amended by PR-4 (entrypoint hardening) +**Status:** Accepted — amended by PR-4 (entrypoint hardening), PR-B (observability + concurrency), PR-C (env-token auth + defunct-reaping), PR-D (credential-isolated home — corrects PR-C) **Deciders:** project maintainer **Authority:** claude CLI v2.1.158 interactive mode — verified live on the test host that sessions launched without `-p` / `--output-format` carry `cc_entrypoint=cli` (subscription pool), not `cc_entrypoint=sdk-cli` (Agent SDK credit pool). Mechanism verified on cli.js v2.1.104; live-confirmed on v2.1.158. @@ -98,12 +98,16 @@ When `CLAUDE_TUI_MODE` is unset (the default), no code path touches `callClaudeT Unset `CLAUDE_TUI_MODE` (or set it to any value other than `"true"`) → stream-json path restored immediately on next restart. -### Home strategy (real-home default) +### Home strategy -`TUI_HOME = OCP_TUI_HOME || HOME` (defaults to the operator's real home). +> **Superseded by the PR-D amendment below for the env-token case.** As of PR-D, `TUI_HOME` +> is computed by `resolveTuiHome()`: when `CLAUDE_CODE_OAUTH_TOKEN` is set (and `OCP_TUI_HOME` +> is unset) the default is a **credential-free scratch home**, not the real home. The +> descriptions below remain accurate for the **no-env-token** case and the **explicit +> `OCP_TUI_HOME` override** case. -- **Real-home (default, `OCP_TUI_HOME` unset):** claude runs with the operator's own `~/.claude/` — shared credentials, existing onboarding, no OAuth fork risk. `ensureTuiCwdTrusted` seeds the trust record for the scratch cwd in the real `~/.claude.json` (atomic write). -- **Scratch-home opt-in (`OCP_TUI_HOME=`):** a dedicated `HOME` that symlinks `~/.claude/.credentials.json` from the real home (token is never copied) and seeds a stripped `~/.claude.json` (no project history, trusts only the scratch cwd). **Caveat:** claude rewrites `.credentials.json` on OAuth token refresh, replacing the symlink with a regular file — this forks the credentials. Use scratch-home only with a dedicated OAuth or for ephemeral testing. +- **Real-home (default when NO env token, `OCP_TUI_HOME` unset):** claude runs with the operator's own `~/.claude/` — shared credentials, existing onboarding, no OAuth fork risk. `ensureTuiCwdTrusted` seeds the trust record for the scratch cwd in the real `~/.claude.json` (atomic write). +- **Scratch-home opt-in (`OCP_TUI_HOME=`, no env token):** a dedicated `HOME` that symlinks `~/.claude/.credentials.json` from the real home (token is never copied) and seeds a stripped `~/.claude.json` (no project history, trusts only the scratch cwd). **Caveat:** claude rewrites `.credentials.json` on OAuth token refresh, replacing the symlink with a regular file — this forks the credentials. Use this legacy symlink mode only with a dedicated OAuth or for ephemeral testing. (The PR-D env-token mode avoids this caveat entirely — no credentials file to fork.) ### Working directory @@ -234,6 +238,79 @@ This amendment **is** that authorization. The argument: --- +## 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=` 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. **`/.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= 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 `` 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 `` zombies that only the server reaps; over 30 days on PI231 this accumulated to **25 defunct ``** (a live `tmux kill-server` dropped it 25→3). + +The node-reachable action that *actually reaps* — rather than merely re-signalling — is to stop the tmux server: on server exit the kernel reparents survivors to init (PID 1), which reaps them. `reapStaleTuiSessions` therefore, after killing our own `ocp-tui-*` sessions, issues `kill-server` **only when no foreign session of any prefix remains** (coexistence: never disrupt a co-hosted `olp-tui-*` instance). This runs at boot (existing) and now on a 15-min periodic interval gated on TUI-mode and on the TUI path being idle (`inflight === 0 && queued === 0`) so a live turn's pane is never torn down. Residual: a request whose pane is created in the narrow window between the idle-check and `kill-server` would fail cleanly via the existing honesty gates (rare; documented in the server comment). + +### ALIGNMENT authorization (Class B) + +Both changes are **Class B** (OCP-owned TUI spawn). `cli.js` does not perform either operation — there is no `cli.js` analogue for "how the TUI pane authenticates" or "reaping tmux-server-owned zombies"; this surface is authorized by **this ADR (0007)** per `ALIGNMENT.md`'s Class B citation requirement. No Class A wire surface, no endpoint shape, no `alignment.yml` blacklist token, and no `models.json` entry is touched. + +--- + +## Credential-isolated home for env-token auth (PR-D amendment) + +**Date:** 2026-06-13 +**Status:** Accepted — amends ADR 0007. **Corrects** the PR-C rationale and the original "Home strategy" section's scratch-home caveat. +**Motivation:** PR-C's env-token passing alone did **not** fix the PI231 401. Decisive live evidence (claude 2.1.104, PI231): + +| Condition | Result | +|---|---| +| env token passed + a broken `~/.claude/.credentials.json` present | **401** (`Please run /login · API Error: 401`) | +| env token passed + `credentials.json` moved aside | **works** (real answer) | + +### Corrected root cause + +**Interactive `claude` PREFERS `~/.claude/.credentials.json` over the `CLAUDE_CODE_OAUTH_TOKEN` env var.** A stale/corrupt `credentials.json` therefore **shadows** the env token. (This is *unlike* `-p` mode, where the env token wins — which is why `server.mjs`'s own `getOAuthCredentials()` is unaffected and why PR-C's premise looked sufficient.) So passing the token (PR-C, `buildTuiCmd`) is **necessary but insufficient**: the TUI `claude` must additionally run in a HOME that has **no `credentials.json`**, so the env token is the only credential and is authoritative. + +This also fixes the original incident at the **root**, more completely than PR-C claimed: with no `credentials.json` in the home, claude never runs the token-refresh path at all, so the single-use refresh token can never be rotated — and therefore never corrupted — by the spawn+`kill-session` cycle. The 25-zombie / empty-refresh-token failure mode becomes structurally impossible, not merely avoided. + +### Decision + +When `CLAUDE_CODE_OAUTH_TOKEN` is set, the TUI `claude` runs in a **credential-free scratch home** by default: + +- `resolveTuiHome({ realHome, configuredHome, envTokenSet })` (exported from `lib/tui/session.mjs`, pure) decides the home: + - **`OCP_TUI_HOME` set** → that path (explicit override, back-compat — an operator who configured it keeps exactly that home). + - **else env token set** → `/.ocp-tui/home` — a dedicated scratch home seeded with a minimal `.claude.json` (`hasCompletedOnboarding=true` + trust **only** the scratch cwd) and its own `projects/` dir, and **deliberately NO `.credentials.json`** (no symlink, no copy). + - **else (no env token)** → the operator's real home — **byte-for-byte the pre-fix behaviour** for hosts that intentionally rely on `credentials.json`. +- `prepareTuiHome(realHome, tuiHome, cwd, { envTokenMode })` gates the credential handling: in `envTokenMode` it creates the scratch `projects/` dir and seeds the minimal trusted `.claude.json` but **never** creates the credentials symlink. `runTuiTurn` sets `envTokenMode = !!CLAUDE_CODE_OAUTH_TOKEN && ehome !== realHome`. +- `readTuiTranscript` reads from the **same** home claude runs under (`ehome`), so transcripts land under `/.claude/projects/` and `findTranscriptPath` globs them there — the home is threaded through consistently. (We chose scratch-`HOME` over `CLAUDE_CONFIG_DIR`: the binary supports `CLAUDE_CONFIG_DIR`, but it relocates the transcript root to `/projects/` rather than `/.claude/projects/`, which would fork the transcript-resolution rule across modes for no benefit. The scratch-HOME lever reuses the existing, tested `prepareTuiHome`/`ehome` plumbing.) + +### This RESOLVES — not reintroduces — the scratch-home caveat + +The original "Home strategy" section and PR-C's `prepareTuiHome` comment warned that scratch-home is unsafe because *claude rewrites a **symlinked** `.credentials.json` on token refresh → forks/corrupts the OAuth credentials*. **That caveat does not apply to env-token mode**: there is no `credentials.json` in the home to fork, and claude never refreshes (it uses the long-lived env token), so there is no rotation and no corruption. The fork risk was inherent to the *symlink* approach; removing the credentials file entirely removes the risk. The legacy symlink mode is retained **only** for an operator who explicitly sets `OCP_TUI_HOME` without an env token, and its caveat is preserved for exactly that path. + +### ALIGNMENT authorization (Class B) + +**Class B** (OCP-owned TUI spawn). `cli.js` has no analogue for the TUI pane's auth/home strategy; authorized by **this ADR (0007)** per `ALIGNMENT.md`'s Class B citation requirement. `server.mjs` is touched only to compute `TUI_HOME` via `resolveTuiHome()` (TUI wiring) and to surface the auth mode in the boot log — no Class A wire surface, no endpoint shape, no `alignment.yml` blacklist token, and no `models.json` entry is touched. + +--- + ## Consequences ### Positive diff --git a/lib/tui/session.mjs b/lib/tui/session.mjs index f565fe7..f0a90a3 100644 --- a/lib/tui/session.mjs +++ b/lib/tui/session.mjs @@ -23,16 +23,44 @@ const defaultTmux = (args, opts = {}) => // Kill ONLY our own stale sessions. Scoped to SESSION_PREFIX so a co-hosted // OLP test instance's `olp-tui-*` sessions are never touched. +// +// Defunct-reaping (PI231 incident): the pane's `claude` process is a child of the +// long-lived tmux SERVER daemon, NOT of the OCP node process — `tmux new-session -d` +// returns the instant the server forks the pane, so node never becomes its parent and +// therefore can NEVER waitpid()/reap it (a SIGKILL still needs the *parent* to reap, and +// here that parent is the tmux server). `kill-session` destroys the session but the server +// can leave the pane's `claude` (and any grandchildren claude spawned) as `` +// zombies that only the server can reap. Over many per-request spawn+teardown cycles these +// accumulate (live evidence on PI231: 25 defunct `` over 30 days; `tmux kill-server` +// dropped it 25→3). The only node-reachable action that ACTUALLY reaps them — rather than +// merely re-signalling — is to stop the tmux server: when the server exits, the kernel +// reparents its surviving children to init (PID 1), which reaps them immediately. +// +// So after killing our own sessions, if the server has NO sessions left of ANY prefix +// (i.e. nothing we could disrupt — no co-hosted `olp-tui-*` or other instance), we +// `kill-server` to flush the defunct backlog. If ANY non-ocp session remains we leave the +// server running (coexistence rule, ADR 0007) and let the next boot/periodic sweep retry +// once the server is otherwise idle. export function reapStaleTuiSessions({ tmux = defaultTmux } = {}) { const r = tmux(["list-sessions", "-F", "#{session_name}"]); if (!r || r.status !== 0) return 0; // no tmux server / no sessions + const names = String(r.stdout || "").split("\n").map((s) => s.trim()).filter(Boolean); let killed = 0; - for (const name of String(r.stdout || "").split("\n").map((s) => s.trim()).filter(Boolean)) { + let othersRemain = false; + for (const name of names) { if (name.startsWith(SESSION_PREFIX)) { tmux(["kill-session", "-t", name]); killed++; + } else { + othersRemain = true; // a session we do NOT own (e.g. olp-tui-*) — never kill-server } } + // Reap defunct `claude` zombies: safe ONLY when the server is now ours-only/empty. + // kill-server is what actually reaps (server exit reparents survivors to init); a + // per-session kill cannot, since node is not the zombies' parent. + if (!othersRemain) { + tmux(["kill-server"]); + } return killed; } @@ -128,39 +156,85 @@ export function ensureTuiCwdTrusted(home, cwd) { } catch { /* best effort */ } } -// Prepare the HOME claude runs under. Two modes: -// - real-home (tuiHome === realHome OR falsy): no isolation; just trust the cwd -// in the real ~/.claude.json. Opt in by setting OCP_TUI_HOME=$HOME. -// - scratch-home: a dedicated HOME that reuses the real OAuth via a SYMLINKED -// .credentials.json, with a seeded .claude.json (onboarded real config minus -// the user's project history; trusts only the scratch cwd) and its own -// projects/ dir — so the real ~/.claude is never mutated or polluted. +// Resolve the HOME the TUI `claude` runs under. Three intents, decided by the env +// token + an explicit OCP_TUI_HOME override: // -// ⚠️ CREDENTIAL CAVEAT (verified live): claude rewrites .credentials.json on token -// refresh, REPLACING the symlink with a regular-file copy → the scratch home then -// FORKS the OAuth credentials. Because OAuth refresh tokens rotate (single-use), a -// refresh in the scratch home can invalidate the token the user's real-home claude -// relies on. Therefore scratch-home is safe only with a DEDICATED OAuth or for -// ephemeral use; for a shared subscription prefer real-home (tuiHome===realHome), -// which shares one .credentials.json — identical to how OCP already spawns claude. -// Idempotent + best-effort: any failure degrades toward the dialog/cap, never -// corrupts. Run BEFORE the session boots. -export function prepareTuiHome(realHome, tuiHome, cwd) { +// - ENV-TOKEN MODE (default when CLAUDE_CODE_OAUTH_TOKEN is set AND OCP_TUI_HOME is +// unset): a CREDENTIAL-FREE scratch home at `/.ocp-tui/home`. There is +// deliberately NO .credentials.json (no symlink, no copy), so the only credential +// claude can find is the long-lived env token (passed by buildTuiCmd). This is what +// actually FORCES env-token auth — see the prepareTuiHome comment for why passing +// the token alone is insufficient. +// - EXPLICIT OVERRIDE: whatever OCP_TUI_HOME names (back-compat; an operator who set it +// keeps exactly that home). +// - REAL-HOME (default when the env token is unset): the operator's real home, shared +// credentials.json — byte-for-byte the pre-fix behaviour for credentials.json hosts. +// +// Pure + deterministic so server.mjs and the tests share one decision. `configuredHome` +// is the raw OCP_TUI_HOME value (undefined/empty => unset). +export const DEFAULT_TUI_SCRATCH_HOME = (realHome) => `${realHome}/.ocp-tui/home`; +export function resolveTuiHome({ realHome, configuredHome, envTokenSet }) { + if (configuredHome) return configuredHome; // explicit override wins (back-compat) + if (envTokenSet) return DEFAULT_TUI_SCRATCH_HOME(realHome); // credential-free scratch + return realHome; // legacy real-home default +} + +// Prepare the HOME claude runs under. Three modes: +// - real-home (tuiHome === realHome OR falsy): no isolation; just trust the cwd +// in the real ~/.claude.json. The legacy default when no env token is set. +// - ENV-TOKEN scratch-home (envTokenMode === true): a dedicated HOME with a seeded +// .claude.json (onboarded + trusts only the scratch cwd) and its own projects/ dir, +// and DELIBERATELY NO .credentials.json (no symlink, no copy). claude then has no +// credentials file to read, so it authenticates via CLAUDE_CODE_OAUTH_TOKEN (passed +// by buildTuiCmd) — which is authoritative precisely because nothing shadows it. +// - legacy scratch-home (envTokenMode falsy, tuiHome !== realHome): the historical +// mode that SYMLINKS the real .credentials.json. Retained only for an operator who +// explicitly set OCP_TUI_HOME without an env token; see the caveat below. +// +// WHY ENV-TOKEN MODE IS THE FIX (proven live on PI231, claude 2.1.104): +// env token passed + a broken ~/.claude/.credentials.json present → 401. +// env token passed + credentials.json moved aside → real answer. +// Interactive `claude` PREFERS .credentials.json over the env var (unlike `-p`, where the +// env token wins), so a stale/corrupt credentials.json SHADOWS the env token. Passing the +// token is necessary but insufficient; the TUI claude must run in a HOME with NO +// credentials.json so the env token is the only credential. This ALSO ends the refresh- +// corruption incident at the root: with no credentials file, claude never runs the token- +// refresh path, so the single-use refresh token can never be rotated (and corrupted) by the +// spawn+kill cycle. (This RESOLVES — not reintroduces — the ADR 0007 scratch-home concern: +// the old caveat was about a SYMLINKED credentials.json being forked on refresh; here there +// is no credentials file to fork and no refresh ever happens.) +// +// ⚠️ LEGACY SCRATCH-HOME CAVEAT (envTokenMode falsy, symlink path): claude rewrites +// .credentials.json on token refresh, REPLACING the symlink with a regular-file copy → the +// scratch home FORKS the OAuth credentials and a refresh can invalidate the real-home token. +// That path is therefore safe only with a DEDICATED OAuth or for ephemeral use. The env-token +// mode above avoids this entirely. +// +// Idempotent + best-effort: any failure degrades toward the dialog/cap, never corrupts. +// Run BEFORE the session boots. +export function prepareTuiHome(realHome, tuiHome, cwd, { envTokenMode = false } = {}) { if (!tuiHome || tuiHome === realHome) { ensureTuiCwdTrusted(realHome, cwd); return; } try { const claudeDir = `${tuiHome}/.claude`; mkdirSync(`${claudeDir}/projects`, { recursive: true }); - // Symlink the real credentials (never copy the OAuth token); refresh if missing. - const link = `${claudeDir}/.credentials.json`; - if (!existsSync(link)) { - try { symlinkSync(`${realHome}/.claude/.credentials.json`, link); } catch { /* best effort */ } + if (!envTokenMode) { + // Legacy mode ONLY: symlink the real credentials (never copy the token); refresh if + // missing. Env-token mode deliberately skips this — no credentials file at all. + const link = `${claudeDir}/.credentials.json`; + if (!existsSync(link)) { + try { symlinkSync(`${realHome}/.claude/.credentials.json`, link); } catch { /* best effort */ } + } } - // Seed .claude.json ONCE (if absent): start from the onboarded real config, - // drop the user's project history, trust only the scratch cwd. mode 0600. + // Seed .claude.json ONCE (if absent): onboarded + trust ONLY the scratch cwd. + // In env-token mode start from a MINIMAL config (do NOT copy the real ~/.claude.json — + // a credential-isolated home should not inherit the operator's account/config state); + // in legacy mode carry the onboarded real config minus the user's project history. const seedPath = `${tuiHome}/.claude.json`; if (!existsSync(seedPath)) { let base = {}; - try { base = JSON.parse(readFileSync(`${realHome}/.claude.json`, "utf8")); } catch { /* fresh */ } + if (!envTokenMode) { + try { base = JSON.parse(readFileSync(`${realHome}/.claude.json`, "utf8")); } catch { /* fresh */ } + } base.hasCompletedOnboarding = true; base.projects = { [cwd]: { hasTrustDialogAccepted: true, allowedTools: [] } }; writeFileSync(seedPath, JSON.stringify(base, null, 2), { mode: 0o600 }); @@ -217,6 +291,27 @@ export function buildTuiCmd(claudeBin, model, sessionId, ehome, entrypointMode) "CLAUDE_CODE_DISABLE_CLAUDE_MDS=1", "CLAUDE_CODE_DISABLE_AUTO_MEMORY=1", ]; + // CLAUDE_CODE_OAUTH_TOKEN: tmux does NOT forward the parent process's env to the pane (the + // same reason the whole env is delivered as an `env` prefix above — verified live 2026-06-01), + // so the token MUST be set explicitly here or the spawned `claude` never sees it. Without it, + // the TUI claude falls back to authenticating via /.claude/.credentials.json, whose + // single-use refresh token gets corrupted by the per-request spawn + `kill-session` teardown + // racing claude's token-rotation write (the PI231 incident: refresh token ended up an empty + // string → permanent 401 "Please run /login", re-login re-corrupted on the next spawn). With + // the long-lived OAuth token in env, claude authenticates via the token and never touches the + // credentials.json refresh path — matching how the stable oracle / Mac-mini hosts already run. + // + // SECURITY: the token appears in the pane command (ps-visible). This is acceptable for the + // single-user A-path — it mirrors the existing plaintext-token practice (server.mjs reads the + // same CLAUDE_CODE_OAUTH_TOKEN env at getOAuthCredentials()), and the multi-user B-path is + // already refused at boot (TUI + AUTH_MODE=multi is a hard FATAL). Read from process.env here, + // consistent with how buildTuiCmd already reads OCP_TUI_FULL_TOOLS / CLAUDE_ALLOWED_TOOLS below. + // + // When the env is unset (e.g. a host that intentionally relies on credentials.json), no token + // is added — behaviour is byte-for-byte unchanged from before this fix. + if (process.env.CLAUDE_CODE_OAUTH_TOKEN) { + sets.push(`CLAUDE_CODE_OAUTH_TOKEN=${shq(process.env.CLAUDE_CODE_OAUTH_TOKEN)}`); + } const unset = ["CLAUDECODE", "ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN"]; if (entrypointMode === "cli") sets.push("CLAUDE_CODE_ENTRYPOINT=cli"); else if (entrypointMode === "auto") unset.push("CLAUDE_CODE_ENTRYPOINT"); // let claude self-classify via TTY @@ -292,10 +387,18 @@ export async function runTuiTurn({ const ehome = home || process.env.HOME; // HOME claude runs under (scratch or real) const rhome = realHome || process.env.HOME; // real home (OAuth + onboarded config source) + // Env-token-only mode: the env token is set AND claude runs in an isolated home + // (ehome !== rhome). In that case the scratch home must be CREDENTIAL-FREE (no + // .credentials.json) so the env token — passed by buildTuiCmd — is the only credential + // and is therefore authoritative (interactive claude otherwise PREFERS a credentials.json, + // shadowing the env token; proven live on PI231). server.mjs derives TUI_HOME via + // resolveTuiHome() so this isolated home is the DEFAULT once CLAUDE_CODE_OAUTH_TOKEN is set. + const envTokenMode = !!process.env.CLAUDE_CODE_OAUTH_TOKEN && ehome !== rhome; + // Ensure scratch cwd exists, then prepare the (scratch or real) HOME + trust the // cwd — before claude boots. if (!existsSync(cwd)) mkdirSync(cwd, { recursive: true }); - prepareTuiHome(rhome, ehome, cwd); + prepareTuiHome(rhome, ehome, cwd, { envTokenMode }); // Write prompt to a temp file (mode 0600) so the content never touches argv. const tmpDir = mkdtempSync(`${tmpdir()}/ocp-tui-`); diff --git a/server.mjs b/server.mjs index ad9bad6..049b72f 100644 --- a/server.mjs +++ b/server.mjs @@ -38,7 +38,7 @@ import { homedir } from "node:os"; import { validateKey, recordUsage, getUsageByKey, getUsageTimeline, getRecentUsage, createKey, listKeys, revokeKey, closeDb, checkQuota, updateKeyQuota, getKeyQuota, findKey, cacheHash, getCachedResponse, setCachedResponse, clearCache, getCacheStats, hasCacheControl, singleflight, getInflightStats } from "./keys.mjs"; import { DEFAULT_PORT } from "./lib/constants.mjs"; import { isLoopbackBind } from "./lib/net.mjs"; -import { runTuiTurn, reapStaleTuiSessions } from "./lib/tui/session.mjs"; +import { runTuiTurn, reapStaleTuiSessions, resolveTuiHome } from "./lib/tui/session.mjs"; import { detectTuiUpstreamError } from "./lib/tui/transcript.mjs"; import { TuiSemaphore, recordTuiEntrypoint, buildTuiHealthBlock } from "./lib/tui/semaphore.mjs"; @@ -300,7 +300,20 @@ let CACHE_TTL = parseInt(process.env.CLAUDE_CACHE_TTL || "0", 10); // 0 = disabl const TUI_MODE = process.env.CLAUDE_TUI_MODE === "true"; const TUI_WALLCLOCK_MS = parseInt(process.env.CLAUDE_TUI_WALLCLOCK_MS || "120000", 10); const TUI_CWD = process.env.OCP_TUI_CWD || `${process.env.HOME}/.ocp-tui/work`; -const TUI_HOME = process.env.OCP_TUI_HOME || process.env.HOME; +// HOME the interactive claude runs under. resolveTuiHome() decides: +// - OCP_TUI_HOME set → that path (explicit override, back-compat). +// - else CLAUDE_CODE_OAUTH_TOKEN set → a CREDENTIAL-FREE scratch home +// (/.ocp-tui/home) with NO .credentials.json, so the env token is the only +// credential and is authoritative — interactive claude otherwise PREFERS a +// credentials.json over the env var, so a stale one shadows the token (proven live on +// PI231) and a refresh on it can corrupt the single-use token. See ADR 0007 PR-D. +// - else (no env token) → the operator's real home (legacy credentials.json path, +// byte-for-byte unchanged for hosts that intentionally rely on credentials.json). +const TUI_HOME = resolveTuiHome({ + realHome: process.env.HOME, + configuredHome: process.env.OCP_TUI_HOME, + envTokenSet: !!process.env.CLAUDE_CODE_OAUTH_TOKEN, +}); const TUI_ENTRYPOINT = process.env.OCP_TUI_ENTRYPOINT || "cli"; // cli|auto|off — see ADR 0007 // Independent concurrency bound for the TUI path (audit C-4). Default 2: a TUI turn is // HEAVY (per-request cold-boot of a tmux+claude session + up to TUI_WALLCLOCK_MS=120s of @@ -495,6 +508,28 @@ const cacheCleanupInterval = setInterval(() => { } }, 600000); +// TUI defunct-session reap (periodic): the boot reap (below) only fires once, but a +// long-lived host (PI231 ran 30 days without restart) accumulates defunct `` +// zombies between restarts — the pane's claude is a child of the tmux server, not node, +// so only the server can reap it (see reapStaleTuiSessions). We sweep every 15 min, but +// ONLY when the TUI path is fully idle: reapStaleTuiSessions may `kill-server`, which would +// tear down a live turn's pane, so we skip the sweep while any turn is inflight or queued. +// RESIDUAL (documented, accepted): a brand-new request whose pane is created in the narrow +// window between this idle-check and kill-server would have its pane torn down and fail the +// turn cleanly via runTuiTurn's existing honesty gates (rare; the boot reap is the primary +// mechanism and the 15-min cadence makes the window negligible). +// Gated on TUI_MODE — zero effect (no kill-server, no list-sessions) when TUI is off. +// cli.js does NOT perform this operation (Class B, OCP-owned TUI spawn) — see ADR 0007. +const TUI_REAP_INTERVAL_MS = 15 * 60 * 1000; +const tuiReapInterval = TUI_MODE ? setInterval(() => { + if (tuiSemaphore.inflight > 0 || tuiSemaphore.queued > 0) return; // a turn is live — defer + try { + const n = reapStaleTuiSessions(); + if (n) logEvent("info", "tui_reaped_stale_sessions", { count: n, trigger: "periodic" }); + } catch (e) { logEvent("error", "tui_periodic_reap_failed", { error: e.message }); } +}, TUI_REAP_INTERVAL_MS) : null; +if (tuiReapInterval && typeof tuiReapInterval.unref === "function") tuiReapInterval.unref(); + // ── Active child process tracking ──────────────────────────────────────── const activeProcesses = new Set(); @@ -2284,6 +2319,7 @@ function gracefulShutdown(signal) { clearInterval(sessionCleanupInterval); clearInterval(authCheckInterval); clearInterval(cacheCleanupInterval); + if (tuiReapInterval) clearInterval(tuiReapInterval); closeDb(); // 3. Kill all active child processes @@ -2342,7 +2378,10 @@ server.listen(PORT, BIND_ADDRESS, () => { else console.log(`Cache: disabled (set CLAUDE_CACHE_TTL to enable)`); if (TUI_MODE) { console.warn(`⚠️ TUI-mode ON — single-user only; do NOT enable on a multi-user OCP (guest prompts would run claude with operator filesystem access). See ADR 0007.`); - console.log(` TUI-mode: ON home=${TUI_HOME} cwd=${TUI_CWD} wallclock=${TUI_WALLCLOCK_MS}ms maxConcurrent=${TUI_MAX_CONCURRENT}`); + const tuiAuth = process.env.CLAUDE_CODE_OAUTH_TOKEN + ? (TUI_HOME === process.env.HOME ? "env-token (real home — unset OCP_TUI_HOME for credential isolation)" : "env-token (credential-isolated home — no credentials.json)") + : "credentials.json (no CLAUDE_CODE_OAUTH_TOKEN — see Troubleshooting #401)"; + console.log(` TUI-mode: ON home=${TUI_HOME} cwd=${TUI_CWD} auth=${tuiAuth} wallclock=${TUI_WALLCLOCK_MS}ms maxConcurrent=${TUI_MAX_CONCURRENT}`); try { const n = reapStaleTuiSessions(); if (n) logEvent("info", "tui_reaped_stale_sessions", { count: n }); diff --git a/test-features.mjs b/test-features.mjs index 718d41f..1daaa29 100644 --- a/test-features.mjs +++ b/test-features.mjs @@ -1691,6 +1691,52 @@ test("buildTuiCmd keeps version pin + entrypoint label + MCP wall", () => { 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 = () => { @@ -1766,6 +1812,41 @@ test("reaper returns 0 for empty session list", () => { assert.equal(killed.length, 0); }); +// Defunct-zombie reaping (PI231 incident): the pane's claude is a child of the tmux server, +// so only kill-server actually reaps it. We kill-server ONLY when no foreign session remains. +console.log("\nTUI defunct-zombie reaping (kill-server):"); + +test("reaper kill-servers when the server is ours-only (flush defunct claude zombies)", () => { + const calls = []; + const fakeTmux = (args) => { + calls.push(args.join(" ")); + if (args[0] === "list-sessions") return { status: 0, stdout: "ocp-tui-aaaa\nocp-tui-bbbb\n" }; + return { status: 0, stdout: "" }; + }; + const n = reapStaleTuiSessions({ tmux: fakeTmux }); + assert.equal(n, 2, "killed both of our sessions"); + assert.ok(calls.includes("kill-server"), "kill-server fired — reaps the defunct backlog"); +}); + +test("reaper does NOT kill-server when a foreign (non-ocp) session remains (coexistence)", () => { + const calls = []; + const fakeTmux = (args) => { + calls.push(args.join(" ")); + if (args[0] === "list-sessions") return { status: 0, stdout: "ocp-tui-aaaa\nolp-tui-bbbb\n" }; + return { status: 0, stdout: "" }; + }; + const n = reapStaleTuiSessions({ tmux: fakeTmux }); + assert.equal(n, 1, "killed only our own session"); + assert.ok(!calls.includes("kill-server"), "kill-server MUST NOT fire — would disrupt olp-tui-*"); +}); + +test("reaper does NOT kill-server when there is no server (status !== 0)", () => { + const calls = []; + const fakeTmux = (args) => { calls.push(args.join(" ")); return { status: 1, stdout: "" }; }; + reapStaleTuiSessions({ tmux: fakeTmux }); + assert.ok(!calls.includes("kill-server"), "no server → no kill-server (early return)"); +}); + // ── TUI home preparation (scratch vs real) ─────────────────────────────── import { prepareTuiHome, ensureTuiCwdTrusted } from "./lib/tui/session.mjs"; import { mkdtempSync as hMkdtemp, mkdirSync as hMkdir, writeFileSync as hWrite, readFileSync as hRead, existsSync as hExists, readlinkSync as hReadlink } from "node:fs"; @@ -1801,6 +1882,53 @@ test("prepareTuiHome real mode (tuiHome===realHome): no symlink, just trusts cwd assert.equal(j.projects[cwd].hasTrustDialogAccepted, true); // cwd trusted in real config }); +// ── PR-D: env-token-only credential-isolated home (PI231 401 root fix) ────── +// Interactive claude PREFERS ~/.claude/.credentials.json over CLAUDE_CODE_OAUTH_TOKEN, so a +// stale/corrupt credentials.json SHADOWS the env token (proven live on PI231 — env token + +// broken creds = 401; env token + creds moved aside = works). The fix runs the TUI claude in +// a home with NO credentials.json so the env token is authoritative (and no refresh ever +// happens → the single-use token can't be corrupted by the spawn+kill cycle). +test("prepareTuiHome env-token mode: NO credentials.json (no symlink, no copy), .claude.json seeded", () => { + const realHome = hMkdtemp(`${hTmp()}/realT-`); + hMkdir(`${realHome}/.claude`, { recursive: true }); + hWrite(`${realHome}/.claude/.credentials.json`, '{"token":"real-oauth"}'); // real creds DO exist… + hWrite(`${realHome}/.claude.json`, JSON.stringify({ theme: "dark", oauthAccount: { uuid: "secret" }, projects: { "/old/secret": { hasTrustDialogAccepted: true } } })); + const tuiHome = hMkdtemp(`${hTmp()}/scratchT-`); + const cwd = `${tuiHome}/work`; + prepareTuiHome(realHome, tuiHome, cwd, { envTokenMode: true }); + // …but the scratch home has NO credentials file at all — neither symlink nor copy. + assert.ok(!hExists(`${tuiHome}/.claude/.credentials.json`), "env-token home must have NO .credentials.json (the whole point — no shadowing, no refresh)"); + // .claude.json IS seeded: onboarding complete + ONLY the scratch cwd trusted (no dialog hang). + const seed = JSON.parse(hRead(`${tuiHome}/.claude.json`, "utf8")); + assert.equal(seed.hasCompletedOnboarding, true, "onboarding pre-completed → no onboarding dialog"); + assert.equal(seed.projects[cwd].hasTrustDialogAccepted, true, "scratch cwd pre-trusted → no trust dialog"); + // Minimal config: the credential-isolated home does NOT inherit the operator's account state. + assert.equal(seed.theme, undefined, "env-token home is minimal — real config not copied in"); + assert.equal(seed.oauthAccount, undefined, "real account state not carried into the isolated home"); + assert.equal(seed.projects["/old/secret"], undefined, "operator project history not carried in"); + assert.ok(hExists(`${tuiHome}/.claude/projects`), "own projects/ dir for transcripts under the same home"); +}); + +console.log("\nresolveTuiHome (env-token credential isolation, PR-D):"); +import { resolveTuiHome, DEFAULT_TUI_SCRATCH_HOME } from "./lib/tui/session.mjs"; + +test("resolveTuiHome: env token set + OCP_TUI_HOME unset → credential-free scratch home", () => { + const h = resolveTuiHome({ realHome: "/home/u", configuredHome: undefined, envTokenSet: true }); + assert.equal(h, DEFAULT_TUI_SCRATCH_HOME("/home/u")); + assert.equal(h, "/home/u/.ocp-tui/home"); + assert.notEqual(h, "/home/u", "must NOT be the real home — real home has the shadowing credentials.json"); +}); + +test("resolveTuiHome: env token UNSET → real home (legacy credentials.json path, unchanged)", () => { + const h = resolveTuiHome({ realHome: "/home/u", configuredHome: undefined, envTokenSet: false }); + assert.equal(h, "/home/u", "no env token → real home, byte-for-byte the pre-fix behaviour"); +}); + +test("resolveTuiHome: explicit OCP_TUI_HOME wins regardless of env token (back-compat)", () => { + assert.equal(resolveTuiHome({ realHome: "/home/u", configuredHome: "/custom/home", envTokenSet: true }), "/custom/home"); + assert.equal(resolveTuiHome({ realHome: "/home/u", configuredHome: "/custom/home", envTokenSet: false }), "/custom/home"); +}); + // ── resolveTuiEntrypointEnv ─────────────────────────────────────────────── import { resolveTuiEntrypointEnv } from "./lib/tui/session.mjs";