mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-21 21:15:09 +00:00
fix(server): re-resolve -p spawn OAuth token per-spawn, expiry-aware (③ regression)
The FIX-③ spawn-home isolation memoized the OAuth token at startup. The macOS keychain access token rotates (~hourly, refreshed by the operator's real claude), so the startup snapshot went stale and every isolated -p spawn returned upstream 401 'Invalid authentication credentials' — a ~31h Mac-mini outage (PI231/oracle use static long-lived env tokens, unaffected). Fix: getSpawnHomeMode() now caches only the isolation DECISION; the token is re-resolved FRESH per spawn via resolveSpawnToken(), which also returns null when a known expiry has passed (5-min buffer) so the caller falls back to real HOME — where the spawned claude refreshes the credential natively and self-heals. OCP still never refreshes the token itself (a refresh-token grant would consume the single-use token and log out the operator's real claude — issue #112). Env-token hosts carry no expiresAt and are never expiry-gated. Infra/process change; no cli.js surface, no new endpoint/header. 238 tests pass; live-verified sonnet 200 on a temp instance.
This commit is contained in:
+34
-9
@@ -392,26 +392,45 @@ function prepareSpawnHome(dir = SPAWN_HOME_DIR) {
|
|||||||
// once). Returns { isolated, home, token } where:
|
// once). Returns { isolated, home, token } where:
|
||||||
// - isolated:true → spawn under SPAWN_HOME_DIR with cwd=SPAWN_HOME_DIR + the env token.
|
// - isolated:true → spawn under SPAWN_HOME_DIR with cwd=SPAWN_HOME_DIR + the env token.
|
||||||
// - isolated:false → legacy real-HOME spawn, no cwd override (no token, or kill-switch on).
|
// - isolated:false → legacy real-HOME spawn, no cwd override (no token, or kill-switch on).
|
||||||
// NEVER logs/returns the token verbatim to any caller that logs; spawnClaudeProcess uses it only
|
// Caches only the isolation DECISION (isolated/home/reason), NOT the token — the token is
|
||||||
// to populate the spawn env. token is null when isolation is off.
|
// re-resolved FRESH per spawn via resolveSpawnToken(). A memoized token goes stale when its
|
||||||
|
// source rotates: the macOS keychain access token rotates (~hourly, refreshed by the operator's
|
||||||
|
// real claude), so a startup snapshot 401s once it expires (caused a ~31h Mac-mini 401 outage,
|
||||||
|
// 2026-06-26). OCP deliberately does NOT refresh the token itself — a refresh-token grant would
|
||||||
|
// consume the single-use refresh token and log out the operator's real claude (issue #112).
|
||||||
let _spawnHomeMode = null;
|
let _spawnHomeMode = null;
|
||||||
function getSpawnHomeMode() {
|
function getSpawnHomeMode() {
|
||||||
if (_spawnHomeMode) return _spawnHomeMode;
|
if (_spawnHomeMode) return _spawnHomeMode;
|
||||||
if (SPAWN_REAL_HOME) {
|
if (SPAWN_REAL_HOME) {
|
||||||
_spawnHomeMode = { isolated: false, home: null, token: null, reason: "kill-switch (OCP_SPAWN_REAL_HOME=1)" };
|
_spawnHomeMode = { isolated: false, home: null, reason: "kill-switch (OCP_SPAWN_REAL_HOME=1)" };
|
||||||
return _spawnHomeMode;
|
return _spawnHomeMode;
|
||||||
}
|
}
|
||||||
let token = null;
|
let hasToken = false;
|
||||||
try { token = getOAuthCredentials()?.accessToken || null; } catch { token = null; }
|
try { hasToken = !!(getOAuthCredentials()?.accessToken); } catch { hasToken = false; }
|
||||||
if (token) {
|
if (hasToken) {
|
||||||
prepareSpawnHome(SPAWN_HOME_DIR);
|
prepareSpawnHome(SPAWN_HOME_DIR);
|
||||||
_spawnHomeMode = { isolated: true, home: SPAWN_HOME_DIR, token, reason: "oauth token resolved" };
|
_spawnHomeMode = { isolated: true, home: SPAWN_HOME_DIR, reason: "oauth token resolved" };
|
||||||
} else {
|
} else {
|
||||||
_spawnHomeMode = { isolated: false, home: null, token: null, reason: "no oauth token resolvable" };
|
_spawnHomeMode = { isolated: false, home: null, reason: "no oauth token resolvable" };
|
||||||
}
|
}
|
||||||
return _spawnHomeMode;
|
return _spawnHomeMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve a FRESH OAuth access token for an isolated spawn. Read-only (keychain / credentials.json
|
||||||
|
// / env) — NEVER refreshes/rotates (see getSpawnHomeMode note). Returns null if none resolvable OR
|
||||||
|
// if a known expiry has passed (5-min buffer): a null return makes the caller fall back to real
|
||||||
|
// HOME, where the spawned claude refreshes the credential natively and self-heals (the keychain
|
||||||
|
// token is then fresh again → next spawn is fast). The env-token path (Linux) carries no expiresAt
|
||||||
|
// → never expiry-gated (those tokens are long-lived).
|
||||||
|
function resolveSpawnToken() {
|
||||||
|
try {
|
||||||
|
const creds = getOAuthCredentials();
|
||||||
|
if (!creds?.accessToken) return null;
|
||||||
|
if (creds.expiresAt && Date.now() + 300000 >= creds.expiresAt) return null;
|
||||||
|
return creds.accessToken;
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
// ── FIX ⑥ (concurrency): bounded wait-queue for the -p / stream-json path ──────────────
|
// ── FIX ⑥ (concurrency): bounded wait-queue for the -p / stream-json path ──────────────
|
||||||
// PROBLEM (proven): spawnClaudeProcess used `if (activeRequests >= MAX_CONCURRENT) throw` →
|
// PROBLEM (proven): spawnClaudeProcess used `if (activeRequests >= MAX_CONCURRENT) throw` →
|
||||||
// the client got an opaque 500 AND the rejection was NOT counted in stats (a 15-concurrent
|
// the client got an opaque 500 AND the rejection was NOT counted in stats (a 15-concurrent
|
||||||
@@ -954,12 +973,18 @@ function spawnClaudeProcess(model, messages, conversationId, keyName, releaseSlo
|
|||||||
const spawnHome = getSpawnHomeMode();
|
const spawnHome = getSpawnHomeMode();
|
||||||
const spawnOpts = { env, stdio: ["pipe", "pipe", "pipe"] };
|
const spawnOpts = { env, stdio: ["pipe", "pipe", "pipe"] };
|
||||||
if (spawnHome.isolated) {
|
if (spawnHome.isolated) {
|
||||||
|
// Re-resolve the token FRESH per spawn (never a startup snapshot — keychain tokens rotate;
|
||||||
|
// a stale snapshot 401s). If unresolvable right now, fall through to real HOME so the spawned
|
||||||
|
// claude resolves + refreshes credentials natively instead of 401ing on a stale/null token.
|
||||||
|
const freshToken = resolveSpawnToken();
|
||||||
|
if (freshToken) {
|
||||||
env.HOME = spawnHome.home;
|
env.HOME = spawnHome.home;
|
||||||
env.CLAUDE_CODE_OAUTH_TOKEN = spawnHome.token; // env token is authoritative for -p
|
env.CLAUDE_CODE_OAUTH_TOKEN = freshToken; // env token is authoritative for -p
|
||||||
env.CLAUDE_CODE_DISABLE_CLAUDE_MDS = "1";
|
env.CLAUDE_CODE_DISABLE_CLAUDE_MDS = "1";
|
||||||
env.CLAUDE_CODE_DISABLE_AUTO_MEMORY = "1";
|
env.CLAUDE_CODE_DISABLE_AUTO_MEMORY = "1";
|
||||||
spawnOpts.cwd = spawnHome.home; // neutral cwd: no project CLAUDE.md/skills
|
spawnOpts.cwd = spawnHome.home; // neutral cwd: no project CLAUDE.md/skills
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const proc = spawn(CLAUDE, cliArgs, spawnOpts);
|
const proc = spawn(CLAUDE, cliArgs, spawnOpts);
|
||||||
activeProcesses.add(proc);
|
activeProcesses.add(proc);
|
||||||
|
|||||||
Reference in New Issue
Block a user