Files
openclaw/extensions/google/cli-backend-auth.runtime.ts
2026-06-17 03:31:14 +01:00

406 lines
12 KiB
TypeScript

import fs from "node:fs/promises";
import path from "node:path";
import type { CliBackendPreparedExecution } from "openclaw/plugin-sdk/cli-backend";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import {
GOOGLE_GEMINI_CLI_PROVIDER_ID,
resolveGeminiCliProfileHome as resolveGeminiCliProfileHomePath,
} from "./gemini-cli-auth-home.js";
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";
const GEMINI_CLI_GCA_AUTH_ENV = [
"GOOGLE_GENAI_USE_GCA",
"GOOGLE_CLOUD_ACCESS_TOKEN",
"GOOGLE_APPLICATION_CREDENTIALS",
"GEMINI_FORCE_ENCRYPTED_FILE_STORAGE",
"GEMINI_FORCE_FILE_STORAGE",
];
const GEMINI_CLI_API_KEY_AUTH_ENV = [
...GEMINI_CLI_GCA_AUTH_ENV,
"GOOGLE_GENAI_USE_VERTEXAI",
"GOOGLE_API_KEY",
"GOOGLE_CLOUD_PROJECT",
"GOOGLE_CLOUD_PROJECT_ID",
"GOOGLE_CLOUD_QUOTA_PROJECT",
"GOOGLE_CLOUD_LOCATION",
"GOOGLE_GEMINI_BASE_URL",
"GEMINI_CLI_CUSTOM_HEADERS",
"GEMINI_API_KEY_AUTH_MECHANISM",
];
const GEMINI_CLI_PROFILE_AUTH_ENV = [...GEMINI_CLI_API_KEY_AUTH_ENV, "GEMINI_API_KEY"];
const GEMINI_CLI_PROFILE_SETTINGS_ENV = ["GEMINI_CLI_SYSTEM_SETTINGS_PATH"];
type GeminiAuthProfileCredential = {
type: "api_key" | "oauth" | "token";
provider: string;
key?: string;
token?: string;
access?: string;
refresh?: string;
expires?: number;
idToken?: string;
projectId?: string;
};
type GeminiOAuthCredential = GeminiAuthProfileCredential & {
type: "oauth";
provider: typeof GEMINI_CLI_PROVIDER_ID;
access: string;
refresh: string;
expires: number;
};
type GeminiApiKeyCredential = GeminiAuthProfileCredential & {
type: "api_key";
provider: typeof GEMINI_CLI_PROVIDER_ID | typeof GOOGLE_PROVIDER_ID;
key: string;
};
type GeminiCliAuthHomeContext = {
agentDir?: string;
authProfileId?: string;
systemSettingsPath?: string;
};
type GeminiCliAuthSelectedType = "oauth-personal" | "gemini-api-key";
function normalizeString(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
function throwUnsupportedGeminiCredential(credential: GeminiAuthProfileCredential): never {
if (credential.provider === VERCEL_AI_GATEWAY_PROVIDER_ID) {
throw new Error(
"Gemini CLI execution cannot use a vercel-ai-gateway auth profile. Use the OpenClaw vercel-ai-gateway provider instead.",
);
}
throw new Error("Gemini CLI execution requires a google-gemini-cli auth profile.");
}
function throwUnstageableSelectedGeminiProfile(
ctx: GeminiCliAuthHomeContext,
credential: GeminiAuthProfileCredential | undefined,
): never {
const authProfileId = normalizeString(ctx.authProfileId);
if (!authProfileId) {
throw new Error("Gemini CLI execution requires a selected auth profile.");
}
if (!credential) {
throw new Error(
"Gemini CLI auth profile was selected but no credential material was found. Re-authenticate with `openclaw models auth login --provider google-gemini-cli --force`.",
);
}
if (credential.provider !== GEMINI_CLI_PROVIDER_ID) {
throwUnsupportedGeminiCredential(credential);
}
throw new Error(
"Gemini CLI execution supports google-gemini-cli OAuth or API-key auth profiles. Re-authenticate with `openclaw models auth login --provider google-gemini-cli --force`.",
);
}
function requireGeminiOAuthCredential(
credential: GeminiAuthProfileCredential | undefined,
): GeminiOAuthCredential | null {
if (!credential) {
return null;
}
if (credential.type !== "oauth") {
return null;
}
if (credential.provider !== GEMINI_CLI_PROVIDER_ID) {
throwUnsupportedGeminiCredential(credential);
}
const access = normalizeString(credential.access);
const refresh = normalizeString(credential.refresh);
if (
!access ||
!refresh ||
typeof credential.expires !== "number" ||
!Number.isFinite(credential.expires)
) {
throw new Error(
"Gemini CLI OAuth profile is missing usable token material. Re-authenticate with `openclaw models auth login --provider google-gemini-cli --force`.",
);
}
return {
...credential,
type: "oauth",
provider: GEMINI_CLI_PROVIDER_ID,
access,
refresh,
expires: credential.expires,
idToken: normalizeString(credential.idToken),
projectId: normalizeString(credential.projectId),
};
}
function requireGeminiApiKeyCredential(
credential: GeminiAuthProfileCredential | undefined,
): GeminiApiKeyCredential | null {
if (!credential) {
return null;
}
if (credential.type !== "api_key") {
return null;
}
if (
credential.provider !== GEMINI_CLI_PROVIDER_ID &&
credential.provider !== GOOGLE_PROVIDER_ID
) {
throwUnsupportedGeminiCredential(credential);
}
const key = normalizeString(credential.key);
if (!key) {
throw new Error("Gemini CLI API-key profile is missing usable key material.");
}
return {
...credential,
type: "api_key",
provider: credential.provider,
key,
};
}
function resolveGeminiCliProfileHome(ctx: GeminiCliAuthHomeContext): {
home: string;
geminiDir: string;
} {
const agentDir = normalizeString(ctx.agentDir);
if (!agentDir) {
throw new Error("Gemini CLI auth profile execution requires an agent directory.");
}
const authProfileId = normalizeString(ctx.authProfileId);
if (!authProfileId) {
throw new Error("Gemini CLI auth profile execution requires a selected auth profile.");
}
const home = resolveGeminiCliProfileHomePath(agentDir, authProfileId);
return { home, geminiDir: path.join(home, ".gemini") };
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function readGeminiAuthProfileCredential(
credential: unknown,
): GeminiAuthProfileCredential | undefined {
if (!isRecord(credential)) {
return undefined;
}
return credential as GeminiAuthProfileCredential;
}
async function readGeminiCliJsonObject(
filePath: string | undefined,
): Promise<Record<string, unknown>> {
const normalized = normalizeString(filePath);
if (!normalized) {
return {};
}
try {
const parsed = JSON.parse(await fs.readFile(normalized, "utf8")) as unknown;
if (!isRecord(parsed)) {
throw new Error(`Gemini CLI system settings must be a JSON object: ${normalized}`);
}
return { ...parsed };
} catch (error) {
if (
error &&
typeof error === "object" &&
"code" in error &&
(error as { code?: unknown }).code === "ENOENT"
) {
return {};
}
throw error;
}
}
function buildGeminiCliAuthSettings(
selectedType: GeminiCliAuthSelectedType,
): Record<string, unknown> {
return { security: { auth: { selectedType } } };
}
async function buildGeminiCliSystemSettings(
ctx: GeminiCliAuthHomeContext,
selectedType: GeminiCliAuthSelectedType,
): Promise<Record<string, unknown>> {
const base = await readGeminiCliJsonObject(ctx.systemSettingsPath);
const security = isRecord(base.security) ? { ...base.security } : {};
const auth = isRecord(security.auth) ? { ...security.auth } : {};
const enforcedType = normalizeString(
typeof auth.enforcedType === "string" ? auth.enforcedType : undefined,
);
if (enforcedType && enforcedType !== selectedType) {
throw new Error(
`Gemini CLI system settings enforce ${enforcedType} auth, but the selected OpenClaw profile requires ${selectedType}.`,
);
}
security.auth = { ...auth, selectedType };
return {
...base,
security,
};
}
async function writeGeminiCliJson(filePath: string, value: unknown): Promise<void> {
await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, {
encoding: "utf8",
mode: 0o600,
});
await fs.chmod(filePath, 0o600);
}
async function prepareGeminiCliProfileHome(
ctx: GeminiCliAuthHomeContext,
selectedType: GeminiCliAuthSelectedType,
): Promise<{
home: string;
geminiDir: string;
systemSettingsPath: string;
cleanup: () => Promise<void>;
}> {
const { home, geminiDir } = resolveGeminiCliProfileHome(ctx);
await fs.mkdir(geminiDir, { recursive: true, mode: 0o700 });
await fs.chmod(home, 0o700);
await fs.chmod(geminiDir, 0o700);
const settings = buildGeminiCliAuthSettings(selectedType);
const systemSettings = await buildGeminiCliSystemSettings(ctx, selectedType);
const systemSettingsDir = await fs.mkdtemp(
path.join(resolvePreferredOpenClawTmpDir(), "openclaw-gemini-cli-"),
);
await fs.chmod(systemSettingsDir, 0o700);
const systemSettingsPath = path.join(systemSettingsDir, "settings.json");
try {
await Promise.all([
writeGeminiCliJson(path.join(geminiDir, "settings.json"), settings),
writeGeminiCliJson(path.join(home, "settings.json"), settings),
writeGeminiCliJson(systemSettingsPath, systemSettings),
]);
} catch (error) {
await fs.rm(systemSettingsDir, { recursive: true, force: true });
throw error;
}
return {
home,
geminiDir,
systemSettingsPath,
cleanup: async () => {
await fs.rm(systemSettingsDir, { recursive: true, force: true });
},
};
}
async function clearGeminiCliCachedCredentials(geminiDir: string): Promise<void> {
// Gemini prefers its token store over oauth_creds.json. Rebuild that store
// from the selected OpenClaw profile each run so stale CLI auth cannot win.
await fs.rm(path.join(geminiDir, GEMINI_CLI_CREDENTIALS_FILENAME), { force: true });
}
function buildGeminiCliProjectEnv(projectId: string | undefined): Record<string, string> {
const normalized = normalizeString(projectId);
if (!normalized) {
return {};
}
return {
GOOGLE_CLOUD_PROJECT: normalized,
GOOGLE_CLOUD_PROJECT_ID: normalized,
GOOGLE_CLOUD_QUOTA_PROJECT: normalized,
};
}
async function prepareGeminiCliOAuthHome(
ctx: GeminiCliAuthHomeContext,
credential: GeminiAuthProfileCredential | undefined,
): Promise<CliBackendPreparedExecution | null> {
const oauth = requireGeminiOAuthCredential(credential);
if (!oauth) {
return null;
}
const { home, geminiDir, systemSettingsPath, cleanup } = await prepareGeminiCliProfileHome(
ctx,
"oauth-personal",
);
await clearGeminiCliCachedCredentials(geminiDir);
const idToken = normalizeString(oauth.idToken);
const oauthCreds: Record<string, string | number> = {
access_token: oauth.access,
refresh_token: oauth.refresh,
expiry_date: oauth.expires,
token_type: "Bearer",
};
if (idToken) {
oauthCreds.id_token = idToken;
}
await writeGeminiCliJson(path.join(geminiDir, "oauth_creds.json"), oauthCreds);
return {
env: {
GEMINI_CLI_HOME: home,
GEMINI_CLI_SYSTEM_SETTINGS_PATH: systemSettingsPath,
GEMINI_FORCE_FILE_STORAGE: "true",
...buildGeminiCliProjectEnv(oauth.projectId),
},
clearEnv: [...GEMINI_CLI_PROFILE_AUTH_ENV, ...GEMINI_CLI_PROFILE_SETTINGS_ENV],
cleanup,
};
}
async function prepareGeminiCliApiKeyHome(
ctx: GeminiCliAuthHomeContext,
credential: GeminiAuthProfileCredential | undefined,
): Promise<CliBackendPreparedExecution | null> {
const apiKey = requireGeminiApiKeyCredential(credential);
if (!apiKey) {
return null;
}
const { home, geminiDir, systemSettingsPath, cleanup } = await prepareGeminiCliProfileHome(
ctx,
"gemini-api-key",
);
await Promise.all([
fs.rm(path.join(geminiDir, "oauth_creds.json"), { force: true }),
clearGeminiCliCachedCredentials(geminiDir),
]);
return {
env: {
GEMINI_CLI_HOME: home,
GEMINI_CLI_SYSTEM_SETTINGS_PATH: systemSettingsPath,
GEMINI_FORCE_FILE_STORAGE: "true",
GEMINI_API_KEY: apiKey.key,
},
clearEnv: [...GEMINI_CLI_PROFILE_AUTH_ENV, ...GEMINI_CLI_PROFILE_SETTINGS_ENV],
cleanup,
};
}
export async function prepareGeminiCliAuthHome(
ctx: GeminiCliAuthHomeContext,
credential: unknown,
): Promise<CliBackendPreparedExecution | null> {
const authCredential = readGeminiAuthProfileCredential(credential);
const prepared =
(await prepareGeminiCliOAuthHome(ctx, authCredential)) ??
(await prepareGeminiCliApiKeyHome(ctx, authCredential));
if (prepared) {
return prepared;
}
if (normalizeString(ctx.authProfileId)) {
throwUnstageableSelectedGeminiProfile(ctx, authCredential);
}
return null;
}