mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 09:51:33 +00:00
* origin/main: (168 commits) fix(plugins): keep registry sqlite root consistent (#116427) fix(cli): keep Gateway auth failures parseable in JSON (#116597) fix(trajectory): preserve prompts in oversized events (#116220) test(buzz): add live QA Lab channel lane (#116298) chore(i18n): refresh native locales (#116567) feat: add OpenClaw TUI prototype skill (#116583) fix(update): accept concrete versions selected by ranges (#116585) fix(agents): preserve locked session model routes test(qa-lab): preserve Matrix fault proxy identity fix(ci): extend OpenAI live profile setup budget (#116580) fix: prevent configured models from stalling startup (#116553) fix(cli): close MCP listener after one-shot commands fix(auto-reply): keep explicit NO_REPLY silent (#116548) test(discord): cover deferred backpressure terminals fix(discord): cancel backpressured realtime playback test(discord): cover realtime output backpressure fix(codex): add remote WebSocket heartbeat and reconnect (#116135) fix(macos): wait for the current reconnect snapshot (#116550) fix(outbound): recognize current-source message-tool sends (#116560) fix(voice-call): type cancelled consult outcomes ... # Conflicts: # extensions/google/setup-api.test.ts # ui/src/e2e/model-setup.e2e.test.ts
711 lines
25 KiB
TypeScript
711 lines
25 KiB
TypeScript
import crypto from "node:crypto";
|
|
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import type {
|
|
CliBackendPreparedExecution,
|
|
CliBackendToolAvailability,
|
|
} from "openclaw/plugin-sdk/cli-backend";
|
|
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
|
|
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
|
|
import {
|
|
assertGeminiCliLiteralIsolatedPrompt,
|
|
GEMINI_CLI_EXACT_TOOL_ENV_BARRIERS,
|
|
type GeminiCliRestrictedAuthContext,
|
|
isolatedCompletionInputError,
|
|
isolatedCompletionUnsupportedError,
|
|
readGeminiCliJsonObject,
|
|
resolveGeminiCliAmbientAuth,
|
|
resolveGeminiCliTrustedTransportEnv,
|
|
} from "./cli-backend-isolated-auth.runtime.js";
|
|
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",
|
|
// Gemini CLI consumes this token only in its Code Assist branch. Keep it
|
|
// coupled to the rejected/cleared GCA selector so Vertex cannot inherit it.
|
|
"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"];
|
|
const GEMINI_CLI_SUPPORTED_AUTH_GUIDANCE =
|
|
"Open Models settings and connect Google with an AI Studio API key, then select that profile for this model.";
|
|
|
|
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 = GeminiCliRestrictedAuthContext & {
|
|
agentDir?: string;
|
|
authProfileId?: string;
|
|
isolatedCompletionCwd?: string;
|
|
toolAvailability?: CliBackendToolAvailability;
|
|
isolatedCompletionModelId?: string;
|
|
isolatedCompletionPrompt?: string;
|
|
isolatedCompletionSystemPrompt?: string;
|
|
};
|
|
|
|
type GeminiCliAuthSelectedType = "oauth-personal" | "gemini-api-key";
|
|
|
|
type GeminiCliPreparedExecution = CliBackendPreparedExecution & {
|
|
isolatedCompletionEnforced?: true;
|
|
};
|
|
|
|
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. ${GEMINI_CLI_SUPPORTED_AUTH_GUIDANCE}`,
|
|
);
|
|
}
|
|
if (credential.provider !== GEMINI_CLI_PROVIDER_ID) {
|
|
throwUnsupportedGeminiCredential(credential);
|
|
}
|
|
throw new Error(
|
|
`Gemini CLI execution requires a Google AI Studio API-key profile or a previously configured valid Gemini CLI OAuth profile. ${GEMINI_CLI_SUPPORTED_AUTH_GUIDANCE}`,
|
|
);
|
|
}
|
|
|
|
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 incomplete and cannot be repaired by OpenClaw. ${GEMINI_CLI_SUPPORTED_AUTH_GUIDANCE}`,
|
|
);
|
|
}
|
|
|
|
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 readGeminiAuthProfileCredential(
|
|
credential: unknown,
|
|
): GeminiAuthProfileCredential | undefined {
|
|
if (!isRecord(credential)) {
|
|
return undefined;
|
|
}
|
|
return credential as GeminiAuthProfileCredential;
|
|
}
|
|
|
|
function buildGeminiCliAuthSettings(
|
|
selectedType: GeminiCliAuthSelectedType,
|
|
): Record<string, unknown> {
|
|
return { security: { auth: { selectedType } } };
|
|
}
|
|
|
|
async function buildGeminiCliSystemSettings(
|
|
ctx: GeminiCliAuthHomeContext,
|
|
selectedType?: string,
|
|
ambientSafeSettings: Record<string, unknown> = {},
|
|
): Promise<Record<string, unknown>> {
|
|
const base = await readGeminiCliJsonObject(ctx.systemSettingsPath);
|
|
const ambientPrivacy = isRecord(ambientSafeSettings.privacy)
|
|
? ambientSafeSettings.privacy
|
|
: undefined;
|
|
const basePrivacy = isRecord(base.privacy) ? base.privacy : undefined;
|
|
const ambientTelemetry = isRecord(ambientSafeSettings.telemetry)
|
|
? ambientSafeSettings.telemetry
|
|
: undefined;
|
|
const baseTelemetry = isRecord(base.telemetry) ? base.telemetry : undefined;
|
|
let settings: Record<string, unknown> = {
|
|
...ambientSafeSettings,
|
|
...base,
|
|
...(ambientPrivacy || basePrivacy ? { privacy: { ...ambientPrivacy, ...basePrivacy } } : {}),
|
|
...(ambientTelemetry || baseTelemetry
|
|
? { telemetry: { ...ambientTelemetry, ...baseTelemetry } }
|
|
: {}),
|
|
};
|
|
if (selectedType) {
|
|
const security = isRecord(settings.security) ? { ...settings.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 };
|
|
settings = { ...settings, security };
|
|
}
|
|
const restricted = ctx.toolAvailability
|
|
? applyGeminiCliToolAvailability(settings, ctx.toolAvailability)
|
|
: settings;
|
|
return applyGeminiCliIsolatedCompletionSettings(restricted, ctx);
|
|
}
|
|
|
|
function applyGeminiCliIsolatedCompletionSettings(
|
|
base: Record<string, unknown>,
|
|
ctx: GeminiCliAuthHomeContext,
|
|
): Record<string, unknown> {
|
|
if (ctx.isolatedCompletionSystemPrompt === undefined) {
|
|
return base;
|
|
}
|
|
const modelId = normalizeString(ctx.isolatedCompletionModelId);
|
|
if (!modelId || modelId === "auto" || modelId.startsWith("auto-")) {
|
|
throw isolatedCompletionInputError(
|
|
"Gemini isolated completion requires one concrete model id.",
|
|
);
|
|
}
|
|
const policy = {
|
|
model: modelId,
|
|
isLastResort: true,
|
|
actions: {
|
|
terminal: "prompt",
|
|
transient: "prompt",
|
|
not_found: "prompt",
|
|
unknown: "prompt",
|
|
},
|
|
stateTransitions: {
|
|
terminal: "terminal",
|
|
transient: "terminal",
|
|
not_found: "terminal",
|
|
unknown: "terminal",
|
|
},
|
|
};
|
|
const general = isRecord(base.general) ? { ...base.general } : {};
|
|
const experimental = isRecord(base.experimental) ? { ...base.experimental } : {};
|
|
const telemetry = isRecord(base.telemetry) ? { ...base.telemetry } : {};
|
|
const exactModelResolution = { default: modelId };
|
|
return {
|
|
...base,
|
|
general: { ...general, maxAttempts: 1, retryFetchErrors: false },
|
|
experimental: {
|
|
...experimental,
|
|
dynamicModelConfiguration: true,
|
|
gemmaModelRouter: { enabled: false },
|
|
},
|
|
modelConfigs: {
|
|
// Gemini CLI model aliases, overrides, and ID resolutions can all change
|
|
// the actual model. Replace that routing surface so authorization and
|
|
// reporting stay bound to the requested concrete model.
|
|
aliases: {},
|
|
customAliases: {},
|
|
overrides: [],
|
|
customOverrides: [],
|
|
modelIdResolutions: { [modelId]: exactModelResolution },
|
|
classifierIdResolutions: {
|
|
flash: exactModelResolution,
|
|
pro: exactModelResolution,
|
|
},
|
|
modelChains: {
|
|
preview: [policy],
|
|
default: [policy],
|
|
lite: [policy],
|
|
[modelId]: [policy],
|
|
},
|
|
},
|
|
// The CLI otherwise discovers GEMINI.md in cwd parents and configured
|
|
// include directories, which would violate prompt-only isolation.
|
|
context: {
|
|
includeDirectoryTree: false,
|
|
discoveryMaxDirs: 1,
|
|
memoryBoundaryMarkers: [],
|
|
includeDirectories: [],
|
|
loadMemoryFromIncludeDirectories: false,
|
|
},
|
|
telemetry: { ...telemetry, logPrompts: false },
|
|
};
|
|
}
|
|
|
|
function applyGeminiCliToolAvailability(
|
|
base: Record<string, unknown>,
|
|
availability: CliBackendToolAvailability,
|
|
): Record<string, unknown> {
|
|
if (availability.native.length > 0) {
|
|
throw new Error("Gemini CLI cannot expose backend-native tools in an exact restricted run.");
|
|
}
|
|
const mcpServers = isRecord(base.mcpServers) ? { ...base.mcpServers } : {};
|
|
// A fully empty cap must not require the loopback server: tool-free handoffs
|
|
// intentionally suppress that runtime before backend preparation.
|
|
const exposesOpenClawTools = availability.openClaw.length > 0;
|
|
let restrictedMcpServers: Record<string, unknown> = {};
|
|
if (exposesOpenClawTools) {
|
|
const openClawMcpServer = mcpServers.openclaw;
|
|
if (!isRecord(openClawMcpServer)) {
|
|
throw new Error("Gemini CLI exact tool availability requires the OpenClaw MCP server.");
|
|
}
|
|
restrictedMcpServers = {
|
|
openclaw: {
|
|
...openClawMcpServer,
|
|
includeTools: [...availability.openClaw],
|
|
},
|
|
};
|
|
}
|
|
const tools = isRecord(base.tools) ? { ...base.tools } : {};
|
|
// `tools.allowed` has higher policy priority than the `tools.core` default
|
|
// deny. Drop it so inherited system settings cannot widen this exact run.
|
|
const {
|
|
allowed: _allowedTools,
|
|
core: _coreTools,
|
|
discoveryCommand: _discoveryCommand,
|
|
callCommand: _callCommand,
|
|
...nonAuthorityToolSettings
|
|
} = tools;
|
|
const mcp = isRecord(base.mcp) ? { ...base.mcp } : {};
|
|
const { serverCommand: _serverCommand, ...nonAuthorityMcpSettings } = mcp;
|
|
// Gemini treats an empty MCP allowlist as unrestricted. Use a per-run name
|
|
// that no inherited server can know when this run must expose no MCP tools.
|
|
const allowedMcpServers = exposesOpenClawTools ? ["openclaw"] : [crypto.randomUUID()];
|
|
const experimental = isRecord(base.experimental) ? { ...base.experimental } : {};
|
|
const agents = isRecord(base.agents) ? { ...base.agents } : {};
|
|
const agentOverrides = isRecord(agents.overrides) ? { ...agents.overrides } : {};
|
|
const hooksConfig = isRecord(base.hooksConfig) ? { ...base.hooksConfig } : {};
|
|
const skills = isRecord(base.skills) ? { ...base.skills } : {};
|
|
return {
|
|
...base,
|
|
tools: {
|
|
...nonAuthorityToolSettings,
|
|
core: exposesOpenClawTools ? ["mcp_openclaw_*"] : [],
|
|
discoveryCommand: "",
|
|
callCommand: "",
|
|
},
|
|
mcp: {
|
|
...nonAuthorityMcpSettings,
|
|
allowed: allowedMcpServers,
|
|
serverCommand: "",
|
|
},
|
|
mcpServers: restrictedMcpServers,
|
|
experimental: { ...experimental, enableAgents: false },
|
|
agents: {
|
|
...agents,
|
|
overrides: {
|
|
...agentOverrides,
|
|
codebase_investigator: {
|
|
...(isRecord(agentOverrides.codebase_investigator)
|
|
? agentOverrides.codebase_investigator
|
|
: {}),
|
|
enabled: false,
|
|
},
|
|
cli_help: {
|
|
...(isRecord(agentOverrides.cli_help) ? agentOverrides.cli_help : {}),
|
|
enabled: false,
|
|
},
|
|
},
|
|
},
|
|
hooksConfig: { ...hooksConfig, enabled: false },
|
|
skills: { ...skills, enabled: false },
|
|
};
|
|
}
|
|
|
|
async function writeGeminiCliJson(filePath: string, value: unknown): Promise<void> {
|
|
await writeGeminiCliPrivateFile(filePath, `${JSON.stringify(value, null, 2)}\n`);
|
|
}
|
|
|
|
async function createGeminiCliPrivateTempDir(prefix: string): Promise<string> {
|
|
const directory = await fs.mkdtemp(path.join(resolvePreferredOpenClawTmpDir(), prefix));
|
|
try {
|
|
await fs.chmod(directory, 0o700);
|
|
return directory;
|
|
} catch (error) {
|
|
// Preparation has no cleanup callback yet, so remove a partially secured
|
|
// directory here rather than leaking it when chmod fails.
|
|
await fs.rm(directory, { recursive: true, force: true }).catch(() => undefined);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function writeGeminiCliPrivateFile(filePath: string, value: string): Promise<void> {
|
|
const tempPath = path.join(
|
|
path.dirname(filePath),
|
|
`.${path.basename(filePath)}.${process.pid}.${crypto.randomUUID()}.tmp`,
|
|
);
|
|
await fs.writeFile(tempPath, value, {
|
|
encoding: "utf8",
|
|
mode: 0o600,
|
|
});
|
|
await fs.chmod(tempPath, 0o600);
|
|
await fs.rename(tempPath, filePath);
|
|
await fs.chmod(filePath, 0o600);
|
|
}
|
|
|
|
async function stageGeminiCliIsolatedCwd(ctx: GeminiCliAuthHomeContext): Promise<void> {
|
|
const cwd = normalizeString(ctx.isolatedCompletionCwd);
|
|
if (!cwd) {
|
|
return;
|
|
}
|
|
// Gemini stops at the first dotenv it finds. The empty file prevents parent
|
|
// temp directories from changing the already validated child environment.
|
|
await writeGeminiCliPrivateFile(path.join(cwd, ".env"), "");
|
|
}
|
|
|
|
async function prepareGeminiCliProfileHome(
|
|
ctx: GeminiCliAuthHomeContext,
|
|
selectedType: GeminiCliAuthSelectedType,
|
|
): Promise<{
|
|
home: string;
|
|
geminiDir: string;
|
|
systemSettingsPath: string;
|
|
isolatedSystemPromptPath?: string;
|
|
beforeExecution: () => Promise<void>;
|
|
cleanup: () => Promise<void>;
|
|
}> {
|
|
const settings = buildGeminiCliAuthSettings(selectedType);
|
|
const systemSettings = await buildGeminiCliSystemSettings(ctx, selectedType);
|
|
const isolated = ctx.isolatedCompletionSystemPrompt !== undefined;
|
|
const exactToolAvailability = ctx.toolAvailability !== undefined;
|
|
// Validate persistent profile ownership before allocating per-run state. A
|
|
// validation failure cannot return the cleanup callback below.
|
|
const persistentProfileHome =
|
|
isolated || exactToolAvailability ? undefined : resolveGeminiCliProfileHome(ctx);
|
|
const systemSettingsDir = await createGeminiCliPrivateTempDir("openclaw-gemini-cli-");
|
|
const { home, geminiDir } = persistentProfileHome ?? {
|
|
home: path.join(systemSettingsDir, "home"),
|
|
geminiDir: path.join(systemSettingsDir, "home", ".gemini"),
|
|
};
|
|
const systemSettingsPath = path.join(systemSettingsDir, "settings.json");
|
|
const isolatedSystemPrompt = ctx.isolatedCompletionSystemPrompt;
|
|
const isolatedSystemPromptPath = isolated ? path.join(systemSettingsDir, "system.md") : undefined;
|
|
return {
|
|
home,
|
|
geminiDir,
|
|
systemSettingsPath,
|
|
...(isolatedSystemPromptPath ? { isolatedSystemPromptPath } : {}),
|
|
beforeExecution: async () => {
|
|
await stageGeminiCliIsolatedCwd(ctx);
|
|
await fs.mkdir(geminiDir, { recursive: true, mode: 0o700 });
|
|
await fs.chmod(home, 0o700);
|
|
await fs.chmod(geminiDir, 0o700);
|
|
await Promise.all([
|
|
writeGeminiCliJson(path.join(geminiDir, "settings.json"), settings),
|
|
writeGeminiCliJson(path.join(home, "settings.json"), settings),
|
|
writeGeminiCliJson(systemSettingsPath, systemSettings),
|
|
...(isolatedSystemPromptPath && isolatedSystemPrompt !== undefined
|
|
? [writeGeminiCliPrivateFile(isolatedSystemPromptPath, isolatedSystemPrompt)]
|
|
: []),
|
|
]);
|
|
},
|
|
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<GeminiCliPreparedExecution | null> {
|
|
const oauth = requireGeminiOAuthCredential(credential);
|
|
if (!oauth) {
|
|
return null;
|
|
}
|
|
if (ctx.toolAvailability !== undefined) {
|
|
const message =
|
|
"Gemini CLI exact tool availability does not support OAuth; Code Assist auth can inject administrator-required tools.";
|
|
throw ctx.isolatedCompletionSystemPrompt === undefined
|
|
? new Error(message)
|
|
: isolatedCompletionUnsupportedError(message);
|
|
}
|
|
|
|
const profileHome = await prepareGeminiCliProfileHome(ctx, "oauth-personal");
|
|
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;
|
|
}
|
|
|
|
return {
|
|
env: {
|
|
GEMINI_CLI_HOME: profileHome.home,
|
|
GEMINI_CLI_SYSTEM_SETTINGS_PATH: profileHome.systemSettingsPath,
|
|
GEMINI_FORCE_FILE_STORAGE: "true",
|
|
...buildGeminiCliProjectEnv(oauth.projectId),
|
|
...(profileHome.isolatedSystemPromptPath
|
|
? { GEMINI_SYSTEM_MD: profileHome.isolatedSystemPromptPath }
|
|
: {}),
|
|
...(profileHome.isolatedSystemPromptPath ? { GEMINI_TELEMETRY_LOG_PROMPTS: "false" } : {}),
|
|
},
|
|
clearEnv: [
|
|
...GEMINI_CLI_PROFILE_AUTH_ENV,
|
|
...GEMINI_CLI_PROFILE_SETTINGS_ENV,
|
|
...(profileHome.isolatedSystemPromptPath
|
|
? ["GEMINI_SYSTEM_MD", "GEMINI_CLI_HOME", "GEMINI_TELEMETRY_LOG_PROMPTS"]
|
|
: []),
|
|
],
|
|
beforeExecution: async () => {
|
|
await profileHome.beforeExecution();
|
|
await clearGeminiCliCachedCredentials(profileHome.geminiDir);
|
|
await writeGeminiCliJson(path.join(profileHome.geminiDir, "oauth_creds.json"), oauthCreds);
|
|
},
|
|
cleanup: profileHome.cleanup,
|
|
};
|
|
}
|
|
|
|
async function prepareGeminiCliApiKeyHome(
|
|
ctx: GeminiCliAuthHomeContext,
|
|
credential: GeminiAuthProfileCredential | undefined,
|
|
): Promise<GeminiCliPreparedExecution | null> {
|
|
const apiKey = requireGeminiApiKeyCredential(credential);
|
|
if (!apiKey) {
|
|
return null;
|
|
}
|
|
const isolatedCompletionEnforced = assertGeminiCliLiteralIsolatedPrompt(ctx);
|
|
const exactToolAvailability = ctx.toolAvailability !== undefined;
|
|
|
|
const restrictedTransportEnv = exactToolAvailability
|
|
? await resolveGeminiCliTrustedTransportEnv(ctx)
|
|
: undefined;
|
|
const profileHome = await prepareGeminiCliProfileHome(ctx, "gemini-api-key");
|
|
return {
|
|
env: {
|
|
GEMINI_CLI_HOME: profileHome.home,
|
|
GEMINI_CLI_SYSTEM_SETTINGS_PATH: profileHome.systemSettingsPath,
|
|
GEMINI_FORCE_FILE_STORAGE: "true",
|
|
GEMINI_API_KEY: apiKey.key,
|
|
...(exactToolAvailability ? GEMINI_CLI_EXACT_TOOL_ENV_BARRIERS : {}),
|
|
...restrictedTransportEnv,
|
|
...(profileHome.isolatedSystemPromptPath
|
|
? { GEMINI_SYSTEM_MD: profileHome.isolatedSystemPromptPath }
|
|
: {}),
|
|
...(profileHome.isolatedSystemPromptPath ? { GEMINI_TELEMETRY_LOG_PROMPTS: "false" } : {}),
|
|
},
|
|
clearEnv: [
|
|
...GEMINI_CLI_PROFILE_AUTH_ENV,
|
|
...GEMINI_CLI_PROFILE_SETTINGS_ENV,
|
|
...(exactToolAvailability ? ["GEMINI_CLI_HOME"] : []),
|
|
...(profileHome.isolatedSystemPromptPath
|
|
? ["GEMINI_SYSTEM_MD", "GEMINI_TELEMETRY_LOG_PROMPTS"]
|
|
: []),
|
|
...(exactToolAvailability ? Object.keys(GEMINI_CLI_EXACT_TOOL_ENV_BARRIERS) : []),
|
|
...Object.keys(restrictedTransportEnv ?? {}),
|
|
],
|
|
beforeExecution: async () => {
|
|
await profileHome.beforeExecution();
|
|
await Promise.all([
|
|
fs.rm(path.join(profileHome.geminiDir, "oauth_creds.json"), { force: true }),
|
|
clearGeminiCliCachedCredentials(profileHome.geminiDir),
|
|
]);
|
|
},
|
|
cleanup: profileHome.cleanup,
|
|
...(isolatedCompletionEnforced ? { isolatedCompletionEnforced: true as const } : {}),
|
|
};
|
|
}
|
|
|
|
async function prepareGeminiCliRestrictedSystemSettings(
|
|
ctx: GeminiCliAuthHomeContext,
|
|
): Promise<GeminiCliPreparedExecution> {
|
|
const isolated = ctx.isolatedCompletionSystemPrompt !== undefined;
|
|
const isolatedCompletionEnforced = assertGeminiCliLiteralIsolatedPrompt(ctx);
|
|
const ambientAuth = await resolveGeminiCliAmbientAuth(ctx);
|
|
const settings = await buildGeminiCliSystemSettings(
|
|
ctx,
|
|
ambientAuth.selectedType,
|
|
ambientAuth.safeSettings,
|
|
);
|
|
const systemSettingsDir = await createGeminiCliPrivateTempDir("openclaw-gemini-cli-policy-");
|
|
const systemSettingsPath = path.join(systemSettingsDir, "settings.json");
|
|
const isolatedSystemPrompt = ctx.isolatedCompletionSystemPrompt;
|
|
const isolatedSystemPromptPath = isolated ? path.join(systemSettingsDir, "system.md") : undefined;
|
|
const restrictedHome = path.join(systemSettingsDir, "home");
|
|
return {
|
|
env: {
|
|
GEMINI_CLI_SYSTEM_SETTINGS_PATH: systemSettingsPath,
|
|
GEMINI_CLI_HOME: restrictedHome,
|
|
...(isolatedSystemPromptPath ? { GEMINI_SYSTEM_MD: isolatedSystemPromptPath } : {}),
|
|
...(isolated ? { GEMINI_TELEMETRY_LOG_PROMPTS: "false" } : {}),
|
|
...ambientAuth.envOverrides,
|
|
},
|
|
clearEnv: [
|
|
// Exact-tool runs clear and restage the resolved auth into a private home.
|
|
// Otherwise installed extensions or user state could widen the tool set.
|
|
...GEMINI_CLI_PROFILE_AUTH_ENV,
|
|
...GEMINI_CLI_PROFILE_SETTINGS_ENV,
|
|
"GEMINI_CLI_HOME",
|
|
...(isolatedSystemPromptPath ? ["GEMINI_SYSTEM_MD", "GEMINI_TELEMETRY_LOG_PROMPTS"] : []),
|
|
...Object.keys(ambientAuth.envOverrides),
|
|
],
|
|
beforeExecution: async () => {
|
|
await stageGeminiCliIsolatedCwd(ctx);
|
|
await fs.mkdir(restrictedHome, { recursive: true, mode: 0o700 });
|
|
await fs.chmod(restrictedHome, 0o700);
|
|
await Promise.all([
|
|
writeGeminiCliJson(systemSettingsPath, settings),
|
|
...(isolatedSystemPromptPath && isolatedSystemPrompt !== undefined
|
|
? [writeGeminiCliPrivateFile(isolatedSystemPromptPath, isolatedSystemPrompt)]
|
|
: []),
|
|
]);
|
|
},
|
|
cleanup: async () => {
|
|
await fs.rm(systemSettingsDir, { recursive: true, force: true });
|
|
},
|
|
toolAvailabilityEnforced: true,
|
|
...(isolatedCompletionEnforced ? { isolatedCompletionEnforced: true as const } : {}),
|
|
};
|
|
}
|
|
|
|
export async function prepareGeminiCliExecution(
|
|
ctx: GeminiCliAuthHomeContext,
|
|
credential: unknown,
|
|
): Promise<GeminiCliPreparedExecution | null> {
|
|
const authCredential = readGeminiAuthProfileCredential(credential);
|
|
const prepared =
|
|
(await prepareGeminiCliOAuthHome(ctx, authCredential)) ??
|
|
(await prepareGeminiCliApiKeyHome(ctx, authCredential));
|
|
if (prepared) {
|
|
return ctx.toolAvailability ? { ...prepared, toolAvailabilityEnforced: true } : prepared;
|
|
}
|
|
if (normalizeString(ctx.authProfileId)) {
|
|
throwUnstageableSelectedGeminiProfile(ctx, authCredential);
|
|
}
|
|
return ctx.toolAvailability ? await prepareGeminiCliRestrictedSystemSettings(ctx) : null;
|
|
}
|