diff --git a/extensions/google/gemini-cli-provider.ts b/extensions/google/gemini-cli-provider.ts index 44dc9db4ca4..d9f245220c9 100644 --- a/extensions/google/gemini-cli-provider.ts +++ b/extensions/google/gemini-cli-provider.ts @@ -122,6 +122,10 @@ export function buildGoogleGeminiCliProvider(): ProviderPlugin { ...GOOGLE_GEMINI_PROVIDER_HOOKS, isModernModelRef: ({ modelId }) => isModernGoogleModel(modelId), formatApiKey: (cred) => formatGoogleOauthApiKey(cred), + refreshOAuth: async (cred) => { + const { refreshGeminiCliOAuthToken } = await import("./oauth.runtime.js"); + return await refreshGeminiCliOAuthToken(cred); + }, resolveUsageAuth: async (ctx) => { const auth = await ctx.resolveOAuthToken(); if (!auth) { diff --git a/extensions/google/index.test.ts b/extensions/google/index.test.ts index f2e3cfff9fa..b0cdec99cba 100644 --- a/extensions/google/index.test.ts +++ b/extensions/google/index.test.ts @@ -10,7 +10,7 @@ import { } from "openclaw/plugin-sdk/plugin-test-runtime"; import { createCapturedThinkingConfigStream } from "openclaw/plugin-sdk/provider-test-contracts"; import type { RealtimeVoiceProviderPlugin } from "openclaw/plugin-sdk/realtime-voice"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { registerGoogleGeminiCliProvider } from "./gemini-cli-provider.js"; import googlePlugin from "./index.js"; import { registerGoogleProvider } from "./provider-registration.js"; @@ -22,6 +22,12 @@ const googleProviderPlugin = { }, }; +const refreshGeminiCliOAuthTokenMock = vi.hoisted(() => vi.fn()); + +vi.mock("./oauth.runtime.js", () => ({ + refreshGeminiCliOAuthToken: refreshGeminiCliOAuthTokenMock, +})); + describe("google provider plugin hooks", () => { it("owns replay policy and reasoning mode for the direct Gemini provider", async () => { const { providers } = await registerProviderPlugin({ @@ -265,4 +271,40 @@ describe("google provider plugin hooks", () => { expect(bridge.setMediaTimestamp(20)).toBeUndefined(); expect(bridge.sendUserMessage?.("hello")).toBeUndefined(); }); + + it("refreshes Gemini CLI OAuth through the provider-owned refresh hook", async () => { + refreshGeminiCliOAuthTokenMock.mockResolvedValueOnce({ + type: "oauth", + provider: "google-gemini-cli", + access: "fresh-access", + refresh: "fresh-refresh", + expires: Date.now() + 60_000, + email: "user@example.com", + projectId: "project-1", + }); + + const { providers } = await registerProviderPlugin({ + plugin: googleProviderPlugin, + id: "google", + name: "Google Provider", + }); + const provider = requireRegisteredProvider(providers, "google-gemini-cli"); + const credential = { + type: "oauth" as const, + provider: "google-gemini-cli", + access: "stale-access", + refresh: "stale-refresh", + expires: Date.now() - 60_000, + email: "user@example.com", + projectId: "project-1", + }; + + await expect(provider.refreshOAuth?.(credential)).resolves.toMatchObject({ + access: "fresh-access", + refresh: "fresh-refresh", + email: "user@example.com", + projectId: "project-1", + }); + expect(refreshGeminiCliOAuthTokenMock).toHaveBeenCalledWith(credential); + }); }); diff --git a/extensions/google/oauth.runtime.ts b/extensions/google/oauth.runtime.ts index 4de8039e2b4..dcbd0ad24df 100644 --- a/extensions/google/oauth.runtime.ts +++ b/extensions/google/oauth.runtime.ts @@ -1 +1 @@ -export { loginGeminiCliOAuth } from "./oauth.js"; +export { loginGeminiCliOAuth, refreshGeminiCliOAuthToken } from "./oauth.js"; diff --git a/extensions/google/oauth.test.ts b/extensions/google/oauth.test.ts index 1065292f2f5..baa999ea996 100644 --- a/extensions/google/oauth.test.ts +++ b/extensions/google/oauth.test.ts @@ -876,4 +876,29 @@ describe("loginGeminiCliOAuth", () => { expect(result.projectId).toBeUndefined(); expect(requests.map(({ url }) => url)).toEqual([TOKEN_URL, USERINFO_URL]); }); + + it("refreshes Gemini CLI OAuth tokens without loadCodeAssist in personal OAuth mode", async () => { + mockSettingsExistsSync.mockReturnValue(true); + mockSettingsReadFileSync.mockReturnValue( + JSON.stringify({ + security: { + auth: { + selectedType: "oauth-personal", + }, + }, + }), + ); + + const { requests } = installGeminiOAuthFetchMock(() => undefined); + const { refreshTokensForGeminiCli } = await import("./oauth.token.js"); + const result = await refreshTokensForGeminiCli({ refresh: "refresh-token" }); + + expect(result).toMatchObject({ + access: "access-token", + refresh: "refresh-token", + email: "lobster@openclaw.ai", + projectId: undefined, + }); + expect(requests.map(({ url }) => url)).toEqual([TOKEN_URL, USERINFO_URL]); + }); }); diff --git a/extensions/google/oauth.token.ts b/extensions/google/oauth.token.ts index 92c910b7159..3943735826c 100644 --- a/extensions/google/oauth.token.ts +++ b/extensions/google/oauth.token.ts @@ -4,6 +4,84 @@ import { resolveGoogleOAuthIdentity, resolveGooglePersonalOAuthIdentity } from " import { isGeminiCliPersonalOAuth } from "./oauth.settings.js"; import { REDIRECT_URI, TOKEN_URL, type GeminiCliOAuthCredentials } from "./oauth.shared.js"; +async function requestTokenGrant(body: URLSearchParams): Promise<{ + access_token?: string; + refresh_token?: string; + expires_in?: number; +}> { + const response = await fetchWithTimeout(TOKEN_URL, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", + Accept: "*/*", + "User-Agent": "google-api-nodejs-client/9.15.1", + }, + body, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Token exchange failed: ${errorText}`); + } + + return (await response.json()) as { + access_token?: string; + refresh_token?: string; + expires_in?: number; + }; +} + +async function buildGeminiCliCredentials(params: { + tokenResponse: { + access_token?: string; + refresh_token?: string; + expires_in?: number; + }; + refreshTokenFallback?: string; + existing?: Pick; +}): Promise { + const accessToken = params.tokenResponse.access_token; + if (!accessToken) { + throw new Error("No access token received. Please try again."); + } + + let identity: { email?: string; projectId?: string } = params.existing ?? {}; + try { + if (!identity.email || !identity.projectId) { + const discovered = await resolveGeminiCliIdentity(accessToken); + identity = { + email: identity.email ?? discovered.email, + projectId: identity.projectId ?? discovered.projectId, + }; + } + } catch { + // If identity discovery is temporarily unavailable during refresh, keep the + // already-stored identity binding instead of failing token renewal. + } + + const expiresInMs = + typeof params.tokenResponse.expires_in === "number" + ? params.tokenResponse.expires_in * 1000 + : 0; + const expiresAt = Date.now() + expiresInMs - 5 * 60 * 1000; + + return { + refresh: params.tokenResponse.refresh_token ?? params.refreshTokenFallback ?? "", + access: accessToken, + expires: expiresAt, + projectId: identity.projectId, + email: identity.email, + }; +} + +async function resolveGeminiCliIdentity( + accessToken: string, +): Promise<{ email?: string; projectId?: string }> { + return isGeminiCliPersonalOAuth() + ? await resolveGooglePersonalOAuthIdentity(accessToken) + : await resolveGoogleOAuthIdentity(accessToken); +} + export async function exchangeCodeForTokens( code: string, verifier: string, @@ -20,41 +98,36 @@ export async function exchangeCodeForTokens( body.set("client_secret", clientSecret); } - const response = await fetchWithTimeout(TOKEN_URL, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", - Accept: "*/*", - "User-Agent": "google-api-nodejs-client/9.15.1", - }, - body, + const refreshed = await buildGeminiCliCredentials({ + tokenResponse: await requestTokenGrant(body), }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Token exchange failed: ${errorText}`); - } - - const data = (await response.json()) as { - access_token: string; - refresh_token: string; - expires_in: number; - }; - - if (!data.refresh_token) { + if (!refreshed.refresh) { throw new Error("No refresh token received. Please try again."); } - - const identity = isGeminiCliPersonalOAuth() - ? await resolveGooglePersonalOAuthIdentity(data.access_token) - : await resolveGoogleOAuthIdentity(data.access_token); - const expiresAt = Date.now() + data.expires_in * 1000 - 5 * 60 * 1000; - - return { - refresh: data.refresh_token, - access: data.access_token, - expires: expiresAt, - projectId: identity.projectId, - email: identity.email, - }; + return refreshed; +} + +export async function refreshTokensForGeminiCli(credentials: { + refresh: string; + email?: string; + projectId?: string; +}): Promise { + const { clientId, clientSecret } = resolveOAuthClientConfig(); + const body = new URLSearchParams({ + client_id: clientId, + grant_type: "refresh_token", + refresh_token: credentials.refresh, + }); + if (clientSecret) { + body.set("client_secret", clientSecret); + } + + return await buildGeminiCliCredentials({ + tokenResponse: await requestTokenGrant(body), + refreshTokenFallback: credentials.refresh, + existing: { + email: credentials.email, + projectId: credentials.projectId, + }, + }); } diff --git a/extensions/google/oauth.ts b/extensions/google/oauth.ts index 2199269e568..18b92ffffce 100644 --- a/extensions/google/oauth.ts +++ b/extensions/google/oauth.ts @@ -8,7 +8,7 @@ import { waitForLocalCallback, } from "./oauth.flow.js"; import type { GeminiCliOAuthContext, GeminiCliOAuthCredentials } from "./oauth.shared.js"; -import { exchangeCodeForTokens } from "./oauth.token.js"; +import { exchangeCodeForTokens, refreshTokensForGeminiCli } from "./oauth.token.js"; export { clearCredentialsCache, extractGeminiCliCredentials }; export type { GeminiCliOAuthContext, GeminiCliOAuthCredentials }; @@ -90,3 +90,9 @@ async function manualFlow( ctx.progress.update("Exchanging authorization code for tokens..."); return exchangeCodeForTokens(parsed.code, verifier); } + +export async function refreshGeminiCliOAuthToken( + credentials: Pick, +): Promise { + return await refreshTokensForGeminiCli(credentials); +} diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts index 015e64bcc55..920721502ff 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -725,7 +725,7 @@ export async function resolveApiKeyForProvider(params: { return result; } } catch (err) { - log.debug?.(`auth profile "${candidate}" failed for provider "${provider}": ${String(err)}`); + log.warn(`auth profile "${candidate}" failed for provider "${provider}": ${String(err)}`); } } @@ -923,7 +923,7 @@ export async function hasAvailableAuthForProvider(params: { return true; } } catch (err) { - log.debug?.(`auth profile "${candidate}" failed for provider "${provider}": ${String(err)}`); + log.warn(`auth profile "${candidate}" failed for provider "${provider}": ${String(err)}`); } } return false;