From b80dcbd650028db37efdb4cfc5d7e86df0b4413a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 30 May 2026 15:04:03 -0400 Subject: [PATCH] fix(plugin-sdk): bound copilot token expiry --- src/plugin-sdk/provider-auth.test.ts | 70 ++++++++++++++++++++++++++++ src/plugin-sdk/provider-auth.ts | 47 ++++++++++++++----- 2 files changed, 105 insertions(+), 12 deletions(-) diff --git a/src/plugin-sdk/provider-auth.test.ts b/src/plugin-sdk/provider-auth.test.ts index c4e322e13d7..75a54780f48 100644 --- a/src/plugin-sdk/provider-auth.test.ts +++ b/src/plugin-sdk/provider-auth.test.ts @@ -190,4 +190,74 @@ describe("provider auth profile helpers", () => { }), ]); }); + + it("rejects Copilot token expiry values outside the supported date range", async () => { + vi.resetModules(); + + const fetchImpl = vi.fn( + async () => + new Response( + JSON.stringify({ + token: "token;proxy-ep=proxy.individual.githubcopilot.com", + expires_at: Number.MAX_SAFE_INTEGER, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + + const { resolveCopilotApiToken } = await import("./provider-auth.js"); + + await expect( + resolveCopilotApiToken({ + githubToken: "github-token", + fetchImpl, + cachePath: "/tmp/copilot-token.json", + loadJsonFileImpl: () => undefined, + saveJsonFileImpl: () => { + throw new Error("should not save invalid token"); + }, + }), + ).rejects.toThrow("Copilot token response has invalid expires_at"); + }); + + it("refreshes cached Copilot tokens with out-of-range expiry values", async () => { + vi.resetModules(); + + const saved: unknown[] = []; + const fetchImpl = vi.fn( + async () => + new Response( + JSON.stringify({ + token: "fresh;proxy-ep=proxy.individual.githubcopilot.com", + expires_at: "+2000000000", + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + + const { COPILOT_INTEGRATION_ID, resolveCopilotApiToken } = await import("./provider-auth.js"); + + const result = await resolveCopilotApiToken({ + githubToken: "github-token", + fetchImpl, + cachePath: "/tmp/copilot-token.json", + loadJsonFileImpl: () => ({ + token: "cached;proxy-ep=proxy.individual.githubcopilot.com", + expiresAt: Number.MAX_SAFE_INTEGER, + updatedAt: Date.now(), + integrationId: COPILOT_INTEGRATION_ID, + }), + saveJsonFileImpl: (_path, value) => saved.push(value), + }); + + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(result.source).toBe("fetched:https://api.github.com/copilot_internal/v2/token"); + expect(result.token).toBe("fresh;proxy-ep=proxy.individual.githubcopilot.com"); + expect(saved).toEqual([ + expect.objectContaining({ + expiresAt: 2_000_000_000_000, + token: "fresh;proxy-ep=proxy.individual.githubcopilot.com", + }), + ]); + }); }); diff --git a/src/plugin-sdk/provider-auth.ts b/src/plugin-sdk/provider-auth.ts index 60f7b4a59cc..1edf36dd8d9 100644 --- a/src/plugin-sdk/provider-auth.ts +++ b/src/plugin-sdk/provider-auth.ts @@ -21,7 +21,11 @@ import { resolveEnvApiKey } from "../agents/model-auth-env.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolveStateDir } from "../config/paths.js"; import { loadJsonFile, saveJsonFile } from "../infra/json-file.js"; -import { parseStrictNonNegativeInteger } from "../infra/parse-finite-number.js"; +import { + asDateTimestampMs, + resolveExpiresAtMsFromEpochSeconds, + parseStrictNonNegativeInteger, +} from "../shared/number-coercion.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { resolveProviderEndpoint } from "./provider-model-shared.js"; @@ -141,7 +145,27 @@ function resolveCopilotTokenCachePath(env: NodeJS.ProcessEnv = process.env) { } function isCopilotTokenUsable(cache: CachedCopilotToken, now = Date.now()): boolean { - return cache.integrationId === COPILOT_INTEGRATION_ID && cache.expiresAt - now > 5 * 60 * 1000; + const expiresAt = asDateTimestampMs(cache.expiresAt); + return ( + cache.integrationId === COPILOT_INTEGRATION_ID && + expiresAt !== undefined && + expiresAt - now > 5 * 60 * 1000 + ); +} + +function resolveCopilotTokenExpiresAtMs(expiresAt: unknown): number | undefined { + const parsed = + typeof expiresAt === "number" && Number.isFinite(expiresAt) + ? expiresAt + : typeof expiresAt === "string" && expiresAt.trim().length > 0 + ? parseStrictNonNegativeInteger(expiresAt) + : undefined; + if (parsed === undefined) { + return undefined; + } + return parsed < 100_000_000_000 + ? resolveExpiresAtMsFromEpochSeconds(parsed) + : asDateTimestampMs(parsed); } function parseCopilotTokenResponse(value: unknown): { @@ -158,18 +182,17 @@ function parseCopilotTokenResponse(value: unknown): { throw new Error("Copilot token response missing token"); } - let expiresAtMs: number; - if (typeof expiresAt === "number" && Number.isFinite(expiresAt)) { - expiresAtMs = expiresAt < 100_000_000_000 ? expiresAt * 1000 : expiresAt; - } else if (typeof expiresAt === "string" && expiresAt.trim().length > 0) { - const parsed = parseStrictNonNegativeInteger(expiresAt); - if (parsed === undefined) { - throw new Error("Copilot token response has invalid expires_at"); - } - expiresAtMs = parsed < 100_000_000_000 ? parsed * 1000 : parsed; - } else { + const expiresAtMs = resolveCopilotTokenExpiresAtMs(expiresAt); + if ( + expiresAt === undefined || + expiresAt === null || + (typeof expiresAt === "string" && expiresAt.trim().length === 0) + ) { throw new Error("Copilot token response missing expires_at"); } + if (expiresAtMs === undefined) { + throw new Error("Copilot token response has invalid expires_at"); + } return { token, expiresAt: expiresAtMs }; }