mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-07 10:34:44 +00:00
fix: load staged Gemini CLI auth profiles
This commit is contained in:
@@ -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") };
|
||||
}
|
||||
|
||||
|
||||
26
extensions/google/gemini-cli-auth-home.ts
Normal file
26
extensions/google/gemini-cli-auth-home.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -666,6 +666,7 @@
|
||||
}
|
||||
},
|
||||
"contracts": {
|
||||
"externalAuthProviders": ["google-gemini-cli"],
|
||||
"mediaUnderstandingProviders": ["google"],
|
||||
"memoryEmbeddingProviders": ["gemini"],
|
||||
"imageGenerationProviders": ["google"],
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user