From 58ed721d6e3a4a8774efcbade921c2f062338ca7 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Mon, 30 Mar 2026 21:51:45 +0900 Subject: [PATCH] feat: auto-recreate reviewer containers on token rotation When OAuth tokens rotate or refresh, all reviewer containers are automatically removed. The next reviewer turn recreates them with the latest token, preventing 401 auth failures. Uses callback pattern to avoid circular dependencies: - token-rotation.ts: onTokenRotated(cb) - token-refresh.ts: onTokenRefreshed(cb) - index.ts: registers recreateAllReviewerContainers as callback --- src/container-runner.ts | 44 +++++++++++++++++++++++++++++++++++++++++ src/index.ts | 8 ++++++++ src/message-runtime.ts | 3 +-- src/token-refresh.ts | 32 ++++++++++++++++++++++++++++++ src/token-rotation.ts | 19 ++++++++++++++++++ 5 files changed, 104 insertions(+), 2 deletions(-) diff --git a/src/container-runner.ts b/src/container-runner.ts index ca369c8..a28b824 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -192,6 +192,50 @@ export function stopReviewerContainer(groupFolder: string): void { } } +/** + * Remove all running reviewer containers so they are recreated on the next + * reviewer turn with fresh credentials. Called after token rotation/refresh + * because the persistent container's Claude Code SDK process holds the token + * that was baked in at `docker run` time — `docker exec -e` only updates the + * env for newly spawned processes, not the already-running SDK. + */ +export function recreateAllReviewerContainers(): void { + try { + const output = execFileSync( + CONTAINER_RUNTIME_BIN, + ['ps', '--filter', 'name=ejclaw-reviewer-', '--format', '{{.Names}}'], + { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 10000 }, + ).trim(); + + const containers = output.split('\n').filter(Boolean); + if (containers.length === 0) { + logger.debug('No reviewer containers to recreate after token refresh'); + return; + } + + for (const name of containers) { + try { + execFileSync(CONTAINER_RUNTIME_BIN, ['rm', '-f', name], { + stdio: 'pipe', + timeout: 10000, + }); + } catch { + /* already gone */ + } + } + + logger.info( + { count: containers.length, names: containers }, + 'Removed reviewer containers after token refresh — will recreate on next turn', + ); + } catch (err) { + logger.warn( + { err }, + 'Failed to list/remove reviewer containers after token refresh', + ); + } +} + // ── Mount builder ───────────────────────────────────────────────── export function buildReviewerMounts( diff --git a/src/index.ts b/src/index.ts index 752a486..6705b7b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -76,8 +76,10 @@ import { initCodexTokenRotation } from './codex-token-rotation.js'; import { hasAvailableClaudeToken, initTokenRotation, + onTokenRotated, } from './token-rotation.js'; import { + onTokenRefreshed, startTokenRefreshLoop, stopTokenRefreshLoop, } from './token-refresh.js'; @@ -87,6 +89,7 @@ import { } from './service-routing.js'; import { FAILOVER_MIN_DURATION_MS } from './config.js'; import { startCredentialProxy } from './credential-proxy.js'; +import { recreateAllReviewerContainers } from './container-runner.js'; import { cleanupOrphans, PROXY_BIND_HOST } from './container-runtime.js'; // Token rotation is initialized lazily on first use or at startup below @@ -333,6 +336,11 @@ async function main(): Promise { ); cleanupOrphans(); + // Recreate reviewer containers when OAuth tokens are rotated or refreshed. + // Persistent containers hold the old token in their running SDK process; + // removing them forces re-creation with fresh credentials on next turn. + onTokenRotated(recreateAllReviewerContainers); + onTokenRefreshed(recreateAllReviewerContainers); startTokenRefreshLoop(); loadState(); diff --git a/src/message-runtime.ts b/src/message-runtime.ts index 966f879..3b328e0 100644 --- a/src/message-runtime.ts +++ b/src/message-runtime.ts @@ -442,8 +442,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { if (isReviewerHandoff) { const revChName = REVIEWER_AGENT_TYPE === 'claude-code' ? 'discord' : 'discord-review'; - handoffChannel = - findChannelByName(deps.channels, revChName) || channel; + handoffChannel = findChannelByName(deps.channels, revChName) || channel; } const runId = `handoff-${handoff.id}`; diff --git a/src/token-refresh.ts b/src/token-refresh.ts index f0b847d..4d2fde6 100644 --- a/src/token-refresh.ts +++ b/src/token-refresh.ts @@ -317,9 +317,31 @@ async function checkAndRefreshAll(): Promise { if (anyRefreshed) { updateEnvTokens(); + if (onTokenRefreshedCallback) { + try { + onTokenRefreshedCallback(); + } catch (err) { + logger.warn( + { err: getErrorMessage(err) }, + 'Post-token-refresh callback failed', + ); + } + } } } +// ── Post-refresh callback ─────────────────────────────────────── +// Allows callers (e.g. index.ts) to register a hook that runs after +// any token is refreshed — used to recreate reviewer containers whose +// persistent Claude Code SDK process still holds the old token. +type TokenRefreshCallback = () => void; +let onTokenRefreshedCallback: TokenRefreshCallback | null = null; + +/** Register a callback to be invoked after a successful token refresh. */ +export function onTokenRefreshed(cb: TokenRefreshCallback): void { + onTokenRefreshedCallback = cb; +} + let refreshInterval: ReturnType | null = null; export function startTokenRefreshLoop(): void { @@ -381,6 +403,16 @@ export async function forceRefreshToken( if (tokenIndex < allTokens.length) { updateTokenValue(tokenIndex, newAccessToken); updateEnvTokens(); + if (onTokenRefreshedCallback) { + try { + onTokenRefreshedCallback(); + } catch (err) { + logger.warn( + { err: getErrorMessage(err) }, + 'Post-token-refresh callback failed (force refresh)', + ); + } + } } } return newAccessToken; diff --git a/src/token-rotation.ts b/src/token-rotation.ts index d3cf58d..94b68ae 100644 --- a/src/token-rotation.ts +++ b/src/token-rotation.ts @@ -33,6 +33,18 @@ const tokens: TokenState[] = []; let currentIndex = 0; let initialized = false; +// ── Post-rotation callback ────────────────────────────────────── +// Invoked after a successful token rotation so that callers (e.g. +// index.ts) can tear down reviewer containers whose SDK process +// still holds the old token. +type TokenRotationCallback = () => void; +let onTokenRotatedCallback: TokenRotationCallback | null = null; + +/** Register a callback to be invoked after a successful token rotation. */ +export function onTokenRotated(cb: TokenRotationCallback): void { + onTokenRotatedCallback = cb; +} + export function initTokenRotation(): void { if (initialized) return; initialized = true; @@ -140,6 +152,13 @@ export function rotateToken( `Rotated to token #${currentIndex + 1}/${tokens.length}`, ); saveState(); + if (onTokenRotatedCallback) { + try { + onTokenRotatedCallback(); + } catch { + /* best effort — don't break rotation flow */ + } + } return true; }