From 17ba3bc65d3f18d8b91647de7aad5e37722dbcdb Mon Sep 17 00:00:00 2001 From: Shakker Date: Tue, 16 Jun 2026 17:18:19 +0100 Subject: [PATCH] fix: load staged Gemini CLI auth profiles --- extensions/google/cli-backend.ts | 10 ++- extensions/google/gemini-cli-auth-home.ts | 26 ++++++ extensions/google/gemini-cli-provider.ts | 90 ++++++++++++++++++- extensions/google/index.test.ts | 77 +++++++++++++++- extensions/google/openclaw.plugin.json | 1 + .../auth-profiles.readonly-sync.test.ts | 45 ++++++++++ src/agents/cli-runner/prepare.ts | 3 + 7 files changed, 246 insertions(+), 6 deletions(-) create mode 100644 extensions/google/gemini-cli-auth-home.ts diff --git a/extensions/google/cli-backend.ts b/extensions/google/cli-backend.ts index e9eff4d386f2..84f5f13cdd08 100644 --- a/extensions/google/cli-backend.ts +++ b/extensions/google/cli-backend.ts @@ -1,4 +1,3 @@ -import crypto from "node:crypto"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -7,6 +6,10 @@ import { CLI_FRESH_WATCHDOG_DEFAULTS, CLI_RESUME_WATCHDOG_DEFAULTS, } from "openclaw/plugin-sdk/cli-backend"; +import { + GOOGLE_GEMINI_CLI_PROVIDER_ID, + resolveGeminiCliProfileHome as resolveGeminiCliProfileHomePath, +} from "./gemini-cli-auth-home.js"; const GEMINI_MODEL_ALIASES: Record = { pro: "gemini-3.1-pro-preview", @@ -14,7 +17,7 @@ const GEMINI_MODEL_ALIASES: Record = { "flash-lite": "gemini-3.1-flash-lite", }; const GEMINI_CLI_DEFAULT_MODEL_REF = "google-gemini-cli/gemini-3-flash-preview"; -const GEMINI_CLI_PROVIDER_ID = "google-gemini-cli"; +const GEMINI_CLI_PROVIDER_ID = GOOGLE_GEMINI_CLI_PROVIDER_ID; const GOOGLE_PROVIDER_ID = "google"; const VERCEL_AI_GATEWAY_PROVIDER_ID = "vercel-ai-gateway"; const GEMINI_CLI_CREDENTIALS_FILENAME = "gemini-credentials.json"; @@ -195,8 +198,7 @@ function resolveGeminiCliProfileHome(ctx: GeminiCliAuthHomeContext): { throw new Error("Gemini CLI auth profile execution requires a selected auth profile."); } - const profileHash = crypto.createHash("sha256").update(authProfileId).digest("hex").slice(0, 24); - const home = path.join(agentDir, "cli-runtimes", GEMINI_CLI_PROVIDER_ID, "profiles", profileHash); + const home = resolveGeminiCliProfileHomePath(agentDir, authProfileId); return { home, geminiDir: path.join(home, ".gemini") }; } diff --git a/extensions/google/gemini-cli-auth-home.ts b/extensions/google/gemini-cli-auth-home.ts new file mode 100644 index 000000000000..a98bbe5495f8 --- /dev/null +++ b/extensions/google/gemini-cli-auth-home.ts @@ -0,0 +1,26 @@ +import crypto from "node:crypto"; +import path from "node:path"; + +export const GOOGLE_GEMINI_CLI_PROVIDER_ID = "google-gemini-cli"; +export const GEMINI_CLI_OAUTH_CREDS_RELATIVE_PATH = ".gemini/oauth_creds.json"; + +export function resolveGeminiCliProfileHome(agentDir: string, profileId: string): string { + const profileHash = crypto.createHash("sha256").update(profileId).digest("hex").slice(0, 24); + return path.join( + agentDir, + "cli-runtimes", + GOOGLE_GEMINI_CLI_PROVIDER_ID, + "profiles", + profileHash, + ); +} + +export function resolveGeminiCliProfileCredentialsPath( + agentDir: string, + profileId: string, +): string { + return path.join( + resolveGeminiCliProfileHome(agentDir, profileId), + GEMINI_CLI_OAUTH_CREDS_RELATIVE_PATH, + ); +} diff --git a/extensions/google/gemini-cli-provider.ts b/extensions/google/gemini-cli-provider.ts index ee080f2ed2a9..903f152bfca4 100644 --- a/extensions/google/gemini-cli-provider.ts +++ b/extensions/google/gemini-cli-provider.ts @@ -1,4 +1,5 @@ // Google provider module implements model/runtime integration. +import fs from "node:fs"; import type { OpenClawPluginApi, ProviderAuthContext, @@ -7,11 +8,15 @@ import type { import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-auth-result"; import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; import { fetchGeminiUsage } from "openclaw/plugin-sdk/provider-usage"; +import { + GOOGLE_GEMINI_CLI_PROVIDER_ID, + resolveGeminiCliProfileCredentialsPath, +} from "./gemini-cli-auth-home.js"; import { formatGoogleOauthApiKey, parseGoogleUsageToken } from "./oauth-token-shared.js"; import { GOOGLE_GEMINI_PROVIDER_HOOKS } from "./provider-hooks.js"; import { isModernGoogleModel, resolveGoogleGeminiForwardCompatModel } from "./provider-models.js"; -const PROVIDER_ID = "google-gemini-cli"; +const PROVIDER_ID = GOOGLE_GEMINI_CLI_PROVIDER_ID; const PROVIDER_LABEL = "Gemini CLI OAuth"; const DEFAULT_MODEL = "google/gemini-3.1-pro-preview"; const ENV_VARS = [ @@ -22,6 +27,9 @@ const ENV_VARS = [ ] as const; let oauthRuntimeModulePromise: Promise | null = null; +type GeminiCliExternalAuthContext = Parameters< + NonNullable +>[0]; const loadOauthRuntimeModule = async () => { oauthRuntimeModulePromise ??= import("./oauth.runtime.js"); @@ -32,6 +40,76 @@ async function fetchGeminiCliUsage(ctx: ProviderFetchUsageSnapshotContext) { return await fetchGeminiUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn, PROVIDER_ID); } +function normalizeString(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +function decodeJwtPayload(token: string): Record { + const payload = token.split(".")[1]; + if (!payload) { + return {}; + } + try { + const parsed = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as unknown; + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : {}; + } catch { + return {}; + } +} + +function readGeminiCliProfileCredential(agentDir: string, profileId: string) { + const credentialsPath = resolveGeminiCliProfileCredentialsPath(agentDir, profileId); + let raw: unknown; + try { + raw = JSON.parse(fs.readFileSync(credentialsPath, "utf8")) as unknown; + } catch { + return null; + } + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + return null; + } + const data = raw as Record; + const access = normalizeString(typeof data.access_token === "string" ? data.access_token : ""); + const refresh = normalizeString(typeof data.refresh_token === "string" ? data.refresh_token : ""); + const expires = data.expiry_date; + if (!access || !refresh || typeof expires !== "number" || !Number.isFinite(expires)) { + return null; + } + + const idToken = normalizeString(typeof data.id_token === "string" ? data.id_token : ""); + const identity = idToken ? decodeJwtPayload(idToken) : {}; + const email = normalizeString(typeof identity.email === "string" ? identity.email : ""); + const accountId = normalizeString(typeof identity.sub === "string" ? identity.sub : ""); + return { + type: "oauth" as const, + provider: PROVIDER_ID, + access, + refresh, + expires, + ...(idToken ? { idToken } : {}), + ...(email ? { email } : {}), + ...(accountId ? { accountId } : {}), + }; +} + +function resolveConfiguredGeminiCliOAuthProfileIds(ctx: GeminiCliExternalAuthContext): string[] { + const profileIds = new Set(); + for (const [profileId, profile] of Object.entries(ctx.config?.auth?.profiles ?? {})) { + if (profile.provider === PROVIDER_ID && profile.mode === "oauth") { + profileIds.add(profileId); + } + } + for (const [profileId, credential] of Object.entries(ctx.store.profiles)) { + if (credential.provider === PROVIDER_ID && credential.type === "oauth") { + profileIds.add(profileId); + } + } + return [...profileIds].toSorted(); +} + export function buildGoogleGeminiCliProvider(): ProviderPlugin { return { id: PROVIDER_ID, @@ -126,6 +204,16 @@ export function buildGoogleGeminiCliProvider(): ProviderPlugin { providerId: PROVIDER_ID, ctx, }), + resolveExternalAuthProfiles: (ctx) => { + const agentDir = normalizeString(ctx.agentDir); + if (!agentDir) { + return []; + } + return resolveConfiguredGeminiCliOAuthProfileIds(ctx).flatMap((profileId) => { + const credential = readGeminiCliProfileCredential(agentDir, profileId); + return credential ? [{ profileId, credential, persistence: "runtime-only" as const }] : []; + }); + }, ...GOOGLE_GEMINI_PROVIDER_HOOKS, isModernModelRef: ({ modelId }) => isModernGoogleModel(modelId), formatApiKey: (cred) => formatGoogleOauthApiKey(cred), diff --git a/extensions/google/index.test.ts b/extensions/google/index.test.ts index 9f9e8aee9167..1d633638d72a 100644 --- a/extensions/google/index.test.ts +++ b/extensions/google/index.test.ts @@ -1,5 +1,6 @@ // Google tests cover index plugin behavior. -import { mkdtemp, writeFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import type { Context, Model } from "openclaw/plugin-sdk/llm"; @@ -34,6 +35,80 @@ vi.mock("./oauth.runtime.js", () => ({ })); describe("google provider plugin hooks", () => { + it("exposes staged Gemini CLI OAuth homes as runtime external auth profiles", async () => { + const agentDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-google-cli-auth-")); + const profileId = "google-gemini-cli:user@example.test"; + const profileHash = createHash("sha256").update(profileId).digest("hex").slice(0, 24); + const credentialsDir = path.join( + agentDir, + "cli-runtimes", + "google-gemini-cli", + "profiles", + profileHash, + ".gemini", + ); + const idTokenPayload = Buffer.from( + JSON.stringify({ sub: "google-account-42", email: "user@example.test" }), + ).toString("base64url"); + const cfg = { + auth: { + profiles: { + [profileId]: { + provider: "google-gemini-cli", + mode: "oauth", + email: "user@example.test", + }, + }, + }, + }; + try { + await mkdir(credentialsDir, { recursive: true, mode: 0o700 }); + await writeFile( + path.join(credentialsDir, "oauth_creds.json"), + `${JSON.stringify({ + access_token: "gemini-access", + refresh_token: "gemini-refresh", + id_token: `header.${idTokenPayload}.signature`, + expiry_date: 1_800_000_000_000, + })}\n`, + "utf8", + ); + + const { providers } = await registerProviderPlugin({ + plugin: googleProviderPlugin, + id: "google", + name: "Google Provider", + }); + const cliProvider = requireRegisteredProvider(providers, "google-gemini-cli"); + const profiles = cliProvider.resolveExternalAuthProfiles?.({ + config: cfg, + agentDir, + workspaceDir: undefined, + env: process.env, + store: { version: 1, profiles: {} }, + } as never); + + expect(profiles).toEqual([ + { + profileId, + persistence: "runtime-only", + credential: { + type: "oauth", + provider: "google-gemini-cli", + access: "gemini-access", + refresh: "gemini-refresh", + expires: 1_800_000_000_000, + idToken: `header.${idTokenPayload}.signature`, + accountId: "google-account-42", + email: "user@example.test", + }, + }, + ]); + } finally { + await rm(agentDir, { recursive: true, force: true }); + } + }); + it("owns replay policy and reasoning mode for the direct Gemini provider", async () => { const { providers } = await registerProviderPlugin({ plugin: googleProviderPlugin, diff --git a/extensions/google/openclaw.plugin.json b/extensions/google/openclaw.plugin.json index 9d673c6162a1..690b9dd33b13 100644 --- a/extensions/google/openclaw.plugin.json +++ b/extensions/google/openclaw.plugin.json @@ -666,6 +666,7 @@ } }, "contracts": { + "externalAuthProviders": ["google-gemini-cli"], "mediaUnderstandingProviders": ["google"], "memoryEmbeddingProviders": ["gemini"], "imageGenerationProviders": ["google"], diff --git a/src/agents/auth-profiles.readonly-sync.test.ts b/src/agents/auth-profiles.readonly-sync.test.ts index dd4028b31a56..d40983653fa2 100644 --- a/src/agents/auth-profiles.readonly-sync.test.ts +++ b/src/agents/auth-profiles.readonly-sync.test.ts @@ -9,6 +9,7 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js"; import { AUTH_STORE_VERSION } from "./auth-profiles/constants.js"; +import { externalCliDiscoveryScoped } from "./auth-profiles/external-cli-discovery.js"; import { loadPersistedAuthProfileStore } from "./auth-profiles/persisted.js"; import { saveAuthProfileStore } from "./auth-profiles/store.js"; import type { AuthProfileStore } from "./auth-profiles/types.js"; @@ -120,4 +121,48 @@ describe("auth profiles read-only external auth overlay", () => { fs.rmSync(agentDir, { recursive: true, force: true }); } }); + + it("passes scoped external auth config to provider hooks", () => { + const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-scoped-config-")); + const profileId = "google-gemini-cli:user@example.test"; + const cfg = { + auth: { + profiles: { + [profileId]: { + provider: "google-gemini-cli", + mode: "oauth" as const, + email: "user@example.test", + }, + }, + }, + }; + try { + loadAuthProfileStoreForRuntime(agentDir, { + readOnly: true, + externalCli: externalCliDiscoveryScoped({ + config: cfg, + providerIds: ["google-gemini-cli"], + profileIds: [profileId], + }), + }); + + expect(resolveExternalAuthProfilesWithPluginsMock).toHaveBeenCalledTimes(1); + const externalAuthCall = firstMockArg( + resolveExternalAuthProfilesWithPluginsMock, + "resolveExternalAuthProfilesWithPlugins", + ) as + | { + config?: unknown; + context?: { + config?: unknown; + }; + } + | undefined; + expect(externalAuthCall?.config).toBe(cfg); + expect(externalAuthCall?.context?.config).toBe(cfg); + } finally { + closeOpenClawAgentDatabasesForTest(); + fs.rmSync(agentDir, { recursive: true, force: true }); + } + }); }); diff --git a/src/agents/cli-runner/prepare.ts b/src/agents/cli-runner/prepare.ts index 6d7bc7902611..d3eda084f3cc 100644 --- a/src/agents/cli-runner/prepare.ts +++ b/src/agents/cli-runner/prepare.ts @@ -294,6 +294,7 @@ export async function prepareCliRunContext( authStore = loadAuthProfileStoreForRuntime(agentDir, { readOnly: true, externalCli: externalCliDiscoveryForProviderAuth({ + cfg: params.config, provider: params.provider, profileId: effectiveAuthProfileId, }), @@ -311,6 +312,7 @@ export async function prepareCliRunContext( const authProfileId = effectiveAuthProfileId; const writableAuthStore = loadAuthProfileStoreForRuntime(agentDir, { externalCli: externalCliDiscoveryForProviderAuth({ + cfg: params.config, provider: params.provider, profileId: authProfileId, }), @@ -326,6 +328,7 @@ export async function prepareCliRunContext( authStore = loadAuthProfileStoreForRuntime(agentDir, { readOnly: true, externalCli: externalCliDiscoveryForProviderAuth({ + cfg: params.config, provider: params.provider, profileId: resolvedAuthProfileId, }),