mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-21 21:15:09 +00:00
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>
This commit is contained in:
co-authored by
Claude <claude-opus> <noreply@anthropic.com>
parent
c86e3d014f
commit
6394ca3265
+50
-1
@@ -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;
|
||||
}
|
||||
|
||||
@@ -217,6 +245,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
|
||||
|
||||
Reference in New Issue
Block a user