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
@@ -1691,6 +1691,52 @@ test("buildTuiCmd keeps version pin + entrypoint label + MCP wall", () => {
|
||||
assert.ok(/-u CLAUDE_CODE_ENTRYPOINT/.test(auto), "auto mode unsets any inherited entrypoint");
|
||||
});
|
||||
|
||||
// CLAUDE_CODE_OAUTH_TOKEN passthrough (PI231 401 incident): tmux doesn't forward the parent
|
||||
// env to the pane, so the token must be set explicitly on the pane command or the TUI claude
|
||||
// falls back to credentials.json (whose refresh token gets corrupted by the spawn/kill cycle).
|
||||
test("buildTuiCmd passes CLAUDE_CODE_OAUTH_TOKEN when the env is set (shq-escaped)", () => {
|
||||
const save = process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||
try {
|
||||
process.env.CLAUDE_CODE_OAUTH_TOKEN = "sk-ant-oat01-abc123";
|
||||
const cmd = buildTuiCmd("/usr/bin/claude", "m", "sid-tok", "/home/u", "cli");
|
||||
// shq wraps in single quotes; a plain token renders as 'token'.
|
||||
assert.ok(cmd.includes("CLAUDE_CODE_OAUTH_TOKEN='sk-ant-oat01-abc123'"),
|
||||
"token must be set on the pane command, shq-escaped");
|
||||
} finally {
|
||||
if (save === undefined) delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||
else process.env.CLAUDE_CODE_OAUTH_TOKEN = save;
|
||||
}
|
||||
});
|
||||
|
||||
test("buildTuiCmd does NOT add CLAUDE_CODE_OAUTH_TOKEN when the env is unset", () => {
|
||||
const save = process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||
try {
|
||||
delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||
const cmd = buildTuiCmd("/usr/bin/claude", "m", "sid-notok", "/home/u", "cli");
|
||||
assert.ok(!/CLAUDE_CODE_OAUTH_TOKEN/.test(cmd),
|
||||
"no token added when env unset (credentials.json-only hosts unaffected)");
|
||||
} finally {
|
||||
if (save === undefined) delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||
else process.env.CLAUDE_CODE_OAUTH_TOKEN = save;
|
||||
}
|
||||
});
|
||||
|
||||
test("buildTuiCmd shq-escapes a token containing shell metacharacters (no injection)", () => {
|
||||
const save = process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||
try {
|
||||
// A token with a single quote must be escaped via the '\'' idiom so it can't break out
|
||||
// of the shell string tmux runs via sh -c.
|
||||
process.env.CLAUDE_CODE_OAUTH_TOKEN = "tok'; rm -rf /;'";
|
||||
const cmd = buildTuiCmd("/usr/bin/claude", "m", "sid-inj", "/home/u", "cli");
|
||||
assert.ok(cmd.includes(`CLAUDE_CODE_OAUTH_TOKEN='tok'\\''; rm -rf /;'\\'''`),
|
||||
"single quote must be shq-escaped, not left bare");
|
||||
assert.ok(!/CLAUDE_CODE_OAUTH_TOKEN=tok'; rm/.test(cmd), "raw unescaped token must NOT appear");
|
||||
} finally {
|
||||
if (save === undefined) delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||
else process.env.CLAUDE_CODE_OAUTH_TOKEN = save;
|
||||
}
|
||||
});
|
||||
|
||||
test("buildTuiCmd OCP_TUI_FULL_TOOLS=1 grants -p-equivalent tool surface (single-user opt-in)", () => {
|
||||
const save = { ...process.env };
|
||||
const restore = () => {
|
||||
@@ -1766,6 +1812,41 @@ test("reaper returns 0 for empty session list", () => {
|
||||
assert.equal(killed.length, 0);
|
||||
});
|
||||
|
||||
// Defunct-zombie reaping (PI231 incident): the pane's claude is a child of the tmux server,
|
||||
// so only kill-server actually reaps it. We kill-server ONLY when no foreign session remains.
|
||||
console.log("\nTUI defunct-zombie reaping (kill-server):");
|
||||
|
||||
test("reaper kill-servers when the server is ours-only (flush defunct claude zombies)", () => {
|
||||
const calls = [];
|
||||
const fakeTmux = (args) => {
|
||||
calls.push(args.join(" "));
|
||||
if (args[0] === "list-sessions") return { status: 0, stdout: "ocp-tui-aaaa\nocp-tui-bbbb\n" };
|
||||
return { status: 0, stdout: "" };
|
||||
};
|
||||
const n = reapStaleTuiSessions({ tmux: fakeTmux });
|
||||
assert.equal(n, 2, "killed both of our sessions");
|
||||
assert.ok(calls.includes("kill-server"), "kill-server fired — reaps the defunct backlog");
|
||||
});
|
||||
|
||||
test("reaper does NOT kill-server when a foreign (non-ocp) session remains (coexistence)", () => {
|
||||
const calls = [];
|
||||
const fakeTmux = (args) => {
|
||||
calls.push(args.join(" "));
|
||||
if (args[0] === "list-sessions") return { status: 0, stdout: "ocp-tui-aaaa\nolp-tui-bbbb\n" };
|
||||
return { status: 0, stdout: "" };
|
||||
};
|
||||
const n = reapStaleTuiSessions({ tmux: fakeTmux });
|
||||
assert.equal(n, 1, "killed only our own session");
|
||||
assert.ok(!calls.includes("kill-server"), "kill-server MUST NOT fire — would disrupt olp-tui-*");
|
||||
});
|
||||
|
||||
test("reaper does NOT kill-server when there is no server (status !== 0)", () => {
|
||||
const calls = [];
|
||||
const fakeTmux = (args) => { calls.push(args.join(" ")); return { status: 1, stdout: "" }; };
|
||||
reapStaleTuiSessions({ tmux: fakeTmux });
|
||||
assert.ok(!calls.includes("kill-server"), "no server → no kill-server (early return)");
|
||||
});
|
||||
|
||||
// ── TUI home preparation (scratch vs real) ───────────────────────────────
|
||||
import { prepareTuiHome, ensureTuiCwdTrusted } from "./lib/tui/session.mjs";
|
||||
import { mkdtempSync as hMkdtemp, mkdirSync as hMkdir, writeFileSync as hWrite, readFileSync as hRead, existsSync as hExists, readlinkSync as hReadlink } from "node:fs";
|
||||
|
||||
Reference in New Issue
Block a user