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
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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<void> {
|
||||
);
|
||||
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();
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -317,9 +317,31 @@ async function checkAndRefreshAll(): Promise<void> {
|
||||
|
||||
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<typeof setInterval> | 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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user