From a4318f22ece9c8fbcbafb87e4ec0a84f71b11d2e Mon Sep 17 00:00:00 2001 From: Adib234 <30782825+Adib234@users.noreply.github.com> Date: Mon, 13 Apr 2026 15:26:52 -0400 Subject: [PATCH 1/3] fix(core): expose GEMINI_PLANS_DIR to hook environment (#25296) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- docs/cli/plan-mode.md | 8 +++-- docs/hooks/index.md | 1 + packages/core/src/hooks/hookRunner.test.ts | 42 ++++++++++++++++++++++ packages/core/src/hooks/hookRunner.ts | 12 +++++++ 4 files changed, 61 insertions(+), 2 deletions(-) diff --git a/docs/cli/plan-mode.md b/docs/cli/plan-mode.md index f5532a07ca..4d9c45ce17 100644 --- a/docs/cli/plan-mode.md +++ b/docs/cli/plan-mode.md @@ -327,8 +327,12 @@ Storage whenever Gemini CLI exits Plan Mode to start the implementation. ```bash #!/usr/bin/env bash -# Extract the plan path from the tool input JSON -plan_path=$(jq -r '.tool_input.plan_path // empty') +# Extract the plan filename from the tool input JSON +plan_filename=$(jq -r '.tool_input.plan_filename // empty') +plan_filename=$(basename -- "$plan_filename") + +# Construct the absolute path using the GEMINI_PLANS_DIR environment variable +plan_path="$GEMINI_PLANS_DIR/$plan_filename" if [ -f "$plan_path" ]; then # Generate a unique filename using a timestamp diff --git a/docs/hooks/index.md b/docs/hooks/index.md index 0d6ae6d447..0125a28eb2 100644 --- a/docs/hooks/index.md +++ b/docs/hooks/index.md @@ -138,6 +138,7 @@ multiple layers in the following order of precedence (highest to lowest): Hooks are executed with a sanitized environment. - `GEMINI_PROJECT_DIR`: The absolute path to the project root. +- `GEMINI_PLANS_DIR`: The absolute path to the plans directory. - `GEMINI_SESSION_ID`: The unique ID for the current session. - `GEMINI_CWD`: The current working directory. - `CLAUDE_PROJECT_DIR`: (Alias) Provided for compatibility. diff --git a/packages/core/src/hooks/hookRunner.test.ts b/packages/core/src/hooks/hookRunner.test.ts index 9cee6575fe..5b155a8516 100644 --- a/packages/core/src/hooks/hookRunner.test.ts +++ b/packages/core/src/hooks/hookRunner.test.ts @@ -76,6 +76,9 @@ describe('HookRunner', () => { sanitizationConfig: { enableEnvironmentVariableRedaction: true, }, + storage: { + getPlansDir: vi.fn().mockReturnValue('/test/project/plans'), + }, } as unknown as Config; hookRunner = new HookRunner(mockConfig); @@ -370,12 +373,51 @@ describe('HookRunner', () => { shell: false, env: expect.objectContaining({ GEMINI_PROJECT_DIR: '/test/project', + GEMINI_PLANS_DIR: '/test/project/plans', + GEMINI_CWD: '/test/project', + GEMINI_SESSION_ID: 'test-session', CLAUDE_PROJECT_DIR: '/test/project', }), }), ); }); + it('should expand and escape GEMINI_PLANS_DIR in commands', async () => { + const configWithEnvVar: HookConfig = { + type: HookType.Command, + command: 'ls $GEMINI_PLANS_DIR', + }; + + // Change plans dir to one with spaces + vi.mocked(mockConfig.storage.getPlansDir).mockReturnValue( + '/test/project/plans with spaces', + ); + + mockSpawn.mockProcessOn.mockImplementation( + (event: string, callback: (code: number) => void) => { + if (event === 'close') { + setImmediate(() => callback(0)); + } + }, + ); + + await hookRunner.executeHook( + configWithEnvVar, + HookEventName.BeforeTool, + mockInput, + ); + + expect(spawn).toHaveBeenCalledWith( + expect.stringMatching(/bash|powershell/), + expect.arrayContaining([ + expect.stringMatching( + /ls ['"]\/test\/project\/plans with spaces['"]/, + ), + ]), + expect.any(Object), + ); + }); + it('should not allow command injection via GEMINI_PROJECT_DIR', async () => { const maliciousCwd = '/test/project; echo "pwned" > /tmp/pwned'; const mockMaliciousInput: HookInput = { diff --git a/packages/core/src/hooks/hookRunner.ts b/packages/core/src/hooks/hookRunner.ts index 812deafcbe..4c199ebfc7 100644 --- a/packages/core/src/hooks/hookRunner.ts +++ b/packages/core/src/hooks/hookRunner.ts @@ -348,6 +348,9 @@ export class HookRunner { const env = { ...sanitizeEnvironment(process.env, this.config.sanitizationConfig), GEMINI_PROJECT_DIR: input.cwd, + GEMINI_PLANS_DIR: this.config.storage.getPlansDir(), + GEMINI_CWD: input.cwd, + GEMINI_SESSION_ID: input.session_id, CLAUDE_PROJECT_DIR: input.cwd, // For compatibility ...hookConfig.env, }; @@ -514,8 +517,17 @@ export class HookRunner { ): string { debugLogger.debug(`Expanding hook command: ${command} (cwd: ${input.cwd})`); const escapedCwd = escapeShellArg(input.cwd, shellType); + const escapedPlansDir = escapeShellArg( + this.config.storage.getPlansDir(), + shellType, + ); + const escapedSessionId = escapeShellArg(input.session_id, shellType); + return command .replace(/\$GEMINI_PROJECT_DIR/g, () => escapedCwd) + .replace(/\$GEMINI_CWD/g, () => escapedCwd) + .replace(/\$GEMINI_PLANS_DIR/g, () => escapedPlansDir) + .replace(/\$GEMINI_SESSION_ID/g, () => escapedSessionId) .replace(/\$CLAUDE_PROJECT_DIR/g, () => escapedCwd); // For compatibility } From a172b328e251ed3b275ea62adcfee1775d1fbcae Mon Sep 17 00:00:00 2001 From: Tanmay Vartak <9002434+TanmayVartak@users.noreply.github.com> Date: Mon, 13 Apr 2026 12:41:40 -0700 Subject: [PATCH 2/3] feat: support auth block in MCP servers config in agents (#24770) --- packages/core/src/agents/agentLoader.test.ts | 36 ++++++++++++ packages/core/src/agents/agentLoader.ts | 58 ++++++++++++++++++-- 2 files changed, 89 insertions(+), 5 deletions(-) diff --git a/packages/core/src/agents/agentLoader.test.ts b/packages/core/src/agents/agentLoader.test.ts index ca2b2be78b..f80a1e8cb3 100644 --- a/packages/core/src/agents/agentLoader.test.ts +++ b/packages/core/src/agents/agentLoader.test.ts @@ -493,6 +493,42 @@ Body`); }); }); + it('should convert mcp_servers with auth block in local agent (google-credentials)', () => { + const markdown = { + kind: 'local' as const, + name: 'spanner-test-agent', + description: 'An agent to test Spanner MCP with auth', + mcp_servers: { + spanner: { + url: 'https://spanner.googleapis.com/mcp', + type: 'http' as const, + auth: { + type: 'google-credentials' as const, + scopes: ['https://www.googleapis.com/auth/cloud-platform'], + }, + timeout: 30000, + }, + }, + system_prompt: 'You are a Spanner test agent.', + }; + + const result = markdownToAgentDefinition( + markdown, + ) as LocalAgentDefinition; + expect(result.kind).toBe('local'); + expect(result.mcpServers).toBeDefined(); + expect(result.mcpServers!['spanner']).toMatchObject({ + url: 'https://spanner.googleapis.com/mcp', + type: 'http', + authProviderType: 'google_credentials', + oauth: { + enabled: true, + scopes: ['https://www.googleapis.com/auth/cloud-platform'], + }, + timeout: 30000, + }); + }); + it('should pass through unknown model names (e.g. auto)', () => { const markdown = { kind: 'local' as const, diff --git a/packages/core/src/agents/agentLoader.ts b/packages/core/src/agents/agentLoader.ts index d34d0e974e..4c5e771af9 100644 --- a/packages/core/src/agents/agentLoader.ts +++ b/packages/core/src/agents/agentLoader.ts @@ -17,7 +17,11 @@ import { DEFAULT_MAX_TIME_MINUTES, } from './types.js'; import type { A2AAuthConfig } from './auth-provider/types.js'; -import { MCPServerConfig } from '../config/config.js'; +import { + MCPServerConfig, + AuthProviderType, + type MCPOAuthConfig, +} from '../config/config.js'; import { isValidToolName } from '../tools/tool-names.js'; import { FRONTMATTER_REGEX } from '../skills/skillLoader.js'; import { getErrorMessage } from '../utils/errors.js'; @@ -62,6 +66,22 @@ const mcpServerSchema = z.object({ description: z.string().optional(), include_tools: z.array(z.string()).optional(), exclude_tools: z.array(z.string()).optional(), + auth: z + .union([ + z.object({ + type: z.literal('google-credentials'), + scopes: z.array(z.string()).optional(), + }), + z.object({ + type: z.literal('oauth'), + client_id: z.string().optional(), + client_secret: z.string().optional(), + scopes: z.array(z.string()).optional(), + authorization_url: z.string().url().optional(), + token_url: z.string().url().optional(), + }), + ]) + .optional(), }); const localAgentSchema = z @@ -74,9 +94,12 @@ const localAgentSchema = z .array( z .string() - .refine((val) => isValidToolName(val, { allowWildcards: true }), { - message: 'Invalid tool name', - }), + .refine( + (val: string) => isValidToolName(val, { allowWildcards: true }), + { + message: 'Invalid tool name', + }, + ), ) .optional(), mcp_servers: z.record(mcpServerSchema).optional(), @@ -191,7 +214,7 @@ const remoteAgentJsonSchema = baseRemoteAgentSchema .extend({ agent_card_url: z.undefined().optional(), agent_card_json: z.string().refine( - (val) => { + (val: string) => { try { JSON.parse(val); return true; @@ -511,6 +534,28 @@ export function markdownToAgentDefinition( const mcpServers: Record = {}; if (markdown.mcp_servers) { for (const [name, config] of Object.entries(markdown.mcp_servers)) { + let authProviderType: AuthProviderType | undefined = undefined; + let oauth: MCPOAuthConfig | undefined = undefined; + + if (config.auth) { + if (config.auth.type === 'google-credentials') { + authProviderType = AuthProviderType.GOOGLE_CREDENTIALS; + oauth = { + enabled: true, + scopes: config.auth.scopes, + }; + } else if (config.auth.type === 'oauth') { + oauth = { + enabled: true, + clientId: config.auth.client_id, + clientSecret: config.auth.client_secret, + scopes: config.auth.scopes, + authorizationUrl: config.auth.authorization_url, + tokenUrl: config.auth.token_url, + }; + } + } + mcpServers[name] = new MCPServerConfig( config.command, config.args, @@ -526,6 +571,9 @@ export function markdownToAgentDefinition( config.description, config.include_tools, config.exclude_tools, + undefined, // extension + oauth, + authProviderType, ); } } From 050c30330e2bb71558f51b2a4482d8970a7a84cb Mon Sep 17 00:00:00 2001 From: Jerop Kipruto Date: Mon, 13 Apr 2026 15:59:24 -0400 Subject: [PATCH 3/3] feat(core): implement silent fallback for Plan Mode model routing (#25317) --- docs/cli/plan-mode.md | 4 + .../core/src/availability/policyCatalog.ts | 2 +- .../src/availability/policyHelpers.test.ts | 15 +++- .../core/src/availability/policyHelpers.ts | 88 +++++++++++-------- 4 files changed, 69 insertions(+), 40 deletions(-) diff --git a/docs/cli/plan-mode.md b/docs/cli/plan-mode.md index 4d9c45ce17..00677943ad 100644 --- a/docs/cli/plan-mode.md +++ b/docs/cli/plan-mode.md @@ -445,6 +445,10 @@ on the current phase of your task: switches to a high-speed **Flash** model. This provides a faster, more responsive experience during the implementation of the plan. +If the high-reasoning model is unavailable or you don't have access to it, +Gemini CLI automatically and silently falls back to a faster model to ensure +your workflow isn't interrupted. + This behavior is enabled by default to provide the best balance of quality and performance. You can disable this automatic switching in your settings: diff --git a/packages/core/src/availability/policyCatalog.ts b/packages/core/src/availability/policyCatalog.ts index 588d9a298d..4ae8aeea7f 100644 --- a/packages/core/src/availability/policyCatalog.ts +++ b/packages/core/src/availability/policyCatalog.ts @@ -41,7 +41,7 @@ const DEFAULT_ACTIONS: ModelPolicyActionMap = { unknown: 'prompt', }; -const SILENT_ACTIONS: ModelPolicyActionMap = { +export const SILENT_ACTIONS: ModelPolicyActionMap = { terminal: 'silent', transient: 'silent', not_found: 'silent', diff --git a/packages/core/src/availability/policyHelpers.test.ts b/packages/core/src/availability/policyHelpers.test.ts index 7035fa9ed9..42344f9bb9 100644 --- a/packages/core/src/availability/policyHelpers.test.ts +++ b/packages/core/src/availability/policyHelpers.test.ts @@ -10,7 +10,7 @@ import { buildFallbackPolicyContext, applyModelSelection, } from './policyHelpers.js'; -import { createDefaultPolicy } from './policyCatalog.js'; +import { createDefaultPolicy, SILENT_ACTIONS } from './policyCatalog.js'; import type { Config } from '../config/config.js'; import { DEFAULT_GEMINI_FLASH_LITE_MODEL, @@ -21,6 +21,7 @@ import { import { AuthType } from '../core/contentGenerator.js'; import { ModelConfigService } from '../services/modelConfigService.js'; import { DEFAULT_MODEL_CONFIGS } from '../config/defaultModelConfigs.js'; +import { ApprovalMode } from '../policy/types.js'; const createMockConfig = (overrides: Partial = {}): Config => { const config = { @@ -164,6 +165,18 @@ describe('policyHelpers', () => { expect(chain[0]?.model).toBe(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL); expect(chain[1]?.model).toBe('gemini-3-flash-preview'); }); + + it('applies SILENT_ACTIONS when ApprovalMode is PLAN', () => { + const config = createMockConfig({ + getApprovalMode: () => ApprovalMode.PLAN, + getModel: () => DEFAULT_GEMINI_MODEL_AUTO, + }); + const chain = resolvePolicyChain(config); + + expect(chain).toHaveLength(2); + expect(chain[0]?.actions).toEqual(SILENT_ACTIONS); + expect(chain[1]?.actions).toEqual(SILENT_ACTIONS); + }); }); describe('resolvePolicyChain behavior is identical between dynamic and legacy implementations', () => { diff --git a/packages/core/src/availability/policyHelpers.ts b/packages/core/src/availability/policyHelpers.ts index 2581a07e28..033443ad5c 100644 --- a/packages/core/src/availability/policyHelpers.ts +++ b/packages/core/src/availability/policyHelpers.ts @@ -18,6 +18,7 @@ import { createSingleModelChain, getModelPolicyChain, getFlashLitePolicyChain, + SILENT_ACTIONS, } from './policyCatalog.js'; import { DEFAULT_GEMINI_FLASH_LITE_MODEL, @@ -29,6 +30,7 @@ import { } from '../config/models.js'; import type { ModelSelectionResult } from './modelAvailabilityService.js'; import type { ModelConfigKey } from '../services/modelConfigService.js'; +import { ApprovalMode } from '../policy/types.js'; /** * Resolves the active policy chain for the given config, ensuring the @@ -43,7 +45,7 @@ export function resolvePolicyChain( preferredModel ?? config.getActiveModel?.() ?? config.getModel(); const configuredModel = config.getModel(); - let chain; + let chain: ModelPolicyChain | undefined; const useGemini31 = config.getGemini31LaunchedSync?.() ?? false; const useGemini31FlashLite = config.getGemini31FlashLiteLaunchedSync?.() ?? false; @@ -103,45 +105,55 @@ export function resolvePolicyChain( // No matching modelChains found, default to single model chain chain = createSingleModelChain(modelFromConfig); } - return applyDynamicSlicing(chain, resolvedModel, wrapsAround); - } - - // --- LEGACY PATH --- - - if (resolvedModel === DEFAULT_GEMINI_FLASH_LITE_MODEL) { - chain = getFlashLitePolicyChain(); - } else if ( - isGemini3Model(resolvedModel, config) || - isAutoPreferred || - isAutoConfigured - ) { - if (hasAccessToPreview) { - const previewEnabled = - isGemini3Model(resolvedModel, config) || - preferredModel === PREVIEW_GEMINI_MODEL_AUTO || - configuredModel === PREVIEW_GEMINI_MODEL_AUTO; - chain = getModelPolicyChain({ - previewEnabled, - userTier: config.getUserTier(), - useGemini31, - useGemini31FlashLite, - useCustomToolModel, - }); - } else { - // User requested Gemini 3 but has no access. Proactively downgrade - // to the stable Gemini 2.5 chain. - chain = getModelPolicyChain({ - previewEnabled: false, - userTier: config.getUserTier(), - useGemini31, - useGemini31FlashLite, - useCustomToolModel, - }); - } + chain = applyDynamicSlicing(chain, resolvedModel, wrapsAround); } else { - chain = createSingleModelChain(modelFromConfig); + // --- LEGACY PATH --- + + if (resolvedModel === DEFAULT_GEMINI_FLASH_LITE_MODEL) { + chain = getFlashLitePolicyChain(); + } else if ( + isGemini3Model(resolvedModel, config) || + isAutoPreferred || + isAutoConfigured + ) { + if (hasAccessToPreview) { + const previewEnabled = + isGemini3Model(resolvedModel, config) || + preferredModel === PREVIEW_GEMINI_MODEL_AUTO || + configuredModel === PREVIEW_GEMINI_MODEL_AUTO; + chain = getModelPolicyChain({ + previewEnabled, + userTier: config.getUserTier(), + useGemini31, + useGemini31FlashLite, + useCustomToolModel, + }); + } else { + // User requested Gemini 3 but has no access. Proactively downgrade + // to the stable Gemini 2.5 chain. + chain = getModelPolicyChain({ + previewEnabled: false, + userTier: config.getUserTier(), + useGemini31, + useGemini31FlashLite, + useCustomToolModel, + }); + } + } else { + chain = createSingleModelChain(modelFromConfig); + } + chain = applyDynamicSlicing(chain, resolvedModel, wrapsAround); } - return applyDynamicSlicing(chain, resolvedModel, wrapsAround); + + // Apply Unified Silent Injection for Plan Mode with defensive checks + if (config?.getApprovalMode?.() === ApprovalMode.PLAN) { + return chain.map((policy) => ({ + ...policy, + actions: { ...SILENT_ACTIONS }, + })); + } + + return chain; } /**