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:
dtzp555-max
2026-06-13 16:54:32 +10:00
committed by GitHub
co-authored by taodeng Claude <claude-opus> <noreply@anthropic.com>
parent c86e3d014f
commit 60930f0ba4
5 changed files with 415 additions and 37 deletions
+42 -3
View File
@@ -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
// (<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 `<claude>`
// zombies between restarts — the pane's claude is a child of the tmux server, not node,
// so only the server can reap it (see reapStaleTuiSessions). We sweep every 15 min, but
// ONLY when the TUI path is fully idle: reapStaleTuiSessions may `kill-server`, which would
// tear down a live turn's pane, so we skip the sweep while any turn is inflight or queued.
// RESIDUAL (documented, accepted): a brand-new request whose pane is created in the narrow
// window between this idle-check and kill-server would have its pane torn down and fail the
// turn cleanly via runTuiTurn's existing honesty gates (rare; the boot reap is the primary
// mechanism and the 15-min cadence makes the window negligible).
// Gated on TUI_MODE — zero effect (no kill-server, no list-sessions) when TUI is off.
// cli.js does NOT perform this operation (Class B, OCP-owned TUI spawn) — see ADR 0007.
const TUI_REAP_INTERVAL_MS = 15 * 60 * 1000;
const tuiReapInterval = TUI_MODE ? setInterval(() => {
if (tuiSemaphore.inflight > 0 || tuiSemaphore.queued > 0) return; // a turn is live — defer
try {
const n = reapStaleTuiSessions();
if (n) logEvent("info", "tui_reaped_stale_sessions", { count: n, trigger: "periodic" });
} catch (e) { logEvent("error", "tui_periodic_reap_failed", { error: e.message }); }
}, TUI_REAP_INTERVAL_MS) : null;
if (tuiReapInterval && typeof tuiReapInterval.unref === "function") tuiReapInterval.unref();
// ── Active child process tracking ────────────────────────────────────────
const activeProcesses = new Set();
@@ -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 });