mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-21 21:15:09 +00:00
fix(tui): credential-isolated env-token auth (ends recurring 401) + reap defunct sessions (#141)
* fix(tui): pass CLAUDE_CODE_OAUTH_TOKEN to spawned claude + reap defunct sessions
Root cause (PI231 incident): tmux does not forward the parent's env to the
pane, so the TUI claude never saw CLAUDE_CODE_OAUTH_TOKEN and fell back to
~/.claude/.credentials.json, whose single-use refresh token got corrupted to an
empty string by the per-request spawn + kill-session teardown racing claude's
token rotation -> permanent "Please run /login" 401 (re-login re-corrupted on
the next spawn). Connected leak: the pane's claude is a child of the tmux server
(not node), so kill-session left <defunct> zombies the server never reaped (25
over 30 days; tmux kill-server dropped it 25->3).
Fix 1: buildTuiCmd now adds CLAUDE_CODE_OAUTH_TOKEN=<shq-escaped> to the pane
env prefix when the env is set, so claude authenticates via the long-lived token
and never touches the credentials.json refresh path (matching stable hosts).
Unset -> no token added (credentials.json-only hosts unaffected).
Fix 2: reapStaleTuiSessions kill-servers after clearing our own sessions ONLY
when no foreign tmux session remains (never disrupts a co-hosted olp-tui-*).
kill-server is the only node-reachable action that ACTUALLY reaps -- server exit
reparents survivors to init, which waitpids them; a per-session kill cannot,
since node is not the zombies' parent. Added a 15-min periodic reap (server.mjs)
gated on TUI_MODE and on the TUI path being idle. Residual: a request whose pane
is created in the idle-check/kill-server window fails cleanly via the existing
honesty gates (documented).
ALIGNMENT: Class B (OCP-owned TUI spawn). cli.js does NOT perform either
operation -- there is no cli.js analogue for "how the TUI pane authenticates" or
"reaping tmux-server-owned zombies"; authorized by ADR 0007 (PR-C amendment) per
ALIGNMENT.md's Class B citation requirement. No Class A wire surface, no endpoint
shape, no alignment.yml token, and no models.json entry touched.
Tests: +6 in test-features.mjs (buildTuiCmd token set/unset/shq-injection;
reaper kill-server ours-only / foreign-present / no-server). 241 passed, 0 failed.
Co-Authored-By: Claude <claude-opus> <noreply@anthropic.com>
* fix(tui): isolate TUI auth to env-token-only home (no credentials.json shadowing)
Passing CLAUDE_CODE_OAUTH_TOKEN to the spawned interactive `claude` (commit
6394ca3) is necessary but INSUFFICIENT to fix the PI231 401: interactive `claude`
PREFERS ~/.claude/.credentials.json over the env var (unlike `-p`, where the env
token wins), so a stale/corrupt credentials.json SHADOWS the env token. Decisive
live evidence on PI231 (claude 2.1.104):
- env token passed + a broken ~/.claude/.credentials.json present → 401
("Please run /login · API Error: 401").
- env token passed + credentials.json moved aside → real answer.
Fix: when CLAUDE_CODE_OAUTH_TOKEN is set (and OCP_TUI_HOME is unset), run the TUI
`claude` in a CREDENTIAL-FREE scratch home (<HOME>/.ocp-tui/home) that has NO
credentials.json — no symlink, no copy. The env token is then the only credential
and is authoritative because nothing shadows it. This ALSO ends the original
refresh-corruption incident (25-zombie / empty-refresh-token) at the ROOT: with no
credentials file, claude never runs the token-refresh path, so the single-use
refresh token can never be rotated/corrupted by the per-request spawn+kill cycle.
This RESOLVES — not reintroduces — the ADR 0007 scratch-home concern. The old
caveat was about a SYMLINKED credentials.json being forked on token refresh; in
env-token mode there is no credentials file to fork and no refresh ever happens.
Mechanism: scratch HOME (not CLAUDE_CONFIG_DIR). The claude binary supports
CLAUDE_CONFIG_DIR, but it relocates transcripts to <CONFIG_DIR>/projects/ rather
than <HOME>/.claude/projects/, forking the transcript-resolution rule across modes
for no benefit. Scratch-HOME reuses the existing, tested prepareTuiHome/ehome
plumbing; readTuiTranscript reads from the same home claude runs under, so
transcripts land under the scratch home and findTranscriptPath globs them there.
Backward compatible: when CLAUDE_CODE_OAUTH_TOKEN is unset, behaviour is byte-for-
byte unchanged (real home + credentials.json) so hosts that intentionally rely on
credentials.json are unaffected. Explicit OCP_TUI_HOME still wins. Onboarding +
cwd-trust are seeded in the scratch .claude.json (hasCompletedOnboarding=true +
trust ONLY the scratch cwd) so no interactive trust/onboarding dialog can hang the
turn.
Changes:
- lib/tui/session.mjs: add resolveTuiHome() (pure) + DEFAULT_TUI_SCRATCH_HOME;
prepareTuiHome() gains { envTokenMode } — skips the credentials symlink and seeds
a minimal .claude.json; runTuiTurn derives envTokenMode = token set && ehome!==rhome.
- server.mjs: TUI_HOME computed via resolveTuiHome(); boot log surfaces the auth mode.
- test-features.mjs: env-token credential-free prepareTuiHome test (asserts NO
credentials.json created/symlinked, .claude.json seeded with onboarding + cwd
trust) + 3 resolveTuiHome decision tests; existing buildTuiCmd-token + reaper +
legacy/real-home tests stay green (245 passed, 0 failed).
- docs/adr/0007: PR-D amendment (corrects the PR-C rationale + the original
scratch-home caveat); README Troubleshooting #401 + env-var table + TUI section.
ALIGNMENT: Class B (OCP-owned TUI spawn). cli.js has no analogue for the TUI pane's
auth/home strategy — authorized by ADR 0007 (PR-D amendment) per ALIGNMENT.md's
Class B citation requirement. No Class A wire path, no alignment.yml blacklist
token, no models.json touched. server.mjs is touched only to wire TUI_HOME via
resolveTuiHome() and surface auth mode in the boot log.
Co-Authored-By: Claude <claude-opus> <noreply@anthropic.com>
---------
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude <claude-opus> <noreply@anthropic.com>
This commit is contained in:
@@ -871,6 +871,24 @@ openclaw gateway restart # so OpenClaw re-reads the config
|
|||||||
|
|
||||||
Future `ocp update` invocations sync automatically.
|
Future `ocp update` invocations sync automatically.
|
||||||
|
|
||||||
|
### TUI-mode returns `Please run /login · API Error: 401` (re-login doesn't stick)
|
||||||
|
|
||||||
|
A long-running TUI-mode host can get stuck returning a permanent 401 that re-login cannot fix.
|
||||||
|
|
||||||
|
**Root cause (two layers):** interactive `claude` **prefers `~/.claude/.credentials.json` over the `CLAUDE_CODE_OAUTH_TOKEN` env var** (this is *unlike* the `-p` path, where the env token wins). So (a) a stale/corrupt `credentials.json` **shadows** the env token — passing the token is not enough on its own; and (b) when claude does use `credentials.json`, its single-use OAuth refresh token can be corrupted (ending up an empty string) by the per-request spawn + `kill-session` teardown racing claude's token rotation. Re-login writes a fresh token, but the next spawn re-corrupts it. Proven live on PI231: *env token passed + broken `credentials.json` present → 401; env token passed + `credentials.json` moved aside → works.*
|
||||||
|
|
||||||
|
**Fix:** set `CLAUDE_CODE_OAUTH_TOKEN` on the OCP host and leave `OCP_TUI_HOME` **unset**. OCP then runs the TUI `claude` in a **credential-isolated home** (`$HOME/.ocp-tui/home`) that has **no `credentials.json`** at all, so the env token is the only credential (authoritative — nothing shadows it) and claude never runs the refresh path (so the single-use token can't be corrupted). Then restart — on systemd `daemon-reload`, on launchd `bootout`+`bootstrap`; `kickstart -k` does **not** reload env. Verify the env reached the process and the boot log shows the isolated home:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Linux (systemd): confirm the token is in the service env
|
||||||
|
tr '\0' '\n' < /proc/$(pgrep -f server.mjs | head -1)/environ | grep CLAUDE_CODE_OAUTH_TOKEN
|
||||||
|
# Boot log should read: TUI-mode: ON home=$HOME/.ocp-tui/home ... auth=env-token (credential-isolated home — no credentials.json)
|
||||||
|
```
|
||||||
|
|
||||||
|
> If you previously set `OCP_TUI_HOME` to the real home (or any home that contains a `credentials.json`), **unset it** so the credential-isolated default takes effect — otherwise the shadowing `credentials.json` remains in play.
|
||||||
|
|
||||||
|
See [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) and ADR 0007 PR-C / PR-D amendments.
|
||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
@@ -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_ANONYMOUS_KEY` | *(unset)* | Well-known anonymous key allowlist (multi mode). When set, this exact string bypasses `validateKey()` and grants public access. Exposed via `/health.anonymousKey` only to localhost, or to all callers when `PROXY_ADVERTISE_ANON_KEY=1`. See [Anonymous Access](#anonymous-access-optional). |
|
||||||
| `PROXY_ADVERTISE_ANON_KEY` | *(unset)* | When `=1`, advertise `PROXY_ANONYMOUS_KEY` in the public `/health` body for remote zero-config discovery. Default off — `/health` is unauthenticated, so this exposes the shared key to any LAN-reachable device (issue #109). Localhost always sees it regardless. |
|
| `PROXY_ADVERTISE_ANON_KEY` | *(unset)* | When `=1`, advertise `PROXY_ANONYMOUS_KEY` in the public `/health` body for remote zero-config discovery. Default off — `/health` is unauthenticated, so this exposes the shared key to any LAN-reachable device (issue #109). Localhost always sees it regardless. |
|
||||||
| `CLAUDE_TUI_MODE` | `false` | **Opt-in.** Set to `"true"` to serve requests via interactive `claude` (no `-p` / `--output-format` → `cc_entrypoint=cli`, subscription pool). **Single-user only** — see [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) for the security constraint. |
|
| `CLAUDE_TUI_MODE` | `false` | **Opt-in.** Set to `"true"` to serve requests via interactive `claude` (no `-p` / `--output-format` → `cc_entrypoint=cli`, subscription pool). **Single-user only** — see [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) for the security constraint. |
|
||||||
|
| `CLAUDE_CODE_OAUTH_TOKEN` | *(unset)* | OAuth bearer token (highest-precedence credential source for the `-p` path). **Recommended for TUI-mode hosts:** when set (and `OCP_TUI_HOME` unset), OCP runs the interactive `claude` in a **credential-isolated home** (`$HOME/.ocp-tui/home`, no `credentials.json`) so this long-lived token is the only credential and is authoritative — interactive `claude` otherwise *prefers* `~/.claude/.credentials.json` over the env var, so a stale one shadows the token and its single-use refresh token gets corrupted by the spawn/teardown cycle (the permanent `Please run /login` 401 — see [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) and ADR 0007 PR-D). The token appears in the pane command (ps-visible) — acceptable for the single-user A-path; the multi-user B-path is refused at boot. |
|
||||||
| `CLAUDE_TUI_WALLCLOCK_MS` | `120000` | (TUI-mode) Maximum time in ms to wait for the native transcript to signal turn completion. Increase for long Opus thinking turns. |
|
| `CLAUDE_TUI_WALLCLOCK_MS` | `120000` | (TUI-mode) Maximum time in ms to wait for the native transcript to signal turn completion. Increase for long Opus thinking turns. |
|
||||||
| `OCP_TUI_CWD` | `$HOME/.ocp-tui/work` | (TUI-mode) Scratch working directory where interactive claude sessions run. Transcripts land under `<HOME>/.claude/projects/<encoded-cwd>/`. Created automatically. |
|
| `OCP_TUI_CWD` | `$HOME/.ocp-tui/work` | (TUI-mode) Scratch working directory where interactive claude sessions run. Transcripts land under `<HOME>/.claude/projects/<encoded-cwd>/`. Created automatically. |
|
||||||
| `OCP_TUI_HOME` | `$HOME` (real home) | (TUI-mode) `HOME` claude runs under. Default is the operator's real home (shared credentials, existing onboarding). Set to a separate path for scratch-home isolation — see ADR 0007 for the credential-fork caveat. |
|
| `OCP_TUI_HOME` | *(auto)* | (TUI-mode) `HOME` claude runs under. **When unset, OCP picks it for you:** if `CLAUDE_CODE_OAUTH_TOKEN` is set → a **credential-isolated** scratch home `$HOME/.ocp-tui/home` (no `credentials.json`, env-token auth — **recommended**); if no env token → the operator's real home (legacy shared `credentials.json`). Setting this to an **explicit** path overrides the auto-default. The credential handling at that path still follows the env token: **with** the env token it is credential-free (env-token auth, no `credentials.json` written); **without** the env token (and the path ≠ real home) it uses the legacy symlinked-credentials scratch mode, which carries the credential-fork caveat — see ADR 0007. |
|
||||||
| `OCP_TUI_ENTRYPOINT` | `cli` | (TUI-mode) Billing-classifier labeling: `cli` (default) pins `cc_entrypoint=cli` deterministically; `auto` lets claude self-classify via TTY detection; `off` leaves the inherited env untouched. Honest only when the spawn is a genuine interactive PTY — see ADR 0007. |
|
| `OCP_TUI_ENTRYPOINT` | `cli` | (TUI-mode) Billing-classifier labeling: `cli` (default) pins `cc_entrypoint=cli` deterministically; `auto` lets claude self-classify via TTY detection; `off` leaves the inherited env untouched. Honest only when the spawn is a genuine interactive PTY — see ADR 0007. |
|
||||||
| `OCP_TUI_MAX_CONCURRENT` | `2` | (TUI-mode) Max concurrent interactive TUI turns. **Independent** of `CLAUDE_MAX_CONCURRENT` (which bounds the `-p`/stream-json path; TUI never uses it). A TUI turn is heavy (per-request cold-boot of tmux+claude + up to `CLAUDE_TUI_WALLCLOCK_MS` wallclock), so the default is low to keep small hosts (e.g. a Pi 4) alive under a burst. Excess turns **queue** (bounded); a full queue yields a 503. See ADR 0007 PR-B amendment. |
|
| `OCP_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_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
|
# Enable
|
||||||
export CLAUDE_TUI_MODE=true
|
export CLAUDE_TUI_MODE=true
|
||||||
|
# STRONGLY RECOMMENDED on a TUI host — authenticate via the long-lived OAuth token.
|
||||||
|
# With this set (and OCP_TUI_HOME left UNSET), OCP runs the interactive claude in a
|
||||||
|
# credential-isolated home ($HOME/.ocp-tui/home, no credentials.json), so the env token
|
||||||
|
# is the only credential and is authoritative. This both stops a stale credentials.json
|
||||||
|
# from shadowing the token AND ends the refresh-token corruption that caused a permanent
|
||||||
|
# "Please run /login" 401 (no credentials file → claude never runs the refresh path).
|
||||||
|
# See the auth note below + ADR 0007 PR-D.
|
||||||
|
export CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-...
|
||||||
# Optionally tune:
|
# Optionally tune:
|
||||||
export CLAUDE_TUI_WALLCLOCK_MS=180000 # 3 min cap for long Opus turns
|
export CLAUDE_TUI_WALLCLOCK_MS=180000 # 3 min cap for long Opus turns
|
||||||
export OCP_TUI_CWD=$HOME/.ocp-tui/work # default; override if needed
|
export OCP_TUI_CWD=$HOME/.ocp-tui/work # default; override if needed
|
||||||
export OCP_TUI_ENTRYPOINT=cli # default; use 'auto' to observe TTY-derived value
|
export OCP_TUI_ENTRYPOINT=cli # default; use 'auto' to observe TTY-derived value
|
||||||
|
# Do NOT set OCP_TUI_HOME for the recommended setup — leaving it unset is what enables
|
||||||
|
# the credential-isolated home. Set it only to opt into the legacy symlinked-creds mode.
|
||||||
```
|
```
|
||||||
|
|
||||||
Then restart OCP. At boot you will see:
|
Then restart OCP. At boot you will see (with the env token set, isolated home auto-selected):
|
||||||
|
|
||||||
```
|
```
|
||||||
⚠️ TUI-mode ON — single-user only; do NOT enable on a multi-user OCP ...
|
⚠️ TUI-mode ON — single-user only; do NOT enable on a multi-user OCP ...
|
||||||
TUI-mode: ON home=/home/user cwd=/home/user/.ocp-tui/work wallclock=120000ms
|
TUI-mode: ON home=/home/user/.ocp-tui/home cwd=/home/user/.ocp-tui/work auth=env-token (credential-isolated home — no credentials.json) wallclock=120000ms maxConcurrent=2
|
||||||
```
|
```
|
||||||
|
|
||||||
### What changes / what doesn't
|
### What changes / what doesn't
|
||||||
@@ -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.
|
- **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` in a credential-isolated home (recommended).** tmux does not forward the parent process's env to the pane, so OCP sets the token explicitly on the spawned `claude` when `CLAUDE_CODE_OAUTH_TOKEN` is present. But passing the token is **not enough on its own**: interactive `claude` *prefers* `~/.claude/.credentials.json` over the env var (unlike the `-p` path), so a stale `credentials.json` would shadow the token. With the env token set and `OCP_TUI_HOME` unset, OCP therefore runs claude in a **credential-isolated home** (`$HOME/.ocp-tui/home`) that has **no `credentials.json`** — so the env token is the only credential and is authoritative, and claude never runs the token-refresh path (so the single-use refresh token can't be corrupted by the spawn/teardown cycle). On a long-running host the credentials.json path produced a permanent `Please run /login · API Error: 401` that re-login could not fix (the next spawn re-corrupted it); the isolated home ends that at the root. Transcripts land under the same isolated home, so the answer-reader is unaffected. Without the env token, claude falls back to the real home's `credentials.json` (byte-for-byte the previous behaviour). (The token is visible in `ps` on the pane command — acceptable for the single-user A-path; the multi-user B-path is refused at boot.) See ADR 0007 PR-C / PR-D amendments.
|
||||||
|
- **Stale tmux sessions are reaped.** The pane's `claude` is a child of the tmux server (not OCP), so OCP cannot reap it directly; `claude` zombies can otherwise accumulate as `<defunct>` over a long-running host. OCP reaps them at boot and on a 15-min idle sweep by issuing `tmux kill-server` — but **only when no foreign tmux session remains** (it never disrupts a co-hosted `olp-tui-*` instance). See ADR 0007 PR-C amendment.
|
||||||
- **Default path unchanged.** Unset `CLAUDE_TUI_MODE` and restart → `callClaude` / `callClaudeStreaming` are used again, byte-for-byte identical to today.
|
- **Default path unchanged.** Unset `CLAUDE_TUI_MODE` and restart → `callClaude` / `callClaudeStreaming` are used again, byte-for-byte identical to today.
|
||||||
- **Concurrency is bounded separately.** TUI turns are heavy (per-request cold-boot + long wallclock), so the TUI path has its own limiter — `OCP_TUI_MAX_CONCURRENT` (default `2`), independent of `CLAUDE_MAX_CONCURRENT`. Excess turns queue; a full queue returns a 503. Tune it up only on a host that can run more interactive `claude` sessions at once.
|
- **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.
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# ADR 0007 — TUI Interactive Mode (subscription-pool bridge)
|
# ADR 0007 — TUI Interactive Mode (subscription-pool bridge)
|
||||||
|
|
||||||
**Date:** 2026-05-31
|
**Date:** 2026-05-31
|
||||||
**Status:** Accepted — amended by PR-4 (entrypoint hardening)
|
**Status:** Accepted — amended by PR-4 (entrypoint hardening), PR-B (observability + concurrency), PR-C (env-token auth + defunct-reaping), PR-D (credential-isolated home — corrects PR-C)
|
||||||
**Deciders:** project maintainer
|
**Deciders:** project maintainer
|
||||||
**Authority:** claude CLI v2.1.158 interactive mode — verified live on the test host that sessions launched without `-p` / `--output-format` carry `cc_entrypoint=cli` (subscription pool), not `cc_entrypoint=sdk-cli` (Agent SDK credit pool). Mechanism verified on cli.js v2.1.104; live-confirmed on v2.1.158.
|
**Authority:** claude CLI v2.1.158 interactive mode — verified live on the test host that sessions launched without `-p` / `--output-format` carry `cc_entrypoint=cli` (subscription pool), not `cc_entrypoint=sdk-cli` (Agent SDK credit pool). Mechanism verified on cli.js v2.1.104; live-confirmed on v2.1.158.
|
||||||
|
|
||||||
@@ -98,12 +98,16 @@ When `CLAUDE_TUI_MODE` is unset (the default), no code path touches `callClaudeT
|
|||||||
|
|
||||||
Unset `CLAUDE_TUI_MODE` (or set it to any value other than `"true"`) → stream-json path restored immediately on next restart.
|
Unset `CLAUDE_TUI_MODE` (or set it to any value other than `"true"`) → stream-json path restored immediately on next restart.
|
||||||
|
|
||||||
### Home strategy (real-home default)
|
### Home strategy
|
||||||
|
|
||||||
`TUI_HOME = OCP_TUI_HOME || HOME` (defaults to the operator's real home).
|
> **Superseded by the PR-D amendment below for the env-token case.** As of PR-D, `TUI_HOME`
|
||||||
|
> is computed by `resolveTuiHome()`: when `CLAUDE_CODE_OAUTH_TOKEN` is set (and `OCP_TUI_HOME`
|
||||||
|
> is unset) the default is a **credential-free scratch home**, not the real home. The
|
||||||
|
> descriptions below remain accurate for the **no-env-token** case and the **explicit
|
||||||
|
> `OCP_TUI_HOME` override** case.
|
||||||
|
|
||||||
- **Real-home (default, `OCP_TUI_HOME` unset):** claude runs with the operator's own `~/.claude/` — shared credentials, existing onboarding, no OAuth fork risk. `ensureTuiCwdTrusted` seeds the trust record for the scratch cwd in the real `~/.claude.json` (atomic write).
|
- **Real-home (default when NO env token, `OCP_TUI_HOME` unset):** claude runs with the operator's own `~/.claude/` — shared credentials, existing onboarding, no OAuth fork risk. `ensureTuiCwdTrusted` seeds the trust record for the scratch cwd in the real `~/.claude.json` (atomic write).
|
||||||
- **Scratch-home opt-in (`OCP_TUI_HOME=<path>`):** a dedicated `HOME` that symlinks `~/.claude/.credentials.json` from the real home (token is never copied) and seeds a stripped `~/.claude.json` (no project history, trusts only the scratch cwd). **Caveat:** claude rewrites `.credentials.json` on OAuth token refresh, replacing the symlink with a regular file — this forks the credentials. Use scratch-home only with a dedicated OAuth or for ephemeral testing.
|
- **Scratch-home opt-in (`OCP_TUI_HOME=<path>`, no env token):** a dedicated `HOME` that symlinks `~/.claude/.credentials.json` from the real home (token is never copied) and seeds a stripped `~/.claude.json` (no project history, trusts only the scratch cwd). **Caveat:** claude rewrites `.credentials.json` on OAuth token refresh, replacing the symlink with a regular file — this forks the credentials. Use this legacy symlink mode only with a dedicated OAuth or for ephemeral testing. (The PR-D env-token mode avoids this caveat entirely — no credentials file to fork.)
|
||||||
|
|
||||||
### Working directory
|
### Working directory
|
||||||
|
|
||||||
@@ -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=<shq-escaped token>` to the pane command's `env` prefix. claude then authenticates via this long-lived token and **never touches the credentials-refresh path**. This is the stable mode — it is exactly how the oracle and Mac-mini hosts already run (and how `server.mjs`'s own `getOAuthCredentials()` takes the same env at highest precedence). cli.js is **not** the authority here: this is a Class B, OCP-owned TUI spawn — see the Class B citation below.
|
||||||
|
2. **`<HOME>/.claude/.credentials.json` (FALLBACK).** When the env var is unset, claude falls back to the credentials file and its short-lived access token, renewing via the single-use refresh token.
|
||||||
|
|
||||||
|
The token MUST be set explicitly in `buildTuiCmd` because **tmux does not forward the parent process's environment to the pane** (verified live 2026-06-01 — the same reason the whole env is delivered as an `env` prefix). A token sitting in the OCP process env is invisible to the pane unless `buildTuiCmd` re-emits it.
|
||||||
|
|
||||||
|
### Why the fallback path corrupts (the PI231 incident)
|
||||||
|
|
||||||
|
When the env token is absent, every per-request spawn drives claude through the credentials.json refresh path. OAuth refresh tokens are **single-use / rotating**: a refresh consumes the old refresh token and writes a new one. The per-request `kill-session` teardown can race / interrupt claude mid-rotation, and over many spawn+kill cycles the refresh token ended up an **empty string** — at which point renewal is impossible and the host returns a permanent 401. Re-login writes a fresh token, but the next spawn re-corrupts it. **Proof the env-token fix works:** on the broken PI231 host, `CLAUDE_CODE_OAUTH_TOKEN=<oat01 token> claude -p ...` returned a real answer *despite* the corrupt credentials.json (control without the env token = 401).
|
||||||
|
|
||||||
|
**Operator guidance:** set `CLAUDE_CODE_OAUTH_TOKEN` on any TUI-mode host. The credentials.json fallback is retained only for hosts that intentionally rely on it; it is not recommended for a long-running TUI deployment.
|
||||||
|
|
||||||
|
**Security note:** with the token in the pane command, it is visible in `ps`. This is acceptable for the **single-user A-path** (it mirrors the existing plaintext-token practice for `server.mjs`), and the **multi-user B-path is already refused at boot** (`CLAUDE_TUI_MODE=true` + `AUTH_MODE=multi` is a hard FATAL), so a guest can never reach this spawn.
|
||||||
|
|
||||||
|
### Defunct `<claude>` reaping
|
||||||
|
|
||||||
|
The connected leak: the pane's `claude` process is a child of the long-lived **tmux server** daemon, not of the OCP node process (`tmux new-session -d` returns the instant the server forks the pane). Node can therefore never `waitpid()`/reap it — a SIGKILL still needs the *parent* (the tmux server) to reap. `kill-session` destroys the session but leaves the pane's `claude` (and its grandchildren) as `<defunct>` zombies that only the server reaps; over 30 days on PI231 this accumulated to **25 defunct `<claude>`** (a live `tmux kill-server` dropped it 25→3).
|
||||||
|
|
||||||
|
The node-reachable action that *actually reaps* — rather than merely re-signalling — is to stop the tmux server: on server exit the kernel reparents survivors to init (PID 1), which reaps them. `reapStaleTuiSessions` therefore, after killing our own `ocp-tui-*` sessions, issues `kill-server` **only when no foreign session of any prefix remains** (coexistence: never disrupt a co-hosted `olp-tui-*` instance). This runs at boot (existing) and now on a 15-min periodic interval gated on TUI-mode and on the TUI path being idle (`inflight === 0 && queued === 0`) so a live turn's pane is never torn down. Residual: a request whose pane is created in the narrow window between the idle-check and `kill-server` would fail cleanly via the existing honesty gates (rare; documented in the server comment).
|
||||||
|
|
||||||
|
### ALIGNMENT authorization (Class B)
|
||||||
|
|
||||||
|
Both changes are **Class B** (OCP-owned TUI spawn). `cli.js` does not perform either operation — there is no `cli.js` analogue for "how the TUI pane authenticates" or "reaping tmux-server-owned zombies"; this surface is authorized by **this ADR (0007)** per `ALIGNMENT.md`'s Class B citation requirement. No Class A wire surface, no endpoint shape, no `alignment.yml` blacklist token, and no `models.json` entry is touched.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Credential-isolated home for env-token auth (PR-D amendment)
|
||||||
|
|
||||||
|
**Date:** 2026-06-13
|
||||||
|
**Status:** Accepted — amends ADR 0007. **Corrects** the PR-C rationale and the original "Home strategy" section's scratch-home caveat.
|
||||||
|
**Motivation:** PR-C's env-token passing alone did **not** fix the PI231 401. Decisive live evidence (claude 2.1.104, PI231):
|
||||||
|
|
||||||
|
| Condition | Result |
|
||||||
|
|---|---|
|
||||||
|
| env token passed + a broken `~/.claude/.credentials.json` present | **401** (`Please run /login · API Error: 401`) |
|
||||||
|
| env token passed + `credentials.json` moved aside | **works** (real answer) |
|
||||||
|
|
||||||
|
### Corrected root cause
|
||||||
|
|
||||||
|
**Interactive `claude` PREFERS `~/.claude/.credentials.json` over the `CLAUDE_CODE_OAUTH_TOKEN` env var.** A stale/corrupt `credentials.json` therefore **shadows** the env token. (This is *unlike* `-p` mode, where the env token wins — which is why `server.mjs`'s own `getOAuthCredentials()` is unaffected and why PR-C's premise looked sufficient.) So passing the token (PR-C, `buildTuiCmd`) is **necessary but insufficient**: the TUI `claude` must additionally run in a HOME that has **no `credentials.json`**, so the env token is the only credential and is authoritative.
|
||||||
|
|
||||||
|
This also fixes the original incident at the **root**, more completely than PR-C claimed: with no `credentials.json` in the home, claude never runs the token-refresh path at all, so the single-use refresh token can never be rotated — and therefore never corrupted — by the spawn+`kill-session` cycle. The 25-zombie / empty-refresh-token failure mode becomes structurally impossible, not merely avoided.
|
||||||
|
|
||||||
|
### Decision
|
||||||
|
|
||||||
|
When `CLAUDE_CODE_OAUTH_TOKEN` is set, the TUI `claude` runs in a **credential-free scratch home** by default:
|
||||||
|
|
||||||
|
- `resolveTuiHome({ realHome, configuredHome, envTokenSet })` (exported from `lib/tui/session.mjs`, pure) decides the home:
|
||||||
|
- **`OCP_TUI_HOME` set** → that path (explicit override, back-compat — an operator who configured it keeps exactly that home).
|
||||||
|
- **else env token set** → `<realHome>/.ocp-tui/home` — a dedicated scratch home seeded with a minimal `.claude.json` (`hasCompletedOnboarding=true` + trust **only** the scratch cwd) and its own `projects/` dir, and **deliberately NO `.credentials.json`** (no symlink, no copy).
|
||||||
|
- **else (no env token)** → the operator's real home — **byte-for-byte the pre-fix behaviour** for hosts that intentionally rely on `credentials.json`.
|
||||||
|
- `prepareTuiHome(realHome, tuiHome, cwd, { envTokenMode })` gates the credential handling: in `envTokenMode` it creates the scratch `projects/` dir and seeds the minimal trusted `.claude.json` but **never** creates the credentials symlink. `runTuiTurn` sets `envTokenMode = !!CLAUDE_CODE_OAUTH_TOKEN && ehome !== realHome`.
|
||||||
|
- `readTuiTranscript` reads from the **same** home claude runs under (`ehome`), so transcripts land under `<scratch home>/.claude/projects/` and `findTranscriptPath` globs them there — the home is threaded through consistently. (We chose scratch-`HOME` over `CLAUDE_CONFIG_DIR`: the binary supports `CLAUDE_CONFIG_DIR`, but it relocates the transcript root to `<CONFIG_DIR>/projects/` rather than `<HOME>/.claude/projects/`, which would fork the transcript-resolution rule across modes for no benefit. The scratch-HOME lever reuses the existing, tested `prepareTuiHome`/`ehome` plumbing.)
|
||||||
|
|
||||||
|
### This RESOLVES — not reintroduces — the scratch-home caveat
|
||||||
|
|
||||||
|
The original "Home strategy" section and PR-C's `prepareTuiHome` comment warned that scratch-home is unsafe because *claude rewrites a **symlinked** `.credentials.json` on token refresh → forks/corrupts the OAuth credentials*. **That caveat does not apply to env-token mode**: there is no `credentials.json` in the home to fork, and claude never refreshes (it uses the long-lived env token), so there is no rotation and no corruption. The fork risk was inherent to the *symlink* approach; removing the credentials file entirely removes the risk. The legacy symlink mode is retained **only** for an operator who explicitly sets `OCP_TUI_HOME` without an env token, and its caveat is preserved for exactly that path.
|
||||||
|
|
||||||
|
### ALIGNMENT authorization (Class B)
|
||||||
|
|
||||||
|
**Class B** (OCP-owned TUI spawn). `cli.js` has no analogue for the TUI pane's auth/home strategy; authorized by **this ADR (0007)** per `ALIGNMENT.md`'s Class B citation requirement. `server.mjs` is touched only to compute `TUI_HOME` via `resolveTuiHome()` (TUI wiring) and to surface the auth mode in the boot log — no Class A wire surface, no endpoint shape, no `alignment.yml` blacklist token, and no `models.json` entry is touched.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
### Positive
|
### Positive
|
||||||
|
|||||||
+129
-26
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,39 +156,85 @@ export function ensureTuiCwdTrusted(home, cwd) {
|
|||||||
} catch { /* best effort */ }
|
} catch { /* best effort */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare the HOME claude runs under. Two modes:
|
// Resolve the HOME the TUI `claude` runs under. Three intents, decided by the env
|
||||||
// - real-home (tuiHome === realHome OR falsy): no isolation; just trust the cwd
|
// token + an explicit OCP_TUI_HOME override:
|
||||||
// in the real ~/.claude.json. Opt in by setting OCP_TUI_HOME=$HOME.
|
|
||||||
// - scratch-home: a dedicated HOME that reuses the real OAuth via a SYMLINKED
|
|
||||||
// .credentials.json, with a seeded .claude.json (onboarded real config minus
|
|
||||||
// the user's project history; trusts only the scratch cwd) and its own
|
|
||||||
// projects/ dir — so the real ~/.claude is never mutated or polluted.
|
|
||||||
//
|
//
|
||||||
// ⚠️ CREDENTIAL CAVEAT (verified live): claude rewrites .credentials.json on token
|
// - ENV-TOKEN MODE (default when CLAUDE_CODE_OAUTH_TOKEN is set AND OCP_TUI_HOME is
|
||||||
// refresh, REPLACING the symlink with a regular-file copy → the scratch home then
|
// unset): a CREDENTIAL-FREE scratch home at `<realHome>/.ocp-tui/home`. There is
|
||||||
// FORKS the OAuth credentials. Because OAuth refresh tokens rotate (single-use), a
|
// deliberately NO .credentials.json (no symlink, no copy), so the only credential
|
||||||
// refresh in the scratch home can invalidate the token the user's real-home claude
|
// claude can find is the long-lived env token (passed by buildTuiCmd). This is what
|
||||||
// relies on. Therefore scratch-home is safe only with a DEDICATED OAuth or for
|
// actually FORCES env-token auth — see the prepareTuiHome comment for why passing
|
||||||
// ephemeral use; for a shared subscription prefer real-home (tuiHome===realHome),
|
// the token alone is insufficient.
|
||||||
// which shares one .credentials.json — identical to how OCP already spawns claude.
|
// - EXPLICIT OVERRIDE: whatever OCP_TUI_HOME names (back-compat; an operator who set it
|
||||||
// Idempotent + best-effort: any failure degrades toward the dialog/cap, never
|
// keeps exactly that home).
|
||||||
// corrupts. Run BEFORE the session boots.
|
// - REAL-HOME (default when the env token is unset): the operator's real home, shared
|
||||||
export function prepareTuiHome(realHome, tuiHome, cwd) {
|
// credentials.json — byte-for-byte the pre-fix behaviour for credentials.json hosts.
|
||||||
|
//
|
||||||
|
// Pure + deterministic so server.mjs and the tests share one decision. `configuredHome`
|
||||||
|
// is the raw OCP_TUI_HOME value (undefined/empty => unset).
|
||||||
|
export const DEFAULT_TUI_SCRATCH_HOME = (realHome) => `${realHome}/.ocp-tui/home`;
|
||||||
|
export function resolveTuiHome({ realHome, configuredHome, envTokenSet }) {
|
||||||
|
if (configuredHome) return configuredHome; // explicit override wins (back-compat)
|
||||||
|
if (envTokenSet) return DEFAULT_TUI_SCRATCH_HOME(realHome); // credential-free scratch
|
||||||
|
return realHome; // legacy real-home default
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare the HOME claude runs under. Three modes:
|
||||||
|
// - real-home (tuiHome === realHome OR falsy): no isolation; just trust the cwd
|
||||||
|
// in the real ~/.claude.json. The legacy default when no env token is set.
|
||||||
|
// - ENV-TOKEN scratch-home (envTokenMode === true): a dedicated HOME with a seeded
|
||||||
|
// .claude.json (onboarded + trusts only the scratch cwd) and its own projects/ dir,
|
||||||
|
// and DELIBERATELY NO .credentials.json (no symlink, no copy). claude then has no
|
||||||
|
// credentials file to read, so it authenticates via CLAUDE_CODE_OAUTH_TOKEN (passed
|
||||||
|
// by buildTuiCmd) — which is authoritative precisely because nothing shadows it.
|
||||||
|
// - legacy scratch-home (envTokenMode falsy, tuiHome !== realHome): the historical
|
||||||
|
// mode that SYMLINKS the real .credentials.json. Retained only for an operator who
|
||||||
|
// explicitly set OCP_TUI_HOME without an env token; see the caveat below.
|
||||||
|
//
|
||||||
|
// WHY ENV-TOKEN MODE IS THE FIX (proven live on PI231, claude 2.1.104):
|
||||||
|
// env token passed + a broken ~/.claude/.credentials.json present → 401.
|
||||||
|
// env token passed + credentials.json moved aside → real answer.
|
||||||
|
// Interactive `claude` PREFERS .credentials.json over the env var (unlike `-p`, where the
|
||||||
|
// env token wins), so a stale/corrupt credentials.json SHADOWS the env token. Passing the
|
||||||
|
// token is necessary but insufficient; the TUI claude must run in a HOME with NO
|
||||||
|
// credentials.json so the env token is the only credential. This ALSO ends the refresh-
|
||||||
|
// corruption incident at the root: with no credentials file, claude never runs the token-
|
||||||
|
// refresh path, so the single-use refresh token can never be rotated (and corrupted) by the
|
||||||
|
// spawn+kill cycle. (This RESOLVES — not reintroduces — the ADR 0007 scratch-home concern:
|
||||||
|
// the old caveat was about a SYMLINKED credentials.json being forked on refresh; here there
|
||||||
|
// is no credentials file to fork and no refresh ever happens.)
|
||||||
|
//
|
||||||
|
// ⚠️ LEGACY SCRATCH-HOME CAVEAT (envTokenMode falsy, symlink path): claude rewrites
|
||||||
|
// .credentials.json on token refresh, REPLACING the symlink with a regular-file copy → the
|
||||||
|
// scratch home FORKS the OAuth credentials and a refresh can invalidate the real-home token.
|
||||||
|
// That path is therefore safe only with a DEDICATED OAuth or for ephemeral use. The env-token
|
||||||
|
// mode above avoids this entirely.
|
||||||
|
//
|
||||||
|
// Idempotent + best-effort: any failure degrades toward the dialog/cap, never corrupts.
|
||||||
|
// Run BEFORE the session boots.
|
||||||
|
export function prepareTuiHome(realHome, tuiHome, cwd, { envTokenMode = false } = {}) {
|
||||||
if (!tuiHome || tuiHome === realHome) { ensureTuiCwdTrusted(realHome, cwd); return; }
|
if (!tuiHome || tuiHome === realHome) { ensureTuiCwdTrusted(realHome, cwd); return; }
|
||||||
try {
|
try {
|
||||||
const claudeDir = `${tuiHome}/.claude`;
|
const claudeDir = `${tuiHome}/.claude`;
|
||||||
mkdirSync(`${claudeDir}/projects`, { recursive: true });
|
mkdirSync(`${claudeDir}/projects`, { recursive: true });
|
||||||
// Symlink the real credentials (never copy the OAuth token); refresh if missing.
|
if (!envTokenMode) {
|
||||||
const link = `${claudeDir}/.credentials.json`;
|
// Legacy mode ONLY: symlink the real credentials (never copy the token); refresh if
|
||||||
if (!existsSync(link)) {
|
// missing. Env-token mode deliberately skips this — no credentials file at all.
|
||||||
try { symlinkSync(`${realHome}/.claude/.credentials.json`, link); } catch { /* best effort */ }
|
const link = `${claudeDir}/.credentials.json`;
|
||||||
|
if (!existsSync(link)) {
|
||||||
|
try { symlinkSync(`${realHome}/.claude/.credentials.json`, link); } catch { /* best effort */ }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Seed .claude.json ONCE (if absent): start from the onboarded real config,
|
// Seed .claude.json ONCE (if absent): onboarded + trust ONLY the scratch cwd.
|
||||||
// drop the user's project history, trust only the scratch cwd. mode 0600.
|
// In env-token mode start from a MINIMAL config (do NOT copy the real ~/.claude.json —
|
||||||
|
// a credential-isolated home should not inherit the operator's account/config state);
|
||||||
|
// in legacy mode carry the onboarded real config minus the user's project history.
|
||||||
const seedPath = `${tuiHome}/.claude.json`;
|
const seedPath = `${tuiHome}/.claude.json`;
|
||||||
if (!existsSync(seedPath)) {
|
if (!existsSync(seedPath)) {
|
||||||
let base = {};
|
let base = {};
|
||||||
try { base = JSON.parse(readFileSync(`${realHome}/.claude.json`, "utf8")); } catch { /* fresh */ }
|
if (!envTokenMode) {
|
||||||
|
try { base = JSON.parse(readFileSync(`${realHome}/.claude.json`, "utf8")); } catch { /* fresh */ }
|
||||||
|
}
|
||||||
base.hasCompletedOnboarding = true;
|
base.hasCompletedOnboarding = true;
|
||||||
base.projects = { [cwd]: { hasTrustDialogAccepted: true, allowedTools: [] } };
|
base.projects = { [cwd]: { hasTrustDialogAccepted: true, allowedTools: [] } };
|
||||||
writeFileSync(seedPath, JSON.stringify(base, null, 2), { mode: 0o600 });
|
writeFileSync(seedPath, JSON.stringify(base, null, 2), { mode: 0o600 });
|
||||||
@@ -217,6 +291,27 @@ 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
|
||||||
@@ -292,10 +387,18 @@ export async function runTuiTurn({
|
|||||||
const ehome = home || process.env.HOME; // HOME claude runs under (scratch or real)
|
const ehome = home || process.env.HOME; // HOME claude runs under (scratch or real)
|
||||||
const rhome = realHome || process.env.HOME; // real home (OAuth + onboarded config source)
|
const rhome = realHome || process.env.HOME; // real home (OAuth + onboarded config source)
|
||||||
|
|
||||||
|
// Env-token-only mode: the env token is set AND claude runs in an isolated home
|
||||||
|
// (ehome !== rhome). In that case the scratch home must be CREDENTIAL-FREE (no
|
||||||
|
// .credentials.json) so the env token — passed by buildTuiCmd — is the only credential
|
||||||
|
// and is therefore authoritative (interactive claude otherwise PREFERS a credentials.json,
|
||||||
|
// shadowing the env token; proven live on PI231). server.mjs derives TUI_HOME via
|
||||||
|
// resolveTuiHome() so this isolated home is the DEFAULT once CLAUDE_CODE_OAUTH_TOKEN is set.
|
||||||
|
const envTokenMode = !!process.env.CLAUDE_CODE_OAUTH_TOKEN && ehome !== rhome;
|
||||||
|
|
||||||
// Ensure scratch cwd exists, then prepare the (scratch or real) HOME + trust the
|
// Ensure scratch cwd exists, then prepare the (scratch or real) HOME + trust the
|
||||||
// cwd — before claude boots.
|
// cwd — before claude boots.
|
||||||
if (!existsSync(cwd)) mkdirSync(cwd, { recursive: true });
|
if (!existsSync(cwd)) mkdirSync(cwd, { recursive: true });
|
||||||
prepareTuiHome(rhome, ehome, cwd);
|
prepareTuiHome(rhome, ehome, cwd, { envTokenMode });
|
||||||
|
|
||||||
// Write prompt to a temp file (mode 0600) so the content never touches argv.
|
// Write prompt to a temp file (mode 0600) so the content never touches argv.
|
||||||
const tmpDir = mkdtempSync(`${tmpdir()}/ocp-tui-`);
|
const tmpDir = mkdtempSync(`${tmpdir()}/ocp-tui-`);
|
||||||
|
|||||||
+42
-3
@@ -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 { validateKey, recordUsage, getUsageByKey, getUsageTimeline, getRecentUsage, createKey, listKeys, revokeKey, closeDb, checkQuota, updateKeyQuota, getKeyQuota, findKey, cacheHash, getCachedResponse, setCachedResponse, clearCache, getCacheStats, hasCacheControl, singleflight, getInflightStats } from "./keys.mjs";
|
||||||
import { DEFAULT_PORT } from "./lib/constants.mjs";
|
import { DEFAULT_PORT } from "./lib/constants.mjs";
|
||||||
import { isLoopbackBind } from "./lib/net.mjs";
|
import { isLoopbackBind } from "./lib/net.mjs";
|
||||||
import { runTuiTurn, reapStaleTuiSessions } from "./lib/tui/session.mjs";
|
import { runTuiTurn, reapStaleTuiSessions, resolveTuiHome } from "./lib/tui/session.mjs";
|
||||||
import { detectTuiUpstreamError } from "./lib/tui/transcript.mjs";
|
import { detectTuiUpstreamError } from "./lib/tui/transcript.mjs";
|
||||||
import { TuiSemaphore, recordTuiEntrypoint, buildTuiHealthBlock } from "./lib/tui/semaphore.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_MODE = process.env.CLAUDE_TUI_MODE === "true";
|
||||||
const TUI_WALLCLOCK_MS = parseInt(process.env.CLAUDE_TUI_WALLCLOCK_MS || "120000", 10);
|
const TUI_WALLCLOCK_MS = parseInt(process.env.CLAUDE_TUI_WALLCLOCK_MS || "120000", 10);
|
||||||
const TUI_CWD = process.env.OCP_TUI_CWD || `${process.env.HOME}/.ocp-tui/work`;
|
const TUI_CWD = process.env.OCP_TUI_CWD || `${process.env.HOME}/.ocp-tui/work`;
|
||||||
const TUI_HOME = process.env.OCP_TUI_HOME || process.env.HOME;
|
// HOME the interactive claude runs under. resolveTuiHome() decides:
|
||||||
|
// - OCP_TUI_HOME set → that path (explicit override, back-compat).
|
||||||
|
// - else CLAUDE_CODE_OAUTH_TOKEN set → a CREDENTIAL-FREE scratch home
|
||||||
|
// (<HOME>/.ocp-tui/home) with NO .credentials.json, so the env token is the only
|
||||||
|
// credential and is authoritative — interactive claude otherwise PREFERS a
|
||||||
|
// credentials.json over the env var, so a stale one shadows the token (proven live on
|
||||||
|
// PI231) and a refresh on it can corrupt the single-use token. See ADR 0007 PR-D.
|
||||||
|
// - else (no env token) → the operator's real home (legacy credentials.json path,
|
||||||
|
// byte-for-byte unchanged for hosts that intentionally rely on credentials.json).
|
||||||
|
const TUI_HOME = resolveTuiHome({
|
||||||
|
realHome: process.env.HOME,
|
||||||
|
configuredHome: process.env.OCP_TUI_HOME,
|
||||||
|
envTokenSet: !!process.env.CLAUDE_CODE_OAUTH_TOKEN,
|
||||||
|
});
|
||||||
const TUI_ENTRYPOINT = process.env.OCP_TUI_ENTRYPOINT || "cli"; // cli|auto|off — see ADR 0007
|
const TUI_ENTRYPOINT = process.env.OCP_TUI_ENTRYPOINT || "cli"; // cli|auto|off — see ADR 0007
|
||||||
// Independent concurrency bound for the TUI path (audit C-4). Default 2: a TUI turn is
|
// 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
|
// 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);
|
}, 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();
|
||||||
|
|
||||||
@@ -2284,6 +2319,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
|
||||||
@@ -2342,7 +2378,10 @@ 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 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 {
|
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 });
|
||||||
|
|||||||
@@ -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");
|
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)", () => {
|
test("buildTuiCmd OCP_TUI_FULL_TOOLS=1 grants -p-equivalent tool surface (single-user opt-in)", () => {
|
||||||
const save = { ...process.env };
|
const save = { ...process.env };
|
||||||
const restore = () => {
|
const restore = () => {
|
||||||
@@ -1766,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";
|
||||||
@@ -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
|
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 ───────────────────────────────────────────────
|
// ── resolveTuiEntrypointEnv ───────────────────────────────────────────────
|
||||||
import { resolveTuiEntrypointEnv } from "./lib/tui/session.mjs";
|
import { resolveTuiEntrypointEnv } from "./lib/tui/session.mjs";
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user