diff --git a/extensions/google/transport-stream.test.ts b/extensions/google/transport-stream.test.ts index d85de02bbdf..320c1bf6ee2 100644 --- a/extensions/google/transport-stream.test.ts +++ b/extensions/google/transport-stream.test.ts @@ -292,6 +292,7 @@ describe("google transport stream", () => { }); afterEach(() => { + vi.useRealTimers(); vi.unstubAllEnvs(); }); @@ -767,6 +768,29 @@ describe("google transport stream", () => { expect(tokenFetchMock).not.toHaveBeenCalled(); }); + it("does not cache google-auth ADC tokens when fallback expiry would exceed Date range", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-google-vertex-authlib-expiry-")); + vi.useFakeTimers(); + vi.setSystemTime(new Date(8_640_000_000_000_000)); + vi.stubEnv("GOOGLE_APPLICATION_CREDENTIALS", ""); + vi.stubEnv("HOME", path.join(tempDir, "home")); + vi.stubEnv("APPDATA", ""); + googleAuthGetAccessTokenMock + .mockResolvedValueOnce("ya29.first-token") + .mockResolvedValueOnce("ya29.second-token"); + const tokenFetchMock = vi.fn(); + + await expect(resolveGoogleVertexAuthorizedUserHeaders(tokenFetchMock)).resolves.toEqual({ + Authorization: "Bearer ya29.first-token", + }); + await expect(resolveGoogleVertexAuthorizedUserHeaders(tokenFetchMock)).resolves.toEqual({ + Authorization: "Bearer ya29.second-token", + }); + + expect(googleAuthGetAccessTokenMock).toHaveBeenCalledTimes(2); + expect(tokenFetchMock).not.toHaveBeenCalled(); + }); + it("uses google-auth-library bearer auth for Google Vertex credential marker requests", async () => { const tempDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-google-vertex-authlib-stream-")); vi.stubEnv("GOOGLE_APPLICATION_CREDENTIALS", ""); diff --git a/extensions/google/vertex-adc.ts b/extensions/google/vertex-adc.ts index 07a5d38ecbb..62399c11aab 100644 --- a/extensions/google/vertex-adc.ts +++ b/extensions/google/vertex-adc.ts @@ -2,7 +2,11 @@ import { existsSync, readFileSync } from "node:fs"; import { readFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { resolveExpiresAtMsFromDurationSeconds } from "openclaw/plugin-sdk/number-runtime"; +import { + asDateTimestampMs, + resolveExpiresAtMsFromDurationMs, + resolveExpiresAtMsFromDurationSeconds, +} from "openclaw/plugin-sdk/number-runtime"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; type GoogleAuthorizedUserCredentials = { @@ -32,6 +36,7 @@ const GOOGLE_VERTEX_OAUTH_SCOPE = "https://www.googleapis.com/auth/cloud-platfor // leaves the gateway. const GOOGLE_VERTEX_TOKEN_EXPIRY_BUFFER_MS = 60_000; const GOOGLE_VERTEX_DEFAULT_TOKEN_LIFETIME_SECONDS = 3600; +const GOOGLE_VERTEX_AUTHLIB_TOKEN_CACHE_MS = 5 * 60_000; let cachedGoogleVertexAuthorizedUserToken: GoogleVertexAuthorizedUserToken | undefined; let cachedGoogleAuthClient: @@ -43,18 +48,36 @@ let cachedGoogleAuthClient: | undefined; let cachedGoogleVertexAdcToken: GoogleVertexAdcToken | undefined; -function resolveAuthorizedUserTokenExpiresAtMs(value: unknown, nowMs: number): number { - if (typeof value === "number" && Number.isFinite(value)) { - return ( - resolveExpiresAtMsFromDurationSeconds(Math.max(1, value), { nowMs }) ?? - nowMs - GOOGLE_VERTEX_TOKEN_EXPIRY_BUFFER_MS - ); +function isGoogleVertexTokenFresh(expiresAtMsRaw: number, nowRaw = Date.now()): boolean { + const expiresAtMs = asDateTimestampMs(expiresAtMsRaw); + const nowMs = asDateTimestampMs(nowRaw); + if (expiresAtMs === undefined || nowMs === undefined) { + return false; } - return ( - resolveExpiresAtMsFromDurationSeconds(GOOGLE_VERTEX_DEFAULT_TOKEN_LIFETIME_SECONDS, { - nowMs, - }) ?? nowMs - GOOGLE_VERTEX_TOKEN_EXPIRY_BUFFER_MS + const minFreshExpiresAtMs = resolveExpiresAtMsFromDurationMs( + GOOGLE_VERTEX_TOKEN_EXPIRY_BUFFER_MS, + { nowMs }, ); + return minFreshExpiresAtMs !== undefined && expiresAtMs > minFreshExpiresAtMs; +} + +function resolveAuthorizedUserTokenExpiresAtMs(value: unknown, nowRaw: number): number | undefined { + const nowMs = asDateTimestampMs(nowRaw); + if (nowMs === undefined) { + return undefined; + } + const lifetimeSeconds = + typeof value === "number" && Number.isFinite(value) + ? Math.max(1, value) + : GOOGLE_VERTEX_DEFAULT_TOKEN_LIFETIME_SECONDS; + return resolveExpiresAtMsFromDurationSeconds(lifetimeSeconds, { nowMs }) ?? nowMs; +} + +function resolveGoogleAuthLibraryTokenExpiresAtMs(nowRaw = Date.now()): number | undefined { + const nowMs = asDateTimestampMs(nowRaw); + return nowMs === undefined + ? undefined + : resolveExpiresAtMsFromDurationMs(GOOGLE_VERTEX_AUTHLIB_TOKEN_CACHE_MS, { nowMs }); } export function resetGoogleVertexAuthorizedUserTokenCacheForTest(): void { @@ -177,7 +200,7 @@ async function refreshGoogleVertexAuthorizedUserAccessToken(params: { if ( cached?.credentialsPath === params.credentialsPath && cached.refreshToken === refreshToken && - cached.expiresAtMs - Date.now() > GOOGLE_VERTEX_TOKEN_EXPIRY_BUFFER_MS + isGoogleVertexTokenFresh(cached.expiresAtMs) ) { return cached.token; } @@ -208,12 +231,15 @@ async function refreshGoogleVertexAuthorizedUserAccessToken(params: { throw new Error("Google Vertex ADC token refresh response did not include an access_token."); } const nowMs = Date.now(); - cachedGoogleVertexAuthorizedUserToken = { - token, - expiresAtMs: resolveAuthorizedUserTokenExpiresAtMs(payload?.expires_in, nowMs), - credentialsPath: params.credentialsPath, - refreshToken, - }; + const expiresAtMs = resolveAuthorizedUserTokenExpiresAtMs(payload?.expires_in, nowMs); + if (expiresAtMs !== undefined) { + cachedGoogleVertexAuthorizedUserToken = { + token, + expiresAtMs, + credentialsPath: params.credentialsPath, + refreshToken, + }; + } return token; } @@ -238,7 +264,7 @@ async function resolveGoogleVertexAccessTokenViaGoogleAuth(): Promise { const auth = await cachedGoogleAuthClient.promise; const cached = cachedGoogleVertexAdcToken; - if (cached && cached.expiresAtMs - Date.now() > GOOGLE_VERTEX_TOKEN_EXPIRY_BUFFER_MS) { + if (cached && isGoogleVertexTokenFresh(cached.expiresAtMs)) { return cached.token; } @@ -255,10 +281,13 @@ async function resolveGoogleVertexAccessTokenViaGoogleAuth(): Promise { // `getAccessToken()` return type, so we cache for a conservative 5 minutes. // The library itself already refreshes well before its own internal expiry, // so this cache is mainly to avoid hot-loop calls into the auth client. - cachedGoogleVertexAdcToken = { - token: normalized, - expiresAtMs: Date.now() + 5 * 60_000, - }; + const expiresAtMs = resolveGoogleAuthLibraryTokenExpiresAtMs(); + if (expiresAtMs !== undefined) { + cachedGoogleVertexAdcToken = { + token: normalized, + expiresAtMs, + }; + } return normalized; }