fix: clamp copilot auth refresh overflow (#55360) (thanks @michael-abdo)

This commit is contained in:
Peter Steinberger 2026-03-26 23:42:25 +00:00
parent f0c1057f68
commit 85b169c453
6 changed files with 67 additions and 3 deletions

View File

@ -65,6 +65,7 @@ Docs: https://docs.openclaw.ai
- Talk/macOS: stop direct system-voice failures from replaying system speech, use app-locale fallback for shared watchdog timing, and add regression coverage for the macOS fallback route and language-aware timeout policy. (#53511) thanks @hongsw.
- Discord/gateway cleanup: keep late Carbon reconnect-exhausted errors suppressed through startup/dispose cleanup so Discord monitor shutdown no longer crashes on late gateway close events. (#55373) Thanks @Takhoffman.
- Discord/gateway shutdown: treat expected reconnect-exhausted events during intentional lifecycle stop as clean shutdowns so startup-abort cleanup no longer surfaces false gateway failures. (#55324) Thanks @joelnishanth.
- GitHub Copilot/auth refresh: treat large `expires_at` values as seconds epochs and clamp far-future runtime auth refresh timers so Copilot token refresh cannot fall into a `setTimeout` overflow hot loop. (#55360) Thanks @michael-abdo.
## 2026.3.24

View File

@ -0,0 +1,24 @@
import { describe, expect, it, vi } from "vitest";
import { resolveCopilotApiToken } from "./github-copilot-token.js";
describe("resolveCopilotApiToken", () => {
it("treats 11-digit expires_at values as seconds epochs", async () => {
const fetchImpl = vi.fn(async () => ({
ok: true,
json: async () => ({
token: "copilot-token",
expires_at: 12_345_678_901,
}),
}));
const result = await resolveCopilotApiToken({
githubToken: "github-token",
cachePath: "/tmp/github-copilot-token-test.json",
loadJsonFileImpl: () => undefined,
saveJsonFileImpl: () => undefined,
fetchImpl: fetchImpl as unknown as typeof fetch,
});
expect(result.expiresAt).toBe(12_345_678_901_000);
});
});

View File

@ -36,15 +36,16 @@ function parseCopilotTokenResponse(value: unknown): {
}
// GitHub returns a unix timestamp (seconds), but we defensively accept ms too.
// Use a 1e11 threshold so large seconds-epoch values are not misread as ms.
let expiresAtMs: number;
if (typeof expiresAt === "number" && Number.isFinite(expiresAt)) {
expiresAtMs = expiresAt > 10_000_000_000 ? expiresAt : expiresAt * 1000;
expiresAtMs = expiresAt < 100_000_000_000 ? expiresAt * 1000 : expiresAt;
} else if (typeof expiresAt === "string" && expiresAt.trim().length > 0) {
const parsed = Number.parseInt(expiresAt, 10);
if (!Number.isFinite(parsed)) {
throw new Error("Copilot token response has invalid expires_at");
}
expiresAtMs = parsed > 10_000_000_000 ? parsed : parsed * 1000;
expiresAtMs = parsed < 100_000_000_000 ? parsed * 1000 : parsed;
} else {
throw new Error("Copilot token response missing expires_at");
}

View File

@ -63,6 +63,7 @@ import {
parseImageSizeError,
pickFallbackThinkingLevel,
} from "../pi-embedded-helpers.js";
import { clampRuntimeAuthRefreshDelayMs } from "../runtime-auth-refresh.js";
import { ensureRuntimePluginsLoaded } from "../runtime-plugins.js";
import { isLikelyMutatingToolName } from "../tool-mutation.js";
import { derivePromptTokens, normalizeUsage, type UsageLike } from "../usage.js";
@ -507,7 +508,11 @@ export async function runEmbeddedPiAgent(
clearRuntimeAuthRefreshTimer();
const now = Date.now();
const refreshAt = runtimeAuthState.expiresAt - RUNTIME_AUTH_REFRESH_MARGIN_MS;
const delayMs = Math.max(RUNTIME_AUTH_REFRESH_MIN_DELAY_MS, refreshAt - now);
const delayMs = clampRuntimeAuthRefreshDelayMs({
refreshAt,
now,
minDelayMs: RUNTIME_AUTH_REFRESH_MIN_DELAY_MS,
});
const timer = setTimeout(() => {
if (runtimeAuthRefreshCancelled) {
return;

View File

@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { clampRuntimeAuthRefreshDelayMs } from "./runtime-auth-refresh.js";
describe("clampRuntimeAuthRefreshDelayMs", () => {
it("clamps far-future refresh delays to a timer-safe ceiling", () => {
expect(
clampRuntimeAuthRefreshDelayMs({
refreshAt: 12_345_678_901_000,
now: 0,
minDelayMs: 60_000,
}),
).toBe(2_147_483_647);
});
it("still respects the configured minimum delay", () => {
expect(
clampRuntimeAuthRefreshDelayMs({
refreshAt: 1_000,
now: 900,
minDelayMs: 60_000,
}),
).toBe(60_000);
});
});

View File

@ -0,0 +1,9 @@
const MAX_SAFE_TIMEOUT_MS = 2_147_483_647;
export function clampRuntimeAuthRefreshDelayMs(params: {
refreshAt: number;
now: number;
minDelayMs: number;
}): number {
return Math.min(MAX_SAFE_TIMEOUT_MS, Math.max(params.minDelayMs, params.refreshAt - params.now));
}