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; }