diff --git a/extensions/google/cli-backend.ts b/extensions/google/cli-backend.ts index 14c20e2ba25..2718827e1ca 100644 --- a/extensions/google/cli-backend.ts +++ b/extensions/google/cli-backend.ts @@ -78,8 +78,11 @@ type GeminiApiKeyCredential = GeminiAuthProfileCredential & { type GeminiCliAuthHomeContext = { agentDir?: string; authProfileId?: string; + systemSettingsPath?: string; }; +type GeminiCliAuthSelectedType = "oauth-personal" | "gemini-api-key"; + function throwUnsupportedGeminiCredential(credential: GeminiAuthProfileCredential): never { if (credential.provider === VERCEL_AI_GATEWAY_PROVIDER_ID) { throw new Error( @@ -195,6 +198,55 @@ function resolveGeminiCliProfileHome(ctx: GeminiCliAuthHomeContext): { return { home, geminiDir: path.join(home, ".gemini") }; } +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +async function readGeminiCliJsonObject( + filePath: string | undefined, +): Promise> { + 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 { + return { security: { auth: { selectedType } } }; +} + +async function buildGeminiCliSystemSettings( + ctx: GeminiCliAuthHomeContext, + selectedType: GeminiCliAuthSelectedType, +): Promise> { + const base = await readGeminiCliJsonObject(ctx.systemSettingsPath); + const security = isRecord(base.security) ? { ...base.security } : {}; + security.auth = { selectedType }; + return { + ...base, + security, + }; +} + async function writeGeminiCliJson(filePath: string, value: unknown): Promise { await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf8", @@ -205,20 +257,25 @@ async function writeGeminiCliJson(filePath: string, value: unknown): Promise { 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 systemSettingsPath = path.join(home, "system-settings.json"); await Promise.all([ writeGeminiCliJson(path.join(geminiDir, "settings.json"), settings), writeGeminiCliJson(path.join(home, "settings.json"), settings), + writeGeminiCliJson(systemSettingsPath, systemSettings), ]); - return { home, geminiDir }; + return { home, geminiDir, systemSettingsPath }; } async function clearGeminiCliCachedCredentials(geminiDir: string): Promise { @@ -248,9 +305,10 @@ async function prepareGeminiCliOAuthHome( return null; } - const { home, geminiDir } = await prepareGeminiCliProfileHome(ctx, { - security: { auth: { selectedType: "oauth-personal" } }, - }); + const { home, geminiDir, systemSettingsPath } = await prepareGeminiCliProfileHome( + ctx, + "oauth-personal", + ); await clearGeminiCliCachedCredentials(geminiDir); const idToken = normalizeString(oauth.idToken); const oauthCreds: Record = { @@ -268,6 +326,7 @@ async function prepareGeminiCliOAuthHome( return { env: { GEMINI_CLI_HOME: home, + GEMINI_CLI_SYSTEM_SETTINGS_PATH: systemSettingsPath, GEMINI_FORCE_FILE_STORAGE: "true", ...buildGeminiCliProjectEnv(oauth.projectId), }, @@ -284,9 +343,10 @@ async function prepareGeminiCliApiKeyHome( return null; } - const { home, geminiDir } = await prepareGeminiCliProfileHome(ctx, { - security: { auth: { selectedType: "gemini-api-key" } }, - }); + const { home, geminiDir, systemSettingsPath } = await prepareGeminiCliProfileHome( + ctx, + "gemini-api-key", + ); await Promise.all([ fs.rm(path.join(geminiDir, "oauth_creds.json"), { force: true }), clearGeminiCliCachedCredentials(geminiDir), @@ -294,6 +354,7 @@ async function prepareGeminiCliApiKeyHome( return { env: { GEMINI_CLI_HOME: home, + GEMINI_CLI_SYSTEM_SETTINGS_PATH: systemSettingsPath, GEMINI_FORCE_FILE_STORAGE: "true", GEMINI_API_KEY: apiKey.key, }, @@ -339,6 +400,8 @@ export function buildGoogleGeminiCliBackend(): CliBackendPlugin { { agentDir: ctx.agentDir, authProfileId: ctx.authProfileId, + systemSettingsPath: (ctx as typeof ctx & { env?: Record }).env + ?.GEMINI_CLI_SYSTEM_SETTINGS_PATH, }, (ctx as typeof ctx & { authCredential?: GeminiAuthProfileCredential }).authCredential, ), diff --git a/extensions/google/setup-api.test.ts b/extensions/google/setup-api.test.ts index e08890cd9f3..cfaa085c1b5 100644 --- a/extensions/google/setup-api.test.ts +++ b/extensions/google/setup-api.test.ts @@ -10,6 +10,7 @@ import setupEntry from "./setup-api.js"; type GeminiPrepareContext = Parameters< NonNullable["prepareExecution"]> >[0] & { + env?: Record; authCredential?: { type: "api_key" | "oauth" | "token"; provider: string; @@ -98,10 +99,24 @@ describe("google gemini cli backend auth bridge", () => { try { const context = buildGeminiOAuthPrepareContext(workspaceDir); + const inheritedSettingsPath = path.join(workspaceDir, "generated-mcp-settings.json"); + await fs.writeFile( + inheritedSettingsPath, + `${JSON.stringify({ + security: { auth: { selectedType: "vertex-ai" } }, + mcp: { allowed: ["openclaw"] }, + mcpServers: { openclaw: { url: "http://127.0.0.1:23119/mcp" } }, + })}\n`, + "utf8", + ); + context.env = { GEMINI_CLI_SYSTEM_SETTINGS_PATH: inheritedSettingsPath }; const prepared = await backend.prepareExecution?.(context); home = prepared?.env?.GEMINI_CLI_HOME; + const systemSettingsPath = prepared?.env?.GEMINI_CLI_SYSTEM_SETTINGS_PATH; expect(home).toBeTruthy(); + expect(systemSettingsPath).toBeTruthy(); + expect(systemSettingsPath).not.toBe(inheritedSettingsPath); expect(prepared?.env?.GEMINI_FORCE_FILE_STORAGE).toBe("true"); expect(prepared?.env?.GOOGLE_CLOUD_PROJECT).toBe("profile-project"); expect(prepared?.env?.GOOGLE_CLOUD_PROJECT_ID).toBe("profile-project"); @@ -126,6 +141,12 @@ describe("google gemini cli backend auth bridge", () => { security: { auth: { selectedType: "oauth-personal" } }, }); expect(JSON.parse(rootSettingsRaw)).toEqual(JSON.parse(nestedSettingsRaw)); + const systemSettingsRaw = await fs.readFile(systemSettingsPath ?? "", "utf8"); + expect(JSON.parse(systemSettingsRaw)).toEqual({ + security: { auth: { selectedType: "oauth-personal" } }, + mcp: { allowed: ["openclaw"] }, + mcpServers: { openclaw: { url: "http://127.0.0.1:23119/mcp" } }, + }); const sessionMarker = path.join(home ?? "", ".gemini", "session-state.json"); await fs.writeFile(sessionMarker, '{"keep":true}\n', "utf8"); diff --git a/src/agents/cli-runner/prepare.test.ts b/src/agents/cli-runner/prepare.test.ts index a70e8061df7..e6442c4f0ec 100644 --- a/src/agents/cli-runner/prepare.test.ts +++ b/src/agents/cli-runner/prepare.test.ts @@ -670,6 +670,85 @@ describe("shouldSkipLocalCliCredentialEpoch", () => { } }); + it("lets Gemini CLI preparation override generated MCP system settings auth", async () => { + const { dir, sessionFile } = createSessionFile(); + const profileSystemSettingsPath = path.join(dir, "profile-system-settings.json"); + const getActiveMcpLoopbackRuntime = vi.fn(() => ({ + port: 31783, + ownerToken: "loopback-owner-token", + nonOwnerToken: "loopback-non-owner-token", + })); + const prepareExecution = vi.fn(async () => ({ + env: { + GEMINI_CLI_SYSTEM_SETTINGS_PATH: profileSystemSettingsPath, + }, + })); + cliBackendsTesting.setDepsForTest({ + resolvePluginSetupCliBackend: () => undefined, + resolveRuntimeCliBackends: () => [ + { + id: "google-gemini-cli", + pluginId: "google", + bundleMcp: true, + bundleMcpMode: "gemini-system-settings", + prepareExecution, + config: { + command: "gemini", + args: ["--prompt", "{prompt}"], + output: "json", + input: "arg", + sessionMode: "existing", + }, + }, + ], + }); + setCliRunnerPrepareTestDeps({ + getActiveMcpLoopbackRuntime, + ensureMcpLoopbackServer: vi.fn(createTestMcpLoopbackServer), + createMcpLoopbackServerConfig: vi.fn(createTestMcpLoopbackServerConfig), + resolveMcpLoopbackBearerToken: vi.fn(() => "loopback-token"), + resolveMcpLoopbackScopedTools: vi.fn(() => ({ agentId: "main", tools: [] })), + }); + + let cleanup: (() => Promise) | undefined; + try { + const context = await prepareCliRunContext({ + sessionId: "session-test", + sessionKey: "agent:main:main", + sessionFile, + workspaceDir: dir, + prompt: "latest ask", + provider: "google-gemini-cli", + model: "gemini-3.1-pro-preview", + timeoutMs: 1_000, + runId: "run-test-gemini-mcp-system-settings", + config: {}, + }); + cleanup = context.preparedBackend.cleanup; + + const prepareExecutionArg = prepareExecution.mock.calls[0]?.[0] as + | { env?: Record } + | undefined; + const generatedSystemSettingsPath = prepareExecutionArg?.env?.GEMINI_CLI_SYSTEM_SETTINGS_PATH; + expect(typeof generatedSystemSettingsPath).toBe("string"); + expect(generatedSystemSettingsPath).not.toBe(profileSystemSettingsPath); + const generatedSettings = JSON.parse( + fs.readFileSync(generatedSystemSettingsPath ?? "", "utf8"), + ) as { + mcp?: { allowed?: string[] }; + mcpServers?: Record; + }; + expect(generatedSettings.mcp?.allowed).toEqual(["openclaw"]); + expect(generatedSettings.mcpServers?.openclaw?.url).toBe("http://127.0.0.1:31783/mcp"); + expect(context.preparedBackend.env?.GEMINI_CLI_SYSTEM_SETTINGS_PATH).toBe( + profileSystemSettingsPath, + ); + } finally { + await cleanup?.(); + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + it("prepares side questions without agent-turn context, tools, hooks, or reusable sessions", async () => { const { dir, sessionFile } = createSessionFile(); appendTranscriptEntry(sessionFile, { diff --git a/src/agents/cli-runner/prepare.ts b/src/agents/cli-runner/prepare.ts index 0287aa3bad3..0e71ab752a9 100644 --- a/src/agents/cli-runner/prepare.ts +++ b/src/agents/cli-runner/prepare.ts @@ -474,6 +474,7 @@ export async function prepareCliRunContext( modelId, authProfileId: effectiveAuthProfileId, executionMode, + env: preparedBackend.env, } as Parameters>[0]; const preparedExecution = await backendResolved.prepareExecution?.( (backendResolved.id === "google-gemini-cli"