mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-15 15:50:35 -07:00
Merge branch 'main' into memory_usage3
This commit is contained in:
@@ -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
|
||||
@@ -441,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:
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, MCPServerConfig> = {};
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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> = {}): 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', () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user