fix: load staged Gemini CLI auth profiles

This commit is contained in:
Shakker
2026-06-16 17:18:19 +01:00
committed by Shakker
parent c38c4e9212
commit 17ba3bc65d
7 changed files with 246 additions and 6 deletions

View File

@@ -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<string, string> = {
pro: "gemini-3.1-pro-preview",
@@ -14,7 +17,7 @@ const GEMINI_MODEL_ALIASES: Record<string, string> = {
"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") };
}

View File

@@ -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,
);
}

View File

@@ -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<typeof import("./oauth.runtime.js")> | null = null;
type GeminiCliExternalAuthContext = Parameters<
NonNullable<ProviderPlugin["resolveExternalAuthProfiles"]>
>[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<string, unknown> {
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<string, unknown>)
: {};
} 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<string, unknown>;
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<string>();
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),

View File

@@ -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,

View File

@@ -666,6 +666,7 @@
}
},
"contracts": {
"externalAuthProviders": ["google-gemini-cli"],
"mediaUnderstandingProviders": ["google"],
"memoryEmbeddingProviders": ["gemini"],
"imageGenerationProviders": ["google"],

View File

@@ -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 });
}
});
});

View File

@@ -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,
}),