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
+129 -26
View File
@@ -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 `<defunct>`
// zombies that only the server can reap. Over many per-request spawn+teardown cycles these
// accumulate (live evidence on PI231: 25 defunct `<claude>` over 30 days; `tmux kill-server`
// dropped it 25→3). The only node-reachable action that ACTUALLY reaps them — rather than
// merely re-signalling — is to stop the tmux server: when the server exits, the kernel
// reparents its surviving children to init (PID 1), which reaps them immediately.
//
// So after killing our own sessions, if the server has NO sessions left of ANY prefix
// (i.e. nothing we could disrupt — no co-hosted `olp-tui-*` or other instance), we
// `kill-server` to flush the defunct backlog. If ANY non-ocp session remains we leave the
// server running (coexistence rule, ADR 0007) and let the next boot/periodic sweep retry
// once the server is otherwise idle.
export function reapStaleTuiSessions({ tmux = defaultTmux } = {}) {
const r = tmux(["list-sessions", "-F", "#{session_name}"]);
if (!r || r.status !== 0) return 0; // no tmux server / no sessions
const names = String(r.stdout || "").split("\n").map((s) => s.trim()).filter(Boolean);
let killed = 0;
for (const name of String(r.stdout || "").split("\n").map((s) => s.trim()).filter(Boolean)) {
let othersRemain = false;
for (const name of names) {
if (name.startsWith(SESSION_PREFIX)) {
tmux(["kill-session", "-t", name]);
killed++;
} else {
othersRemain = true; // a session we do NOT own (e.g. olp-tui-*) — never kill-server
}
}
// Reap defunct `claude` zombies: safe ONLY when the server is now ours-only/empty.
// kill-server is what actually reaps (server exit reparents survivors to init); a
// per-session kill cannot, since node is not the zombies' parent.
if (!othersRemain) {
tmux(["kill-server"]);
}
return killed;
}
@@ -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 `<realHome>/.ocp-tui/home`. There is
// deliberately NO .credentials.json (no symlink, no copy), so the only credential
// claude can find is the long-lived env token (passed by buildTuiCmd). This is what
// actually FORCES env-token auth — see the prepareTuiHome comment for why passing
// the token alone is insufficient.
// - EXPLICIT OVERRIDE: whatever OCP_TUI_HOME names (back-compat; an operator who set it
// keeps exactly that home).
// - REAL-HOME (default when the env token is unset): the operator's real home, shared
// credentials.json — byte-for-byte the pre-fix behaviour for credentials.json hosts.
//
// Pure + deterministic so server.mjs and the tests share one decision. `configuredHome`
// is the raw OCP_TUI_HOME value (undefined/empty => unset).
export const DEFAULT_TUI_SCRATCH_HOME = (realHome) => `${realHome}/.ocp-tui/home`;
export function resolveTuiHome({ realHome, configuredHome, envTokenSet }) {
if (configuredHome) return configuredHome; // explicit override wins (back-compat)
if (envTokenSet) return DEFAULT_TUI_SCRATCH_HOME(realHome); // credential-free scratch
return realHome; // legacy real-home default
}
// Prepare the HOME claude runs under. Three modes:
// - real-home (tuiHome === realHome OR falsy): no isolation; just trust the cwd
// in the real ~/.claude.json. The legacy default when no env token is set.
// - ENV-TOKEN scratch-home (envTokenMode === true): a dedicated HOME with a seeded
// .claude.json (onboarded + trusts only the scratch cwd) and its own projects/ dir,
// and DELIBERATELY NO .credentials.json (no symlink, no copy). claude then has no
// credentials file to read, so it authenticates via CLAUDE_CODE_OAUTH_TOKEN (passed
// by buildTuiCmd) — which is authoritative precisely because nothing shadows it.
// - legacy scratch-home (envTokenMode falsy, tuiHome !== realHome): the historical
// mode that SYMLINKS the real .credentials.json. Retained only for an operator who
// explicitly set OCP_TUI_HOME without an env token; see the caveat below.
//
// WHY ENV-TOKEN MODE IS THE FIX (proven live on PI231, claude 2.1.104):
// env token passed + a broken ~/.claude/.credentials.json present → 401.
// env token passed + credentials.json moved aside → real answer.
// Interactive `claude` PREFERS .credentials.json over the env var (unlike `-p`, where the
// env token wins), so a stale/corrupt credentials.json SHADOWS the env token. Passing the
// token is necessary but insufficient; the TUI claude must run in a HOME with NO
// credentials.json so the env token is the only credential. This ALSO ends the refresh-
// corruption incident at the root: with no credentials file, claude never runs the token-
// refresh path, so the single-use refresh token can never be rotated (and corrupted) by the
// spawn+kill cycle. (This RESOLVES — not reintroduces — the ADR 0007 scratch-home concern:
// the old caveat was about a SYMLINKED credentials.json being forked on refresh; here there
// is no credentials file to fork and no refresh ever happens.)
//
// ⚠️ LEGACY SCRATCH-HOME CAVEAT (envTokenMode falsy, symlink path): claude rewrites
// .credentials.json on token refresh, REPLACING the symlink with a regular-file copy → the
// scratch home FORKS the OAuth credentials and a refresh can invalidate the real-home token.
// That path is therefore safe only with a DEDICATED OAuth or for ephemeral use. The env-token
// mode above avoids this entirely.
//
// Idempotent + best-effort: any failure degrades toward the dialog/cap, never corrupts.
// Run BEFORE the session boots.
export function prepareTuiHome(realHome, tuiHome, cwd, { envTokenMode = false } = {}) {
if (!tuiHome || tuiHome === realHome) { ensureTuiCwdTrusted(realHome, cwd); return; }
try {
const claudeDir = `${tuiHome}/.claude`;
mkdirSync(`${claudeDir}/projects`, { recursive: true });
// Symlink the real credentials (never copy the OAuth token); refresh if missing.
const link = `${claudeDir}/.credentials.json`;
if (!existsSync(link)) {
try { symlinkSync(`${realHome}/.claude/.credentials.json`, link); } catch { /* best effort */ }
if (!envTokenMode) {
// Legacy mode ONLY: symlink the real credentials (never copy the token); refresh if
// missing. Env-token mode deliberately skips this — no credentials file at all.
const link = `${claudeDir}/.credentials.json`;
if (!existsSync(link)) {
try { symlinkSync(`${realHome}/.claude/.credentials.json`, link); } catch { /* best effort */ }
}
}
// Seed .claude.json ONCE (if absent): start from the onboarded real config,
// drop the user's project history, trust only the scratch cwd. mode 0600.
// Seed .claude.json ONCE (if absent): onboarded + trust ONLY the scratch cwd.
// In env-token mode start from a MINIMAL config (do NOT copy the real ~/.claude.json —
// a credential-isolated home should not inherit the operator's account/config state);
// in legacy mode carry the onboarded real config minus the user's project history.
const seedPath = `${tuiHome}/.claude.json`;
if (!existsSync(seedPath)) {
let base = {};
try { base = JSON.parse(readFileSync(`${realHome}/.claude.json`, "utf8")); } catch { /* fresh */ }
if (!envTokenMode) {
try { base = JSON.parse(readFileSync(`${realHome}/.claude.json`, "utf8")); } catch { /* fresh */ }
}
base.hasCompletedOnboarding = true;
base.projects = { [cwd]: { hasTrustDialogAccepted: true, allowedTools: [] } };
writeFileSync(seedPath, JSON.stringify(base, null, 2), { mode: 0o600 });
@@ -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 <HOME>/.claude/.credentials.json, whose
// single-use refresh token gets corrupted by the per-request spawn + `kill-session` teardown
// racing claude's token-rotation write (the PI231 incident: refresh token ended up an empty
// string → permanent 401 "Please run /login", re-login re-corrupted on the next spawn). With
// the long-lived OAuth token in env, claude authenticates via the token and never touches the
// credentials.json refresh path — matching how the stable oracle / Mac-mini hosts already run.
//
// SECURITY: the token appears in the pane command (ps-visible). This is acceptable for the
// single-user A-path — it mirrors the existing plaintext-token practice (server.mjs reads the
// same CLAUDE_CODE_OAUTH_TOKEN env at getOAuthCredentials()), and the multi-user B-path is
// already refused at boot (TUI + AUTH_MODE=multi is a hard FATAL). Read from process.env here,
// consistent with how buildTuiCmd already reads OCP_TUI_FULL_TOOLS / CLAUDE_ALLOWED_TOOLS below.
//
// When the env is unset (e.g. a host that intentionally relies on credentials.json), no token
// is added — behaviour is byte-for-byte unchanged from before this fix.
if (process.env.CLAUDE_CODE_OAUTH_TOKEN) {
sets.push(`CLAUDE_CODE_OAUTH_TOKEN=${shq(process.env.CLAUDE_CODE_OAUTH_TOKEN)}`);
}
const unset = ["CLAUDECODE", "ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN"];
if (entrypointMode === "cli") sets.push("CLAUDE_CODE_ENTRYPOINT=cli");
else if (entrypointMode === "auto") unset.push("CLAUDE_CODE_ENTRYPOINT"); // let claude self-classify via TTY
@@ -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-`);