diff --git a/CHANGELOG.md b/CHANGELOG.md index a54da3d139f..9afc20b9d93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/agents/github-copilot-token.test.ts b/src/agents/github-copilot-token.test.ts new file mode 100644 index 00000000000..0bad7397c14 --- /dev/null +++ b/src/agents/github-copilot-token.test.ts @@ -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); + }); +}); diff --git a/src/agents/github-copilot-token.ts b/src/agents/github-copilot-token.ts index a5d9a6b1e8e..ee0d09098a2 100644 --- a/src/agents/github-copilot-token.ts +++ b/src/agents/github-copilot-token.ts @@ -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"); } diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 22e26cd9628..da37d9ee69a 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -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; diff --git a/src/agents/runtime-auth-refresh.test.ts b/src/agents/runtime-auth-refresh.test.ts new file mode 100644 index 00000000000..b57eeb27b5c --- /dev/null +++ b/src/agents/runtime-auth-refresh.test.ts @@ -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); + }); +}); diff --git a/src/agents/runtime-auth-refresh.ts b/src/agents/runtime-auth-refresh.ts new file mode 100644 index 00000000000..0860e1e4031 --- /dev/null +++ b/src/agents/runtime-auth-refresh.ts @@ -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)); +}