fix: refresh Copilot token before expiry and retry on auth errors

GitHub Copilot API tokens expire after ~30 minutes. When OpenClaw spawns
a long-running subagent using Copilot as the provider, the token would
expire mid-session with no recovery mechanism, causing 401 auth errors.

This commit adds:
- Periodic token refresh scheduled 5 minutes before expiry
- Auth error detection with automatic token refresh and single retry
- Proper timer cleanup on session shutdown to prevent leaks

The implementation uses a per-attempt retry flag to ensure each auth
error can trigger one refresh+retry cycle without creating infinite
retry loops.

🤖 AI-assisted: This fix was developed with GitHub Copilot CLI assistance.
Testing: Fully tested with 3 new unit tests covering auth retry, retry
reset, and timer cleanup scenarios. All 11 auth rotation tests pass.
This commit is contained in:
Arthur Freitas Ramos
2026-02-04 09:13:59 -03:00
committed by Peter Steinberger
parent e54ddf6161
commit 2dcd2f9094
2 changed files with 410 additions and 0 deletions

View File

@@ -66,6 +66,17 @@ import { describeUnknownError } from "./utils.js";
type ApiKeyInfo = ResolvedProviderAuth;
type CopilotTokenState = {
githubToken: string;
expiresAt: number;
refreshTimer?: ReturnType<typeof setTimeout>;
refreshInFlight?: Promise<void>;
};
const COPILOT_REFRESH_MARGIN_MS = 5 * 60 * 1000;
const COPILOT_REFRESH_RETRY_MS = 60 * 1000;
const COPILOT_REFRESH_MIN_DELAY_MS = 5 * 1000;
// Avoid Anthropic's refusal test token poisoning session transcripts.
const ANTHROPIC_MAGIC_STRING_TRIGGER_REFUSAL = "ANTHROPIC_MAGIC_STRING_TRIGGER_REFUSAL";
const ANTHROPIC_MAGIC_STRING_REPLACEMENT = "ANTHROPIC MAGIC STRING TRIGGER REFUSAL (redacted)";
@@ -365,6 +376,105 @@ export async function runEmbeddedPiAgent(
const attemptedThinking = new Set<ThinkLevel>();
let apiKeyInfo: ApiKeyInfo | null = null;
let lastProfileId: string | undefined;
const copilotTokenState: CopilotTokenState | null =
model.provider === "github-copilot" ? { githubToken: "", expiresAt: 0 } : null;
let copilotRefreshCancelled = false;
const hasCopilotGithubToken = () => Boolean(copilotTokenState?.githubToken.trim());
const clearCopilotRefreshTimer = () => {
if (!copilotTokenState?.refreshTimer) {
return;
}
clearTimeout(copilotTokenState.refreshTimer);
copilotTokenState.refreshTimer = undefined;
};
const stopCopilotRefreshTimer = () => {
if (!copilotTokenState) {
return;
}
copilotRefreshCancelled = true;
clearCopilotRefreshTimer();
};
const refreshCopilotToken = async (reason: string): Promise<void> => {
if (!copilotTokenState) {
return;
}
if (copilotTokenState.refreshInFlight) {
await copilotTokenState.refreshInFlight;
return;
}
const { resolveCopilotApiToken } = await import("../../providers/github-copilot-token.js");
copilotTokenState.refreshInFlight = (async () => {
const githubToken = copilotTokenState.githubToken.trim();
if (!githubToken) {
throw new Error("Copilot refresh requires a GitHub token.");
}
log.debug(`Refreshing GitHub Copilot token (${reason})...`);
const copilotToken = await resolveCopilotApiToken({
githubToken,
});
authStorage.setRuntimeApiKey(model.provider, copilotToken.token);
copilotTokenState.expiresAt = copilotToken.expiresAt;
const remaining = copilotToken.expiresAt - Date.now();
log.debug(
`Copilot token refreshed; expires in ${Math.max(0, Math.floor(remaining / 1000))}s.`,
);
})()
.catch((err) => {
log.warn(`Copilot token refresh failed: ${describeUnknownError(err)}`);
throw err;
})
.finally(() => {
copilotTokenState.refreshInFlight = undefined;
});
await copilotTokenState.refreshInFlight;
};
const scheduleCopilotRefresh = (): void => {
if (!copilotTokenState || copilotRefreshCancelled) {
return;
}
if (!hasCopilotGithubToken()) {
log.warn("Skipping Copilot refresh scheduling; GitHub token missing.");
return;
}
clearCopilotRefreshTimer();
const now = Date.now();
const refreshAt = copilotTokenState.expiresAt - COPILOT_REFRESH_MARGIN_MS;
const delayMs = Math.max(COPILOT_REFRESH_MIN_DELAY_MS, refreshAt - now);
const timer = setTimeout(() => {
if (copilotRefreshCancelled) {
return;
}
refreshCopilotToken("scheduled")
.then(() => scheduleCopilotRefresh())
.catch(() => {
if (copilotRefreshCancelled) {
return;
}
const retryTimer = setTimeout(() => {
if (copilotRefreshCancelled) {
return;
}
refreshCopilotToken("scheduled-retry")
.then(() => scheduleCopilotRefresh())
.catch(() => undefined);
}, COPILOT_REFRESH_RETRY_MS);
copilotTokenState.refreshTimer = retryTimer;
if (copilotRefreshCancelled) {
clearTimeout(retryTimer);
copilotTokenState.refreshTimer = undefined;
}
});
}, delayMs);
copilotTokenState.refreshTimer = timer;
if (copilotRefreshCancelled) {
clearTimeout(timer);
copilotTokenState.refreshTimer = undefined;
}
};
const resolveAuthProfileFailoverReason = (params: {
allInCooldown: boolean;
@@ -445,6 +555,11 @@ export async function runEmbeddedPiAgent(
githubToken: apiKeyInfo.apiKey,
});
authStorage.setRuntimeApiKey(model.provider, copilotToken.token);
if (copilotTokenState) {
copilotTokenState.githubToken = apiKeyInfo.apiKey;
copilotTokenState.expiresAt = copilotToken.expiresAt;
scheduleCopilotRefresh();
}
} else {
authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey);
}
@@ -508,6 +623,28 @@ export async function runEmbeddedPiAgent(
}
}
const maybeRefreshCopilotForAuthError = async (
errorText: string,
retried: boolean,
): Promise<boolean> => {
if (!copilotTokenState || retried) {
return false;
}
if (!isFailoverErrorMessage(errorText)) {
return false;
}
if (classifyFailoverReason(errorText) !== "auth") {
return false;
}
try {
await refreshCopilotToken("auth-error");
scheduleCopilotRefresh();
return true;
} catch {
return false;
}
};
const MAX_OVERFLOW_COMPACTION_ATTEMPTS = 3;
const MAX_RUN_LOOP_ITERATIONS = resolveMaxRunRetryIterations(profileCandidates.length);
let overflowCompactionAttempts = 0;
@@ -535,6 +672,7 @@ export async function runEmbeddedPiAgent(
});
};
try {
let authRetryPending = false;
while (true) {
if (runLoopIterations >= MAX_RUN_LOOP_ITERATIONS) {
const message =
@@ -566,6 +704,8 @@ export async function runEmbeddedPiAgent(
};
}
runLoopIterations += 1;
const copilotAuthRetry = authRetryPending;
authRetryPending = false;
attemptedThinking.add(thinkLevel);
await fs.mkdir(resolvedWorkspace, { recursive: true });
@@ -852,6 +992,10 @@ export async function runEmbeddedPiAgent(
if (promptError && !aborted) {
const errorText = describeUnknownError(promptError);
if (await maybeRefreshCopilotForAuthError(errorText, copilotAuthRetry)) {
authRetryPending = true;
continue;
}
// Handle role ordering errors with a user-friendly message
if (/incorrect role information|roles must alternate/i.test(errorText)) {
return {
@@ -960,6 +1104,16 @@ export async function runEmbeddedPiAgent(
const cloudCodeAssistFormatError = attempt.cloudCodeAssistFormatError;
const imageDimensionError = parseImageDimensionError(lastAssistant?.errorMessage ?? "");
if (
authFailure &&
(await maybeRefreshCopilotForAuthError(
lastAssistant?.errorMessage ?? "",
copilotAuthRetry,
))
) {
authRetryPending = true;
continue;
}
if (imageDimensionError && lastProfileId) {
const details = [
imageDimensionError.messageIndex !== undefined
@@ -1157,6 +1311,7 @@ export async function runEmbeddedPiAgent(
};
}
} finally {
stopCopilotRefreshTimer();
process.chdir(prevCwd);
}
}),