mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-11 15:23:43 +00:00
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:
committed by
Peter Steinberger
parent
e54ddf6161
commit
2dcd2f9094
@@ -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);
|
||||
}
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user