diff --git a/packages/cli/src/__snapshots__/nonInteractiveCli.test.ts.snap b/packages/cli/src/__snapshots__/nonInteractiveCli.test.ts.snap new file mode 100644 index 0000000000000000000000000000000000000000..92f396a59cc1ae1bbf6a554b26f95851f135f01a --- /dev/null +++ b/packages/cli/src/__snapshots__/nonInteractiveCli.test.ts.snap @@ -0,0 +1,35 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`runNonInteractive > should emit appropriate error event in streaming JSON mode: 'loop detected' 1`] = ` +"{"type":"init","timestamp":"","session_id":"test-session-id","model":"test-model"} +{"type":"message","timestamp":"","role":"user","content":"Loop test"} +{"type":"error","timestamp":"","severity":"warning","message":"Loop detected, stopping execution"} +{"type":"result","timestamp":"","status":"success","stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"cached":0,"input":0,"duration_ms":,"tool_calls":0,"models":{}}} +" +`; + +exports[`runNonInteractive > should emit appropriate error event in streaming JSON mode: 'max session turns' 1`] = ` +"{"type":"init","timestamp":"","session_id":"test-session-id","model":"test-model"} +{"type":"message","timestamp":"","role":"user","content":"Max turns test"} +{"type":"error","timestamp":"","severity":"error","message":"Maximum session turns exceeded"} +{"type":"result","timestamp":"","status":"success","stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"cached":0,"input":0,"duration_ms":,"tool_calls":0,"models":{}}} +" +`; + +exports[`runNonInteractive > should emit appropriate events for streaming JSON output 1`] = ` +"{"type":"init","timestamp":"","session_id":"test-session-id","model":"test-model"} +{"type":"message","timestamp":"","role":"user","content":"Stream test"} +{"type":"message","timestamp":"","role":"assistant","content":"Thinking...","delta":true} +{"type":"tool_use","timestamp":"","tool_name":"testTool","tool_id":"tool-1","parameters":{"arg1":"value1"}} +{"type":"tool_result","timestamp":"","tool_id":"tool-1","status":"success","output":"Tool executed successfully"} +{"type":"message","timestamp":"","role":"assistant","content":"Final answer","delta":true} +{"type":"result","timestamp":"","status":"success","stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"cached":0,"input":0,"duration_ms":,"tool_calls":0,"models":{}}} +" +`; + +exports[`runNonInteractive > should write a single newline between sequential text outputs from the model 1`] = ` +"Use mock tool +Use mock tool again +Finished. +" +`; diff --git a/packages/cli/src/__snapshots__/nonInteractiveCliAgentSession.test.ts.snap b/packages/cli/src/__snapshots__/nonInteractiveCliAgentSession.test.ts.snap new file mode 100644 index 0000000000000000000000000000000000000000..92f396a59cc1ae1bbf6a554b26f95851f135f01a --- /dev/null +++ b/packages/cli/src/__snapshots__/nonInteractiveCliAgentSession.test.ts.snap @@ -0,0 +1,35 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`runNonInteractive > should emit appropriate error event in streaming JSON mode: 'loop detected' 1`] = ` +"{"type":"init","timestamp":"","session_id":"test-session-id","model":"test-model"} +{"type":"message","timestamp":"","role":"user","content":"Loop test"} +{"type":"error","timestamp":"","severity":"warning","message":"Loop detected, stopping execution"} +{"type":"result","timestamp":"","status":"success","stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"cached":0,"input":0,"duration_ms":,"tool_calls":0,"models":{}}} +" +`; + +exports[`runNonInteractive > should emit appropriate error event in streaming JSON mode: 'max session turns' 1`] = ` +"{"type":"init","timestamp":"","session_id":"test-session-id","model":"test-model"} +{"type":"message","timestamp":"","role":"user","content":"Max turns test"} +{"type":"error","timestamp":"","severity":"error","message":"Maximum session turns exceeded"} +{"type":"result","timestamp":"","status":"success","stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"cached":0,"input":0,"duration_ms":,"tool_calls":0,"models":{}}} +" +`; + +exports[`runNonInteractive > should emit appropriate events for streaming JSON output 1`] = ` +"{"type":"init","timestamp":"","session_id":"test-session-id","model":"test-model"} +{"type":"message","timestamp":"","role":"user","content":"Stream test"} +{"type":"message","timestamp":"","role":"assistant","content":"Thinking...","delta":true} +{"type":"tool_use","timestamp":"","tool_name":"testTool","tool_id":"tool-1","parameters":{"arg1":"value1"}} +{"type":"tool_result","timestamp":"","tool_id":"tool-1","status":"success","output":"Tool executed successfully"} +{"type":"message","timestamp":"","role":"assistant","content":"Final answer","delta":true} +{"type":"result","timestamp":"","status":"success","stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"cached":0,"input":0,"duration_ms":,"tool_calls":0,"models":{}}} +" +`; + +exports[`runNonInteractive > should write a single newline between sequential text outputs from the model 1`] = ` +"Use mock tool +Use mock tool again +Finished. +" +`; diff --git a/packages/cli/src/acp/README.md b/packages/cli/src/acp/README.md new file mode 100644 index 0000000000000000000000000000000000000000..e8b4b4be70341021f026b606fe535a26e00e51b2 --- /dev/null +++ b/packages/cli/src/acp/README.md @@ -0,0 +1,81 @@ +# Agent Client Protocol (ACP) Implementation + +This directory contains the implementation of the Agent Client Protocol (ACP) +for the Gemini CLI. The ACP allows external clients (like IDE extensions) to +communicate with the Gemini CLI agent over a structured JSON-RPC based protocol. + +## Directory Structure + +Following Phase 1 of the modularization refactor, the ACP client is organized +into the following specialized modules, all sharing the `acp` prefix for +consistency: + +- **[acpStdioTransport.ts](./acpStdioTransport.ts)**: Handles raw I/O. It sets + up the Web streams for standard input/output and creates the + `AgentSideConnection` using line-delimited JSON (ndjson). +- **[acpRpcDispatcher.ts](./acpRpcDispatcher.ts)**: Contains the `GeminiAgent` + class. This is the main entry point for incoming JSON-RPC messages. It + implements the protocol methods and delegates session-specific work to the + manager and individual sessions. +- **[acpSessionManager.ts](./acpSessionManager.ts)**: Manages multi-session + state. It handles session creation (`newSession`), loading (`loadSession`), + and configuration, isolating session state from the RPC routing. +- **[acpSession.ts](./acpSession.ts)**: Manages individual active chat sessions. + It handles prompt execution, `@path` file resolution, tool execution, command + interception, and streaming updates back to the client. +- **[acpUtils.ts](./acpUtils.ts)**: Contains shared helper functions, type + mappers (e.g., mapping internal tool kinds to ACP kinds), and Zod schemas used + across the modules. +- **[acpErrors.ts](./acpErrors.ts)**: Centralized error handling and mapping to + ACP-compliant error codes. +- **[acpCommandHandler.ts](./acpCommandHandler.ts)**: Handles interception and + execution of slash commands (e.g., `/memory`, `/init`) sent via ACP prompts. +- **[acpFileSystemService.ts](./acpFileSystemService.ts)**: Provides access to + the file system restricted by the workspace boundaries and permissions. + +## Development Instructions + +### Running Tests + +Tests are co-located with the source files: + +- `acpRpcDispatcher.test.ts`: Tests for initialization, authentication, and + handler delegation. +- `acpSessionManager.test.ts`: Tests for session lifecycle and configuration. +- `acpSession.test.ts`: Tests for prompt loops, tool execution, and @path + resolution. +- `acpResume.test.ts`: Integration tests for loading/resuming sessions. + +To run specific tests, use Vitest with the workspace filter: + +```bash +# General pattern +npm test -w @google/gemini-cli -- src/acp/.ts + +# Example +npm test -w @google/gemini-cli -- src/acp/acpRpcDispatcher.test.ts +``` + +Note: You may need to ensure your environment has Node available. If running in +a restricted environment, try sourcing NVM first: + +```bash +source ~/.nvm/nvm.sh && nvm use default && npm test -w @google/gemini-cli -- src/acp/acpSession.test.ts +``` + +### Adding New Features + +- **New RPC Method**: Add the method to `GeminiAgent` in `acpRpcDispatcher.ts` + and register it in the `AgentSideConnection` setup if necessary. +- **Session State**: If a feature requires storing state across turns within a + session, add it to the `Session` class in `acpSession.ts`. +- **Protocol Helpers**: Add any new mapping or serialization logic to + `acpUtils.ts`. + +### Coding Conventions + +- **Imports**: Use specific imports and do not import across package boundaries + using relative paths. +- **License Headers**: All new files must include the Apache-2.0 license header. +- **Type Safety**: Avoid using `any` assertions. Use Zod schemas to validate + untrusted input from the protocol. diff --git a/packages/cli/src/acp/acpCommandHandler.test.ts b/packages/cli/src/acp/acpCommandHandler.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..f3b5db4640f8ae07124995c0c6ab27c56d4e412a --- /dev/null +++ b/packages/cli/src/acp/acpCommandHandler.test.ts @@ -0,0 +1,35 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CommandHandler } from './acpCommandHandler.js'; +import { describe, it, expect } from 'vitest'; + +describe('CommandHandler', () => { + it('parses commands correctly', () => { + const handler = new CommandHandler(); + // @ts-expect-error - testing private method + const parse = (query: string) => handler.parseSlashCommand(query); + + const memShow = parse('/memory show'); + expect(memShow.commandToExecute?.name).toBe('memory show'); + expect(memShow.args).toBe(''); + + const memList = parse('/memory list'); + expect(memList.commandToExecute?.name).toBe('memory list'); + + const extList = parse('/extensions list'); + expect(extList.commandToExecute?.name).toBe('extensions list'); + + const init = parse('/init'); + expect(init.commandToExecute?.name).toBe('init'); + + const about = parse('/about'); + expect(about.commandToExecute?.name).toBe('about'); + + const help = parse('/help'); + expect(help.commandToExecute?.name).toBe('help'); + }); +}); diff --git a/packages/cli/src/acp/acpCommandHandler.ts b/packages/cli/src/acp/acpCommandHandler.ts new file mode 100644 index 0000000000000000000000000000000000000000..6b171b5532598894ef5b299438aaa152944690a0 --- /dev/null +++ b/packages/cli/src/acp/acpCommandHandler.ts @@ -0,0 +1,138 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Command, CommandContext } from './commands/types.js'; +import { CommandRegistry } from './commands/commandRegistry.js'; +import { MemoryCommand } from './commands/memory.js'; +import { ExtensionsCommand } from './commands/extensions.js'; +import { InitCommand } from './commands/init.js'; +import { RestoreCommand } from './commands/restore.js'; +import { AboutCommand } from './commands/about.js'; +import { HelpCommand } from './commands/help.js'; + +export class CommandHandler { + private registry: CommandRegistry; + + constructor() { + this.registry = CommandHandler.createRegistry(); + } + + private static createRegistry(): CommandRegistry { + const registry = new CommandRegistry(); + registry.register(new MemoryCommand()); + registry.register(new ExtensionsCommand()); + registry.register(new InitCommand()); + registry.register(new RestoreCommand()); + registry.register(new AboutCommand()); + registry.register(new HelpCommand(registry)); + return registry; + } + + getAvailableCommands(): Array<{ name: string; description: string }> { + return this.registry.getAllCommands().map((cmd) => ({ + name: cmd.name, + description: cmd.description, + })); + } + + /** + * Parses and executes a command string if it matches a registered command. + * Returns true if a command was handled, false otherwise. + */ + async handleCommand( + commandText: string, + context: CommandContext, + ): Promise { + const { commandToExecute, args } = this.parseSlashCommand(commandText); + + if (commandToExecute) { + await this.runCommand(commandToExecute, args, context); + return true; + } + + return false; + } + + private async runCommand( + commandToExecute: Command, + args: string, + context: CommandContext, + ): Promise { + try { + const result = await commandToExecute.execute( + context, + args ? args.split(/\s+/) : [], + ); + + let messageContent = ''; + if (typeof result.data === 'string') { + messageContent = result.data; + } else if ( + typeof result.data === 'object' && + result.data !== null && + 'content' in result.data + ) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any + messageContent = (result.data as Record)[ + 'content' + ] as string; + } else { + messageContent = JSON.stringify(result.data, null, 2); + } + + await context.sendMessage(messageContent); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + await context.sendMessage(`Error: ${errorMessage}`); + } + } + + /** + * Parses a raw slash command string into its matching headless command and arguments. + * Mirrors `packages/cli/src/utils/commands.ts` logic. + */ + private parseSlashCommand(query: string): { + commandToExecute: Command | undefined; + args: string; + } { + const trimmed = query.trim(); + const parts = trimmed.substring(1).trim().split(/\s+/); + const commandPath = parts.filter((p) => p); + + let currentCommands = this.registry.getAllCommands(); + let commandToExecute: Command | undefined; + let pathIndex = 0; + + for (const part of commandPath) { + const foundCommand = currentCommands.find((cmd) => { + const expectedName = commandPath.slice(0, pathIndex + 1).join(' '); + return ( + cmd.name === part || + cmd.name === expectedName || + cmd.aliases?.includes(part) || + cmd.aliases?.includes(expectedName) + ); + }); + + if (foundCommand) { + commandToExecute = foundCommand; + pathIndex++; + if (foundCommand.subCommands) { + currentCommands = foundCommand.subCommands; + } else { + break; + } + } else { + break; + } + } + + const args = parts.slice(pathIndex).join(' '); + + return { commandToExecute, args }; + } +} diff --git a/packages/cli/src/acp/acpErrors.test.ts b/packages/cli/src/acp/acpErrors.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..1eeb78d59d731f620a3e7c6072b2b5d484e9140f --- /dev/null +++ b/packages/cli/src/acp/acpErrors.test.ts @@ -0,0 +1,45 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { getAcpErrorMessage } from './acpErrors.js'; + +describe('getAcpErrorMessage', () => { + it('should return plain error message', () => { + expect(getAcpErrorMessage(new Error('plain error'))).toBe('plain error'); + }); + + it('should parse simple JSON error response', () => { + const json = JSON.stringify({ error: { message: 'json error' } }); + expect(getAcpErrorMessage(new Error(json))).toBe('json error'); + }); + + it('should parse double-encoded JSON error response', () => { + const innerJson = JSON.stringify({ error: { message: 'nested error' } }); + const outerJson = JSON.stringify({ error: { message: innerJson } }); + expect(getAcpErrorMessage(new Error(outerJson))).toBe('nested error'); + }); + + it('should parse array-style JSON error response', () => { + const json = JSON.stringify([{ error: { message: 'array error' } }]); + expect(getAcpErrorMessage(new Error(json))).toBe('array error'); + }); + + it('should parse JSON with top-level message field', () => { + const json = JSON.stringify({ message: 'top-level message' }); + expect(getAcpErrorMessage(new Error(json))).toBe('top-level message'); + }); + + it('should handle JSON with trailing newline', () => { + const json = JSON.stringify({ error: { message: 'newline error' } }) + '\n'; + expect(getAcpErrorMessage(new Error(json))).toBe('newline error'); + }); + + it('should return original message if JSON parsing fails', () => { + const invalidJson = '{ not-json }'; + expect(getAcpErrorMessage(new Error(invalidJson))).toBe(invalidJson); + }); +}); diff --git a/packages/cli/src/acp/acpErrors.ts b/packages/cli/src/acp/acpErrors.ts new file mode 100644 index 0000000000000000000000000000000000000000..c2988b8c4fbc7fca448961389c33effda867be4f --- /dev/null +++ b/packages/cli/src/acp/acpErrors.ts @@ -0,0 +1,44 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { getErrorMessage as getCoreErrorMessage } from '@google/gemini-cli-core'; + +/** + * Extracts a human-readable error message specifically for ACP (IDE) clients. + * This function recursively parses JSON error blobs that are common in + * Google API responses but ugly to display in an IDE's UI. + */ +export function getAcpErrorMessage(error: unknown): string { + const coreMessage = getCoreErrorMessage(error); + return extractRecursiveMessage(coreMessage); +} + +function extractRecursiveMessage(input: string): string { + const trimmed = input.trim(); + + // Attempt to parse JSON error responses (common in Google API errors) + if ( + (trimmed.startsWith('{') && trimmed.endsWith('}')) || + (trimmed.startsWith('[') && trimmed.endsWith(']')) + ) { + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const parsed = JSON.parse(trimmed); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const next = + parsed?.error?.message || + parsed?.[0]?.error?.message || + parsed?.message; + + if (next && typeof next === 'string' && next !== input) { + return extractRecursiveMessage(next); + } + } catch { + // Fall back to original string if parsing fails + } + } + return input; +} diff --git a/packages/cli/src/acp/acpFileSystemService.test.ts b/packages/cli/src/acp/acpFileSystemService.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..7ddbb21537842b2337a911586595ea5f4b69441a --- /dev/null +++ b/packages/cli/src/acp/acpFileSystemService.test.ts @@ -0,0 +1,236 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, + type Mocked, +} from 'vitest'; +import { AcpFileSystemService } from './acpFileSystemService.js'; +import type { AgentSideConnection } from '@agentclientprotocol/sdk'; +import type { FileSystemService } from '@google/gemini-cli-core'; +import os from 'node:os'; + +vi.mock('node:os', () => ({ + default: { + homedir: vi.fn(), + }, +})); + +describe('AcpFileSystemService', () => { + let mockConnection: Mocked; + let mockFallback: Mocked; + let service: AcpFileSystemService; + + beforeEach(() => { + mockConnection = { + requestPermission: vi.fn(), + sessionUpdate: vi.fn(), + writeTextFile: vi.fn(), + readTextFile: vi.fn(), + } as unknown as Mocked; + mockFallback = { + readTextFile: vi.fn(), + writeTextFile: vi.fn(), + }; + vi.mocked(os.homedir).mockReturnValue('/home/user'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('readTextFile', () => { + it.each([ + { + capability: true, + path: '/path/to/file', + desc: 'connection if capability exists and file is inside root', + setup: () => { + mockConnection.readTextFile.mockResolvedValue({ content: 'content' }); + }, + verify: () => { + expect(mockConnection.readTextFile).toHaveBeenCalledWith({ + path: '/path/to/file', + sessionId: 'session-1', + }); + expect(mockFallback.readTextFile).not.toHaveBeenCalled(); + }, + }, + { + capability: false, + path: '/path/to/file', + desc: 'fallback if capability missing', + setup: () => { + mockFallback.readTextFile.mockResolvedValue('content'); + }, + verify: () => { + expect(mockFallback.readTextFile).toHaveBeenCalledWith( + '/path/to/file', + ); + expect(mockConnection.readTextFile).not.toHaveBeenCalled(); + }, + }, + { + capability: true, + path: '/outside/file', + desc: 'fallback if capability exists but file is outside root', + setup: () => { + mockFallback.readTextFile.mockResolvedValue('content'); + }, + verify: () => { + expect(mockFallback.readTextFile).toHaveBeenCalledWith( + '/outside/file', + ); + expect(mockConnection.readTextFile).not.toHaveBeenCalled(); + }, + }, + { + capability: true, + path: '/home/user/.gemini/tmp/file.md', + root: '/home/user', + desc: 'fallback if file is inside global gemini dir, even if root overlaps', + setup: () => { + mockFallback.readTextFile.mockResolvedValue('content'); + }, + verify: () => { + expect(mockFallback.readTextFile).toHaveBeenCalledWith( + '/home/user/.gemini/tmp/file.md', + ); + expect(mockConnection.readTextFile).not.toHaveBeenCalled(); + }, + }, + ])( + 'should use $desc', + async ({ capability, path, root, setup, verify }) => { + service = new AcpFileSystemService( + mockConnection, + 'session-1', + { readTextFile: capability, writeTextFile: true }, + mockFallback, + root || '/path/to', + ); + setup(); + + const result = await service.readTextFile(path); + + expect(result).toBe('content'); + verify(); + }, + ); + + it('should throw normalized ENOENT error when readTextFile encounters "Resource not found"', async () => { + service = new AcpFileSystemService( + mockConnection, + 'session-1', + { readTextFile: true, writeTextFile: true }, + mockFallback, + '/path/to', + ); + mockConnection.readTextFile.mockRejectedValue( + new Error('Resource not found for document'), + ); + + await expect( + service.readTextFile('/path/to/missing'), + ).rejects.toMatchObject({ + code: 'ENOENT', + message: 'Resource not found for document', + }); + }); + }); + + describe('writeTextFile', () => { + it.each([ + { + capability: true, + path: '/path/to/file', + desc: 'connection if capability exists and file is inside root', + verify: () => { + expect(mockConnection.writeTextFile).toHaveBeenCalledWith({ + path: '/path/to/file', + content: 'content', + sessionId: 'session-1', + }); + expect(mockFallback.writeTextFile).not.toHaveBeenCalled(); + }, + }, + { + capability: false, + path: '/path/to/file', + desc: 'fallback if capability missing', + verify: () => { + expect(mockFallback.writeTextFile).toHaveBeenCalledWith( + '/path/to/file', + 'content', + ); + expect(mockConnection.writeTextFile).not.toHaveBeenCalled(); + }, + }, + { + capability: true, + path: '/outside/file', + desc: 'fallback if capability exists but file is outside root', + verify: () => { + expect(mockFallback.writeTextFile).toHaveBeenCalledWith( + '/outside/file', + 'content', + ); + expect(mockConnection.writeTextFile).not.toHaveBeenCalled(); + }, + }, + { + capability: true, + path: '/home/user/.gemini/tmp/file.md', + root: '/home/user', + desc: 'fallback if file is inside global gemini dir, even if root overlaps', + verify: () => { + expect(mockFallback.writeTextFile).toHaveBeenCalledWith( + '/home/user/.gemini/tmp/file.md', + 'content', + ); + expect(mockConnection.writeTextFile).not.toHaveBeenCalled(); + }, + }, + ])('should use $desc', async ({ capability, path, root, verify }) => { + service = new AcpFileSystemService( + mockConnection, + 'session-1', + { writeTextFile: capability, readTextFile: true }, + mockFallback, + root || '/path/to', + ); + + await service.writeTextFile(path, 'content'); + + verify(); + }); + + it('should throw normalized ENOENT error when writeTextFile encounters "Resource not found"', async () => { + service = new AcpFileSystemService( + mockConnection, + 'session-1', + { readTextFile: true, writeTextFile: true }, + mockFallback, + '/path/to', + ); + mockConnection.writeTextFile.mockRejectedValue( + new Error('Resource not found for directory'), + ); + + await expect( + service.writeTextFile('/path/to/missing', 'content'), + ).rejects.toMatchObject({ + code: 'ENOENT', + message: 'Resource not found for directory', + }); + }); + }); +}); diff --git a/packages/cli/src/acp/acpFileSystemService.ts b/packages/cli/src/acp/acpFileSystemService.ts new file mode 100644 index 0000000000000000000000000000000000000000..3b66d06963db158f15db980483b68174ae0668e7 --- /dev/null +++ b/packages/cli/src/acp/acpFileSystemService.ts @@ -0,0 +1,88 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { isWithinRoot, type FileSystemService } from '@google/gemini-cli-core'; +import type * as acp from '@agentclientprotocol/sdk'; +import os from 'node:os'; +import path from 'node:path'; + +/** + * ACP client-based implementation of FileSystemService + */ +export class AcpFileSystemService implements FileSystemService { + private readonly geminiDir = path.join(os.homedir(), '.gemini'); + + constructor( + private readonly connection: acp.AgentSideConnection, + private readonly sessionId: string, + private readonly capabilities: acp.FileSystemCapabilities, + private readonly fallback: FileSystemService, + private readonly root: string, + ) {} + + private shouldUseFallback(filePath: string): boolean { + // Files inside the global CLI directory must always use the native file system, + // even if the user runs the CLI directly from their home directory (which + // would make the IDE's project root overlap with the global directory). + return ( + !isWithinRoot(filePath, this.root) || + isWithinRoot(filePath, this.geminiDir) + ); + } + + private normalizeFileSystemError(err: unknown): never { + const errorMessage = err instanceof Error ? err.message : String(err); + if ( + errorMessage.includes('Resource not found') || + errorMessage.includes('ENOENT') || + errorMessage.includes('does not exist') || + errorMessage.includes('No such file') + ) { + const newErr = new Error(errorMessage) as NodeJS.ErrnoException; + newErr.code = 'ENOENT'; + throw newErr; + } + throw err; + } + + async readTextFile(filePath: string): Promise { + if (!this.capabilities.readTextFile || this.shouldUseFallback(filePath)) { + return this.fallback.readTextFile(filePath); + } + + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const response = await this.connection.readTextFile({ + path: filePath, + sessionId: this.sessionId, + }); + + const content: unknown = response.content; + if (typeof content !== 'string') { + throw new Error('content must be a string'); // replace with other response type formats when modified in the future + } + return content; + } catch (err: unknown) { + this.normalizeFileSystemError(err); + } + } + + async writeTextFile(filePath: string, content: string): Promise { + if (!this.capabilities.writeTextFile || this.shouldUseFallback(filePath)) { + return this.fallback.writeTextFile(filePath, content); + } + + try { + await this.connection.writeTextFile({ + path: filePath, + content, + sessionId: this.sessionId, + }); + } catch (err: unknown) { + this.normalizeFileSystemError(err); + } + } +} diff --git a/packages/cli/src/acp/acpResume.test.ts b/packages/cli/src/acp/acpResume.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..c38b23d7151bea4f863a8d5c6b89326c3f6a3861 --- /dev/null +++ b/packages/cli/src/acp/acpResume.test.ts @@ -0,0 +1,313 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + describe, + it, + expect, + vi, + beforeEach, + type Mocked, + type Mock, +} from 'vitest'; +import { GeminiAgent } from './acpRpcDispatcher.js'; +import * as acp from '@agentclientprotocol/sdk'; +import { + ApprovalMode, + AuthType, + type Config, + CoreToolCallStatus, +} from '@google/gemini-cli-core'; +import { loadCliConfig, type CliArgs } from '../config/config.js'; +import { + SessionSelector, + convertSessionToHistoryFormats, +} from '../utils/sessionUtils.js'; +import { convertSessionToClientHistory } from '@google/gemini-cli-core'; +import type { LoadedSettings } from '../config/settings.js'; +import { waitFor } from '../test-utils/async.js'; + +vi.mock('../config/config.js', () => ({ + loadCliConfig: vi.fn(), +})); + +vi.mock('../utils/sessionUtils.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + SessionSelector: vi.fn(), + convertSessionToHistoryFormats: vi.fn(), + }; +}); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + CoreToolCallStatus: { + Validating: 'validating', + Scheduled: 'scheduled', + Error: 'error', + Success: 'success', + Executing: 'executing', + Cancelled: 'cancelled', + AwaitingApproval: 'awaiting_approval', + }, + LlmRole: { + MAIN: 'main', + SUBAGENT: 'subagent', + UTILITY_TOOL: 'utility_tool', + USER: 'user', + MODEL: 'model', + SYSTEM: 'system', + TOOL: 'tool', + }, + convertSessionToClientHistory: vi.fn(), + }; +}); + +describe('GeminiAgent Session Resume', () => { + let mockConfig: Mocked; + let mockSettings: Mocked; + let mockArgv: CliArgs; + let mockConnection: Mocked; + let agent: GeminiAgent; + + beforeEach(() => { + mockConfig = { + refreshAuth: vi.fn().mockResolvedValue(undefined), + initialize: vi.fn().mockResolvedValue(undefined), + getFileSystemService: vi.fn(), + setFileSystemService: vi.fn(), + getGeminiClient: vi.fn().mockReturnValue({ + initialize: vi.fn().mockResolvedValue(undefined), + resumeChat: vi.fn().mockResolvedValue(undefined), + getChat: vi.fn().mockReturnValue({}), + }), + storage: { + getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'), + }, + getPolicyEngine: vi.fn().mockReturnValue({ + addRule: vi.fn(), + }), + messageBus: { + publish: vi.fn(), + subscribe: vi.fn(), + unsubscribe: vi.fn(), + }, + getMessageBus: vi.fn().mockReturnValue({ + publish: vi.fn(), + subscribe: vi.fn(), + unsubscribe: vi.fn(), + }), + getApprovalMode: vi.fn().mockReturnValue('default'), + isAutoMemoryEnabled: vi.fn().mockReturnValue(false), + isPlanEnabled: vi.fn().mockReturnValue(true), + getModel: vi.fn().mockReturnValue('gemini-pro'), + getHasAccessToPreviewModel: vi.fn().mockReturnValue(false), + getGemini31LaunchedSync: vi.fn().mockReturnValue(false), + getCheckpointingEnabled: vi.fn().mockReturnValue(false), + toolRegistry: { + getTool: vi.fn().mockReturnValue({ kind: 'read' }), + }, + get config() { + return this; + }, + } as unknown as Mocked; + mockSettings = { + merged: { + security: { auth: { selectedType: AuthType.LOGIN_WITH_GOOGLE } }, + mcpServers: {}, + }, + setValue: vi.fn(), + } as unknown as Mocked; + mockArgv = {} as unknown as CliArgs; + mockConnection = { + sessionUpdate: vi.fn().mockResolvedValue(undefined), + } as unknown as Mocked; + + (loadCliConfig as Mock).mockResolvedValue(mockConfig); + + agent = new GeminiAgent(mockConfig, mockSettings, mockArgv, mockConnection); + }); + + it('should advertise loadSession capability', async () => { + const response = await agent.initialize({ + protocolVersion: acp.PROTOCOL_VERSION, + }); + expect(response.agentCapabilities?.loadSession).toBe(true); + }); + + it('should load a session, resume chat, and stream all message types', async () => { + const sessionId = 'existing-session-id'; + const sessionData = { + sessionId, + messages: [ + { type: 'user', content: [{ text: 'Hello' }] }, + { + type: 'gemini', + content: [{ text: 'Hi there' }], + thoughts: [{ subject: 'Thinking', description: 'about greeting' }], + toolCalls: [ + { + id: 'call-1', + name: 'test_tool', + displayName: 'Test Tool', + status: CoreToolCallStatus.Success, + resultDisplay: 'Tool output', + }, + ], + }, + { + type: 'gemini', + content: [{ text: 'Trying a write' }], + toolCalls: [ + { + id: 'call-2', + name: 'write_file', + displayName: 'Write File', + status: CoreToolCallStatus.Error, + resultDisplay: 'Permission denied', + }, + ], + }, + ], + }; + + (SessionSelector as unknown as Mock).mockImplementation(() => ({ + resolveSession: vi.fn().mockResolvedValue({ + sessionData, + sessionPath: '/path/to/session.json', + }), + })); + + const mockClientHistory = [ + { role: 'user', parts: [{ text: 'Hello' }] }, + { role: 'model', parts: [{ text: 'Hi there' }] }, + ]; + (convertSessionToHistoryFormats as unknown as Mock).mockReturnValue({ + uiHistory: [], + }); + (convertSessionToClientHistory as unknown as Mock).mockReturnValue( + mockClientHistory, + ); + + const response = await agent.loadSession({ + sessionId, + cwd: '/tmp', + mcpServers: [], + }); + + expect(response).toEqual({ + modes: { + availableModes: [ + { + id: ApprovalMode.DEFAULT, + name: 'Default', + description: 'Prompts for approval', + }, + { + id: ApprovalMode.AUTO_EDIT, + name: 'Auto Edit', + description: 'Auto-approves edit tools', + }, + { + id: ApprovalMode.YOLO, + name: 'YOLO', + description: 'Auto-approves all tools', + }, + { + id: ApprovalMode.PLAN, + name: 'Plan', + description: 'Read-only mode', + }, + ], + currentModeId: ApprovalMode.DEFAULT, + }, + models: { + availableModels: expect.any(Array) as unknown, + currentModelId: 'gemini-pro', + }, + }); + + // Verify resumeChat received the correct arguments + expect(mockConfig.getGeminiClient().resumeChat).toHaveBeenCalledWith( + mockClientHistory, + expect.objectContaining({ + conversation: sessionData, + filePath: '/path/to/session.json', + }), + ); + + await waitFor(() => { + // User message + expect(mockConnection.sessionUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + update: expect.objectContaining({ + sessionUpdate: 'user_message_chunk', + content: expect.objectContaining({ text: 'Hello' }), + }), + }), + ); + + // Agent thought + expect(mockConnection.sessionUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + update: expect.objectContaining({ + sessionUpdate: 'agent_thought_chunk', + content: expect.objectContaining({ + text: '**Thinking**\nabout greeting', + }), + }), + }), + ); + + // Agent message + expect(mockConnection.sessionUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + update: expect.objectContaining({ + sessionUpdate: 'agent_message_chunk', + content: expect.objectContaining({ text: 'Hi there' }), + }), + }), + ); + + // Successful tool call → 'completed' + expect(mockConnection.sessionUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + update: expect.objectContaining({ + sessionUpdate: 'tool_call', + toolCallId: 'call-1', + status: 'completed', + title: 'Test Tool', + kind: 'read', + content: [ + { + type: 'content', + content: { type: 'text', text: 'Tool output' }, + }, + ], + }), + }), + ); + + // Failed tool call → 'failed' + expect(mockConnection.sessionUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + update: expect.objectContaining({ + sessionUpdate: 'tool_call', + toolCallId: 'call-2', + status: 'failed', + title: 'Write File', + kind: 'read', + }), + }), + ); + }); + }); +}); diff --git a/packages/cli/src/acp/acpRpcDispatcher.test.ts b/packages/cli/src/acp/acpRpcDispatcher.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..6dff01c751ab6d36aa820c693c312f05239b81f0 --- /dev/null +++ b/packages/cli/src/acp/acpRpcDispatcher.test.ts @@ -0,0 +1,339 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + describe, + it, + expect, + vi, + beforeEach, + type Mock, + type Mocked, +} from 'vitest'; +import { GeminiAgent } from './acpRpcDispatcher.js'; +import * as acp from '@agentclientprotocol/sdk'; +import { + AuthType, + type Config, + type MessageBus, + type Storage, +} from '@google/gemini-cli-core'; +import type { LoadedSettings } from '../config/settings.js'; +import { loadCliConfig, type CliArgs } from '../config/config.js'; +import { loadSettings, SettingScope } from '../config/settings.js'; + +vi.mock('../config/config.js', () => ({ + loadCliConfig: vi.fn(), +})); + +vi.mock('../config/settings.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadSettings: vi.fn(), + }; +}); + +describe('GeminiAgent - RPC Dispatcher', () => { + let mockConfig: Mocked; + let mockSettings: Mocked; + let mockArgv: CliArgs; + let mockConnection: Mocked; + let agent: GeminiAgent; + + beforeEach(() => { + mockConfig = { + refreshAuth: vi.fn(), + initialize: vi.fn(), + waitForMcpInit: vi.fn(), + getFileSystemService: vi.fn(), + setFileSystemService: vi.fn(), + getContentGeneratorConfig: vi.fn(), + getActiveModel: vi.fn().mockReturnValue('gemini-pro'), + getModel: vi.fn().mockReturnValue('gemini-pro'), + getGeminiClient: vi.fn().mockReturnValue({ + startChat: vi.fn().mockResolvedValue({}), + }), + getMessageBus: vi.fn().mockReturnValue({ + publish: vi.fn(), + subscribe: vi.fn(), + unsubscribe: vi.fn(), + }), + getApprovalMode: vi.fn().mockReturnValue('default'), + isPlanEnabled: vi.fn().mockReturnValue(true), + getGemini31LaunchedSync: vi.fn().mockReturnValue(false), + getHasAccessToPreviewModel: vi.fn().mockReturnValue(false), + getCheckpointingEnabled: vi.fn().mockReturnValue(false), + getDisableAlwaysAllow: vi.fn().mockReturnValue(false), + validatePathAccess: vi.fn().mockReturnValue(null), + getWorkspaceContext: vi.fn().mockReturnValue({ + addReadOnlyPath: vi.fn(), + getDirectories: vi.fn().mockReturnValue(['/tmp']), + }), + getPolicyEngine: vi.fn().mockReturnValue({ + addRule: vi.fn(), + }), + messageBus: { + publish: vi.fn(), + subscribe: vi.fn(), + unsubscribe: vi.fn(), + } as unknown as MessageBus, + storage: { + getWorkspaceAutoSavedPolicyPath: vi.fn(), + getAutoSavedPolicyPath: vi.fn(), + } as unknown as Storage, + + get config() { + return this; + }, + } as unknown as Mocked; + mockSettings = { + merged: { + security: { auth: { selectedType: 'login_with_google' } }, + mcpServers: {}, + }, + setValue: vi.fn(), + } as unknown as Mocked; + mockArgv = {} as unknown as CliArgs; + mockConnection = { + sessionUpdate: vi.fn(), + requestPermission: vi.fn(), + } as unknown as Mocked; + + (loadCliConfig as unknown as Mock).mockResolvedValue(mockConfig); + (loadSettings as unknown as Mock).mockImplementation(() => ({ + merged: { + security: { + auth: { selectedType: AuthType.LOGIN_WITH_GOOGLE }, + enablePermanentToolApproval: true, + }, + mcpServers: {}, + }, + setValue: vi.fn(), + })); + + agent = new GeminiAgent(mockConfig, mockSettings, mockArgv, mockConnection); + }); + + it('should initialize correctly', async () => { + const response = await agent.initialize({ + clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } }, + protocolVersion: 1, + }); + + expect(response.protocolVersion).toBe(acp.PROTOCOL_VERSION); + expect(response.authMethods).toHaveLength(4); + const gatewayAuth = response.authMethods?.find( + (m) => m.id === AuthType.GATEWAY, + ); + expect(gatewayAuth?._meta).toEqual({ + gateway: { + protocol: 'google', + restartRequired: 'false', + }, + }); + const geminiAuth = response.authMethods?.find( + (m) => m.id === AuthType.USE_GEMINI, + ); + expect(geminiAuth?._meta).toEqual({ + 'api-key': { + provider: 'google', + }, + }); + expect(response.agentCapabilities?.loadSession).toBe(true); + }); + + it('should authenticate correctly', async () => { + await agent.authenticate({ + methodId: AuthType.LOGIN_WITH_GOOGLE, + }); + + expect(mockConfig.refreshAuth).toHaveBeenCalledWith( + AuthType.LOGIN_WITH_GOOGLE, + undefined, + undefined, + undefined, + ); + expect(mockSettings.setValue).toHaveBeenCalledWith( + SettingScope.User, + 'security.auth.selectedType', + AuthType.LOGIN_WITH_GOOGLE, + ); + }); + + it('should authenticate correctly with api-key in _meta', async () => { + await agent.authenticate({ + methodId: AuthType.USE_GEMINI, + _meta: { + 'api-key': 'test-api-key', + }, + } as unknown as acp.AuthenticateRequest); + + expect(mockConfig.refreshAuth).toHaveBeenCalledWith( + AuthType.USE_GEMINI, + 'test-api-key', + undefined, + undefined, + ); + expect(mockSettings.setValue).toHaveBeenCalledWith( + SettingScope.User, + 'security.auth.selectedType', + AuthType.USE_GEMINI, + ); + }); + + it('should authenticate correctly with gateway method', async () => { + await agent.authenticate({ + methodId: AuthType.GATEWAY, + _meta: { + gateway: { + baseUrl: 'https://example.com', + headers: { Authorization: 'Bearer token' }, + }, + }, + } as unknown as acp.AuthenticateRequest); + + expect(mockConfig.refreshAuth).toHaveBeenCalledWith( + AuthType.GATEWAY, + undefined, + 'https://example.com', + { Authorization: 'Bearer token' }, + ); + expect(mockSettings.setValue).toHaveBeenCalledWith( + SettingScope.User, + 'security.auth.selectedType', + AuthType.GATEWAY, + ); + }); + + it('should throw acp.RequestError when gateway payload is malformed', async () => { + await expect( + agent.authenticate({ + methodId: AuthType.GATEWAY, + _meta: { + gateway: { + baseUrl: 123, + headers: { Authorization: 'Bearer token' }, + }, + }, + } as unknown as acp.AuthenticateRequest), + ).rejects.toThrow(/Malformed gateway payload/); + }); + + it('should cancel a session', async () => { + const mockSession = { + cancelPendingPrompt: vi.fn(), + }; + ( + agent as unknown as { sessionManager: { getSession: Mock } } + ).sessionManager = { + getSession: vi.fn().mockReturnValue(mockSession), + }; + + await agent.cancel({ sessionId: 'test-session-id' }); + + expect(mockSession.cancelPendingPrompt).toHaveBeenCalled(); + }); + + it('should throw error when cancelling non-existent session', async () => { + ( + agent as unknown as { sessionManager: { getSession: Mock } } + ).sessionManager = { + getSession: vi.fn().mockReturnValue(undefined), + }; + + await expect(agent.cancel({ sessionId: 'unknown' })).rejects.toThrow( + 'Session not found', + ); + }); + + it('should delegate prompt to session', async () => { + const mockSession = { + prompt: vi.fn().mockResolvedValue({ stopReason: 'end_turn' }), + }; + ( + agent as unknown as { sessionManager: { getSession: Mock } } + ).sessionManager = { + getSession: vi.fn().mockReturnValue(mockSession), + }; + + const result = await agent.prompt({ + sessionId: 'test-session-id', + prompt: [], + }); + + expect(mockSession.prompt).toHaveBeenCalled(); + expect(result).toMatchObject({ stopReason: 'end_turn' }); + }); + + it('should delegate setMode to session', async () => { + const mockSession = { + setMode: vi.fn().mockReturnValue({}), + }; + ( + agent as unknown as { sessionManager: { getSession: Mock } } + ).sessionManager = { + getSession: vi.fn().mockReturnValue(mockSession), + }; + + const result = await agent.setSessionMode({ + sessionId: 'test-session-id', + modeId: 'plan', + }); + + expect(mockSession.setMode).toHaveBeenCalledWith('plan'); + expect(result).toEqual({}); + }); + + it('should throw error when setting mode on non-existent session', async () => { + ( + agent as unknown as { sessionManager: { getSession: Mock } } + ).sessionManager = { + getSession: vi.fn().mockReturnValue(undefined), + }; + + await expect( + agent.setSessionMode({ + sessionId: 'unknown', + modeId: 'plan', + }), + ).rejects.toThrow('Session not found: unknown'); + }); + + it('should delegate setModel to session (unstable)', async () => { + const mockSession = { + setModel: vi.fn().mockReturnValue({}), + }; + ( + agent as unknown as { sessionManager: { getSession: Mock } } + ).sessionManager = { + getSession: vi.fn().mockReturnValue(mockSession), + }; + + const result = await agent.unstable_setSessionModel({ + sessionId: 'test-session-id', + modelId: 'gemini-2.0-pro-exp', + }); + + expect(mockSession.setModel).toHaveBeenCalledWith('gemini-2.0-pro-exp'); + expect(result).toEqual({}); + }); + + it('should throw error when setting model on non-existent session (unstable)', async () => { + ( + agent as unknown as { sessionManager: { getSession: Mock } } + ).sessionManager = { + getSession: vi.fn().mockReturnValue(undefined), + }; + + await expect( + agent.unstable_setSessionModel({ + sessionId: 'unknown', + modelId: 'gemini-2.0-pro-exp', + }), + ).rejects.toThrow('Session not found: unknown'); + }); +}); diff --git a/packages/cli/src/acp/acpRpcDispatcher.ts b/packages/cli/src/acp/acpRpcDispatcher.ts new file mode 100644 index 0000000000000000000000000000000000000000..a7d7d26e61dd3d996e8eb0ec8176a73fa8620508 --- /dev/null +++ b/packages/cli/src/acp/acpRpcDispatcher.ts @@ -0,0 +1,236 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + type AgentLoopContext, + AuthType, + clearCachedCredentialFile, + getVersion, +} from '@google/gemini-cli-core'; +import * as acp from '@agentclientprotocol/sdk'; +import { z } from 'zod'; +import { SettingScope, type LoadedSettings } from '../config/settings.js'; +import type { CliArgs } from '../config/config.js'; +import { getAcpErrorMessage } from './acpErrors.js'; +import { AcpSessionManager, type AuthDetails } from './acpSessionManager.js'; +import { hasMeta } from './acpUtils.js'; + +export class GeminiAgent { + private apiKey: string | undefined; + private baseUrl: string | undefined; + private customHeaders: Record | undefined; + private sessionManager: AcpSessionManager; + + constructor( + private context: AgentLoopContext, + private settings: LoadedSettings, + argv: CliArgs, + connection: acp.AgentSideConnection, + ) { + this.sessionManager = new AcpSessionManager(settings, argv, connection); + } + + dispose(): void { + this.sessionManager.dispose(); + } + + async initialize( + args: acp.InitializeRequest, + ): Promise { + if (args.clientCapabilities) { + this.sessionManager.setClientCapabilities(args.clientCapabilities); + } + + const authMethods = [ + { + id: AuthType.LOGIN_WITH_GOOGLE, + name: 'Log in with Google', + description: 'Log in with your Google account', + }, + { + id: AuthType.USE_GEMINI, + name: 'Gemini API key', + description: 'Use an API key with Gemini Developer API', + _meta: { + 'api-key': { + provider: 'google', + }, + }, + }, + { + id: AuthType.USE_VERTEX_AI, + name: 'Vertex AI', + description: 'Use an API key with Vertex AI GenAI API', + }, + { + id: AuthType.GATEWAY, + name: 'AI API Gateway', + description: 'Use a custom AI API Gateway', + _meta: { + gateway: { + protocol: 'google', + restartRequired: 'false', + }, + }, + }, + ]; + + await this.context.config.initialize(); + const version = await getVersion(); + return { + protocolVersion: acp.PROTOCOL_VERSION, + authMethods, + agentInfo: { + name: 'gemini-cli', + title: 'Gemini CLI', + version, + }, + agentCapabilities: { + loadSession: true, + promptCapabilities: { + image: true, + audio: true, + embeddedContext: true, + }, + mcpCapabilities: { + http: true, + sse: true, + }, + }, + }; + } + + async authenticate(req: acp.AuthenticateRequest): Promise { + const { methodId } = req; + const method = z.nativeEnum(AuthType).parse(methodId); + const selectedAuthType = this.settings.merged.security.auth.selectedType; + + // Only clear credentials when switching to a different auth method + if (selectedAuthType && selectedAuthType !== method) { + await clearCachedCredentialFile(); + } + // Check for api-key in _meta + const meta = hasMeta(req) ? req._meta : undefined; + const apiKey = + typeof meta?.['api-key'] === 'string' ? meta['api-key'] : undefined; + + // Refresh auth with the requested method + // This will reuse existing credentials if they're valid, + // or perform new authentication if needed + try { + if (apiKey) { + this.apiKey = apiKey; + } + + // Extract gateway details if present + const gatewaySchema = z.object({ + baseUrl: z.string().optional(), + headers: z.record(z.string()).optional(), + }); + + let baseUrl: string | undefined; + let headers: Record | undefined; + + if (meta?.['gateway']) { + const result = gatewaySchema.safeParse(meta['gateway']); + if (result.success) { + baseUrl = result.data.baseUrl; + headers = result.data.headers; + } else { + throw new acp.RequestError( + -32602, + `Malformed gateway payload: ${result.error.message}`, + ); + } + } + + this.baseUrl = baseUrl; + this.customHeaders = headers; + + await this.context.config.refreshAuth( + method, + apiKey ?? this.apiKey, + baseUrl, + headers, + ); + } catch (e) { + throw new acp.RequestError(-32000, getAcpErrorMessage(e)); + } + this.settings.setValue( + SettingScope.User, + 'security.auth.selectedType', + method, + ); + } + + private getAuthDetails(): AuthDetails { + return { + apiKey: this.apiKey, + baseUrl: this.baseUrl, + customHeaders: this.customHeaders, + }; + } + + async newSession( + params: acp.NewSessionRequest, + ): Promise { + return this.sessionManager.newSession(params, this.getAuthDetails()); + } + + async loadSession( + params: acp.LoadSessionRequest, + ): Promise { + return this.sessionManager.loadSession(params, this.getAuthDetails()); + } + + async cancel(params: acp.CancelNotification): Promise { + const session = this.sessionManager.getSession(params.sessionId); + if (!session) { + throw new acp.RequestError( + -32602, + `Session not found: ${params.sessionId}`, + ); + } + await session.cancelPendingPrompt(); + } + + async prompt(params: acp.PromptRequest): Promise { + const session = this.sessionManager.getSession(params.sessionId); + if (!session) { + throw new acp.RequestError( + -32602, + `Session not found: ${params.sessionId}`, + ); + } + return session.prompt(params); + } + + async setSessionMode( + params: acp.SetSessionModeRequest, + ): Promise { + const session = this.sessionManager.getSession(params.sessionId); + if (!session) { + throw new acp.RequestError( + -32602, + `Session not found: ${params.sessionId}`, + ); + } + return session.setMode(params.modeId); + } + + async unstable_setSessionModel( + params: acp.SetSessionModelRequest, + ): Promise { + const session = this.sessionManager.getSession(params.sessionId); + if (!session) { + throw new acp.RequestError( + -32602, + `Session not found: ${params.sessionId}`, + ); + } + return session.setModel(params.modelId); + } +} diff --git a/packages/cli/src/acp/acpSession.test.ts b/packages/cli/src/acp/acpSession.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..b2db64e009a8daa61cd934d127c74f7b463994c4 --- /dev/null +++ b/packages/cli/src/acp/acpSession.test.ts @@ -0,0 +1,1144 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, + type Mock, + type Mocked, + type MockInstance, +} from 'vitest'; +import { Session } from './acpSession.js'; +import type * as acp from '@agentclientprotocol/sdk'; +import { + ReadManyFilesTool, + type GeminiChat, + type Config, + type MessageBus, + type GitService, + InvalidStreamError, + GeminiEventType, + type ServerGeminiStreamEvent, + PolicyDecision, + MessageBusType, + type ToolConfirmationRequest, + DiscoveredMCPTool, +} from '@google/gemini-cli-core'; +import type { LoadedSettings } from '../config/settings.js'; +import { type Part, FinishReason } from '@google/genai'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import type { CommandHandler } from './acpCommandHandler.js'; + +vi.mock('node:fs/promises'); +vi.mock('node:path', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolve: vi.fn(), + }; +}); + +vi.mock( + '@google/gemini-cli-core', + async ( + importOriginal: () => Promise, + ) => { + const actual = await importOriginal(); + return { + ...actual, + updatePolicy: vi.fn(), + ReadManyFilesTool: vi.fn(), + logToolCall: vi.fn(), + processSingleFileContent: vi.fn(), + }; + }, +); + +async function* createMockStream( + items: readonly ServerGeminiStreamEvent[], +): AsyncGenerator { + for (const item of items) { + yield item; + } + + yield { + type: GeminiEventType.Finished, + value: { + reason: FinishReason.STOP, + usageMetadata: { + promptTokenCount: 5, + candidatesTokenCount: 10, + }, + }, + }; +} + +describe('Session', () => { + let mockChat: Mocked; + let mockConfig: Mocked; + let mockConnection: Mocked; + let session: Session; + let mockToolRegistry: { getTool: Mock }; + let mockTool: { kind: string; build: Mock }; + let mockMessageBus: Mocked; + let mockSendMessageStream: MockInstance< + ( + request: Part[], + signal: AbortSignal, + promptId: string, + ) => AsyncGenerator + >; + + beforeEach(() => { + mockChat = { + sendMessageStream: vi.fn(), + addHistory: vi.fn(), + recordCompletedToolCalls: vi.fn(), + getHistory: vi.fn().mockReturnValue([]), + } as unknown as Mocked; + mockTool = { + kind: 'read', + build: vi.fn().mockReturnValue({ + getDescription: () => 'Test Tool', + toolLocations: () => [], + shouldConfirmExecute: vi.fn().mockResolvedValue(null), + execute: vi.fn().mockResolvedValue({ llmContent: 'Tool Result' }), + }), + }; + mockToolRegistry = { + getTool: vi.fn().mockReturnValue(mockTool), + }; + mockMessageBus = { + publish: vi.fn(), + subscribe: vi.fn(), + unsubscribe: vi.fn(), + } as unknown as Mocked; + mockSendMessageStream = vi.fn(); + mockConfig = { + getModel: vi.fn().mockReturnValue('gemini-pro'), + getActiveModel: vi.fn().mockReturnValue('gemini-pro'), + getModelRouterService: vi.fn().mockReturnValue({ + route: vi.fn().mockResolvedValue({ model: 'resolved-model' }), + }), + getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry), + getFileService: vi.fn().mockReturnValue({ + shouldIgnoreFile: vi.fn().mockReturnValue(false), + }), + getFileFilteringOptions: vi.fn().mockReturnValue({}), + getFileSystemService: vi.fn().mockReturnValue({}), + getTargetDir: vi.fn().mockReturnValue('/tmp'), + getEnableRecursiveFileSearch: vi.fn().mockReturnValue(false), + getDebugMode: vi.fn().mockReturnValue(false), + getMessageBus: vi.fn().mockReturnValue(mockMessageBus), + setApprovalMode: vi.fn(), + setModel: vi.fn(), + isPlanEnabled: vi.fn().mockReturnValue(true), + getCheckpointingEnabled: vi.fn().mockReturnValue(false), + getGitService: vi.fn().mockResolvedValue({} as GitService), + getPolicyEngine: vi.fn().mockReturnValue({ + check: vi.fn(), + }), + validatePathAccess: vi.fn().mockReturnValue(null), + getWorkspaceContext: vi.fn().mockReturnValue({ + addReadOnlyPath: vi.fn(), + getDirectories: vi.fn().mockReturnValue(['/tmp']), + }), + waitForMcpInit: vi.fn(), + getDisableAlwaysAllow: vi.fn().mockReturnValue(false), + getMaxSessionTurns: vi.fn().mockReturnValue(-1), + geminiClient: { + sendMessageStream: mockSendMessageStream, + getChat: vi.fn().mockReturnValue(mockChat), + }, + get config() { + return this; + }, + get toolRegistry() { + return mockToolRegistry; + }, + } as unknown as Mocked; + mockConnection = { + sessionUpdate: vi.fn(), + requestPermission: vi.fn(), + } as unknown as Mocked; + + session = new Session('session-1', mockChat, mockConfig, mockConnection, { + merged: { + security: { enablePermanentToolApproval: true }, + mcpServers: {}, + }, + errors: [], + } as unknown as LoadedSettings); + + (ReadManyFilesTool as unknown as Mock).mockImplementation(() => ({ + name: 'read_many_files', + kind: 'read', + build: vi.fn().mockReturnValue({ + getDescription: () => 'Read files', + toolLocations: () => [], + execute: vi.fn().mockResolvedValue({ + llmContent: ['--- file.txt ---\n\nFile content\n\n'], + }), + }), + })); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should send available commands', async () => { + await session.sendAvailableCommands(); + + expect(mockConnection.sessionUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + update: expect.objectContaining({ + sessionUpdate: 'available_commands_update', + }), + }), + ); + }); + + it('should await MCP initialization before processing a prompt', async () => { + const stream = createMockStream([ + { + type: GeminiEventType.Content, + value: 'Hi', + }, + ]); + mockSendMessageStream.mockReturnValue(stream); + + await session.prompt({ + sessionId: 'session-1', + prompt: [{ type: 'text', text: 'test' }], + }); + + expect(mockConfig.waitForMcpInit).toHaveBeenCalledOnce(); + }); + + it('should handle prompt with text response', async () => { + const stream = createMockStream([ + { + type: GeminiEventType.Content, + value: 'Hello', + }, + ]); + mockSendMessageStream.mockReturnValue(stream); + + const result = await session.prompt({ + sessionId: 'session-1', + prompt: [{ type: 'text', text: 'Hi' }], + }); + + expect(mockSendMessageStream).toHaveBeenCalled(); + expect(mockConnection.sessionUpdate).toHaveBeenCalledWith({ + sessionId: 'session-1', + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'Hello' }, + }, + }); + expect(result).toMatchObject({ stopReason: 'end_turn' }); + }); + + it('should pass current session information directly onto geminiClient.sendMessageStream', async () => { + const stream = createMockStream([ + { + type: GeminiEventType.Content, + value: 'Hello', + }, + ]); + mockSendMessageStream.mockReturnValue(stream); + + await session.prompt({ + sessionId: 'session-1', + prompt: [{ type: 'text', text: 'Hi' }], + }); + + expect(mockSendMessageStream).toHaveBeenCalledWith( + expect.arrayContaining([{ text: 'Hi' }]), + expect.any(AbortSignal), + expect.any(String), + ); + }); + + it('should handle prompt with empty response (InvalidStreamError)', async () => { + const error = new InvalidStreamError('Empty response', 'NO_RESPONSE_TEXT'); + mockSendMessageStream.mockImplementation(() => { + async function* errorGen(): AsyncGenerator< + ServerGeminiStreamEvent, + void, + unknown + > { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + yield* [] as any; + throw error; + } + return errorGen(); + }); + + const result = await session.prompt({ + sessionId: 'session-1', + prompt: [{ type: 'text', text: 'Hi' }], + }); + + expect(result).toMatchObject({ stopReason: 'end_turn' }); + }); + + it('should handle prompt with no finish reason (InvalidStreamError)', async () => { + const error = new InvalidStreamError( + 'No finish reason', + 'NO_FINISH_REASON', + ); + mockSendMessageStream.mockImplementation(() => { + async function* errorGen(): AsyncGenerator< + ServerGeminiStreamEvent, + void, + unknown + > { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + yield* [] as any; + throw error; + } + return errorGen(); + }); + + const result = await session.prompt({ + sessionId: 'session-1', + prompt: [{ type: 'text', text: 'Hi' }], + }); + + expect(result).toMatchObject({ stopReason: 'end_turn' }); + }); + + it.each([ + { type: 'MAX_TOKENS_EXCEEDED', reason: 'MAX_TOKENS' }, + { type: 'SAFETY_BLOCKED', reason: 'SAFETY' }, + { type: 'RECITATION_BLOCKED', reason: 'RECITATION' }, + { type: 'OTHER_BLOCKED', reason: 'OTHER' }, + { type: 'THINKING_ONLY_RESPONSE', reason: 'STOP' }, + ])( + 'should gracefully handle InvalidStreamError with type $type in ACP session', + async ({ type, reason }) => { + const error = new InvalidStreamError( + `Stream failed with ${reason}`, + type as InvalidStreamError['type'], + ); + mockSendMessageStream.mockImplementation(() => { + async function* errorGen(): AsyncGenerator< + ServerGeminiStreamEvent, + void, + unknown + > { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + yield* [] as any; + throw error; + } + return errorGen(); + }); + + const result = await session.prompt({ + sessionId: 'session-1', + prompt: [{ type: 'text', text: 'Hi' }], + }); + + expect(result).toMatchObject({ stopReason: 'end_turn' }); + }, + ); + + it('should handle /memory command', async () => { + const handleCommandSpy = vi + .spyOn( + (session as unknown as { commandHandler: CommandHandler }) + .commandHandler, + 'handleCommand', + ) + .mockResolvedValue(true); + + const result = await session.prompt({ + sessionId: 'session-1', + prompt: [{ type: 'text', text: '/memory view' }], + }); + + expect(result).toMatchObject({ stopReason: 'end_turn' }); + expect(handleCommandSpy).toHaveBeenCalledWith( + '/memory view', + expect.any(Object), + ); + }); + + it('should handle tool calls', async () => { + const stream1 = createMockStream([ + { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'call-1', + name: 'test_tool', + args: { foo: 'bar' }, + isClientInitiated: false, + prompt_id: 'prompt-1', + }, + }, + ]); + const stream2 = createMockStream([ + { + type: GeminiEventType.Content, + value: 'Result', + }, + ]); + + mockSendMessageStream + .mockReturnValueOnce(stream1) + .mockReturnValueOnce(stream2); + + const result = await session.prompt({ + sessionId: 'session-1', + prompt: [{ type: 'text', text: 'Call tool' }], + }); + + expect(mockToolRegistry.getTool).toHaveBeenCalledWith('test_tool'); + expect(result).toMatchObject({ stopReason: 'end_turn' }); + }); + + it('should handle tool call permission request', async () => { + const confirmationDetails = { + type: 'info', + onConfirm: vi.fn(), + }; + mockTool.build.mockReturnValue({ + getDescription: () => 'Test Tool', + toolLocations: () => [], + shouldConfirmExecute: vi.fn().mockResolvedValue(confirmationDetails), + execute: vi.fn().mockResolvedValue({ llmContent: 'Tool Result' }), + }); + + mockConnection.requestPermission.mockResolvedValue({ + outcome: { + outcome: 'selected', + optionId: 'proceed_once', + }, + }); + + const stream1 = createMockStream([ + { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'call-1', + name: 'test_tool', + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-1', + }, + }, + ]); + const stream2 = createMockStream([ + { + type: GeminiEventType.Content, + value: '', + }, + ]); + + mockSendMessageStream + .mockReturnValueOnce(stream1) + .mockReturnValueOnce(stream2); + + await session.prompt({ + sessionId: 'session-1', + prompt: [{ type: 'text', text: 'Call tool' }], + }); + + expect(mockConnection.requestPermission).toHaveBeenCalled(); + expect(confirmationDetails.onConfirm).toHaveBeenCalled(); + }); + + it('should handle @path resolution', async () => { + (path.resolve as unknown as Mock).mockReturnValue('/tmp/file.txt'); + (fs.stat as unknown as Mock).mockResolvedValue({ + isDirectory: () => false, + }); + + const stream = createMockStream([ + { + type: GeminiEventType.Content, + value: '', + }, + ]); + mockSendMessageStream.mockReturnValue(stream); + + await session.prompt({ + sessionId: 'session-1', + prompt: [ + { type: 'text', text: 'Read' }, + { + type: 'resource_link', + uri: 'file://file.txt', + mimeType: 'text/plain', + name: 'file.txt', + }, + ], + }); + + expect(path.resolve).toHaveBeenCalled(); + expect(fs.stat).toHaveBeenCalled(); + expect(mockSendMessageStream).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + text: expect.stringContaining('Content from @file.txt'), + }), + ]), + expect.any(AbortSignal), + expect.any(String), + ); + }); + + it('should handle rate limit error', async () => { + const error = new Error('Rate limit'); + const customError = error as { status?: number; message?: string }; + customError.status = 429; + + mockSendMessageStream.mockImplementation(() => { + async function* errorGen(): AsyncGenerator< + ServerGeminiStreamEvent, + void, + unknown + > { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + yield* [] as any; + throw customError; + } + return errorGen(); + }); + + await expect( + session.prompt({ + sessionId: 'session-1', + prompt: [{ type: 'text', text: 'Hi' }], + }), + ).rejects.toMatchObject({ + code: 429, + message: 'Rate limit exceeded. Try again later.', + }); + }); + + it('should handle missing tool', async () => { + mockToolRegistry.getTool.mockReturnValue(undefined); + + const stream1 = createMockStream([ + { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'call-1', + name: 'unknown_tool', + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-1', + }, + }, + ]); + const stream2 = createMockStream([ + { + type: GeminiEventType.Content, + value: '', + }, + ]); + + mockSendMessageStream + .mockReturnValueOnce(stream1) + .mockReturnValueOnce(stream2); + + await session.prompt({ + sessionId: 'session-1', + prompt: [{ type: 'text', text: 'Call tool' }], + }); + + expect(mockSendMessageStream).toHaveBeenCalledTimes(2); + }); + + it('should handle GeminiEventType.LoopDetected', async () => { + const stream = createMockStream([ + { + type: GeminiEventType.LoopDetected, + }, + ]); + mockSendMessageStream.mockReturnValue(stream); + + const result = await session.prompt({ + sessionId: 'session-1', + prompt: [{ type: 'text', text: 'Trigger Loop Simulation' }], + }); + + expect(result.stopReason).toBe('max_turn_requests'); + }); + + it('should handle GeminiEventType.ContextWindowWillOverflow', async () => { + const stream = createMockStream([ + { + type: GeminiEventType.ContextWindowWillOverflow, + value: { estimatedRequestTokenCount: 1000, remainingTokenCount: 200 }, + }, + ]); + mockSendMessageStream.mockReturnValue(stream); + + const result = await session.prompt({ + sessionId: 'session-1', + prompt: [{ type: 'text', text: 'Trigger Overflow Simulation' }], + }); + + expect(result.stopReason).toBe('max_tokens'); + }); + + it('should handle GeminiEventType.MaxSessionTurns', async () => { + const stream = createMockStream([ + { + type: GeminiEventType.MaxSessionTurns, + }, + ]); + mockSendMessageStream.mockReturnValue(stream); + + const result = await session.prompt({ + sessionId: 'session-1', + prompt: [{ type: 'text', text: 'Trigger Safety Limits' }], + }); + + expect(result.stopReason).toBe('max_turn_requests'); + }); + + it('should send sessionUpdate when approval mode changes', async () => { + const { coreEvents, CoreEvent, ApprovalMode } = await import( + '@google/gemini-cli-core' + ); + + coreEvents.emit(CoreEvent.ApprovalModeChanged, { + sessionId: 'session-1', + mode: ApprovalMode.PLAN, + }); + + expect(mockConnection.sessionUpdate).toHaveBeenCalledWith({ + sessionId: 'session-1', + update: { + sessionUpdate: 'agent_message_chunk', + content: { + type: 'text', + text: `[MODE_UPDATE] ${ApprovalMode.PLAN}`, + }, + }, + }); + }); + + it('should add explanation to tool call content instead of thought chunk', async () => { + mockTool.build.mockReturnValue({ + getDescription: () => 'Test Tool', + getExplanation: () => 'Test Explanation', + toolLocations: () => [], + shouldConfirmExecute: vi + .fn() + .mockResolvedValue({ type: 'info', onConfirm: vi.fn() }), + execute: vi.fn().mockResolvedValue({ llmContent: 'Tool Result' }), + }); + + mockConnection.requestPermission.mockResolvedValue({ + outcome: { + outcome: 'selected', + optionId: 'proceed_once', + }, + }); + + const stream1 = createMockStream([ + { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'call-1', + name: 'test_tool', + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-1', + }, + }, + ]); + const stream2 = createMockStream([ + { + type: GeminiEventType.Content, + value: '', + }, + ]); + + mockSendMessageStream + .mockReturnValueOnce(stream1) + .mockReturnValueOnce(stream2); + + await session.prompt({ + sessionId: 'session-1', + prompt: [{ type: 'text', text: 'Call tool' }], + }); + + expect(mockConnection.sessionUpdate).not.toHaveBeenCalledWith( + expect.objectContaining({ + update: expect.objectContaining({ + sessionUpdate: 'agent_thought_chunk', + content: { type: 'text', text: 'Test Explanation' }, + }), + }), + ); + + expect(mockConnection.requestPermission).toHaveBeenCalledWith( + expect.objectContaining({ + toolCall: expect.objectContaining({ + content: expect.arrayContaining([ + { + type: 'content', + content: { type: 'text', text: 'Test Explanation' }, + }, + ]), + }), + }), + ); + }); + + it('should include both diff and explanation in request_permission content for edit tools', async () => { + mockTool.build.mockReturnValue({ + getDescription: () => 'edit_file(file_path: test.ts)', + getDisplayTitle: () => 'edit_file(file_path: test.ts)', + getExplanation: () => 'Updating configuration value', + toolLocations: () => [], + shouldConfirmExecute: vi.fn().mockResolvedValue({ + type: 'edit', + filePath: 'test.ts', + originalContent: 'old', + newContent: 'new', + onConfirm: vi.fn(), + }), + execute: vi.fn().mockResolvedValue({ llmContent: 'Tool Result' }), + }); + + mockConnection.requestPermission.mockResolvedValue({ + outcome: { + outcome: 'selected', + optionId: 'proceed_once', + }, + }); + + const stream1 = createMockStream([ + { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'call-edit-1', + name: 'test_tool', + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-1', + }, + }, + ]); + const stream2 = createMockStream([ + { + type: GeminiEventType.Content, + value: '', + }, + ]); + + mockSendMessageStream + .mockReturnValueOnce(stream1) + .mockReturnValueOnce(stream2); + + await session.prompt({ + sessionId: 'session-1', + prompt: [{ type: 'text', text: 'Edit file' }], + }); + + expect(mockConnection.requestPermission).toHaveBeenCalledWith( + expect.objectContaining({ + toolCall: expect.objectContaining({ + title: 'edit_file(file_path: test.ts)', + content: [ + expect.objectContaining({ + type: 'diff', + path: 'test.ts', + }), + { + type: 'content', + content: { type: 'text', text: 'Updating configuration value' }, + }, + ], + }), + }), + ); + }); + + it('should add explanation to tool_call update content instead of thought chunk when no permission required', async () => { + mockTool.build.mockReturnValue({ + getDescription: () => 'Test Tool', + getExplanation: () => 'Test Explanation', + toolLocations: () => [], + shouldConfirmExecute: vi.fn().mockResolvedValue(null), + execute: vi.fn().mockResolvedValue({ llmContent: 'Tool Result' }), + }); + + const stream1 = createMockStream([ + { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'call-1', + name: 'test_tool', + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-1', + }, + }, + ]); + const stream2 = createMockStream([ + { + type: GeminiEventType.Content, + value: '', + }, + ]); + + mockSendMessageStream + .mockReturnValueOnce(stream1) + .mockReturnValueOnce(stream2); + + await session.prompt({ + sessionId: 'session-1', + prompt: [{ type: 'text', text: 'Call tool' }], + }); + + expect(mockConnection.sessionUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + update: expect.objectContaining({ + sessionUpdate: 'tool_call', + content: expect.arrayContaining([ + { + type: 'content', + content: { type: 'text', text: 'Test Explanation' }, + }, + ]), + }), + }), + ); + }); + + describe('Policy Handling', () => { + it('should auto-approve tool calls when PolicyEngine returns ALLOW', async () => { + const mockPolicyEngine = mockConfig.getPolicyEngine() as unknown as { + check: Mock< + ( + toolCall: { name: string; args: Record }, + serverName?: string, + toolAnnotations?: Record, + subagent?: string, + ) => Promise<{ decision: PolicyDecision }> + >; + }; + mockPolicyEngine.check.mockResolvedValue({ + decision: PolicyDecision.ALLOW, + }); + + // Trigger the subscription handler + const handler = mockMessageBus.subscribe.mock.calls.find( + (call) => call[0] === MessageBusType.TOOL_CONFIRMATION_REQUEST, + )?.[1] as (request: ToolConfirmationRequest) => Promise; + + expect(handler).toBeDefined(); + + await handler({ + type: MessageBusType.TOOL_CONFIRMATION_REQUEST, + correlationId: 'test-id', + toolCall: { name: 'ls', args: {} }, + }); + + expect(mockMessageBus.publish).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, + correlationId: 'test-id', + confirmed: true, + requiresUserConfirmation: false, + }), + ); + }); + + it('should request user confirmation when PolicyEngine returns ASK_USER', async () => { + const mockPolicyEngine = mockConfig.getPolicyEngine() as unknown as { + check: Mock< + ( + toolCall: { name: string; args: Record }, + serverName?: string, + toolAnnotations?: Record, + subagent?: string, + ) => Promise<{ decision: PolicyDecision }> + >; + }; + mockPolicyEngine.check.mockResolvedValue({ + decision: PolicyDecision.ASK_USER, + }); + + const handler = mockMessageBus.subscribe.mock.calls.find( + (call) => call[0] === MessageBusType.TOOL_CONFIRMATION_REQUEST, + )?.[1] as (request: ToolConfirmationRequest) => Promise; + + await handler({ + type: MessageBusType.TOOL_CONFIRMATION_REQUEST, + correlationId: 'test-id-2', + toolCall: { name: 'rm', args: { path: '/' } }, + }); + + expect(mockMessageBus.publish).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, + correlationId: 'test-id-2', + confirmed: false, + requiresUserConfirmation: true, + }), + ); + }); + + it('should deny tool calls when PolicyEngine returns DENY', async () => { + const mockPolicyEngine = mockConfig.getPolicyEngine() as unknown as { + check: Mock< + ( + toolCall: { name: string; args: Record }, + serverName?: string, + toolAnnotations?: Record, + subagent?: string, + ) => Promise<{ decision: PolicyDecision }> + >; + }; + mockPolicyEngine.check.mockResolvedValue({ + decision: PolicyDecision.DENY, + }); + + const handler = mockMessageBus.subscribe.mock.calls.find( + (call) => call[0] === MessageBusType.TOOL_CONFIRMATION_REQUEST, + )?.[1] as (request: ToolConfirmationRequest) => Promise; + + await handler({ + type: MessageBusType.TOOL_CONFIRMATION_REQUEST, + correlationId: 'test-id-3', + toolCall: { name: 'forbidden', args: {} }, + }); + + expect(mockMessageBus.publish).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, + correlationId: 'test-id-3', + confirmed: false, + requiresUserConfirmation: false, + }), + ); + }); + + it('should pass subagent and trusted tool info to PolicyEngine', async () => { + const mockPolicyEngine = mockConfig.getPolicyEngine() as unknown as { + check: Mock< + ( + toolCall: { name: string; args: Record }, + serverName?: string, + toolAnnotations?: Record, + subagent?: string, + ) => Promise<{ decision: PolicyDecision }> + >; + }; + mockPolicyEngine.check.mockResolvedValue({ + decision: PolicyDecision.ALLOW, + }); + + // Mock tool in registry with trusted annotations + const trustedAnnotations = { safe: true }; + mockToolRegistry.getTool.mockReturnValue({ + name: 'ls', + toolAnnotations: trustedAnnotations, + }); + + const handler = mockMessageBus.subscribe.mock.calls.find( + (call) => call[0] === MessageBusType.TOOL_CONFIRMATION_REQUEST, + )?.[1] as (request: ToolConfirmationRequest) => Promise; + + await handler({ + type: MessageBusType.TOOL_CONFIRMATION_REQUEST, + correlationId: 'test-id-trusted', + toolCall: { name: 'ls', args: {} }, + subagent: 'restricted-subagent', + serverName: 'spoofed-server', // Should be ignored + toolAnnotations: { malicious: true }, // Should be ignored + }); + + expect(mockPolicyEngine.check).toHaveBeenCalledWith( + expect.anything(), + undefined, // serverName for non-MCP tool + trustedAnnotations, + 'restricted-subagent', + ); + }); + + it('should handle exceptions in PolicyEngine by failing closed', async () => { + const mockPolicyEngine = mockConfig.getPolicyEngine() as unknown as { + check: Mock< + ( + toolCall: { name: string; args: Record }, + serverName?: string, + toolAnnotations?: Record, + subagent?: string, + ) => Promise<{ decision: PolicyDecision }> + >; + }; + mockPolicyEngine.check.mockRejectedValue( + new Error('Policy check failed'), + ); + + const handler = mockMessageBus.subscribe.mock.calls.find( + (call) => call[0] === MessageBusType.TOOL_CONFIRMATION_REQUEST, + )?.[1] as (request: ToolConfirmationRequest) => Promise; + + await handler({ + type: MessageBusType.TOOL_CONFIRMATION_REQUEST, + correlationId: 'test-id-error', + toolCall: { name: 'ls', args: {} }, + }); + + expect(mockMessageBus.publish).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, + correlationId: 'test-id-error', + confirmed: false, + requiresUserConfirmation: false, + }), + ); + }); + + it('should fail closed when PolicyEngine is missing', async () => { + (mockConfig.getPolicyEngine as Mock).mockReturnValue(undefined); + + const handler = mockMessageBus.subscribe.mock.calls.find( + (call) => call[0] === MessageBusType.TOOL_CONFIRMATION_REQUEST, + )?.[1] as (request: ToolConfirmationRequest) => Promise; + + await handler({ + type: MessageBusType.TOOL_CONFIRMATION_REQUEST, + correlationId: 'test-id-no-engine', + toolCall: { name: 'ls', args: {} }, + }); + + expect(mockMessageBus.publish).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, + correlationId: 'test-id-no-engine', + confirmed: false, + requiresUserConfirmation: false, + }), + ); + }); + + it('should handle missing tool name in request by failing closed', async () => { + const handler = mockMessageBus.subscribe.mock.calls.find( + (call) => call[0] === MessageBusType.TOOL_CONFIRMATION_REQUEST, + )?.[1] as (request: ToolConfirmationRequest) => Promise; + + await handler({ + type: MessageBusType.TOOL_CONFIRMATION_REQUEST, + correlationId: 'test-id-no-name', + toolCall: { name: '', args: {} }, + }); + + expect(mockMessageBus.publish).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, + correlationId: 'test-id-no-name', + confirmed: false, + requiresUserConfirmation: false, + }), + ); + }); + + it('should trim tool name before lookup and validation', async () => { + const handler = mockMessageBus.subscribe.mock.calls.find( + (call) => call[0] === MessageBusType.TOOL_CONFIRMATION_REQUEST, + )?.[1] as (request: ToolConfirmationRequest) => Promise; + + await handler({ + type: MessageBusType.TOOL_CONFIRMATION_REQUEST, + correlationId: 'test-id-whitespace', + toolCall: { name: ' ', args: {} }, + }); + + expect(mockMessageBus.publish).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, + correlationId: 'test-id-whitespace', + confirmed: false, + requiresUserConfirmation: false, + }), + ); + }); + + it('should pass serverName from DiscoveredMCPTool to PolicyEngine', async () => { + const mockPolicyEngine = mockConfig.getPolicyEngine() as unknown as { + check: Mock< + ( + toolCall: { name: string; args: Record }, + serverName?: string, + toolAnnotations?: Record, + subagent?: string, + ) => Promise<{ decision: PolicyDecision }> + >; + }; + mockPolicyEngine.check.mockResolvedValue({ + decision: PolicyDecision.ALLOW, + }); + + // Mock tool in registry as a DiscoveredMCPTool instance + const mcpTool = { + name: 'mcp_server_tool', + serverName: 'test-server', + toolAnnotations: { mcp: true }, + }; + Object.setPrototypeOf(mcpTool, DiscoveredMCPTool.prototype); + mockToolRegistry.getTool.mockReturnValue(mcpTool); + + const handler = mockMessageBus.subscribe.mock.calls.find( + (call) => call[0] === MessageBusType.TOOL_CONFIRMATION_REQUEST, + )?.[1] as (request: ToolConfirmationRequest) => Promise; + + await handler({ + type: MessageBusType.TOOL_CONFIRMATION_REQUEST, + correlationId: 'test-id-mcp', + toolCall: { name: 'mcp_server_tool', args: {} }, + }); + + expect(mockPolicyEngine.check).toHaveBeenCalledWith( + expect.anything(), + 'test-server', + { mcp: true }, + undefined, + ); + }); + + it('should fail closed and deny unknown tools', async () => { + mockToolRegistry.getTool.mockReturnValue(undefined); + + const handler = mockMessageBus.subscribe.mock.calls.find( + (call) => call[0] === MessageBusType.TOOL_CONFIRMATION_REQUEST, + )?.[1] as (request: ToolConfirmationRequest) => Promise; + + await handler({ + type: MessageBusType.TOOL_CONFIRMATION_REQUEST, + correlationId: 'test-id-unknown', + toolCall: { name: 'unknown_tool', args: {} }, + }); + + expect(mockMessageBus.publish).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, + correlationId: 'test-id-unknown', + confirmed: false, + requiresUserConfirmation: false, + }), + ); + }); + }); +}); diff --git a/packages/cli/src/acp/acpSession.ts b/packages/cli/src/acp/acpSession.ts new file mode 100644 index 0000000000000000000000000000000000000000..bf7936538f0d2f6e4822bcf37e3be3dee309f860 --- /dev/null +++ b/packages/cli/src/acp/acpSession.ts @@ -0,0 +1,1527 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + type ApprovalMode, + type ConversationRecord, + CoreToolCallStatus, + coreEvents, + CoreEvent, + type ApprovalModeChangedPayload, + logToolCall, + convertToFunctionResponse, + ToolConfirmationOutcome, + getErrorStatus, + DiscoveredMCPTool, + ToolCallEvent, + debugLogger, + ReadManyFilesTool, + partListUnionToString, + type AgentLoopContext, + updatePolicy, + getErrorMessage, + type FilterFilesOptions, + isTextPart, + GeminiEventType, + type ToolCallRequestInfo, + type GeminiChat, + type ToolResult, + isWithinRoot, + processSingleFileContent, + isNodeError, + REFERENCE_CONTENT_START, + InvalidStreamError, + MessageBusType, + PolicyDecision, + type ToolConfirmationRequest, + resolveAtCommandPath, + type ResolvedAtCommandPath, +} from '@google/gemini-cli-core'; +import * as acp from '@agentclientprotocol/sdk'; +import type { Part, FunctionCall } from '@google/genai'; +import type { LoadedSettings } from '../config/settings.js'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { CommandHandler } from './acpCommandHandler.js'; +import { + toToolCallContent, + toPermissionOptions, + toAcpToolKind, + buildAvailableModes, + RequestPermissionResponseSchema, +} from './acpUtils.js'; +import { z } from 'zod'; +import { getAcpErrorMessage } from './acpErrors.js'; + +const StructuredErrorSchema = z.object({ + status: z.number().optional(), + message: z.string().optional(), +}); + +export class Session { + private pendingPrompt: AbortController | null = null; + private commandHandler = new CommandHandler(); + private callIdCounter = 0; + private readonly disposeController = new AbortController(); + + private generateCallId(name: string): string { + return `${name}-${Date.now()}-${++this.callIdCounter}`; + } + + constructor( + private readonly id: string, + private readonly chat: GeminiChat, + private readonly context: AgentLoopContext, + private readonly connection: acp.AgentSideConnection, + private readonly settings: LoadedSettings, + ) { + coreEvents.on( + CoreEvent.ApprovalModeChanged, + this.handleApprovalModeChanged, + ); + + // Subscribe to tool confirmation requests to handle policy checks (e.g. auto-allowing safe shell commands) + this.context.config + .getMessageBus() + ?.subscribe( + MessageBusType.TOOL_CONFIRMATION_REQUEST, + this.handleToolConfirmationRequest, + { signal: this.disposeController.signal }, + ); + } + + private handleToolConfirmationRequest = async ( + request: ToolConfirmationRequest, + ) => { + try { + const policyEngine = this.context.config.getPolicyEngine?.(); + const messageBus = this.context.config.getMessageBus(); + + if (!messageBus) { + return; + } + + if (!policyEngine) { + debugLogger.warn( + 'Policy engine missing. Denying tool confirmation request.', + ); + await messageBus.publish({ + type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, + correlationId: request.correlationId, + confirmed: false, + requiresUserConfirmation: false, + }); + return; + } + + const toolName = request.toolCall.name?.trim(); + if (!toolName) { + debugLogger.warn( + 'Tool confirmation request missing tool name. Denying.', + ); + await messageBus.publish({ + type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, + correlationId: request.correlationId, + confirmed: false, + requiresUserConfirmation: false, + }); + return; + } + + const tool = this.context.toolRegistry.getTool(toolName); + if (!tool) { + debugLogger.warn( + `Tool confirmation request for unknown tool: ${toolName}. Denying.`, + ); + await messageBus.publish({ + type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, + correlationId: request.correlationId, + confirmed: false, + requiresUserConfirmation: false, + }); + return; + } + + const serverName = + tool instanceof DiscoveredMCPTool ? tool.serverName : undefined; + const toolAnnotations = tool.toolAnnotations; + + const result = await policyEngine.check( + request.toolCall, + serverName, + toolAnnotations, + request.subagent, + ); + + await messageBus.publish({ + type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, + correlationId: request.correlationId, + confirmed: result.decision === PolicyDecision.ALLOW, + requiresUserConfirmation: result.decision === PolicyDecision.ASK_USER, + }); + } catch (error) { + debugLogger.error('Error handling tool confirmation request:', error); + // Fail closed on exception + await this.context.config.getMessageBus()?.publish({ + type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, + correlationId: request.correlationId, + confirmed: false, + requiresUserConfirmation: false, + }); + } + }; + + private handleApprovalModeChanged = (payload: ApprovalModeChangedPayload) => { + if (payload.sessionId === this.id) { + void this.sendUpdate({ + sessionUpdate: 'agent_message_chunk', + content: { + type: 'text', + text: `[MODE_UPDATE] ${payload.mode}`, + }, + }); + } + }; + + dispose(): void { + coreEvents.off( + CoreEvent.ApprovalModeChanged, + this.handleApprovalModeChanged, + ); + this.disposeController.abort(); + } + + async cancelPendingPrompt(): Promise { + if (!this.pendingPrompt) { + throw new Error('Not currently generating'); + } + + this.pendingPrompt.abort(); + this.pendingPrompt = null; + } + + setMode(modeId: acp.SessionModeId): acp.SetSessionModeResponse { + const availableModes = buildAvailableModes( + this.context.config.isPlanEnabled(), + ); + const mode = availableModes.find((m) => m.id === modeId); + if (!mode) { + throw new Error(`Invalid or unavailable mode: ${modeId}`); + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + this.context.config.setApprovalMode(mode.id as ApprovalMode); + return {}; + } + + private getAvailableCommands() { + return this.commandHandler.getAvailableCommands(); + } + + async sendAvailableCommands(): Promise { + const availableCommands = this.getAvailableCommands().map((command) => ({ + name: command.name, + description: command.description, + })); + + await this.sendUpdate({ + sessionUpdate: 'available_commands_update', + availableCommands, + }); + } + + setModel(modelId: acp.ModelId): acp.SetSessionModelResponse { + this.context.config.setModel(modelId); + return {}; + } + + async streamHistory(messages: ConversationRecord['messages']): Promise { + for (const msg of messages) { + const contentString = partListUnionToString(msg.content); + + if (msg.type === 'user') { + if (contentString.trim()) { + await this.sendUpdate({ + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: contentString }, + }); + } + } else if (msg.type === 'gemini') { + // Thoughts + if (msg.thoughts) { + for (const thought of msg.thoughts) { + const thoughtText = `**${thought.subject}**\n${thought.description}`; + await this.sendUpdate({ + sessionUpdate: 'agent_thought_chunk', + content: { type: 'text', text: thoughtText }, + }); + } + } + + // Message text + if (contentString.trim()) { + await this.sendUpdate({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: contentString }, + }); + } + + // Tool calls + if (msg.toolCalls) { + for (const toolCall of msg.toolCalls) { + const toolCallContent: acp.ToolCallContent[] = []; + if (toolCall.resultDisplay) { + if (typeof toolCall.resultDisplay === 'string') { + toolCallContent.push({ + type: 'content', + content: { type: 'text', text: toolCall.resultDisplay }, + }); + } else if ('fileName' in toolCall.resultDisplay) { + toolCallContent.push({ + type: 'diff', + path: toolCall.resultDisplay.fileName, + oldText: toolCall.resultDisplay.originalContent, + newText: toolCall.resultDisplay.newContent, + }); + } + } + + const tool = this.context.toolRegistry.getTool(toolCall.name); + + await this.sendUpdate({ + sessionUpdate: 'tool_call', + toolCallId: toolCall.id, + status: + toolCall.status === CoreToolCallStatus.Success + ? 'completed' + : 'failed', + title: toolCall.displayName || toolCall.name, + content: toolCallContent, + kind: tool ? toAcpToolKind(tool.kind) : 'other', + }); + } + } + } + } + } + + async prompt(params: acp.PromptRequest): Promise { + this.pendingPrompt?.abort(); + const pendingSend = new AbortController(); + this.pendingPrompt = pendingSend; + + await this.context.config.waitForMcpInit(); + + const promptId = Math.random().toString(16).slice(2); + + const parts = await this.#resolvePrompt(params.prompt, pendingSend.signal); + + // Command interception + let commandText = ''; + + for (const part of parts) { + if (typeof part === 'object' && part !== null) { + if (isTextPart(part)) { + // It is a text part + const text = part.text; + commandText += text; + } else { + // Non-text part (image, embedded resource) + // Stop looking for command + break; + } + } + } + + commandText = commandText.trim(); + + if ( + commandText && + (commandText.startsWith('/') || commandText.startsWith('$')) + ) { + // If we found a command, pass it to handleCommand + // Note: handleCommand currently expects `commandText` to be the command string + // It uses `parts` argument but effectively ignores it in current implementation + const handled = await this.handleCommand(commandText, parts); + if (handled) { + return { + stopReason: 'end_turn', + _meta: { + quota: { + token_count: { input_tokens: 0, output_tokens: 0 }, + model_usage: [], + }, + }, + }; + } + } + + let totalInputTokens = 0; + let totalOutputTokens = 0; + const modelUsageMap = new Map(); + + let currentParts: Part[] = parts; + let turnCount = 0; + const maxTurns = this.context.config.getMaxSessionTurns(); + + while (true) { + turnCount++; + if (maxTurns >= 0 && turnCount > maxTurns) { + return { + stopReason: 'max_turn_requests', + _meta: { + quota: { + token_count: { + input_tokens: totalInputTokens, + output_tokens: totalOutputTokens, + }, + model_usage: Array.from(modelUsageMap.entries()).map( + ([modelName, counts]) => ({ + model: modelName, + token_count: { + input_tokens: counts.input, + output_tokens: counts.output, + }, + }), + ), + }, + }, + }; + } + + if (pendingSend.signal.aborted) { + return { stopReason: 'cancelled' }; + } + + const toolCallRequests: ToolCallRequestInfo[] = []; + let stopReason: acp.StopReason = 'end_turn'; + let turnModelId = this.context.config.getModel(); + let turnInputTokens = 0; + let turnOutputTokens = 0; + + try { + const responseStream = this.context.geminiClient.sendMessageStream( + currentParts, + pendingSend.signal, + promptId, + ); + + for await (const event of responseStream) { + if (pendingSend.signal.aborted) { + return { stopReason: 'cancelled' }; + } + + switch (event.type) { + case GeminiEventType.Content: { + const content: acp.ContentBlock = { + type: 'text', + text: event.value, + }; + + await this.sendUpdate({ + sessionUpdate: 'agent_message_chunk', + content, + }); + break; + } + + case GeminiEventType.Thought: { + const thoughtText = `**${event.value.subject}**\n${event.value.description}`; + await this.sendUpdate({ + sessionUpdate: 'agent_thought_chunk', + content: { type: 'text', text: thoughtText }, + }); + break; + } + + case GeminiEventType.ToolCallRequest: + toolCallRequests.push(event.value); + break; + + case GeminiEventType.Finished: { + const usage = event.value.usageMetadata; + if (usage) { + turnInputTokens = usage.promptTokenCount ?? turnInputTokens; + turnOutputTokens = + usage.candidatesTokenCount ?? turnOutputTokens; + } + break; + } + + case GeminiEventType.ModelInfo: + turnModelId = event.value; + break; + + case GeminiEventType.MaxSessionTurns: + stopReason = 'max_turn_requests'; + break; + + case GeminiEventType.LoopDetected: + stopReason = 'max_turn_requests'; + break; + + case GeminiEventType.ContextWindowWillOverflow: + stopReason = 'max_tokens'; + break; + + case GeminiEventType.Error: { + const parseResult = StructuredErrorSchema.safeParse( + event.value.error, + ); + const errData = parseResult.success ? parseResult.data : {}; + + throw new acp.RequestError( + errData.status ?? 500, + errData.message ?? 'Unknown stream execution error.', + ); + } + + default: + break; + } + } + } catch (error) { + if (getErrorStatus(error) === 429) { + throw new acp.RequestError( + 429, + 'Rate limit exceeded. Try again later.', + ); + } + + if ( + pendingSend.signal.aborted || + (error instanceof Error && error.name === 'AbortError') + ) { + return { stopReason: 'cancelled' }; + } + + if (error instanceof acp.RequestError) { + throw error; + } + + if ( + error instanceof InvalidStreamError || + (error && + typeof error === 'object' && + 'type' in error && + (error.type === 'NO_RESPONSE_TEXT' || + error.type === 'NO_FINISH_REASON' || + error.type === 'MALFORMED_FUNCTION_CALL' || + error.type === 'UNEXPECTED_TOOL_CALL' || + error.type === 'MAX_TOKENS_EXCEEDED' || + error.type === 'SAFETY_BLOCKED' || + error.type === 'RECITATION_BLOCKED' || + error.type === 'OTHER_BLOCKED' || + error.type === 'THINKING_ONLY_RESPONSE')) + ) { + // The stream ended with an empty response or malformed tool call. + // Treat this as a graceful end to the model's turn rather than a crash. + return { + stopReason: 'end_turn', + _meta: { + quota: { + token_count: { + input_tokens: totalInputTokens, + output_tokens: totalOutputTokens, + }, + model_usage: Array.from(modelUsageMap.entries()).map( + ([modelName, counts]) => ({ + model: modelName, + token_count: { + input_tokens: counts.input, + output_tokens: counts.output, + }, + }), + ), + }, + }, + }; + } + + throw new acp.RequestError( + getErrorStatus(error) || 500, + getAcpErrorMessage(error), + ); + } + + totalInputTokens += turnInputTokens; + totalOutputTokens += turnOutputTokens; + + if (turnInputTokens > 0 || turnOutputTokens > 0) { + const existing = modelUsageMap.get(turnModelId) ?? { + input: 0, + output: 0, + }; + existing.input += turnInputTokens; + existing.output += turnOutputTokens; + modelUsageMap.set(turnModelId, existing); + } + + if (stopReason !== 'end_turn') { + return { + stopReason, + _meta: { + quota: { + token_count: { + input_tokens: totalInputTokens, + output_tokens: totalOutputTokens, + }, + model_usage: Array.from(modelUsageMap.entries()).map( + ([modelName, counts]) => ({ + model: modelName, + token_count: { + input_tokens: counts.input, + output_tokens: counts.output, + }, + }), + ), + }, + }, + }; + } + + if (toolCallRequests.length === 0) { + break; + } + + const toolResponseParts: Part[] = []; + for (const tReq of toolCallRequests) { + const fc: FunctionCall = { + id: tReq.callId, + name: tReq.name, + args: tReq.args, + }; + + const response = await this.runTool(pendingSend.signal, promptId, fc); + toolResponseParts.push(...response); + } + + currentParts = toolResponseParts; + } + + const modelUsageArray = Array.from(modelUsageMap.entries()).map( + ([modelName, counts]) => ({ + model: modelName, + token_count: { + input_tokens: counts.input, + output_tokens: counts.output, + }, + }), + ); + + return { + stopReason: 'end_turn', + _meta: { + quota: { + token_count: { + input_tokens: totalInputTokens, + output_tokens: totalOutputTokens, + }, + model_usage: modelUsageArray, + }, + }, + }; + } + + private async handleCommand( + commandText: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + parts: Part[], + ): Promise { + const gitService = await this.context.config.getGitService(); + const commandContext = { + agentContext: this.context, + settings: this.settings, + git: gitService, + sendMessage: async (text: string) => { + await this.sendUpdate({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text }, + }); + }, + }; + + return this.commandHandler.handleCommand(commandText, commandContext); + } + + private async sendUpdate(update: acp.SessionUpdate): Promise { + const params: acp.SessionNotification = { + sessionId: this.id, + update, + }; + + await this.connection.sessionUpdate(params); + } + + private async runTool( + abortSignal: AbortSignal, + promptId: string, + fc: FunctionCall, + ): Promise { + const callId = fc.id ?? this.generateCallId(fc.name || 'unknown'); + const args = fc.args ?? {}; + + const startTime = Date.now(); + + const errorResponse = (error: Error) => { + const durationMs = Date.now() - startTime; + logToolCall( + this.context.config, + new ToolCallEvent( + undefined, + fc.name ?? '', + args, + durationMs, + false, + promptId, + typeof tool !== 'undefined' && tool instanceof DiscoveredMCPTool + ? 'mcp' + : 'native', + error.message, + ), + ); + + return [ + { + functionResponse: { + id: callId, + name: fc.name ?? '', + response: { error: error.message }, + }, + }, + ]; + }; + + if (!fc.name) { + return errorResponse(new Error('Missing function name')); + } + + const toolRegistry = this.context.toolRegistry; + const tool = toolRegistry.getTool(fc.name); + + if (!tool) { + return errorResponse( + new Error(`Tool "${fc.name}" not found in registry.`), + ); + } + + try { + const invocation = tool.build(args); + + const displayTitle = + typeof invocation.getDisplayTitle === 'function' + ? invocation.getDisplayTitle() + : invocation.getDescription(); + + const explanation = + typeof invocation.getExplanation === 'function' + ? invocation.getExplanation() + : ''; + + const confirmationDetails = + await invocation.shouldConfirmExecute(abortSignal); + + if (confirmationDetails) { + const content: acp.ToolCallContent[] = []; + + if (confirmationDetails.type === 'edit') { + content.push({ + type: 'diff', + path: confirmationDetails.filePath, + oldText: confirmationDetails.originalContent, + newText: confirmationDetails.newContent, + _meta: { + kind: !confirmationDetails.originalContent + ? 'add' + : confirmationDetails.newContent === '' + ? 'delete' + : 'modify', + }, + }); + } + + if (explanation) { + content.push({ + type: 'content', + content: { type: 'text', text: explanation }, + }); + } + + const params: acp.RequestPermissionRequest = { + sessionId: this.id, + options: toPermissionOptions( + confirmationDetails, + this.context.config, + this.settings.merged.security.enablePermanentToolApproval, + ), + toolCall: { + toolCallId: callId, + status: 'pending', + title: displayTitle, + content, + locations: invocation.toolLocations(), + kind: toAcpToolKind(tool.kind), + }, + }; + + const output = RequestPermissionResponseSchema.parse( + await this.connection.requestPermission(params), + ); + + const outcome = + output.outcome.outcome === 'cancelled' + ? ToolConfirmationOutcome.Cancel + : z + .nativeEnum(ToolConfirmationOutcome) + .parse(output.outcome.optionId); + + await confirmationDetails.onConfirm(outcome); + + // Update policy to enable Always Allow persistence + await updatePolicy( + tool, + outcome, + confirmationDetails, + this.context, + this.context.messageBus, + invocation, + ); + + switch (outcome) { + case ToolConfirmationOutcome.Cancel: + return errorResponse( + new Error(`Tool "${fc.name}" was canceled by the user.`), + ); + case ToolConfirmationOutcome.ProceedOnce: + case ToolConfirmationOutcome.ProceedAlways: + case ToolConfirmationOutcome.ProceedAlwaysAndSave: + case ToolConfirmationOutcome.ProceedAlwaysServer: + case ToolConfirmationOutcome.ProceedAlwaysTool: + case ToolConfirmationOutcome.ModifyWithEditor: + break; + default: { + const resultOutcome: never = outcome; + throw new Error(`Unexpected: ${resultOutcome}`); + } + } + } else { + const content: acp.ToolCallContent[] = []; + + if (explanation) { + content.push({ + type: 'content', + content: { type: 'text', text: explanation }, + }); + } + + await this.sendUpdate({ + sessionUpdate: 'tool_call', + toolCallId: callId, + status: 'in_progress', + title: displayTitle, + content, + locations: invocation.toolLocations(), + kind: toAcpToolKind(tool.kind), + }); + } + + const toolResult: ToolResult = await invocation.execute({ + abortSignal, + }); + const content = toToolCallContent(toolResult); + + const updateContent: acp.ToolCallContent[] = content ? [content] : []; + + await this.sendUpdate({ + sessionUpdate: 'tool_call_update', + toolCallId: callId, + status: 'completed', + title: displayTitle, + content: updateContent, + locations: invocation.toolLocations(), + kind: toAcpToolKind(tool.kind), + }); + + const durationMs = Date.now() - startTime; + logToolCall( + this.context.config, + new ToolCallEvent( + undefined, + fc.name ?? '', + args, + durationMs, + true, + promptId, + typeof tool !== 'undefined' && tool instanceof DiscoveredMCPTool + ? 'mcp' + : 'native', + ), + ); + + this.chat.recordCompletedToolCalls(this.context.config.getActiveModel(), [ + { + status: CoreToolCallStatus.Success, + request: { + callId, + name: fc.name, + args, + isClientInitiated: false, + prompt_id: promptId, + }, + tool, + invocation, + response: { + callId, + responseParts: convertToFunctionResponse( + fc.name, + callId, + toolResult.llmContent, + this.context.config.getActiveModel(), + this.context.config, + ), + resultDisplay: toolResult.returnDisplay, + error: undefined, + errorType: undefined, + }, + }, + ]); + + return convertToFunctionResponse( + fc.name, + callId, + toolResult.llmContent, + this.context.config.getActiveModel(), + this.context.config, + ); + } catch (e) { + const error = e instanceof Error ? e : new Error(String(e)); + + await this.sendUpdate({ + sessionUpdate: 'tool_call_update', + toolCallId: callId, + status: 'failed', + content: [ + { type: 'content', content: { type: 'text', text: error.message } }, + ], + kind: toAcpToolKind(tool.kind), + }); + + this.chat.recordCompletedToolCalls(this.context.config.getActiveModel(), [ + { + status: CoreToolCallStatus.Error, + request: { + callId, + name: fc.name, + args, + isClientInitiated: false, + prompt_id: promptId, + }, + tool, + response: { + callId, + responseParts: [ + { + functionResponse: { + id: callId, + name: fc.name ?? '', + response: { error: error.message }, + }, + }, + ], + resultDisplay: error.message, + error, + errorType: undefined, + }, + }, + ]); + + return errorResponse(error); + } + } + + async #resolvePrompt( + message: acp.ContentBlock[], + abortSignal: AbortSignal, + ): Promise { + const FILE_URI_SCHEME = 'file://'; + + const embeddedContext: acp.EmbeddedResourceResource[] = []; + + const parts = message.map((part) => { + switch (part.type) { + case 'text': + return { text: part.text }; + case 'image': + case 'audio': + return { + inlineData: { + mimeType: part.mimeType, + data: part.data, + }, + }; + case 'resource_link': { + if (part.uri.startsWith(FILE_URI_SCHEME)) { + return { + fileData: { + mimeData: part.mimeType, + name: part.name, + fileUri: part.uri.slice(FILE_URI_SCHEME.length), + }, + }; + } else { + return { text: `@${part.uri}` }; + } + } + case 'resource': { + embeddedContext.push(part.resource); + return { text: `@${part.resource.uri}` }; + } + default: { + const unreachable: never = part; + throw new Error(`Unexpected chunk type: '${unreachable}'`); + } + } + }); + + const atPathCommandParts = parts.filter((part) => 'fileData' in part); + + if (atPathCommandParts.length === 0 && embeddedContext.length === 0) { + return parts; + } + + const atPathToResolvedSpecMap = new Map(); + + // Get centralized file discovery service + const fileDiscovery = this.context.config.getFileService(); + const fileFilteringOptions: FilterFilesOptions = + this.context.config.getFileFilteringOptions(); + + const pathSpecsToRead: string[] = []; + const contentLabelsForDisplay: string[] = []; + const ignoredPaths: string[] = []; + const directContents: Array<{ + spec: string; + content?: string; + part?: Part; + }> = []; + + const toolRegistry = this.context.toolRegistry; + const readManyFilesTool = new ReadManyFilesTool( + this.context.config, + this.context.messageBus, + ); + const globTool = toolRegistry.getTool('glob'); + + if (!readManyFilesTool) { + throw new Error('Error: read_many_files tool not found.'); + } + + for (const atPathPart of atPathCommandParts) { + const pathName = atPathPart.fileData!.fileUri; + // Check if path should be ignored + if (fileDiscovery.shouldIgnoreFile(pathName, fileFilteringOptions)) { + ignoredPaths.push(pathName); + debugLogger.warn(`Path ${pathName} is ignored and will be skipped.`); + continue; + } + let currentPathSpec = pathName; + let resolvedSuccessfully = false; + let readDirectly = false; + + const result = await resolveAtCommandPath( + pathName, + this.context.config, + (msg) => this.debug(msg), + ); + + let validationError: string | null = null; + let absolutePath: string; + let resolved: ResolvedAtCommandPath | undefined; + + if (result.status === 'resolved') { + resolved = result.resolved; + absolutePath = resolved.absolutePath; + } else if (result.status === 'unauthorized') { + absolutePath = result.absolutePath; + validationError = result.error; + } else if (result.status === 'invalid') { + // Already logged in resolveAtCommandPath + continue; + } else { + // Result is not_found. + // We still check if it's an unauthorized absolute path that we can ask permission for, + // specifically for paths that are completely outside the root and not even in any workspace directory. + // For relative paths not found anywhere, we resolve relative to targetDir for permission check. + absolutePath = path.resolve( + this.context.config.getTargetDir(), + pathName, + ); + } + + if ( + !resolved && + validationError && + !isWithinRoot(absolutePath, this.context.config.getTargetDir()) + ) { + try { + const stats = await fs.stat(absolutePath); + if (stats.isFile()) { + const syntheticCallId = `resolve-prompt-${pathName}-${randomUUID()}`; + const params = { + sessionId: this.id, + options: [ + { + optionId: ToolConfirmationOutcome.ProceedOnce, + name: 'Allow once', + kind: 'allow_once', + }, + { + optionId: ToolConfirmationOutcome.Cancel, + name: 'Deny', + kind: 'reject_once', + }, + ] as acp.PermissionOption[], + toolCall: { + toolCallId: syntheticCallId, + status: 'pending', + title: `Allow access to absolute path: ${pathName}`, + content: [ + { + type: 'content', + content: { + type: 'text', + text: `The Agent needs access to read an attached file outside your workspace: ${pathName}`, + }, + }, + ], + locations: [], + kind: 'read', + }, + }; + + const output = RequestPermissionResponseSchema.parse( + await this.connection.requestPermission(params), + ); + + const outcome = + output.outcome.outcome === 'cancelled' + ? ToolConfirmationOutcome.Cancel + : z + .nativeEnum(ToolConfirmationOutcome) + .parse(output.outcome.optionId); + + if (outcome === ToolConfirmationOutcome.ProceedOnce) { + this.context.config + .getWorkspaceContext() + .addReadOnlyPath(absolutePath); + validationError = null; + } else { + this.debug( + `Direct read authorization denied for absolute path ${pathName}`, + ); + directContents.push({ + spec: pathName, + content: `[Warning: Access to absolute path \`${pathName}\` denied by user.]`, + }); + continue; + } + } + } catch (error) { + this.debug( + `Failed to request permission for absolute attachment ${pathName}: ${getErrorMessage(error)}`, + ); + await this.sendUpdate({ + sessionUpdate: 'agent_thought_chunk', + content: { + type: 'text', + text: `Warning: Failed to display permission dialog for \`${absolutePath}\`. Error: ${getErrorMessage(error)}`, + }, + }); + } + } + + try { + if (!validationError) { + // If it's an absolute path that is authorized (e.g. added via readOnlyPaths), + // read it directly to avoid ReadManyFilesTool absolute path resolution issues. + if ( + (path.isAbsolute(pathName) || + !isWithinRoot( + absolutePath, + this.context.config.getTargetDir(), + )) && + !readDirectly + ) { + try { + const stats = resolved + ? resolved.stats + : await fs.stat(absolutePath); + if (stats.isFile()) { + const fileReadResult = await processSingleFileContent( + absolutePath, + this.context.config.getTargetDir(), + this.context.config.getFileSystemService(), + ); + + if (!fileReadResult.error) { + if ( + typeof fileReadResult.llmContent === 'object' && + 'inlineData' in fileReadResult.llmContent + ) { + directContents.push({ + spec: pathName, + part: fileReadResult.llmContent, + }); + } else if (typeof fileReadResult.llmContent === 'string') { + let contentToPush = fileReadResult.llmContent; + if (fileReadResult.isTruncated) { + contentToPush = `[WARNING: This file was truncated]\n\n${contentToPush}`; + } + directContents.push({ + spec: pathName, + content: contentToPush, + }); + } + readDirectly = true; + resolvedSuccessfully = true; + } else { + this.debug( + `Direct read failed for absolute path ${pathName}: ${fileReadResult.error}`, + ); + await this.sendUpdate({ + sessionUpdate: 'agent_thought_chunk', + content: { + type: 'text', + text: `Warning: file read failed for \`${pathName}\`. Reason: ${fileReadResult.error}`, + }, + }); + continue; + } + } + } catch (error) { + this.debug( + `File stat/access error for absolute path ${pathName}: ${getErrorMessage(error)}`, + ); + await this.sendUpdate({ + sessionUpdate: 'agent_thought_chunk', + content: { + type: 'text', + text: `Warning: file access failed for \`${pathName}\`. Reason: ${getErrorMessage(error)}`, + }, + }); + continue; + } + } + + if (!readDirectly) { + const stats = resolved + ? resolved.stats + : await fs.stat(absolutePath); + if (stats.isDirectory()) { + currentPathSpec = pathName.endsWith('/') + ? `${pathName}**` + : `${pathName}/**`; + this.debug( + `Path ${pathName} resolved to directory, using glob: ${currentPathSpec}`, + ); + } else { + this.debug( + `Path ${pathName} resolved to file: ${currentPathSpec}`, + ); + } + resolvedSuccessfully = true; + } + } else { + this.debug( + `Path ${pathName} access disallowed: ${validationError}. Skipping.`, + ); + await this.sendUpdate({ + sessionUpdate: 'agent_thought_chunk', + content: { + type: 'text', + text: `Warning: skipping access to \`${pathName}\`. Reason: ${validationError}`, + }, + }); + } + } catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') { + if (this.context.config.getEnableRecursiveFileSearch() && globTool) { + this.debug( + `Path ${pathName} not found directly, attempting glob search.`, + ); + try { + const globResult = await globTool.buildAndExecute( + { + pattern: `**/*${pathName}*`, + path: this.context.config.getTargetDir(), + }, + abortSignal, + ); + if ( + globResult.llmContent && + typeof globResult.llmContent === 'string' && + !globResult.llmContent.startsWith('No files found') && + !globResult.llmContent.startsWith('Error:') + ) { + const lines = globResult.llmContent.split('\n'); + if (lines.length > 1 && lines[1]) { + const firstMatchAbsolute = lines[1].trim(); + currentPathSpec = path.relative( + this.context.config.getTargetDir(), + firstMatchAbsolute, + ); + this.debug( + `Glob search for ${pathName} found ${firstMatchAbsolute}, using relative path: ${currentPathSpec}`, + ); + resolvedSuccessfully = true; + } else { + this.debug( + `Glob search for '**/*${pathName}*' did not return a usable path. Path ${pathName} will be skipped.`, + ); + } + } else { + this.debug( + `Glob search for '**/*${pathName}*' found no files or an error. Path ${pathName} will be skipped.`, + ); + } + } catch (globError) { + debugLogger.error( + `Error during glob search for ${pathName}: ${getErrorMessage(globError)}`, + ); + } + } else { + this.debug( + `Glob tool not found. Path ${pathName} will be skipped.`, + ); + } + } else { + debugLogger.error( + `Error stating path ${pathName}. Path ${pathName} will be skipped.`, + ); + } + } + if (resolvedSuccessfully) { + if (!readDirectly) { + pathSpecsToRead.push(currentPathSpec); + } + atPathToResolvedSpecMap.set(pathName, currentPathSpec); + contentLabelsForDisplay.push(pathName); + } + } + + // Construct the initial part of the query for the LLM + let initialQueryText = ''; + for (let i = 0; i < parts.length; i++) { + const chunk = parts[i]; + if ('text' in chunk) { + initialQueryText += chunk.text; + } else { + // type === 'atPath' + const resolvedSpec = + chunk.fileData && atPathToResolvedSpecMap.get(chunk.fileData.fileUri); + if ( + i > 0 && + initialQueryText.length > 0 && + !initialQueryText.endsWith(' ') && + resolvedSpec + ) { + // Add space if previous part was text and didn't end with space, or if previous was @path + const prevPart = parts[i - 1]; + if ( + 'text' in prevPart || + ('fileData' in prevPart && + atPathToResolvedSpecMap.has(prevPart.fileData!.fileUri)) + ) { + initialQueryText += ' '; + } + } + if (resolvedSpec) { + initialQueryText += `@${resolvedSpec}`; + } else { + // If not resolved for reading (e.g. lone @ or invalid path that was skipped), + // add the original @-string back, ensuring spacing if it's not the first element. + if ( + i > 0 && + initialQueryText.length > 0 && + !initialQueryText.endsWith(' ') && + !chunk.fileData?.fileUri.startsWith(' ') + ) { + initialQueryText += ' '; + } + if (chunk.fileData?.fileUri) { + initialQueryText += `@${chunk.fileData.fileUri}`; + } + } + } + } + initialQueryText = initialQueryText.trim(); + // Inform user about ignored paths + if (ignoredPaths.length > 0) { + this.debug( + `Ignored ${ignoredPaths.length} files: ${ignoredPaths.join(', ')}`, + ); + } + + const processedQueryParts: Part[] = [{ text: initialQueryText }]; + + if ( + pathSpecsToRead.length === 0 && + embeddedContext.length === 0 && + directContents.length === 0 + ) { + // Fallback for lone "@" or completely invalid @-commands resulting in empty initialQueryText + debugLogger.warn('No valid file paths found in @ commands to read.'); + return [{ text: initialQueryText }]; + } + + if (pathSpecsToRead.length > 0) { + const toolArgs = { + include: pathSpecsToRead, + }; + + const callId = this.generateCallId(readManyFilesTool.name); + + try { + const invocation = readManyFilesTool.build(toolArgs); + + const displayTitle = + typeof invocation.getDisplayTitle === 'function' + ? invocation.getDisplayTitle() + : invocation.getDescription(); + + await this.sendUpdate({ + sessionUpdate: 'tool_call', + toolCallId: callId, + status: 'in_progress', + title: displayTitle, + content: [], + locations: invocation.toolLocations(), + kind: toAcpToolKind(readManyFilesTool.kind), + }); + + const result = await invocation.execute({ abortSignal }); + const content = toToolCallContent(result) || { + type: 'content', + content: { + type: 'text', + text: `Successfully read: ${contentLabelsForDisplay.join(', ')}`, + }, + }; + await this.sendUpdate({ + sessionUpdate: 'tool_call_update', + toolCallId: callId, + status: 'completed', + title: displayTitle, + content: content ? [content] : [], + locations: invocation.toolLocations(), + kind: toAcpToolKind(readManyFilesTool.kind), + }); + if (Array.isArray(result.llmContent)) { + const fileContentRegex = /^--- (.*?) ---\n\n([\s\S]*?)\n\n$/; + processedQueryParts.push({ + text: `\n${REFERENCE_CONTENT_START}`, + }); + for (const part of result.llmContent) { + if (typeof part === 'string') { + const match = fileContentRegex.exec(part); + if (match) { + const filePathSpecInContent = match[1]; // This is a resolved pathSpec + const fileActualContent = match[2].trim(); + processedQueryParts.push({ + text: `\nContent from @${filePathSpecInContent}:\n`, + }); + processedQueryParts.push({ text: fileActualContent }); + } else { + processedQueryParts.push({ text: part }); + } + } else { + // part is a Part object. + processedQueryParts.push(part); + } + } + } else { + debugLogger.warn( + 'read_many_files tool returned no content or empty content.', + ); + } + } catch (error: unknown) { + await this.sendUpdate({ + sessionUpdate: 'tool_call_update', + toolCallId: callId, + status: 'failed', + content: [ + { + type: 'content', + content: { + type: 'text', + text: `Error reading files (${contentLabelsForDisplay.join(', ')}): ${getErrorMessage(error)}`, + }, + }, + ], + kind: toAcpToolKind(readManyFilesTool.kind), + }); + + throw error; + } + } + + if (directContents.length > 0) { + const hasReferenceStart = processedQueryParts.some( + (p) => + 'text' in p && + typeof p.text === 'string' && + p.text.includes(REFERENCE_CONTENT_START), + ); + if (!hasReferenceStart) { + processedQueryParts.push({ + text: `\n${REFERENCE_CONTENT_START}`, + }); + } + for (const item of directContents) { + processedQueryParts.push({ + text: `\nContent from @${item.spec}:\n`, + }); + if (item.content) { + processedQueryParts.push({ text: item.content }); + } else if (item.part) { + processedQueryParts.push(item.part); + } + } + } + + if (embeddedContext.length > 0) { + processedQueryParts.push({ + text: '\n--- Content from referenced context ---', + }); + + for (const contextPart of embeddedContext) { + processedQueryParts.push({ + text: `\nContent from @${contextPart.uri}:\n`, + }); + if ('text' in contextPart) { + processedQueryParts.push({ + text: contextPart.text, + }); + } else { + processedQueryParts.push({ + inlineData: { + mimeType: contextPart.mimeType ?? 'application/octet-stream', + data: contextPart.blob, + }, + }); + } + } + } + + return processedQueryParts; + } + + debug(msg: string) { + if (this.context.config.getDebugMode()) { + debugLogger.warn(msg); + } + } +} diff --git a/packages/cli/src/acp/acpSessionManager.test.ts b/packages/cli/src/acp/acpSessionManager.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..adeb07e1e4bc2dc5f228d0034a4ad7342d96b272 --- /dev/null +++ b/packages/cli/src/acp/acpSessionManager.test.ts @@ -0,0 +1,382 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, + type Mock, + type Mocked, +} from 'vitest'; +import { AcpSessionManager } from './acpSessionManager.js'; +import type * as acp from '@agentclientprotocol/sdk'; +import { + AuthType, + type Config, + GEMINI_MODEL_ALIAS_AUTO, + type MessageBus, + type Storage, +} from '@google/gemini-cli-core'; +import type { LoadedSettings } from '../config/settings.js'; +import { loadCliConfig, type CliArgs } from '../config/config.js'; +import { loadSettings } from '../config/settings.js'; + +vi.mock('../config/config.js', () => ({ + loadCliConfig: vi.fn(), +})); + +vi.mock('../config/settings.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadSettings: vi.fn(), + }; +}); + +const startAutoMemoryIfEnabledMock = vi.fn(); +vi.mock('../utils/autoMemory.js', () => ({ + startAutoMemoryIfEnabled: (config: Config) => + startAutoMemoryIfEnabledMock(config), +})); + +describe('AcpSessionManager', () => { + let mockConfig: Mocked; + let mockSettings: Mocked; + let mockArgv: CliArgs; + let mockConnection: Mocked; + let manager: AcpSessionManager; + + beforeEach(() => { + mockConfig = { + refreshAuth: vi.fn(), + initialize: vi.fn(), + waitForMcpInit: vi.fn(), + getFileSystemService: vi.fn(), + setFileSystemService: vi.fn(), + getContentGeneratorConfig: vi.fn(), + getActiveModel: vi.fn().mockReturnValue('gemini-pro'), + getModel: vi.fn().mockReturnValue('gemini-pro'), + getGeminiClient: vi.fn().mockReturnValue({ + startChat: vi.fn().mockResolvedValue({}), + }), + getMessageBus: vi.fn().mockReturnValue({ + publish: vi.fn(), + subscribe: vi.fn(), + unsubscribe: vi.fn(), + }), + getApprovalMode: vi.fn().mockReturnValue('default'), + isPlanEnabled: vi.fn().mockReturnValue(true), + getGemini31LaunchedSync: vi.fn().mockReturnValue(false), + getHasAccessToPreviewModel: vi.fn().mockReturnValue(false), + getCheckpointingEnabled: vi.fn().mockReturnValue(false), + getDisableAlwaysAllow: vi.fn().mockReturnValue(false), + validatePathAccess: vi.fn().mockReturnValue(null), + getWorkspaceContext: vi.fn().mockReturnValue({ + addReadOnlyPath: vi.fn(), + getDirectories: vi.fn().mockReturnValue(['/tmp']), + }), + getPolicyEngine: vi.fn().mockReturnValue({ + addRule: vi.fn(), + }), + messageBus: { + publish: vi.fn(), + subscribe: vi.fn(), + unsubscribe: vi.fn(), + } as unknown as MessageBus, + storage: { + getWorkspaceAutoSavedPolicyPath: vi.fn(), + getAutoSavedPolicyPath: vi.fn(), + } as unknown as Storage, + + get config() { + return this; + }, + } as unknown as Mocked; + mockSettings = { + merged: { + security: { auth: { selectedType: 'login_with_google' } }, + mcpServers: {}, + }, + setValue: vi.fn(), + } as unknown as Mocked; + mockArgv = {} as unknown as CliArgs; + mockConnection = { + sessionUpdate: vi.fn(), + requestPermission: vi.fn(), + } as unknown as Mocked; + + (loadCliConfig as unknown as Mock).mockResolvedValue(mockConfig); + (loadSettings as unknown as Mock).mockImplementation(() => ({ + merged: { + security: { + auth: { selectedType: AuthType.LOGIN_WITH_GOOGLE }, + enablePermanentToolApproval: true, + }, + mcpServers: {}, + }, + setValue: vi.fn(), + })); + + manager = new AcpSessionManager(mockSettings, mockArgv, mockConnection); + vi.mock('node:crypto', () => ({ + randomUUID: () => 'test-session-id', + })); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should create a new session', async () => { + vi.useFakeTimers(); + mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({ + apiKey: 'test-key', + }); + const response = await manager.newSession( + { + cwd: '/tmp', + mcpServers: [], + }, + {}, + ); + + expect(response.sessionId).toBe('test-session-id'); + expect(loadCliConfig).toHaveBeenCalled(); + expect(mockConfig.initialize).toHaveBeenCalled(); + expect(mockConfig.getGeminiClient).toHaveBeenCalled(); + + // Verify deferred call (sendAvailableCommands) + await vi.runAllTimersAsync(); + expect(mockConnection.sessionUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + update: expect.objectContaining({ + sessionUpdate: 'available_commands_update', + }), + }), + ); + vi.useRealTimers(); + }); + + it('should return modes without plan mode when plan is disabled', async () => { + mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({ + apiKey: 'test-key', + }); + mockConfig.isPlanEnabled = vi.fn().mockReturnValue(false); + mockConfig.getApprovalMode = vi.fn().mockReturnValue('default'); + + const response = await manager.newSession( + { + cwd: '/tmp', + mcpServers: [], + }, + {}, + ); + + expect(response.modes).toEqual({ + availableModes: [ + { id: 'default', name: 'Default', description: 'Prompts for approval' }, + { + id: 'autoEdit', + name: 'Auto Edit', + description: 'Auto-approves edit tools', + }, + { id: 'yolo', name: 'YOLO', description: 'Auto-approves all tools' }, + ], + currentModeId: 'default', + }); + }); + + it('should include preview models when user has access', async () => { + mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({ + apiKey: 'test-key', + }); + mockConfig.getHasAccessToPreviewModel = vi.fn().mockReturnValue(true); + mockConfig.getGemini31LaunchedSync = vi.fn().mockReturnValue(true); + + const response = await manager.newSession( + { + cwd: '/tmp', + mcpServers: [], + }, + {}, + ); + + expect(response.models?.availableModels).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + modelId: GEMINI_MODEL_ALIAS_AUTO, + name: expect.stringContaining('Auto'), + }), + ]), + ); + }); + + it('should NOT include retired preview models (none) in available models', async () => { + mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({ + apiKey: 'test-key', + }); + mockConfig.getHasAccessToPreviewModel = vi.fn().mockReturnValue(true); + mockConfig.getGemini31LaunchedSync = vi.fn().mockReturnValue(true); + + const response = await manager.newSession( + { + cwd: '/tmp', + mcpServers: [], + }, + {}, + ); + + const modelIds = + response.models?.availableModels?.map((m) => m.modelId) ?? []; + expect(modelIds).not.toContain('none'); + }); + + it('should return modes with plan mode when plan is enabled', async () => { + mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({ + apiKey: 'test-key', + }); + mockConfig.isPlanEnabled = vi.fn().mockReturnValue(true); + mockConfig.getApprovalMode = vi.fn().mockReturnValue('plan'); + + const response = await manager.newSession( + { + cwd: '/tmp', + mcpServers: [], + }, + {}, + ); + + expect(response.modes).toEqual({ + availableModes: [ + { id: 'default', name: 'Default', description: 'Prompts for approval' }, + { + id: 'autoEdit', + name: 'Auto Edit', + description: 'Auto-approves edit tools', + }, + { id: 'yolo', name: 'YOLO', description: 'Auto-approves all tools' }, + { id: 'plan', name: 'Plan', description: 'Read-only mode' }, + ], + currentModeId: 'plan', + }); + }); + + it('should fail session creation if Gemini API key is missing', async () => { + (loadSettings as unknown as Mock).mockImplementation(() => ({ + merged: { + security: { auth: { selectedType: AuthType.USE_GEMINI } }, + mcpServers: {}, + }, + setValue: vi.fn(), + })); + mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({ + apiKey: undefined, + }); + + await expect( + manager.newSession( + { + cwd: '/tmp', + mcpServers: [], + }, + {}, + ), + ).rejects.toMatchObject({ + message: 'Gemini API key is missing or not configured.', + }); + }); + + it('should create a new session with mcp servers', async () => { + mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({ + apiKey: 'test-key', + }); + const mcpServers = [ + { + name: 'test-server', + command: 'node', + args: ['server.js'], + env: [{ name: 'KEY', value: 'VALUE' }], + }, + ]; + + await manager.newSession( + { + cwd: '/tmp', + mcpServers, + }, + {}, + ); + + expect(loadCliConfig).toHaveBeenCalledWith( + expect.objectContaining({ + mcpServers: expect.objectContaining({ + 'test-server': expect.objectContaining({ + command: 'node', + args: ['server.js'], + env: { KEY: 'VALUE' }, + }), + }), + }), + 'test-session-id', + mockArgv, + { cwd: '/tmp' }, + ); + }); + + it('should handle authentication failure gracefully', async () => { + mockConfig.refreshAuth.mockRejectedValue(new Error('Auth failed')); + + await expect( + manager.newSession( + { + cwd: '/tmp', + mcpServers: [], + }, + {}, + ), + ).rejects.toMatchObject({ + message: 'Auth failed', + }); + }); + + it('should initialize file system service if client supports it', async () => { + mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({ + apiKey: 'test-key', + }); + manager.setClientCapabilities({ + fs: { readTextFile: true, writeTextFile: true }, + }); + + await manager.newSession( + { + cwd: '/tmp', + mcpServers: [], + }, + {}, + ); + + expect(mockConfig.setFileSystemService).toHaveBeenCalled(); + }); + + it('should start auto memory for new ACP sessions', async () => { + mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({ + apiKey: 'test-key', + }); + + await manager.newSession( + { + cwd: '/tmp', + mcpServers: [], + }, + {}, + ); + + expect(startAutoMemoryIfEnabledMock).toHaveBeenCalledWith(mockConfig); + }); +}); diff --git a/packages/cli/src/acp/acpSessionManager.ts b/packages/cli/src/acp/acpSessionManager.ts new file mode 100644 index 0000000000000000000000000000000000000000..cfa7037a24bc53fb55fb52747b1f1625d6d27414 --- /dev/null +++ b/packages/cli/src/acp/acpSessionManager.ts @@ -0,0 +1,343 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + type Config, + AuthType, + MCPServerConfig, + debugLogger, + startupProfiler, + convertSessionToClientHistory, + createPolicyUpdater, +} from '@google/gemini-cli-core'; +import * as acp from '@agentclientprotocol/sdk'; +import { randomUUID } from 'node:crypto'; +import { loadSettings, type LoadedSettings } from '../config/settings.js'; +import { SessionSelector } from '../utils/sessionUtils.js'; +import { Session } from './acpSession.js'; +import { AcpFileSystemService } from './acpFileSystemService.js'; +import { getAcpErrorMessage } from './acpErrors.js'; +import { buildAvailableModels, buildAvailableModes } from './acpUtils.js'; +import { loadCliConfig, type CliArgs } from '../config/config.js'; +import { startAutoMemoryIfEnabled } from '../utils/autoMemory.js'; + +export interface AuthDetails { + apiKey?: string; + baseUrl?: string; + customHeaders?: Record; +} + +export class AcpSessionManager { + private sessions: Map = new Map(); + private clientCapabilities: acp.ClientCapabilities | undefined; + + constructor( + private settings: LoadedSettings, + private argv: CliArgs, + private connection: acp.AgentSideConnection, + ) {} + + setClientCapabilities(capabilities: acp.ClientCapabilities) { + this.clientCapabilities = capabilities; + } + + getSession(sessionId: string): Session | undefined { + return this.sessions.get(sessionId); + } + + dispose(): void { + for (const session of this.sessions.values()) { + session.dispose(); + } + this.sessions.clear(); + } + + async newSession( + { cwd, mcpServers }: acp.NewSessionRequest, + authDetails: AuthDetails, + ): Promise { + const sessionId = randomUUID(); + const loadedSettings = loadSettings(cwd); + const config = await this.newSessionConfig( + sessionId, + cwd, + mcpServers, + loadedSettings, + ); + + const authType = + loadedSettings.merged.security.auth.selectedType || + (authDetails.baseUrl || process.env['GOOGLE_GEMINI_BASE_URL'] + ? AuthType.GATEWAY + : AuthType.USE_GEMINI); + + let isAuthenticated = false; + let authErrorMessage = ''; + try { + await config.refreshAuth( + authType, + authDetails.apiKey, + authDetails.baseUrl, + authDetails.customHeaders, + ); + isAuthenticated = true; + + // Extra validation for Gemini API key + const contentGeneratorConfig = config.getContentGeneratorConfig(); + if ( + authType === AuthType.USE_GEMINI && + (!contentGeneratorConfig || !contentGeneratorConfig.apiKey) + ) { + isAuthenticated = false; + authErrorMessage = 'Gemini API key is missing or not configured.'; + } + } catch (e) { + isAuthenticated = false; + authErrorMessage = getAcpErrorMessage(e); + debugLogger.error( + `Authentication failed: ${e instanceof Error ? e.stack : e}`, + ); + } + + if (!isAuthenticated) { + throw new acp.RequestError( + -32000, + authErrorMessage || 'Authentication required.', + ); + } + + if (this.clientCapabilities?.fs) { + const acpFileSystemService = new AcpFileSystemService( + this.connection, + sessionId, + this.clientCapabilities.fs, + config.getFileSystemService(), + cwd, + ); + config.setFileSystemService(acpFileSystemService); + } + + await config.initialize(); + startupProfiler.flush(config); + startAutoMemoryIfEnabled(config); + + const geminiClient = config.getGeminiClient(); + + const chat = await geminiClient.startChat(); + + const session = new Session( + sessionId, + chat, + config, + this.connection, + this.settings, + ); + this.sessions.set(sessionId, session); + + setTimeout(() => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + session.sendAvailableCommands(); + }, 0); + + const { availableModels, currentModelId } = buildAvailableModels( + config, + loadedSettings, + ); + + const response = { + sessionId, + modes: { + availableModes: buildAvailableModes(config.isPlanEnabled()), + currentModeId: config.getApprovalMode(), + }, + models: { + availableModels, + currentModelId, + }, + }; + return response; + } + + async loadSession( + { sessionId, cwd, mcpServers }: acp.LoadSessionRequest, + authDetails: AuthDetails, + ): Promise { + const config = await this.initializeSessionConfig( + sessionId, + cwd, + mcpServers, + authDetails, + ); + + const sessionSelector = new SessionSelector(config.storage); + + const { sessionData, sessionPath } = + await sessionSelector.resolveSession(sessionId); + + const clientHistory = convertSessionToClientHistory(sessionData.messages); + + const geminiClient = config.getGeminiClient(); + await geminiClient.initialize(); + await geminiClient.resumeChat(clientHistory, { + conversation: sessionData, + filePath: sessionPath, + }); + + const session = new Session( + sessionId, + geminiClient.getChat(), + config, + this.connection, + this.settings, + ); + + const existingSession = this.sessions.get(sessionId); + if (existingSession) { + existingSession.dispose(); + } + + this.sessions.set(sessionId, session); + + // Stream history back to client + // eslint-disable-next-line @typescript-eslint/no-floating-promises + session.streamHistory(sessionData.messages); + + setTimeout(() => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + session.sendAvailableCommands(); + }, 0); + + const { availableModels, currentModelId } = buildAvailableModels( + config, + this.settings, + ); + + const response = { + modes: { + availableModes: buildAvailableModes(config.isPlanEnabled()), + currentModeId: config.getApprovalMode(), + }, + models: { + availableModels, + currentModelId, + }, + }; + return response; + } + + private async initializeSessionConfig( + sessionId: string, + cwd: string, + mcpServers: acp.McpServer[], + authDetails: AuthDetails, + ): Promise { + const selectedAuthType = + this.settings.merged.security.auth.selectedType || + (authDetails.baseUrl || process.env['GOOGLE_GEMINI_BASE_URL'] + ? AuthType.GATEWAY + : undefined); + + if (!selectedAuthType) { + throw acp.RequestError.authRequired(); + } + + // 1. Create config WITHOUT initializing it (no MCP servers started yet) + const config = await this.newSessionConfig(sessionId, cwd, mcpServers); + + // 2. Authenticate BEFORE initializing configuration or starting MCP servers. + // This satisfies the security requirement to verify the user before executing + // potentially unsafe server definitions. + try { + await config.refreshAuth( + selectedAuthType, + authDetails.apiKey, + authDetails.baseUrl, + authDetails.customHeaders, + ); + } catch (e) { + debugLogger.error(`Authentication failed: ${e}`); + throw acp.RequestError.authRequired(); + } + + // 3. Set the ACP FileSystemService (if supported) before config initialization + if (this.clientCapabilities?.fs) { + const acpFileSystemService = new AcpFileSystemService( + this.connection, + sessionId, + this.clientCapabilities.fs, + config.getFileSystemService(), + cwd, + ); + config.setFileSystemService(acpFileSystemService); + } + + // 4. Now that we are authenticated, it is safe to initialize the config + // which starts the MCP servers and other heavy resources. + await config.initialize(); + startupProfiler.flush(config); + startAutoMemoryIfEnabled(config); + + return config; + } + + async newSessionConfig( + sessionId: string, + cwd: string, + mcpServers: acp.McpServer[], + loadedSettings?: LoadedSettings, + ): Promise { + const currentSettings = loadedSettings || this.settings; + const mergedMcpServers = { ...currentSettings.merged.mcpServers }; + + for (const server of mcpServers) { + if ( + 'type' in server && + (server.type === 'sse' || server.type === 'http') + ) { + // HTTP or SSE MCP server + const headers = Object.fromEntries( + server.headers.map(({ name, value }) => [name, value]), + ); + mergedMcpServers[server.name] = new MCPServerConfig( + undefined, // command + undefined, // args + undefined, // env + undefined, // cwd + server.type === 'sse' ? server.url : undefined, // url (sse) + server.type === 'http' ? server.url : undefined, // httpUrl + headers, + ); + } else if ('command' in server) { + // Stdio MCP server + const env: Record = {}; + for (const { name: envName, value } of server.env) { + env[envName] = value; + } + mergedMcpServers[server.name] = new MCPServerConfig( + server.command, + server.args, + env, + cwd, + ); + } + } + + const settings = { + ...currentSettings.merged, + mcpServers: mergedMcpServers, + }; + + const config = await loadCliConfig(settings, sessionId, this.argv, { cwd }); + + createPolicyUpdater( + config.getPolicyEngine(), + config.messageBus, + config.storage, + ); + + return config; + } +} diff --git a/packages/cli/src/acp/acpStdioTransport.ts b/packages/cli/src/acp/acpStdioTransport.ts new file mode 100644 index 0000000000000000000000000000000000000000..59198dee62f363e73b9f00714cf242baf1090805 --- /dev/null +++ b/packages/cli/src/acp/acpStdioTransport.ts @@ -0,0 +1,35 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { type Config, createWorkingStdio } from '@google/gemini-cli-core'; +import { runExitCleanup } from '../utils/cleanup.js'; +import * as acp from '@agentclientprotocol/sdk'; +import { Readable, Writable } from 'node:stream'; +import type { LoadedSettings } from '../config/settings.js'; +import type { CliArgs } from '../config/config.js'; +import { GeminiAgent } from './acpRpcDispatcher.js'; + +export async function runAcpClient( + config: Config, + settings: LoadedSettings, + argv: CliArgs, +) { + const { stdout: workingStdout } = createWorkingStdio(); + const stdout = Writable.toWeb(workingStdout) as WritableStream; + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const stdin = Readable.toWeb(process.stdin) as ReadableStream; + + const stream = acp.ndJsonStream(stdout, stdin); + const connection = new acp.AgentSideConnection( + (connection) => new GeminiAgent(config, settings, argv, connection), + stream, + ); + + // SIGTERM/SIGINT handlers (in sdk.ts) don't fire when stdin closes. + // We must explicitly await the connection close to flush telemetry. + // Use finally() to ensure cleanup runs even on stream errors. + await connection.closed.finally(runExitCleanup); +} diff --git a/packages/cli/src/acp/acpUtils.ts b/packages/cli/src/acp/acpUtils.ts new file mode 100644 index 0000000000000000000000000000000000000000..ea5e160f54a4168f85c71e5a834bface432e4e8f --- /dev/null +++ b/packages/cli/src/acp/acpUtils.ts @@ -0,0 +1,365 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + type Config, + type ToolResult, + type ToolCallConfirmationDetails, + Kind, + ApprovalMode, + GEMINI_MODEL_ALIAS_AUTO, + DEFAULT_GEMINI_MODEL, + DEFAULT_GEMINI_FLASH_MODEL, + DEFAULT_GEMINI_FLASH_LITE_MODEL, + PREVIEW_GEMINI_3_1_MODEL, + PREVIEW_GEMINI_MODEL, + PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, + PREVIEW_GEMINI_FLASH_MODEL, + PREVIEW_GEMINI_FLASH_LITE_MODEL, + getDisplayString, + AuthType, + ToolConfirmationOutcome, + getAutoModelDescription, +} from '@google/gemini-cli-core'; +import type * as acp from '@agentclientprotocol/sdk'; +import { z } from 'zod'; +import type { LoadedSettings } from '../config/settings.js'; + +export function hasMeta( + obj: unknown, +): obj is { _meta?: Record } { + return typeof obj === 'object' && obj !== null && '_meta' in obj; +} + +export const RequestPermissionResponseSchema = z.object({ + outcome: z.discriminatedUnion('outcome', [ + z.object({ outcome: z.literal('cancelled') }), + z.object({ + outcome: z.literal('selected'), + optionId: z.string(), + }), + ]), +}); + +export function toToolCallContent( + toolResult: ToolResult, +): acp.ToolCallContent | null { + if (toolResult.error?.message) { + throw new Error(toolResult.error.message); + } + + if (toolResult.returnDisplay) { + if (typeof toolResult.returnDisplay === 'string') { + return { + type: 'content', + content: { type: 'text', text: toolResult.returnDisplay }, + }; + } else { + if ('fileName' in toolResult.returnDisplay) { + return { + type: 'diff', + path: + toolResult.returnDisplay.filePath ?? + toolResult.returnDisplay.fileName, + oldText: toolResult.returnDisplay.originalContent, + newText: toolResult.returnDisplay.newContent, + _meta: { + kind: !toolResult.returnDisplay.originalContent + ? 'add' + : toolResult.returnDisplay.newContent === '' + ? 'delete' + : 'modify', + }, + }; + } + return null; + } + } else { + return null; + } +} + +const basicPermissionOptions = [ + { + optionId: ToolConfirmationOutcome.ProceedOnce, + name: 'Allow', + kind: 'allow_once', + }, + { + optionId: ToolConfirmationOutcome.Cancel, + name: 'Reject', + kind: 'reject_once', + }, +] as const; + +export function toPermissionOptions( + confirmation: ToolCallConfirmationDetails, + config: Config, + enablePermanentToolApproval: boolean = false, +): acp.PermissionOption[] { + const disableAlwaysAllow = config.getDisableAlwaysAllow(); + const options: acp.PermissionOption[] = []; + + if (!disableAlwaysAllow) { + switch (confirmation.type) { + case 'edit': + options.push({ + optionId: ToolConfirmationOutcome.ProceedAlways, + name: 'Allow for this session', + kind: 'allow_always', + }); + if (enablePermanentToolApproval) { + options.push({ + optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave, + name: 'Allow for this file in all future sessions', + kind: 'allow_always', + }); + } + break; + case 'exec': + options.push({ + optionId: ToolConfirmationOutcome.ProceedAlways, + name: 'Allow for this session', + kind: 'allow_always', + }); + if (enablePermanentToolApproval) { + options.push({ + optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave, + name: 'Allow this command for all future sessions', + kind: 'allow_always', + }); + } + break; + case 'mcp': + options.push( + { + optionId: ToolConfirmationOutcome.ProceedAlwaysServer, + name: 'Allow all server tools for this session', + kind: 'allow_always', + }, + { + optionId: ToolConfirmationOutcome.ProceedAlwaysTool, + name: 'Allow tool for this session', + kind: 'allow_always', + }, + ); + if (enablePermanentToolApproval) { + options.push({ + optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave, + name: 'Allow tool for all future sessions', + kind: 'allow_always', + }); + } + break; + case 'info': + options.push({ + optionId: ToolConfirmationOutcome.ProceedAlways, + name: 'Allow for this session', + kind: 'allow_always', + }); + if (enablePermanentToolApproval) { + options.push({ + optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave, + name: 'Allow for all future sessions', + kind: 'allow_always', + }); + } + break; + case 'ask_user': + case 'exit_plan_mode': + // askuser and exit_plan_mode don't need "always allow" options + break; + default: + // No "always allow" options for other types + break; + } + } + + options.push(...basicPermissionOptions); + + // Exhaustive check + switch (confirmation.type) { + case 'edit': + case 'exec': + case 'mcp': + case 'info': + case 'ask_user': + case 'exit_plan_mode': + case 'sandbox_expansion': + break; + default: { + const unreachable: never = confirmation; + throw new Error(`Unexpected: ${unreachable}`); + } + } + + return options; +} + +export function toAcpToolKind(kind: Kind): acp.ToolKind { + switch (kind) { + case Kind.Read: + case Kind.Edit: + case Kind.Execute: + case Kind.Search: + case Kind.Delete: + case Kind.Move: + case Kind.Think: + case Kind.Fetch: + case Kind.SwitchMode: + case Kind.Other: + return kind as acp.ToolKind; + case Kind.Agent: + return 'think'; + case Kind.Plan: + case Kind.Communicate: + default: + return 'other'; + } +} + +export function buildAvailableModes(isPlanEnabled: boolean): acp.SessionMode[] { + const modes: acp.SessionMode[] = [ + { + id: ApprovalMode.DEFAULT, + name: 'Default', + description: 'Prompts for approval', + }, + { + id: ApprovalMode.AUTO_EDIT, + name: 'Auto Edit', + description: 'Auto-approves edit tools', + }, + { + id: ApprovalMode.YOLO, + name: 'YOLO', + description: 'Auto-approves all tools', + }, + ]; + + if (isPlanEnabled) { + modes.push({ + id: ApprovalMode.PLAN, + name: 'Plan', + description: 'Read-only mode', + }); + } + + return modes; +} + +export function buildAvailableModels( + config: Config, + settings: LoadedSettings, +): { + availableModels: Array<{ + modelId: string; + name: string; + description?: string; + }>; + currentModelId: string; +} { + const preferredModel = config.getModel() || GEMINI_MODEL_ALIAS_AUTO; + const shouldShowPreviewModels = config.getHasAccessToPreviewModel(); + const useGemini31 = config.getGemini31LaunchedSync?.() ?? false; + const useGemini3_5Flash = config.hasGemini35FlashGAAccess?.() ?? false; + const selectedAuthType = settings.merged.security.auth.selectedType; + const useCustomToolModel = + useGemini31 && selectedAuthType === AuthType.USE_GEMINI; + + // --- DYNAMIC PATH --- + if ( + config.getExperimentalDynamicModelConfiguration?.() === true && + config.getModelConfigService + ) { + const options = config.getModelConfigService().getAvailableModelOptions({ + useGemini3_1: useGemini31, + useGemini3_5Flash, + useCustomTools: useCustomToolModel, + hasAccessToPreview: shouldShowPreviewModels, + }); + + return { + availableModels: options, + currentModelId: preferredModel, + }; + } + + // --- LEGACY PATH --- + const mainOptions = [ + { + value: GEMINI_MODEL_ALIAS_AUTO, + title: getDisplayString(GEMINI_MODEL_ALIAS_AUTO), + description: getAutoModelDescription( + shouldShowPreviewModels, + useGemini31, + useGemini3_5Flash, + ), + }, + ]; + + const manualOptions = [ + { + value: DEFAULT_GEMINI_MODEL, + title: getDisplayString(DEFAULT_GEMINI_MODEL), + }, + { + value: DEFAULT_GEMINI_FLASH_MODEL, + title: getDisplayString(DEFAULT_GEMINI_FLASH_MODEL), + }, + { + value: DEFAULT_GEMINI_FLASH_LITE_MODEL, + title: getDisplayString(DEFAULT_GEMINI_FLASH_LITE_MODEL), + }, + ]; + + if (shouldShowPreviewModels) { + const previewProModel = useGemini31 + ? PREVIEW_GEMINI_3_1_MODEL + : PREVIEW_GEMINI_MODEL; + + const previewProValue = useCustomToolModel + ? PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL + : previewProModel; + + const previewOptions = [ + { + value: previewProValue, + title: getDisplayString(previewProModel), + }, + { + value: PREVIEW_GEMINI_FLASH_MODEL, + title: getDisplayString(PREVIEW_GEMINI_FLASH_MODEL), + }, + ]; + + if (PREVIEW_GEMINI_FLASH_LITE_MODEL !== 'none') { + previewOptions.push({ + value: PREVIEW_GEMINI_FLASH_LITE_MODEL, + title: getDisplayString(PREVIEW_GEMINI_FLASH_LITE_MODEL), + }); + } + + manualOptions.unshift(...previewOptions); + } + + const scaleOptions = ( + options: Array<{ value: string; title: string; description?: string }>, + ) => + options.map((o) => ({ + modelId: o.value, + name: o.title, + description: o.description, + })); + + return { + availableModels: [ + ...scaleOptions(mainOptions), + ...scaleOptions(manualOptions), + ], + currentModelId: preferredModel, + }; +} diff --git a/packages/cli/src/acp/commands/about.ts b/packages/cli/src/acp/commands/about.ts new file mode 100644 index 0000000000000000000000000000000000000000..06349e88d73fe7903ac10d0904a5afeed09cad91 --- /dev/null +++ b/packages/cli/src/acp/commands/about.ts @@ -0,0 +1,74 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + IdeClient, + UserAccountManager, + getVersion, +} from '@google/gemini-cli-core'; +import type { + Command, + CommandContext, + CommandExecutionResponse, +} from './types.js'; +import process from 'node:process'; + +export class AboutCommand implements Command { + readonly name = 'about'; + readonly description = 'Show version and environment info'; + + async execute( + context: CommandContext, + _args: string[] = [], + ): Promise { + const osVersion = process.platform; + let sandboxEnv = 'no sandbox'; + if (process.env['SANDBOX'] && process.env['SANDBOX'] !== 'sandbox-exec') { + sandboxEnv = process.env['SANDBOX']; + } else if (process.env['SANDBOX'] === 'sandbox-exec') { + sandboxEnv = `sandbox-exec (${ + process.env['SEATBELT_PROFILE'] || 'unknown' + })`; + } + const modelVersion = context.agentContext.config.getModel() || 'Unknown'; + const cliVersion = await getVersion(); + const selectedAuthType = + context.settings.merged?.security?.auth?.selectedType ?? ''; + const gcpProject = process.env['GOOGLE_CLOUD_PROJECT'] || ''; + const ideClient = await getIdeClientName(context); + + const userAccountManager = new UserAccountManager(); + const cachedAccount = userAccountManager.getCachedGoogleAccount(); + const userEmail = cachedAccount ?? 'Unknown'; + + const tier = context.agentContext.config.getUserTierName() || 'Unknown'; + + const info = [ + `- Version: ${cliVersion}`, + `- OS: ${osVersion}`, + `- Sandbox: ${sandboxEnv}`, + `- Model: ${modelVersion}`, + `- Auth Type: ${selectedAuthType}`, + `- GCP Project: ${gcpProject}`, + `- IDE Client: ${ideClient}`, + `- User Email: ${userEmail}`, + `- Tier: ${tier}`, + ].join('\n'); + + return { + name: this.name, + data: `Gemini CLI Info:\n${info}`, + }; + } +} + +async function getIdeClientName(context: CommandContext) { + if (!context.agentContext.config.getIdeMode()) { + return ''; + } + const ideClient = await IdeClient.getInstance(); + return ideClient?.getDetectedIdeDisplayName() ?? ''; +} diff --git a/packages/cli/src/acp/commands/commandRegistry.ts b/packages/cli/src/acp/commands/commandRegistry.ts new file mode 100644 index 0000000000000000000000000000000000000000..e8af6c4048baf59b16883c8cf81e760aa58a2738 --- /dev/null +++ b/packages/cli/src/acp/commands/commandRegistry.ts @@ -0,0 +1,33 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { debugLogger } from '@google/gemini-cli-core'; +import type { Command } from './types.js'; + +export class CommandRegistry { + private readonly commands = new Map(); + + register(command: Command) { + if (this.commands.has(command.name)) { + debugLogger.warn(`Command ${command.name} already registered. Skipping.`); + return; + } + + this.commands.set(command.name, command); + + for (const subCommand of command.subCommands ?? []) { + this.register(subCommand); + } + } + + get(commandName: string): Command | undefined { + return this.commands.get(commandName); + } + + getAllCommands(): Command[] { + return [...this.commands.values()]; + } +} diff --git a/packages/cli/src/acp/commands/extensions.test.ts b/packages/cli/src/acp/commands/extensions.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..33423d0df36b6d100778d219990add1d58f23143 --- /dev/null +++ b/packages/cli/src/acp/commands/extensions.test.ts @@ -0,0 +1,100 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { CommandContext } from './types.js'; +import { + DisableExtensionCommand, + UninstallExtensionCommand, +} from './extensions.js'; +import { ExtensionManager } from '../../config/extension-manager.js'; + +const mockGetErrorMessage = vi.hoisted(() => vi.fn()); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + getErrorMessage: mockGetErrorMessage, + }; +}); + +vi.mock('../../config/extension-manager.js', () => { + class MockExtensionManager { + disableExtension = vi.fn(async () => undefined); + uninstallExtension = vi.fn(async () => undefined); + getExtensions = vi.fn(() => []); + } + + return { + ExtensionManager: MockExtensionManager, + inferInstallMetadata: vi.fn(), + }; +}); + +type TestExtensionManager = InstanceType & { + disableExtension: ReturnType; + uninstallExtension: ReturnType; + getExtensions: ReturnType; +}; + +function createExtensionManager(): TestExtensionManager { + return new ExtensionManager({} as never) as TestExtensionManager; +} + +function createContext(extensionLoader: unknown): CommandContext { + return { + agentContext: { + config: { + getExtensionLoader: vi.fn().mockReturnValue(extensionLoader), + }, + } as unknown as CommandContext['agentContext'], + settings: {} as CommandContext['settings'], + sendMessage: vi.fn().mockResolvedValue(undefined), + }; +} + +describe('ACP extensions error paths', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetErrorMessage.mockImplementation((error: unknown) => + error instanceof Error ? error.message : String(error), + ); + }); + + it('returns error when disabling fails', async () => { + const command = new DisableExtensionCommand(); + const extensionManager = createExtensionManager(); + extensionManager.disableExtension.mockRejectedValue( + new Error('Extension not found.'), + ); + const context = createContext(extensionManager); + + const result = await command.execute(context, ['missing-ext']); + + expect(result).toEqual({ + name: 'extensions disable', + data: 'Failed to disable "missing-ext": Extension not found.', + }); + }); + + it('returns error when uninstalling a non-existent extension', async () => { + const command = new UninstallExtensionCommand(); + const extensionManager = createExtensionManager(); + extensionManager.uninstallExtension.mockRejectedValue( + new Error('Extension not found.'), + ); + const context = createContext(extensionManager); + + const result = await command.execute(context, ['non-existent-ext']); + + expect(result).toEqual({ + name: 'extensions uninstall', + data: 'Failed to uninstall extension "non-existent-ext": Extension not found.', + }); + }); +}); diff --git a/packages/cli/src/acp/commands/extensions.ts b/packages/cli/src/acp/commands/extensions.ts new file mode 100644 index 0000000000000000000000000000000000000000..52f17c48b3772249248beaf4f26ca60e231435f6 --- /dev/null +++ b/packages/cli/src/acp/commands/extensions.ts @@ -0,0 +1,448 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + listExtensions, + type Config, + getErrorMessage, +} from '@google/gemini-cli-core'; +import { SettingScope } from '../../config/settings.js'; +import { + ExtensionManager, + inferInstallMetadata, +} from '../../config/extension-manager.js'; +import { McpServerEnablementManager } from '../../config/mcp/mcpServerEnablement.js'; +import { stat } from 'node:fs/promises'; +import type { + Command, + CommandContext, + CommandExecutionResponse, +} from './types.js'; + +export class ExtensionsCommand implements Command { + readonly name = 'extensions'; + readonly description = 'Manage extensions.'; + readonly subCommands = [ + new ListExtensionsCommand(), + new ExploreExtensionsCommand(), + new EnableExtensionCommand(), + new DisableExtensionCommand(), + new InstallExtensionCommand(), + new LinkExtensionCommand(), + new UninstallExtensionCommand(), + new RestartExtensionCommand(), + new UpdateExtensionCommand(), + ]; + + async execute( + context: CommandContext, + _: string[], + ): Promise { + return new ListExtensionsCommand().execute(context, _); + } +} + +export class ListExtensionsCommand implements Command { + readonly name = 'extensions list'; + readonly description = 'Lists all installed extensions.'; + + async execute( + context: CommandContext, + _: string[], + ): Promise { + const extensions = listExtensions(context.agentContext.config); + const data = extensions.length ? extensions : 'No extensions installed.'; + + return { name: this.name, data }; + } +} + +export class ExploreExtensionsCommand implements Command { + readonly name = 'extensions explore'; + readonly description = 'Explore available extensions.'; + + async execute( + _context: CommandContext, + _: string[], + ): Promise { + const extensionsUrl = 'https://geminicli.com/extensions/'; + return { + name: this.name, + data: `View or install available extensions at ${extensionsUrl}`, + }; + } +} + +function getEnableDisableContext( + config: Config, + args: string[], + invocationName: string, +) { + const extensionManager = config.getExtensionLoader(); + if (!(extensionManager instanceof ExtensionManager)) { + return { + error: `Cannot ${invocationName} extensions in this environment.`, + }; + } + + if (args.length === 0) { + return { + error: `Usage: /extensions ${invocationName} [--scope=]`, + }; + } + + let scope = SettingScope.User; + if (args.includes('--scope=workspace') || args.includes('workspace')) { + scope = SettingScope.Workspace; + } else if (args.includes('--scope=session') || args.includes('session')) { + scope = SettingScope.Session; + } + + const name = args.filter( + (a) => + !a.startsWith('--scope') && !['user', 'workspace', 'session'].includes(a), + )[0]; + + let names: string[] = []; + if (name === '--all') { + let extensions = extensionManager.getExtensions(); + if (invocationName === 'enable') { + extensions = extensions.filter((ext) => !ext.isActive); + } + if (invocationName === 'disable') { + extensions = extensions.filter((ext) => ext.isActive); + } + names = extensions.map((ext) => ext.name); + } else if (name) { + names = [name]; + } else { + return { error: 'No extension name provided.' }; + } + + return { extensionManager, names, scope }; +} + +export class EnableExtensionCommand implements Command { + readonly name = 'extensions enable'; + readonly description = 'Enable an extension.'; + + async execute( + context: CommandContext, + args: string[], + ): Promise { + const enableContext = getEnableDisableContext( + context.agentContext.config, + args, + 'enable', + ); + if ('error' in enableContext) { + return { name: this.name, data: enableContext.error }; + } + + const { names, scope, extensionManager } = enableContext; + const output: string[] = []; + + for (const name of names) { + try { + await extensionManager.enableExtension(name, scope); + output.push(`Extension "${name}" enabled for scope "${scope}".`); + + const extension = extensionManager + .getExtensions() + .find((e) => e.name === name); + + if (extension?.mcpServers) { + const mcpEnablementManager = McpServerEnablementManager.getInstance(); + const mcpClientManager = + context.agentContext.config.getMcpClientManager(); + const enabledServers = await mcpEnablementManager.autoEnableServers( + Object.keys(extension.mcpServers), + ); + + if (mcpClientManager && enabledServers.length > 0) { + const restartPromises = enabledServers.map((serverName) => + mcpClientManager.restartServer(serverName).catch((error) => { + output.push( + `Failed to restart MCP server '${serverName}': ${getErrorMessage(error)}`, + ); + }), + ); + await Promise.all(restartPromises); + output.push(`Re-enabled MCP servers: ${enabledServers.join(', ')}`); + } + } + } catch (e) { + output.push(`Failed to enable "${name}": ${getErrorMessage(e)}`); + } + } + + return { name: this.name, data: output.join('\n') || 'No action taken.' }; + } +} + +export class DisableExtensionCommand implements Command { + readonly name = 'extensions disable'; + readonly description = 'Disable an extension.'; + + async execute( + context: CommandContext, + args: string[], + ): Promise { + const enableContext = getEnableDisableContext( + context.agentContext.config, + args, + 'disable', + ); + if ('error' in enableContext) { + return { name: this.name, data: enableContext.error }; + } + + const { names, scope, extensionManager } = enableContext; + const output: string[] = []; + + for (const name of names) { + try { + await extensionManager.disableExtension(name, scope); + output.push(`Extension "${name}" disabled for scope "${scope}".`); + } catch (e) { + output.push(`Failed to disable "${name}": ${getErrorMessage(e)}`); + } + } + + return { name: this.name, data: output.join('\n') || 'No action taken.' }; + } +} + +export class InstallExtensionCommand implements Command { + readonly name = 'extensions install'; + readonly description = 'Install an extension from a git repo or local path.'; + + async execute( + context: CommandContext, + args: string[], + ): Promise { + const extensionLoader = context.agentContext.config.getExtensionLoader(); + if (!(extensionLoader instanceof ExtensionManager)) { + return { + name: this.name, + data: 'Cannot install extensions in this environment.', + }; + } + + const source = args.join(' ').trim(); + if (!source) { + return { name: this.name, data: `Usage: /extensions install ` }; + } + + if (/[;&|`'"]/.test(source)) { + return { + name: this.name, + data: `Invalid source: contains disallowed characters.`, + }; + } + + try { + const installMetadata = await inferInstallMetadata(source); + const extension = + await extensionLoader.installOrUpdateExtension(installMetadata); + return { + name: this.name, + data: `Extension "${extension.name}" installed successfully.`, + }; + } catch (error) { + return { + name: this.name, + data: `Failed to install extension from "${source}": ${getErrorMessage(error)}`, + }; + } + } +} + +export class LinkExtensionCommand implements Command { + readonly name = 'extensions link'; + readonly description = 'Link an extension from a local path.'; + + async execute( + context: CommandContext, + args: string[], + ): Promise { + const extensionLoader = context.agentContext.config.getExtensionLoader(); + if (!(extensionLoader instanceof ExtensionManager)) { + return { + name: this.name, + data: 'Cannot link extensions in this environment.', + }; + } + + const sourceFilepath = args.join(' ').trim(); + if (!sourceFilepath) { + return { name: this.name, data: `Usage: /extensions link ` }; + } + + try { + await stat(sourceFilepath); + } catch { + return { name: this.name, data: `Invalid source: ${sourceFilepath}` }; + } + + try { + const extension = await extensionLoader.installOrUpdateExtension({ + source: sourceFilepath, + type: 'link', + }); + return { + name: this.name, + data: `Extension "${extension.name}" linked successfully.`, + }; + } catch (error) { + return { + name: this.name, + data: `Failed to link extension: ${getErrorMessage(error)}`, + }; + } + } +} + +export class UninstallExtensionCommand implements Command { + readonly name = 'extensions uninstall'; + readonly description = 'Uninstall an extension.'; + + async execute( + context: CommandContext, + args: string[], + ): Promise { + const extensionLoader = context.agentContext.config.getExtensionLoader(); + if (!(extensionLoader instanceof ExtensionManager)) { + return { + name: this.name, + data: 'Cannot uninstall extensions in this environment.', + }; + } + + const all = args.includes('--all'); + const names = args.filter((a) => !a.startsWith('--')).map((a) => a.trim()); + + if (!all && names.length === 0) { + return { + name: this.name, + data: `Usage: /extensions uninstall |--all`, + }; + } + + let namesToUninstall: string[] = []; + if (all) { + namesToUninstall = extensionLoader.getExtensions().map((ext) => ext.name); + } else { + namesToUninstall = names; + } + + if (namesToUninstall.length === 0) { + return { + name: this.name, + data: all ? 'No extensions installed.' : 'No extension name provided.', + }; + } + + const output: string[] = []; + for (const extensionName of namesToUninstall) { + try { + await extensionLoader.uninstallExtension(extensionName, false); + output.push(`Extension "${extensionName}" uninstalled successfully.`); + } catch (error) { + output.push( + `Failed to uninstall extension "${extensionName}": ${getErrorMessage(error)}`, + ); + } + } + + return { name: this.name, data: output.join('\n') }; + } +} + +export class RestartExtensionCommand implements Command { + readonly name = 'extensions restart'; + readonly description = 'Restart an extension.'; + + async execute( + context: CommandContext, + args: string[], + ): Promise { + const extensionLoader = context.agentContext.config.getExtensionLoader(); + if (!(extensionLoader instanceof ExtensionManager)) { + return { name: this.name, data: 'Cannot restart extensions.' }; + } + + const all = args.includes('--all'); + const names = all ? null : args.filter((a) => !!a); + + if (!all && names?.length === 0) { + return { + name: this.name, + data: 'Usage: /extensions restart |--all', + }; + } + + let extensionsToRestart = extensionLoader + .getExtensions() + .filter((e) => e.isActive); + if (names) { + extensionsToRestart = extensionsToRestart.filter((e) => + names.includes(e.name), + ); + } + + if (extensionsToRestart.length === 0) { + return { + name: this.name, + data: 'No active extensions matched the request.', + }; + } + + const output: string[] = []; + for (const extension of extensionsToRestart) { + try { + await extensionLoader.restartExtension(extension); + output.push(`Restarted "${extension.name}".`); + } catch (e) { + output.push( + `Failed to restart "${extension.name}": ${getErrorMessage(e)}`, + ); + } + } + + return { name: this.name, data: output.join('\n') }; + } +} + +export class UpdateExtensionCommand implements Command { + readonly name = 'extensions update'; + readonly description = 'Update an extension.'; + + async execute( + context: CommandContext, + args: string[], + ): Promise { + const extensionLoader = context.agentContext.config.getExtensionLoader(); + if (!(extensionLoader instanceof ExtensionManager)) { + return { name: this.name, data: 'Cannot update extensions.' }; + } + + const all = args.includes('--all'); + const names = all ? null : args.filter((a) => !!a); + + if (!all && names?.length === 0) { + return { + name: this.name, + data: 'Usage: /extensions update |--all', + }; + } + + return { + name: this.name, + data: 'Headless extension updating requires internal UI dispatches. Please use `gemini extensions update` directly in the terminal.', + }; + } +} diff --git a/packages/cli/src/acp/commands/help.test.ts b/packages/cli/src/acp/commands/help.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..c703328a4286f746ad26694efe1f8198853f1a6d --- /dev/null +++ b/packages/cli/src/acp/commands/help.test.ts @@ -0,0 +1,53 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { HelpCommand } from './help.js'; +import { CommandRegistry } from './commandRegistry.js'; +import type { Command, CommandContext } from './types.js'; + +describe('HelpCommand', () => { + it('returns formatted help text with sorted commands', async () => { + const registry = new CommandRegistry(); + + const cmdB: Command = { + name: 'bravo', + description: 'Bravo command', + execute: async () => ({ name: 'bravo', data: '' }), + }; + + const cmdA: Command = { + name: 'alpha', + description: 'Alpha command', + execute: async () => ({ name: 'alpha', data: '' }), + }; + + registry.register(cmdB); + registry.register(cmdA); + + const helpCommand = new HelpCommand(registry); + + const context = {} as CommandContext; + + const response = await helpCommand.execute(context, []); + + expect(response.name).toBe('help'); + + const data = response.data as string; + + expect(data).toContain('Gemini CLI Help:'); + expect(data).toContain('### Basics'); + expect(data).toContain('### Commands'); + + const lines = data.split('\n'); + const alphaIndex = lines.findIndex((l) => l.includes('/alpha')); + const bravoIndex = lines.findIndex((l) => l.includes('/bravo')); + + expect(alphaIndex).toBeLessThan(bravoIndex); + expect(alphaIndex).not.toBe(-1); + expect(bravoIndex).not.toBe(-1); + }); +}); diff --git a/packages/cli/src/acp/commands/help.ts b/packages/cli/src/acp/commands/help.ts new file mode 100644 index 0000000000000000000000000000000000000000..0c0729ecabc92b8a03c51a4fb7a7a0f11539ca4b --- /dev/null +++ b/packages/cli/src/acp/commands/help.ts @@ -0,0 +1,50 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + Command, + CommandContext, + CommandExecutionResponse, +} from './types.js'; +import type { CommandRegistry } from './commandRegistry.js'; + +export class HelpCommand implements Command { + readonly name = 'help'; + readonly description = 'Show available commands'; + + constructor(private registry: CommandRegistry) {} + + async execute( + _context: CommandContext, + _args: string[] = [], + ): Promise { + const commands = this.registry + .getAllCommands() + .sort((a, b) => a.name.localeCompare(b.name)); + + const lines: string[] = []; + + lines.push('Gemini CLI Help:'); + lines.push(''); + lines.push('### Basics'); + lines.push( + '- **Add context**: Use `@` to specify files for context (e.g., `@src/myFile.ts`) to target specific files or folders.', + ); + lines.push(''); + + lines.push('### Commands'); + for (const cmd of commands) { + if (cmd.description) { + lines.push(`- **/${cmd.name}** - ${cmd.description}`); + } + } + + return { + name: this.name, + data: lines.join('\n'), + }; + } +} diff --git a/packages/cli/src/acp/commands/init.ts b/packages/cli/src/acp/commands/init.ts new file mode 100644 index 0000000000000000000000000000000000000000..954be123d07a431f3ee535ce4cbc4020ecb3d56e --- /dev/null +++ b/packages/cli/src/acp/commands/init.ts @@ -0,0 +1,62 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { performInit } from '@google/gemini-cli-core'; +import type { + Command, + CommandContext, + CommandExecutionResponse, +} from './types.js'; + +export class InitCommand implements Command { + name = 'init'; + description = 'Analyzes the project and creates a tailored GEMINI.md file'; + requiresWorkspace = true; + + async execute( + context: CommandContext, + _args: string[] = [], + ): Promise { + const targetDir = context.agentContext.config.getTargetDir(); + if (!targetDir) { + throw new Error('Command requires a workspace.'); + } + + const geminiMdPath = path.join(targetDir, 'GEMINI.md'); + const result = performInit(fs.existsSync(geminiMdPath)); + + switch (result.type) { + case 'message': + return { + name: this.name, + data: result, + }; + case 'submit_prompt': + fs.writeFileSync(geminiMdPath, '', 'utf8'); + + if (typeof result.content !== 'string') { + throw new Error('Init command content must be a string.'); + } + + // Inform the user since we can't trigger the UI-based interactive agent loop here directly. + // We output the prompt text they can use to re-trigger the generation manually, + // or just seed the GEMINI.md file as we've done above. + return { + name: this.name, + data: { + type: 'message', + messageType: 'info', + content: `A template GEMINI.md has been created at ${geminiMdPath}.\n\nTo populate it with project context, you can run the following prompt in a new chat:\n\n${result.content}`, + }, + }; + + default: + throw new Error('Unknown result type from performInit'); + } + } +} diff --git a/packages/cli/src/acp/commands/memory.ts b/packages/cli/src/acp/commands/memory.ts new file mode 100644 index 0000000000000000000000000000000000000000..e83012f17cee38a8a9b3f8a434eeb301b9617a72 --- /dev/null +++ b/packages/cli/src/acp/commands/memory.ts @@ -0,0 +1,142 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + listInboxMemoryPatches, + listInboxSkills, + listInboxPatches, + listMemoryFiles, + refreshMemory, + showMemory, +} from '@google/gemini-cli-core'; +import type { + Command, + CommandContext, + CommandExecutionResponse, +} from './types.js'; + +export class MemoryCommand implements Command { + readonly name = 'memory'; + readonly description = 'Manage memory.'; + readonly subCommands = [ + new ShowMemoryCommand(), + new RefreshMemoryCommand(), + new ListMemoryCommand(), + new InboxMemoryCommand(), + ]; + readonly requiresWorkspace = true; + + async execute( + context: CommandContext, + _: string[], + ): Promise { + return new ShowMemoryCommand().execute(context, _); + } +} + +export class ShowMemoryCommand implements Command { + readonly name = 'memory show'; + readonly description = 'Shows the current memory contents.'; + + async execute( + context: CommandContext, + _: string[], + ): Promise { + const result = showMemory(context.agentContext.config); + return { name: this.name, data: result.content }; + } +} + +export class RefreshMemoryCommand implements Command { + readonly name = 'memory refresh'; + readonly aliases = ['memory reload']; + readonly description = 'Refreshes the memory from the source.'; + + async execute( + context: CommandContext, + _: string[], + ): Promise { + const result = await refreshMemory(context.agentContext.config); + return { name: this.name, data: result.content }; + } +} + +export class ListMemoryCommand implements Command { + readonly name = 'memory list'; + readonly description = 'Lists the paths of the GEMINI.md files in use.'; + + async execute( + context: CommandContext, + _: string[], + ): Promise { + const result = listMemoryFiles(context.agentContext.config); + return { name: this.name, data: result.content }; + } +} + +export class InboxMemoryCommand implements Command { + readonly name = 'memory inbox'; + readonly description = + 'Lists memory items extracted from past sessions that are pending review.'; + + async execute( + context: CommandContext, + _: string[], + ): Promise { + if (!context.agentContext.config.isAutoMemoryEnabled()) { + return { + name: this.name, + data: 'The memory inbox requires Auto Memory. Enable it with: experimental.autoMemory = true in settings.', + }; + } + + const [skills, patches, memoryPatches] = await Promise.all([ + listInboxSkills(context.agentContext.config), + listInboxPatches(context.agentContext.config), + listInboxMemoryPatches(context.agentContext.config), + ]); + + if ( + skills.length === 0 && + patches.length === 0 && + memoryPatches.length === 0 + ) { + return { name: this.name, data: 'No items in inbox.' }; + } + + const lines: string[] = []; + for (const s of skills) { + const date = s.extractedAt + ? ` (extracted: ${new Date(s.extractedAt).toLocaleDateString()})` + : ''; + lines.push(`- **${s.name}**: ${s.description}${date}`); + } + for (const p of patches) { + const targets = p.entries.map((e) => e.targetPath).join(', '); + const date = p.extractedAt + ? ` (extracted: ${new Date(p.extractedAt).toLocaleDateString()})` + : ''; + lines.push(`- **${p.name}** (update): patches ${targets}${date}`); + } + for (const memoryPatch of memoryPatches) { + const targets = memoryPatch.entries.map((e) => e.targetPath).join(', '); + const date = memoryPatch.extractedAt + ? ` (latest extract: ${new Date(memoryPatch.extractedAt).toLocaleDateString()})` + : ''; + const sourceCount = memoryPatch.sourceFiles.length; + const sourceLabel = sourceCount === 1 ? 'patch' : 'patches'; + lines.push( + `- **${memoryPatch.name}** (${sourceCount} source ${sourceLabel}, ${memoryPatch.entries.length} hunks): targets ${targets}${date}`, + ); + } + + const total = skills.length + patches.length + memoryPatches.length; + return { + name: this.name, + data: `Memory inbox (${total}):\n${lines.join('\n')}`, + }; + } +} diff --git a/packages/cli/src/acp/commands/restore.test.ts b/packages/cli/src/acp/commands/restore.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..c9bfaf5063a42877d0f7d0df6a4d2b368e2093a9 --- /dev/null +++ b/packages/cli/src/acp/commands/restore.test.ts @@ -0,0 +1,244 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { RestoreCommand, ListCheckpointsCommand } from './restore.js'; +import * as fs from 'node:fs/promises'; +import { + getCheckpointInfoList, + getToolCallDataSchema, + isNodeError, + performRestore, +} from '@google/gemini-cli-core'; +import type { CommandContext } from './types.js'; +import type { Mock } from 'vitest'; + +vi.mock('node:fs/promises'); +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + getCheckpointInfoList: vi.fn(), + getToolCallDataSchema: vi.fn(), + isNodeError: vi.fn(), + performRestore: vi.fn(), + }; +}); + +describe('RestoreCommand', () => { + let context: CommandContext; + let restoreCommand: RestoreCommand; + + beforeEach(() => { + vi.resetAllMocks(); + restoreCommand = new RestoreCommand(); + context = { + agentContext: { + config: { + getCheckpointingEnabled: vi.fn().mockReturnValue(true), + storage: { + getProjectTempCheckpointsDir: vi + .fn() + .mockReturnValue('/tmp/checkpoints'), + }, + }, + }, + git: {}, + sendMessage: vi.fn(), + } as unknown as CommandContext; + }); + + it('delegates to list behavior when invoked without args', async () => { + const listExecuteSpy = vi + .spyOn(ListCheckpointsCommand.prototype, 'execute') + .mockResolvedValue({ + name: 'restore list', + data: 'list data', + }); + + const response = await restoreCommand.execute(context, []); + + expect(listExecuteSpy).toHaveBeenCalledWith(context); + expect(response).toEqual({ + name: 'restore list', + data: 'list data', + }); + }); + + it('returns checkpointing-disabled message when disabled', async () => { + ( + context.agentContext.config.getCheckpointingEnabled as Mock + ).mockReturnValue(false); + + const response = await restoreCommand.execute(context, ['checkpoint1']); + + expect(response.data).toContain('Checkpointing is not enabled'); + }); + + it('returns file-not-found message for missing checkpoint', async () => { + const error = new Error('ENOENT'); + (error as Error & { code: string }).code = 'ENOENT'; + vi.mocked(fs.readFile).mockRejectedValue(error); + vi.mocked(isNodeError).mockReturnValue(true); + + const response = await restoreCommand.execute(context, ['missing']); + + expect(response.data).toBe('File not found: missing.json'); + }); + + it('handles checkpoint filename already ending in .json', async () => { + const error = new Error('ENOENT'); + (error as Error & { code: string }).code = 'ENOENT'; + vi.mocked(fs.readFile).mockRejectedValue(error); + vi.mocked(isNodeError).mockReturnValue(true); + + const response = await restoreCommand.execute(context, ['existing.json']); + + expect(response.data).toBe('File not found: existing.json'); + expect(fs.readFile).toHaveBeenCalledWith( + expect.stringContaining('existing.json'), + 'utf-8', + ); + }); + + it('returns invalid/corrupt checkpoint message when schema parse fails', async () => { + vi.mocked(fs.readFile).mockResolvedValue('{"invalid": "data"}'); + vi.mocked(getToolCallDataSchema).mockReturnValue({ + safeParse: vi.fn().mockReturnValue({ success: false }), + } as unknown as ReturnType); + + const response = await restoreCommand.execute(context, ['invalid']); + + expect(response.data).toBe('Checkpoint file is invalid or corrupted.'); + }); + + it('formats streamed restore results correctly', async () => { + vi.mocked(fs.readFile).mockResolvedValue('{"valid": "data"}'); + vi.mocked(getToolCallDataSchema).mockReturnValue({ + safeParse: vi + .fn() + .mockReturnValue({ success: true, data: { some: 'data' } }), + } as unknown as ReturnType); + + async function* mockRestoreGenerator() { + yield { type: 'message', messageType: 'info', content: 'Restoring...' }; + yield { type: 'load_history', clientHistory: [{}, {}] }; + yield { type: 'other', some: 'other' }; + } + vi.mocked(performRestore).mockReturnValue( + mockRestoreGenerator() as unknown as ReturnType, + ); + + const response = await restoreCommand.execute(context, ['valid']); + + expect(response.data).toContain('[INFO] Restoring...'); + expect(response.data).toContain('Loaded history with 2 messages.'); + expect(response.data).toContain( + 'Restored: {"type":"other","some":"other"}', + ); + }); + + it('returns generic unexpected error message for non-ENOENT failures', async () => { + vi.mocked(fs.readFile).mockRejectedValue(new Error('Random error')); + vi.mocked(isNodeError).mockReturnValue(false); + + const response = await restoreCommand.execute(context, ['error']); + + expect(response.data).toContain( + 'An unexpected error occurred during restore: Error: Random error', + ); + }); +}); + +describe('ListCheckpointsCommand', () => { + let context: CommandContext; + let listCommand: ListCheckpointsCommand; + let mockReaddir: Mock<(path: string) => Promise>; + + beforeEach(() => { + vi.resetAllMocks(); + listCommand = new ListCheckpointsCommand(); + mockReaddir = vi.mocked(fs.readdir) as unknown as Mock< + (path: string) => Promise + >; + + context = { + agentContext: { + config: { + getCheckpointingEnabled: vi.fn().mockReturnValue(true), + storage: { + getProjectTempCheckpointsDir: vi + .fn() + .mockReturnValue('/tmp/checkpoints'), + }, + }, + }, + } as unknown as CommandContext; + }); + + it('returns checkpointing-disabled message when disabled', async () => { + ( + context.agentContext.config.getCheckpointingEnabled as Mock + ).mockReturnValue(false); + + const response = await listCommand.execute(context); + + expect(response.data).toContain('Checkpointing is not enabled'); + }); + + it('returns "No checkpoints found." when no .json checkpoints exist', async () => { + mockReaddir.mockResolvedValue(['not-a-checkpoint.txt']); + + const response = await listCommand.execute(context); + + expect(response.data).toBe('No checkpoints found.'); + }); + + it('ignores error when mkdir fails', async () => { + vi.mocked(fs.mkdir).mockRejectedValue(new Error('mkdir fail')); + mockReaddir.mockResolvedValue([]); + + const response = await listCommand.execute(context); + + expect(response.data).toBe('No checkpoints found.'); + expect(fs.mkdir).toHaveBeenCalled(); + }); + + it('formats checkpoint summary output from checkpoint metadata', async () => { + mockReaddir.mockResolvedValue(['cp1.json', 'cp2.json']); + vi.mocked(getCheckpointInfoList).mockReturnValue([ + { messageId: 'id1', checkpoint: 'cp1' }, + { messageId: 'id2', checkpoint: 'cp2' }, + ]); + + const response = await listCommand.execute(context); + + expect(response.data).toContain('Available Checkpoints:'); + // Note: The current implementation of ListCheckpointsCommand incorrectly accesses + // fileName, toolName, etc. which don't exist on CheckpointInfo, resulting in 'Unknown'. + expect(response.data).toContain('- **Unknown**: Unknown (Status: Unknown)'); + }); + + it('handles empty checkpoint info list', async () => { + mockReaddir.mockResolvedValue(['some.json']); + vi.mocked(getCheckpointInfoList).mockReturnValue([]); + + const response = await listCommand.execute(context); + + expect(response.data).toBe('Available Checkpoints:\n'); + }); + + it('returns generic unexpected error message on failures', async () => { + mockReaddir.mockRejectedValue(new Error('Readdir fail')); + + const response = await listCommand.execute(context); + + expect(response.data).toBe( + 'An unexpected error occurred while listing checkpoints.', + ); + }); +}); diff --git a/packages/cli/src/acp/commands/restore.ts b/packages/cli/src/acp/commands/restore.ts new file mode 100644 index 0000000000000000000000000000000000000000..e45dec67e2c54d1057523c7f6564e59c8237689d --- /dev/null +++ b/packages/cli/src/acp/commands/restore.ts @@ -0,0 +1,179 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + getCheckpointInfoList, + getToolCallDataSchema, + isNodeError, + performRestore, +} from '@google/gemini-cli-core'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import type { + Command, + CommandContext, + CommandExecutionResponse, +} from './types.js'; + +export class RestoreCommand implements Command { + readonly name = 'restore'; + readonly description = + 'Restore to a previous checkpoint, or list available checkpoints to restore. This will reset the conversation and file history to the state it was in when the checkpoint was created'; + readonly requiresWorkspace = true; + readonly subCommands = [new ListCheckpointsCommand()]; + + async execute( + context: CommandContext, + args: string[], + ): Promise { + const { agentContext: agentContext, git: gitService } = context; + const { config } = agentContext; + const argsStr = args.join(' '); + + try { + if (!argsStr) { + return await new ListCheckpointsCommand().execute(context); + } + + if (!config.getCheckpointingEnabled()) { + return { + name: this.name, + data: 'Checkpointing is not enabled. Please enable it in your settings (`general.checkpointing.enabled: true`) to use /restore.', + }; + } + + const selectedFile = argsStr.endsWith('.json') + ? argsStr + : `${argsStr}.json`; + + const checkpointDir = config.storage.getProjectTempCheckpointsDir(); + const filePath = path.join(checkpointDir, selectedFile); + + let data: string; + try { + data = await fs.readFile(filePath, 'utf-8'); + } catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') { + return { + name: this.name, + data: `File not found: ${selectedFile}`, + }; + } + throw error; + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const toolCallData = JSON.parse(data); + const ToolCallDataSchema = getToolCallDataSchema(); + const parseResult = ToolCallDataSchema.safeParse(toolCallData); + + if (!parseResult.success) { + return { + name: this.name, + data: 'Checkpoint file is invalid or corrupted.', + }; + } + + const restoreResultGenerator = performRestore( + parseResult.data, + gitService, + ); + + const restoreResult = []; + for await (const result of restoreResultGenerator) { + restoreResult.push(result); + } + + // Format the result nicely since Zed just dumps data + const formattedResult = restoreResult + .map((r) => { + if (r.type === 'message') { + return `[${r.messageType.toUpperCase()}] ${r.content}`; + } else if (r.type === 'load_history') { + return `Loaded history with ${r.clientHistory.length} messages.`; + } + return `Restored: ${JSON.stringify(r)}`; + }) + .join('\n'); + + return { + name: this.name, + data: formattedResult, + }; + } catch (error) { + return { + name: this.name, + data: `An unexpected error occurred during restore: ${error}`, + }; + } + } +} + +export class ListCheckpointsCommand implements Command { + readonly name = 'restore list'; + readonly description = 'Lists all available checkpoints.'; + + async execute(context: CommandContext): Promise { + const { config } = context.agentContext; + + try { + if (!config.getCheckpointingEnabled()) { + return { + name: this.name, + data: 'Checkpointing is not enabled. Please enable it in your settings (`general.checkpointing.enabled: true`) to use /restore.', + }; + } + + const checkpointDir = config.storage.getProjectTempCheckpointsDir(); + try { + await fs.mkdir(checkpointDir, { recursive: true }); + } catch { + // Ignore + } + + const files = await fs.readdir(checkpointDir); + const jsonFiles = files.filter((file) => file.endsWith('.json')); + + if (jsonFiles.length === 0) { + return { name: this.name, data: 'No checkpoints found.' }; + } + + const checkpointFiles = new Map(); + for (const file of jsonFiles) { + const filePath = path.join(checkpointDir, file); + const data = await fs.readFile(filePath, 'utf-8'); + checkpointFiles.set(file, data); + } + + const checkpointInfoList = getCheckpointInfoList(checkpointFiles); + + const formatted = checkpointInfoList + .map((info) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const i = info as Record; + const fileName = String(i['fileName'] || 'Unknown'); + const toolName = String(i['toolName'] || 'Unknown'); + const status = String(i['status'] || 'Unknown'); + const timestamp = new Date( + Number(i['timestamp']) || 0, + ).toLocaleString(); + + return `- **${fileName}**: ${toolName} (Status: ${status}) [${timestamp}]`; + }) + .join('\n'); + + return { + name: this.name, + data: `Available Checkpoints:\n${formatted}`, + }; + } catch { + return { + name: this.name, + data: 'An unexpected error occurred while listing checkpoints.', + }; + } + } +} diff --git a/packages/cli/src/acp/commands/types.ts b/packages/cli/src/acp/commands/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..a175d5fc82550c16337c6acdf85a4ce07ce5418e --- /dev/null +++ b/packages/cli/src/acp/commands/types.ts @@ -0,0 +1,40 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { AgentLoopContext, GitService } from '@google/gemini-cli-core'; +import type { LoadedSettings } from '../../config/settings.js'; + +export interface CommandContext { + agentContext: AgentLoopContext; + settings: LoadedSettings; + git?: GitService; + sendMessage: (text: string) => Promise; +} + +export interface CommandArgument { + readonly name: string; + readonly description: string; + readonly isRequired?: boolean; +} + +export interface Command { + readonly name: string; + readonly aliases?: string[]; + readonly description: string; + readonly arguments?: CommandArgument[]; + readonly subCommands?: Command[]; + readonly requiresWorkspace?: boolean; + + execute( + context: CommandContext, + args: string[], + ): Promise; +} + +export interface CommandExecutionResponse { + readonly name: string; + readonly data: unknown; +} diff --git a/packages/cli/src/commands/extensions.test.tsx b/packages/cli/src/commands/extensions.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0630b398ff711f299cfd78fb14adfda5cc31a4f9 --- /dev/null +++ b/packages/cli/src/commands/extensions.test.tsx @@ -0,0 +1,92 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi } from 'vitest'; +import { extensionsCommand } from './extensions.js'; + +// Mock subcommands +vi.mock('./extensions/install.js', () => ({ + installCommand: { command: 'install' }, +})); +vi.mock('./extensions/uninstall.js', () => ({ + uninstallCommand: { command: 'uninstall' }, +})); +vi.mock('./extensions/list.js', () => ({ listCommand: { command: 'list' } })); +vi.mock('./extensions/update.js', () => ({ + updateCommand: { command: 'update' }, +})); +vi.mock('./extensions/disable.js', () => ({ + disableCommand: { command: 'disable' }, +})); +vi.mock('./extensions/enable.js', () => ({ + enableCommand: { command: 'enable' }, +})); +vi.mock('./extensions/link.js', () => ({ linkCommand: { command: 'link' } })); +vi.mock('./extensions/new.js', () => ({ newCommand: { command: 'new' } })); +vi.mock('./extensions/validate.js', () => ({ + validateCommand: { command: 'validate' }, +})); + +// Mock gemini.js +vi.mock('../gemini.js', () => ({ + initializeOutputListenersAndFlush: vi.fn(), +})); + +describe('extensionsCommand', () => { + it('should have correct command and aliases', () => { + expect(extensionsCommand.command).toBe('extensions '); + expect(extensionsCommand.aliases).toEqual(['extension']); + expect(extensionsCommand.describe).toBe('Manage Gemini CLI extensions.'); + }); + + it('should register all subcommands in builder', () => { + const mockYargs = { + middleware: vi.fn().mockReturnThis(), + command: vi.fn().mockReturnThis(), + demandCommand: vi.fn().mockReturnThis(), + version: vi.fn().mockReturnThis(), + }; + + // @ts-expect-error - Mocking yargs + extensionsCommand.builder(mockYargs); + + expect(mockYargs.middleware).toHaveBeenCalled(); + expect(mockYargs.command).toHaveBeenCalledWith( + expect.objectContaining({ command: 'install' }), + ); + expect(mockYargs.command).toHaveBeenCalledWith( + expect.objectContaining({ command: 'uninstall' }), + ); + expect(mockYargs.command).toHaveBeenCalledWith( + expect.objectContaining({ command: 'list' }), + ); + expect(mockYargs.command).toHaveBeenCalledWith( + expect.objectContaining({ command: 'update' }), + ); + expect(mockYargs.command).toHaveBeenCalledWith( + expect.objectContaining({ command: 'disable' }), + ); + expect(mockYargs.command).toHaveBeenCalledWith( + expect.objectContaining({ command: 'enable' }), + ); + expect(mockYargs.command).toHaveBeenCalledWith( + expect.objectContaining({ command: 'link' }), + ); + expect(mockYargs.command).toHaveBeenCalledWith( + expect.objectContaining({ command: 'new' }), + ); + expect(mockYargs.command).toHaveBeenCalledWith( + expect.objectContaining({ command: 'validate' }), + ); + expect(mockYargs.demandCommand).toHaveBeenCalledWith(1, expect.any(String)); + expect(mockYargs.version).toHaveBeenCalledWith(false); + }); + + it('should have a handler that does nothing', () => { + // @ts-expect-error - Handler doesn't take arguments in this case + expect(extensionsCommand.handler()).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/commands/extensions.tsx b/packages/cli/src/commands/extensions.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ec646cfc829c877af72871b3633249d1fae9d136 --- /dev/null +++ b/packages/cli/src/commands/extensions.tsx @@ -0,0 +1,47 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import { installCommand } from './extensions/install.js'; +import { uninstallCommand } from './extensions/uninstall.js'; +import { listCommand } from './extensions/list.js'; +import { updateCommand } from './extensions/update.js'; +import { disableCommand } from './extensions/disable.js'; +import { enableCommand } from './extensions/enable.js'; +import { linkCommand } from './extensions/link.js'; +import { newCommand } from './extensions/new.js'; +import { validateCommand } from './extensions/validate.js'; +import { configureCommand } from './extensions/configure.js'; +import { initializeOutputListenersAndFlush } from '../gemini.js'; +import { defer } from '../deferred.js'; + +export const extensionsCommand: CommandModule = { + command: 'extensions ', + aliases: ['extension'], + describe: 'Manage Gemini CLI extensions.', + builder: (yargs) => + yargs + .middleware((argv) => { + initializeOutputListenersAndFlush(); + argv['isCommand'] = true; + }) + .command(defer(installCommand, 'extensions')) + .command(defer(uninstallCommand, 'extensions')) + .command(defer(listCommand, 'extensions')) + .command(defer(updateCommand, 'extensions')) + .command(defer(disableCommand, 'extensions')) + .command(defer(enableCommand, 'extensions')) + .command(defer(linkCommand, 'extensions')) + .command(defer(newCommand, 'extensions')) + .command(defer(validateCommand, 'extensions')) + .command(defer(configureCommand, 'extensions')) + .demandCommand(1, 'You need at least one command before continuing.') + .version(false), + handler: () => { + // This handler is not called when a subcommand is provided. + // Yargs will show the help menu. + }, +}; diff --git a/packages/cli/src/commands/extensions/configure.test.ts b/packages/cli/src/commands/extensions/configure.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..dffd3fee37e98e4af9af483f1c783cff78dc293e --- /dev/null +++ b/packages/cli/src/commands/extensions/configure.test.ts @@ -0,0 +1,290 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, + type Mock, +} from 'vitest'; +import { configureCommand } from './configure.js'; +import yargs from 'yargs'; +import { debugLogger } from '@google/gemini-cli-core'; +import { + updateSetting, + getScopedEnvContents, + type ExtensionSetting, +} from '../../config/extensions/extensionSettings.js'; +import { cleanupTmpDir } from '@google/gemini-cli-test-utils'; +import prompts from 'prompts'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +const { mockExtensionManager, mockGetExtensionManager, mockLoadSettings } = + vi.hoisted(() => { + const extensionManager = { + loadExtensionConfig: vi.fn(), + getExtensions: vi.fn(), + loadExtensions: vi.fn(), + getSettings: vi.fn(), + }; + return { + mockExtensionManager: extensionManager, + mockGetExtensionManager: vi.fn(), + mockLoadSettings: vi.fn().mockReturnValue({ merged: {} }), + }; + }); + +vi.mock('../../config/extension-manager.js', () => ({ + ExtensionManager: vi.fn().mockImplementation(() => mockExtensionManager), +})); + +vi.mock('../../config/extensions/extensionSettings.js', () => ({ + updateSetting: vi.fn(), + promptForSetting: vi.fn(), + getScopedEnvContents: vi.fn(), + ExtensionSettingScope: { + USER: 'user', + WORKSPACE: 'workspace', + }, +})); + +vi.mock('../utils.js', () => ({ + exitCli: vi.fn(), +})); + +vi.mock('./utils.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getExtensionManager: mockGetExtensionManager, + }; +}); + +vi.mock('prompts'); + +vi.mock('../../config/extensions/consent.js', () => ({ + requestConsentNonInteractive: vi.fn(), +})); + +import { ExtensionManager } from '../../config/extension-manager.js'; + +vi.mock('../../config/settings.js', () => ({ + loadSettings: mockLoadSettings, +})); + +describe('extensions configure command', () => { + let tempWorkspaceDir: string; + + beforeEach(() => { + vi.spyOn(debugLogger, 'log'); + vi.spyOn(debugLogger, 'error'); + vi.clearAllMocks(); + + tempWorkspaceDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'gemini-cli-test-workspace-'), + ); + vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir); + // Default behaviors + mockLoadSettings.mockReturnValue({ merged: {} }); + mockGetExtensionManager.mockResolvedValue(mockExtensionManager); + (ExtensionManager as unknown as Mock).mockImplementation( + () => mockExtensionManager, + ); + }); + + afterEach(async () => { + await cleanupTmpDir(tempWorkspaceDir); + vi.restoreAllMocks(); + }); + + const runCommand = async (command: string) => { + const parser = yargs().command(configureCommand).help(false).version(false); + await parser.parse(command); + }; + + const setupExtension = ( + name: string, + settings: Array> = [], + id = 'test-id', + path = '/test/path', + ) => { + const extension = { name, path, id }; + + mockExtensionManager.getExtensions.mockReturnValue([extension]); + mockExtensionManager.loadExtensionConfig.mockResolvedValue({ + name, + settings, + }); + return extension; + }; + + describe('Specific setting configuration', () => { + it('should configure a specific setting', async () => { + setupExtension('test-ext', [ + { name: 'Test Setting', envVar: 'TEST_VAR' }, + ]); + (updateSetting as Mock).mockResolvedValue(undefined); + + await runCommand('config test-ext TEST_VAR'); + + expect(updateSetting).toHaveBeenCalledWith( + expect.objectContaining({ name: 'test-ext' }), + 'test-id', + 'TEST_VAR', + expect.any(Function), + 'user', + tempWorkspaceDir, + ); + }); + + it('should handle missing extension', async () => { + mockExtensionManager.getExtensions.mockReturnValue([]); + + await runCommand('config missing-ext TEST_VAR'); + + expect(updateSetting).not.toHaveBeenCalled(); + }); + + it('should reject invalid extension names', async () => { + await runCommand('config ../invalid TEST_VAR'); + expect(debugLogger.error).toHaveBeenCalledWith( + expect.stringContaining('Invalid extension name'), + ); + + await runCommand('config ext/with/slash TEST_VAR'); + expect(debugLogger.error).toHaveBeenCalledWith( + expect.stringContaining('Invalid extension name'), + ); + }); + }); + + describe('Extension configuration (all settings)', () => { + it('should configure all settings for an extension', async () => { + const settings = [{ name: 'Setting 1', envVar: 'VAR_1' }]; + setupExtension('test-ext', settings); + (getScopedEnvContents as Mock).mockResolvedValue({}); + (updateSetting as Mock).mockResolvedValue(undefined); + + await runCommand('config test-ext'); + + expect(debugLogger.log).toHaveBeenCalledWith( + 'Configuring settings for "test-ext"...', + ); + expect(updateSetting).toHaveBeenCalledWith( + expect.objectContaining({ name: 'test-ext' }), + 'test-id', + 'VAR_1', + expect.any(Function), + 'user', + tempWorkspaceDir, + ); + }); + + it('should verify overwrite if setting is already set', async () => { + const settings = [{ name: 'Setting 1', envVar: 'VAR_1' }]; + setupExtension('test-ext', settings); + (getScopedEnvContents as Mock).mockImplementation( + async (_config, _id, scope) => { + if (scope === 'user') return { VAR_1: 'existing' }; + return {}; + }, + ); + (prompts as unknown as Mock).mockResolvedValue({ confirm: true }); + (updateSetting as Mock).mockResolvedValue(undefined); + + await runCommand('config test-ext'); + + expect(prompts).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'confirm', + message: expect.stringContaining('is already set. Overwrite?'), + }), + ); + expect(updateSetting).toHaveBeenCalled(); + }); + + it('should note if setting is configured in workspace', async () => { + const settings = [{ name: 'Setting 1', envVar: 'VAR_1' }]; + setupExtension('test-ext', settings); + (getScopedEnvContents as Mock).mockImplementation( + async (_config, _id, scope) => { + if (scope === 'workspace') return { VAR_1: 'workspace_value' }; + return {}; + }, + ); + (updateSetting as Mock).mockResolvedValue(undefined); + + await runCommand('config test-ext'); + + expect(debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining('is already configured in the workspace scope'), + ); + }); + + it('should skip update if user denies overwrite', async () => { + const settings = [{ name: 'Setting 1', envVar: 'VAR_1' }]; + setupExtension('test-ext', settings); + (getScopedEnvContents as Mock).mockResolvedValue({ VAR_1: 'existing' }); + (prompts as unknown as Mock).mockResolvedValue({ confirm: false }); + + await runCommand('config test-ext'); + + expect(prompts).toHaveBeenCalled(); + expect(updateSetting).not.toHaveBeenCalled(); + }); + }); + + describe('Configure all extensions', () => { + it('should configure settings for all installed extensions', async () => { + const ext1 = { + name: 'ext1', + path: '/p1', + id: 'id1', + settings: [{ envVar: 'V1' }], + }; + const ext2 = { + name: 'ext2', + path: '/p2', + id: 'id2', + settings: [{ envVar: 'V2' }], + }; + mockExtensionManager.getExtensions.mockReturnValue([ext1, ext2]); + + mockExtensionManager.loadExtensionConfig.mockImplementation( + async (path) => { + if (path === '/p1') + return { name: 'ext1', settings: [{ name: 'S1', envVar: 'V1' }] }; + if (path === '/p2') + return { name: 'ext2', settings: [{ name: 'S2', envVar: 'V2' }] }; + return null; + }, + ); + + (getScopedEnvContents as Mock).mockResolvedValue({}); + (updateSetting as Mock).mockResolvedValue(undefined); + + await runCommand('config'); + + expect(debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining('Configuring settings for "ext1"'), + ); + expect(debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining('Configuring settings for "ext2"'), + ); + expect(updateSetting).toHaveBeenCalledTimes(2); + }); + + it('should log if no extensions installed', async () => { + mockExtensionManager.getExtensions.mockReturnValue([]); + await runCommand('config'); + expect(debugLogger.log).toHaveBeenCalledWith('No extensions installed.'); + }); + }); +}); diff --git a/packages/cli/src/commands/extensions/configure.ts b/packages/cli/src/commands/extensions/configure.ts new file mode 100644 index 0000000000000000000000000000000000000000..a2136968b34455b7377691b3e1a3bcba410452fa --- /dev/null +++ b/packages/cli/src/commands/extensions/configure.ts @@ -0,0 +1,98 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import type { ExtensionSettingScope } from '../../config/extensions/extensionSettings.js'; +import { + configureAllExtensions, + configureExtension, + configureSpecificSetting, + getExtensionManager, +} from './utils.js'; +import { loadSettings } from '../../config/settings.js'; +import { coreEvents, debugLogger } from '@google/gemini-cli-core'; +import { exitCli } from '../utils.js'; + +interface ConfigureArgs { + name?: string; + setting?: string; + scope: string; +} + +export const configureCommand: CommandModule = { + command: 'config [name] [setting]', + describe: 'Configure extension settings.', + builder: (yargs) => + yargs + .positional('name', { + describe: 'Name of the extension to configure.', + type: 'string', + }) + .positional('setting', { + describe: 'The specific setting to configure (name or env var).', + type: 'string', + }) + .option('scope', { + describe: 'The scope to set the setting in.', + type: 'string', + choices: ['user', 'workspace'], + default: 'user', + }), + handler: async (args) => { + const { name, setting, scope } = args; + const settings = loadSettings(process.cwd()).merged; + + if (!(settings.experimental?.extensionConfig ?? true)) { + coreEvents.emitFeedback( + 'error', + 'Extension configuration is currently disabled. Enable it by setting "experimental.extensionConfig" to true.', + ); + await exitCli(); + return; + } + + if (name) { + if (name.includes('/') || name.includes('\\') || name.includes('..')) { + debugLogger.error( + 'Invalid extension name. Names cannot contain path separators or "..".', + ); + return; + } + } + + const extensionManager = await getExtensionManager(); + + // Case 1: Configure specific setting for an extension + if (name && setting) { + await configureSpecificSetting( + extensionManager, + name, + setting, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + scope as ExtensionSettingScope, + ); + } + // Case 2: Configure all settings for an extension + else if (name) { + await configureExtension( + extensionManager, + name, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + scope as ExtensionSettingScope, + ); + } + // Case 3: Configure all extensions + else { + await configureAllExtensions( + extensionManager, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + scope as ExtensionSettingScope, + ); + } + + await exitCli(); + }, +}; diff --git a/packages/cli/src/commands/extensions/disable.test.ts b/packages/cli/src/commands/extensions/disable.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..47fc1190c0f823d524186a36e047c1081d5428d4 --- /dev/null +++ b/packages/cli/src/commands/extensions/disable.test.ts @@ -0,0 +1,250 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + vi, + describe, + it, + expect, + beforeEach, + afterEach, + type Mock, +} from 'vitest'; +import { format } from 'node:util'; +import { type Argv } from 'yargs'; +import { handleDisable, disableCommand } from './disable.js'; +import { ExtensionManager } from '../../config/extension-manager.js'; +import { + loadSettings, + SettingScope, + type LoadedSettings, +} from '../../config/settings.js'; +import { getErrorMessage } from '@google/gemini-cli-core'; + +// Mock dependencies +const emitConsoleLog = vi.hoisted(() => vi.fn()); +const debugLogger = vi.hoisted(() => ({ + log: vi.fn((message, ...args) => { + emitConsoleLog('log', format(message, ...args)); + }), + error: vi.fn((message, ...args) => { + emitConsoleLog('error', format(message, ...args)); + }), +})); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + coreEvents: { + emitConsoleLog, + }, + debugLogger, + getErrorMessage: vi.fn(), + }; +}); + +vi.mock('../../config/extension-manager.js'); +vi.mock('../../config/settings.js'); +vi.mock('../../config/extensions/consent.js', () => ({ + requestConsentNonInteractive: vi.fn(), +})); +vi.mock('../../config/extensions/extensionSettings.js', () => ({ + promptForSetting: vi.fn(), +})); +vi.mock('../utils.js', () => ({ + exitCli: vi.fn(), +})); + +describe('extensions disable command', () => { + const mockLoadSettings = vi.mocked(loadSettings); + const mockGetErrorMessage = vi.mocked(getErrorMessage); + const mockExtensionManager = vi.mocked(ExtensionManager); + + beforeEach(async () => { + vi.clearAllMocks(); + mockLoadSettings.mockReturnValue({ + merged: {}, + } as unknown as LoadedSettings); + mockExtensionManager.prototype.loadExtensions = vi + .fn() + .mockResolvedValue(undefined); + mockExtensionManager.prototype.disableExtension = vi + .fn() + .mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('handleDisable', () => { + it.each([ + { + name: 'my-extension', + scope: undefined, + expectedScope: SettingScope.User, + expectedLog: + 'Extension "my-extension" successfully disabled for scope "undefined".', + }, + { + name: 'my-extension', + scope: 'user', + expectedScope: SettingScope.User, + expectedLog: + 'Extension "my-extension" successfully disabled for scope "user".', + }, + { + name: 'my-extension', + scope: 'workspace', + expectedScope: SettingScope.Workspace, + expectedLog: + 'Extension "my-extension" successfully disabled for scope "workspace".', + }, + ])( + 'should disable an extension in the $expectedScope scope when scope is $scope', + async ({ name, scope, expectedScope, expectedLog }) => { + const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); + await handleDisable({ name, scope }); + expect(mockExtensionManager).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceDir: '/test/dir', + }), + ); + expect( + mockExtensionManager.prototype.loadExtensions, + ).toHaveBeenCalled(); + expect( + mockExtensionManager.prototype.disableExtension, + ).toHaveBeenCalledWith(name, expectedScope); + expect(emitConsoleLog).toHaveBeenCalledWith('log', expectedLog); + mockCwd.mockRestore(); + }, + ); + + it('should log an error message and exit with code 1 when extension disabling fails', async () => { + const mockProcessExit = vi + .spyOn(process, 'exit') + .mockImplementation((() => {}) as ( + code?: string | number | null | undefined, + ) => never); + const error = new Error('Disable failed'); + ( + mockExtensionManager.prototype.disableExtension as Mock + ).mockRejectedValue(error); + mockGetErrorMessage.mockReturnValue('Disable failed message'); + await handleDisable({ name: 'my-extension' }); + expect(emitConsoleLog).toHaveBeenCalledWith( + 'error', + 'Disable failed message', + ); + expect(mockProcessExit).toHaveBeenCalledWith(1); + mockProcessExit.mockRestore(); + }); + }); + + describe('disableCommand', () => { + const command = disableCommand; + + it('should have correct command and describe', () => { + expect(command.command).toBe('disable [--scope] '); + expect(command.describe).toBe('Disables an extension.'); + }); + + describe('builder', () => { + interface MockYargs { + positional: Mock; + option: Mock; + check: Mock; + } + + let yargsMock: MockYargs; + + beforeEach(() => { + yargsMock = { + positional: vi.fn().mockReturnThis(), + option: vi.fn().mockReturnThis(), + check: vi.fn().mockReturnThis(), + }; + }); + + it('should configure positional and option arguments', () => { + (command.builder as (yargs: Argv) => Argv)( + yargsMock as unknown as Argv, + ); + expect(yargsMock.positional).toHaveBeenCalledWith('name', { + describe: 'The name of the extension to disable.', + type: 'string', + }); + expect(yargsMock.option).toHaveBeenCalledWith('scope', { + describe: 'The scope to disable the extension in.', + type: 'string', + default: SettingScope.User, + }); + expect(yargsMock.check).toHaveBeenCalled(); + }); + + it('check function should throw for invalid scope', () => { + (command.builder as (yargs: Argv) => Argv)( + yargsMock as unknown as Argv, + ); + const checkCallback = yargsMock.check.mock.calls[0][0]; + const expectedError = `Invalid scope: invalid. Please use one of ${Object.values( + SettingScope, + ) + .map((s) => s.toLowerCase()) + .join(', ')}.`; + expect(() => checkCallback({ scope: 'invalid' })).toThrow( + expectedError, + ); + }); + + it.each(['user', 'workspace', 'USER', 'WorkSpace'])( + 'check function should return true for valid scope "%s"', + (scope) => { + (command.builder as (yargs: Argv) => Argv)( + yargsMock as unknown as Argv, + ); + const checkCallback = yargsMock.check.mock.calls[0][0]; + expect(checkCallback({ scope })).toBe(true); + }, + ); + }); + + it('handler should trigger extension disabling', async () => { + const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); + interface TestArgv { + name: string; + scope: string; + [key: string]: unknown; + } + const argv: TestArgv = { + name: 'test-ext', + scope: 'workspace', + _: [], + $0: '', + }; + await (command.handler as unknown as (args: TestArgv) => Promise)( + argv, + ); + expect(mockExtensionManager).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceDir: '/test/dir', + }), + ); + expect(mockExtensionManager.prototype.loadExtensions).toHaveBeenCalled(); + expect( + mockExtensionManager.prototype.disableExtension, + ).toHaveBeenCalledWith('test-ext', SettingScope.Workspace); + expect(emitConsoleLog).toHaveBeenCalledWith( + 'log', + 'Extension "test-ext" successfully disabled for scope "workspace".', + ); + mockCwd.mockRestore(); + }); + }); +}); diff --git a/packages/cli/src/commands/extensions/disable.ts b/packages/cli/src/commands/extensions/disable.ts new file mode 100644 index 0000000000000000000000000000000000000000..dae97ea58461073ccde6c5af1b43bcba0d1fc2f2 --- /dev/null +++ b/packages/cli/src/commands/extensions/disable.ts @@ -0,0 +1,88 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { type CommandModule } from 'yargs'; +import { loadSettings, SettingScope } from '../../config/settings.js'; +import { debugLogger, getErrorMessage } from '@google/gemini-cli-core'; +import { ExtensionManager } from '../../config/extension-manager.js'; +import { requestConsentNonInteractive } from '../../config/extensions/consent.js'; +import { promptForSetting } from '../../config/extensions/extensionSettings.js'; +import { exitCli } from '../utils.js'; + +interface DisableArgs { + name: string; + scope?: string; +} + +export async function handleDisable(args: DisableArgs) { + const workspaceDir = process.cwd(); + const extensionManager = new ExtensionManager({ + workspaceDir, + requestConsent: requestConsentNonInteractive, + requestSetting: promptForSetting, + settings: loadSettings(workspaceDir).merged, + }); + await extensionManager.loadExtensions(); + + try { + if (args.scope?.toLowerCase() === 'workspace') { + await extensionManager.disableExtension( + args.name, + SettingScope.Workspace, + ); + } else { + await extensionManager.disableExtension(args.name, SettingScope.User); + } + debugLogger.log( + `Extension "${args.name}" successfully disabled for scope "${args.scope}".`, + ); + } catch (error) { + debugLogger.error(getErrorMessage(error)); + process.exit(1); + } +} + +export const disableCommand: CommandModule = { + command: 'disable [--scope] ', + describe: 'Disables an extension.', + builder: (yargs) => + yargs + .positional('name', { + describe: 'The name of the extension to disable.', + type: 'string', + }) + .option('scope', { + describe: 'The scope to disable the extension in.', + type: 'string', + default: SettingScope.User, + }) + .check((argv) => { + if ( + argv.scope && + !Object.values(SettingScope) + .map((s) => s.toLowerCase()) + .includes(argv.scope.toLowerCase()) + ) { + throw new Error( + `Invalid scope: ${argv.scope}. Please use one of ${Object.values( + SettingScope, + ) + .map((s) => s.toLowerCase()) + .join(', ')}.`, + ); + } + return true; + }), + handler: async (argv) => { + await handleDisable({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + name: argv['name'] as string, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + scope: argv['scope'] as string, + }); + await exitCli(); + }, +}; diff --git a/packages/cli/src/commands/extensions/enable.test.ts b/packages/cli/src/commands/extensions/enable.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..aafb5193e44e774fccb359b0b1385754b5788aa6 --- /dev/null +++ b/packages/cli/src/commands/extensions/enable.test.ts @@ -0,0 +1,280 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + vi, + describe, + it, + expect, + beforeEach, + afterEach, + type Mock, +} from 'vitest'; +import { format } from 'node:util'; +import { type Argv } from 'yargs'; +import { handleEnable, enableCommand } from './enable.js'; +import { ExtensionManager } from '../../config/extension-manager.js'; +import { + loadSettings, + SettingScope, + type LoadedSettings, +} from '../../config/settings.js'; +import { FatalConfigError } from '@google/gemini-cli-core'; + +// Mock dependencies +const emitConsoleLog = vi.hoisted(() => vi.fn()); +const debugLogger = vi.hoisted(() => ({ + log: vi.fn((message, ...args) => { + emitConsoleLog('log', format(message, ...args)); + }), + error: vi.fn((message, ...args) => { + emitConsoleLog('error', format(message, ...args)); + }), +})); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + coreEvents: { + emitConsoleLog, + }, + debugLogger, + getErrorMessage: vi.fn((error: { message: string }) => error.message), + FatalConfigError: class extends Error { + constructor(message: string) { + super(message); + this.name = 'FatalConfigError'; + } + }, + }; +}); + +vi.mock('../../config/extension-manager.js'); +vi.mock('../../config/settings.js'); +vi.mock('../../config/extensions/consent.js'); +vi.mock('../../config/extensions/extensionSettings.js'); +vi.mock('../utils.js', () => ({ + exitCli: vi.fn(), +})); + +const mockEnablementInstance = vi.hoisted(() => ({ + getDisplayState: vi.fn(), + enable: vi.fn(), + clearSessionDisable: vi.fn(), + autoEnableServers: vi.fn(), +})); +vi.mock('../../config/mcp/mcpServerEnablement.js', () => ({ + McpServerEnablementManager: { + getInstance: () => mockEnablementInstance, + }, +})); + +describe('extensions enable command', () => { + const mockLoadSettings = vi.mocked(loadSettings); + const mockExtensionManager = vi.mocked(ExtensionManager); + + beforeEach(async () => { + vi.clearAllMocks(); + mockLoadSettings.mockReturnValue({ + merged: {}, + } as unknown as LoadedSettings); + mockExtensionManager.prototype.loadExtensions = vi + .fn() + .mockResolvedValue(undefined); + mockExtensionManager.prototype.enableExtension = vi.fn(); + mockExtensionManager.prototype.getExtensions = vi.fn().mockReturnValue([]); + mockEnablementInstance.getDisplayState.mockReset(); + mockEnablementInstance.enable.mockReset(); + mockEnablementInstance.clearSessionDisable.mockReset(); + mockEnablementInstance.autoEnableServers.mockReset(); + mockEnablementInstance.autoEnableServers.mockResolvedValue([]); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('handleEnable', () => { + it.each([ + { + name: 'my-extension', + scope: undefined, + expectedScope: SettingScope.User, + expectedLog: + 'Extension "my-extension" successfully enabled in all scopes.', + }, + { + name: 'my-extension', + scope: 'workspace', + expectedScope: SettingScope.Workspace, + expectedLog: + 'Extension "my-extension" successfully enabled for scope "workspace".', + }, + ])( + 'should enable an extension in the $expectedScope scope when scope is $scope', + async ({ name, scope, expectedScope, expectedLog }) => { + const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); + await handleEnable({ name, scope }); + + expect(mockExtensionManager).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceDir: '/test/dir', + }), + ); + expect( + mockExtensionManager.prototype.loadExtensions, + ).toHaveBeenCalled(); + expect( + mockExtensionManager.prototype.enableExtension, + ).toHaveBeenCalledWith(name, expectedScope); + expect(emitConsoleLog).toHaveBeenCalledWith('log', expectedLog); + mockCwd.mockRestore(); + }, + ); + + it('should throw FatalConfigError when extension enabling fails', async () => { + const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); + const error = new Error('Enable failed'); + ( + mockExtensionManager.prototype.enableExtension as Mock + ).mockImplementation(() => { + throw error; + }); + + const promise = handleEnable({ name: 'my-extension' }); + await expect(promise).rejects.toThrow(FatalConfigError); + await expect(promise).rejects.toThrow('Enable failed'); + + mockCwd.mockRestore(); + }); + + it('should auto-enable disabled MCP servers for the extension', async () => { + const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); + mockEnablementInstance.autoEnableServers.mockResolvedValue([ + 'test-server', + ]); + mockExtensionManager.prototype.getExtensions = vi + .fn() + .mockReturnValue([ + { name: 'my-extension', mcpServers: { 'test-server': {} } }, + ]); + + await handleEnable({ name: 'my-extension' }); + + expect(mockEnablementInstance.autoEnableServers).toHaveBeenCalledWith([ + 'test-server', + ]); + expect(emitConsoleLog).toHaveBeenCalledWith( + 'log', + expect.stringContaining("MCP server 'test-server' was disabled"), + ); + mockCwd.mockRestore(); + }); + + it('should not log when MCP servers are already enabled', async () => { + const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); + mockEnablementInstance.autoEnableServers.mockResolvedValue([]); + mockExtensionManager.prototype.getExtensions = vi + .fn() + .mockReturnValue([ + { name: 'my-extension', mcpServers: { 'test-server': {} } }, + ]); + + await handleEnable({ name: 'my-extension' }); + + expect(mockEnablementInstance.autoEnableServers).toHaveBeenCalledWith([ + 'test-server', + ]); + expect(emitConsoleLog).not.toHaveBeenCalledWith( + 'log', + expect.stringContaining("MCP server 'test-server' was disabled"), + ); + mockCwd.mockRestore(); + }); + }); + + describe('enableCommand', () => { + const command = enableCommand; + + it('should have correct command and describe', () => { + expect(command.command).toBe('enable [--scope] '); + expect(command.describe).toBe('Enables an extension.'); + }); + + describe('builder', () => { + interface MockYargs { + positional: Mock; + option: Mock; + check: Mock; + } + + let yargsMock: MockYargs; + beforeEach(() => { + yargsMock = { + positional: vi.fn().mockReturnThis(), + option: vi.fn().mockReturnThis(), + check: vi.fn().mockReturnThis(), + }; + }); + + it('should configure positional and option arguments', () => { + (command.builder as (yargs: Argv) => Argv)( + yargsMock as unknown as Argv, + ); + expect(yargsMock.positional).toHaveBeenCalledWith('name', { + describe: 'The name of the extension to enable.', + type: 'string', + }); + expect(yargsMock.option).toHaveBeenCalledWith('scope', { + describe: + 'The scope to enable the extension in. If not set, will be enabled in all scopes.', + type: 'string', + }); + expect(yargsMock.check).toHaveBeenCalled(); + }); + + it('check function should throw for invalid scope', () => { + (command.builder as (yargs: Argv) => Argv)( + yargsMock as unknown as Argv, + ); + const checkCallback = yargsMock.check.mock.calls[0][0]; + const expectedError = `Invalid scope: invalid. Please use one of ${Object.values( + SettingScope, + ) + .map((s) => s.toLowerCase()) + .join(', ')}.`; + expect(() => checkCallback({ scope: 'invalid' })).toThrow( + expectedError, + ); + }); + }); + + it('handler should call handleEnable', async () => { + const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); + interface TestArgv { + name: string; + scope: string; + [key: string]: unknown; + } + const argv: TestArgv = { + name: 'test-ext', + scope: 'workspace', + _: [], + $0: '', + }; + await (command.handler as unknown as (args: TestArgv) => Promise)( + argv, + ); + + expect( + mockExtensionManager.prototype.enableExtension, + ).toHaveBeenCalledWith('test-ext', SettingScope.Workspace); + mockCwd.mockRestore(); + }); + }); +}); diff --git a/packages/cli/src/commands/extensions/enable.ts b/packages/cli/src/commands/extensions/enable.ts new file mode 100644 index 0000000000000000000000000000000000000000..e0976aa10a86e12a8a8347d39cf2c34a8746ca6d --- /dev/null +++ b/packages/cli/src/commands/extensions/enable.ts @@ -0,0 +1,115 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { type CommandModule } from 'yargs'; +import { loadSettings, SettingScope } from '../../config/settings.js'; +import { requestConsentNonInteractive } from '../../config/extensions/consent.js'; +import { ExtensionManager } from '../../config/extension-manager.js'; +import { + debugLogger, + FatalConfigError, + getErrorMessage, +} from '@google/gemini-cli-core'; +import { promptForSetting } from '../../config/extensions/extensionSettings.js'; +import { exitCli } from '../utils.js'; +import { McpServerEnablementManager } from '../../config/mcp/mcpServerEnablement.js'; + +interface EnableArgs { + name: string; + scope?: string; +} + +export async function handleEnable(args: EnableArgs) { + const workingDir = process.cwd(); + const extensionManager = new ExtensionManager({ + workspaceDir: workingDir, + requestConsent: requestConsentNonInteractive, + requestSetting: promptForSetting, + settings: loadSettings(workingDir).merged, + }); + await extensionManager.loadExtensions(); + + try { + if (args.scope?.toLowerCase() === 'workspace') { + await extensionManager.enableExtension(args.name, SettingScope.Workspace); + } else { + await extensionManager.enableExtension(args.name, SettingScope.User); + } + + // Auto-enable any disabled MCP servers for this extension + const extension = extensionManager + .getExtensions() + .find((e) => e.name === args.name); + + if (extension?.mcpServers) { + const mcpEnablementManager = McpServerEnablementManager.getInstance(); + const enabledServers = await mcpEnablementManager.autoEnableServers( + Object.keys(extension.mcpServers ?? {}), + ); + + for (const serverName of enabledServers) { + debugLogger.log( + `MCP server '${serverName}' was disabled - now enabled.`, + ); + } + // Note: No restartServer() - CLI exits immediately, servers load on next session + } + + if (args.scope) { + debugLogger.log( + `Extension "${args.name}" successfully enabled for scope "${args.scope}".`, + ); + } else { + debugLogger.log( + `Extension "${args.name}" successfully enabled in all scopes.`, + ); + } + } catch (error) { + throw new FatalConfigError(getErrorMessage(error)); + } +} + +export const enableCommand: CommandModule = { + command: 'enable [--scope] ', + describe: 'Enables an extension.', + builder: (yargs) => + yargs + .positional('name', { + describe: 'The name of the extension to enable.', + type: 'string', + }) + .option('scope', { + describe: + 'The scope to enable the extension in. If not set, will be enabled in all scopes.', + type: 'string', + }) + .check((argv) => { + if ( + argv.scope && + !Object.values(SettingScope) + .map((s) => s.toLowerCase()) + .includes(argv.scope.toLowerCase()) + ) { + throw new Error( + `Invalid scope: ${argv.scope}. Please use one of ${Object.values( + SettingScope, + ) + .map((s) => s.toLowerCase()) + .join(', ')}.`, + ); + } + return true; + }), + handler: async (argv) => { + await handleEnable({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + name: argv['name'] as string, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + scope: argv['scope'] as string, + }); + await exitCli(); + }, +}; diff --git a/packages/cli/src/commands/extensions/examples/exclude-tools/.gitignore b/packages/cli/src/commands/extensions/examples/exclude-tools/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..b2a70da46e5515d38fc6b7551ac76b3b46c0e5ae --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/exclude-tools/.gitignore @@ -0,0 +1,26 @@ +# Dependencies +node_modules/ +npm-debug.log* +yarn-error.log +yarn-debug.log + +# Build output +dist/ + +# OS metadata +.DS_Store +Thumbs.db + +# TypeScript +*.tsbuildinfo + +# Environment variables +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# IDEs +.vscode/ +.idea/ diff --git a/packages/cli/src/commands/extensions/examples/exclude-tools/gemini-extension.json b/packages/cli/src/commands/extensions/examples/exclude-tools/gemini-extension.json new file mode 100644 index 0000000000000000000000000000000000000000..5023fb7ad068a1008dd63039c793c39c9e4f55c6 --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/exclude-tools/gemini-extension.json @@ -0,0 +1,5 @@ +{ + "name": "excludeTools", + "version": "1.0.0", + "excludeTools": ["run_shell_command(rm -rf)"] +} diff --git a/packages/cli/src/commands/extensions/examples/mcp-server/.gitignore b/packages/cli/src/commands/extensions/examples/mcp-server/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..b2a70da46e5515d38fc6b7551ac76b3b46c0e5ae --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/mcp-server/.gitignore @@ -0,0 +1,26 @@ +# Dependencies +node_modules/ +npm-debug.log* +yarn-error.log +yarn-debug.log + +# Build output +dist/ + +# OS metadata +.DS_Store +Thumbs.db + +# TypeScript +*.tsbuildinfo + +# Environment variables +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# IDEs +.vscode/ +.idea/ diff --git a/packages/cli/src/commands/extensions/examples/mcp-server/README.md b/packages/cli/src/commands/extensions/examples/mcp-server/README.md new file mode 100644 index 0000000000000000000000000000000000000000..3ca50977ed309e389dd0f3359b495fe2bcd4486e --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/mcp-server/README.md @@ -0,0 +1,35 @@ +# MCP Server Example + +This is a basic example of an MCP (Model Context Protocol) server used as a +Gemini CLI extension. It demonstrates how to expose tools and prompts to the +Gemini CLI. + +## Description + +The contents of this directory are a valid MCP server implementation using the +`@modelcontextprotocol/sdk`. It exposes: + +- A tool `fetch_posts` that mock-fetches posts. +- A prompt `poem-writer`. + +## Structure + +- `example.js`: The main server entry point. +- `gemini-extension.json`: The configuration file that tells Gemini CLI how to + use this extension. +- `package.json`: Helper for dependencies. + +## How to Use + +1. Navigate to this directory: + + ```bash + cd packages/cli/src/commands/extensions/examples/mcp-server + ``` + +2. Install dependencies: + ```bash + npm install + ``` + +This example is typically used by `gemini extensions new`. diff --git a/packages/cli/src/commands/extensions/examples/mcp-server/gemini-extension.json b/packages/cli/src/commands/extensions/examples/mcp-server/gemini-extension.json new file mode 100644 index 0000000000000000000000000000000000000000..25cea934114e90abb47dbce19aecfaa8619f3bdb --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/mcp-server/gemini-extension.json @@ -0,0 +1,11 @@ +{ + "name": "mcp-server-example", + "version": "1.0.0", + "mcpServers": { + "nodeServer": { + "command": "node", + "args": ["${extensionPath}${/}example.js"], + "cwd": "${extensionPath}" + } + } +} diff --git a/packages/cli/src/commands/extensions/install.test.ts b/packages/cli/src/commands/extensions/install.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..df0d162026b1bcb0f94eb8b83d07768a753e66c4 --- /dev/null +++ b/packages/cli/src/commands/extensions/install.test.ts @@ -0,0 +1,464 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, + type MockInstance, +} from 'vitest'; +import { handleInstall, installCommand } from './install.js'; +import yargs from 'yargs'; +import * as core from '@google/gemini-cli-core'; +import type { Stats } from 'node:fs'; +import * as path from 'node:path'; +import { promptForSetting } from '../../config/extensions/extensionSettings.js'; + +const { + mockInstallOrUpdateExtension, + mockLoadExtensions, + mockExtensionManager, + mockRequestConsentNonInteractive, + mockPromptForConsentNonInteractive, + mockStat, + mockInferInstallMetadata, + mockIsWorkspaceTrusted, + mockLoadTrustedFolders, + mockDiscover, +} = vi.hoisted(() => { + const mockLoadExtensions = vi.fn(); + const mockInstallOrUpdateExtension = vi.fn(); + const mockExtensionManager = vi.fn().mockImplementation(() => ({ + loadExtensions: mockLoadExtensions, + installOrUpdateExtension: mockInstallOrUpdateExtension, + })); + + return { + mockLoadExtensions, + mockInstallOrUpdateExtension, + mockExtensionManager, + mockRequestConsentNonInteractive: vi.fn(), + mockPromptForConsentNonInteractive: vi.fn(), + mockStat: vi.fn(), + mockInferInstallMetadata: vi.fn(), + mockIsWorkspaceTrusted: vi.fn(), + mockLoadTrustedFolders: vi.fn(), + mockDiscover: vi.fn(), + }; +}); + +vi.mock('../../config/extensions/consent.js', () => ({ + requestConsentNonInteractive: mockRequestConsentNonInteractive, + promptForConsentNonInteractive: mockPromptForConsentNonInteractive, + INSTALL_WARNING_MESSAGE: 'warning', +})); + +vi.mock('../../config/trustedFolders.js', () => ({ + isWorkspaceTrusted: mockIsWorkspaceTrusted, + loadTrustedFolders: mockLoadTrustedFolders, + TrustLevel: { + TRUST_FOLDER: 'TRUST_FOLDER', + }, +})); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + FolderTrustDiscoveryService: { + discover: mockDiscover, + }, + }; +}); + +vi.mock('../../config/extension-manager.js', async (importOriginal) => ({ + ...(await importOriginal< + typeof import('../../config/extension-manager.js') + >()), + ExtensionManager: mockExtensionManager, + inferInstallMetadata: mockInferInstallMetadata, +})); + +vi.mock('../../utils/errors.js', () => ({ + getErrorMessage: vi.fn((error: Error) => error.message), +})); + +vi.mock('node:fs/promises', () => ({ + stat: mockStat, + default: { + stat: mockStat, + }, +})); + +vi.mock('../utils.js', () => ({ + exitCli: vi.fn(), +})); + +describe('extensions install command', () => { + it('should fail if no source is provided', () => { + const validationParser = yargs([]) + .locale('en') + .command(installCommand) + .fail(false); + expect(() => validationParser.parse('install')).toThrow( + 'Not enough non-option arguments: got 0, need at least 1', + ); + }); +}); + +describe('handleInstall', () => { + let debugLogSpy: MockInstance; + let debugErrorSpy: MockInstance; + let processSpy: MockInstance; + + beforeEach(() => { + debugLogSpy = vi + .spyOn(core.debugLogger, 'log') + .mockImplementation(() => {}); + debugErrorSpy = vi + .spyOn(core.debugLogger, 'error') + .mockImplementation(() => {}); + processSpy = vi + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + + mockLoadExtensions.mockResolvedValue([]); + mockInstallOrUpdateExtension.mockReset(); + + mockIsWorkspaceTrusted.mockReturnValue({ isTrusted: true, source: 'file' }); + mockDiscover.mockResolvedValue({ + commands: [], + mcps: [], + hooks: [], + skills: [], + agents: [], + settings: [], + securityWarnings: [], + discoveryErrors: [], + }); + + mockInferInstallMetadata.mockImplementation(async (source, args) => { + if ( + source.startsWith('http://') || + source.startsWith('https://') || + source.startsWith('git@') || + source.startsWith('sso://') + ) { + return { + source, + type: 'git', + ref: args?.ref, + autoUpdate: args?.autoUpdate, + allowPreRelease: args?.allowPreRelease, + }; + } + return { source, type: 'local' }; + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + function createMockExtension( + overrides: Partial = {}, + ): core.GeminiCLIExtension { + return { + name: 'mock-extension', + version: '1.0.0', + isActive: true, + path: '/mock/path', + contextFiles: [], + id: 'mock-id', + ...overrides, + }; + } + + it('should install an extension from a http source', async () => { + mockInstallOrUpdateExtension.mockResolvedValue( + createMockExtension({ + name: 'http-extension', + }), + ); + + await handleInstall({ + source: 'http://google.com', + }); + + expect(debugLogSpy).toHaveBeenCalledWith( + 'Extension "http-extension" installed successfully and enabled.', + ); + }); + + it('should install an extension from a https source', async () => { + mockInstallOrUpdateExtension.mockResolvedValue( + createMockExtension({ + name: 'https-extension', + }), + ); + + await handleInstall({ + source: 'https://google.com', + }); + + expect(debugLogSpy).toHaveBeenCalledWith( + 'Extension "https-extension" installed successfully and enabled.', + ); + }); + + it('should install an extension from a git source', async () => { + mockInstallOrUpdateExtension.mockResolvedValue( + createMockExtension({ + name: 'git-extension', + }), + ); + + await handleInstall({ + source: 'git@some-url', + }); + + expect(debugLogSpy).toHaveBeenCalledWith( + 'Extension "git-extension" installed successfully and enabled.', + ); + }); + + it('throws an error from an unknown source', async () => { + mockInferInstallMetadata.mockRejectedValue( + new Error('Install source not found.'), + ); + await handleInstall({ + source: 'test://google.com', + }); + + expect(debugErrorSpy).toHaveBeenCalledWith('Install source not found.'); + expect(processSpy).toHaveBeenCalledWith(1); + }); + + it('should install an extension from a sso source', async () => { + mockInstallOrUpdateExtension.mockResolvedValue( + createMockExtension({ + name: 'sso-extension', + }), + ); + + await handleInstall({ + source: 'sso://google.com', + }); + + expect(debugLogSpy).toHaveBeenCalledWith( + 'Extension "sso-extension" installed successfully and enabled.', + ); + }); + + it('should install an extension from a local path', async () => { + mockInstallOrUpdateExtension.mockResolvedValue( + createMockExtension({ + name: 'local-extension', + }), + ); + mockStat.mockResolvedValue({} as Stats); + await handleInstall({ + source: path.join('/', 'some', 'path'), + }); + + expect(debugLogSpy).toHaveBeenCalledWith( + 'Extension "local-extension" installed successfully and enabled.', + ); + }); + + it('should throw an error if install extension fails', async () => { + mockInstallOrUpdateExtension.mockRejectedValue( + new Error('Install extension failed'), + ); + + await handleInstall({ source: 'git@some-url' }); + + expect(debugErrorSpy).toHaveBeenCalledWith('Install extension failed'); + expect(processSpy).toHaveBeenCalledWith(1); + }); + + it('should pass promptForSetting when skipSettings is not provided', async () => { + mockInstallOrUpdateExtension.mockResolvedValue({ + name: 'test-extension', + } as unknown as core.GeminiCLIExtension); + + await handleInstall({ + source: 'http://google.com', + }); + + expect(mockExtensionManager).toHaveBeenCalledWith( + expect.objectContaining({ + requestSetting: promptForSetting, + }), + ); + }); + + it('should pass null for requestSetting when skipSettings is true', async () => { + mockInstallOrUpdateExtension.mockResolvedValue({ + name: 'test-extension', + } as unknown as core.GeminiCLIExtension); + + await handleInstall({ + source: 'http://google.com', + skipSettings: true, + }); + + expect(mockExtensionManager).toHaveBeenCalledWith( + expect.objectContaining({ + requestSetting: null, + }), + ); + }); + + it('should proceed if local path is already trusted', async () => { + mockInstallOrUpdateExtension.mockResolvedValue( + createMockExtension({ + name: 'local-extension', + }), + ); + mockStat.mockResolvedValue({} as Stats); + mockIsWorkspaceTrusted.mockReturnValue({ isTrusted: true, source: 'file' }); + + await handleInstall({ + source: path.join('/', 'some', 'path'), + }); + + expect(mockIsWorkspaceTrusted).toHaveBeenCalled(); + expect(mockPromptForConsentNonInteractive).not.toHaveBeenCalled(); + expect(debugLogSpy).toHaveBeenCalledWith( + 'Extension "local-extension" installed successfully and enabled.', + ); + }); + + it('should prompt and proceed if user accepts trust', async () => { + mockInstallOrUpdateExtension.mockResolvedValue( + createMockExtension({ + name: 'local-extension', + }), + ); + mockStat.mockResolvedValue({} as Stats); + mockIsWorkspaceTrusted.mockReturnValue({ + isTrusted: undefined, + source: undefined, + }); + mockPromptForConsentNonInteractive.mockResolvedValue(true); + const mockSetValue = vi.fn(); + mockLoadTrustedFolders.mockReturnValue({ + setValue: mockSetValue, + user: { path: '', config: {} }, + errors: [], + rules: [], + isPathTrusted: vi.fn(), + }); + + await handleInstall({ + source: path.join('/', 'untrusted', 'path'), + }); + + expect(mockIsWorkspaceTrusted).toHaveBeenCalled(); + expect(mockPromptForConsentNonInteractive).toHaveBeenCalled(); + expect(mockSetValue).toHaveBeenCalledWith( + expect.stringContaining(path.join('untrusted', 'path')), + 'TRUST_FOLDER', + ); + expect(debugLogSpy).toHaveBeenCalledWith( + 'Extension "local-extension" installed successfully and enabled.', + ); + }); + + it('should prompt and abort if user denies trust', async () => { + mockStat.mockResolvedValue({} as Stats); + mockIsWorkspaceTrusted.mockReturnValue({ + isTrusted: undefined, + source: undefined, + }); + mockPromptForConsentNonInteractive.mockResolvedValue(false); + + await handleInstall({ + source: path.join('/', 'evil', 'path'), + }); + + expect(mockIsWorkspaceTrusted).toHaveBeenCalled(); + expect(mockPromptForConsentNonInteractive).toHaveBeenCalled(); + expect(debugErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Installation aborted: Folder'), + ); + expect(processSpy).toHaveBeenCalledWith(1); + }); + + it('should include discovery results in trust prompt', async () => { + mockInstallOrUpdateExtension.mockResolvedValue( + createMockExtension({ + name: 'local-extension', + }), + ); + mockStat.mockResolvedValue({} as Stats); + mockIsWorkspaceTrusted.mockReturnValue({ + isTrusted: undefined, + source: undefined, + }); + mockDiscover.mockResolvedValue({ + commands: ['custom-cmd'], + mcps: [], + hooks: [], + skills: ['cool-skill'], + agents: ['cool-agent'], + settings: [], + securityWarnings: ['Security risk!'], + discoveryErrors: ['Read error'], + }); + mockPromptForConsentNonInteractive.mockResolvedValue(true); + mockLoadTrustedFolders.mockReturnValue({ + setValue: vi.fn(), + user: { path: '', config: {} }, + errors: [], + rules: [], + isPathTrusted: vi.fn(), + }); + + await handleInstall({ + source: '/untrusted/path', + }); + + expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith( + expect.stringContaining('This folder contains:'), + false, + ); + expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith( + expect.stringContaining('custom-cmd'), + false, + ); + expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith( + expect.stringContaining('cool-skill'), + false, + ); + expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith( + expect.stringContaining('cool-agent'), + false, + ); + expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith( + expect.stringContaining('Security Warnings:'), + false, + ); + expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith( + expect.stringContaining('Security risk!'), + false, + ); + expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith( + expect.stringContaining('Discovery Errors:'), + false, + ); + expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith( + expect.stringContaining('Read error'), + false, + ); + }); +}); +// Implementation completed. diff --git a/packages/cli/src/commands/extensions/install.ts b/packages/cli/src/commands/extensions/install.ts new file mode 100644 index 0000000000000000000000000000000000000000..cf135a936685c985f94ab14ac86f721df9205b8e --- /dev/null +++ b/packages/cli/src/commands/extensions/install.ts @@ -0,0 +1,228 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import * as path from 'node:path'; +import chalk from 'chalk'; +import { + debugLogger, + FolderTrustDiscoveryService, + getRealPath, + getErrorMessage, +} from '@google/gemini-cli-core'; +import { + INSTALL_WARNING_MESSAGE, + promptForConsentNonInteractive, + requestConsentNonInteractive, +} from '../../config/extensions/consent.js'; +import { + ExtensionManager, + inferInstallMetadata, +} from '../../config/extension-manager.js'; +import { loadSettings } from '../../config/settings.js'; +import { + isWorkspaceTrusted, + loadTrustedFolders, + TrustLevel, +} from '../../config/trustedFolders.js'; +import { promptForSetting } from '../../config/extensions/extensionSettings.js'; +import { exitCli } from '../utils.js'; + +interface InstallArgs { + source: string; + ref?: string; + autoUpdate?: boolean; + allowPreRelease?: boolean; + consent?: boolean; + skipSettings?: boolean; +} + +export async function handleInstall(args: InstallArgs) { + try { + const { source } = args; + const installMetadata = await inferInstallMetadata(source, { + ref: args.ref, + autoUpdate: args.autoUpdate, + allowPreRelease: args.allowPreRelease, + }); + + const workspaceDir = process.cwd(); + const settings = loadSettings(workspaceDir).merged; + + if (installMetadata.type === 'local' || installMetadata.type === 'link') { + const absolutePath = path.resolve(source); + const realPath = getRealPath(absolutePath); + installMetadata.source = absolutePath; + const trustResult = isWorkspaceTrusted(settings, absolutePath); + if (trustResult.isTrusted !== true) { + const discoveryResults = + await FolderTrustDiscoveryService.discover(realPath); + + const hasDiscovery = + discoveryResults.commands.length > 0 || + discoveryResults.mcps.length > 0 || + discoveryResults.hooks.length > 0 || + discoveryResults.skills.length > 0 || + discoveryResults.settings.length > 0; + + const promptLines = [ + '', + chalk.bold('Do you trust the files in this folder?'), + '', + `The extension source at "${absolutePath}" is not trusted.`, + '', + 'Trusting a folder allows Gemini CLI to load its local configurations,', + 'including custom commands, hooks, MCP servers, agent skills, and', + 'settings. These configurations could execute code on your behalf or', + 'change the behavior of the CLI.', + '', + ]; + + if (discoveryResults.discoveryErrors.length > 0) { + promptLines.push(chalk.red('❌ Discovery Errors:')); + for (const error of discoveryResults.discoveryErrors) { + promptLines.push(chalk.red(` • ${error}`)); + } + promptLines.push(''); + } + + if (discoveryResults.securityWarnings.length > 0) { + promptLines.push(chalk.yellow('⚠️ Security Warnings:')); + for (const warning of discoveryResults.securityWarnings) { + promptLines.push(chalk.yellow(` • ${warning}`)); + } + promptLines.push(''); + } + + if (hasDiscovery) { + promptLines.push(chalk.bold('This folder contains:')); + const groups = [ + { label: 'Commands', items: discoveryResults.commands ?? [] }, + { label: 'MCP Servers', items: discoveryResults.mcps ?? [] }, + { label: 'Hooks', items: discoveryResults.hooks ?? [] }, + { label: 'Skills', items: discoveryResults.skills ?? [] }, + { label: 'Agents', items: discoveryResults.agents ?? [] }, + { + label: 'Setting overrides', + items: discoveryResults.settings ?? [], + }, + ].filter((g) => g.items.length > 0); + + for (const group of groups) { + promptLines.push( + ` • ${chalk.bold(group.label)} (${group.items.length}):`, + ); + for (const item of group.items) { + promptLines.push(` - ${item}`); + } + } + promptLines.push(''); + } + + promptLines.push( + chalk.yellow( + 'Do you want to trust this folder and continue with the installation? [y/N]: ', + ), + ); + + const confirmed = await promptForConsentNonInteractive( + promptLines.join('\n'), + false, + ); + if (confirmed) { + const trustedFolders = loadTrustedFolders(); + await trustedFolders.setValue(realPath, TrustLevel.TRUST_FOLDER); + } else { + throw new Error( + `Installation aborted: Folder "${absolutePath}" is not trusted.`, + ); + } + } + } + + const requestConsent = args.consent + ? () => Promise.resolve(true) + : requestConsentNonInteractive; + if (args.consent) { + debugLogger.log('You have consented to the following:'); + debugLogger.log(INSTALL_WARNING_MESSAGE); + } + + const extensionManager = new ExtensionManager({ + workspaceDir, + requestConsent, + requestSetting: args.skipSettings ? null : promptForSetting, + settings, + }); + await extensionManager.loadExtensions(); + const extension = + await extensionManager.installOrUpdateExtension(installMetadata); + debugLogger.log( + `Extension "${extension.name}" installed successfully and enabled.`, + ); + } catch (error) { + debugLogger.error(getErrorMessage(error)); + process.exit(1); + } +} + +export const installCommand: CommandModule = { + command: 'install [--auto-update] [--pre-release]', + describe: 'Installs an extension from a git repository URL or a local path.', + builder: (yargs) => + yargs + .positional('source', { + describe: 'The github URL or local path of the extension to install.', + type: 'string', + demandOption: true, + }) + .option('ref', { + describe: 'The git ref to install from.', + type: 'string', + }) + .option('auto-update', { + describe: 'Enable auto-update for this extension.', + type: 'boolean', + }) + .option('pre-release', { + describe: 'Enable pre-release versions for this extension.', + type: 'boolean', + }) + .option('consent', { + describe: + 'Acknowledge the security risks of installing an extension and skip the confirmation prompt.', + type: 'boolean', + default: false, + }) + .option('skip-settings', { + describe: 'Skip the configuration on install process.', + type: 'boolean', + default: false, + }) + .check((argv) => { + if (!argv.source) { + throw new Error('The source argument must be provided.'); + } + return true; + }), + handler: async (argv) => { + await handleInstall({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + source: argv['source'] as string, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + ref: argv['ref'] as string | undefined, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + autoUpdate: argv['auto-update'] as boolean | undefined, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + allowPreRelease: argv['pre-release'] as boolean | undefined, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + consent: argv['consent'] as boolean | undefined, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + skipSettings: argv['skip-settings'] as boolean | undefined, + }); + await exitCli(); + }, +}; diff --git a/packages/cli/src/commands/extensions/link.test.ts b/packages/cli/src/commands/extensions/link.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d54b81e0838c7d14693a1dfffe781e3da3510521 --- /dev/null +++ b/packages/cli/src/commands/extensions/link.test.ts @@ -0,0 +1,181 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + vi, + describe, + it, + expect, + beforeEach, + afterEach, + type Mock, +} from 'vitest'; +import { coreEvents, getErrorMessage } from '@google/gemini-cli-core'; +import { type Argv } from 'yargs'; +import { handleLink, linkCommand } from './link.js'; +import { ExtensionManager } from '../../config/extension-manager.js'; +import { loadSettings, type LoadedSettings } from '../../config/settings.js'; + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const { mockCoreDebugLogger } = await import( + '../../test-utils/mockDebugLogger.js' + ); + const actual = + await importOriginal(); + const mocked = mockCoreDebugLogger(actual, { stripAnsi: true }); + return { ...mocked, getErrorMessage: vi.fn() }; +}); + +vi.mock('../../config/extension-manager.js'); +vi.mock('../../config/settings.js'); +vi.mock('../../config/extensions/consent.js', () => ({ + requestConsentNonInteractive: vi.fn(), +})); +vi.mock('../../config/extensions/extensionSettings.js', () => ({ + promptForSetting: vi.fn(), +})); +vi.mock('../utils.js', () => ({ + exitCli: vi.fn(), +})); + +describe('extensions link command', () => { + const mockLoadSettings = vi.mocked(loadSettings); + const mockGetErrorMessage = vi.mocked(getErrorMessage); + const mockExtensionManager = vi.mocked(ExtensionManager); + + beforeEach(async () => { + vi.clearAllMocks(); + mockLoadSettings.mockReturnValue({ + merged: {}, + } as unknown as LoadedSettings); + mockExtensionManager.prototype.loadExtensions = vi + .fn() + .mockResolvedValue(undefined); + mockExtensionManager.prototype.installOrUpdateExtension = vi + .fn() + .mockResolvedValue({ name: 'my-linked-extension' }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('handleLink', () => { + it('should link an extension from a local path', async () => { + const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); + await handleLink({ path: '/local/path/to/extension' }); + + expect(mockExtensionManager).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceDir: '/test/dir', + }), + ); + expect(mockExtensionManager.prototype.loadExtensions).toHaveBeenCalled(); + expect( + mockExtensionManager.prototype.installOrUpdateExtension, + ).toHaveBeenCalledWith({ + source: '/local/path/to/extension', + type: 'link', + }); + expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith( + 'log', + 'Extension "my-linked-extension" linked successfully and enabled.', + ); + mockCwd.mockRestore(); + }); + + it('should log an error message and exit with code 1 when linking fails', async () => { + const mockProcessExit = vi + .spyOn(process, 'exit') + .mockImplementation((() => {}) as ( + code?: string | number | null | undefined, + ) => never); + const error = new Error('Link failed'); + ( + mockExtensionManager.prototype.installOrUpdateExtension as Mock + ).mockRejectedValue(error); + mockGetErrorMessage.mockReturnValue('Link failed message'); + + await handleLink({ path: '/local/path/to/extension' }); + + expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith( + 'error', + 'Link failed message', + ); + expect(mockProcessExit).toHaveBeenCalledWith(1); + mockProcessExit.mockRestore(); + }); + }); + + describe('linkCommand', () => { + const command = linkCommand; + + it('should have correct command and describe', () => { + expect(command.command).toBe('link '); + expect(command.describe).toBe( + 'Links an extension from a local path. Updates made to the local path will always be reflected.', + ); + }); + + describe('builder', () => { + interface MockYargs { + positional: Mock; + option: Mock; + check: Mock; + } + + let yargsMock: MockYargs; + beforeEach(() => { + yargsMock = { + positional: vi.fn().mockReturnThis(), + option: vi.fn().mockReturnThis(), + check: vi.fn().mockReturnThis(), + }; + }); + + it('should configure positional argument', () => { + (command.builder as (yargs: Argv) => Argv)( + yargsMock as unknown as Argv, + ); + expect(yargsMock.positional).toHaveBeenCalledWith('path', { + describe: 'The name of the extension to link.', + type: 'string', + }); + expect(yargsMock.option).toHaveBeenCalledWith('consent', { + describe: + 'Acknowledge the security risks of installing an extension and skip the confirmation prompt.', + type: 'boolean', + default: false, + }); + expect(yargsMock.check).toHaveBeenCalled(); + }); + }); + + it('handler should call handleLink', async () => { + const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); + interface TestArgv { + path: string; + [key: string]: unknown; + } + const argv: TestArgv = { + path: '/local/path/to/extension', + _: [], + $0: '', + }; + await (command.handler as unknown as (args: TestArgv) => Promise)( + argv, + ); + + expect( + mockExtensionManager.prototype.installOrUpdateExtension, + ).toHaveBeenCalledWith({ + source: '/local/path/to/extension', + type: 'link', + }); + mockCwd.mockRestore(); + }); + }); +}); diff --git a/packages/cli/src/commands/extensions/link.ts b/packages/cli/src/commands/extensions/link.ts new file mode 100644 index 0000000000000000000000000000000000000000..0f419c5cad4dcbb8a934f4201fe148550db71647 --- /dev/null +++ b/packages/cli/src/commands/extensions/link.ts @@ -0,0 +1,89 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import chalk from 'chalk'; +import { + debugLogger, + getErrorMessage, + type ExtensionInstallMetadata, +} from '@google/gemini-cli-core'; + +import { + INSTALL_WARNING_MESSAGE, + requestConsentNonInteractive, +} from '../../config/extensions/consent.js'; +import { ExtensionManager } from '../../config/extension-manager.js'; +import { loadSettings } from '../../config/settings.js'; +import { promptForSetting } from '../../config/extensions/extensionSettings.js'; +import { exitCli } from '../utils.js'; + +interface InstallArgs { + path: string; + consent?: boolean; +} + +export async function handleLink(args: InstallArgs) { + try { + const installMetadata: ExtensionInstallMetadata = { + source: args.path, + type: 'link', + }; + const requestConsent = args.consent + ? () => Promise.resolve(true) + : requestConsentNonInteractive; + if (args.consent) { + debugLogger.log('You have consented to the following:'); + debugLogger.log(INSTALL_WARNING_MESSAGE); + } + const workspaceDir = process.cwd(); + const extensionManager = new ExtensionManager({ + workspaceDir, + requestConsent, + requestSetting: promptForSetting, + settings: loadSettings(workspaceDir).merged, + }); + await extensionManager.loadExtensions(); + const extension = + await extensionManager.installOrUpdateExtension(installMetadata); + debugLogger.log( + chalk.green( + `Extension "${extension.name}" linked successfully and enabled.`, + ), + ); + } catch (error) { + debugLogger.error(getErrorMessage(error)); + process.exit(1); + } +} + +export const linkCommand: CommandModule = { + command: 'link ', + describe: + 'Links an extension from a local path. Updates made to the local path will always be reflected.', + builder: (yargs) => + yargs + .positional('path', { + describe: 'The name of the extension to link.', + type: 'string', + }) + .option('consent', { + describe: + 'Acknowledge the security risks of installing an extension and skip the confirmation prompt.', + type: 'boolean', + default: false, + }) + .check((_) => true), + handler: async (argv) => { + await handleLink({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + path: argv['path'] as string, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + consent: argv['consent'] as boolean | undefined, + }); + await exitCli(); + }, +}; diff --git a/packages/cli/src/commands/extensions/list.test.ts b/packages/cli/src/commands/extensions/list.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..b65cfdaf3e2f932bb2fbda6d864d3c427be03fbd --- /dev/null +++ b/packages/cli/src/commands/extensions/list.test.ts @@ -0,0 +1,179 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { coreEvents, getErrorMessage } from '@google/gemini-cli-core'; +import { handleList, listCommand } from './list.js'; +import { ExtensionManager } from '../../config/extension-manager.js'; +import { loadSettings, type LoadedSettings } from '../../config/settings.js'; + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const { mockCoreDebugLogger } = await import( + '../../test-utils/mockDebugLogger.js' + ); + const actual = + await importOriginal(); + const mocked = mockCoreDebugLogger(actual, { stripAnsi: false }); + return { ...mocked, getErrorMessage: vi.fn() }; +}); + +vi.mock('../../config/extension-manager.js'); +vi.mock('../../config/settings.js'); +vi.mock('../../config/extensions/consent.js', () => ({ + requestConsentNonInteractive: vi.fn(), +})); +vi.mock('../../config/extensions/extensionSettings.js', () => ({ + promptForSetting: vi.fn(), +})); +vi.mock('../utils.js', () => ({ + exitCli: vi.fn(), +})); + +describe('extensions list command', () => { + const mockLoadSettings = vi.mocked(loadSettings); + const mockGetErrorMessage = vi.mocked(getErrorMessage); + const mockExtensionManager = vi.mocked(ExtensionManager); + + beforeEach(async () => { + vi.clearAllMocks(); + mockLoadSettings.mockReturnValue({ + merged: {}, + } as unknown as LoadedSettings); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('handleList', () => { + it('should log a message if no extensions are installed', async () => { + const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); + mockExtensionManager.prototype.loadExtensions = vi + .fn() + .mockResolvedValue([]); + await handleList(); + + expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith( + 'log', + 'No extensions installed.', + ); + mockCwd.mockRestore(); + }); + + it('should output empty JSON array if no extensions are installed and output-format is json', async () => { + const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); + mockExtensionManager.prototype.loadExtensions = vi + .fn() + .mockResolvedValue([]); + await handleList({ outputFormat: 'json' }); + + expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith('log', '[]'); + mockCwd.mockRestore(); + }); + + it('should list all installed extensions', async () => { + const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); + const extensions = [ + { name: 'ext1', version: '1.0.0' }, + { name: 'ext2', version: '2.0.0' }, + ]; + mockExtensionManager.prototype.loadExtensions = vi + .fn() + .mockResolvedValue(extensions); + mockExtensionManager.prototype.toOutputString = vi.fn( + (ext) => `${ext.name}@${ext.version}`, + ); + await handleList(); + + expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith( + 'log', + 'ext1@1.0.0\n\next2@2.0.0', + ); + mockCwd.mockRestore(); + }); + + it('should list all installed extensions in JSON format', async () => { + const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); + const extensions = [ + { name: 'ext1', version: '1.0.0' }, + { name: 'ext2', version: '2.0.0' }, + ]; + mockExtensionManager.prototype.loadExtensions = vi + .fn() + .mockResolvedValue(extensions); + await handleList({ outputFormat: 'json' }); + + expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith( + 'log', + JSON.stringify(extensions, null, 2), + ); + mockCwd.mockRestore(); + }); + + it('should log an error message and exit with code 1 when listing fails', async () => { + const mockProcessExit = vi + .spyOn(process, 'exit') + .mockImplementation((() => {}) as ( + code?: string | number | null | undefined, + ) => never); + const error = new Error('List failed'); + mockExtensionManager.prototype.loadExtensions = vi + .fn() + .mockRejectedValue(error); + mockGetErrorMessage.mockReturnValue('List failed message'); + + await handleList(); + + expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith( + 'error', + 'List failed message', + ); + expect(mockProcessExit).toHaveBeenCalledWith(1); + mockProcessExit.mockRestore(); + }); + }); + + describe('listCommand', () => { + const command = listCommand; + + it('should have correct command and describe', () => { + expect(command.command).toBe('list'); + expect(command.describe).toBe('Lists installed extensions.'); + }); + + it('builder should have output-format option', () => { + const mockYargs = { + option: vi.fn().mockReturnThis(), + }; + ( + command.builder as unknown as ( + yargs: typeof mockYargs, + ) => typeof mockYargs + )(mockYargs); + expect(mockYargs.option).toHaveBeenCalledWith('output-format', { + alias: 'o', + type: 'string', + describe: 'The format of the CLI output.', + choices: ['text', 'json'], + default: 'text', + }); + }); + + it('handler should call handleList with parsed arguments', async () => { + mockExtensionManager.prototype.loadExtensions = vi + .fn() + .mockResolvedValue([]); + await ( + command.handler as unknown as (args: { + 'output-format': string; + }) => Promise + )({ + 'output-format': 'json', + }); + expect(mockExtensionManager.prototype.loadExtensions).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/cli/src/commands/extensions/list.ts b/packages/cli/src/commands/extensions/list.ts new file mode 100644 index 0000000000000000000000000000000000000000..e477ce3c2140a4d313bdfc7a0b7ea69d9964fe2f --- /dev/null +++ b/packages/cli/src/commands/extensions/list.ts @@ -0,0 +1,69 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import { debugLogger, getErrorMessage } from '@google/gemini-cli-core'; +import { ExtensionManager } from '../../config/extension-manager.js'; +import { requestConsentNonInteractive } from '../../config/extensions/consent.js'; +import { loadSettings } from '../../config/settings.js'; +import { promptForSetting } from '../../config/extensions/extensionSettings.js'; +import { exitCli } from '../utils.js'; + +export async function handleList(options?: { outputFormat?: 'text' | 'json' }) { + try { + const workspaceDir = process.cwd(); + const extensionManager = new ExtensionManager({ + workspaceDir, + requestConsent: requestConsentNonInteractive, + requestSetting: promptForSetting, + settings: loadSettings(workspaceDir).merged, + }); + const extensions = await extensionManager.loadExtensions(); + if (extensions.length === 0) { + if (options?.outputFormat === 'json') { + debugLogger.log('[]'); + } else { + debugLogger.log('No extensions installed.'); + } + return; + } + + if (options?.outputFormat === 'json') { + debugLogger.log(JSON.stringify(extensions, null, 2)); + } else { + debugLogger.log( + extensions + .map((extension, _): string => + extensionManager.toOutputString(extension), + ) + .join('\n\n'), + ); + } + } catch (error) { + debugLogger.error(getErrorMessage(error)); + process.exit(1); + } +} + +export const listCommand: CommandModule = { + command: 'list', + describe: 'Lists installed extensions.', + builder: (yargs) => + yargs.option('output-format', { + alias: 'o', + type: 'string', + describe: 'The format of the CLI output.', + choices: ['text', 'json'], + default: 'text', + }), + handler: async (argv) => { + await handleList({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + outputFormat: argv['output-format'] as 'text' | 'json', + }); + await exitCli(); + }, +}; diff --git a/packages/cli/src/commands/extensions/new.test.ts b/packages/cli/src/commands/extensions/new.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..2400ac61a787e744698503bf189c56bfee75c9f9 --- /dev/null +++ b/packages/cli/src/commands/extensions/new.test.ts @@ -0,0 +1,94 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { newCommand } from './new.js'; +import yargs from 'yargs'; +import * as fsPromises from 'node:fs/promises'; +import path from 'node:path'; + +vi.mock('node:fs/promises'); +vi.mock('../utils.js', () => ({ + exitCli: vi.fn(), +})); + +const mockedFs = vi.mocked(fsPromises); + +describe('extensions new command', () => { + beforeEach(() => { + vi.resetAllMocks(); + + const fakeFiles = [ + { name: 'context', isDirectory: () => true }, + { name: 'custom-commands', isDirectory: () => true }, + { name: 'mcp-server', isDirectory: () => true }, + ]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockedFs.readdir.mockResolvedValue(fakeFiles as any); + }); + + it('should fail if no path is provided', async () => { + const parser = yargs([]).command(newCommand).fail(false).locale('en'); + await expect(parser.parseAsync('new')).rejects.toThrow( + 'Not enough non-option arguments: got 0, need at least 1', + ); + }); + + it('should create directory when no template is provided', async () => { + mockedFs.access.mockRejectedValue(new Error('ENOENT')); + mockedFs.mkdir.mockResolvedValue(undefined); + + const parser = yargs([]).command(newCommand).fail(false); + + await parser.parseAsync('new /some/path'); + + expect(mockedFs.mkdir).toHaveBeenCalledWith('/some/path', { + recursive: true, + }); + expect(mockedFs.cp).not.toHaveBeenCalled(); + }); + + it('should create directory and copy files when path does not exist', async () => { + mockedFs.access.mockRejectedValue(new Error('ENOENT')); + mockedFs.mkdir.mockResolvedValue(undefined); + mockedFs.cp.mockResolvedValue(undefined); + + const parser = yargs([]).command(newCommand).fail(false); + + await parser.parseAsync('new /some/path context'); + + expect(mockedFs.mkdir).toHaveBeenCalledWith('/some/path', { + recursive: true, + }); + expect(mockedFs.cp).toHaveBeenCalledWith( + expect.stringContaining(path.normalize('context/context')), + path.normalize('/some/path/context'), + { recursive: true }, + ); + expect(mockedFs.cp).toHaveBeenCalledWith( + expect.stringContaining(path.normalize('context/custom-commands')), + path.normalize('/some/path/custom-commands'), + { recursive: true }, + ); + expect(mockedFs.cp).toHaveBeenCalledWith( + expect.stringContaining(path.normalize('context/mcp-server')), + path.normalize('/some/path/mcp-server'), + { recursive: true }, + ); + }); + + it('should throw an error if the path already exists', async () => { + mockedFs.access.mockResolvedValue(undefined); + const parser = yargs([]).command(newCommand).fail(false); + + await expect(parser.parseAsync('new /some/path context')).rejects.toThrow( + 'Path already exists: /some/path', + ); + + expect(mockedFs.mkdir).not.toHaveBeenCalled(); + expect(mockedFs.cp).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/commands/extensions/new.ts b/packages/cli/src/commands/extensions/new.ts new file mode 100644 index 0000000000000000000000000000000000000000..2ff97834c394098ac9db6546e9029743ebd515f5 --- /dev/null +++ b/packages/cli/src/commands/extensions/new.ts @@ -0,0 +1,108 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { access, cp, mkdir, readdir, writeFile } from 'node:fs/promises'; +import { join, dirname, basename } from 'node:path'; +import type { CommandModule } from 'yargs'; +import { fileURLToPath } from 'node:url'; +import { debugLogger } from '@google/gemini-cli-core'; +import { exitCli } from '../utils.js'; + +interface NewArgs { + path: string; + template?: string; +} + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const EXAMPLES_PATH = join(__dirname, 'examples'); + +async function pathExists(path: string) { + try { + await access(path); + return true; + } catch { + return false; + } +} + +async function createDirectory(path: string) { + if (await pathExists(path)) { + throw new Error(`Path already exists: ${path}`); + } + await mkdir(path, { recursive: true }); +} + +async function copyDirectory(template: string, path: string) { + await createDirectory(path); + + const examplePath = join(EXAMPLES_PATH, template); + const entries = await readdir(examplePath, { withFileTypes: true }); + for (const entry of entries) { + const srcPath = join(examplePath, entry.name); + const destPath = join(path, entry.name); + await cp(srcPath, destPath, { recursive: true }); + } +} + +async function handleNew(args: NewArgs) { + if (args.template) { + await copyDirectory(args.template, args.path); + debugLogger.log( + `Successfully created new extension from template "${args.template}" at ${args.path}.`, + ); + } else { + await createDirectory(args.path); + const extensionName = basename(args.path); + const manifest = { + name: extensionName, + version: '1.0.0', + }; + await writeFile( + join(args.path, 'gemini-extension.json'), + JSON.stringify(manifest, null, 2), + ); + debugLogger.log(`Successfully created new extension at ${args.path}.`); + } + debugLogger.log( + `You can install this using "gemini extensions link ${args.path}" to test it out.`, + ); +} + +async function getBoilerplateChoices() { + const entries = await readdir(EXAMPLES_PATH, { withFileTypes: true }); + return entries + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name); +} + +export const newCommand: CommandModule = { + command: 'new [template]', + describe: 'Create a new extension from a boilerplate example.', + builder: async (yargs) => { + const choices = await getBoilerplateChoices(); + return yargs + .positional('path', { + describe: 'The path to create the extension in.', + type: 'string', + }) + .positional('template', { + describe: 'The boilerplate template to use.', + type: 'string', + choices, + }); + }, + handler: async (args) => { + await handleNew({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + path: args['path'] as string, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + template: args['template'] as string | undefined, + }); + await exitCli(); + }, +}; diff --git a/packages/cli/src/commands/extensions/uninstall.test.ts b/packages/cli/src/commands/extensions/uninstall.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..341c0f7a7e2a92f56fab76f2a8800337ec12bd48 --- /dev/null +++ b/packages/cli/src/commands/extensions/uninstall.test.ts @@ -0,0 +1,361 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + vi, + describe, + it, + expect, + beforeEach, + afterEach, + type Mock, +} from 'vitest'; +import { format } from 'node:util'; +import { type Argv } from 'yargs'; +import { handleUninstall, uninstallCommand } from './uninstall.js'; +import { ExtensionManager } from '../../config/extension-manager.js'; +import { loadSettings, type LoadedSettings } from '../../config/settings.js'; +import { getErrorMessage } from '@google/gemini-cli-core'; + +// NOTE: This file uses vi.hoisted() mocks to enable testing of sequential +// mock behaviors (mockResolvedValueOnce/mockRejectedValueOnce chaining). +// The hoisted mocks persist across vi.clearAllMocks() calls, which is necessary +// for testing partial failure scenarios in the multiple extension uninstall feature. + +// Hoisted mocks - these survive vi.clearAllMocks() +const mockUninstallExtension = vi.hoisted(() => vi.fn()); +const mockLoadExtensions = vi.hoisted(() => vi.fn()); +const mockGetExtensions = vi.hoisted(() => vi.fn()); + +// Mock dependencies with hoisted functions +vi.mock('../../config/extension-manager.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + ExtensionManager: vi.fn().mockImplementation(() => ({ + uninstallExtension: mockUninstallExtension, + loadExtensions: mockLoadExtensions, + getExtensions: mockGetExtensions, + setRequestConsent: vi.fn(), + setRequestSetting: vi.fn(), + })), + }; +}); + +// Mock dependencies +const emitConsoleLog = vi.hoisted(() => vi.fn()); +const debugLogger = vi.hoisted(() => ({ + log: vi.fn((message, ...args) => { + emitConsoleLog('log', format(message, ...args)); + }), + error: vi.fn((message, ...args) => { + emitConsoleLog('error', format(message, ...args)); + }), +})); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + coreEvents: { + emitConsoleLog, + }, + debugLogger, + getErrorMessage: vi.fn(), + }; +}); + +vi.mock('../../config/settings.js'); +vi.mock('../../config/extensions/consent.js', () => ({ + requestConsentNonInteractive: vi.fn(), +})); +vi.mock('../../config/extensions/extensionSettings.js', () => ({ + promptForSetting: vi.fn(), +})); +vi.mock('../utils.js', () => ({ + exitCli: vi.fn(), +})); + +describe('extensions uninstall command', () => { + const mockLoadSettings = vi.mocked(loadSettings); + const mockGetErrorMessage = vi.mocked(getErrorMessage); + const mockExtensionManager = vi.mocked(ExtensionManager); + + beforeEach(async () => { + mockLoadSettings.mockReturnValue({ + merged: {}, + } as unknown as LoadedSettings); + }); + + afterEach(() => { + mockLoadExtensions.mockClear(); + mockUninstallExtension.mockClear(); + mockGetExtensions.mockClear(); + vi.clearAllMocks(); + }); + + describe('handleUninstall', () => { + it('should uninstall a single extension', async () => { + mockLoadExtensions.mockResolvedValue(undefined); + mockUninstallExtension.mockResolvedValue(undefined); + const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); + await handleUninstall({ names: ['my-extension'] }); + + expect(mockExtensionManager).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceDir: '/test/dir', + }), + ); + expect(mockLoadExtensions).toHaveBeenCalled(); + expect(mockUninstallExtension).toHaveBeenCalledWith( + 'my-extension', + false, + ); + expect(emitConsoleLog).toHaveBeenCalledWith( + 'log', + 'Extension "my-extension" successfully uninstalled.', + ); + mockCwd.mockRestore(); + }); + + it('should uninstall multiple extensions', async () => { + mockLoadExtensions.mockResolvedValue(undefined); + mockUninstallExtension.mockResolvedValue(undefined); + const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); + await handleUninstall({ names: ['ext1', 'ext2', 'ext3'] }); + + expect(mockUninstallExtension).toHaveBeenCalledTimes(3); + expect(mockUninstallExtension).toHaveBeenCalledWith('ext1', false); + expect(mockUninstallExtension).toHaveBeenCalledWith('ext2', false); + expect(mockUninstallExtension).toHaveBeenCalledWith('ext3', false); + expect(emitConsoleLog).toHaveBeenCalledWith( + 'log', + 'Extension "ext1" successfully uninstalled.', + ); + expect(emitConsoleLog).toHaveBeenCalledWith( + 'log', + 'Extension "ext2" successfully uninstalled.', + ); + expect(emitConsoleLog).toHaveBeenCalledWith( + 'log', + 'Extension "ext3" successfully uninstalled.', + ); + mockCwd.mockRestore(); + }); + + it('should uninstall all extensions when --all flag is used', async () => { + mockLoadExtensions.mockResolvedValue(undefined); + mockUninstallExtension.mockResolvedValue(undefined); + mockGetExtensions.mockReturnValue([{ name: 'ext1' }, { name: 'ext2' }]); + const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); + await handleUninstall({ all: true }); + + expect(mockUninstallExtension).toHaveBeenCalledTimes(2); + expect(mockUninstallExtension).toHaveBeenCalledWith('ext1', false); + expect(mockUninstallExtension).toHaveBeenCalledWith('ext2', false); + expect(emitConsoleLog).toHaveBeenCalledWith( + 'log', + 'Extension "ext1" successfully uninstalled.', + ); + expect(emitConsoleLog).toHaveBeenCalledWith( + 'log', + 'Extension "ext2" successfully uninstalled.', + ); + mockCwd.mockRestore(); + }); + + it('should log a message if no extensions are installed and --all flag is used', async () => { + mockLoadExtensions.mockResolvedValue(undefined); + mockGetExtensions.mockReturnValue([]); + const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); + await handleUninstall({ all: true }); + + expect(mockUninstallExtension).not.toHaveBeenCalled(); + expect(emitConsoleLog).toHaveBeenCalledWith( + 'log', + 'No extensions currently installed.', + ); + mockCwd.mockRestore(); + }); + + it('should report errors for failed uninstalls but continue with others', async () => { + mockLoadExtensions.mockResolvedValue(undefined); + const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); + const mockProcessExit = vi + .spyOn(process, 'exit') + .mockImplementation((() => {}) as ( + code?: string | number | null | undefined, + ) => never); + + const error = new Error('Extension not found'); + // Chain sequential mock behaviors - this works with hoisted mocks + mockUninstallExtension + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(error) + .mockResolvedValueOnce(undefined); + mockGetErrorMessage.mockReturnValue('Extension not found'); + + await handleUninstall({ names: ['ext1', 'ext2', 'ext3'] }); + + expect(mockUninstallExtension).toHaveBeenCalledTimes(3); + expect(emitConsoleLog).toHaveBeenCalledWith( + 'log', + 'Extension "ext1" successfully uninstalled.', + ); + expect(emitConsoleLog).toHaveBeenCalledWith( + 'error', + 'Failed to uninstall "ext2": Extension not found', + ); + expect(emitConsoleLog).toHaveBeenCalledWith( + 'log', + 'Extension "ext3" successfully uninstalled.', + ); + expect(mockProcessExit).toHaveBeenCalledWith(1); + mockProcessExit.mockRestore(); + mockCwd.mockRestore(); + }); + + it('should exit with error code if all uninstalls fail', async () => { + mockLoadExtensions.mockResolvedValue(undefined); + const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); + const mockProcessExit = vi + .spyOn(process, 'exit') + .mockImplementation((() => {}) as ( + code?: string | number | null | undefined, + ) => never); + const error = new Error('Extension not found'); + mockUninstallExtension.mockRejectedValue(error); + mockGetErrorMessage.mockReturnValue('Extension not found'); + + await handleUninstall({ names: ['ext1', 'ext2'] }); + + expect(emitConsoleLog).toHaveBeenCalledWith( + 'error', + 'Failed to uninstall "ext1": Extension not found', + ); + expect(emitConsoleLog).toHaveBeenCalledWith( + 'error', + 'Failed to uninstall "ext2": Extension not found', + ); + expect(mockProcessExit).toHaveBeenCalledWith(1); + mockProcessExit.mockRestore(); + mockCwd.mockRestore(); + }); + + it('should log an error message and exit with code 1 when initialization fails', async () => { + const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); + const mockProcessExit = vi + .spyOn(process, 'exit') + .mockImplementation((() => {}) as ( + code?: string | number | null | undefined, + ) => never); + const error = new Error('Initialization failed'); + mockLoadExtensions.mockRejectedValue(error); + mockGetErrorMessage.mockReturnValue('Initialization failed message'); + + await handleUninstall({ names: ['my-extension'] }); + + expect(emitConsoleLog).toHaveBeenCalledWith( + 'error', + 'Initialization failed message', + ); + expect(mockProcessExit).toHaveBeenCalledWith(1); + mockProcessExit.mockRestore(); + mockCwd.mockRestore(); + }); + }); + + describe('uninstallCommand', () => { + const command = uninstallCommand; + + it('should have correct command and describe', () => { + expect(command.command).toBe('uninstall [names..]'); + expect(command.describe).toBe('Uninstalls one or more extensions.'); + }); + + describe('builder', () => { + interface MockYargs { + positional: Mock; + option: Mock; + check: Mock; + } + + let yargsMock: MockYargs; + beforeEach(() => { + yargsMock = { + positional: vi.fn().mockReturnThis(), + option: vi.fn().mockReturnThis(), + check: vi.fn().mockReturnThis(), + }; + }); + + it('should configure arguments and options', () => { + (command.builder as (yargs: Argv) => Argv)( + yargsMock as unknown as Argv, + ); + expect(yargsMock.positional).toHaveBeenCalledWith('names', { + describe: + 'The name(s) or source path(s) of the extension(s) to uninstall.', + type: 'string', + array: true, + }); + expect(yargsMock.option).toHaveBeenCalledWith('all', { + type: 'boolean', + describe: 'Uninstall all installed extensions.', + default: false, + }); + expect(yargsMock.check).toHaveBeenCalled(); + }); + + it('check function should throw for missing names and no --all flag', () => { + (command.builder as (yargs: Argv) => Argv)( + yargsMock as unknown as Argv, + ); + const checkCallback = yargsMock.check.mock.calls[0][0]; + expect(() => checkCallback({ names: [], all: false })).toThrow( + 'Please include at least one extension name to uninstall as a positional argument, or use the --all flag.', + ); + }); + + it('check function should pass if --all flag is used even without names', () => { + (command.builder as (yargs: Argv) => Argv)( + yargsMock as unknown as Argv, + ); + const checkCallback = yargsMock.check.mock.calls[0][0]; + expect(() => checkCallback({ names: [], all: true })).not.toThrow(); + }); + }); + + it('handler should call handleUninstall', async () => { + mockLoadExtensions.mockResolvedValue(undefined); + mockUninstallExtension.mockResolvedValue(undefined); + const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); + interface TestArgv { + names?: string[]; + all?: boolean; + _: string[]; + $0: string; + } + const argv: TestArgv = { + names: ['my-extension'], + all: false, + _: [], + $0: '', + }; + await (command.handler as unknown as (args: TestArgv) => Promise)( + argv, + ); + + expect(mockUninstallExtension).toHaveBeenCalledWith( + 'my-extension', + false, + ); + mockCwd.mockRestore(); + }); + }); +}); diff --git a/packages/cli/src/commands/extensions/uninstall.ts b/packages/cli/src/commands/extensions/uninstall.ts new file mode 100644 index 0000000000000000000000000000000000000000..3a63602149da76095690ebbe95e03109ff722b95 --- /dev/null +++ b/packages/cli/src/commands/extensions/uninstall.ts @@ -0,0 +1,102 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import { debugLogger, getErrorMessage } from '@google/gemini-cli-core'; +import { requestConsentNonInteractive } from '../../config/extensions/consent.js'; +import { ExtensionManager } from '../../config/extension-manager.js'; +import { loadSettings } from '../../config/settings.js'; +import { promptForSetting } from '../../config/extensions/extensionSettings.js'; +import { exitCli } from '../utils.js'; + +interface UninstallArgs { + names?: string[]; // can be extension names or source URLs. + all?: boolean; +} + +export async function handleUninstall(args: UninstallArgs) { + try { + const workspaceDir = process.cwd(); + const extensionManager = new ExtensionManager({ + workspaceDir, + requestConsent: requestConsentNonInteractive, + requestSetting: promptForSetting, + settings: loadSettings(workspaceDir).merged, + }); + await extensionManager.loadExtensions(); + + let namesToUninstall: string[] = []; + if (args.all) { + namesToUninstall = extensionManager + .getExtensions() + .map((ext) => ext.name); + } else if (args.names) { + namesToUninstall = [...new Set(args.names)]; + } + + if (namesToUninstall.length === 0) { + if (args.all) { + debugLogger.log('No extensions currently installed.'); + } + return; + } + + const errors: Array<{ name: string; error: string }> = []; + for (const name of namesToUninstall) { + try { + await extensionManager.uninstallExtension(name, false); + debugLogger.log(`Extension "${name}" successfully uninstalled.`); + } catch (error) { + errors.push({ name, error: getErrorMessage(error) }); + } + } + + if (errors.length > 0) { + for (const { name, error } of errors) { + debugLogger.error(`Failed to uninstall "${name}": ${error}`); + } + process.exit(1); + } + } catch (error) { + debugLogger.error(getErrorMessage(error)); + process.exit(1); + } +} + +export const uninstallCommand: CommandModule = { + command: 'uninstall [names..]', + describe: 'Uninstalls one or more extensions.', + builder: (yargs) => + yargs + .positional('names', { + describe: + 'The name(s) or source path(s) of the extension(s) to uninstall.', + type: 'string', + array: true, + }) + .option('all', { + type: 'boolean', + describe: 'Uninstall all installed extensions.', + default: false, + }) + .check((argv) => { + if (!argv.all && (!argv.names || argv.names.length === 0)) { + throw new Error( + 'Please include at least one extension name to uninstall as a positional argument, or use the --all flag.', + ); + } + return true; + }), + handler: async (argv) => { + await handleUninstall({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + names: argv['names'] as string[] | undefined, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + all: argv['all'] as boolean, + }); + await exitCli(); + }, +}; diff --git a/packages/cli/src/commands/extensions/update.test.ts b/packages/cli/src/commands/extensions/update.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..886a7d2b0c11ecda52d0967ce2f355e5e1c51e68 --- /dev/null +++ b/packages/cli/src/commands/extensions/update.test.ts @@ -0,0 +1,271 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + vi, + describe, + it, + expect, + beforeEach, + afterEach, + type Mock, +} from 'vitest'; +import { format } from 'node:util'; +import { type Argv } from 'yargs'; +import { handleUpdate, updateCommand } from './update.js'; +import { ExtensionManager } from '../../config/extension-manager.js'; +import { loadSettings, type LoadedSettings } from '../../config/settings.js'; +import * as update from '../../config/extensions/update.js'; +import * as github from '../../config/extensions/github.js'; +import { ExtensionUpdateState } from '../../ui/state/extensions.js'; + +// Mock dependencies +const emitConsoleLog = vi.hoisted(() => vi.fn()); +const emitFeedback = vi.hoisted(() => vi.fn()); +const debugLogger = vi.hoisted(() => ({ + log: vi.fn((message, ...args) => { + emitConsoleLog('log', format(message, ...args)); + }), + error: vi.fn((message, ...args) => { + emitConsoleLog('error', format(message, ...args)); + }), +})); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + coreEvents: { + emitConsoleLog, + emitFeedback, + }, + debugLogger, + }; +}); + +vi.mock('../../config/extension-manager.js'); +vi.mock('../../config/settings.js'); +vi.mock('../../utils/errors.js'); +vi.mock('../../config/extensions/update.js'); +vi.mock('../../config/extensions/github.js'); +vi.mock('../../config/extensions/consent.js', () => ({ + requestConsentNonInteractive: vi.fn(), +})); +vi.mock('../../config/extensions/extensionSettings.js', () => ({ + promptForSetting: vi.fn(), +})); +vi.mock('../utils.js', () => ({ + exitCli: vi.fn(), +})); + +describe('extensions update command', () => { + const mockLoadSettings = vi.mocked(loadSettings); + const mockExtensionManager = vi.mocked(ExtensionManager); + const mockUpdateExtension = vi.mocked(update.updateExtension); + const mockCheckForExtensionUpdate = vi.mocked(github.checkForExtensionUpdate); + const mockCheckForAllExtensionUpdates = vi.mocked( + update.checkForAllExtensionUpdates, + ); + const mockUpdateAllUpdatableExtensions = vi.mocked( + update.updateAllUpdatableExtensions, + ); + + beforeEach(async () => { + vi.clearAllMocks(); + mockLoadSettings.mockReturnValue({ + merged: { experimental: { extensionReloading: true } }, + } as unknown as LoadedSettings); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('handleUpdate', () => { + it('should list installed extensions when requested extension is not found', async () => { + const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); + const extensions = [ + { name: 'ext1', version: '1.0.0' }, + { name: 'ext2', version: '2.0.0' }, + ]; + mockExtensionManager.prototype.loadExtensions = vi + .fn() + .mockResolvedValue(extensions); + + await handleUpdate({ name: 'missing-extension' }); + + expect(emitFeedback).toHaveBeenCalledWith( + 'error', + 'Extension "missing-extension" not found.\n\nInstalled extensions:\next1 (1.0.0)\next2 (2.0.0)\n\nRun "gemini extensions list" for details.', + ); + expect(mockUpdateExtension).not.toHaveBeenCalled(); + mockCwd.mockRestore(); + }); + + it('should log a helpful message when no extensions are installed and requested extension is not found', async () => { + const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); + mockExtensionManager.prototype.loadExtensions = vi + .fn() + .mockResolvedValue([]); + + await handleUpdate({ name: 'missing-extension' }); + + expect(emitFeedback).toHaveBeenCalledWith( + 'error', + 'Extension "missing-extension" not found.\n\nNo extensions installed.', + ); + expect(mockUpdateExtension).not.toHaveBeenCalled(); + mockCwd.mockRestore(); + }); + + it.each([ + { + state: ExtensionUpdateState.UPDATE_AVAILABLE, + expectedLog: + 'Extension "my-extension" successfully updated: 1.0.0 → 1.1.0.', + shouldCallUpdateExtension: true, + }, + { + state: ExtensionUpdateState.UP_TO_DATE, + expectedLog: 'Extension "my-extension" is already up to date.', + shouldCallUpdateExtension: false, + }, + ])( + 'should handle single extension update state: $state', + async ({ state, expectedLog, shouldCallUpdateExtension }) => { + const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); + const extensions = [{ name: 'my-extension', installMetadata: {} }]; + mockExtensionManager.prototype.loadExtensions = vi + .fn() + .mockResolvedValue(extensions); + mockCheckForExtensionUpdate.mockResolvedValue(state); + mockUpdateExtension.mockResolvedValue({ + name: 'my-extension', + originalVersion: '1.0.0', + updatedVersion: '1.1.0', + }); + + await handleUpdate({ name: 'my-extension' }); + + expect(emitConsoleLog).toHaveBeenCalledWith('log', expectedLog); + if (shouldCallUpdateExtension) { + expect(mockUpdateExtension).toHaveBeenCalled(); + } else { + expect(mockUpdateExtension).not.toHaveBeenCalled(); + } + mockCwd.mockRestore(); + }, + ); + + it.each([ + { + updatedExtensions: [ + { name: 'ext1', originalVersion: '1.0.0', updatedVersion: '1.1.0' }, + { name: 'ext2', originalVersion: '2.0.0', updatedVersion: '2.1.0' }, + ], + expectedLog: + 'Extension "ext1" successfully updated: 1.0.0 → 1.1.0.\nExtension "ext2" successfully updated: 2.0.0 → 2.1.0.', + }, + { + updatedExtensions: [], + expectedLog: 'No extensions to update.', + }, + ])( + 'should handle updating all extensions: %s', + async ({ updatedExtensions, expectedLog }) => { + const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); + mockExtensionManager.prototype.loadExtensions = vi + .fn() + .mockResolvedValue([]); + mockCheckForAllExtensionUpdates.mockResolvedValue(undefined); + mockUpdateAllUpdatableExtensions.mockResolvedValue(updatedExtensions); + + await handleUpdate({ all: true }); + + expect(emitConsoleLog).toHaveBeenCalledWith('log', expectedLog); + mockCwd.mockRestore(); + }, + ); + }); + + describe('updateCommand', () => { + const command = updateCommand; + + it('should have correct command and describe', () => { + expect(command.command).toBe('update [] [--all]'); + expect(command.describe).toBe( + 'Updates all extensions or a named extension to the latest version.', + ); + }); + + describe('builder', () => { + interface MockYargs { + positional: Mock; + option: Mock; + conflicts: Mock; + check: Mock; + } + + let yargsMock: MockYargs; + beforeEach(() => { + yargsMock = { + positional: vi.fn().mockReturnThis(), + option: vi.fn().mockReturnThis(), + conflicts: vi.fn().mockReturnThis(), + check: vi.fn().mockReturnThis(), + }; + }); + + it('should configure arguments', () => { + (command.builder as (yargs: Argv) => Argv)( + yargsMock as unknown as Argv, + ); + expect(yargsMock.positional).toHaveBeenCalledWith( + 'name', + expect.any(Object), + ); + expect(yargsMock.option).toHaveBeenCalledWith( + 'all', + expect.any(Object), + ); + expect(yargsMock.conflicts).toHaveBeenCalledWith('name', 'all'); + expect(yargsMock.check).toHaveBeenCalled(); + }); + + it('check function should throw an error if neither a name nor --all is provided', () => { + (command.builder as (yargs: Argv) => Argv)( + yargsMock as unknown as Argv, + ); + const checkCallback = yargsMock.check.mock.calls[0][0]; + expect(() => checkCallback({ name: undefined, all: false })).toThrow( + 'Either an extension name or --all must be provided', + ); + }); + }); + + it('handler should call handleUpdate', async () => { + const extensions = [{ name: 'my-extension', installMetadata: {} }]; + mockExtensionManager.prototype.loadExtensions = vi + .fn() + .mockResolvedValue(extensions); + mockCheckForExtensionUpdate.mockResolvedValue( + ExtensionUpdateState.UPDATE_AVAILABLE, + ); + mockUpdateExtension.mockResolvedValue({ + name: 'my-extension', + originalVersion: '1.0.0', + updatedVersion: '1.1.0', + }); + + await (command.handler as (args: object) => Promise)({ + name: 'my-extension', + }); + + expect(mockUpdateExtension).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/cli/src/commands/extensions/update.ts b/packages/cli/src/commands/extensions/update.ts new file mode 100644 index 0000000000000000000000000000000000000000..2459b5d7c4c1ee70f032ed28ff0df4a6bb25d2ce --- /dev/null +++ b/packages/cli/src/commands/extensions/update.ts @@ -0,0 +1,168 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import { + updateAllUpdatableExtensions, + type ExtensionUpdateInfo, + checkForAllExtensionUpdates, + updateExtension, +} from '../../config/extensions/update.js'; +import { checkForExtensionUpdate } from '../../config/extensions/github.js'; +import { ExtensionUpdateState } from '../../ui/state/extensions.js'; +import { + coreEvents, + debugLogger, + getErrorMessage, +} from '@google/gemini-cli-core'; +import { ExtensionManager } from '../../config/extension-manager.js'; +import { requestConsentNonInteractive } from '../../config/extensions/consent.js'; +import { loadSettings } from '../../config/settings.js'; +import { promptForSetting } from '../../config/extensions/extensionSettings.js'; +import { exitCli } from '../utils.js'; + +interface UpdateArgs { + name?: string; + all?: boolean; +} + +const updateOutput = (info: ExtensionUpdateInfo) => + `Extension "${info.name}" successfully updated: ${info.originalVersion} → ${info.updatedVersion}.`; + +export async function handleUpdate(args: UpdateArgs) { + const workspaceDir = process.cwd(); + const settings = loadSettings(workspaceDir).merged; + const extensionManager = new ExtensionManager({ + workspaceDir, + requestConsent: requestConsentNonInteractive, + requestSetting: promptForSetting, + settings, + }); + + const extensions = await extensionManager.loadExtensions(); + if (args.name) { + try { + const extension = extensions.find( + (extension) => extension.name === args.name, + ); + if (!extension) { + if (extensions.length === 0) { + coreEvents.emitFeedback( + 'error', + `Extension "${args.name}" not found.\n\nNo extensions installed.`, + ); + return; + } + + const installedExtensions = extensions + .map((extension) => `${extension.name} (${extension.version})`) + .join('\n'); + coreEvents.emitFeedback( + 'error', + `Extension "${args.name}" not found.\n\nInstalled extensions:\n${installedExtensions}\n\nRun "gemini extensions list" for details.`, + ); + return; + } + if (!extension.installMetadata) { + debugLogger.log( + `Unable to install extension "${args.name}" due to missing install metadata`, + ); + return; + } + const updateState = await checkForExtensionUpdate( + extension, + extensionManager, + ); + if (updateState !== ExtensionUpdateState.UPDATE_AVAILABLE) { + debugLogger.log(`Extension "${args.name}" is already up to date.`); + return; + } + const updatedExtensionInfo = (await updateExtension( + extension, + extensionManager, + updateState, + () => {}, + settings.experimental?.extensionReloading, + ))!; + if ( + updatedExtensionInfo.originalVersion !== + updatedExtensionInfo.updatedVersion + ) { + debugLogger.log( + `Extension "${args.name}" successfully updated: ${updatedExtensionInfo.originalVersion} → ${updatedExtensionInfo.updatedVersion}.`, + ); + } else { + debugLogger.log(`Extension "${args.name}" is already up to date.`); + } + } catch (error) { + debugLogger.error(getErrorMessage(error)); + } + } + if (args.all) { + try { + const extensionState = new Map(); + await checkForAllExtensionUpdates( + extensions, + extensionManager, + (action) => { + if (action.type === 'SET_STATE') { + extensionState.set(action.payload.name, { + status: action.payload.state, + }); + } + }, + ); + let updateInfos = await updateAllUpdatableExtensions( + extensions, + extensionState, + extensionManager, + () => {}, + ); + updateInfos = updateInfos.filter( + (info) => info.originalVersion !== info.updatedVersion, + ); + if (updateInfos.length === 0) { + debugLogger.log('No extensions to update.'); + return; + } + debugLogger.log(updateInfos.map((info) => updateOutput(info)).join('\n')); + } catch (error) { + debugLogger.error(getErrorMessage(error)); + } + } +} + +export const updateCommand: CommandModule = { + command: 'update [] [--all]', + describe: + 'Updates all extensions or a named extension to the latest version.', + builder: (yargs) => + yargs + .positional('name', { + describe: 'The name of the extension to update.', + type: 'string', + }) + .option('all', { + describe: 'Update all extensions.', + type: 'boolean', + }) + .conflicts('name', 'all') + .check((argv) => { + if (!argv.all && !argv.name) { + throw new Error('Either an extension name or --all must be provided'); + } + return true; + }), + handler: async (argv) => { + await handleUpdate({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + name: argv['name'] as string | undefined, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + all: argv['all'] as boolean | undefined, + }); + await exitCli(); + }, +}; diff --git a/packages/cli/src/commands/extensions/utils.ts b/packages/cli/src/commands/extensions/utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..87bacda1ae44b310ede1c84c20ed0db10ae5fdba --- /dev/null +++ b/packages/cli/src/commands/extensions/utils.ts @@ -0,0 +1,263 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import { ExtensionManager } from '../../config/extension-manager.js'; +import { loadSettings } from '../../config/settings.js'; +import { requestConsentNonInteractive } from '../../config/extensions/consent.js'; +import { + debugLogger, + type ResolvedExtensionSetting, +} from '@google/gemini-cli-core'; +import type { ExtensionConfig } from '../../config/extension.js'; +import prompts from 'prompts'; +import { + promptForSetting, + updateSetting, + type ExtensionSetting, + getScopedEnvContents, + ExtensionSettingScope, +} from '../../config/extensions/extensionSettings.js'; + +export interface ConfigLogger { + log(message: string): void; + error(message: string): void; +} + +export type RequestSettingCallback = ( + setting: ExtensionSetting, +) => Promise; +export type RequestConfirmationCallback = (message: string) => Promise; + +const defaultLogger: ConfigLogger = { + log: (message: string) => debugLogger.log(message), + error: (message: string) => debugLogger.error(message), +}; + +const defaultRequestSetting: RequestSettingCallback = async (setting) => + promptForSetting(setting); + +const defaultRequestConfirmation: RequestConfirmationCallback = async ( + message, +) => { + const response = await prompts({ + type: 'confirm', + name: 'confirm', + message, + initial: false, + }); + return typeof response.confirm === 'boolean' ? response.confirm : false; +}; + +export async function getExtensionManager() { + const workspaceDir = process.cwd(); + const extensionManager = new ExtensionManager({ + workspaceDir, + requestConsent: requestConsentNonInteractive, + requestSetting: promptForSetting, + settings: loadSettings(workspaceDir).merged, + }); + await extensionManager.loadExtensions(); + return extensionManager; +} + +export async function getExtensionAndManager( + extensionManager: ExtensionManager, + name: string, + logger: ConfigLogger = defaultLogger, +) { + const extension = extensionManager + .getExtensions() + .find((ext) => ext.name === name); + + if (!extension) { + logger.error(`Extension "${name}" is not installed.`); + return { extension: null }; + } + + return { extension }; +} + +export async function configureSpecificSetting( + extensionManager: ExtensionManager, + extensionName: string, + settingKey: string, + scope: ExtensionSettingScope, + logger: ConfigLogger = defaultLogger, + requestSetting: RequestSettingCallback = defaultRequestSetting, +) { + const { extension } = await getExtensionAndManager( + extensionManager, + extensionName, + logger, + ); + if (!extension) { + return; + } + const extensionConfig = await extensionManager.loadExtensionConfig( + extension.path, + ); + if (!extensionConfig) { + logger.error( + `Could not find configuration for extension "${extensionName}".`, + ); + return; + } + + await updateSetting( + extensionConfig, + extension.id, + settingKey, + requestSetting, + scope, + process.cwd(), + ); + + logger.log(`Setting "${settingKey}" updated.`); +} + +export async function configureExtension( + extensionManager: ExtensionManager, + extensionName: string, + scope: ExtensionSettingScope, + logger: ConfigLogger = defaultLogger, + requestSetting: RequestSettingCallback = defaultRequestSetting, + requestConfirmation: RequestConfirmationCallback = defaultRequestConfirmation, +) { + const { extension } = await getExtensionAndManager( + extensionManager, + extensionName, + logger, + ); + if (!extension) { + return; + } + const extensionConfig = await extensionManager.loadExtensionConfig( + extension.path, + ); + if ( + !extensionConfig || + !extensionConfig.settings || + extensionConfig.settings.length === 0 + ) { + logger.log(`Extension "${extensionName}" has no settings to configure.`); + return; + } + + logger.log(`Configuring settings for "${extensionName}"...`); + await configureExtensionSettings( + extensionConfig, + extension.id, + scope, + logger, + requestSetting, + requestConfirmation, + ); +} + +export async function configureAllExtensions( + extensionManager: ExtensionManager, + scope: ExtensionSettingScope, + logger: ConfigLogger = defaultLogger, + requestSetting: RequestSettingCallback = defaultRequestSetting, + requestConfirmation: RequestConfirmationCallback = defaultRequestConfirmation, +) { + const extensions = extensionManager.getExtensions(); + + if (extensions.length === 0) { + logger.log('No extensions installed.'); + return; + } + + for (const extension of extensions) { + const extensionConfig = await extensionManager.loadExtensionConfig( + extension.path, + ); + if ( + extensionConfig && + extensionConfig.settings && + extensionConfig.settings.length > 0 + ) { + logger.log(`\nConfiguring settings for "${extension.name}"...`); + await configureExtensionSettings( + extensionConfig, + extension.id, + scope, + logger, + requestSetting, + requestConfirmation, + ); + } + } +} + +export async function configureExtensionSettings( + extensionConfig: ExtensionConfig, + extensionId: string, + scope: ExtensionSettingScope, + logger: ConfigLogger = defaultLogger, + requestSetting: RequestSettingCallback = defaultRequestSetting, + requestConfirmation: RequestConfirmationCallback = defaultRequestConfirmation, +) { + const currentScopedSettings = await getScopedEnvContents( + extensionConfig, + extensionId, + scope, + process.cwd(), + ); + + let workspaceSettings: Record = {}; + if (scope === ExtensionSettingScope.USER) { + workspaceSettings = await getScopedEnvContents( + extensionConfig, + extensionId, + ExtensionSettingScope.WORKSPACE, + process.cwd(), + ); + } + + if (!extensionConfig.settings) return; + + for (const setting of extensionConfig.settings) { + const currentValue = currentScopedSettings[setting.envVar]; + const workspaceValue = workspaceSettings[setting.envVar]; + + if (workspaceValue !== undefined) { + logger.log( + `Note: Setting "${setting.name}" is already configured in the workspace scope.`, + ); + } + + if (currentValue !== undefined) { + const confirmed = await requestConfirmation( + `Setting "${setting.name}" (${setting.envVar}) is already set. Overwrite?`, + ); + + if (!confirmed) { + continue; + } + } + + await updateSetting( + extensionConfig, + extensionId, + setting.envVar, + requestSetting, + scope, + process.cwd(), + ); + } +} + +export function getFormattedSettingValue( + setting: ResolvedExtensionSetting, +): string { + if (!setting.value) { + return '[not set]'; + } + if (setting.sensitive) { + return '***'; + } + return setting.value; +} diff --git a/packages/cli/src/commands/extensions/validate.test.ts b/packages/cli/src/commands/extensions/validate.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..e082f6c2fa40946537a08ec9b3827602f1788c04 --- /dev/null +++ b/packages/cli/src/commands/extensions/validate.test.ts @@ -0,0 +1,206 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, + type MockInstance, +} from 'vitest'; +import { handleValidate, validateCommand } from './validate.js'; +import yargs from 'yargs'; +import { createExtension } from '../../test-utils/createExtension.js'; +import path from 'node:path'; +import * as os from 'node:os'; +import { debugLogger } from '@google/gemini-cli-core'; + +vi.mock('../utils.js', () => ({ + exitCli: vi.fn(), +})); + +describe('extensions validate command', () => { + it('should fail if no path is provided', () => { + const validationParser = yargs([]) + .locale('en') + .command(validateCommand) + .fail(false); + expect(() => validationParser.parse('validate')).toThrow( + 'Not enough non-option arguments: got 0, need at least 1', + ); + }); +}); + +describe('handleValidate', () => { + let debugLoggerLogSpy: MockInstance; + let debugLoggerWarnSpy: MockInstance; + let debugLoggerErrorSpy: MockInstance; + let processSpy: MockInstance; + let tempHomeDir: string; + let tempWorkspaceDir: string; + + beforeEach(() => { + debugLoggerLogSpy = vi.spyOn(debugLogger, 'log'); + debugLoggerWarnSpy = vi.spyOn(debugLogger, 'warn'); + debugLoggerErrorSpy = vi.spyOn(debugLogger, 'error'); + processSpy = vi + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + tempHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-home')); + tempWorkspaceDir = fs.mkdtempSync(path.join(tempHomeDir, 'test-workspace')); + vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir); + }); + + afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(tempHomeDir, { recursive: true, force: true }); + fs.rmSync(tempWorkspaceDir, { recursive: true, force: true }); + }); + + it('should validate an extension from a local dir', async () => { + createExtension({ + extensionsDir: tempWorkspaceDir, + name: 'local-ext-name', + version: '1.0.0', + }); + + await handleValidate({ + path: 'local-ext-name', + }); + expect(debugLoggerLogSpy).toHaveBeenCalledWith( + 'Extension local-ext-name has been successfully validated.', + ); + }); + + it('should throw an error if the extension name is invalid', async () => { + createExtension({ + extensionsDir: tempWorkspaceDir, + name: 'INVALID_NAME', + version: '1.0.0', + }); + + await handleValidate({ + path: 'INVALID_NAME', + }); + expect(debugLoggerErrorSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'Invalid extension name: "INVALID_NAME". Only letters (a-z, A-Z), numbers (0-9), and dashes (-) are allowed.', + ), + ); + expect(processSpy).toHaveBeenCalledWith(1); + }); + + it('should warn if version is not formatted with semver', async () => { + createExtension({ + extensionsDir: tempWorkspaceDir, + name: 'valid-name', + version: '1', + }); + + await handleValidate({ + path: 'valid-name', + }); + expect(debugLoggerWarnSpy).toHaveBeenCalledWith( + expect.stringContaining( + "Version '1' does not appear to be standard semver (e.g., 1.0.0).", + ), + ); + expect(debugLoggerLogSpy).toHaveBeenCalledWith( + 'Extension valid-name has been successfully validated.', + ); + }); + + it('should throw an error if context files are missing', async () => { + createExtension({ + extensionsDir: tempWorkspaceDir, + name: 'valid-name', + version: '1.0.0', + contextFileName: 'contextFile.md', + }); + fs.rmSync(path.join(tempWorkspaceDir, 'valid-name/contextFile.md')); + await handleValidate({ + path: 'valid-name', + }); + expect(debugLoggerErrorSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'The following context files referenced in gemini-extension.json are missing: contextFile.md', + ), + ); + expect(processSpy).toHaveBeenCalledWith(1); + }); + + it('should throw an error if context file uses relative parent directory navigation', async () => { + createExtension({ + extensionsDir: tempWorkspaceDir, + name: 'parent-nav-ext', + version: '1.0.0', + contextFileName: '../secret.txt', + }); + await handleValidate({ + path: 'parent-nav-ext', + }); + expect(debugLoggerErrorSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'Invalid context file path: "../secret.txt". Relative parent directory references ("..") are not allowed.', + ), + ); + expect(processSpy).toHaveBeenCalledWith(1); + }); + + it('should throw an error if context file uses URL-encoded relative parent directory navigation', async () => { + createExtension({ + extensionsDir: tempWorkspaceDir, + name: 'encoded-nav-ext', + version: '1.0.0', + contextFileName: '%2e%2e/secret.txt', + }); + await handleValidate({ + path: 'encoded-nav-ext', + }); + expect(debugLoggerErrorSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'Invalid context file path: "%2e%2e/secret.txt". Relative parent directory references ("..") are not allowed.', + ), + ); + expect(processSpy).toHaveBeenCalledWith(1); + }); + + it('should throw an error if context file uses absolute path', async () => { + createExtension({ + extensionsDir: tempWorkspaceDir, + name: 'abs-path-ext', + version: '1.0.0', + contextFileName: '/etc/hosts', + }); + await handleValidate({ + path: 'abs-path-ext', + }); + expect(debugLoggerErrorSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'Invalid context file path: "/etc/hosts". Absolute paths are not allowed.', + ), + ); + expect(processSpy).toHaveBeenCalledWith(1); + }); + + it('should successfully validate an extension with context file containing percent characters', async () => { + createExtension({ + extensionsDir: tempWorkspaceDir, + name: 'percent-name-ext', + version: '1.0.0', + contextFileName: '100%_complete.md', + }); + await handleValidate({ + path: 'percent-name-ext', + }); + expect(debugLoggerLogSpy).toHaveBeenCalledWith( + 'Extension percent-name-ext has been successfully validated.', + ); + }); +}); diff --git a/packages/cli/src/commands/extensions/validate.ts b/packages/cli/src/commands/extensions/validate.ts new file mode 100644 index 0000000000000000000000000000000000000000..b70d657fb8412e9fae781eb9d1bc51a1f1ad51cd --- /dev/null +++ b/packages/cli/src/commands/extensions/validate.ts @@ -0,0 +1,119 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import { debugLogger, getErrorMessage } from '@google/gemini-cli-core'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import semver from 'semver'; +import type { ExtensionConfig } from '../../config/extension.js'; +import { + ExtensionManager, + validateContextFilePath, +} from '../../config/extension-manager.js'; +import { requestConsentNonInteractive } from '../../config/extensions/consent.js'; +import { promptForSetting } from '../../config/extensions/extensionSettings.js'; +import { loadSettings } from '../../config/settings.js'; +import { exitCli } from '../utils.js'; + +interface ValidateArgs { + path: string; +} + +export async function handleValidate(args: ValidateArgs) { + try { + await validateExtension(args); + debugLogger.log(`Extension ${args.path} has been successfully validated.`); + } catch (error) { + debugLogger.error(getErrorMessage(error)); + process.exit(1); + } +} + +async function validateExtension(args: ValidateArgs) { + const workspaceDir = process.cwd(); + const extensionManager = new ExtensionManager({ + workspaceDir, + requestConsent: requestConsentNonInteractive, + requestSetting: promptForSetting, + settings: loadSettings(workspaceDir).merged, + }); + const absoluteInputPath = path.resolve(args.path); + const extensionConfig: ExtensionConfig = + await extensionManager.loadExtensionConfig(absoluteInputPath); + const warnings: string[] = []; + const errors: string[] = []; + + if (extensionConfig.contextFileName) { + const contextFileNames = Array.isArray(extensionConfig.contextFileName) + ? extensionConfig.contextFileName + : [extensionConfig.contextFileName]; + + const missingContextFiles: string[] = []; + + for (const contextFilePath of contextFileNames) { + const validation = validateContextFilePath( + contextFilePath, + absoluteInputPath, + ); + + if (!validation.isValid) { + errors.push( + `Invalid context file path: "${contextFilePath}". ${validation.errorMessage}`, + ); + continue; + } + + if (!fs.existsSync(validation.resolvedPath!)) { + missingContextFiles.push(contextFilePath); + } + } + if (missingContextFiles.length > 0) { + errors.push( + `The following context files referenced in gemini-extension.json are missing: ${missingContextFiles}`, + ); + } + } + + if (!semver.valid(extensionConfig.version)) { + warnings.push( + `Warning: Version '${extensionConfig.version}' does not appear to be standard semver (e.g., 1.0.0).`, + ); + } + + if (warnings.length > 0) { + debugLogger.warn('Validation warnings:'); + for (const warning of warnings) { + debugLogger.warn(` - ${warning}`); + } + } + + if (errors.length > 0) { + debugLogger.error('Validation failed with the following errors:'); + for (const error of errors) { + debugLogger.error(` - ${error}`); + } + throw new Error('Extension validation failed.'); + } +} + +export const validateCommand: CommandModule = { + command: 'validate ', + describe: 'Validates an extension from a local path.', + builder: (yargs) => + yargs.positional('path', { + describe: 'The path of the extension to validate.', + type: 'string', + demandOption: true, + }), + handler: async (args) => { + await handleValidate({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + path: args['path'] as string, + }); + await exitCli(); + }, +}; diff --git a/packages/cli/src/commands/gemma.ts b/packages/cli/src/commands/gemma.ts new file mode 100644 index 0000000000000000000000000000000000000000..737bbb069baaed5bf29b65370bc952140de1e246 --- /dev/null +++ b/packages/cli/src/commands/gemma.ts @@ -0,0 +1,33 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule, Argv } from 'yargs'; +import { initializeOutputListenersAndFlush } from '../gemini.js'; +import { defer } from '../deferred.js'; +import { setupCommand } from './gemma/setup.js'; +import { startCommand } from './gemma/start.js'; +import { stopCommand } from './gemma/stop.js'; +import { statusCommand } from './gemma/status.js'; +import { logsCommand } from './gemma/logs.js'; + +export const gemmaCommand: CommandModule = { + command: 'gemma', + describe: 'Manage local Gemma model routing', + builder: (yargs: Argv) => + yargs + .middleware((argv) => { + initializeOutputListenersAndFlush(); + argv['isCommand'] = true; + }) + .command(defer(setupCommand, 'gemma')) + .command(defer(startCommand, 'gemma')) + .command(defer(stopCommand, 'gemma')) + .command(defer(statusCommand, 'gemma')) + .command(defer(logsCommand, 'gemma')) + .demandCommand(1, 'You need at least one command before continuing.') + .version(false), + handler: () => {}, +}; diff --git a/packages/cli/src/commands/gemma/constants.ts b/packages/cli/src/commands/gemma/constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..a37326a0578544e007a9d23f945a1523a2e30ba9 --- /dev/null +++ b/packages/cli/src/commands/gemma/constants.ts @@ -0,0 +1,45 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import path from 'node:path'; +import { Storage } from '@google/gemini-cli-core'; + +export const LITERT_RELEASE_VERSION = 'v0.9.0-alpha03'; +export const LITERT_RELEASE_BASE_URL = + 'https://github.com/google-ai-edge/LiteRT-LM/releases/download'; +export const GEMMA_MODEL_NAME = 'gemma3-1b-gpu-custom'; +export const DEFAULT_PORT = 9379; +export const HEALTH_CHECK_TIMEOUT_MS = 5000; +export const LITERT_API_VERSION = 'v1beta'; +export const SERVER_START_WAIT_MS = 3000; + +export const PLATFORM_BINARY_MAP: Record = { + 'darwin-arm64': 'lit.macos_arm64', + 'linux-x64': 'lit.linux_x86_64', + 'win32-x64': 'lit.windows_x86_64.exe', +}; + +// SHA-256 hashes for the official LiteRT-LM v0.9.0-alpha03 release binaries. +export const PLATFORM_BINARY_SHA256: Record = { + 'lit.macos_arm64': + '9e826a2634f2e8b220ad0f1e1b5c139e0b47cb172326e3b7d46d31382f49478e', + 'lit.linux_x86_64': + '66601df8a07f08244b188e9fcab0bf4a16562fe76d8d47e49f40273d57541ee8', + 'lit.windows_x86_64.exe': + 'de82d2829d2fb1cbdb318e2d8a78dc2f9659ff14cb11b2894d1f30e0bfde2bf6', +}; + +export function getLiteRtBinDir(): string { + return path.join(Storage.getGlobalGeminiDir(), 'bin', 'litert'); +} + +export function getPidFilePath(): string { + return path.join(Storage.getGlobalTempDir(), 'litert-server.pid'); +} + +export function getLogFilePath(): string { + return path.join(Storage.getGlobalTempDir(), 'litert-server.log'); +} diff --git a/packages/cli/src/commands/gemma/logs.test.ts b/packages/cli/src/commands/gemma/logs.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..49ab8d43c68e9aabcf9bdf1ff5bdb96d89bdf7d5 --- /dev/null +++ b/packages/cli/src/commands/gemma/logs.test.ts @@ -0,0 +1,186 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs'; +import type { ChildProcess } from 'node:child_process'; +import { EventEmitter } from 'node:events'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { spawn } from 'node:child_process'; +import { exitCli } from '../utils.js'; +import { getLogFilePath } from './constants.js'; +import { logsCommand, readLastLines } from './logs.js'; + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const { mockCoreDebugLogger } = await import( + '../../test-utils/mockDebugLogger.js' + ); + return mockCoreDebugLogger( + await importOriginal(), + { + stripAnsi: false, + }, + ); +}); + +vi.mock('node:child_process', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + spawn: vi.fn(), + }; +}); + +vi.mock('../utils.js', () => ({ + exitCli: vi.fn(), +})); + +vi.mock('./constants.js', () => ({ + getLogFilePath: vi.fn(), +})); + +function createMockChild(): ChildProcess { + return Object.assign(new EventEmitter(), { + kill: vi.fn(), + }) as unknown as ChildProcess; +} + +async function flushMicrotasks() { + await Promise.resolve(); + await Promise.resolve(); +} + +describe('readLastLines', () => { + const tempFiles: string[] = []; + + afterEach(async () => { + await Promise.all( + tempFiles + .splice(0) + .map((filePath) => fs.promises.rm(filePath, { force: true })), + ); + }); + + it('returns only the requested tail lines without reading the whole file eagerly', async () => { + const filePath = path.join( + os.tmpdir(), + `gemma-logs-${Date.now()}-${Math.random().toString(36).slice(2)}.log`, + ); + tempFiles.push(filePath); + + const content = Array.from({ length: 2000 }, (_, i) => `line-${i + 1}`) + .join('\n') + .concat('\n'); + await fs.promises.writeFile(filePath, content, 'utf-8'); + + await expect(readLastLines(filePath, 3)).resolves.toBe( + 'line-1998\nline-1999\nline-2000\n', + ); + }); + + it('returns an empty string when zero lines are requested', async () => { + const filePath = path.join( + os.tmpdir(), + `gemma-logs-${Date.now()}-${Math.random().toString(36).slice(2)}.log`, + ); + tempFiles.push(filePath); + await fs.promises.writeFile(filePath, 'line-1\nline-2\n', 'utf-8'); + + await expect(readLastLines(filePath, 0)).resolves.toBe(''); + }); +}); + +describe('logsCommand', () => { + const originalPlatform = process.platform; + + beforeEach(() => { + vi.clearAllMocks(); + Object.defineProperty(process, 'platform', { + value: 'linux', + configurable: true, + }); + vi.mocked(getLogFilePath).mockReturnValue('/tmp/gemma.log'); + vi.spyOn(fs.promises, 'access').mockResolvedValue(undefined); + }); + + afterEach(() => { + Object.defineProperty(process, 'platform', { + value: originalPlatform, + configurable: true, + }); + vi.restoreAllMocks(); + }); + + it('waits for the tail process to close before exiting in follow mode', async () => { + const child = createMockChild(); + vi.mocked(spawn).mockReturnValue(child); + + let resolved = false; + const handlerPromise = ( + logsCommand.handler as (argv: Record) => Promise + )({}).then(() => { + resolved = true; + }); + + await flushMicrotasks(); + + expect(spawn).toHaveBeenCalledWith( + 'tail', + ['-f', '-n', '20', '/tmp/gemma.log'], + { stdio: 'inherit' }, + ); + expect(resolved).toBe(false); + expect(exitCli).not.toHaveBeenCalled(); + + child.emit('close', 0); + await handlerPromise; + + expect(exitCli).toHaveBeenCalledWith(0); + }); + + it('uses one-shot tail output when follow is disabled', async () => { + const child = createMockChild(); + vi.mocked(spawn).mockReturnValue(child); + + const handlerPromise = ( + logsCommand.handler as (argv: Record) => Promise + )({ follow: false }); + + await flushMicrotasks(); + + expect(spawn).toHaveBeenCalledWith('tail', ['-n', '20', '/tmp/gemma.log'], { + stdio: 'inherit', + }); + + child.emit('close', 0); + await handlerPromise; + + expect(exitCli).toHaveBeenCalledWith(0); + }); + + it('follows from the requested line count when both --lines and --follow are set', async () => { + const child = createMockChild(); + vi.mocked(spawn).mockReturnValue(child); + + const handlerPromise = ( + logsCommand.handler as (argv: Record) => Promise + )({ lines: 5, follow: true }); + + await flushMicrotasks(); + + expect(spawn).toHaveBeenCalledWith( + 'tail', + ['-f', '-n', '5', '/tmp/gemma.log'], + { stdio: 'inherit' }, + ); + + child.emit('close', 0); + await handlerPromise; + + expect(exitCli).toHaveBeenCalledWith(0); + }); +}); diff --git a/packages/cli/src/commands/gemma/logs.ts b/packages/cli/src/commands/gemma/logs.ts new file mode 100644 index 0000000000000000000000000000000000000000..023b8e6352a87b4fd6bcd6c531648e159d1c70c2 --- /dev/null +++ b/packages/cli/src/commands/gemma/logs.ts @@ -0,0 +1,200 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import fs from 'node:fs'; +import { spawn, type ChildProcess } from 'node:child_process'; +import { debugLogger } from '@google/gemini-cli-core'; +import { exitCli } from '../utils.js'; +import { getLogFilePath } from './constants.js'; + +export async function readLastLines( + filePath: string, + count: number, +): Promise { + if (count <= 0) { + return ''; + } + + const CHUNK_SIZE = 64 * 1024; + const fileHandle = await fs.promises.open(filePath, fs.constants.O_RDONLY); + + try { + const stats = await fileHandle.stat(); + if (stats.size === 0) { + return ''; + } + + const chunks: Buffer[] = []; + let totalBytes = 0; + let newlineCount = 0; + let position = stats.size; + + while (position > 0 && newlineCount <= count) { + const readSize = Math.min(CHUNK_SIZE, position); + position -= readSize; + + const buffer = Buffer.allocUnsafe(readSize); + const { bytesRead } = await fileHandle.read( + buffer, + 0, + readSize, + position, + ); + + if (bytesRead === 0) { + break; + } + + const chunk = + bytesRead === readSize ? buffer : buffer.subarray(0, bytesRead); + chunks.unshift(chunk); + totalBytes += chunk.length; + + for (const byte of chunk) { + if (byte === 0x0a) { + newlineCount += 1; + } + } + } + + const content = Buffer.concat(chunks, totalBytes).toString('utf-8'); + const lines = content.split('\n'); + + if (position > 0 && lines.length > 0) { + const boundary = Buffer.allocUnsafe(1); + const { bytesRead } = await fileHandle.read(boundary, 0, 1, position - 1); + if (bytesRead === 1 && boundary[0] !== 0x0a) { + lines.shift(); + } + } + + if (lines.length > 0 && lines[lines.length - 1] === '') { + lines.pop(); + } + + if (lines.length === 0) { + return ''; + } + + return lines.slice(-count).join('\n') + '\n'; + } finally { + await fileHandle.close(); + } +} + +interface LogsArgs { + lines?: number; + follow?: boolean; +} + +function waitForChild(child: ChildProcess): Promise { + return new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', (code) => resolve(code ?? 1)); + }); +} + +async function runTail(logPath: string, lines: number, follow: boolean) { + const tailArgs = follow + ? ['-f', '-n', String(lines), logPath] + : ['-n', String(lines), logPath]; + const child = spawn('tail', tailArgs, { stdio: 'inherit' }); + + if (!follow) { + return waitForChild(child); + } + + const handleSigint = () => { + child.kill('SIGTERM'); + }; + process.once('SIGINT', handleSigint); + + try { + return await waitForChild(child); + } finally { + process.off('SIGINT', handleSigint); + } +} + +export const logsCommand: CommandModule = { + command: 'logs', + describe: 'View LiteRT-LM server logs', + builder: (yargs) => + yargs + .option('lines', { + alias: 'n', + type: 'number', + description: 'Show the last N lines and exit (omit to follow live)', + }) + .option('follow', { + alias: 'f', + type: 'boolean', + description: + 'Follow log output (defaults to true when --lines is omitted)', + }), + handler: async (argv) => { + const logPath = getLogFilePath(); + + try { + await fs.promises.access(logPath, fs.constants.F_OK); + } catch { + debugLogger.log(`No log file found at ${logPath}`); + debugLogger.log( + 'Is the LiteRT server running? Start it with: gemini gemma start', + ); + await exitCli(1); + return; + } + + const lines = argv.lines; + const follow = argv.follow ?? lines === undefined; + const requestedLines = lines ?? 20; + + if (follow && process.platform === 'win32') { + debugLogger.log( + 'Live log following is not supported on Windows. Use --lines N to view recent logs.', + ); + await exitCli(1); + return; + } + + if (process.platform === 'win32') { + process.stdout.write(await readLastLines(logPath, requestedLines)); + await exitCli(0); + return; + } + + try { + if (follow) { + debugLogger.log(`Tailing ${logPath} (Ctrl+C to stop)\n`); + } + const exitCode = await runTail(logPath, requestedLines, follow); + await exitCli(exitCode); + } catch (error) { + if ( + error instanceof Error && + 'code' in error && + error.code === 'ENOENT' + ) { + if (!follow) { + process.stdout.write(await readLastLines(logPath, requestedLines)); + await exitCli(0); + } else { + debugLogger.error( + '"tail" command not found. Use --lines N to view recent logs without tail.', + ); + await exitCli(1); + } + } else { + debugLogger.error( + `Failed to read log output: ${error instanceof Error ? error.message : String(error)}`, + ); + await exitCli(1); + } + } + }, +}; diff --git a/packages/cli/src/commands/gemma/platform.test.ts b/packages/cli/src/commands/gemma/platform.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..b00549365a977a0139da0ac97d3ec94bf097800c --- /dev/null +++ b/packages/cli/src/commands/gemma/platform.test.ts @@ -0,0 +1,162 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { SettingScope } from '../../config/settings.js'; +import { getLiteRtBinDir } from './constants.js'; + +const mockLoadSettings = vi.hoisted(() => vi.fn()); + +vi.mock('../../config/settings.js', () => ({ + loadSettings: mockLoadSettings, + SettingScope: { + User: 'User', + }, +})); + +import { + getBinaryPath, + isExpectedLiteRtServerCommand, + isBinaryInstalled, + readServerProcessInfo, + resolveGemmaConfig, +} from './platform.js'; + +describe('gemma platform helpers', () => { + function createMockSettings( + userGemmaSettings?: object, + mergedGemmaSettings?: object, + ) { + return { + merged: { + experimental: { + gemmaModelRouter: mergedGemmaSettings, + }, + }, + forScope: vi.fn((scope: SettingScope) => { + if (scope !== SettingScope.User) { + throw new Error(`Unexpected scope ${scope}`); + } + return { + settings: { + experimental: { + gemmaModelRouter: userGemmaSettings, + }, + }, + }; + }), + }; + } + + beforeEach(() => { + vi.clearAllMocks(); + mockLoadSettings.mockReturnValue(createMockSettings()); + }); + + it('prefers the configured binary path from settings', () => { + mockLoadSettings.mockReturnValue( + createMockSettings({ binaryPath: '/custom/lit' }), + ); + + expect(getBinaryPath('lit.test')).toBe('/custom/lit'); + }); + + it('ignores workspace overrides for the configured binary path', () => { + mockLoadSettings.mockReturnValue( + createMockSettings( + { binaryPath: '/user/lit' }, + { binaryPath: '/workspace/evil' }, + ), + ); + + expect(getBinaryPath('lit.test')).toBe('/user/lit'); + }); + + it('falls back to the default install location when no custom path is set', () => { + expect(getBinaryPath('lit.test')).toBe( + path.join(getLiteRtBinDir(), 'lit.test'), + ); + }); + + it('resolves the configured port and binary path from settings', () => { + mockLoadSettings.mockReturnValue( + createMockSettings( + { binaryPath: '/custom/lit' }, + { + enabled: true, + classifier: { + host: 'http://localhost:8123/v1beta', + }, + }, + ), + ); + + expect(resolveGemmaConfig(9379)).toEqual({ + settingsEnabled: true, + configuredPort: 8123, + configuredBinaryPath: '/custom/lit', + }); + }); + + it('checks binary installation using the resolved binary path', () => { + mockLoadSettings.mockReturnValue( + createMockSettings({ binaryPath: '/custom/lit' }), + ); + vi.spyOn(fs, 'existsSync').mockReturnValue(true); + + expect(isBinaryInstalled()).toBe(true); + expect(fs.existsSync).toHaveBeenCalledWith('/custom/lit'); + }); + + it('parses structured server process info from the pid file', () => { + vi.spyOn(fs, 'readFileSync').mockReturnValue( + JSON.stringify({ + pid: 1234, + binaryPath: '/custom/lit', + port: 8123, + }), + ); + + expect(readServerProcessInfo()).toEqual({ + pid: 1234, + binaryPath: '/custom/lit', + port: 8123, + }); + }); + + it('parses legacy pid-only files for backward compatibility', () => { + vi.spyOn(fs, 'readFileSync').mockReturnValue('4321'); + + expect(readServerProcessInfo()).toEqual({ + pid: 4321, + }); + }); + + it('matches only the expected LiteRT serve command', () => { + expect( + isExpectedLiteRtServerCommand('/custom/lit serve --port=8123 --verbose', { + binaryPath: '/custom/lit', + port: 8123, + }), + ).toBe(true); + + expect( + isExpectedLiteRtServerCommand('/custom/lit run --port=8123', { + binaryPath: '/custom/lit', + port: 8123, + }), + ).toBe(false); + + expect( + isExpectedLiteRtServerCommand('/custom/lit serve --port=9000', { + binaryPath: '/custom/lit', + port: 8123, + }), + ).toBe(false); + }); +}); diff --git a/packages/cli/src/commands/gemma/platform.ts b/packages/cli/src/commands/gemma/platform.ts new file mode 100644 index 0000000000000000000000000000000000000000..0fdd6e02e148a45f5da7a063d289576354121def --- /dev/null +++ b/packages/cli/src/commands/gemma/platform.ts @@ -0,0 +1,316 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { loadSettings, SettingScope } from '../../config/settings.js'; +import fs from 'node:fs'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { + PLATFORM_BINARY_MAP, + LITERT_RELEASE_BASE_URL, + LITERT_RELEASE_VERSION, + getLiteRtBinDir, + GEMMA_MODEL_NAME, + HEALTH_CHECK_TIMEOUT_MS, + LITERT_API_VERSION, + getPidFilePath, +} from './constants.js'; + +export interface PlatformInfo { + key: string; + binaryName: string; +} + +export interface GemmaConfigStatus { + settingsEnabled: boolean; + configuredPort: number; + configuredBinaryPath?: string; +} + +export interface LiteRtServerProcessInfo { + pid: number; + binaryPath?: string; + port?: number; +} + +function getUserConfiguredBinaryPath( + workspaceDir = process.cwd(), +): string | undefined { + try { + const userGemmaSettings = loadSettings(workspaceDir).forScope( + SettingScope.User, + ).settings.experimental?.gemmaModelRouter; + return userGemmaSettings?.binaryPath?.trim() || undefined; + } catch { + return undefined; + } +} + +function parsePortFromHost( + host: string | undefined, + fallbackPort: number, +): number { + if (!host) { + return fallbackPort; + } + + try { + const url = new URL(host); + const port = Number(url.port); + return Number.isFinite(port) && port > 0 ? port : fallbackPort; + } catch { + const match = host.match(/:(\d+)/); + if (!match) { + return fallbackPort; + } + const port = parseInt(match[1], 10); + return Number.isFinite(port) && port > 0 ? port : fallbackPort; + } +} + +export function resolveGemmaConfig(fallbackPort: number): GemmaConfigStatus { + let settingsEnabled = false; + let configuredPort = fallbackPort; + const configuredBinaryPath = getUserConfiguredBinaryPath(); + try { + const settings = loadSettings(process.cwd()); + const gemmaSettings = settings.merged.experimental?.gemmaModelRouter; + settingsEnabled = gemmaSettings?.enabled === true; + configuredPort = parsePortFromHost( + gemmaSettings?.classifier?.host, + fallbackPort, + ); + } catch { + // ignore — settings may fail to load outside a workspace + } + return { settingsEnabled, configuredPort, configuredBinaryPath }; +} + +export function detectPlatform(): PlatformInfo | null { + const key = `${process.platform}-${process.arch}`; + const binaryName = PLATFORM_BINARY_MAP[key]; + if (!binaryName) { + return null; + } + return { key, binaryName }; +} + +export function getBinaryPath(binaryName?: string): string | null { + const configuredBinaryPath = getUserConfiguredBinaryPath(); + if (configuredBinaryPath) { + return configuredBinaryPath; + } + + const name = binaryName ?? detectPlatform()?.binaryName; + if (!name) return null; + return path.join(getLiteRtBinDir(), name); +} + +export function getBinaryDownloadUrl(binaryName: string): string { + return `${LITERT_RELEASE_BASE_URL}/${LITERT_RELEASE_VERSION}/${binaryName}`; +} + +export function isBinaryInstalled(binaryPath = getBinaryPath()): boolean { + if (!binaryPath) return false; + return fs.existsSync(binaryPath); +} + +export function isModelDownloaded(binaryPath: string): boolean { + try { + const output = execFileSync(binaryPath, ['list'], { + encoding: 'utf-8', + timeout: 10000, + }); + return output.includes(GEMMA_MODEL_NAME); + } catch { + return false; + } +} + +export async function isServerRunning(port: number): Promise { + try { + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(), + HEALTH_CHECK_TIMEOUT_MS, + ); + const response = await fetch( + `http://localhost:${port}/${LITERT_API_VERSION}/models/${GEMMA_MODEL_NAME}:generateContent`, + { method: 'POST', signal: controller.signal }, + ); + clearTimeout(timeout); + // A 400 (bad request) confirms the route exists — the server recognises + // the model endpoint. Only a 404 means "wrong server / wrong model". + return response.status !== 404; + } catch { + return false; + } +} + +function isLiteRtServerProcessInfo( + value: unknown, +): value is LiteRtServerProcessInfo { + if (!value || typeof value !== 'object') { + return false; + } + + const isPositiveInteger = (candidate: unknown): candidate is number => + typeof candidate === 'number' && + Number.isInteger(candidate) && + candidate > 0; + const isNonEmptyString = (candidate: unknown): candidate is string => + typeof candidate === 'string' && candidate.length > 0; + + const pid: unknown = Object.getOwnPropertyDescriptor(value, 'pid')?.value; + if (!isPositiveInteger(pid)) { + return false; + } + + const binaryPath: unknown = Object.getOwnPropertyDescriptor( + value, + 'binaryPath', + )?.value; + if (binaryPath !== undefined && !isNonEmptyString(binaryPath)) { + return false; + } + + const port: unknown = Object.getOwnPropertyDescriptor(value, 'port')?.value; + if (port !== undefined && !isPositiveInteger(port)) { + return false; + } + + return true; +} + +export function readServerProcessInfo(): LiteRtServerProcessInfo | null { + const pidPath = getPidFilePath(); + try { + const content = fs.readFileSync(pidPath, 'utf-8').trim(); + if (!content) { + return null; + } + + if (/^\d+$/.test(content)) { + return { pid: parseInt(content, 10) }; + } + + const parsed = JSON.parse(content) as unknown; + return isLiteRtServerProcessInfo(parsed) ? parsed : null; + } catch { + return null; + } +} + +export function writeServerProcessInfo( + processInfo: LiteRtServerProcessInfo, +): void { + fs.writeFileSync(getPidFilePath(), JSON.stringify(processInfo), 'utf-8'); +} + +export function readServerPid(): number | null { + return readServerProcessInfo()?.pid ?? null; +} + +function normalizeProcessValue(value: string): string { + const normalized = value.replace(/\0/g, ' ').trim(); + if (process.platform === 'win32') { + return normalized.replace(/\\/g, '/').replace(/\s+/g, ' ').toLowerCase(); + } + return normalized.replace(/\s+/g, ' '); +} + +function readProcessCommandLine(pid: number): string | null { + try { + if (process.platform === 'linux') { + const output = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf-8'); + return output.trim() ? output : null; + } + + if (process.platform === 'win32') { + const output = execFileSync( + 'powershell.exe', + [ + '-NoProfile', + '-Command', + `(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").CommandLine`, + ], + { + encoding: 'utf-8', + timeout: 5000, + }, + ); + return output.trim() || null; + } + + const output = execFileSync('ps', ['-p', String(pid), '-o', 'command='], { + encoding: 'utf-8', + timeout: 5000, + }); + return output.trim() || null; + } catch { + return null; + } +} + +export function isExpectedLiteRtServerCommand( + commandLine: string, + options: { + binaryPath?: string | null; + port?: number; + }, +): boolean { + const normalizedCommandLine = normalizeProcessValue(commandLine); + if (!normalizedCommandLine) { + return false; + } + + if (!/(^|\s|")serve(\s|$)/.test(normalizedCommandLine)) { + return false; + } + + if ( + options.port !== undefined && + !normalizedCommandLine.includes(`--port=${options.port}`) + ) { + return false; + } + + if (!options.binaryPath) { + return true; + } + + const normalizedBinaryPath = normalizeProcessValue(options.binaryPath); + const normalizedBinaryName = normalizeProcessValue( + path.basename(options.binaryPath), + ); + return ( + normalizedCommandLine.includes(normalizedBinaryPath) || + normalizedCommandLine.includes(normalizedBinaryName) + ); +} + +export function isExpectedLiteRtServerProcess( + pid: number, + options: { + binaryPath?: string | null; + port?: number; + }, +): boolean { + const commandLine = readProcessCommandLine(pid); + if (!commandLine) { + return false; + } + return isExpectedLiteRtServerCommand(commandLine, options); +} + +export function isProcessRunning(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} diff --git a/packages/cli/src/commands/gemma/setup.test.ts b/packages/cli/src/commands/gemma/setup.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..663a5d6e4c341105d74639a718ba60a854a9555d --- /dev/null +++ b/packages/cli/src/commands/gemma/setup.test.ts @@ -0,0 +1,60 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { PLATFORM_BINARY_MAP, PLATFORM_BINARY_SHA256 } from './constants.js'; +import { computeFileSha256, verifyFileSha256 } from './setup.js'; + +describe('gemma setup checksum helpers', () => { + const tempFiles: string[] = []; + + afterEach(async () => { + await Promise.all( + tempFiles + .splice(0) + .map((filePath) => fs.promises.rm(filePath, { force: true })), + ); + }); + + it('has a pinned checksum for every supported LiteRT binary', () => { + expect(Object.keys(PLATFORM_BINARY_SHA256).sort()).toEqual( + Object.values(PLATFORM_BINARY_MAP).sort(), + ); + }); + + it('computes the sha256 for a downloaded file', async () => { + const filePath = path.join( + os.tmpdir(), + `gemma-setup-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + tempFiles.push(filePath); + await fs.promises.writeFile(filePath, 'hello world', 'utf-8'); + + await expect(computeFileSha256(filePath)).resolves.toBe( + 'b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9', + ); + }); + + it('verifies whether a file matches the expected sha256', async () => { + const filePath = path.join( + os.tmpdir(), + `gemma-setup-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + tempFiles.push(filePath); + await fs.promises.writeFile(filePath, 'hello world', 'utf-8'); + + await expect( + verifyFileSha256( + filePath, + 'b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9', + ), + ).resolves.toBe(true); + await expect(verifyFileSha256(filePath, 'deadbeef')).resolves.toBe(false); + }); +}); diff --git a/packages/cli/src/commands/gemma/setup.ts b/packages/cli/src/commands/gemma/setup.ts new file mode 100644 index 0000000000000000000000000000000000000000..a936462dbfff339f91de766199b148ac052ed744 --- /dev/null +++ b/packages/cli/src/commands/gemma/setup.ts @@ -0,0 +1,504 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { execFileSync, spawn as nodeSpawn } from 'node:child_process'; +import chalk from 'chalk'; +import { debugLogger } from '@google/gemini-cli-core'; +import { loadSettings, SettingScope } from '../../config/settings.js'; +import { exitCli } from '../utils.js'; +import { + DEFAULT_PORT, + GEMMA_MODEL_NAME, + PLATFORM_BINARY_SHA256, +} from './constants.js'; +import { + detectPlatform, + getBinaryDownloadUrl, + getBinaryPath, + isBinaryInstalled, + isModelDownloaded, +} from './platform.js'; +import { startServer } from './start.js'; +import readline from 'node:readline'; + +const log = (msg: string) => debugLogger.log(msg); +const logError = (msg: string) => debugLogger.error(msg); + +async function promptYesNo(question: string): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + return new Promise((resolve) => { + rl.question(`${question} (y/N): `, (answer) => { + rl.close(); + resolve( + answer.trim().toLowerCase() === 'y' || + answer.trim().toLowerCase() === 'yes', + ); + }); + }); +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function renderProgress(downloaded: number, total: number | null): void { + const barWidth = 30; + if (total && total > 0) { + const pct = Math.min(downloaded / total, 1); + const filled = Math.round(barWidth * pct); + const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled); + const pctStr = (pct * 100).toFixed(0).padStart(3); + process.stderr.write( + `\r [${bar}] ${pctStr}% ${formatBytes(downloaded)} / ${formatBytes(total)}`, + ); + } else { + process.stderr.write(`\r Downloaded ${formatBytes(downloaded)}`); + } +} + +async function downloadFile(url: string, destPath: string): Promise { + const tmpPath = destPath + '.downloading'; + if (fs.existsSync(tmpPath)) { + fs.unlinkSync(tmpPath); + } + + const response = await fetch(url, { redirect: 'follow' }); + if (!response.ok) { + throw new Error( + `Download failed: HTTP ${response.status} ${response.statusText}`, + ); + } + if (!response.body) { + throw new Error('Download failed: No response body'); + } + + const contentLength = response.headers.get('content-length'); + const totalBytes = contentLength ? parseInt(contentLength, 10) : null; + let downloadedBytes = 0; + + const fileStream = fs.createWriteStream(tmpPath); + const reader = response.body.getReader(); + + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + const writeOk = fileStream.write(value); + if (!writeOk) { + await new Promise((resolve) => fileStream.once('drain', resolve)); + } + downloadedBytes += value.byteLength; + renderProgress(downloadedBytes, totalBytes); + } + } finally { + fileStream.end(); + process.stderr.write('\r' + ' '.repeat(80) + '\r'); + } + + await new Promise((resolve, reject) => { + fileStream.on('finish', resolve); + fileStream.on('error', reject); + }); + + fs.renameSync(tmpPath, destPath); +} + +export async function computeFileSha256(filePath: string): Promise { + const hash = createHash('sha256'); + const fileStream = fs.createReadStream(filePath); + + return new Promise((resolve, reject) => { + fileStream.on('data', (chunk) => { + hash.update(chunk); + }); + fileStream.on('error', reject); + fileStream.on('end', () => { + resolve(hash.digest('hex')); + }); + }); +} + +export async function verifyFileSha256( + filePath: string, + expectedHash: string, +): Promise { + const actualHash = await computeFileSha256(filePath); + return actualHash === expectedHash; +} + +function spawnInherited(command: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + const child = nodeSpawn(command, args, { + stdio: 'inherit', + }); + child.on('close', (code) => resolve(code ?? 1)); + child.on('error', reject); + }); +} + +interface SetupArgs { + port: number; + skipModel: boolean; + start: boolean; + force: boolean; + consent: boolean; +} + +async function handleSetup(argv: SetupArgs): Promise { + const { port, force } = argv; + let settingsUpdated = false; + let serverStarted = false; + let autoStartServer = true; + + log(''); + log(chalk.bold('Gemma Local Model Routing Setup')); + log(chalk.dim('─'.repeat(40))); + log(''); + + const platform = detectPlatform(); + if (!platform) { + logError( + chalk.red(`Unsupported platform: ${process.platform}-${process.arch}`), + ); + logError( + 'LiteRT-LM binaries are available for: macOS (ARM64), Linux (x86_64), Windows (x86_64)', + ); + return 1; + } + log(chalk.dim(` Platform: ${platform.key} → ${platform.binaryName}`)); + + if (!argv.consent) { + log(''); + log('This will download and install the LiteRT-LM runtime and the'); + log( + `Gemma model (${GEMMA_MODEL_NAME}, ~1 GB). By proceeding, you agree to the`, + ); + log('Gemma Terms of Use: https://ai.google.dev/gemma/terms'); + log(''); + + const accepted = await promptYesNo('Do you want to continue?'); + if (!accepted) { + log('Setup cancelled.'); + return 0; + } + } + + const binaryPath = getBinaryPath(platform.binaryName)!; + const alreadyInstalled = isBinaryInstalled(); + + if (alreadyInstalled && !force) { + log(''); + log(chalk.green(' ✓ LiteRT-LM binary already installed at:')); + log(chalk.dim(` ${binaryPath}`)); + } else { + log(''); + log(' Downloading LiteRT-LM binary...'); + const downloadUrl = getBinaryDownloadUrl(platform.binaryName); + debugLogger.log(`Downloading from: ${downloadUrl}`); + + try { + const binDir = path.dirname(binaryPath); + fs.mkdirSync(binDir, { recursive: true }); + await downloadFile(downloadUrl, binaryPath); + log(chalk.green(' ✓ Binary downloaded successfully')); + } catch (error) { + logError( + chalk.red( + ` ✗ Failed to download binary: ${error instanceof Error ? error.message : String(error)}`, + ), + ); + logError(' Check your internet connection and try again.'); + return 1; + } + + const expectedHash = PLATFORM_BINARY_SHA256[platform.binaryName]; + if (!expectedHash) { + logError( + chalk.red( + ` ✗ No checksum is configured for ${platform.binaryName}. Refusing to install the binary.`, + ), + ); + try { + fs.rmSync(binaryPath, { force: true }); + } catch { + // ignore + } + return 1; + } + + try { + const checksumVerified = await verifyFileSha256(binaryPath, expectedHash); + if (!checksumVerified) { + logError( + chalk.red( + ' ✗ Downloaded binary checksum did not match the expected release hash.', + ), + ); + try { + fs.rmSync(binaryPath, { force: true }); + } catch { + // ignore + } + return 1; + } + log(chalk.green(' ✓ Binary checksum verified')); + } catch (error) { + logError( + chalk.red( + ` ✗ Failed to verify binary checksum: ${error instanceof Error ? error.message : String(error)}`, + ), + ); + try { + fs.rmSync(binaryPath, { force: true }); + } catch { + // ignore + } + return 1; + } + + if (process.platform !== 'win32') { + try { + fs.chmodSync(binaryPath, 0o755); + } catch (error) { + logError( + chalk.red( + ` ✗ Failed to set executable permission: ${error instanceof Error ? error.message : String(error)}`, + ), + ); + return 1; + } + } + + if (process.platform === 'darwin') { + try { + execFileSync('xattr', ['-d', 'com.apple.quarantine', binaryPath], { + stdio: 'ignore', + }); + log(chalk.green(' ✓ macOS quarantine attribute removed')); + } catch { + // Expected if the attribute doesn't exist. + } + } + } + + if (!argv.skipModel) { + const modelAlreadyDownloaded = isModelDownloaded(binaryPath); + if (modelAlreadyDownloaded && !force) { + log(''); + log(chalk.green(` ✓ Model ${GEMMA_MODEL_NAME} already downloaded`)); + } else { + log(''); + log(` Downloading model ${GEMMA_MODEL_NAME}...`); + log(chalk.dim(' You may be prompted to accept the Gemma Terms of Use.')); + log(''); + + const exitCode = await spawnInherited(binaryPath, [ + 'pull', + GEMMA_MODEL_NAME, + ]); + if (exitCode !== 0) { + logError(''); + logError( + chalk.red(` ✗ Model download failed (exit code ${exitCode})`), + ); + return 1; + } + log(''); + log(chalk.green(` ✓ Model ${GEMMA_MODEL_NAME} downloaded`)); + } + } + + log(''); + log(' Configuring settings...'); + try { + const settings = loadSettings(process.cwd()); + + // User scope: security-sensitive settings that must not be overridable + // by workspace configs (prevents arbitrary binary execution). + const existingUserGemma = + settings.forScope(SettingScope.User).settings.experimental + ?.gemmaModelRouter ?? {}; + autoStartServer = existingUserGemma.autoStartServer ?? true; + const existingUserExperimental = + settings.forScope(SettingScope.User).settings.experimental ?? {}; + settings.setValue(SettingScope.User, 'experimental', { + ...existingUserExperimental, + gemmaModelRouter: { + autoStartServer, + ...(existingUserGemma.binaryPath !== undefined + ? { binaryPath: existingUserGemma.binaryPath } + : {}), + }, + }); + + // Workspace scope: project-isolated settings so the local model only + // runs for this specific project, saving resources globally. + const existingWorkspaceGemma = + settings.forScope(SettingScope.Workspace).settings.experimental + ?.gemmaModelRouter ?? {}; + const existingWorkspaceExperimental = + settings.forScope(SettingScope.Workspace).settings.experimental ?? {}; + settings.setValue(SettingScope.Workspace, 'experimental', { + ...existingWorkspaceExperimental, + gemmaModelRouter: { + ...existingWorkspaceGemma, + enabled: true, + classifier: { + ...existingWorkspaceGemma.classifier, + host: `http://localhost:${port}`, + model: GEMMA_MODEL_NAME, + }, + }, + }); + + log(chalk.green(' ✓ Settings updated')); + log(chalk.dim(' User (~/.gemini/settings.json): autoStartServer')); + log( + chalk.dim(' Workspace (.gemini/settings.json): enabled, classifier'), + ); + settingsUpdated = true; + } catch (error) { + logError( + chalk.red( + ` ✗ Failed to update settings: ${error instanceof Error ? error.message : String(error)}`, + ), + ); + logError( + ' You can manually add the configuration to ~/.gemini/settings.json', + ); + } + + if (argv.start) { + log(''); + log(' Starting LiteRT server...'); + serverStarted = await startServer(binaryPath, port); + if (serverStarted) { + log(chalk.green(` ✓ Server started on port ${port}`)); + } else { + log( + chalk.yellow( + ` ! Server may not have started correctly. Check: gemini gemma status`, + ), + ); + } + } + + const routingActive = settingsUpdated && serverStarted; + const setupSucceeded = settingsUpdated && (!argv.start || serverStarted); + log(''); + log(chalk.dim('─'.repeat(40))); + if (routingActive) { + log(chalk.bold.green(' Setup complete! Local model routing is active.')); + } else if (settingsUpdated) { + log( + chalk.bold.green(' Setup complete! Local model routing is configured.'), + ); + } else { + log( + chalk.bold.yellow( + ' Setup incomplete. Manual settings changes are still required.', + ), + ); + } + log(''); + log(' How it works: Every request is classified by the local Gemma model.'); + log( + ' Simple tasks (file reads, quick edits) route to ' + + chalk.cyan('Flash') + + ' for speed.', + ); + log( + ' Complex tasks (debugging, architecture) route to ' + + chalk.cyan('Pro') + + ' for quality.', + ); + log(' This happens automatically — just use the CLI as usual.'); + log(''); + if (!settingsUpdated) { + log( + chalk.yellow( + ' Fix the settings update above, then rerun "gemini gemma status".', + ), + ); + log(''); + } else if (!argv.start) { + log(chalk.yellow(' Note: Run "gemini gemma start" to start the server.')); + if (autoStartServer) { + log( + chalk.yellow( + ' Or restart the CLI to auto-start it on the next launch.', + ), + ); + } + log(''); + } else if (!serverStarted) { + log( + chalk.yellow( + ' Review the server logs and rerun "gemini gemma start" after fixing the issue.', + ), + ); + log(''); + } + log(' Useful commands:'); + log(chalk.dim(' gemini gemma status Check routing status')); + log(chalk.dim(' gemini gemma start Start the LiteRT server')); + log(chalk.dim(' gemini gemma stop Stop the LiteRT server')); + log(chalk.dim(' /gemma Check status inside a session')); + log(''); + + return setupSucceeded ? 0 : 1; +} + +export const setupCommand: CommandModule = { + command: 'setup', + describe: 'Download and configure Gemma local model routing', + builder: (yargs) => + yargs + .option('port', { + type: 'number', + default: DEFAULT_PORT, + description: 'Port for the LiteRT server', + }) + .option('skip-model', { + type: 'boolean', + default: false, + description: 'Skip model download (binary only)', + }) + .option('start', { + type: 'boolean', + default: true, + description: 'Start the server after setup', + }) + .option('force', { + type: 'boolean', + default: false, + description: 'Re-download binary and model even if already present', + }) + .option('consent', { + type: 'boolean', + default: false, + description: 'Skip interactive consent prompt (implies acceptance)', + }), + handler: async (argv) => { + const exitCode = await handleSetup({ + port: Number(argv['port']), + skipModel: Boolean(argv['skipModel']), + start: Boolean(argv['start']), + force: Boolean(argv['force']), + consent: Boolean(argv['consent']), + }); + await exitCli(exitCode); + }, +}; diff --git a/packages/cli/src/commands/gemma/start.ts b/packages/cli/src/commands/gemma/start.ts new file mode 100644 index 0000000000000000000000000000000000000000..badf7b69a5ae4f78cc732378ce971dbfae9f0860 --- /dev/null +++ b/packages/cli/src/commands/gemma/start.ts @@ -0,0 +1,123 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import fs from 'node:fs'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; +import chalk from 'chalk'; +import { debugLogger } from '@google/gemini-cli-core'; +import { exitCli } from '../utils.js'; +import { + DEFAULT_PORT, + getPidFilePath, + getLogFilePath, + getLiteRtBinDir, + SERVER_START_WAIT_MS, +} from './constants.js'; +import { + getBinaryPath, + isBinaryInstalled, + isServerRunning, + resolveGemmaConfig, + writeServerProcessInfo, +} from './platform.js'; + +export async function startServer( + binaryPath: string, + port: number, +): Promise { + const alreadyRunning = await isServerRunning(port); + if (alreadyRunning) { + debugLogger.log(`LiteRT server already running on port ${port}`); + return true; + } + + const logPath = getLogFilePath(); + fs.mkdirSync(getLiteRtBinDir(), { recursive: true }); + const tmpDir = path.dirname(getPidFilePath()); + fs.mkdirSync(tmpDir, { recursive: true }); + + const logFd = fs.openSync(logPath, 'a'); + + try { + const child = spawn(binaryPath, ['serve', `--port=${port}`, '--verbose'], { + detached: true, + stdio: ['ignore', logFd, logFd], + }); + + if (child.pid) { + writeServerProcessInfo({ + pid: child.pid, + binaryPath, + port, + }); + } + + child.unref(); + } finally { + fs.closeSync(logFd); + } + + await new Promise((resolve) => setTimeout(resolve, SERVER_START_WAIT_MS)); + return isServerRunning(port); +} + +export const startCommand: CommandModule = { + command: 'start', + describe: 'Start the LiteRT-LM server', + builder: (yargs) => + yargs.option('port', { + type: 'number', + description: 'Port for the LiteRT server', + }), + handler: async (argv) => { + let port: number | undefined; + if (argv['port'] !== undefined) { + port = Number(argv['port']); + } + + if (!port) { + const { configuredPort } = resolveGemmaConfig(DEFAULT_PORT); + port = configuredPort; + } + + const binaryPath = getBinaryPath(); + if (!binaryPath || !isBinaryInstalled(binaryPath)) { + debugLogger.error( + chalk.red( + 'LiteRT-LM binary not found. Run "gemini gemma setup" first.', + ), + ); + await exitCli(1); + return; + } + + const alreadyRunning = await isServerRunning(port); + if (alreadyRunning) { + debugLogger.log( + chalk.green(`LiteRT server is already running on port ${port}.`), + ); + await exitCli(0); + return; + } + + debugLogger.log(`Starting LiteRT server on port ${port}...`); + + const started = await startServer(binaryPath, port); + if (started) { + debugLogger.log(chalk.green(`LiteRT server started on port ${port}.`)); + debugLogger.log(chalk.dim(`Logs: ${getLogFilePath()}`)); + await exitCli(0); + } else { + debugLogger.error( + chalk.red('Server may not have started correctly. Check logs:'), + ); + debugLogger.error(chalk.dim(` ${getLogFilePath()}`)); + await exitCli(1); + } + }, +}; diff --git a/packages/cli/src/commands/gemma/status.ts b/packages/cli/src/commands/gemma/status.ts new file mode 100644 index 0000000000000000000000000000000000000000..8ce9f006dcba213705ea63ae4fd26c916bcde7ea --- /dev/null +++ b/packages/cli/src/commands/gemma/status.ts @@ -0,0 +1,165 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import chalk from 'chalk'; +import { DEFAULT_PORT, GEMMA_MODEL_NAME } from './constants.js'; +import { + detectPlatform, + getBinaryPath, + isBinaryInstalled, + isModelDownloaded, + isServerRunning, + readServerPid, + isProcessRunning, + resolveGemmaConfig, +} from './platform.js'; +import { exitCli } from '../utils.js'; + +export interface GemmaStatusResult { + binaryInstalled: boolean; + binaryPath: string | null; + modelDownloaded: boolean; + serverRunning: boolean; + serverPid: number | null; + settingsEnabled: boolean; + port: number; + allPassing: boolean; +} + +export async function checkGemmaStatus( + port?: number, +): Promise { + const { settingsEnabled, configuredPort } = resolveGemmaConfig(DEFAULT_PORT); + + const effectivePort = port ?? configuredPort; + const binaryPath = getBinaryPath(); + const binaryInstalled = isBinaryInstalled(binaryPath); + const modelDownloaded = + binaryInstalled && binaryPath ? isModelDownloaded(binaryPath) : false; + const serverRunning = await isServerRunning(effectivePort); + const pid = readServerPid(); + const serverPid = pid && isProcessRunning(pid) ? pid : null; + + const allPassing = + binaryInstalled && modelDownloaded && serverRunning && settingsEnabled; + + return { + binaryInstalled, + binaryPath, + modelDownloaded, + serverRunning, + serverPid, + settingsEnabled, + port: effectivePort, + allPassing, + }; +} + +export function formatGemmaStatus(status: GemmaStatusResult): string { + const check = (ok: boolean) => (ok ? chalk.green('✓') : chalk.red('✗')); + + const lines: string[] = [ + '', + chalk.bold('Gemma Local Model Routing Status'), + chalk.dim('─'.repeat(40)), + '', + ]; + + if (status.binaryInstalled) { + lines.push(` Binary: ${check(true)} Installed (${status.binaryPath})`); + } else { + const platform = detectPlatform(); + if (platform) { + lines.push(` Binary: ${check(false)} Not installed`); + lines.push(chalk.dim(` Run: gemini gemma setup`)); + } else { + lines.push( + ` Binary: ${check(false)} Unsupported platform (${process.platform}-${process.arch})`, + ); + } + } + + if (status.modelDownloaded) { + lines.push(` Model: ${check(true)} ${GEMMA_MODEL_NAME} downloaded`); + } else { + lines.push(` Model: ${check(false)} ${GEMMA_MODEL_NAME} not found`); + if (status.binaryInstalled) { + lines.push( + chalk.dim( + ` Run: ${status.binaryPath} pull ${GEMMA_MODEL_NAME}`, + ), + ); + } else { + lines.push(chalk.dim(` Run: gemini gemma setup`)); + } + } + + if (status.serverRunning) { + const pidInfo = status.serverPid ? ` (PID ${status.serverPid})` : ''; + lines.push( + ` Server: ${check(true)} Running on port ${status.port}${pidInfo}`, + ); + } else { + lines.push( + ` Server: ${check(false)} Not running on port ${status.port}`, + ); + lines.push(chalk.dim(` Run: gemini gemma start`)); + } + + if (status.settingsEnabled) { + lines.push(` Settings: ${check(true)} Enabled in settings.json`); + } else { + lines.push(` Settings: ${check(false)} Not enabled in settings.json`); + lines.push( + chalk.dim( + ` Run: gemini gemma setup (auto-configures settings)`, + ), + ); + } + + lines.push(''); + + if (status.allPassing) { + lines.push(chalk.green(' Routing is active — no action needed.')); + lines.push(''); + lines.push( + chalk.dim( + ' Simple requests → Flash (fast) | Complex requests → Pro (powerful)', + ), + ); + lines.push(chalk.dim(' This happens automatically on every request.')); + } else { + lines.push( + chalk.yellow( + ' Some checks failed. Run "gemini gemma setup" for guided installation.', + ), + ); + } + + lines.push(''); + return lines.join('\n'); +} + +export const statusCommand: CommandModule = { + command: 'status', + describe: 'Check Gemma local model routing status', + builder: (yargs) => + yargs.option('port', { + type: 'number', + description: 'Port to check for the LiteRT server', + }), + handler: async (argv) => { + let port: number | undefined; + if (argv['port'] !== undefined) { + port = Number(argv['port']); + } + const status = await checkGemmaStatus(port); + const output = formatGemmaStatus(status); + process.stdout.write(output); + await exitCli(status.allPassing ? 0 : 1); + }, +}; diff --git a/packages/cli/src/commands/gemma/stop.test.ts b/packages/cli/src/commands/gemma/stop.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..64eaf6d5fcd27d710a645c6bb623ba7a04c95dde --- /dev/null +++ b/packages/cli/src/commands/gemma/stop.test.ts @@ -0,0 +1,112 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockGetBinaryPath = vi.hoisted(() => vi.fn()); +const mockIsExpectedLiteRtServerProcess = vi.hoisted(() => vi.fn()); +const mockIsProcessRunning = vi.hoisted(() => vi.fn()); +const mockIsServerRunning = vi.hoisted(() => vi.fn()); +const mockReadServerPid = vi.hoisted(() => vi.fn()); +const mockReadServerProcessInfo = vi.hoisted(() => vi.fn()); +const mockResolveGemmaConfig = vi.hoisted(() => vi.fn()); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const { mockCoreDebugLogger } = await import( + '../../test-utils/mockDebugLogger.js' + ); + return mockCoreDebugLogger( + await importOriginal(), + { + stripAnsi: false, + }, + ); +}); + +vi.mock('./constants.js', () => ({ + DEFAULT_PORT: 9379, + getPidFilePath: vi.fn(() => '/tmp/litert-server.pid'), +})); + +vi.mock('./platform.js', () => ({ + getBinaryPath: mockGetBinaryPath, + isExpectedLiteRtServerProcess: mockIsExpectedLiteRtServerProcess, + isProcessRunning: mockIsProcessRunning, + isServerRunning: mockIsServerRunning, + readServerPid: mockReadServerPid, + readServerProcessInfo: mockReadServerProcessInfo, + resolveGemmaConfig: mockResolveGemmaConfig, +})); + +vi.mock('../utils.js', () => ({ + exitCli: vi.fn(), +})); + +import { stopServer } from './stop.js'; + +describe('gemma stop command', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + mockGetBinaryPath.mockReturnValue('/custom/lit'); + mockResolveGemmaConfig.mockReturnValue({ configuredPort: 9379 }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('refuses to signal a pid that does not match the expected LiteRT server', async () => { + mockReadServerProcessInfo.mockReturnValue({ + pid: 1234, + binaryPath: '/custom/lit', + port: 8123, + }); + mockIsProcessRunning.mockReturnValue(true); + mockIsExpectedLiteRtServerProcess.mockReturnValue(false); + + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true); + + await expect(stopServer(8123)).resolves.toBe('unexpected-process'); + expect(killSpy).not.toHaveBeenCalled(); + }); + + it('stops the verified LiteRT server and removes the pid file', async () => { + mockReadServerProcessInfo.mockReturnValue({ + pid: 1234, + binaryPath: '/custom/lit', + port: 8123, + }); + mockIsProcessRunning.mockReturnValueOnce(true).mockReturnValueOnce(false); + mockIsExpectedLiteRtServerProcess.mockReturnValue(true); + + const unlinkSpy = vi.spyOn(fs, 'unlinkSync').mockImplementation(() => {}); + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true); + + const stopPromise = stopServer(8123); + await vi.runAllTimersAsync(); + + await expect(stopPromise).resolves.toBe('stopped'); + expect(killSpy).toHaveBeenCalledWith(1234, 'SIGTERM'); + expect(unlinkSpy).toHaveBeenCalledWith('/tmp/litert-server.pid'); + }); + + it('cleans up a stale pid file when the recorded process is no longer running', async () => { + mockReadServerProcessInfo.mockReturnValue({ + pid: 1234, + binaryPath: '/custom/lit', + port: 8123, + }); + mockIsProcessRunning.mockReturnValue(false); + + const unlinkSpy = vi.spyOn(fs, 'unlinkSync').mockImplementation(() => {}); + + await expect(stopServer(8123)).resolves.toBe('not-running'); + expect(unlinkSpy).toHaveBeenCalledWith('/tmp/litert-server.pid'); + }); +}); diff --git a/packages/cli/src/commands/gemma/stop.ts b/packages/cli/src/commands/gemma/stop.ts new file mode 100644 index 0000000000000000000000000000000000000000..c51269c579ce9dae069fe17924a96e9ebeebdba4 --- /dev/null +++ b/packages/cli/src/commands/gemma/stop.ts @@ -0,0 +1,155 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import fs from 'node:fs'; +import chalk from 'chalk'; +import { debugLogger } from '@google/gemini-cli-core'; +import { exitCli } from '../utils.js'; +import { DEFAULT_PORT, getPidFilePath } from './constants.js'; +import { + getBinaryPath, + isExpectedLiteRtServerProcess, + isProcessRunning, + isServerRunning, + readServerPid, + readServerProcessInfo, + resolveGemmaConfig, +} from './platform.js'; + +export type StopServerResult = + | 'stopped' + | 'not-running' + | 'unexpected-process' + | 'failed'; + +export async function stopServer( + expectedPort?: number, +): Promise { + const processInfo = readServerProcessInfo(); + const pidPath = getPidFilePath(); + + if (!processInfo) { + return 'not-running'; + } + + const { pid } = processInfo; + if (!isProcessRunning(pid)) { + debugLogger.log( + `Stale PID file found (PID ${pid} is not running), removing ${pidPath}`, + ); + try { + fs.unlinkSync(pidPath); + } catch { + // ignore + } + return 'not-running'; + } + + const binaryPath = processInfo.binaryPath ?? getBinaryPath(); + const port = processInfo.port ?? expectedPort; + if (!isExpectedLiteRtServerProcess(pid, { binaryPath, port })) { + debugLogger.warn( + `Refusing to stop PID ${pid} because it does not match the expected LiteRT server process.`, + ); + return 'unexpected-process'; + } + + try { + process.kill(pid, 'SIGTERM'); + } catch { + return 'failed'; + } + + await new Promise((resolve) => setTimeout(resolve, 1000)); + + if (isProcessRunning(pid)) { + try { + process.kill(pid, 'SIGKILL'); + } catch { + // ignore + } + await new Promise((resolve) => setTimeout(resolve, 500)); + if (isProcessRunning(pid)) { + return 'failed'; + } + } + + try { + fs.unlinkSync(pidPath); + } catch { + // ignore + } + + return 'stopped'; +} + +export const stopCommand: CommandModule = { + command: 'stop', + describe: 'Stop the LiteRT-LM server', + builder: (yargs) => + yargs.option('port', { + type: 'number', + description: 'Port where the LiteRT server is running', + }), + handler: async (argv) => { + let port: number | undefined; + if (argv['port'] !== undefined) { + port = Number(argv['port']); + } + + if (!port) { + const { configuredPort } = resolveGemmaConfig(DEFAULT_PORT); + port = configuredPort; + } + + const processInfo = readServerProcessInfo(); + const pid = processInfo?.pid ?? readServerPid(); + + if (pid !== null && isProcessRunning(pid)) { + debugLogger.log(`Stopping LiteRT server (PID ${pid})...`); + const result = await stopServer(port); + if (result === 'stopped') { + debugLogger.log(chalk.green('LiteRT server stopped.')); + await exitCli(0); + } else if (result === 'unexpected-process') { + debugLogger.error( + chalk.red( + `Refusing to stop PID ${pid} because it does not match the expected LiteRT server process.`, + ), + ); + debugLogger.error( + chalk.dim( + 'Remove the stale pid file after verifying the process, or stop the process manually.', + ), + ); + await exitCli(1); + } else { + debugLogger.error(chalk.red('Failed to stop LiteRT server.')); + await exitCli(1); + } + return; + } + + const running = await isServerRunning(port); + if (running) { + debugLogger.log( + chalk.yellow( + `A server is responding on port ${port}, but it was not started by "gemini gemma start".`, + ), + ); + debugLogger.log( + chalk.dim( + 'If you started it manually, stop it from the terminal where it is running.', + ), + ); + await exitCli(1); + } else { + debugLogger.log('No LiteRT server is currently running.'); + await exitCli(0); + } + }, +}; diff --git a/packages/cli/src/commands/hooks.tsx b/packages/cli/src/commands/hooks.tsx new file mode 100644 index 0000000000000000000000000000000000000000..fdb4594d0413063a913c1bd04028ca029dcdfe8e --- /dev/null +++ b/packages/cli/src/commands/hooks.tsx @@ -0,0 +1,28 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import { migrateCommand } from './hooks/migrate.js'; +import { initializeOutputListenersAndFlush } from '../gemini.js'; + +export const hooksCommand: CommandModule = { + command: 'hooks ', + aliases: ['hook'], + describe: 'Manage Gemini CLI hooks.', + builder: (yargs) => + yargs + .middleware((argv) => { + initializeOutputListenersAndFlush(); + argv['isCommand'] = true; + }) + .command(migrateCommand) + .demandCommand(1, 'You need at least one command before continuing.') + .version(false), + handler: () => { + // This handler is not called when a subcommand is provided. + // Yargs will show the help menu. + }, +}; diff --git a/packages/cli/src/commands/hooks/migrate.test.ts b/packages/cli/src/commands/hooks/migrate.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..847d7bc4673e07ade544198215a2e27e125dfe32 --- /dev/null +++ b/packages/cli/src/commands/hooks/migrate.test.ts @@ -0,0 +1,515 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, + type Mock, + type MockInstance, +} from 'vitest'; +import * as fs from 'node:fs'; +import { loadSettings, SettingScope } from '../../config/settings.js'; +import { debugLogger } from '@google/gemini-cli-core'; +import { handleMigrateFromClaude } from './migrate.js'; + +vi.mock('node:fs'); +vi.mock('../utils.js', () => ({ + exitCli: vi.fn(), +})); + +vi.mock('../../config/settings.js', async () => { + const actual = await vi.importActual('../../config/settings.js'); + return { + ...actual, + loadSettings: vi.fn(), + }; +}); + +const mockedLoadSettings = loadSettings as Mock; +const mockedFs = vi.mocked(fs); + +describe('migrate command', () => { + let mockSetValue: Mock; + let debugLoggerLogSpy: MockInstance; + let debugLoggerErrorSpy: MockInstance; + let originalCwd: () => string; + + beforeEach(() => { + vi.resetAllMocks(); + + mockSetValue = vi.fn(); + debugLoggerLogSpy = vi + .spyOn(debugLogger, 'log') + .mockImplementation(() => {}); + debugLoggerErrorSpy = vi + .spyOn(debugLogger, 'error') + .mockImplementation(() => {}); + + // Mock process.cwd() + originalCwd = process.cwd; + process.cwd = vi.fn(() => '/test/project'); + + mockedLoadSettings.mockReturnValue({ + merged: { + hooks: {}, + }, + setValue: mockSetValue, + workspace: { path: '/test/project/.gemini' }, + }); + }); + + afterEach(() => { + process.cwd = originalCwd; + vi.restoreAllMocks(); + }); + + it('should log error when no Claude settings files exist', async () => { + mockedFs.existsSync.mockReturnValue(false); + + await handleMigrateFromClaude(); + + expect(debugLoggerErrorSpy).toHaveBeenCalledWith( + 'No Claude Code settings found in .claude directory. Expected settings.json or settings.local.json', + ); + expect(mockSetValue).not.toHaveBeenCalled(); + }); + + it('should migrate hooks from settings.json when it exists', async () => { + const claudeSettings = { + hooks: { + PreToolUse: [ + { + matcher: 'Edit', + hooks: [ + { + type: 'command', + command: 'echo "Before Edit"', + timeout: 30, + }, + ], + }, + ], + }, + }; + + mockedFs.existsSync.mockImplementation((path) => + path.toString().endsWith('settings.json'), + ); + + mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings)); + + await handleMigrateFromClaude(); + + expect(mockSetValue).toHaveBeenCalledWith( + SettingScope.Workspace, + 'hooks', + expect.objectContaining({ + BeforeTool: expect.arrayContaining([ + expect.objectContaining({ + matcher: 'replace', + hooks: expect.arrayContaining([ + expect.objectContaining({ + command: 'echo "Before Edit"', + type: 'command', + timeout: 30, + }), + ]), + }), + ]), + }), + ); + + expect(debugLoggerLogSpy).toHaveBeenCalledWith( + expect.stringContaining('Found Claude Code settings'), + ); + expect(debugLoggerLogSpy).toHaveBeenCalledWith( + expect.stringContaining('Migrating 1 hook event'), + ); + expect(debugLoggerLogSpy).toHaveBeenCalledWith( + '✓ Hooks successfully migrated to .gemini/settings.json', + ); + }); + + it('should prefer settings.local.json over settings.json', async () => { + const localSettings = { + hooks: { + SessionStart: [ + { + hooks: [ + { + type: 'command', + command: 'echo "Local session start"', + }, + ], + }, + ], + }, + }; + + mockedFs.existsSync.mockReturnValue(true); + mockedFs.readFileSync.mockReturnValue(JSON.stringify(localSettings)); + + await handleMigrateFromClaude(); + + expect(mockedFs.readFileSync).toHaveBeenCalledWith( + expect.stringContaining('settings.local.json'), + 'utf-8', + ); + expect(mockSetValue).toHaveBeenCalledWith( + SettingScope.Workspace, + 'hooks', + expect.objectContaining({ + SessionStart: expect.any(Array), + }), + ); + }); + + it('should migrate all supported event types', async () => { + const claudeSettings = { + hooks: { + PreToolUse: [{ hooks: [{ type: 'command', command: 'echo 1' }] }], + PostToolUse: [{ hooks: [{ type: 'command', command: 'echo 2' }] }], + UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'echo 3' }] }], + Stop: [{ hooks: [{ type: 'command', command: 'echo 4' }] }], + SubAgentStop: [{ hooks: [{ type: 'command', command: 'echo 5' }] }], + SessionStart: [{ hooks: [{ type: 'command', command: 'echo 6' }] }], + SessionEnd: [{ hooks: [{ type: 'command', command: 'echo 7' }] }], + PreCompact: [{ hooks: [{ type: 'command', command: 'echo 8' }] }], + Notification: [{ hooks: [{ type: 'command', command: 'echo 9' }] }], + }, + }; + + mockedFs.existsSync.mockReturnValue(true); + mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings)); + + await handleMigrateFromClaude(); + + const migratedHooks = mockSetValue.mock.calls[0][2]; + + expect(migratedHooks).toHaveProperty('BeforeTool'); + expect(migratedHooks).toHaveProperty('AfterTool'); + expect(migratedHooks).toHaveProperty('BeforeAgent'); + expect(migratedHooks).toHaveProperty('AfterAgent'); + expect(migratedHooks).toHaveProperty('SessionStart'); + expect(migratedHooks).toHaveProperty('SessionEnd'); + expect(migratedHooks).toHaveProperty('PreCompress'); + expect(migratedHooks).toHaveProperty('Notification'); + }); + + it('should transform tool names in matchers', async () => { + const claudeSettings = { + hooks: { + PreToolUse: [ + { + matcher: 'Edit|Bash|Read|Write|Glob|Grep', + hooks: [{ type: 'command', command: 'echo "test"' }], + }, + ], + }, + }; + + mockedFs.existsSync.mockReturnValue(true); + mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings)); + + await handleMigrateFromClaude(); + + const migratedHooks = mockSetValue.mock.calls[0][2]; + expect(migratedHooks.BeforeTool[0].matcher).toBe( + 'replace|run_shell_command|read_file|write_file|glob|grep', + ); + }); + + it('should replace $CLAUDE_PROJECT_DIR with $GEMINI_PROJECT_DIR', async () => { + const claudeSettings = { + hooks: { + PreToolUse: [ + { + hooks: [ + { + type: 'command', + command: 'cd $CLAUDE_PROJECT_DIR && ls', + }, + ], + }, + ], + }, + }; + + mockedFs.existsSync.mockReturnValue(true); + mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings)); + + await handleMigrateFromClaude(); + + const migratedHooks = mockSetValue.mock.calls[0][2]; + expect(migratedHooks.BeforeTool[0].hooks[0].command).toBe( + 'cd $GEMINI_PROJECT_DIR && ls', + ); + }); + + it('should preserve sequential flag', async () => { + const claudeSettings = { + hooks: { + PreToolUse: [ + { + sequential: true, + hooks: [{ type: 'command', command: 'echo "test"' }], + }, + ], + }, + }; + + mockedFs.existsSync.mockReturnValue(true); + mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings)); + + await handleMigrateFromClaude(); + + const migratedHooks = mockSetValue.mock.calls[0][2]; + expect(migratedHooks.BeforeTool[0].sequential).toBe(true); + }); + + it('should preserve timeout values', async () => { + const claudeSettings = { + hooks: { + PreToolUse: [ + { + hooks: [ + { + type: 'command', + command: 'echo "test"', + timeout: 60, + }, + ], + }, + ], + }, + }; + + mockedFs.existsSync.mockReturnValue(true); + mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings)); + + await handleMigrateFromClaude(); + + const migratedHooks = mockSetValue.mock.calls[0][2]; + expect(migratedHooks.BeforeTool[0].hooks[0].timeout).toBe(60); + }); + + it('should merge with existing Gemini hooks', async () => { + const claudeSettings = { + hooks: { + PreToolUse: [ + { + hooks: [{ type: 'command', command: 'echo "claude"' }], + }, + ], + }, + }; + + mockedLoadSettings.mockReturnValue({ + merged: { + hooks: { + AfterTool: [ + { + hooks: [{ type: 'command', command: 'echo "existing"' }], + }, + ], + }, + }, + setValue: mockSetValue, + workspace: { path: '/test/project/.gemini' }, + }); + + mockedFs.existsSync.mockReturnValue(true); + mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings)); + + await handleMigrateFromClaude(); + + const migratedHooks = mockSetValue.mock.calls[0][2]; + expect(migratedHooks).toHaveProperty('BeforeTool'); + expect(migratedHooks).toHaveProperty('AfterTool'); + expect(migratedHooks.AfterTool[0].hooks[0].command).toBe('echo "existing"'); + expect(migratedHooks.BeforeTool[0].hooks[0].command).toBe('echo "claude"'); + }); + + it('should handle JSON with comments', async () => { + const claudeSettingsWithComments = `{ + // This is a comment + "hooks": { + /* Block comment */ + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "echo test" // Inline comment + } + ] + } + ] + } + }`; + + mockedFs.existsSync.mockReturnValue(true); + mockedFs.readFileSync.mockReturnValue(claudeSettingsWithComments); + + await handleMigrateFromClaude(); + + expect(mockSetValue).toHaveBeenCalledWith( + SettingScope.Workspace, + 'hooks', + expect.objectContaining({ + BeforeTool: expect.any(Array), + }), + ); + }); + + it('should handle malformed JSON gracefully', async () => { + mockedFs.existsSync.mockReturnValue(true); + mockedFs.readFileSync.mockReturnValue('{ invalid json }'); + + await handleMigrateFromClaude(); + + expect(debugLoggerErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Error reading'), + ); + expect(mockSetValue).not.toHaveBeenCalled(); + }); + + it('should log info when no hooks are found in Claude settings', async () => { + const claudeSettings = { + someOtherSetting: 'value', + }; + + mockedFs.existsSync.mockReturnValue(true); + mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings)); + + await handleMigrateFromClaude(); + + expect(debugLoggerLogSpy).toHaveBeenCalledWith( + 'No hooks found in Claude Code settings to migrate.', + ); + expect(mockSetValue).not.toHaveBeenCalled(); + }); + + it('should handle setValue errors gracefully', async () => { + const claudeSettings = { + hooks: { + PreToolUse: [ + { + hooks: [{ type: 'command', command: 'echo "test"' }], + }, + ], + }, + }; + + mockedFs.existsSync.mockReturnValue(true); + mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings)); + mockSetValue.mockImplementation(() => { + throw new Error('Failed to save'); + }); + + await handleMigrateFromClaude(); + + expect(debugLoggerErrorSpy).toHaveBeenCalledWith( + 'Error saving migrated hooks: Failed to save', + ); + }); + + it('should handle hooks with matcher but no command', async () => { + const claudeSettings = { + hooks: { + PreToolUse: [ + { + matcher: 'Edit', + hooks: [ + { + type: 'command', + }, + ], + }, + ], + }, + }; + + mockedFs.existsSync.mockReturnValue(true); + mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings)); + + await handleMigrateFromClaude(); + + const migratedHooks = mockSetValue.mock.calls[0][2]; + expect(migratedHooks.BeforeTool[0].matcher).toBe('replace'); + expect(migratedHooks.BeforeTool[0].hooks[0].type).toBe('command'); + }); + + it('should handle empty hooks array', async () => { + const claudeSettings = { + hooks: { + PreToolUse: [ + { + hooks: [], + }, + ], + }, + }; + + mockedFs.existsSync.mockReturnValue(true); + mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings)); + + await handleMigrateFromClaude(); + + const migratedHooks = mockSetValue.mock.calls[0][2]; + expect(migratedHooks.BeforeTool[0].hooks).toEqual([]); + }); + + it('should handle non-array event config gracefully', async () => { + const claudeSettings = { + hooks: { + PreToolUse: 'not an array', + PostToolUse: [ + { + hooks: [{ type: 'command', command: 'echo "test"' }], + }, + ], + }, + }; + + mockedFs.existsSync.mockReturnValue(true); + mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings)); + + await handleMigrateFromClaude(); + + const migratedHooks = mockSetValue.mock.calls[0][2]; + expect(migratedHooks).not.toHaveProperty('BeforeTool'); + expect(migratedHooks).toHaveProperty('AfterTool'); + }); + + it('should display migration instructions after successful migration', async () => { + const claudeSettings = { + hooks: { + PreToolUse: [ + { + hooks: [{ type: 'command', command: 'echo "test"' }], + }, + ], + }, + }; + + mockedFs.existsSync.mockReturnValue(true); + mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings)); + + await handleMigrateFromClaude(); + + expect(debugLoggerLogSpy).toHaveBeenCalledWith( + '✓ Hooks successfully migrated to .gemini/settings.json', + ); + expect(debugLoggerLogSpy).toHaveBeenCalledWith( + '\nMigration complete! Please review the migrated hooks in .gemini/settings.json', + ); + }); +}); diff --git a/packages/cli/src/commands/hooks/migrate.ts b/packages/cli/src/commands/hooks/migrate.ts new file mode 100644 index 0000000000000000000000000000000000000000..36bb2cf9aa56238f609ff31b51cc0075b942dafb --- /dev/null +++ b/packages/cli/src/commands/hooks/migrate.ts @@ -0,0 +1,282 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { debugLogger, getErrorMessage } from '@google/gemini-cli-core'; +import { loadSettings, SettingScope } from '../../config/settings.js'; +import { exitCli } from '../utils.js'; +import stripJsonComments from 'strip-json-comments'; + +interface MigrateArgs { + fromClaude: boolean; +} + +/** + * Mapping from Claude Code event names to Gemini event names + */ +const EVENT_MAPPING: Record = { + PreToolUse: 'BeforeTool', + PostToolUse: 'AfterTool', + UserPromptSubmit: 'BeforeAgent', + Stop: 'AfterAgent', + SubAgentStop: 'AfterAgent', // Gemini doesn't have sub-agents, map to AfterAgent + SessionStart: 'SessionStart', + SessionEnd: 'SessionEnd', + PreCompact: 'PreCompress', + Notification: 'Notification', +}; + +/** + * Mapping from Claude Code tool names to Gemini tool names + */ +const TOOL_NAME_MAPPING: Record = { + Edit: 'replace', + Bash: 'run_shell_command', + Read: 'read_file', + Write: 'write_file', + Glob: 'glob', + Grep: 'grep', + LS: 'ls', +}; + +/** + * Transform a matcher regex to update tool names from Claude to Gemini + */ +function transformMatcher(matcher: string | undefined): string | undefined { + if (!matcher) return matcher; + + let transformed = matcher; + for (const [claudeName, geminiName] of Object.entries(TOOL_NAME_MAPPING)) { + // Replace exact matches and matches within regex alternations + transformed = transformed.replace( + new RegExp(`\\b${claudeName}\\b`, 'g'), + geminiName, + ); + } + + return transformed; +} + +/** + * Migrate a Claude Code hook configuration to Gemini format + */ +function migrateClaudeHook(claudeHook: unknown): unknown { + if (!claudeHook || typeof claudeHook !== 'object') { + return claudeHook; + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const hook = claudeHook as Record; + const migrated: Record = {}; + + // Map command field + if ('command' in hook) { + migrated['command'] = hook['command']; + + // Replace CLAUDE_PROJECT_DIR with GEMINI_PROJECT_DIR in command + // eslint-disable-next-line no-restricted-syntax + if (typeof migrated['command'] === 'string') { + migrated['command'] = migrated['command'].replace( + /\$CLAUDE_PROJECT_DIR/g, + '$GEMINI_PROJECT_DIR', + ); + } + } + + // Map type field + if ('type' in hook && hook['type'] === 'command') { + migrated['type'] = 'command'; + } + + // Map timeout field (Claude uses seconds, Gemini uses seconds) + // eslint-disable-next-line no-restricted-syntax + if ('timeout' in hook && typeof hook['timeout'] === 'number') { + migrated['timeout'] = hook['timeout']; + } + + return migrated; +} + +/** + * Migrate Claude Code hooks configuration to Gemini format + */ +function migrateClaudeHooks(claudeConfig: unknown): Record { + if (!claudeConfig || typeof claudeConfig !== 'object') { + return {}; + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const config = claudeConfig as Record; + const geminiHooks: Record = {}; + + // Check if there's a hooks section + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const hooksSection = config['hooks'] as Record | undefined; + if (!hooksSection || typeof hooksSection !== 'object') { + return {}; + } + + for (const [eventName, eventConfig] of Object.entries(hooksSection)) { + // Map event name + const geminiEventName = EVENT_MAPPING[eventName] || eventName; + + if (!Array.isArray(eventConfig)) { + continue; + } + + // Migrate each hook definition + const migratedDefinitions = eventConfig.map((def: unknown) => { + if (!def || typeof def !== 'object') { + return def; + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const definition = def as Record; + const migratedDef: Record = {}; + + // Transform matcher + if ( + 'matcher' in definition && + // eslint-disable-next-line no-restricted-syntax + typeof definition['matcher'] === 'string' + ) { + migratedDef['matcher'] = transformMatcher(definition['matcher']); + } + + // Copy sequential flag + if ('sequential' in definition) { + migratedDef['sequential'] = definition['sequential']; + } + + // Migrate hooks array + if ('hooks' in definition && Array.isArray(definition['hooks'])) { + migratedDef['hooks'] = definition['hooks'].map(migrateClaudeHook); + } + + return migratedDef; + }); + + geminiHooks[geminiEventName] = migratedDefinitions; + } + + return geminiHooks; +} + +/** + * Handle migration from Claude Code + */ +export async function handleMigrateFromClaude() { + const workingDir = process.cwd(); + + // Look for Claude settings in .claude directory + const claudeDir = path.join(workingDir, '.claude'); + const claudeSettingsPath = path.join(claudeDir, 'settings.json'); + const claudeLocalSettingsPath = path.join(claudeDir, 'settings.local.json'); + + let claudeSettings: Record | null = null; + let sourceFile = ''; + + // Try to read settings.local.json first, then settings.json + if (fs.existsSync(claudeLocalSettingsPath)) { + sourceFile = claudeLocalSettingsPath; + try { + const content = fs.readFileSync(claudeLocalSettingsPath, 'utf-8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + claudeSettings = JSON.parse(stripJsonComments(content)) as Record< + string, + unknown + >; + } catch (error) { + debugLogger.error( + `Error reading ${claudeLocalSettingsPath}: ${getErrorMessage(error)}`, + ); + } + } else if (fs.existsSync(claudeSettingsPath)) { + sourceFile = claudeSettingsPath; + try { + const content = fs.readFileSync(claudeSettingsPath, 'utf-8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + claudeSettings = JSON.parse(stripJsonComments(content)) as Record< + string, + unknown + >; + } catch (error) { + debugLogger.error( + `Error reading ${claudeSettingsPath}: ${getErrorMessage(error)}`, + ); + } + } else { + debugLogger.error( + 'No Claude Code settings found in .claude directory. Expected settings.json or settings.local.json', + ); + return; + } + + if (!claudeSettings) { + return; + } + + debugLogger.log(`Found Claude Code settings in: ${sourceFile}`); + + // Migrate hooks + const migratedHooks = migrateClaudeHooks(claudeSettings); + + if (Object.keys(migratedHooks).length === 0) { + debugLogger.log('No hooks found in Claude Code settings to migrate.'); + return; + } + + debugLogger.log( + `Migrating ${Object.keys(migratedHooks).length} hook event(s)...`, + ); + + // Load current Gemini settings + const settings = loadSettings(workingDir); + + // Merge migrated hooks with existing hooks + const existingHooks = (settings.merged?.hooks || {}) as Record< + string, + unknown + >; + const mergedHooks = { ...existingHooks, ...migratedHooks }; + + // Update settings (setValue automatically saves) + try { + settings.setValue(SettingScope.Workspace, 'hooks', mergedHooks); + + debugLogger.log('✓ Hooks successfully migrated to .gemini/settings.json'); + debugLogger.log( + '\nMigration complete! Please review the migrated hooks in .gemini/settings.json', + ); + } catch (error) { + debugLogger.error(`Error saving migrated hooks: ${getErrorMessage(error)}`); + } +} + +export const migrateCommand: CommandModule = { + command: 'migrate', + describe: 'Migrate hooks from Claude Code to Gemini CLI', + builder: (yargs) => + yargs.option('from-claude', { + describe: 'Migrate from Claude Code hooks', + type: 'boolean', + default: false, + }), + handler: async (argv) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const args = argv as unknown as MigrateArgs; + if (args.fromClaude) { + await handleMigrateFromClaude(); + } else { + debugLogger.log( + 'Usage: gemini hooks migrate --from-claude\n\nMigrate hooks from Claude Code to Gemini CLI format.', + ); + } + await exitCli(); + }, +}; diff --git a/packages/cli/src/commands/mcp.test.ts b/packages/cli/src/commands/mcp.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d10898935b5c76907da931892b3e4c5d5046b430 --- /dev/null +++ b/packages/cli/src/commands/mcp.test.ts @@ -0,0 +1,80 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi } from 'vitest'; +import { mcpCommand } from './mcp.js'; +import yargs, { type Argv } from 'yargs'; + +describe('mcp command', () => { + it('should have correct command definition', () => { + expect(mcpCommand.command).toBe('mcp'); + expect(mcpCommand.describe).toBe('Manage MCP servers'); + expect(typeof mcpCommand.builder).toBe('function'); + expect(typeof mcpCommand.handler).toBe('function'); + }); + + it('should show help when no subcommand is provided', async () => { + const yargsInstance = yargs().locale('en'); + (mcpCommand.builder as (y: Argv) => Argv)(yargsInstance); + + const parser = yargsInstance.command(mcpCommand).help(); + + // Mock console.log and console.error to catch help output + const consoleLogMock = vi + .spyOn(console, 'log') + .mockImplementation(() => {}); + const consoleErrorMock = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + try { + await parser.parse('mcp'); + } catch { + // yargs might throw an error when demandCommand is not met + } + + // Check if help output is shown + const helpOutput = + consoleLogMock.mock.calls.join('\n') + + consoleErrorMock.mock.calls.join('\n'); + expect(helpOutput).toContain('Manage MCP servers'); + expect(helpOutput).toContain('Commands:'); + expect(helpOutput).toContain('add'); + expect(helpOutput).toContain('remove'); + expect(helpOutput).toContain('list'); + + consoleLogMock.mockRestore(); + consoleErrorMock.mockRestore(); + }); + + it('should register add, remove, and list subcommands', () => { + const mockYargs = { + command: vi.fn().mockReturnThis(), + demandCommand: vi.fn().mockReturnThis(), + version: vi.fn().mockReturnThis(), + middleware: vi.fn().mockReturnThis(), + }; + + (mcpCommand.builder as (y: Argv) => Argv)(mockYargs as unknown as Argv); + + expect(mockYargs.command).toHaveBeenCalledTimes(5); + + // Verify that the specific subcommands are registered + const commandCalls = mockYargs.command.mock.calls; + const commandNames = commandCalls.map((call) => call[0].command); + + expect(commandNames).toContain('add [args...]'); + expect(commandNames).toContain('remove '); + expect(commandNames).toContain('list'); + expect(commandNames).toContain('enable '); + expect(commandNames).toContain('disable '); + + expect(mockYargs.demandCommand).toHaveBeenCalledWith( + 1, + 'You need at least one command before continuing.', + ); + }); +}); diff --git a/packages/cli/src/commands/mcp.ts b/packages/cli/src/commands/mcp.ts new file mode 100644 index 0000000000000000000000000000000000000000..d2b7f85f034b743a3e974623ccc3547c0846cc98 --- /dev/null +++ b/packages/cli/src/commands/mcp.ts @@ -0,0 +1,36 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// File for 'gemini mcp' command +import type { CommandModule, Argv } from 'yargs'; +import { addCommand } from './mcp/add.js'; +import { removeCommand } from './mcp/remove.js'; +import { listCommand } from './mcp/list.js'; +import { enableCommand, disableCommand } from './mcp/enableDisable.js'; +import { initializeOutputListenersAndFlush } from '../gemini.js'; +import { defer } from '../deferred.js'; + +export const mcpCommand: CommandModule = { + command: 'mcp', + describe: 'Manage MCP servers', + builder: (yargs: Argv) => + yargs + .middleware((argv) => { + initializeOutputListenersAndFlush(); + argv['isCommand'] = true; + }) + .command(defer(addCommand, 'mcp')) + .command(defer(removeCommand, 'mcp')) + .command(defer(listCommand, 'mcp')) + .command(defer(enableCommand, 'mcp')) + .command(defer(disableCommand, 'mcp')) + .demandCommand(1, 'You need at least one command before continuing.') + .version(false), + handler: () => { + // yargs will automatically show help if no subcommand is provided + // thanks to demandCommand(1) in the builder. + }, +}; diff --git a/packages/cli/src/commands/mcp/add.test.ts b/packages/cli/src/commands/mcp/add.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..e6f955b680dd4556b9b08f36d5bd92f74ef84afe --- /dev/null +++ b/packages/cli/src/commands/mcp/add.test.ts @@ -0,0 +1,421 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + describe, + it, + expect, + vi, + beforeEach, + type Mock, + type MockInstance, +} from 'vitest'; +import yargs, { type Argv } from 'yargs'; +import { addCommand } from './add.js'; +import { loadSettings, SettingScope } from '../../config/settings.js'; +import { debugLogger } from '@google/gemini-cli-core'; + +vi.mock('../utils.js', () => ({ + exitCli: vi.fn(), +})); + +vi.mock('fs/promises', () => ({ + readFile: vi.fn(), + writeFile: vi.fn(), +})); + +vi.mock('os', () => { + const homedir = vi.fn(() => '/home/user'); + return { + default: { + homedir, + }, + homedir, + }; +}); + +vi.mock('../../config/settings.js', async () => { + const actual = await vi.importActual('../../config/settings.js'); + return { + ...actual, + loadSettings: vi.fn(), + }; +}); + +const mockedLoadSettings = loadSettings as Mock; + +describe('mcp add command', () => { + let parser: Argv; + let mockSetValue: Mock; + let mockConsoleError: Mock; + let debugLoggerErrorSpy: MockInstance; + + beforeEach(() => { + vi.resetAllMocks(); + const yargsInstance = yargs([]).command(addCommand); + parser = yargsInstance; + mockSetValue = vi.fn(); + mockConsoleError = vi.fn(); + debugLoggerErrorSpy = vi + .spyOn(debugLogger, 'error') + .mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(mockConsoleError); + mockedLoadSettings.mockReturnValue({ + forScope: () => ({ settings: {} }), + setValue: mockSetValue, + workspace: { path: '/path/to/project' }, + user: { path: '/home/user' }, + }); + }); + + it('should add a stdio server to project settings', async () => { + await parser.parseAsync( + 'add -e FOO=bar my-server /path/to/server arg1 arg2', + ); + + expect(mockSetValue).toHaveBeenCalledWith( + SettingScope.Workspace, + 'mcpServers', + { + 'my-server': { + command: '/path/to/server', + args: ['arg1', 'arg2'], + env: { FOO: 'bar' }, + }, + }, + ); + }); + + it('should handle multiple env vars before positional args', async () => { + await parser.parseAsync( + 'add -e FOO=bar -e BAZ=qux my-server /path/to/server', + ); + + expect(mockSetValue).toHaveBeenCalledWith( + SettingScope.Workspace, + 'mcpServers', + { + 'my-server': { + command: '/path/to/server', + args: [], + env: { FOO: 'bar', BAZ: 'qux' }, + }, + }, + ); + }); + + it('should add an sse server to user settings', async () => { + await parser.parseAsync( + 'add --transport sse --scope user -H "X-API-Key: your-key" sse-server https://example.com/sse-endpoint', + ); + + expect(mockSetValue).toHaveBeenCalledWith(SettingScope.User, 'mcpServers', { + 'sse-server': { + url: 'https://example.com/sse-endpoint', + type: 'sse', + headers: { 'X-API-Key': 'your-key' }, + }, + }); + }); + + it('should add an http server to project settings', async () => { + await parser.parseAsync( + 'add --transport http -H "Authorization: Bearer your-token" http-server https://example.com/mcp', + ); + + expect(mockSetValue).toHaveBeenCalledWith( + SettingScope.Workspace, + 'mcpServers', + { + 'http-server': { + url: 'https://example.com/mcp', + type: 'http', + headers: { Authorization: 'Bearer your-token' }, + }, + }, + ); + }); + + it('should add an sse server using --type alias', async () => { + await parser.parseAsync( + 'add --type sse --scope user -H "X-API-Key: your-key" sse-server https://example.com/sse', + ); + + expect(mockSetValue).toHaveBeenCalledWith(SettingScope.User, 'mcpServers', { + 'sse-server': { + url: 'https://example.com/sse', + type: 'sse', + headers: { 'X-API-Key': 'your-key' }, + }, + }); + }); + + it('should add an http server using --type alias', async () => { + await parser.parseAsync( + 'add --type http -H "Authorization: Bearer your-token" http-server https://example.com/mcp', + ); + + expect(mockSetValue).toHaveBeenCalledWith( + SettingScope.Workspace, + 'mcpServers', + { + 'http-server': { + url: 'https://example.com/mcp', + type: 'http', + headers: { Authorization: 'Bearer your-token' }, + }, + }, + ); + }); + + it('should handle MCP server args with -- separator', async () => { + await parser.parseAsync( + 'add my-server npx -- -y http://example.com/some-package', + ); + + expect(mockSetValue).toHaveBeenCalledWith( + SettingScope.Workspace, + 'mcpServers', + { + 'my-server': { + command: 'npx', + args: ['-y', 'http://example.com/some-package'], + }, + }, + ); + }); + + it('should handle unknown options as MCP server args', async () => { + await parser.parseAsync( + 'add test-server npx -y http://example.com/some-package', + ); + + expect(mockSetValue).toHaveBeenCalledWith( + SettingScope.Workspace, + 'mcpServers', + { + 'test-server': { + command: 'npx', + args: ['-y', 'http://example.com/some-package'], + }, + }, + ); + }); + + describe('when handling scope and directory', () => { + const serverName = 'test-server'; + const command = 'echo'; + + const setupMocks = (cwd: string, workspacePath: string) => { + vi.spyOn(process, 'cwd').mockReturnValue(cwd); + mockedLoadSettings.mockReturnValue({ + forScope: () => ({ settings: {} }), + setValue: mockSetValue, + workspace: { path: workspacePath }, + user: { path: '/home/user' }, + }); + }; + + describe('when in a project directory', () => { + beforeEach(() => { + setupMocks('/path/to/project', '/path/to/project'); + }); + + it('should use project scope by default', async () => { + await parser.parseAsync(`add ${serverName} ${command}`); + expect(mockSetValue).toHaveBeenCalledWith( + SettingScope.Workspace, + 'mcpServers', + expect.any(Object), + ); + }); + + it('should use project scope when --scope=project is used', async () => { + await parser.parseAsync(`add --scope project ${serverName} ${command}`); + expect(mockSetValue).toHaveBeenCalledWith( + SettingScope.Workspace, + 'mcpServers', + expect.any(Object), + ); + }); + + it('should use user scope when --scope=user is used', async () => { + await parser.parseAsync(`add --scope user ${serverName} ${command}`); + expect(mockSetValue).toHaveBeenCalledWith( + SettingScope.User, + 'mcpServers', + expect.any(Object), + ); + }); + }); + + describe('when in a subdirectory of a project', () => { + beforeEach(() => { + setupMocks('/path/to/project/subdir', '/path/to/project'); + }); + + it('should use project scope by default', async () => { + await parser.parseAsync(`add ${serverName} ${command}`); + expect(mockSetValue).toHaveBeenCalledWith( + SettingScope.Workspace, + 'mcpServers', + expect.any(Object), + ); + }); + }); + + describe('when in the home directory', () => { + beforeEach(() => { + setupMocks('/home/user', '/home/user'); + }); + + it('should show an error by default', async () => { + const mockProcessExit = vi + .spyOn(process, 'exit') + .mockImplementation((() => { + throw new Error('process.exit called'); + }) as (code?: number | string | null) => never); + + await expect( + parser.parseAsync(`add ${serverName} ${command}`), + ).rejects.toThrow('process.exit called'); + + expect(debugLoggerErrorSpy).toHaveBeenCalledWith( + 'Error: Please use --scope user to edit settings in the home directory.', + ); + expect(mockProcessExit).toHaveBeenCalledWith(1); + expect(mockSetValue).not.toHaveBeenCalled(); + }); + + it('should show an error when --scope=project is used explicitly', async () => { + const mockProcessExit = vi + .spyOn(process, 'exit') + .mockImplementation((() => { + throw new Error('process.exit called'); + }) as (code?: number | string | null) => never); + + await expect( + parser.parseAsync(`add --scope project ${serverName} ${command}`), + ).rejects.toThrow('process.exit called'); + + expect(debugLoggerErrorSpy).toHaveBeenCalledWith( + 'Error: Please use --scope user to edit settings in the home directory.', + ); + expect(mockProcessExit).toHaveBeenCalledWith(1); + expect(mockSetValue).not.toHaveBeenCalled(); + }); + + it('should use user scope when --scope=user is used', async () => { + await parser.parseAsync(`add --scope user ${serverName} ${command}`); + expect(mockSetValue).toHaveBeenCalledWith( + SettingScope.User, + 'mcpServers', + expect.any(Object), + ); + expect(debugLoggerErrorSpy).not.toHaveBeenCalled(); + }); + }); + + describe('when in a subdirectory of home (not a project)', () => { + beforeEach(() => { + setupMocks('/home/user/some/dir', '/home/user/some/dir'); + }); + + it('should use project scope by default', async () => { + await parser.parseAsync(`add ${serverName} ${command}`); + expect(mockSetValue).toHaveBeenCalledWith( + SettingScope.Workspace, + 'mcpServers', + expect.any(Object), + ); + }); + + it('should write to the WORKSPACE scope, not the USER scope', async () => { + await parser.parseAsync(`add my-new-server echo`); + + // We expect setValue to be called once. + expect(mockSetValue).toHaveBeenCalledTimes(1); + + // We get the scope that setValue was called with. + const calledScope = mockSetValue.mock.calls[0][0]; + + // We assert that the scope was Workspace, not User. + expect(calledScope).toBe(SettingScope.Workspace); + }); + }); + + describe('when outside of home (not a project)', () => { + beforeEach(() => { + setupMocks('/tmp/foo', '/tmp/foo'); + }); + + it('should use project scope by default', async () => { + await parser.parseAsync(`add ${serverName} ${command}`); + expect(mockSetValue).toHaveBeenCalledWith( + SettingScope.Workspace, + 'mcpServers', + expect.any(Object), + ); + }); + }); + }); + + describe('when updating an existing server', () => { + const serverName = 'existing-server'; + const initialCommand = 'echo old'; + const updatedCommand = 'echo'; + const updatedArgs = ['new']; + + beforeEach(() => { + mockedLoadSettings.mockReturnValue({ + forScope: () => ({ + settings: { + mcpServers: { + [serverName]: { + command: initialCommand, + }, + }, + }, + }), + setValue: mockSetValue, + workspace: { path: '/path/to/project' }, + user: { path: '/home/user' }, + }); + }); + + it('should update the existing server in the project scope', async () => { + await parser.parseAsync( + `add ${serverName} ${updatedCommand} ${updatedArgs.join(' ')}`, + ); + expect(mockSetValue).toHaveBeenCalledWith( + SettingScope.Workspace, + 'mcpServers', + expect.objectContaining({ + [serverName]: expect.objectContaining({ + command: updatedCommand, + args: updatedArgs, + }), + }), + ); + }); + + it('should update the existing server in the user scope', async () => { + await parser.parseAsync( + `add --scope user ${serverName} ${updatedCommand} ${updatedArgs.join(' ')}`, + ); + expect(mockSetValue).toHaveBeenCalledWith( + SettingScope.User, + 'mcpServers', + expect.objectContaining({ + [serverName]: expect.objectContaining({ + command: updatedCommand, + args: updatedArgs, + }), + }), + ); + }); + }); +}); diff --git a/packages/cli/src/commands/mcp/add.ts b/packages/cli/src/commands/mcp/add.ts new file mode 100644 index 0000000000000000000000000000000000000000..98e6a708797001b7e9993acb5cd2f96eb2177a0f --- /dev/null +++ b/packages/cli/src/commands/mcp/add.ts @@ -0,0 +1,252 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// File for 'gemini mcp add' command +import type { CommandModule } from 'yargs'; +import { loadSettings, SettingScope } from '../../config/settings.js'; +import { debugLogger, type MCPServerConfig } from '@google/gemini-cli-core'; +import { exitCli } from '../utils.js'; + +async function addMcpServer( + name: string, + commandOrUrl: string, + args: Array | undefined, + options: { + scope: string; + transport: string; + env: string[] | undefined; + header: string[] | undefined; + timeout?: number; + trust?: boolean; + description?: string; + includeTools?: string[]; + excludeTools?: string[]; + }, +) { + const { + scope, + transport, + env, + header, + timeout, + trust, + description, + includeTools, + excludeTools, + } = options; + + const settings = loadSettings(process.cwd()); + const inHome = settings.workspace.path === settings.user.path; + + if (scope === 'project' && inHome) { + debugLogger.error( + 'Error: Please use --scope user to edit settings in the home directory.', + ); + process.exit(1); + } + + const settingsScope = + scope === 'user' ? SettingScope.User : SettingScope.Workspace; + + let newServer: Partial = {}; + + const headers = header?.reduce( + (acc, curr) => { + const [key, ...valueParts] = curr.split(':'); + const value = valueParts.join(':').trim(); + if (key.trim() && value) { + acc[key.trim()] = value; + } + return acc; + }, + {} as Record, + ); + + switch (transport) { + case 'sse': + newServer = { + url: commandOrUrl, + type: 'sse', + headers, + timeout, + trust, + description, + includeTools, + excludeTools, + }; + break; + case 'http': + newServer = { + url: commandOrUrl, + type: 'http', + headers, + timeout, + trust, + description, + includeTools, + excludeTools, + }; + break; + case 'stdio': + default: + newServer = { + command: commandOrUrl, + args: args?.map(String), + env: env?.reduce( + (acc, curr) => { + const [key, value] = curr.split('='); + if (key && value) { + acc[key] = value; + } + return acc; + }, + {} as Record, + ), + timeout, + trust, + description, + includeTools, + excludeTools, + }; + break; + } + + const existingSettings = settings.forScope(settingsScope).settings; + const mcpServers = existingSettings.mcpServers || {}; + + const isExistingServer = !!mcpServers[name]; + if (isExistingServer) { + debugLogger.log( + `MCP server "${name}" is already configured within ${scope} settings.`, + ); + } + + mcpServers[name] = newServer as MCPServerConfig; + + settings.setValue(settingsScope, 'mcpServers', mcpServers); + + if (isExistingServer) { + debugLogger.log(`MCP server "${name}" updated in ${scope} settings.`); + } else { + debugLogger.log( + `MCP server "${name}" added to ${scope} settings. (${transport})`, + ); + } +} + +export const addCommand: CommandModule = { + command: 'add [args...]', + describe: 'Add a server', + builder: (yargs) => + yargs + .usage('Usage: gemini mcp add [options] [args...]') + .parserConfiguration({ + 'unknown-options-as-args': true, // Pass unknown options as server args + 'populate--': true, // Populate server args after -- separator + }) + .positional('name', { + describe: 'Name of the server', + type: 'string', + demandOption: true, + }) + .positional('commandOrUrl', { + describe: 'Command (stdio) or URL (sse, http)', + type: 'string', + demandOption: true, + }) + .option('scope', { + alias: 's', + describe: 'Configuration scope (user or project)', + type: 'string', + default: 'project', + choices: ['user', 'project'], + }) + .option('transport', { + alias: ['t', 'type'], + describe: 'Transport type (stdio, sse, http)', + type: 'string', + default: 'stdio', + choices: ['stdio', 'sse', 'http'], + }) + .option('env', { + alias: 'e', + describe: 'Set environment variables (e.g. -e KEY=value)', + type: 'array', + string: true, + nargs: 1, + }) + .option('header', { + alias: 'H', + describe: + 'Set HTTP headers for SSE and HTTP transports (e.g. -H "X-Api-Key: abc123" -H "Authorization: Bearer abc123")', + type: 'array', + string: true, + nargs: 1, + }) + .option('timeout', { + describe: 'Set connection timeout in milliseconds', + type: 'number', + }) + .option('trust', { + describe: + 'Trust the server (bypass all tool call confirmation prompts)', + type: 'boolean', + }) + .option('description', { + describe: 'Set the description for the server', + type: 'string', + }) + .option('include-tools', { + describe: 'A comma-separated list of tools to include', + type: 'array', + string: true, + }) + .option('exclude-tools', { + describe: 'A comma-separated list of tools to exclude', + type: 'array', + string: true, + }) + .middleware((argv) => { + // Handle -- separator args as server args if present + if (argv['--']) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const existingArgs = (argv['args'] as Array) || []; + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + argv['args'] = [...existingArgs, ...(argv['--'] as string[])]; + } + }), + handler: async (argv) => { + await addMcpServer( + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + argv['name'] as string, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + argv['commandOrUrl'] as string, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + argv['args'] as Array, + { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + scope: argv['scope'] as string, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + transport: argv['transport'] as string, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + env: argv['env'] as string[], + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + header: argv['header'] as string[], + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + timeout: argv['timeout'] as number | undefined, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + trust: argv['trust'] as boolean | undefined, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + description: argv['description'] as string | undefined, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + includeTools: argv['includeTools'] as string[] | undefined, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + excludeTools: argv['excludeTools'] as string[] | undefined, + }, + ); + await exitCli(); + }, +}; diff --git a/packages/cli/src/commands/mcp/enableDisable.ts b/packages/cli/src/commands/mcp/enableDisable.ts new file mode 100644 index 0000000000000000000000000000000000000000..b47e259eca8cc8674f10bbf343a6dda0e6f55ea9 --- /dev/null +++ b/packages/cli/src/commands/mcp/enableDisable.ts @@ -0,0 +1,139 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import { debugLogger } from '@google/gemini-cli-core'; +import { + McpServerEnablementManager, + canLoadServer, + normalizeServerId, +} from '../../config/mcp/mcpServerEnablement.js'; +import { loadSettings } from '../../config/settings.js'; +import { exitCli } from '../utils.js'; +import { getMcpServersFromConfig } from './list.js'; + +const GREEN = '\x1b[32m'; +const YELLOW = '\x1b[33m'; +const RED = '\x1b[31m'; +const RESET = '\x1b[0m'; + +interface Args { + name: string; + session?: boolean; +} + +async function handleEnable(args: Args): Promise { + const manager = McpServerEnablementManager.getInstance(); + const name = normalizeServerId(args.name); + + // Check settings blocks + const settings = loadSettings(); + + // Get all servers including extensions + const servers = await getMcpServersFromConfig(); + const normalizedServerNames = Object.keys(servers).map(normalizeServerId); + if (!normalizedServerNames.includes(name)) { + debugLogger.log( + `${RED}Error:${RESET} Server '${args.name}' not found. Use 'gemini mcp' to see available servers.`, + ); + return; + } + + const result = await canLoadServer(name, { + adminMcpEnabled: settings.merged.admin?.mcp?.enabled ?? true, + allowedList: settings.merged.mcp?.allowed, + excludedList: settings.merged.mcp?.excluded, + }); + + if ( + !result.allowed && + (result.blockType === 'allowlist' || result.blockType === 'excludelist') + ) { + debugLogger.log(`${RED}Error:${RESET} ${result.reason}`); + return; + } + + if (args.session) { + manager.clearSessionDisable(name); + debugLogger.log(`${GREEN}✓${RESET} Session disable cleared for '${name}'.`); + } else { + await manager.enable(name); + debugLogger.log(`${GREEN}✓${RESET} MCP server '${name}' enabled.`); + } + + if (result.blockType === 'admin') { + debugLogger.log( + `${YELLOW}Warning:${RESET} MCP servers are disabled by administrator.`, + ); + } +} + +async function handleDisable(args: Args): Promise { + const manager = McpServerEnablementManager.getInstance(); + const name = normalizeServerId(args.name); + + // Get all servers including extensions + const servers = await getMcpServersFromConfig(); + const normalizedServerNames = Object.keys(servers).map(normalizeServerId); + if (!normalizedServerNames.includes(name)) { + debugLogger.log( + `${RED}Error:${RESET} Server '${args.name}' not found. Use 'gemini mcp' to see available servers.`, + ); + return; + } + + if (args.session) { + manager.disableForSession(name); + debugLogger.log( + `${GREEN}✓${RESET} MCP server '${name}' disabled for this session.`, + ); + } else { + await manager.disable(name); + debugLogger.log(`${GREEN}✓${RESET} MCP server '${name}' disabled.`); + } +} + +export const enableCommand: CommandModule = { + command: 'enable ', + describe: 'Enable an MCP server', + builder: (yargs) => + yargs + .positional('name', { + describe: 'MCP server name to enable', + type: 'string', + demandOption: true, + }) + .option('session', { + describe: 'Clear session-only disable', + type: 'boolean', + default: false, + }), + handler: async (argv) => { + await handleEnable(argv as Args); + await exitCli(); + }, +}; + +export const disableCommand: CommandModule = { + command: 'disable ', + describe: 'Disable an MCP server', + builder: (yargs) => + yargs + .positional('name', { + describe: 'MCP server name to disable', + type: 'string', + demandOption: true, + }) + .option('session', { + describe: 'Disable for current session only', + type: 'boolean', + default: false, + }), + handler: async (argv) => { + await handleDisable(argv as Args); + await exitCli(); + }, +}; diff --git a/packages/cli/src/commands/mcp/list.test.ts b/packages/cli/src/commands/mcp/list.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..3a91531d00cbb725faa3aa1f9e052e7a82137c15 --- /dev/null +++ b/packages/cli/src/commands/mcp/list.test.ts @@ -0,0 +1,732 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + vi, + describe, + it, + expect, + beforeEach, + afterEach, + type Mock, +} from 'vitest'; +import { listMcpServers } from './list.js'; +import { loadSettings } from '../../config/settings.js'; +import { + createTransport, + debugLogger, + type AdminControlsSettings, +} from '@google/gemini-cli-core'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { ExtensionStorage } from '../../config/extensions/storage.js'; +import { ExtensionManager } from '../../config/extension-manager.js'; +import { McpServerEnablementManager } from '../../config/mcp/index.js'; +import { createMockSettings } from '../../test-utils/settings.js'; + +vi.mock('../../config/settings.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + loadSettings: vi.fn(), + }; +}); +vi.mock('../../config/extensions/storage.js', () => ({ + ExtensionStorage: { + getUserExtensionsDir: vi.fn(), + }, +})); +vi.mock('../../config/extension-manager.js'); +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const original = + await importOriginal(); + return { + ...original, + createTransport: vi.fn(), + + MCPServerStatus: { + CONNECTED: 'CONNECTED', + CONNECTING: 'CONNECTING', + DISCONNECTED: 'DISCONNECTED', + BLOCKED: 'BLOCKED', + DISABLED: 'DISABLED', + }, + Storage: Object.assign( + vi.fn().mockImplementation((_cwd: string) => ({ + getGlobalSettingsPath: () => '/tmp/gemini/settings.json', + getWorkspaceSettingsPath: () => '/tmp/gemini/workspace-settings.json', + getProjectTempDir: () => '/test/home/.gemini/tmp/mocked_hash', + })), + { + getGlobalSettingsPath: () => '/tmp/gemini/settings.json', + getGlobalGeminiDir: () => '/tmp/gemini', + }, + ), + GEMINI_DIR: '.gemini', + getErrorMessage: (e: unknown) => + e instanceof Error ? e.message : String(e), + }; +}); +vi.mock('@modelcontextprotocol/sdk/client/index.js'); + +vi.mock('../utils.js', () => ({ + exitCli: vi.fn(), +})); + +const mockedGetUserExtensionsDir = + ExtensionStorage.getUserExtensionsDir as Mock; +const mockedLoadSettings = loadSettings as Mock; +const mockedCreateTransport = createTransport as Mock; +const MockedClient = Client as Mock; +const MockedExtensionManager = ExtensionManager as Mock; + +interface MockClient { + connect: Mock; + ping: Mock; + close: Mock; +} + +interface MockExtensionManager { + loadExtensions: Mock; +} + +interface MockTransport { + close: Mock; +} + +describe('mcp list command', () => { + let mockClient: MockClient; + let mockExtensionManager: MockExtensionManager; + let mockTransport: MockTransport; + + beforeEach(() => { + vi.resetAllMocks(); + vi.spyOn(debugLogger, 'log').mockImplementation(() => {}); + McpServerEnablementManager.resetInstance(); + // Use a mock for isFileEnabled to avoid reading real files + vi.spyOn( + McpServerEnablementManager.prototype, + 'isFileEnabled', + ).mockResolvedValue(true); + + mockTransport = { close: vi.fn() }; + mockClient = { + connect: vi.fn(), + ping: vi.fn(), + close: vi.fn(), + }; + mockExtensionManager = { + loadExtensions: vi.fn(), + }; + + MockedClient.mockImplementation(() => mockClient); + MockedExtensionManager.mockImplementation(() => mockExtensionManager); + mockedCreateTransport.mockResolvedValue(mockTransport); + mockExtensionManager.loadExtensions.mockReturnValue([]); + mockedGetUserExtensionsDir.mockReturnValue('/mocked/extensions/dir'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should display message when no servers configured', async () => { + mockedLoadSettings.mockReturnValue(createMockSettings({ mcpServers: {} })); + + await listMcpServers(); + + expect(debugLogger.log).toHaveBeenCalledWith('No MCP servers configured.'); + }); + + it('should display different server types with connected status', async () => { + mockedLoadSettings.mockReturnValue( + createMockSettings({ + mcpServers: { + 'stdio-server': { command: '/path/to/server', args: ['arg1'] }, + 'sse-server': { url: 'https://example.com/sse', type: 'sse' }, + 'http-server': { httpUrl: 'https://example.com/http' }, + 'http-server-by-default': { url: 'https://example.com/http' }, + 'http-server-with-type': { + url: 'https://example.com/http', + type: 'http', + }, + }, + isTrusted: true, + }), + ); + + mockClient.connect.mockResolvedValue(undefined); + mockClient.ping.mockResolvedValue(undefined); + + await listMcpServers(); + + expect(debugLogger.log).toHaveBeenCalledWith('Configured MCP servers:\n'); + expect(debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining( + 'stdio-server: /path/to/server arg1 (stdio) - Connected', + ), + ); + expect(debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining( + 'sse-server: https://example.com/sse (sse) - Connected', + ), + ); + expect(debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining( + 'http-server: https://example.com/http (http) - Connected', + ), + ); + expect(debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining( + 'http-server-by-default: https://example.com/http (http) - Connected', + ), + ); + expect(debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining( + 'http-server-with-type: https://example.com/http (http) - Connected', + ), + ); + }); + + it('should display disconnected status when connection fails', async () => { + mockedLoadSettings.mockReturnValue( + createMockSettings({ + mcpServers: { + 'test-server': { command: '/test/server' }, + }, + isTrusted: true, + }), + ); + + mockClient.connect.mockRejectedValue(new Error('Connection failed')); + + await listMcpServers(); + + expect(debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining( + 'test-server: /test/server (stdio) - Disconnected', + ), + ); + }); + + it('should display connected status even if ping fails', async () => { + mockedLoadSettings.mockReturnValue( + createMockSettings({ + mcpServers: { + 'test-server': { command: '/test/server' }, + }, + isTrusted: true, + }), + ); + + mockClient.connect.mockResolvedValue(undefined); + mockClient.ping.mockRejectedValue(new Error('Ping failed')); + + await listMcpServers(); + + expect(debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining('test-server: /test/server (stdio) - Connected'), + ); + }); + + it('should use configured timeout for connection', async () => { + mockedLoadSettings.mockReturnValue( + createMockSettings({ + mcpServers: { + 'test-server': { command: '/test/server', timeout: 12345 }, + }, + isTrusted: true, + }), + ); + + mockClient.connect.mockResolvedValue(undefined); + + await listMcpServers(); + + expect(mockClient.connect).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ timeout: 12345 }), + ); + expect(mockClient.ping).toHaveBeenCalledWith( + expect.objectContaining({ timeout: 12345 }), + ); + }); + + it('should use default timeout for connection when not configured', async () => { + mockedLoadSettings.mockReturnValue( + createMockSettings({ + mcpServers: { + 'test-server': { command: '/test/server' }, + }, + isTrusted: true, + }), + ); + + mockClient.connect.mockResolvedValue(undefined); + + await listMcpServers(); + + expect(mockClient.connect).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ timeout: 5000 }), + ); + expect(mockClient.ping).toHaveBeenCalledWith( + expect.objectContaining({ timeout: 5000 }), + ); + }); + + it('should merge extension servers with config servers', async () => { + mockedLoadSettings.mockReturnValue( + createMockSettings({ + mcpServers: { + 'config-server': { command: '/config/server' }, + }, + isTrusted: true, + }), + ); + + mockExtensionManager.loadExtensions.mockReturnValue([ + { + name: 'test-extension', + mcpServers: { 'extension-server': { command: '/ext/server' } }, + }, + ]); + + mockClient.connect.mockResolvedValue(undefined); + mockClient.ping.mockResolvedValue(undefined); + + await listMcpServers(); + + expect(debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining( + 'config-server: /config/server (stdio) - Connected', + ), + ); + expect(debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining( + 'extension-server (from test-extension): /ext/server (stdio) - Connected', + ), + ); + }); + + it('should filter servers based on admin allowlist passed in settings', async () => { + const adminControls = { + strictModeDisabled: true, + mcpSetting: { + mcpEnabled: true, + mcpConfig: { + mcpServers: { + 'allowed-server': { url: 'http://allowed' }, + }, + }, + }, + }; + + const mcpServers = { + 'allowed-server': { command: 'cmd1' }, + 'forbidden-server': { command: 'cmd2' }, + }; + + const mockSettings = createMockSettings({ + mcpServers, + isTrusted: true, + }); + // setRemoteAdminSettings is the correct way to set admin settings in tests + ( + mockSettings as unknown as { + setRemoteAdminSettings: (controls: AdminControlsSettings) => void; + } + ).setRemoteAdminSettings(adminControls as unknown as AdminControlsSettings); + + mockedLoadSettings.mockReturnValue(mockSettings); + + mockClient.connect.mockResolvedValue(undefined); + mockClient.ping.mockResolvedValue(undefined); + + await listMcpServers(mockSettings); + + expect(debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining('allowed-server'), + ); + expect(debugLogger.log).not.toHaveBeenCalledWith( + expect.stringContaining('forbidden-server'), + ); + expect(mockedCreateTransport).toHaveBeenCalledWith( + 'allowed-server', + expect.objectContaining({ url: 'http://allowed' }), // Should use admin config + false, + expect.anything(), + ); + }); + + it('should show stdio servers as disabled in untrusted folders', async () => { + mockedLoadSettings.mockReturnValue( + createMockSettings({ + mcpServers: { + 'test-server': { command: '/test/server' }, + }, + isTrusted: false, + }), + ); + + await listMcpServers(); + + expect(debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining( + 'Warning: MCP servers are configured but disabled because this folder is untrusted.', + ), + ); + expect(debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining('test-server: /test/server (stdio) - Disabled'), + ); + }); + + it('should display blocked status for servers in excluded list', async () => { + mockedLoadSettings.mockReturnValue( + createMockSettings({ + mcp: { + excluded: ['blocked-server'], + }, + mcpServers: { + 'blocked-server': { command: '/test/server' }, + }, + isTrusted: true, + }), + ); + + await listMcpServers(); + + expect(debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining( + 'blocked-server: /test/server (stdio) - Blocked', + ), + ); + expect(mockedCreateTransport).not.toHaveBeenCalled(); + }); + + it('should display disabled status for servers disabled via enablement manager', async () => { + mockedLoadSettings.mockReturnValue( + createMockSettings({ + mcpServers: { + 'disabled-server': { command: '/test/server' }, + }, + isTrusted: true, + }), + ); + + vi.spyOn( + McpServerEnablementManager.prototype, + 'isFileEnabled', + ).mockResolvedValue(false); + + await listMcpServers(); + + expect(debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining( + 'disabled-server: /test/server (stdio) - Disabled', + ), + ); + expect(mockedCreateTransport).not.toHaveBeenCalled(); + }); + + it('should display warning and disabled status in untrusted folders', async () => { + const userMcpServers = { + 'user-server': { url: 'https://example.com/user' }, + }; + const workspaceMcpServers = { + 'project-server': { command: '/path/to/project/server' }, + }; + + mockedLoadSettings.mockReturnValue( + createMockSettings({ + user: { + settings: { mcpServers: userMcpServers }, + originalSettings: { mcpServers: userMcpServers }, + path: '/mock/user/settings.json', + }, + workspace: { + settings: { mcpServers: workspaceMcpServers }, + originalSettings: { mcpServers: workspaceMcpServers }, + path: '/mock/workspace/settings.json', + }, + isTrusted: false, + }), + ); + + await listMcpServers(); + + expect(debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining( + 'Warning: MCP servers are configured but disabled because this folder is untrusted.', + ), + ); + expect(debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining( + 'project-server: /path/to/project/server (stdio) - Disabled', + ), + ); + expect(debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining( + 'user-server: https://example.com/user (http) - Disabled', + ), + ); + expect(mockedCreateTransport).not.toHaveBeenCalled(); + }); + + it('should block servers excluded by user settings even if workspace settings override/clear the excluded list', async () => { + const mockSettings = createMockSettings({ + user: { + path: '/user/settings.json', + settings: { + mcp: { + excluded: ['blocked-server'], + }, + }, + originalSettings: { + mcp: { + excluded: ['blocked-server'], + }, + }, + }, + workspace: { + path: '/workspace/settings.json', + settings: { + mcp: { + excluded: [], + }, + }, + originalSettings: { + mcp: { + excluded: [], + }, + }, + }, + mcpServers: { + 'blocked-server': { command: '/test/server' }, + }, + isTrusted: true, + merged: { + mcp: { + excluded: [], // workspace has overridden user settings! + }, + mcpServers: { + 'blocked-server': { command: '/test/server' }, + }, + }, + }); + + mockedLoadSettings.mockReturnValue(mockSettings); + + await listMcpServers(); + + expect(debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining( + 'blocked-server: /test/server (stdio) - Blocked', + ), + ); + expect(mockedCreateTransport).not.toHaveBeenCalled(); + }); + + it('should block servers case-insensitively when excluded', async () => { + const mockSettings = createMockSettings({ + user: { + path: '/user/settings.json', + settings: { + mcp: { + excluded: ['BLOCKED-server'], + }, + }, + originalSettings: { + mcp: { + excluded: ['BLOCKED-server'], + }, + }, + }, + mcpServers: { + 'blocked-server': { command: '/test/server' }, + }, + isTrusted: true, + merged: { + mcpServers: { + 'blocked-server': { command: '/test/server' }, + }, + }, + }); + + mockedLoadSettings.mockReturnValue(mockSettings); + + await listMcpServers(); + + expect(debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining( + 'blocked-server: /test/server (stdio) - Blocked', + ), + ); + expect(mockedCreateTransport).not.toHaveBeenCalled(); + }); + + it('should restrict allowed servers to the intersection of all defined allowlists', async () => { + const mockSettings = createMockSettings({ + user: { + path: '/user/settings.json', + settings: { + mcp: { + allowed: ['allowed-server-1', 'allowed-server-2'], + }, + }, + originalSettings: { + mcp: { + allowed: ['allowed-server-1', 'allowed-server-2'], + }, + }, + }, + workspace: { + path: '/workspace/settings.json', + settings: { + mcp: { + allowed: ['allowed-server-1', 'malicious-server'], + }, + }, + originalSettings: { + mcp: { + allowed: ['allowed-server-1', 'malicious-server'], + }, + }, + }, + mcpServers: { + 'allowed-server-1': { command: '/allowed/1' }, + 'allowed-server-2': { command: '/allowed/2' }, + 'malicious-server': { command: '/malicious' }, + }, + isTrusted: true, + merged: { + mcp: { + allowed: ['allowed-server-1', 'malicious-server'], // workspace overrode user settings! + }, + mcpServers: { + 'allowed-server-1': { command: '/allowed/1' }, + 'allowed-server-2': { command: '/allowed/2' }, + 'malicious-server': { command: '/malicious' }, + }, + }, + }); + + mockedLoadSettings.mockReturnValue(mockSettings); + mockClient.connect.mockResolvedValue(undefined); + mockClient.ping.mockResolvedValue(undefined); + + await listMcpServers(); + + // allowed-server-1 is in the intersection, so it should connect + expect(debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining( + 'allowed-server-1: /allowed/1 (stdio) - Connected', + ), + ); + // allowed-server-2 and malicious-server are not in the intersection, so they should be Blocked + expect(debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining( + 'allowed-server-2: /allowed/2 (stdio) - Blocked', + ), + ); + expect(debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining( + 'malicious-server: /malicious (stdio) - Blocked', + ), + ); + + expect(mockedCreateTransport).toHaveBeenCalledTimes(1); + expect(mockedCreateTransport).toHaveBeenCalledWith( + 'allowed-server-1', + expect.any(Object), + false, + expect.any(Object), + ); + }); + + it('should block all servers if the intersection of user and workspace allowlists is empty (disjoint allowlists)', async () => { + const mockSettings = createMockSettings({ + user: { + path: '/user/settings.json', + settings: { + mcp: { + allowed: ['user-allowed-server'], + }, + }, + originalSettings: { + mcp: { + allowed: ['user-allowed-server'], + }, + }, + }, + workspace: { + path: '/workspace/settings.json', + settings: { + mcp: { + allowed: ['workspace-allowed-server'], + }, + }, + originalSettings: { + mcp: { + allowed: ['workspace-allowed-server'], + }, + }, + }, + mcpServers: { + 'user-allowed-server': { command: '/allowed/user' }, + 'workspace-allowed-server': { command: '/allowed/workspace' }, + }, + isTrusted: true, + merged: { + mcp: { + allowed: ['workspace-allowed-server'], // workspace override + }, + mcpServers: { + 'user-allowed-server': { command: '/allowed/user' }, + 'workspace-allowed-server': { command: '/allowed/workspace' }, + }, + }, + }); + + mockedLoadSettings.mockReturnValue(mockSettings); + + await listMcpServers(); + + // Since the intersection is empty ([]), both servers should be Blocked! + expect(debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining( + 'user-allowed-server: /allowed/user (stdio) - Blocked', + ), + ); + expect(debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining( + 'workspace-allowed-server: /allowed/workspace (stdio) - Blocked', + ), + ); + expect(mockedCreateTransport).not.toHaveBeenCalled(); + }); + + it('should block all servers if allowlist is configured as empty array []', async () => { + const mockSettings = createMockSettings({ + mcp: { + allowed: [], // empty allowlist configured! + }, + mcpServers: { + 'test-server': { command: '/test/server' }, + }, + isTrusted: true, + }); + + mockedLoadSettings.mockReturnValue(mockSettings); + + await listMcpServers(); + + expect(debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining('test-server: /test/server (stdio) - Blocked'), + ); + expect(mockedCreateTransport).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/commands/mcp/list.ts b/packages/cli/src/commands/mcp/list.ts new file mode 100644 index 0000000000000000000000000000000000000000..0282277557e9b5d332e7eefe0fe5a564cfd521aa --- /dev/null +++ b/packages/cli/src/commands/mcp/list.ts @@ -0,0 +1,306 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// File for 'gemini mcp list' command +import type { CommandModule } from 'yargs'; +import { + type MergedSettings, + loadSettings, + type LoadedSettings, +} from '../../config/settings.js'; +import { + MCPServerStatus, + createTransport, + debugLogger, + applyAdminAllowlist, + getAdminBlockedMcpServersMessage, +} from '@google/gemini-cli-core'; +import type { MCPServerConfig } from '@google/gemini-cli-core'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { ExtensionManager } from '../../config/extension-manager.js'; +import { + canLoadServer, + McpServerEnablementManager, +} from '../../config/mcp/index.js'; +import { requestConsentNonInteractive } from '../../config/extensions/consent.js'; +import { promptForSetting } from '../../config/extensions/extensionSettings.js'; +import { exitCli } from '../utils.js'; +import chalk from 'chalk'; + +export async function getMcpServersFromConfig( + settings?: MergedSettings, +): Promise<{ + mcpServers: Record; + blockedServerNames: string[]; +}> { + if (!settings) { + settings = loadSettings().merged; + } + + const extensionManager = new ExtensionManager({ + settings, + workspaceDir: process.cwd(), + requestConsent: requestConsentNonInteractive, + requestSetting: promptForSetting, + }); + const extensions = await extensionManager.loadExtensions(); + const mcpServers = { ...settings.mcpServers }; + for (const extension of extensions) { + Object.entries(extension.mcpServers || {}).forEach(([key, server]) => { + if (mcpServers[key]) { + return; + } + mcpServers[key] = { + // eslint-disable-next-line @typescript-eslint/no-misused-spread + ...server, + extension, + }; + }); + } + + const adminAllowlist = settings.admin?.mcp?.config; + const filteredResult = applyAdminAllowlist(mcpServers, adminAllowlist); + + return filteredResult; +} + +const MCP_LIST_DEFAULT_TIMEOUT_MSEC = 5000; + +async function testMCPConnection( + serverName: string, + config: MCPServerConfig, + isTrusted: boolean, + activeSettings: MergedSettings, +): Promise { + // SECURITY: Only test connection if workspace is trusted or if it's a remote server. + // stdio servers execute local commands and must never run in untrusted workspaces. + const isStdio = !!config.command; + if (isStdio && !isTrusted) { + return MCPServerStatus.DISCONNECTED; + } + + const client = new Client({ + name: 'mcp-test-client', + version: '0.0.1', + }); + + const mcpContext = { + sanitizationConfig: { + enableEnvironmentVariableRedaction: true, + allowedEnvironmentVariables: [], + blockedEnvironmentVariables: activeSettings.advanced.excludedEnvVars, + }, + emitMcpDiagnostic: ( + severity: 'info' | 'warning' | 'error', + message: string, + error?: unknown, + serverName?: string, + ) => { + // In non-interactive list, we log everything through debugLogger for consistency + if (severity === 'error') { + debugLogger.error( + chalk.red(`Error${serverName ? ` (${serverName})` : ''}: ${message}`), + error, + ); + } else if (severity === 'warning') { + debugLogger.warn( + chalk.yellow( + `Warning${serverName ? ` (${serverName})` : ''}: ${message}`, + ), + error, + ); + } else { + debugLogger.log(message, error); + } + }, + isTrustedFolder: () => isTrusted, + }; + + let transport; + try { + // Use the same transport creation logic as core + transport = await createTransport(serverName, config, false, mcpContext); + } catch { + await client.close(); + return MCPServerStatus.DISCONNECTED; + } + + try { + // Attempt actual MCP connection with timeout from config or default to 5s. + // We use a short default for the list command to keep it responsive. + const timeout = config.timeout ?? MCP_LIST_DEFAULT_TIMEOUT_MSEC; + await client.connect(transport, { timeout }); + + // Test basic MCP protocol by pinging the server. + // Ping is optional per MCP spec - some servers (e.g. Google first-party) + // don't implement it. A successful connect() is sufficient proof of connectivity. + try { + await client.ping({ timeout }); + } catch (e) { + debugLogger.debug( + `MCP ping failed for ${serverName}, but connect succeeded:`, + e, + ); + } + + await client.close(); + return MCPServerStatus.CONNECTED; + } catch { + await transport.close(); + return MCPServerStatus.DISCONNECTED; + } +} + +async function getServerStatus( + serverName: string, + server: MCPServerConfig, + isTrusted: boolean, + activeSettings: MergedSettings, + consolidatedExcluded: string[], + consolidatedAllowed: string[] | undefined, +): Promise { + const mcpEnablementManager = McpServerEnablementManager.getInstance(); + + const loadResult = await canLoadServer(serverName, { + adminMcpEnabled: activeSettings.admin?.mcp?.enabled ?? true, + allowedList: consolidatedAllowed, + excludedList: + consolidatedExcluded.length > 0 ? consolidatedExcluded : undefined, + enablement: mcpEnablementManager.getEnablementCallbacks(), + }); + + if (!loadResult.allowed) { + if ( + loadResult.blockType === 'admin' || + loadResult.blockType === 'allowlist' || + loadResult.blockType === 'excludelist' + ) { + return MCPServerStatus.BLOCKED; + } + return MCPServerStatus.DISABLED; + } + + if (!isTrusted) { + return MCPServerStatus.DISABLED; + } + + // Test all server types by attempting actual connection + return testMCPConnection(serverName, server, isTrusted, activeSettings); +} + +export async function listMcpServers( + loadedSettingsArg?: LoadedSettings, +): Promise { + const loadedSettings = loadedSettingsArg ?? loadSettings(); + const activeSettings = loadedSettings.merged; + + // If the folder is untrusted, we want to show all configured servers (including + // project-scoped ones) as disabled. + const allSettings = !loadedSettings.isTrusted + ? loadedSettings.getMergedSettingsAsIfTrusted() + : activeSettings; + + const { mcpServers, blockedServerNames } = + await getMcpServersFromConfig(allSettings); + const serverNames = Object.keys(mcpServers); + + if (blockedServerNames.length > 0) { + const message = getAdminBlockedMcpServersMessage( + blockedServerNames, + undefined, + ); + debugLogger.log(chalk.yellow(message + '\n')); + } + + if (serverNames.length === 0) { + if (blockedServerNames.length === 0) { + debugLogger.log('No MCP servers configured.'); + } + return; + } + + if (!loadedSettings.isTrusted) { + debugLogger.log( + chalk.yellow( + 'Warning: MCP servers are configured but disabled because this folder is untrusted.\n' + + 'User-level servers are also suppressed in untrusted folders to prevent accidental side-effects.\n', + ), + ); + } + + const consolidatedExcluded = + loadedSettings.getConsolidatedExcludedMcpServers(); + const consolidatedAllowed = loadedSettings.getConsolidatedAllowedMcpServers(); + + debugLogger.log('Configured MCP servers:\n'); + + for (const serverName of serverNames) { + const server = mcpServers[serverName]; + + const status = await getServerStatus( + serverName, + server, + loadedSettings.isTrusted, + activeSettings, + consolidatedExcluded, + consolidatedAllowed, + ); + + let statusIndicator = ''; + let statusText = ''; + switch (status) { + case MCPServerStatus.CONNECTED: + statusIndicator = chalk.green('✓'); + statusText = 'Connected'; + break; + case MCPServerStatus.CONNECTING: + statusIndicator = chalk.yellow('…'); + statusText = 'Connecting'; + break; + case MCPServerStatus.BLOCKED: + statusIndicator = chalk.red('⛔'); + statusText = 'Blocked'; + break; + case MCPServerStatus.DISABLED: + statusIndicator = chalk.gray('○'); + statusText = 'Disabled'; + break; + case MCPServerStatus.DISCONNECTED: + default: + statusIndicator = chalk.red('✗'); + statusText = 'Disconnected'; + break; + } + + let serverInfo = + serverName + + (server.extension?.name ? ` (from ${server.extension.name})` : '') + + ': '; + if (server.httpUrl) { + serverInfo += `${server.httpUrl} (http)`; + } else if (server.url) { + const type = server.type || 'http'; + serverInfo += `${server.url} (${type})`; + } else if (server.command) { + serverInfo += `${server.command} ${server.args?.join(' ') || ''} (stdio)`; + } + + debugLogger.log(`${statusIndicator} ${serverInfo} - ${statusText}`); + } +} + +interface ListArgs { + loadedSettings?: LoadedSettings; +} + +export const listCommand: CommandModule = { + command: 'list', + describe: 'List all configured MCP servers', + handler: async (argv) => { + await listMcpServers(argv.loadedSettings); + await exitCli(); + }, +}; diff --git a/packages/cli/src/commands/mcp/remove.test.ts b/packages/cli/src/commands/mcp/remove.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..ef8f35f096222d30d4799c8c8aba82f669154153 --- /dev/null +++ b/packages/cli/src/commands/mcp/remove.test.ts @@ -0,0 +1,262 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + vi, + describe, + it, + expect, + beforeEach, + afterEach, + type Mock, +} from 'vitest'; +import yargs, { type Argv } from 'yargs'; +import { SettingScope, type LoadedSettings } from '../../config/settings.js'; +import { removeCommand } from './remove.js'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { GEMINI_DIR, debugLogger } from '@google/gemini-cli-core'; + +vi.mock('fs', async (importOriginal) => { + const actualFs = await importOriginal(); + return { + ...actualFs, + existsSync: vi.fn(actualFs.existsSync), + readFileSync: vi.fn(actualFs.readFileSync), + writeFileSync: vi.fn(actualFs.writeFileSync), + mkdirSync: vi.fn(actualFs.mkdirSync), + }; +}); + +vi.mock('fs/promises', () => ({ + readFile: vi.fn(), + writeFile: vi.fn(), +})); + +vi.mock('../utils.js', () => ({ + exitCli: vi.fn(), +})); + +vi.mock('../../config/trustedFolders.js', () => ({ + isWorkspaceTrusted: vi.fn(() => ({ + isTrusted: true, + source: undefined, + })), + isFolderTrustEnabled: vi.fn(() => false), +})); + +describe('mcp remove command', () => { + describe('unit tests with mocks', () => { + let parser: Argv; + let mockSetValue: Mock; + let mockSettings: Record; + + beforeEach(async () => { + vi.resetAllMocks(); + + mockSetValue = vi.fn(); + mockSettings = { + mcpServers: { + 'test-server': { + command: 'echo "hello"', + }, + }, + }; + + vi.spyOn( + await import('../../config/settings.js'), + 'loadSettings', + ).mockReturnValue({ + forScope: () => ({ settings: mockSettings }), + setValue: mockSetValue, + workspace: { path: '/path/to/project' }, + user: { path: '/home/user' }, + } as unknown as LoadedSettings); + + const yargsInstance = yargs([]).command(removeCommand); + parser = yargsInstance; + }); + + it('should remove a server from project settings', async () => { + await parser.parseAsync('remove test-server'); + + expect(mockSetValue).toHaveBeenCalledWith( + SettingScope.Workspace, + 'mcpServers', + {}, + ); + }); + + it('should show a message if server not found', async () => { + const debugLogSpy = vi + .spyOn(debugLogger, 'log') + .mockImplementation(() => {}); + await parser.parseAsync('remove non-existent-server'); + + expect(mockSetValue).not.toHaveBeenCalled(); + expect(debugLogSpy).toHaveBeenCalledWith( + 'Server "non-existent-server" not found in project settings.', + ); + debugLogSpy.mockRestore(); + }); + }); + + describe('integration tests with real file I/O', () => { + let tempDir: string; + let settingsDir: string; + let settingsPath: string; + let parser: Argv; + let cwdSpy: ReturnType; + + beforeEach(() => { + vi.resetAllMocks(); + vi.restoreAllMocks(); + + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mcp-remove-test-')); + settingsDir = path.join(tempDir, GEMINI_DIR); + settingsPath = path.join(settingsDir, 'settings.json'); + fs.mkdirSync(settingsDir, { recursive: true }); + + cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(tempDir); + + parser = yargs([]).command(removeCommand); + }); + + afterEach(() => { + cwdSpy.mockRestore(); + + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('should actually remove a server from the settings file', async () => { + const originalContent = `{ + "mcpServers": { + "server-to-keep": { + "command": "node", + "args": ["keep.js"] + }, + "server-to-remove": { + "command": "node", + "args": ["remove.js"] + } + } + }`; + fs.writeFileSync(settingsPath, originalContent, 'utf-8'); + + const debugLogSpy = vi + .spyOn(debugLogger, 'log') + .mockImplementation(() => {}); + await parser.parseAsync('remove server-to-remove'); + + const updatedContent = fs.readFileSync(settingsPath, 'utf-8'); + expect(updatedContent).toContain('"server-to-keep"'); + expect(updatedContent).not.toContain('"server-to-remove"'); + + expect(debugLogSpy).toHaveBeenCalledWith( + 'Server "server-to-remove" removed from project settings.', + ); + + debugLogSpy.mockRestore(); + }); + + it('should preserve comments when removing a server', async () => { + const originalContent = `{ + "mcpServers": { + // Server to keep + "context7": { + "command": "node", + "args": ["server.js"] + }, + // Server to remove + "oldServer": { + "command": "old", + "args": ["old.js"] + } + } + }`; + fs.writeFileSync(settingsPath, originalContent, 'utf-8'); + + const debugLogSpy = vi + .spyOn(debugLogger, 'log') + .mockImplementation(() => {}); + await parser.parseAsync('remove oldServer'); + + const updatedContent = fs.readFileSync(settingsPath, 'utf-8'); + expect(updatedContent).toContain('// Server to keep'); + expect(updatedContent).toContain('"context7"'); + expect(updatedContent).not.toContain('"oldServer"'); + expect(updatedContent).toContain('// Server to remove'); + + debugLogSpy.mockRestore(); + }); + + it('should handle removing the only server', async () => { + const originalContent = `{ + "mcpServers": { + "only-server": { + "command": "node", + "args": ["server.js"] + } + } + }`; + fs.writeFileSync(settingsPath, originalContent, 'utf-8'); + + const debugLogSpy = vi + .spyOn(debugLogger, 'log') + .mockImplementation(() => {}); + await parser.parseAsync('remove only-server'); + + const updatedContent = fs.readFileSync(settingsPath, 'utf-8'); + expect(updatedContent).toContain('"mcpServers"'); + expect(updatedContent).not.toContain('"only-server"'); + expect(updatedContent).toMatch(/"mcpServers"\s*:\s*\{\s*\}/); + + debugLogSpy.mockRestore(); + }); + + it('should preserve other settings when removing a server', async () => { + // Create settings file with other settings + // Note: "model" will be migrated to "model": { "name": ... } format + const originalContent = `{ + "model": { + "name": "gemini-2.5-pro" + }, + "mcpServers": { + "server1": { + "command": "node", + "args": ["s1.js"] + }, + "server2": { + "command": "node", + "args": ["s2.js"] + } + }, + "ui": { + "theme": "dark" + } + }`; + fs.writeFileSync(settingsPath, originalContent, 'utf-8'); + + const debugLogSpy = vi + .spyOn(debugLogger, 'log') + .mockImplementation(() => {}); + await parser.parseAsync('remove server1'); + + const updatedContent = fs.readFileSync(settingsPath, 'utf-8'); + expect(updatedContent).toContain('"model"'); + expect(updatedContent).toContain('"gemini-2.5-pro"'); + expect(updatedContent).toContain('"server2"'); + expect(updatedContent).toContain('"ui"'); + expect(updatedContent).toContain('"theme": "dark"'); + expect(updatedContent).not.toContain('"server1"'); + + debugLogSpy.mockRestore(); + }); + }); +}); diff --git a/packages/cli/src/commands/mcp/remove.ts b/packages/cli/src/commands/mcp/remove.ts new file mode 100644 index 0000000000000000000000000000000000000000..8c5bd1efabcaf29df49796f8478c1db6407ead4e --- /dev/null +++ b/packages/cli/src/commands/mcp/remove.ts @@ -0,0 +1,65 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// File for 'gemini mcp remove' command +import type { CommandModule } from 'yargs'; +import { loadSettings, SettingScope } from '../../config/settings.js'; +import { debugLogger } from '@google/gemini-cli-core'; +import { exitCli } from '../utils.js'; + +async function removeMcpServer( + name: string, + options: { + scope: string; + }, +) { + const { scope } = options; + const settingsScope = + scope === 'user' ? SettingScope.User : SettingScope.Workspace; + const settings = loadSettings(); + + const existingSettings = settings.forScope(settingsScope).settings; + const mcpServers = existingSettings.mcpServers || {}; + + if (!mcpServers[name]) { + debugLogger.log(`Server "${name}" not found in ${scope} settings.`); + return; + } + + delete mcpServers[name]; + + settings.setValue(settingsScope, 'mcpServers', mcpServers); + + debugLogger.log(`Server "${name}" removed from ${scope} settings.`); +} + +export const removeCommand: CommandModule = { + command: 'remove ', + describe: 'Remove a server', + builder: (yargs) => + yargs + .usage('Usage: gemini mcp remove [options] ') + .positional('name', { + describe: 'Name of the server', + type: 'string', + demandOption: true, + }) + .option('scope', { + alias: 's', + describe: 'Configuration scope (user or project)', + type: 'string', + default: 'project', + choices: ['user', 'project'], + }), + handler: async (argv) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + await removeMcpServer(argv['name'] as string, { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + scope: argv['scope'] as string, + }); + await exitCli(); + }, +}; diff --git a/packages/cli/src/commands/skills.test.tsx b/packages/cli/src/commands/skills.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e7b9a4eb9dd36e2c39cde793e57712e81cc77ace --- /dev/null +++ b/packages/cli/src/commands/skills.test.tsx @@ -0,0 +1,62 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi } from 'vitest'; +import { skillsCommand } from './skills.js'; + +vi.mock('./skills/list.js', () => ({ listCommand: { command: 'list' } })); +vi.mock('./skills/enable.js', () => ({ + enableCommand: { command: 'enable ' }, +})); +vi.mock('./skills/disable.js', () => ({ + disableCommand: { command: 'disable ' }, +})); + +vi.mock('../gemini.js', () => ({ + initializeOutputListenersAndFlush: vi.fn(), +})); + +describe('skillsCommand', () => { + it('should have correct command and aliases', () => { + expect(skillsCommand.command).toBe('skills '); + expect(skillsCommand.aliases).toEqual(['skill']); + expect(skillsCommand.describe).toBe('Manage agent skills.'); + }); + + it('should register all subcommands in builder', () => { + const mockYargs = { + middleware: vi.fn().mockReturnThis(), + command: vi.fn().mockReturnThis(), + demandCommand: vi.fn().mockReturnThis(), + version: vi.fn().mockReturnThis(), + }; + + // @ts-expect-error - Mocking yargs + skillsCommand.builder(mockYargs); + + expect(mockYargs.middleware).toHaveBeenCalled(); + expect(mockYargs.command).toHaveBeenCalledWith( + expect.objectContaining({ command: 'list' }), + ); + expect(mockYargs.command).toHaveBeenCalledWith( + expect.objectContaining({ + command: 'enable ', + }), + ); + expect(mockYargs.command).toHaveBeenCalledWith( + expect.objectContaining({ + command: 'disable ', + }), + ); + expect(mockYargs.demandCommand).toHaveBeenCalledWith(1, expect.any(String)); + expect(mockYargs.version).toHaveBeenCalledWith(false); + }); + + it('should have a handler that does nothing', () => { + // @ts-expect-error - Handler doesn't take arguments in this case + expect(skillsCommand.handler()).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/commands/skills.tsx b/packages/cli/src/commands/skills.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8a51c4150e57e186f37b36ee0cc9a847cd15d007 --- /dev/null +++ b/packages/cli/src/commands/skills.tsx @@ -0,0 +1,39 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import { listCommand } from './skills/list.js'; +import { enableCommand } from './skills/enable.js'; +import { disableCommand } from './skills/disable.js'; +import { installCommand } from './skills/install.js'; +import { linkCommand } from './skills/link.js'; +import { uninstallCommand } from './skills/uninstall.js'; +import { initializeOutputListenersAndFlush } from '../gemini.js'; +import { defer } from '../deferred.js'; + +export const skillsCommand: CommandModule = { + command: 'skills ', + aliases: ['skill'], + describe: 'Manage agent skills.', + builder: (yargs) => + yargs + .middleware((argv) => { + initializeOutputListenersAndFlush(); + argv['isCommand'] = true; + }) + .command(defer(listCommand, 'skills')) + .command(defer(enableCommand, 'skills')) + .command(defer(disableCommand, 'skills')) + .command(defer(installCommand, 'skills')) + .command(defer(linkCommand, 'skills')) + .command(defer(uninstallCommand, 'skills')) + .demandCommand(1, 'You need at least one command before continuing.') + .version(false), + handler: () => { + // This handler is not called when a subcommand is provided. + // Yargs will show the help menu. + }, +}; diff --git a/packages/cli/src/commands/skills/disable.test.ts b/packages/cli/src/commands/skills/disable.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..531a08d21b596e2093abd0382490f19a6e29b58d --- /dev/null +++ b/packages/cli/src/commands/skills/disable.test.ts @@ -0,0 +1,142 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { handleDisable, disableCommand } from './disable.js'; +import { + loadSettings, + SettingScope, + type LoadedSettings, + type LoadableSettingScope, +} from '../../config/settings.js'; + +const { emitConsoleLog, debugLogger } = await vi.hoisted(async () => { + const { createMockDebugLogger } = await import( + '../../test-utils/mockDebugLogger.js' + ); + return createMockDebugLogger({ stripAnsi: true }); +}); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + debugLogger, + }; +}); + +vi.mock('../../config/settings.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + loadSettings: vi.fn(), + isLoadableSettingScope: vi.fn((s) => s === 'User' || s === 'Workspace'), + }; +}); + +vi.mock('../utils.js', () => ({ + exitCli: vi.fn(), +})); + +describe('skills disable command', () => { + const mockLoadSettings = vi.mocked(loadSettings); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('handleDisable', () => { + it('should disable an enabled skill in user scope', async () => { + const mockSettings = { + forScope: vi.fn().mockReturnValue({ + settings: { skills: { disabled: [] } }, + path: '/user/settings.json', + }), + setValue: vi.fn(), + }; + mockLoadSettings.mockReturnValue( + mockSettings as unknown as LoadedSettings, + ); + + await handleDisable({ + name: 'skill1', + scope: SettingScope.User as LoadableSettingScope, + }); + + expect(mockSettings.setValue).toHaveBeenCalledWith( + SettingScope.User, + 'skills.disabled', + ['skill1'], + ); + expect(emitConsoleLog).toHaveBeenCalledWith( + 'log', + 'Skill "skill1" disabled by adding it to the disabled list in user (/user/settings.json) settings.', + ); + }); + + it('should disable an enabled skill in workspace scope', async () => { + const mockSettings = { + forScope: vi.fn().mockReturnValue({ + settings: { skills: { disabled: [] } }, + path: '/workspace/.gemini/settings.json', + }), + setValue: vi.fn(), + }; + mockLoadSettings.mockReturnValue( + mockSettings as unknown as LoadedSettings, + ); + + await handleDisable({ + name: 'skill1', + scope: SettingScope.Workspace as LoadableSettingScope, + }); + + expect(mockSettings.setValue).toHaveBeenCalledWith( + SettingScope.Workspace, + 'skills.disabled', + ['skill1'], + ); + expect(emitConsoleLog).toHaveBeenCalledWith( + 'log', + 'Skill "skill1" disabled by adding it to the disabled list in workspace (/workspace/.gemini/settings.json) settings.', + ); + }); + + it('should log a message if the skill is already disabled', async () => { + const mockSettings = { + forScope: vi.fn().mockReturnValue({ + settings: { skills: { disabled: ['skill1'] } }, + path: '/user/settings.json', + }), + setValue: vi.fn(), + }; + vi.mocked(loadSettings).mockReturnValue( + mockSettings as unknown as LoadedSettings, + ); + + await handleDisable({ name: 'skill1', scope: SettingScope.User }); + + expect(mockSettings.setValue).not.toHaveBeenCalled(); + expect(emitConsoleLog).toHaveBeenCalledWith( + 'log', + 'Skill "skill1" is already disabled.', + ); + }); + }); + + describe('disableCommand', () => { + it('should have correct command and describe', () => { + expect(disableCommand.command).toBe('disable [--scope]'); + expect(disableCommand.describe).toBe('Disables an agent skill.'); + }); + }); +}); diff --git a/packages/cli/src/commands/skills/disable.ts b/packages/cli/src/commands/skills/disable.ts new file mode 100644 index 0000000000000000000000000000000000000000..59a74fd3c52749e83547046387b35e7e87b2a507 --- /dev/null +++ b/packages/cli/src/commands/skills/disable.ts @@ -0,0 +1,62 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import { loadSettings, SettingScope } from '../../config/settings.js'; +import { debugLogger } from '@google/gemini-cli-core'; +import { exitCli } from '../utils.js'; +import { disableSkill } from '../../utils/skillSettings.js'; +import { renderSkillActionFeedback } from '../../utils/skillUtils.js'; +import chalk from 'chalk'; + +interface DisableArgs { + name: string; + scope: SettingScope; +} + +export async function handleDisable(args: DisableArgs) { + const { name, scope } = args; + const workspaceDir = process.cwd(); + const settings = loadSettings(workspaceDir); + + const result = disableSkill(settings, name, scope); + const feedback = renderSkillActionFeedback( + result, + (label, path) => `${chalk.bold(label)} (${chalk.dim(path)})`, + ); + debugLogger.log(feedback); +} + +export const disableCommand: CommandModule = { + command: 'disable [--scope]', + describe: 'Disables an agent skill.', + builder: (yargs) => + yargs + .positional('name', { + describe: 'The name of the skill to disable.', + type: 'string', + demandOption: true, + }) + .option('scope', { + alias: 's', + describe: 'The scope to disable the skill in (user or workspace).', + type: 'string', + default: 'workspace', + choices: ['user', 'workspace'], + }), + handler: async (argv) => { + const scope = + argv['scope'] === 'workspace' + ? SettingScope.Workspace + : SettingScope.User; + await handleDisable({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + name: argv['name'] as string, + scope, + }); + await exitCli(); + }, +}; diff --git a/packages/cli/src/commands/skills/enable.test.ts b/packages/cli/src/commands/skills/enable.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d34737d2df2b1374285b125c76f4bdbbfb675ea3 --- /dev/null +++ b/packages/cli/src/commands/skills/enable.test.ts @@ -0,0 +1,156 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { handleEnable, enableCommand } from './enable.js'; +import { + loadSettings, + SettingScope, + type LoadedSettings, +} from '../../config/settings.js'; + +const { emitConsoleLog, debugLogger } = await vi.hoisted(async () => { + const { createMockDebugLogger } = await import( + '../../test-utils/mockDebugLogger.js' + ); + return createMockDebugLogger({ stripAnsi: true }); +}); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + debugLogger, + }; +}); + +vi.mock('../../config/settings.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + loadSettings: vi.fn(), + isLoadableSettingScope: vi.fn((s) => s === 'User' || s === 'Workspace'), + }; +}); + +vi.mock('../utils.js', () => ({ + exitCli: vi.fn(), +})); + +describe('skills enable command', () => { + const mockLoadSettings = vi.mocked(loadSettings); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('handleEnable', () => { + it('should enable a disabled skill in user scope', async () => { + const mockSettings = { + forScope: vi.fn().mockImplementation((scope) => { + if (scope === SettingScope.User) { + return { + settings: { skills: { disabled: ['skill1'] } }, + path: '/user/settings.json', + }; + } + return { settings: {}, path: '/workspace/settings.json' }; + }), + setValue: vi.fn(), + }; + mockLoadSettings.mockReturnValue( + mockSettings as unknown as LoadedSettings, + ); + + await handleEnable({ name: 'skill1' }); + + expect(mockSettings.setValue).toHaveBeenCalledWith( + SettingScope.User, + 'skills.disabled', + [], + ); + expect(emitConsoleLog).toHaveBeenCalledWith( + 'log', + 'Skill "skill1" enabled by removing it from the disabled list in user (/user/settings.json) and workspace (/workspace/settings.json) settings.', + ); + }); + + it('should enable a skill across multiple scopes', async () => { + const mockSettings = { + forScope: vi.fn().mockImplementation((scope) => { + if (scope === SettingScope.User) { + return { + settings: { skills: { disabled: ['skill1'] } }, + path: '/user/settings.json', + }; + } + if (scope === SettingScope.Workspace) { + return { + settings: { skills: { disabled: ['skill1'] } }, + path: '/workspace/settings.json', + }; + } + return { settings: {}, path: '' }; + }), + setValue: vi.fn(), + }; + mockLoadSettings.mockReturnValue( + mockSettings as unknown as LoadedSettings, + ); + + await handleEnable({ name: 'skill1' }); + + expect(mockSettings.setValue).toHaveBeenCalledWith( + SettingScope.User, + 'skills.disabled', + [], + ); + expect(mockSettings.setValue).toHaveBeenCalledWith( + SettingScope.Workspace, + 'skills.disabled', + [], + ); + expect(emitConsoleLog).toHaveBeenCalledWith( + 'log', + 'Skill "skill1" enabled by removing it from the disabled list in workspace (/workspace/settings.json) and user (/user/settings.json) settings.', + ); + }); + + it('should log a message if the skill is already enabled', async () => { + const mockSettings = { + forScope: vi.fn().mockReturnValue({ + settings: { skills: { disabled: [] } }, + path: '/user/settings.json', + }), + setValue: vi.fn(), + }; + mockLoadSettings.mockReturnValue( + mockSettings as unknown as LoadedSettings, + ); + + await handleEnable({ name: 'skill1' }); + + expect(mockSettings.setValue).not.toHaveBeenCalled(); + expect(emitConsoleLog).toHaveBeenCalledWith( + 'log', + 'Skill "skill1" is already enabled.', + ); + }); + }); + + describe('enableCommand', () => { + it('should have correct command and describe', () => { + expect(enableCommand.command).toBe('enable '); + expect(enableCommand.describe).toBe('Enables an agent skill.'); + }); + }); +}); diff --git a/packages/cli/src/commands/skills/enable.ts b/packages/cli/src/commands/skills/enable.ts new file mode 100644 index 0000000000000000000000000000000000000000..6f58cf471ee29d9f34f11348d13b905e74b873ee --- /dev/null +++ b/packages/cli/src/commands/skills/enable.ts @@ -0,0 +1,48 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import { loadSettings } from '../../config/settings.js'; +import { debugLogger } from '@google/gemini-cli-core'; +import { exitCli } from '../utils.js'; +import { enableSkill } from '../../utils/skillSettings.js'; +import { renderSkillActionFeedback } from '../../utils/skillUtils.js'; +import chalk from 'chalk'; + +interface EnableArgs { + name: string; +} + +export async function handleEnable(args: EnableArgs) { + const { name } = args; + const workspaceDir = process.cwd(); + const settings = loadSettings(workspaceDir); + + const result = enableSkill(settings, name); + const feedback = renderSkillActionFeedback( + result, + (label, path) => `${chalk.bold(label)} (${chalk.dim(path)})`, + ); + debugLogger.log(feedback); +} + +export const enableCommand: CommandModule = { + command: 'enable ', + describe: 'Enables an agent skill.', + builder: (yargs) => + yargs.positional('name', { + describe: 'The name of the skill to enable.', + type: 'string', + demandOption: true, + }), + handler: async (argv) => { + await handleEnable({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + name: argv['name'] as string, + }); + await exitCli(); + }, +}; diff --git a/packages/cli/src/commands/skills/install.test.ts b/packages/cli/src/commands/skills/install.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..db2548950d276110fc9c193bdb582dfb8bee710c --- /dev/null +++ b/packages/cli/src/commands/skills/install.test.ts @@ -0,0 +1,156 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach } from 'vitest'; + +const mockInstallSkill = vi.hoisted(() => vi.fn()); +const mockRequestConsentNonInteractive = vi.hoisted(() => vi.fn()); +const mockSkillsConsentString = vi.hoisted(() => vi.fn()); + +vi.mock('../../utils/skillUtils.js', () => ({ + installSkill: mockInstallSkill, +})); + +vi.mock('../../config/extensions/consent.js', () => ({ + requestConsentNonInteractive: mockRequestConsentNonInteractive, + skillsConsentString: mockSkillsConsentString, +})); + +const { debugLogger, emitConsoleLog } = await vi.hoisted(async () => { + const { createMockDebugLogger } = await import( + '../../test-utils/mockDebugLogger.js' + ); + return createMockDebugLogger({ stripAnsi: true }); +}); + +vi.mock('@google/gemini-cli-core', () => ({ + debugLogger, + getErrorMessage: vi.fn((e: unknown) => + e instanceof Error ? e.message : String(e), + ), +})); + +import { handleInstall, installCommand } from './install.js'; + +describe('skill install command', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + mockSkillsConsentString.mockResolvedValue('Mock Consent String'); + mockRequestConsentNonInteractive.mockResolvedValue(true); + }); + + describe('installCommand', () => { + it('should have correct command and describe', () => { + expect(installCommand.command).toBe( + 'install [--scope] [--path]', + ); + expect(installCommand.describe).toBe( + 'Installs an agent skill from a git repository URL or a local path.', + ); + }); + }); + + it('should call installSkill with correct arguments for user scope', async () => { + mockInstallSkill.mockImplementation(async (_s, _sc, _p, _ol, rc) => { + await rc([]); + return [{ name: 'test-skill', location: '/mock/user/skills/test-skill' }]; + }); + + await handleInstall({ + source: 'https://example.com/repo.git', + scope: 'user', + }); + + expect(mockInstallSkill).toHaveBeenCalledWith( + 'https://example.com/repo.git', + 'user', + undefined, + expect.any(Function), + expect.any(Function), + ); + expect(emitConsoleLog).toHaveBeenCalledWith( + 'log', + expect.stringContaining('Successfully installed skill: test-skill'), + ); + expect(emitConsoleLog).toHaveBeenCalledWith( + 'log', + expect.stringContaining('location: /mock/user/skills/test-skill'), + ); + expect(mockRequestConsentNonInteractive).toHaveBeenCalledWith( + 'Mock Consent String', + ); + }); + + it('should skip prompt and log consent when --consent is provided', async () => { + mockInstallSkill.mockImplementation(async (_s, _sc, _p, _ol, rc) => { + await rc([]); + return [{ name: 'test-skill', location: '/mock/user/skills/test-skill' }]; + }); + + await handleInstall({ + source: 'https://example.com/repo.git', + consent: true, + }); + + expect(mockRequestConsentNonInteractive).not.toHaveBeenCalled(); + expect(emitConsoleLog).toHaveBeenCalledWith( + 'log', + 'You have consented to the following:', + ); + expect(emitConsoleLog).toHaveBeenCalledWith('log', 'Mock Consent String'); + expect(mockInstallSkill).toHaveBeenCalled(); + }); + + it('should abort installation if consent is denied', async () => { + mockRequestConsentNonInteractive.mockResolvedValue(false); + mockInstallSkill.mockImplementation(async (_s, _sc, _p, _ol, rc) => { + if (!(await rc([]))) { + throw new Error('Skill installation cancelled by user.'); + } + return []; + }); + + await handleInstall({ + source: 'https://example.com/repo.git', + }); + + expect(emitConsoleLog).toHaveBeenCalledWith( + 'error', + 'Skill installation cancelled by user.', + ); + expect(process.exit).toHaveBeenCalledWith(1); + }); + + it('should call installSkill with correct arguments for workspace scope and subpath', async () => { + mockInstallSkill.mockResolvedValue([ + { name: 'test-skill', location: '/mock/workspace/skills/test-skill' }, + ]); + + await handleInstall({ + source: 'https://example.com/repo.git', + scope: 'workspace', + path: 'my-skills-dir', + }); + + expect(mockInstallSkill).toHaveBeenCalledWith( + 'https://example.com/repo.git', + 'workspace', + 'my-skills-dir', + expect.any(Function), + expect.any(Function), + ); + }); + + it('should handle errors gracefully', async () => { + mockInstallSkill.mockRejectedValue(new Error('Install failed')); + + await handleInstall({ source: '/local/path' }); + + expect(emitConsoleLog).toHaveBeenCalledWith('error', 'Install failed'); + expect(process.exit).toHaveBeenCalledWith(1); + }); +}); diff --git a/packages/cli/src/commands/skills/install.ts b/packages/cli/src/commands/skills/install.ts new file mode 100644 index 0000000000000000000000000000000000000000..75dad58f0f403ecdef030277c547b10dcbe59dbe --- /dev/null +++ b/packages/cli/src/commands/skills/install.ts @@ -0,0 +1,119 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import { + debugLogger, + type SkillDefinition, + getErrorMessage, +} from '@google/gemini-cli-core'; +import { exitCli } from '../utils.js'; +import { installSkill } from '../../utils/skillUtils.js'; +import chalk from 'chalk'; +import { + requestConsentNonInteractive, + skillsConsentString, +} from '../../config/extensions/consent.js'; + +interface InstallArgs { + source: string; + scope?: 'user' | 'workspace'; + path?: string; + consent?: boolean; +} + +export async function handleInstall(args: InstallArgs) { + try { + const { source, consent } = args; + const scope = args.scope ?? 'user'; + const subpath = args.path; + + const requestConsent = async ( + skills: SkillDefinition[], + targetDir: string, + ) => { + if (consent) { + debugLogger.log('You have consented to the following:'); + debugLogger.log(await skillsConsentString(skills, source, targetDir)); + return true; + } + return requestConsentNonInteractive( + await skillsConsentString(skills, source, targetDir), + ); + }; + + const installedSkills = await installSkill( + source, + scope, + subpath, + (msg) => { + debugLogger.log(msg); + }, + requestConsent, + ); + + for (const skill of installedSkills) { + debugLogger.log( + chalk.green( + `Successfully installed skill: ${chalk.bold(skill.name)} (scope: ${scope}, location: ${skill.location})`, + ), + ); + } + } catch (error) { + debugLogger.error(getErrorMessage(error)); + await exitCli(1); + } +} + +export const installCommand: CommandModule = { + command: 'install [--scope] [--path]', + describe: + 'Installs an agent skill from a git repository URL or a local path.', + builder: (yargs) => + yargs + .positional('source', { + describe: + 'The git repository URL or local path of the skill to install.', + type: 'string', + demandOption: true, + }) + .option('scope', { + describe: + 'The scope to install the skill into. Defaults to "user" (global).', + choices: ['user', 'workspace'], + default: 'user', + }) + .option('path', { + describe: + 'Sub-path within the repository to install from (only used for git repository sources).', + type: 'string', + }) + .option('consent', { + describe: + 'Acknowledge the security risks of installing a skill and skip the confirmation prompt.', + type: 'boolean', + default: false, + }) + .check((argv) => { + if (!argv.source) { + throw new Error('The source argument must be provided.'); + } + return true; + }), + handler: async (argv) => { + await handleInstall({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + source: argv['source'] as string, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + scope: argv['scope'] as 'user' | 'workspace', + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + path: argv['path'] as string | undefined, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + consent: argv['consent'] as boolean | undefined, + }); + await exitCli(); + }, +}; diff --git a/packages/cli/src/commands/skills/link.test.ts b/packages/cli/src/commands/skills/link.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..e6614409520d4e3152e090835379821a67e24de0 --- /dev/null +++ b/packages/cli/src/commands/skills/link.test.ts @@ -0,0 +1,77 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { handleLink, linkCommand } from './link.js'; + +const mockLinkSkill = vi.hoisted(() => vi.fn()); +const mockRequestConsentNonInteractive = vi.hoisted(() => vi.fn()); +const mockSkillsConsentString = vi.hoisted(() => vi.fn()); + +vi.mock('../../utils/skillUtils.js', () => ({ + linkSkill: mockLinkSkill, +})); + +const { debugLogger } = await vi.hoisted(async () => { + const { createMockDebugLogger } = await import( + '../../test-utils/mockDebugLogger.js' + ); + return createMockDebugLogger({ stripAnsi: false }); +}); + +vi.mock('@google/gemini-cli-core', () => ({ + debugLogger, + getErrorMessage: vi.fn((e: unknown) => + e instanceof Error ? e.message : String(e), + ), +})); + +vi.mock('../../config/extensions/consent.js', () => ({ + requestConsentNonInteractive: mockRequestConsentNonInteractive, + skillsConsentString: mockSkillsConsentString, +})); + +describe('skills link command', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + }); + + describe('linkCommand', () => { + it('should have correct command and describe', () => { + expect(linkCommand.command).toBe('link '); + expect(linkCommand.describe).toContain('Links an agent skill'); + }); + }); + + it('should call linkSkill with correct arguments', async () => { + const sourcePath = '/source/path'; + mockLinkSkill.mockResolvedValue([ + { name: 'test-skill', location: '/dest/path' }, + ]); + + await handleLink({ path: sourcePath, scope: 'user' }); + + expect(mockLinkSkill).toHaveBeenCalledWith( + sourcePath, + 'user', + expect.any(Function), + expect.any(Function), + ); + expect(debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining('Successfully linked skills'), + ); + }); + + it('should handle linkSkill failure', async () => { + mockLinkSkill.mockRejectedValue(new Error('Link failed')); + + await handleLink({ path: '/some/path' }); + + expect(debugLogger.error).toHaveBeenCalledWith('Link failed'); + expect(process.exit).toHaveBeenCalledWith(1); + }); +}); diff --git a/packages/cli/src/commands/skills/link.ts b/packages/cli/src/commands/skills/link.ts new file mode 100644 index 0000000000000000000000000000000000000000..3a03b93e6b60e277406d7a81ed5b15b6aa05b1ad --- /dev/null +++ b/packages/cli/src/commands/skills/link.ts @@ -0,0 +1,95 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import { debugLogger, getErrorMessage } from '@google/gemini-cli-core'; +import chalk from 'chalk'; + +import { exitCli } from '../utils.js'; +import { + requestConsentNonInteractive, + skillsConsentString, +} from '../../config/extensions/consent.js'; +import { linkSkill } from '../../utils/skillUtils.js'; + +interface LinkArgs { + path: string; + scope?: 'user' | 'workspace'; + consent?: boolean; +} + +export async function handleLink(args: LinkArgs) { + try { + const { scope = 'user', consent } = args; + + await linkSkill( + args.path, + scope, + (msg) => debugLogger.log(msg), + async (skills, targetDir) => { + const consentString = await skillsConsentString( + skills, + args.path, + targetDir, + true, + ); + if (consent) { + debugLogger.log('You have consented to the following:'); + debugLogger.log(consentString); + return true; + } + return requestConsentNonInteractive(consentString); + }, + ); + + debugLogger.log(chalk.green('\nSuccessfully linked skills.')); + } catch (error) { + debugLogger.error(getErrorMessage(error)); + await exitCli(1); + } +} + +export const linkCommand: CommandModule = { + command: 'link ', + describe: + 'Links an agent skill from a local path. Updates to the source will be reflected immediately.', + builder: (yargs) => + yargs + .positional('path', { + describe: 'The local path of the skill to link.', + type: 'string', + demandOption: true, + }) + .option('scope', { + describe: + 'The scope to link the skill into. Defaults to "user" (global).', + choices: ['user', 'workspace'], + default: 'user', + }) + .option('consent', { + describe: + 'Acknowledge the security risks of linking a skill and skip the confirmation prompt.', + type: 'boolean', + default: false, + }) + .check((argv) => { + if (!argv.path) { + throw new Error('The path argument must be provided.'); + } + return true; + }), + handler: async (argv) => { + await handleLink({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + path: argv['path'] as string, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + scope: argv['scope'] as 'user' | 'workspace', + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + consent: argv['consent'] as boolean | undefined, + }); + await exitCli(); + }, +}; diff --git a/packages/cli/src/commands/skills/list.test.ts b/packages/cli/src/commands/skills/list.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..37f6b26613d2af47919c35a103a6c5c4408c13fc --- /dev/null +++ b/packages/cli/src/commands/skills/list.test.ts @@ -0,0 +1,179 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + vi, + describe, + it, + expect, + beforeEach, + afterEach, + type MockInstance, +} from 'vitest'; +import { type Config } from '@google/gemini-cli-core'; +import { handleList, listCommand } from './list.js'; +import { loadSettings, type LoadedSettings } from '../../config/settings.js'; +import { loadCliConfig } from '../../config/config.js'; +import chalk from 'chalk'; + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const { mockCoreDebugLogger } = await import( + '../../test-utils/mockDebugLogger.js' + ); + return mockCoreDebugLogger( + await importOriginal(), + { + stripAnsi: false, + }, + ); +}); + +vi.mock('../../config/settings.js'); +vi.mock('../../config/config.js'); +vi.mock('../utils.js', () => ({ + exitCli: vi.fn(), +})); + +describe('skills list command', () => { + const mockLoadSettings = vi.mocked(loadSettings); + const mockLoadCliConfig = vi.mocked(loadCliConfig); + let stdoutWriteSpy: MockInstance; + + beforeEach(async () => { + vi.clearAllMocks(); + mockLoadSettings.mockReturnValue({ + merged: {}, + } as unknown as LoadedSettings); + stdoutWriteSpy = vi + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('handleList', () => { + it('should log a message if no skills are discovered', async () => { + const mockConfig = { + initialize: vi.fn().mockResolvedValue(undefined), + getSkillManager: vi.fn().mockReturnValue({ + getAllSkills: vi.fn().mockReturnValue([]), + }), + }; + mockLoadCliConfig.mockResolvedValue(mockConfig as unknown as Config); + + await handleList({}); + + expect(stdoutWriteSpy).toHaveBeenCalledWith('No skills discovered.\n'); + }); + + it('should list all discovered skills', async () => { + const skills = [ + { + name: 'skill1', + description: 'desc1', + disabled: false, + location: '/path/to/skill1', + }, + { + name: 'skill2', + description: 'desc2', + disabled: true, + location: '/path/to/skill2', + }, + ]; + const mockConfig = { + initialize: vi.fn().mockResolvedValue(undefined), + getSkillManager: vi.fn().mockReturnValue({ + getAllSkills: vi.fn().mockReturnValue(skills), + }), + }; + mockLoadCliConfig.mockResolvedValue(mockConfig as unknown as Config); + + await handleList({}); + + expect(stdoutWriteSpy).toHaveBeenCalledWith( + chalk.bold('Discovered Agent Skills:') + '\n\n', + ); + expect(stdoutWriteSpy).toHaveBeenCalledWith( + expect.stringContaining('skill1'), + ); + expect(stdoutWriteSpy).toHaveBeenCalledWith( + expect.stringContaining(chalk.green('[Enabled]')), + ); + expect(stdoutWriteSpy).toHaveBeenCalledWith( + expect.stringContaining('skill2'), + ); + expect(stdoutWriteSpy).toHaveBeenCalledWith( + expect.stringContaining(chalk.red('[Disabled]')), + ); + }); + + it('should filter built-in skills by default and show them with { all: true }', async () => { + const skills = [ + { + name: 'regular', + description: 'desc1', + disabled: false, + location: '/loc1', + }, + { + name: 'builtin', + description: 'desc2', + disabled: false, + location: '/loc2', + isBuiltin: true, + }, + ]; + const mockConfig = { + initialize: vi.fn().mockResolvedValue(undefined), + getSkillManager: vi.fn().mockReturnValue({ + getAllSkills: vi.fn().mockReturnValue(skills), + }), + }; + mockLoadCliConfig.mockResolvedValue(mockConfig as unknown as Config); + + // Default + await handleList({ all: false }); + expect(stdoutWriteSpy).toHaveBeenCalledWith( + expect.stringContaining('regular'), + ); + expect(stdoutWriteSpy).not.toHaveBeenCalledWith( + expect.stringContaining('builtin'), + ); + + vi.clearAllMocks(); + + // With all: true + await handleList({ all: true }); + expect(stdoutWriteSpy).toHaveBeenCalledWith( + expect.stringContaining('regular'), + ); + expect(stdoutWriteSpy).toHaveBeenCalledWith( + expect.stringContaining('builtin'), + ); + expect(stdoutWriteSpy).toHaveBeenCalledWith( + expect.stringContaining(chalk.gray(' [Built-in]')), + ); + }); + + it('should throw an error when listing fails', async () => { + mockLoadCliConfig.mockRejectedValue(new Error('List failed')); + + await expect(handleList({})).rejects.toThrow('List failed'); + }); + }); + + describe('listCommand', () => { + const command = listCommand; + + it('should have correct command and describe', () => { + expect(command.command).toBe('list [--all]'); + expect(command.describe).toBe('Lists discovered agent skills.'); + }); + }); +}); diff --git a/packages/cli/src/commands/skills/list.ts b/packages/cli/src/commands/skills/list.ts new file mode 100644 index 0000000000000000000000000000000000000000..bc05c6c2afa3cb8686d7e9ca0321b8c1abad4960 --- /dev/null +++ b/packages/cli/src/commands/skills/list.ts @@ -0,0 +1,79 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import { loadSettings } from '../../config/settings.js'; +import { loadCliConfig, type CliArgs } from '../../config/config.js'; +import { exitCli } from '../utils.js'; +import chalk from 'chalk'; + +export async function handleList(args: { all?: boolean }) { + const workspaceDir = process.cwd(); + const settings = loadSettings(workspaceDir); + + const config = await loadCliConfig( + settings.merged, + 'skills-list-session', + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + { + debug: false, + } as Partial as CliArgs, + { cwd: workspaceDir }, + ); + + // Initialize to trigger extension loading and skill discovery + await config.initialize(); + + const skillManager = config.getSkillManager(); + const skills = args.all + ? skillManager.getAllSkills() + : skillManager.getAllSkills().filter((s) => !s.isBuiltin); + + // Sort skills: non-built-in first, then alphabetically by name + skills.sort((a, b) => { + if (a.isBuiltin === b.isBuiltin) { + return a.name.localeCompare(b.name); + } + return a.isBuiltin ? 1 : -1; + }); + + if (skills.length === 0) { + process.stdout.write('No skills discovered.\n'); + return; + } + + process.stdout.write(chalk.bold('Discovered Agent Skills:') + '\n\n'); + + for (const skill of skills) { + const status = skill.disabled + ? chalk.red('[Disabled]') + : chalk.green('[Enabled]'); + + const builtinSuffix = skill.isBuiltin ? chalk.gray(' [Built-in]') : ''; + + process.stdout.write( + `${chalk.bold(skill.name)} ${status}${builtinSuffix}\n`, + ); + process.stdout.write(` Description: ${skill.description}\n`); + process.stdout.write(` Location: ${skill.location}\n\n`); + } +} + +export const listCommand: CommandModule = { + command: 'list [--all]', + describe: 'Lists discovered agent skills.', + builder: (yargs) => + yargs.option('all', { + type: 'boolean', + description: 'Show all skills, including built-in ones.', + default: false, + }), + handler: async (argv) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + await handleList({ all: argv['all'] as boolean }); + await exitCli(); + }, +}; diff --git a/packages/cli/src/commands/skills/uninstall.test.ts b/packages/cli/src/commands/skills/uninstall.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..e12bda53537e1f04130447a773e8c15ff4b8ed82 --- /dev/null +++ b/packages/cli/src/commands/skills/uninstall.test.ts @@ -0,0 +1,99 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mockUninstallSkill = vi.hoisted(() => vi.fn()); + +vi.mock('../../utils/skillUtils.js', () => ({ + uninstallSkill: mockUninstallSkill, +})); + +const { debugLogger, emitConsoleLog } = await vi.hoisted(async () => { + const { createMockDebugLogger } = await import( + '../../test-utils/mockDebugLogger.js' + ); + return createMockDebugLogger({ stripAnsi: true }); +}); + +vi.mock('@google/gemini-cli-core', () => ({ + debugLogger, + getErrorMessage: vi.fn((e: unknown) => + e instanceof Error ? e.message : String(e), + ), +})); + +import { handleUninstall, uninstallCommand } from './uninstall.js'; + +describe('skill uninstall command', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + }); + + describe('uninstallCommand', () => { + it('should have correct command and describe', () => { + expect(uninstallCommand.command).toBe('uninstall [--scope]'); + expect(uninstallCommand.describe).toBe( + 'Uninstalls an agent skill by name.', + ); + }); + }); + + it('should call uninstallSkill with correct arguments for user scope', async () => { + mockUninstallSkill.mockResolvedValue({ + location: '/mock/user/skills/test-skill', + }); + + await handleUninstall({ + name: 'test-skill', + scope: 'user', + }); + + expect(mockUninstallSkill).toHaveBeenCalledWith('test-skill', 'user'); + expect(emitConsoleLog).toHaveBeenCalledWith( + 'log', + expect.stringContaining('Successfully uninstalled skill: test-skill'), + ); + expect(emitConsoleLog).toHaveBeenCalledWith( + 'log', + expect.stringContaining('location: /mock/user/skills/test-skill'), + ); + }); + + it('should call uninstallSkill with correct arguments for workspace scope', async () => { + mockUninstallSkill.mockResolvedValue({ + location: '/mock/workspace/skills/test-skill', + }); + + await handleUninstall({ + name: 'test-skill', + scope: 'workspace', + }); + + expect(mockUninstallSkill).toHaveBeenCalledWith('test-skill', 'workspace'); + }); + + it('should log an error if skill is not found', async () => { + mockUninstallSkill.mockResolvedValue(null); + + await handleUninstall({ name: 'test-skill' }); + + expect(emitConsoleLog).toHaveBeenCalledWith( + 'error', + 'Skill "test-skill" is not installed in the user scope.', + ); + }); + + it('should handle errors gracefully', async () => { + mockUninstallSkill.mockRejectedValue(new Error('Uninstall failed')); + + await handleUninstall({ name: 'test-skill' }); + + expect(emitConsoleLog).toHaveBeenCalledWith('error', 'Uninstall failed'); + expect(process.exit).toHaveBeenCalledWith(1); + }); +}); diff --git a/packages/cli/src/commands/skills/uninstall.ts b/packages/cli/src/commands/skills/uninstall.ts new file mode 100644 index 0000000000000000000000000000000000000000..cfcb67da2144fc82edbfabbb29131d10aa7e4c24 --- /dev/null +++ b/packages/cli/src/commands/skills/uninstall.ts @@ -0,0 +1,73 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import { debugLogger, getErrorMessage } from '@google/gemini-cli-core'; +import { exitCli } from '../utils.js'; +import { uninstallSkill } from '../../utils/skillUtils.js'; +import chalk from 'chalk'; + +interface UninstallArgs { + name: string; + scope?: 'user' | 'workspace'; +} + +export async function handleUninstall(args: UninstallArgs) { + try { + const { name } = args; + const scope = args.scope ?? 'user'; + + const result = await uninstallSkill(name, scope); + + if (result) { + debugLogger.log( + chalk.green( + `Successfully uninstalled skill: ${chalk.bold(name)} (scope: ${scope}, location: ${result.location})`, + ), + ); + } else { + debugLogger.error( + `Skill "${name}" is not installed in the ${scope} scope.`, + ); + } + } catch (error) { + debugLogger.error(getErrorMessage(error)); + await exitCli(1); + } +} + +export const uninstallCommand: CommandModule = { + command: 'uninstall [--scope]', + describe: 'Uninstalls an agent skill by name.', + builder: (yargs) => + yargs + .positional('name', { + describe: 'The name of the skill to uninstall.', + type: 'string', + demandOption: true, + }) + .option('scope', { + describe: + 'The scope to uninstall the skill from. Defaults to "user" (global).', + choices: ['user', 'workspace'], + default: 'user', + }) + .check((argv) => { + if (!argv.name) { + throw new Error('The skill name must be provided.'); + } + return true; + }), + handler: async (argv) => { + await handleUninstall({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + name: argv['name'] as string, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + scope: argv['scope'] as 'user' | 'workspace', + }); + await exitCli(); + }, +}; diff --git a/packages/cli/src/commands/utils.test.ts b/packages/cli/src/commands/utils.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..648893ab25881683ed5435abbde987285b0536f0 --- /dev/null +++ b/packages/cli/src/commands/utils.test.ts @@ -0,0 +1,41 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { exitCli } from './utils.js'; +import { runExitCleanup } from '../utils/cleanup.js'; + +vi.mock('../utils/cleanup.js', () => ({ + runExitCleanup: vi.fn(), +})); + +describe('utils', () => { + const originalProcessExit = process.exit; + + beforeEach(() => { + // @ts-expect-error - Mocking process.exit + process.exit = vi.fn(); + }); + + afterEach(() => { + process.exit = originalProcessExit; + vi.clearAllMocks(); + }); + + describe('exitCli', () => { + it('should call runExitCleanup and process.exit with default exit code 0', async () => { + await exitCli(); + expect(runExitCleanup).toHaveBeenCalled(); + expect(process.exit).toHaveBeenCalledWith(0); + }); + + it('should call runExitCleanup and process.exit with specified exit code', async () => { + await exitCli(1); + expect(runExitCleanup).toHaveBeenCalled(); + expect(process.exit).toHaveBeenCalledWith(1); + }); + }); +}); diff --git a/packages/cli/src/commands/utils.ts b/packages/cli/src/commands/utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..33c5c142a988415a358a7773b5fe0de7e376428d --- /dev/null +++ b/packages/cli/src/commands/utils.ts @@ -0,0 +1,12 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { runExitCleanup } from '../utils/cleanup.js'; + +export async function exitCli(exitCode = 0) { + await runExitCleanup(); + process.exit(exitCode); +} diff --git a/packages/cli/src/config/auth.test.ts b/packages/cli/src/config/auth.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..2360cf60e764b36105a521e2263c2691c177d37b --- /dev/null +++ b/packages/cli/src/config/auth.test.ts @@ -0,0 +1,108 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { AuthType } from '@google/gemini-cli-core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { validateAuthMethod } from './auth.js'; + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + loadApiKey: vi.fn().mockResolvedValue(null), + }; +}); + +vi.mock('./settings.js', () => ({ + loadEnvironment: vi.fn(), + loadSettings: vi.fn().mockReturnValue({ + merged: vi.fn().mockReturnValue({}), + }), +})); + +describe('validateAuthMethod', () => { + beforeEach(() => { + vi.stubEnv('GEMINI_API_KEY', undefined); + vi.stubEnv('GOOGLE_CLOUD_PROJECT', undefined); + vi.stubEnv('GOOGLE_CLOUD_LOCATION', undefined); + vi.stubEnv('GOOGLE_API_KEY', undefined); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it.each([ + { + description: 'should return null for LOGIN_WITH_GOOGLE', + authType: AuthType.LOGIN_WITH_GOOGLE, + envs: {}, + expected: null, + }, + { + description: 'should return null for COMPUTE_ADC', + authType: AuthType.COMPUTE_ADC, + envs: {}, + expected: null, + }, + { + description: 'should return null for USE_GEMINI if GEMINI_API_KEY is set', + authType: AuthType.USE_GEMINI, + envs: { GEMINI_API_KEY: 'test-key' }, + expected: null, + }, + { + description: + 'should return an error message for USE_GEMINI if GEMINI_API_KEY is not set', + authType: AuthType.USE_GEMINI, + envs: {}, + expected: + 'When using Gemini API, you must specify the GEMINI_API_KEY environment variable.\n' + + 'Update your environment and try again (no reload needed if using .env)!', + }, + { + description: + 'should return null for USE_VERTEX_AI if GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION are set', + authType: AuthType.USE_VERTEX_AI, + envs: { + GOOGLE_CLOUD_PROJECT: 'test-project', + GOOGLE_CLOUD_LOCATION: 'test-location', + }, + expected: null, + }, + { + description: + 'should return null for USE_VERTEX_AI if GOOGLE_API_KEY is set', + authType: AuthType.USE_VERTEX_AI, + envs: { GOOGLE_API_KEY: 'test-api-key' }, + expected: null, + }, + { + description: + 'should return an error message for USE_VERTEX_AI if no required environment variables are set', + authType: AuthType.USE_VERTEX_AI, + envs: {}, + expected: + 'When using Vertex AI, you must specify either:\n' + + '• GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION environment variables.\n' + + '• GOOGLE_API_KEY environment variable (if using express mode).\n' + + 'Update your environment and try again (no reload needed if using .env)!', + }, + { + description: 'should return an error message for an invalid auth method', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + authType: 'invalid-method' as any, + envs: {}, + expected: 'Invalid auth method selected.', + }, + ])('$description', async ({ authType, envs, expected }) => { + for (const [key, value] of Object.entries(envs)) { + vi.stubEnv(key, value as string); + } + expect(await validateAuthMethod(authType)).toBe(expected); + }); +}); diff --git a/packages/cli/src/config/auth.ts b/packages/cli/src/config/auth.ts new file mode 100644 index 0000000000000000000000000000000000000000..1ca07f98eb4c588b2b0bef0d627bc9b373bd6e7f --- /dev/null +++ b/packages/cli/src/config/auth.ts @@ -0,0 +1,49 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { AuthType, loadApiKey } from '@google/gemini-cli-core'; +import { loadEnvironment, loadSettings } from './settings.js'; + +export async function validateAuthMethod( + authMethod: string, +): Promise { + loadEnvironment(loadSettings().merged, process.cwd()); + if ( + authMethod === AuthType.LOGIN_WITH_GOOGLE || + authMethod === AuthType.COMPUTE_ADC + ) { + return null; + } + + if (authMethod === AuthType.USE_GEMINI) { + const key = process.env['GEMINI_API_KEY'] || (await loadApiKey()); + if (!key) { + return ( + 'When using Gemini API, you must specify the GEMINI_API_KEY environment variable.\n' + + 'Update your environment and try again (no reload needed if using .env)!' + ); + } + return null; + } + + if (authMethod === AuthType.USE_VERTEX_AI) { + const hasVertexProjectLocationConfig = + !!process.env['GOOGLE_CLOUD_PROJECT'] && + !!process.env['GOOGLE_CLOUD_LOCATION']; + const hasGoogleApiKey = !!process.env['GOOGLE_API_KEY']; + if (!hasVertexProjectLocationConfig && !hasGoogleApiKey) { + return ( + 'When using Vertex AI, you must specify either:\n' + + '• GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION environment variables.\n' + + '• GOOGLE_API_KEY environment variable (if using express mode).\n' + + 'Update your environment and try again (no reload needed if using .env)!' + ); + } + return null; + } + + return 'Invalid auth method selected.'; +} diff --git a/packages/cli/src/config/config.integration.test.ts b/packages/cli/src/config/config.integration.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..6797be4447a25a45e4b078cfdf9b5a67809665dc --- /dev/null +++ b/packages/cli/src/config/config.integration.test.ts @@ -0,0 +1,247 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { tmpdir } from 'node:os'; +import type { ConfigParameters } from '@google/gemini-cli-core'; +import { + Config, + DEFAULT_FILE_FILTERING_OPTIONS, +} from '@google/gemini-cli-core'; +import { createTestMergedSettings } from './settings.js'; +import { http, HttpResponse } from 'msw'; + +import { setupServer } from 'msw/node'; + +export const server = setupServer(); + +// TODO(richieforeman): Consider moving this to test setup globally. +beforeAll(() => { + server.listen({}); +}); + +afterEach(() => { + server.resetHandlers(); +}); + +afterAll(() => { + server.close(); +}); + +const CLEARCUT_URL = 'https://play.googleapis.com/log'; + +// Mock file discovery service and tool registry +vi.mock('@google/gemini-cli-core', async () => { + const actual = await vi.importActual('@google/gemini-cli-core'); + return { + ...actual, + FileDiscoveryService: vi.fn().mockImplementation(() => ({ + initialize: vi.fn(), + })), + createToolRegistry: vi.fn().mockResolvedValue({}), + }; +}); + +describe('Configuration Integration Tests', () => { + let tempDir: string; + + beforeEach(() => { + server.resetHandlers(http.post(CLEARCUT_URL, () => HttpResponse.text())); + + tempDir = fs.mkdtempSync(path.join(tmpdir(), 'gemini-cli-test-')); + vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true }); + } + }); + + describe('File Filtering and Configuration', () => { + it.each([ + { + description: + 'should load default file filtering settings when fileFiltering is missing', + fileFiltering: undefined, + expected: DEFAULT_FILE_FILTERING_OPTIONS.respectGitIgnore, + }, + { + description: + 'should load custom file filtering settings from configuration', + fileFiltering: { respectGitIgnore: false }, + expected: false, + }, + { + description: + 'should respect file filtering settings from configuration', + fileFiltering: { respectGitIgnore: true }, + expected: true, + }, + { + description: + 'should handle empty fileFiltering object gracefully and use defaults', + fileFiltering: {}, + expected: DEFAULT_FILE_FILTERING_OPTIONS.respectGitIgnore, + }, + ])('$description', async ({ fileFiltering, expected }) => { + const configParams: ConfigParameters = { + sessionId: 'test-session', + cwd: '/tmp', + model: 'test-model', + embeddingModel: 'test-embedding-model', + sandbox: undefined, + targetDir: tempDir, + debugMode: false, + fileFiltering, + }; + + const config = new Config(configParams); + + expect(config.getFileFilteringRespectGitIgnore()).toBe(expected); + }); + }); + + describe('Real-world Configuration Scenarios', () => { + it.each([ + { + description: 'should handle a security-focused configuration', + respectGitIgnore: true, + }, + { + description: 'should handle a CI/CD environment configuration', + respectGitIgnore: false, + }, + ])('$description', async ({ respectGitIgnore }) => { + const configParams: ConfigParameters = { + sessionId: 'test-session', + cwd: '/tmp', + model: 'test-model', + embeddingModel: 'test-embedding-model', + sandbox: undefined, + targetDir: tempDir, + debugMode: false, + fileFiltering: { + respectGitIgnore, + }, + }; + + const config = new Config(configParams); + + expect(config.getFileFilteringRespectGitIgnore()).toBe(respectGitIgnore); + }); + }); + + describe('Checkpointing Configuration', () => { + it('should enable checkpointing when the setting is true', async () => { + const configParams: ConfigParameters = { + sessionId: 'test-session', + cwd: '/tmp', + model: 'test-model', + embeddingModel: 'test-embedding-model', + sandbox: undefined, + targetDir: tempDir, + debugMode: false, + checkpointing: true, + }; + + const config = new Config(configParams); + + expect(config.getCheckpointingEnabled()).toBe(true); + }); + }); + + describe('Approval Mode Integration Tests', () => { + let parseArguments: typeof import('./config.js').parseArguments; + + beforeEach(async () => { + // Import the argument parsing function for integration testing + const { parseArguments: parseArgs } = await import('./config.js'); + parseArguments = parseArgs; + }); + + it.each([ + { + description: 'should parse --approval-mode=auto_edit correctly', + argv: [ + 'node', + 'script.js', + '--approval-mode', + 'auto_edit', + '-p', + 'test', + ], + expected: { approvalMode: 'auto_edit', prompt: 'test', yolo: false }, + }, + { + description: 'should parse --approval-mode=yolo correctly', + argv: ['node', 'script.js', '--approval-mode', 'yolo', '-p', 'test'], + expected: { approvalMode: 'yolo', prompt: 'test', yolo: false }, + }, + { + description: 'should parse --approval-mode=default correctly', + argv: ['node', 'script.js', '--approval-mode', 'default', '-p', 'test'], + expected: { approvalMode: 'default', prompt: 'test', yolo: false }, + }, + { + description: 'should parse legacy --yolo flag correctly', + argv: ['node', 'script.js', '--yolo', '-p', 'test'], + expected: { yolo: true, approvalMode: undefined, prompt: 'test' }, + }, + { + description: 'should handle no approval mode arguments', + argv: ['node', 'script.js', '-p', 'test'], + expected: { approvalMode: undefined, yolo: false, prompt: 'test' }, + }, + ])('$description', async ({ argv, expected }) => { + const originalArgv = process.argv; + try { + process.argv = argv; + const parsedArgs = await parseArguments(createTestMergedSettings()); + expect(parsedArgs.approvalMode).toBe(expected.approvalMode); + expect(parsedArgs.prompt).toBe(expected.prompt); + expect(parsedArgs.yolo).toBe(expected.yolo); + } finally { + process.argv = originalArgv; + } + }); + + it.each([ + { + description: 'should reject invalid approval mode values', + argv: ['node', 'script.js', '--approval-mode', 'invalid_mode'], + }, + { + description: + 'should reject conflicting --yolo and --approval-mode flags', + argv: ['node', 'script.js', '--yolo', '--approval-mode', 'default'], + }, + ])('$description', async ({ argv }) => { + const originalArgv = process.argv; + try { + process.argv = argv; + await expect( + parseArguments(createTestMergedSettings()), + ).rejects.toThrow(); + } finally { + process.argv = originalArgv; + } + }); + }); +}); diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..9aeb1f60bed01c9c79988ea2e93d2ac1faedac51 --- /dev/null +++ b/packages/cli/src/config/config.test.ts @@ -0,0 +1,3893 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { + DEFAULT_FILE_FILTERING_OPTIONS, + OutputFormat, + SHELL_TOOL_NAME, + WRITE_FILE_TOOL_NAME, + EDIT_TOOL_NAME, + WEB_FETCH_TOOL_NAME, + ASK_USER_TOOL_NAME, + debugLogger, + ApprovalMode, + type MCPServerConfig, + type GeminiCLIExtension, + Storage, +} from '@google/gemini-cli-core'; +import { loadCliConfig, parseArguments, type CliArgs } from './config.js'; +import { + type Settings, + type MergedSettings, + createTestMergedSettings, +} from './settings.js'; +import * as ServerConfig from '@google/gemini-cli-core'; + +import { isWorkspaceTrusted } from './trustedFolders.js'; +import { ExtensionManager } from './extension-manager.js'; +import { RESUME_LATEST } from '../utils/sessionUtils.js'; + +vi.mock('./trustedFolders.js', () => ({ + isWorkspaceTrusted: vi.fn(() => ({ isTrusted: true, source: 'file' })), // Default to trusted +})); + +vi.mock('./sandboxConfig.js', () => ({ + loadSandboxConfig: vi.fn(async () => undefined), +})); + +vi.mock('../commands/utils.js', () => ({ + exitCli: vi.fn(), +})); + +vi.mock('fs', async (importOriginal) => { + const actualFs = await importOriginal(); + const pathMod = await import('node:path'); + const mockHome = pathMod.resolve(pathMod.sep, 'mock', 'home', 'user'); + const MOCK_CWD1 = process.cwd(); + const MOCK_CWD2 = pathMod.resolve(pathMod.sep, 'home', 'user', 'project'); + + const mockPaths = new Set([ + MOCK_CWD1, + MOCK_CWD2, + pathMod.resolve(pathMod.sep, 'cli', 'path1'), + pathMod.resolve(pathMod.sep, 'settings', 'path1'), + pathMod.join(mockHome, 'settings', 'path2'), + pathMod.join(MOCK_CWD2, 'cli', 'path2'), + pathMod.join(MOCK_CWD2, 'settings', 'path3'), + ]); + + const mockedRealpath = vi.fn((p) => p); + Object.defineProperty(mockedRealpath, 'native', { + value: (p: Parameters[0]) => + mockedRealpath(p), + writable: true, + }); + + return { + ...actualFs, + mkdirSync: vi.fn((p) => { + mockPaths.add(p.toString()); + }), + writeFileSync: vi.fn(), + existsSync: vi.fn((p) => mockPaths.has(p.toString())), + statSync: vi.fn((p) => { + if (mockPaths.has(p.toString())) { + return { isDirectory: () => true } as unknown as import('fs').Stats; + } + return actualFs.statSync(p as unknown as string); + }), + realpathSync: mockedRealpath, + }; +}); + +vi.mock('os', async (importOriginal) => { + const actualOs = await importOriginal(); + return { + ...actualOs, + homedir: vi.fn(() => path.resolve(path.sep, 'mock', 'home', 'user')), + }; +}); + +vi.mock('open', () => ({ + default: vi.fn(), +})); + +vi.mock('read-package-up', () => ({ + readPackageUp: vi.fn(() => + Promise.resolve({ packageJson: { version: 'test-version' } }), + ), +})); + +vi.mock('@google/gemini-cli-core', async () => { + const actualServer = await vi.importActual( + '@google/gemini-cli-core', + ); + return { + ...actualServer, + IdeClient: { + getInstance: vi.fn().mockResolvedValue({ + getConnectionStatus: vi.fn(), + initialize: vi.fn(), + shutdown: vi.fn(), + }), + }, + loadEnvironment: vi.fn(), + DEFAULT_MEMORY_FILE_FILTERING_OPTIONS: { + respectGitIgnore: false, + respectGeminiIgnore: true, + customIgnoreFilePaths: [], + }, + DEFAULT_FILE_FILTERING_OPTIONS: { + respectGitIgnore: true, + respectGeminiIgnore: true, + customIgnoreFilePaths: [], + }, + createPolicyEngineConfig: vi.fn( + async (_settings, approvalMode, _workspacePoliciesDir, interactive) => ({ + rules: [], + checkers: [], + defaultDecision: interactive + ? ServerConfig.PolicyDecision.ASK_USER + : ServerConfig.PolicyDecision.DENY, + approvalMode: approvalMode ?? ServerConfig.ApprovalMode.DEFAULT, + nonInteractive: !interactive, + }), + ), + getAdminErrorMessage: vi.fn( + (_feature) => + `YOLO mode is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli`, + ), + isHeadlessMode: vi.fn((opts) => { + if (process.env['VITEST'] === 'true') { + return ( + !!opts?.prompt || + (!!process.stdin && !process.stdin.isTTY) || + (!!process.stdout && !process.stdout.isTTY) + ); + } + return ( + !!opts?.prompt || + process.env['CI'] === 'true' || + process.env['GITHUB_ACTIONS'] === 'true' || + (!!process.stdin && !process.stdin.isTTY) || + (!!process.stdout && !process.stdout.isTTY) + ); + }), + }; +}); + +vi.mock('./extension-manager.js', () => { + const ExtensionManager = vi.fn(); + ExtensionManager.prototype.loadExtensions = vi.fn(); + ExtensionManager.prototype.getExtensions = vi.fn().mockReturnValue([]); + return { ExtensionManager }; +}); + +// Global setup to ensure clean environment for all tests in this file +const originalArgv = process.argv; +const originalGeminiModel = process.env['GEMINI_MODEL']; +const originalStdoutIsTTY = process.stdout.isTTY; +const originalStdinIsTTY = process.stdin.isTTY; + +beforeEach(() => { + delete process.env['GEMINI_MODEL']; + // Restore ExtensionManager mocks by re-assigning them + ExtensionManager.prototype.getExtensions = vi.fn().mockReturnValue([]); + ExtensionManager.prototype.loadExtensions = vi + .fn() + .mockResolvedValue(undefined); + + // Default to interactive mode for tests unless otherwise specified + Object.defineProperty(process.stdout, 'isTTY', { + value: true, + configurable: true, + writable: true, + }); + Object.defineProperty(process.stdin, 'isTTY', { + value: true, + configurable: true, + writable: true, + }); +}); + +afterEach(() => { + process.argv = originalArgv; + if (originalGeminiModel !== undefined) { + process.env['GEMINI_MODEL'] = originalGeminiModel; + } else { + delete process.env['GEMINI_MODEL']; + } + Object.defineProperty(process.stdout, 'isTTY', { + value: originalStdoutIsTTY, + configurable: true, + writable: true, + }); + Object.defineProperty(process.stdin, 'isTTY', { + value: originalStdinIsTTY, + configurable: true, + writable: true, + }); +}); + +describe('parseArguments', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + it('should fail if multiple session flags are provided', async () => { + process.argv = [ + 'node', + 'script.js', + '--resume', + '--session-id', + 'test-uuid-1234', + ]; + const mockConsoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + + await expect(parseArguments(createTestMergedSettings())).rejects.toThrow( + 'process.exit called', + ); + + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining( + 'The flags --resume, --session-id, and --session-file are mutually exclusive. Please provide only one.', + ), + ); + }); + + it('should parse --session-id option correctly', async () => { + process.argv = ['node', 'script.js', '--session-id', 'test-uuid-1234']; + vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + + const parsedArgs = await parseArguments(createTestMergedSettings()); + expect(parsedArgs.sessionId).toBe('test-uuid-1234'); + }); + + describe('worktree', () => { + it('should parse --worktree flag when provided with a name', async () => { + process.argv = ['node', 'script.js', '--worktree', 'my-feature']; + const settings = createTestMergedSettings(); + settings.experimental.worktrees = true; + const argv = await parseArguments(settings); + expect(argv.worktree).toBe('my-feature'); + }); + + it('should generate a random name when --worktree is provided without a name', async () => { + process.argv = ['node', 'script.js', '--worktree']; + const settings = createTestMergedSettings(); + settings.experimental.worktrees = true; + const argv = await parseArguments(settings); + expect(argv.worktree).toBeDefined(); + expect(argv.worktree).not.toBe(''); + expect(typeof argv.worktree).toBe('string'); + }); + + it('should throw an error when --worktree is used but experimental.worktrees is not enabled', async () => { + process.argv = ['node', 'script.js', '--worktree', 'feature']; + const settings = createTestMergedSettings(); + settings.experimental.worktrees = false; + + vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + const mockConsoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + await expect(parseArguments(settings)).rejects.toThrow( + 'process.exit called', + ); + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining( + 'The --worktree flag is only available when experimental.worktrees is enabled in your settings.', + ), + ); + }); + }); + + it.each([ + { + description: 'long flags', + argv: [ + 'node', + 'script.js', + '--prompt', + 'test prompt', + '--prompt-interactive', + 'interactive prompt', + ], + }, + { + description: 'short flags', + argv: [ + 'node', + 'script.js', + '-p', + 'test prompt', + '-i', + 'interactive prompt', + ], + }, + ])( + 'should throw an error when using conflicting prompt flags ($description)', + async ({ argv }) => { + process.argv = argv; + + vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + + const mockConsoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + await expect(parseArguments(createTestMergedSettings())).rejects.toThrow( + 'process.exit called', + ); + + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining( + 'Cannot use both --prompt (-p) and --prompt-interactive (-i) together', + ), + ); + }, + ); + + describe('isCommand middleware', () => { + it.each([ + { cmd: 'mcp list', expected: true }, + { cmd: 'extensions list', expected: true }, + { cmd: 'extension list', expected: true }, + { cmd: 'skills list', expected: true }, + { cmd: 'skill list', expected: true }, + { cmd: 'hooks migrate', expected: true }, + { cmd: 'hook migrate', expected: true }, + { cmd: 'gemma status', expected: true }, + { cmd: 'some query', expected: undefined }, + { cmd: 'hello world', expected: undefined }, + ])( + 'should set isCommand to $expected for "$cmd"', + async ({ cmd, expected }) => { + process.argv = ['node', 'script.js', ...cmd.split(' ')]; + const settings = createTestMergedSettings({ + admin: { + mcp: { enabled: true }, + }, + experimental: { + extensionManagement: true, + }, + skills: { + enabled: true, + }, + hooksConfig: { + enabled: true, + }, + }); + const parsedArgs = await parseArguments(settings); + expect(parsedArgs.isCommand).toBe(expected); + }, + ); + }); + + it.each([ + { + description: 'should allow --prompt without --prompt-interactive', + argv: ['node', 'script.js', '--prompt', 'test prompt'], + expected: { prompt: 'test prompt', promptInteractive: undefined }, + }, + { + description: 'should allow --prompt-interactive without --prompt', + argv: ['node', 'script.js', '--prompt-interactive', 'interactive prompt'], + expected: { prompt: undefined, promptInteractive: 'interactive prompt' }, + }, + { + description: 'should allow -i flag as alias for --prompt-interactive', + argv: ['node', 'script.js', '-i', 'interactive prompt'], + expected: { prompt: undefined, promptInteractive: 'interactive prompt' }, + }, + ])('$description', async ({ argv, expected }) => { + process.argv = argv; + const parsedArgs = await parseArguments(createTestMergedSettings()); + expect(parsedArgs.prompt).toBe(expected.prompt); + expect(parsedArgs.promptInteractive).toBe(expected.promptInteractive); + }); + + describe('positional arguments and @commands', () => { + beforeEach(() => { + // Default to headless mode for these tests as they mostly expect one-shot behavior + process.stdin.isTTY = false; + Object.defineProperty(process.stdout, 'isTTY', { + value: false, + configurable: true, + writable: true, + }); + }); + + it.each([ + { + description: + 'should convert positional query argument to prompt by default', + argv: ['node', 'script.js', 'Hi Gemini'], + expectedQuery: 'Hi Gemini', + expectedModel: undefined, + debug: false, + }, + { + description: + 'should map @path to prompt (one-shot) when it starts with @', + argv: ['node', 'script.js', '@path ./file.md'], + expectedQuery: '@path ./file.md', + expectedModel: undefined, + debug: false, + }, + { + description: + 'should map @path to prompt even when config flags are present', + argv: [ + 'node', + 'script.js', + '@path', + './file.md', + '--model', + 'gemini-2.5-pro', + ], + expectedQuery: '@path ./file.md', + expectedModel: 'gemini-2.5-pro', + debug: false, + }, + { + description: + 'maps unquoted positional @path + arg to prompt (one-shot)', + argv: ['node', 'script.js', '@path', './file.md'], + expectedQuery: '@path ./file.md', + expectedModel: undefined, + debug: false, + }, + { + description: + 'should handle multiple @path arguments in a single command (one-shot)', + argv: [ + 'node', + 'script.js', + '@path', + './file1.md', + '@path', + './file2.md', + ], + expectedQuery: '@path ./file1.md @path ./file2.md', + expectedModel: undefined, + debug: false, + }, + { + description: + 'should handle mixed quoted and unquoted @path arguments (one-shot)', + argv: [ + 'node', + 'script.js', + '@path ./file1.md', + '@path', + './file2.md', + 'additional text', + ], + expectedQuery: '@path ./file1.md @path ./file2.md additional text', + expectedModel: undefined, + debug: false, + }, + { + description: 'should map @path to prompt with ambient flags (debug)', + argv: ['node', 'script.js', '@path', './file.md', '--debug'], + expectedQuery: '@path ./file.md', + expectedModel: undefined, + debug: true, + }, + { + description: 'should map @include to prompt (one-shot)', + argv: ['node', 'script.js', '@include src/'], + expectedQuery: '@include src/', + expectedModel: undefined, + debug: false, + }, + { + description: 'should map @search to prompt (one-shot)', + argv: ['node', 'script.js', '@search pattern'], + expectedQuery: '@search pattern', + expectedModel: undefined, + debug: false, + }, + { + description: 'should map @web to prompt (one-shot)', + argv: ['node', 'script.js', '@web query'], + expectedQuery: '@web query', + expectedModel: undefined, + debug: false, + }, + { + description: 'should map @git to prompt (one-shot)', + argv: ['node', 'script.js', '@git status'], + expectedQuery: '@git status', + expectedModel: undefined, + debug: false, + }, + { + description: 'should handle @command with leading whitespace', + argv: ['node', 'script.js', ' @path ./file.md'], + expectedQuery: ' @path ./file.md', + expectedModel: undefined, + debug: false, + }, + ])( + '$description', + async ({ argv, expectedQuery, expectedModel, debug }) => { + process.argv = argv; + const parsedArgs = await parseArguments(createTestMergedSettings()); + expect(parsedArgs.query).toBe(expectedQuery); + expect(parsedArgs.prompt).toBe(expectedQuery); + expect(parsedArgs.promptInteractive).toBeUndefined(); + if (expectedModel) { + expect(parsedArgs.model).toBe(expectedModel); + } + if (debug) { + expect(parsedArgs.debug).toBe(true); + } + }, + ); + + it('should include a startup message when converting positional query to interactive prompt', async () => { + process.stdin.isTTY = true; + Object.defineProperty(process.stdout, 'isTTY', { + value: true, + configurable: true, + writable: true, + }); + process.argv = ['node', 'script.js', 'hello']; + + try { + const argv = await parseArguments(createTestMergedSettings()); + expect(argv.startupMessages).toContain( + 'Positional arguments now default to interactive mode. To run in non-interactive mode, use the --prompt (-p) flag.', + ); + } finally { + // beforeEach handles resetting + } + }); + }); + + it.each([ + { + description: 'long flags', + argv: ['node', 'script.js', '--yolo', '--approval-mode', 'default'], + }, + { + description: 'short flags', + argv: ['node', 'script.js', '-y', '--approval-mode', 'yolo'], + }, + ])( + 'should throw an error when using conflicting yolo/approval-mode flags ($description)', + async ({ argv }) => { + process.argv = argv; + + vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + + const mockConsoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + await expect(parseArguments(createTestMergedSettings())).rejects.toThrow( + 'process.exit called', + ); + + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining( + 'Cannot use both --yolo (-y) and --approval-mode together. Use --approval-mode=yolo instead.', + ), + ); + }, + ); + + it.each([ + { + description: 'should allow --approval-mode without --yolo', + argv: ['node', 'script.js', '--approval-mode', 'auto_edit'], + expected: { approvalMode: 'auto_edit', yolo: false }, + }, + { + description: 'should allow --yolo without --approval-mode', + argv: ['node', 'script.js', '--yolo'], + expected: { approvalMode: undefined, yolo: true }, + }, + ])('$description', async ({ argv, expected }) => { + process.argv = argv; + const parsedArgs = await parseArguments(createTestMergedSettings()); + expect(parsedArgs.approvalMode).toBe(expected.approvalMode); + expect(parsedArgs.yolo).toBe(expected.yolo); + }); + + it('should reject invalid --approval-mode values', async () => { + process.argv = ['node', 'script.js', '--approval-mode', 'invalid']; + + vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + + const mockConsoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + const debugErrorSpy = vi + .spyOn(debugLogger, 'error') + .mockImplementation(() => {}); + + await expect(parseArguments(createTestMergedSettings())).rejects.toThrow( + 'process.exit called', + ); + + expect(debugErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Invalid values:'), + ); + expect(mockConsoleError).toHaveBeenCalled(); + }); + + it('should allow resuming a session without prompt argument in non-interactive mode (expecting stdin)', async () => { + const originalIsTTY = process.stdin.isTTY; + process.stdin.isTTY = false; + process.argv = ['node', 'script.js', '--resume', 'session-id']; + + try { + const argv = await parseArguments(createTestMergedSettings()); + expect(argv.resume).toBe('session-id'); + } finally { + process.stdin.isTTY = originalIsTTY; + } + }); + + it('should return RESUME_LATEST constant when --resume is passed without a value', async () => { + const originalIsTTY = process.stdin.isTTY; + process.stdin.isTTY = true; // Make it interactive to avoid validation error + process.argv = ['node', 'script.js', '--resume']; + + try { + const argv = await parseArguments(createTestMergedSettings()); + expect(argv.resume).toBe(RESUME_LATEST); + expect(argv.resume).toBe('latest'); + } finally { + process.stdin.isTTY = originalIsTTY; + } + }); + + it('should support comma-separated values for --allowed-tools', async () => { + process.argv = [ + 'node', + 'script.js', + '--allowed-tools', + 'read_file,ShellTool(git status)', + ]; + const argv = await parseArguments(createTestMergedSettings()); + expect(argv.allowedTools).toEqual(['read_file', 'ShellTool(git status)']); + }); + + it('should support comma-separated values for --allowed-mcp-server-names', async () => { + process.argv = [ + 'node', + 'script.js', + '--allowed-mcp-server-names', + 'server1,server2', + ]; + const argv = await parseArguments(createTestMergedSettings()); + expect(argv.allowedMcpServerNames).toEqual(['server1', 'server2']); + }); + + it('should support comma-separated values for --extensions', async () => { + process.argv = ['node', 'script.js', '--extensions', 'ext1,ext2']; + const argv = await parseArguments(createTestMergedSettings()); + expect(argv.extensions).toEqual(['ext1', 'ext2']); + }); + + it('should correctly parse positional arguments when flags with arguments are present', async () => { + process.argv = [ + 'node', + 'script.js', + '--model', + 'test-model-string', + 'my-positional-arg', + ]; + const argv = await parseArguments(createTestMergedSettings()); + expect(argv.model).toBe('test-model-string'); + expect(argv.query).toBe('my-positional-arg'); + }); + + it('should handle long positional prompts with multiple flags', async () => { + process.argv = [ + 'node', + 'script.js', + '-e', + 'none', + '--approval-mode=auto_edit', + '--allowed-tools=ShellTool', + '--allowed-tools=ShellTool(whoami)', + '--allowed-tools=ShellTool(wc)', + 'Use whoami to write a poem in file poem.md about my username in pig latin and use wc to tell me how many lines are in the poem you wrote.', + ]; + const argv = await parseArguments(createTestMergedSettings()); + expect(argv.extensions).toEqual(['none']); + expect(argv.approvalMode).toBe('auto_edit'); + expect(argv.allowedTools).toEqual([ + 'ShellTool', + 'ShellTool(whoami)', + 'ShellTool(wc)', + ]); + expect(argv.query).toBe( + 'Use whoami to write a poem in file poem.md about my username in pig latin and use wc to tell me how many lines are in the poem you wrote.', + ); + }); + + it('should set isCommand to true for mcp command', async () => { + process.argv = ['node', 'script.js', 'mcp', 'list']; + const argv = await parseArguments(createTestMergedSettings()); + expect(argv.isCommand).toBe(true); + }); + + it('should set isCommand to true for extensions command', async () => { + process.argv = ['node', 'script.js', 'extensions', 'list']; + // Extensions command uses experimental settings + const settings = createTestMergedSettings({ + experimental: { extensionManagement: true }, + }); + const argv = await parseArguments(settings); + expect(argv.isCommand).toBe(true); + }); + + it('should set isCommand to true for skills command', async () => { + process.argv = ['node', 'script.js', 'skills', 'list']; + // Skills command enabled by default or via experimental + const settings = createTestMergedSettings({ + skills: { enabled: true }, + }); + const argv = await parseArguments(settings); + expect(argv.isCommand).toBe(true); + }); + + it('should set isCommand to true for hooks command', async () => { + process.argv = ['node', 'script.js', 'hooks', 'migrate']; + // Hooks command enabled via hooksConfig settings + const settings = createTestMergedSettings({ + hooksConfig: { enabled: true }, + }); + const argv = await parseArguments(settings); + expect(argv.isCommand).toBe(true); + }); + + it('should set isCommand to true for gemma command', async () => { + process.argv = ['node', 'script.js', 'gemma', 'status']; + const argv = await parseArguments(createTestMergedSettings()); + expect(argv.isCommand).toBe(true); + }); +}); + +describe('loadCliConfig', () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); + vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + describe('Model resolution', () => { + it('should handle multiple --model flags by taking the last one', async () => { + const argv = { + query: undefined, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + model: ['gemini-1.5-pro', 'gemini-2.0-flash'] as any, + sandbox: undefined, + debug: false, + prompt: undefined, + promptInteractive: undefined, + yolo: undefined, + approvalMode: undefined, + policy: undefined, + adminPolicy: undefined, + allowedMcpServerNames: undefined, + allowedTools: undefined, + extensions: undefined, + listExtensions: false, + listSessions: false, + deleteSession: undefined, + screenReader: undefined, + isCommand: false, + rawOutput: false, + acceptRawOutputRisk: false, + startupMessages: [], + resume: undefined, + includeDirectories: [], + useWriteTodos: false, + outputFormat: undefined, + fakeResponses: undefined, + recordResponses: undefined, + skipTrust: false, + }; + + const settings = createTestMergedSettings(); + const config = await loadCliConfig( + settings, + 'test-session', + argv as unknown as CliArgs, + { + cwd: process.cwd(), + }, + ); + + expect(config.getModel()).toBe('gemini-2.0-flash'); + }); + + it('should handle non-string model flags by coercing to string', async () => { + const argv = { + query: undefined, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + model: true as any, + sandbox: undefined, + debug: false, + prompt: undefined, + promptInteractive: undefined, + yolo: undefined, + approvalMode: undefined, + policy: undefined, + adminPolicy: undefined, + allowedMcpServerNames: undefined, + allowedTools: undefined, + extensions: undefined, + listExtensions: false, + listSessions: false, + deleteSession: undefined, + screenReader: undefined, + isCommand: false, + rawOutput: false, + acceptRawOutputRisk: false, + startupMessages: [], + resume: undefined, + includeDirectories: [], + useWriteTodos: false, + outputFormat: undefined, + fakeResponses: undefined, + recordResponses: undefined, + skipTrust: false, + }; + + const settings = createTestMergedSettings(); + const config = await loadCliConfig( + settings, + 'test-session', + argv as unknown as CliArgs, + { + cwd: process.cwd(), + }, + ); + + expect(config.getModel()).toBe('true'); + }); + }); + + describe('Proxy configuration', () => { + const originalProxyEnv: { [key: string]: string | undefined } = {}; + const proxyEnvVars = [ + 'HTTP_PROXY', + 'HTTPS_PROXY', + 'http_proxy', + 'https_proxy', + ]; + + beforeEach(() => { + for (const key of proxyEnvVars) { + originalProxyEnv[key] = process.env[key]; + delete process.env[key]; + } + }); + + afterEach(() => { + for (const key of proxyEnvVars) { + if (originalProxyEnv[key]) { + process.env[key] = originalProxyEnv[key]; + } else { + delete process.env[key]; + } + } + }); + + it(`should leave proxy to empty by default`, async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getProxy()).toBeFalsy(); + }); + + const proxy_url = 'http://localhost:7890'; + const testCases = [ + { + input: { + env_name: 'https_proxy', + proxy_url, + }, + expected: proxy_url, + }, + { + input: { + env_name: 'http_proxy', + proxy_url, + }, + expected: proxy_url, + }, + { + input: { + env_name: 'HTTPS_PROXY', + proxy_url, + }, + expected: proxy_url, + }, + { + input: { + env_name: 'HTTP_PROXY', + proxy_url, + }, + expected: proxy_url, + }, + ]; + testCases.forEach(({ input, expected }) => { + it(`should set proxy to ${expected} according to environment variable [${input.env_name}]`, async () => { + vi.stubEnv(input.env_name, input.proxy_url); + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getProxy()).toBe(expected); + }); + }); + }); + + it('should add IDE workspace folders from GEMINI_CLI_IDE_WORKSPACE_PATH to include directories', async () => { + vi.stubEnv( + 'GEMINI_CLI_IDE_WORKSPACE_PATH', + ['/project/folderA', '/project/folderB'].join(path.delimiter), + ); + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); + const config = await loadCliConfig(settings, 'test-session', argv); + const dirs = config.getPendingIncludeDirectories(); + expect(dirs).toContain('/project/folderA'); + expect(dirs).toContain('/project/folderB'); + }); + + it('should skip inaccessible workspace folders from GEMINI_CLI_IDE_WORKSPACE_PATH', async () => { + vi.spyOn(ServerConfig, 'resolveToRealPath').mockImplementation((p) => { + if (p.toString().includes('restricted')) { + const err = new Error('EACCES: permission denied'); + (err as NodeJS.ErrnoException).code = 'EACCES'; + throw err; + } + return p.toString(); + }); + vi.stubEnv( + 'GEMINI_CLI_IDE_WORKSPACE_PATH', + ['/project/folderA', '/nonexistent/restricted/folder'].join( + path.delimiter, + ), + ); + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); + const config = await loadCliConfig(settings, 'test-session', argv); + const dirs = config.getPendingIncludeDirectories(); + expect(dirs).toContain('/project/folderA'); + expect(dirs).not.toContain('/nonexistent/restricted/folder'); + }); + + it('should use default fileFilter options when unconfigured', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getFileFilteringRespectGitIgnore()).toBe( + DEFAULT_FILE_FILTERING_OPTIONS.respectGitIgnore, + ); + expect(config.getFileFilteringRespectGeminiIgnore()).toBe( + DEFAULT_FILE_FILTERING_OPTIONS.respectGeminiIgnore, + ); + expect(config.getCustomIgnoreFilePaths()).toEqual( + DEFAULT_FILE_FILTERING_OPTIONS.customIgnoreFilePaths, + ); + expect(config.getApprovalMode()).toBe(ApprovalMode.DEFAULT); + }); + + it('should be non-interactive when isCommand is set', async () => { + process.argv = ['node', 'script.js', 'mcp', 'list']; + const argv = await parseArguments(createTestMergedSettings()); + argv.isCommand = true; // explicitly set it as if middleware ran (it does in parseArguments but we want to be sure for this isolated test if we were mocking argv) + + // reset tty for this test + process.stdin.isTTY = true; + + const settings = createTestMergedSettings(); + const config = await loadCliConfig(settings, 'test-session', argv); + + expect(config.isInteractive()).toBe(false); + }); + + describe('isAcpMode', () => { + it('should force skipNextSpeakerCheck to true when in ACP mode', async () => { + process.argv = ['node', 'script.js', '--acp']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + model: { skipNextSpeakerCheck: false }, + }); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getSkipNextSpeakerCheck()).toBe(true); + }); + + it('should respect settings.model.skipNextSpeakerCheck when not in ACP mode', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + model: { skipNextSpeakerCheck: false }, + }); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getSkipNextSpeakerCheck()).toBe(false); + }); + }); +}); + +describe('mergeMcpServers', () => { + it('should not modify the original settings object', async () => { + const settings = createTestMergedSettings({ + mcpServers: { + 'test-server': { + url: 'http://localhost:8080', + }, + }, + }); + + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ + { + path: '/path/to/ext1', + name: 'ext1', + id: 'ext1-id', + + version: '1.0.0', + mcpServers: { + 'ext1-server': { + url: 'http://localhost:8081', + }, + }, + contextFiles: [], + isActive: true, + }, + ]); + const originalSettings = JSON.parse(JSON.stringify(settings)); + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + await loadCliConfig(settings, 'test-session', argv); + expect(settings).toEqual(originalSettings); + }); +}); + +describe('mergeExcludeTools', () => { + const originalIsTTY = process.stdin.isTTY; + + beforeEach(() => { + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); + process.stdin.isTTY = true; + }); + + afterEach(() => { + process.stdin.isTTY = originalIsTTY; + }); + + it('should merge excludeTools from settings and extensions', async () => { + const settings = createTestMergedSettings({ + tools: { exclude: ['tool1', 'tool2'] }, + }); + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ + { + path: '/path/to/ext1', + name: 'ext1', + id: 'ext1-id', + version: '1.0.0', + excludeTools: ['tool3', 'tool4'], + contextFiles: [], + isActive: true, + }, + { + path: '/path/to/ext2', + name: 'ext2', + id: 'ext2-id', + version: '1.0.0', + excludeTools: ['tool5'], + contextFiles: [], + isActive: true, + }, + ]); + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getExcludeTools()).toEqual( + new Set(['tool1', 'tool2', 'tool3', 'tool4', 'tool5']), + ); + expect(config.getExcludeTools()).toHaveLength(5); + }); + + it('should handle overlapping excludeTools between settings and extensions', async () => { + const settings = createTestMergedSettings({ + tools: { exclude: ['tool1', 'tool2'] }, + }); + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ + { + path: '/path/to/ext1', + name: 'ext1', + id: 'ext1-id', + version: '1.0.0', + excludeTools: ['tool2', 'tool3'], + contextFiles: [], + isActive: true, + }, + ]); + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getExcludeTools()).toEqual( + new Set(['tool1', 'tool2', 'tool3']), + ); + expect(config.getExcludeTools()).toHaveLength(3); + }); + + it('should handle overlapping excludeTools between extensions', async () => { + const settings = createTestMergedSettings({ + tools: { exclude: ['tool1'] }, + }); + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ + { + path: '/path/to/ext1', + name: 'ext1', + id: 'ext1-id', + version: '1.0.0', + excludeTools: ['tool2', 'tool3'], + contextFiles: [], + isActive: true, + }, + { + path: '/path/to/ext2', + name: 'ext2', + id: 'ext2-id', + version: '1.0.0', + excludeTools: ['tool3', 'tool4'], + contextFiles: [], + isActive: true, + }, + ]); + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getExcludeTools()).toEqual( + new Set(['tool1', 'tool2', 'tool3', 'tool4']), + ); + expect(config.getExcludeTools()).toHaveLength(4); + }); + + it('should return an empty array when no excludeTools are specified and it is interactive', async () => { + process.stdin.isTTY = true; + const settings = createTestMergedSettings(); + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getExcludeTools()).toEqual(new Set([])); + }); + + it('should return default excludes when no excludeTools are specified and it is not interactive', async () => { + process.stdin.isTTY = false; + const settings = createTestMergedSettings(); + process.argv = ['node', 'script.js', '-p', 'test']; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getExcludeTools()).toEqual(new Set([ASK_USER_TOOL_NAME])); + }); + + it('should handle settings with excludeTools but no extensions', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + tools: { exclude: ['tool1', 'tool2'] }, + }); + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getExcludeTools()).toEqual(new Set(['tool1', 'tool2'])); + expect(config.getExcludeTools()).toHaveLength(2); + }); + + it('should handle extensions with excludeTools but no settings', async () => { + const settings = createTestMergedSettings(); + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ + { + path: '/path/to/ext', + name: 'ext1', + id: 'ext1-id', + version: '1.0.0', + excludeTools: ['tool1', 'tool2'], + contextFiles: [], + isActive: true, + }, + ]); + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getExcludeTools()).toEqual(new Set(['tool1', 'tool2'])); + expect(config.getExcludeTools()).toHaveLength(2); + }); + + it('should not modify the original settings object', async () => { + const settings = createTestMergedSettings({ + tools: { exclude: ['tool1'] }, + }); + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ + { + path: '/path/to/ext', + name: 'ext1', + id: 'ext1-id', + version: '1.0.0', + excludeTools: ['tool2'], + contextFiles: [], + isActive: true, + }, + ]); + const originalSettings = JSON.parse(JSON.stringify(settings)); + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + await loadCliConfig(settings, 'test-session', argv); + expect(settings).toEqual(originalSettings); + }); +}); + +describe('Approval mode tool exclusion logic', () => { + const originalIsTTY = process.stdin.isTTY; + + beforeEach(() => { + process.stdin.isTTY = false; // Ensure non-interactive mode + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: true, + source: undefined, + }); + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); + }); + + afterEach(() => { + process.stdin.isTTY = originalIsTTY; + }); + + it('should exclude all interactive tools in non-interactive mode with default approval mode', async () => { + process.argv = ['node', 'script.js', '-p', 'test']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); + const config = await loadCliConfig(settings, 'test-session', argv); + + const excludedTools = config.getExcludeTools(); + expect(excludedTools).not.toContain(SHELL_TOOL_NAME); + expect(excludedTools).not.toContain(EDIT_TOOL_NAME); + expect(excludedTools).not.toContain(WRITE_FILE_TOOL_NAME); + expect(excludedTools).toContain(ASK_USER_TOOL_NAME); + }); + + it('should exclude all interactive tools in non-interactive mode with explicit default approval mode', async () => { + process.argv = [ + 'node', + 'script.js', + '--approval-mode', + 'default', + '-p', + 'test', + ]; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); + + const config = await loadCliConfig(settings, 'test-session', argv); + + const excludedTools = config.getExcludeTools(); + expect(excludedTools).not.toContain(SHELL_TOOL_NAME); + expect(excludedTools).not.toContain(EDIT_TOOL_NAME); + expect(excludedTools).not.toContain(WRITE_FILE_TOOL_NAME); + expect(excludedTools).toContain(ASK_USER_TOOL_NAME); + }); + + it('should exclude only shell tools in non-interactive mode with auto_edit approval mode', async () => { + process.argv = [ + 'node', + 'script.js', + '--approval-mode', + 'auto_edit', + '-p', + 'test', + ]; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); + + const config = await loadCliConfig(settings, 'test-session', argv); + + const excludedTools = config.getExcludeTools(); + expect(excludedTools).not.toContain(SHELL_TOOL_NAME); + expect(excludedTools).not.toContain(EDIT_TOOL_NAME); + expect(excludedTools).not.toContain(WRITE_FILE_TOOL_NAME); + expect(excludedTools).toContain(ASK_USER_TOOL_NAME); + }); + + it('should exclude only ask_user in non-interactive mode with yolo approval mode', async () => { + process.argv = [ + 'node', + 'script.js', + '--approval-mode', + 'yolo', + '-p', + 'test', + ]; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); + + const config = await loadCliConfig(settings, 'test-session', argv); + + const excludedTools = config.getExcludeTools(); + expect(excludedTools).not.toContain(SHELL_TOOL_NAME); + expect(excludedTools).not.toContain(EDIT_TOOL_NAME); + expect(excludedTools).not.toContain(WRITE_FILE_TOOL_NAME); + expect(excludedTools).toContain(ASK_USER_TOOL_NAME); + }); + + it('should exclude all interactive tools in non-interactive mode with plan approval mode', async () => { + process.argv = [ + 'node', + 'script.js', + '--approval-mode', + 'plan', + '-p', + 'test', + ]; + const settings = createTestMergedSettings({ + general: { + plan: { enabled: true }, + }, + }); + const argv = await parseArguments(createTestMergedSettings()); + + const config = await loadCliConfig(settings, 'test-session', argv); + + const excludedTools = config.getExcludeTools(); + expect(excludedTools).not.toContain(SHELL_TOOL_NAME); + expect(excludedTools).not.toContain(EDIT_TOOL_NAME); + expect(excludedTools).not.toContain(WRITE_FILE_TOOL_NAME); + expect(excludedTools).toContain(ASK_USER_TOOL_NAME); + }); + + it('should exclude only ask_user in non-interactive mode with legacy yolo flag', async () => { + process.argv = ['node', 'script.js', '--yolo', '-p', 'test']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); + + const config = await loadCliConfig(settings, 'test-session', argv); + + const excludedTools = config.getExcludeTools(); + expect(excludedTools).not.toContain(SHELL_TOOL_NAME); + expect(excludedTools).not.toContain(EDIT_TOOL_NAME); + expect(excludedTools).not.toContain(WRITE_FILE_TOOL_NAME); + expect(excludedTools).toContain(ASK_USER_TOOL_NAME); + }); + + it('should not exclude interactive tools in interactive mode regardless of approval mode', async () => { + process.stdin.isTTY = true; // Interactive mode + + const testCases = [ + { args: ['node', 'script.js'] }, // default + { args: ['node', 'script.js', '--approval-mode', 'default'] }, + { args: ['node', 'script.js', '--approval-mode', 'auto_edit'] }, + { args: ['node', 'script.js', '--approval-mode', 'yolo'] }, + { args: ['node', 'script.js', '--yolo'] }, + ]; + + for (const testCase of testCases) { + process.argv = testCase.args; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); + + const config = await loadCliConfig(settings, 'test-session', argv); + + const excludedTools = config.getExcludeTools(); + expect(excludedTools).not.toContain(SHELL_TOOL_NAME); + expect(excludedTools).not.toContain(EDIT_TOOL_NAME); + expect(excludedTools).not.toContain(WRITE_FILE_TOOL_NAME); + expect(excludedTools).not.toContain(ASK_USER_TOOL_NAME); + } + }); + + it('should merge approval mode exclusions with settings exclusions in auto_edit mode', async () => { + process.argv = [ + 'node', + 'script.js', + '--approval-mode', + 'auto_edit', + '-p', + 'test', + ]; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + tools: { exclude: ['custom_tool'] }, + }); + + const config = await loadCliConfig(settings, 'test-session', argv); + + const excludedTools = config.getExcludeTools(); + expect(excludedTools).toContain('custom_tool'); // From settings + expect(excludedTools).not.toContain(SHELL_TOOL_NAME); // No longer from approval mode + expect(excludedTools).not.toContain(EDIT_TOOL_NAME); // Should be allowed in auto_edit + expect(excludedTools).not.toContain(WRITE_FILE_TOOL_NAME); // Should be allowed in auto_edit + expect(excludedTools).toContain(ASK_USER_TOOL_NAME); + }); + + it('should throw an error if YOLO mode is attempted when disableYoloMode is true', async () => { + process.argv = ['node', 'script.js', '--yolo']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + security: { + disableYoloMode: true, + }, + }); + + await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow( + 'YOLO mode is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli', + ); + }); + + it('should throw an error for invalid approval mode values in loadCliConfig', async () => { + // Create a mock argv with an invalid approval mode that bypasses argument parsing validation + const invalidArgv: Partial & { approvalMode: string } = { + approvalMode: 'invalid_mode', + promptInteractive: '', + prompt: '', + yolo: false, + }; + + const settings = createTestMergedSettings(); + await expect( + loadCliConfig(settings, 'test-session', invalidArgv as CliArgs), + ).rejects.toThrow( + 'Invalid approval mode: invalid_mode. Valid values are: yolo, auto_edit, plan, default', + ); + }); + + it('should fall back to default approval mode if plan mode is requested but not enabled', async () => { + process.argv = ['node', 'script.js']; + const settings = createTestMergedSettings({ + general: { + defaultApprovalMode: 'plan', + plan: { enabled: false }, + }, + }); + const argv = await parseArguments(settings); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getApprovalMode()).toBe(ApprovalMode.DEFAULT); + }); + + it('should allow plan approval mode if plan is enabled', async () => { + process.argv = ['node', 'script.js']; + const settings = createTestMergedSettings({ + general: { + defaultApprovalMode: 'plan', + plan: { enabled: true }, + }, + }); + const argv = await parseArguments(settings); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getApprovalMode()).toBe(ApprovalMode.PLAN); + }); +}); + +describe('loadCliConfig with allowed-mcp-server-names', () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); + vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + const baseSettings = createTestMergedSettings({ + mcpServers: { + server1: { url: 'http://localhost:8080' }, + server2: { url: 'http://localhost:8081' }, + server3: { url: 'http://localhost:8082' }, + }, + }); + + it('should allow all MCP servers if the flag is not provided', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig(baseSettings, 'test-session', argv); + expect(config.getMcpServers()).toEqual(baseSettings.mcpServers); + }); + + it('should allow only the specified MCP server', async () => { + process.argv = [ + 'node', + 'script.js', + '--allowed-mcp-server-names', + 'server1', + ]; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig(baseSettings, 'test-session', argv); + expect(config.getAllowedMcpServers()).toEqual(['server1']); + }); + + it('should allow multiple specified MCP servers', async () => { + process.argv = [ + 'node', + 'script.js', + '--allowed-mcp-server-names', + 'server1', + '--allowed-mcp-server-names', + 'server3', + ]; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig(baseSettings, 'test-session', argv); + expect(config.getAllowedMcpServers()).toEqual(['server1', 'server3']); + }); + + it('should handle server names that do not exist', async () => { + process.argv = [ + 'node', + 'script.js', + '--allowed-mcp-server-names', + 'server1', + '--allowed-mcp-server-names', + 'server4', + ]; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig(baseSettings, 'test-session', argv); + expect(config.getAllowedMcpServers()).toEqual(['server1', 'server4']); + }); + + it('should allow no MCP servers if the flag is provided but empty', async () => { + process.argv = ['node', 'script.js', '--allowed-mcp-server-names', '']; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig(baseSettings, 'test-session', argv); + expect(config.getAllowedMcpServers()).toEqual(['']); + }); + + it('should read allowMCPServers from settings', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + ...baseSettings, + mcp: { allowed: ['server1', 'server2'] }, + }); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getAllowedMcpServers()).toEqual(['server1', 'server2']); + }); + + it('should read excludeMCPServers from settings', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + ...baseSettings, + mcp: { excluded: ['server1', 'server2'] }, + }); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getBlockedMcpServers()).toEqual(['server1', 'server2']); + }); + + it('should override allowMCPServers with excludeMCPServers if overlapping', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + ...baseSettings, + mcp: { + excluded: ['server1'], + allowed: ['server1', 'server2'], + }, + }); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getAllowedMcpServers()).toEqual(['server1', 'server2']); + expect(config.getBlockedMcpServers()).toEqual(['server1']); + }); + + it('should prioritize mcp server flag if set', async () => { + process.argv = [ + 'node', + 'script.js', + '--allowed-mcp-server-names', + 'server1', + ]; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + ...baseSettings, + mcp: { + excluded: ['server1'], + allowed: ['server2'], + }, + }); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getAllowedMcpServers()).toEqual(['server1']); + }); + + it('should prioritize CLI flag over both allowed and excluded settings', async () => { + process.argv = [ + 'node', + 'script.js', + '--allowed-mcp-server-names', + 'server2', + '--allowed-mcp-server-names', + 'server3', + ]; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + ...baseSettings, + mcp: { + allowed: ['server1', 'server2'], // Should be ignored + excluded: ['server3'], // Should be ignored + }, + }); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getAllowedMcpServers()).toEqual(['server2', 'server3']); + expect(config.getBlockedMcpServers()).toEqual([]); + }); +}); + +describe('loadCliConfig with admin.mcp.config', () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); + vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + const localMcpServers: Record = { + serverA: { + command: 'npx', + args: ['-y', '@mcp/server-a'], + env: { KEY: 'VALUE' }, + cwd: '/local/cwd', + trust: false, + }, + serverB: { + command: 'npx', + args: ['-y', '@mcp/server-b'], + trust: false, + }, + }; + + const baseSettings = createTestMergedSettings({ + mcp: { serverCommand: 'npx -y @mcp/default-server' }, + mcpServers: localMcpServers, + }); + + it('should use local configuration if admin allowlist is empty', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + mcp: baseSettings.mcp, + mcpServers: localMcpServers, + admin: { + ...baseSettings.admin, + mcp: { enabled: true, config: {} }, + }, + }); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getMcpServers()).toEqual(localMcpServers); + expect(config.getMcpServerCommand()).toBe('npx -y @mcp/default-server'); + }); + + it('should ignore locally configured servers not present in the allowlist', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const adminAllowlist: Record = { + serverA: { + type: 'sse', + url: 'https://admin-server-a.com/sse', + trust: true, + }, + }; + const settings = createTestMergedSettings({ + mcp: baseSettings.mcp, + mcpServers: localMcpServers, + admin: { + ...baseSettings.admin, + mcp: { enabled: true, config: adminAllowlist }, + }, + }); + const config = await loadCliConfig(settings, 'test-session', argv); + + const mergedServers = config.getMcpServers() ?? {}; + expect(mergedServers).toHaveProperty('serverA'); + expect(mergedServers).not.toHaveProperty('serverB'); + }); + + it('should clear command, args, env, and cwd for present servers', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const adminAllowlist: Record = { + serverA: { + type: 'sse', + url: 'https://admin-server-a.com/sse', + trust: true, + }, + }; + const settings = createTestMergedSettings({ + mcpServers: localMcpServers, + admin: { + ...baseSettings.admin, + mcp: { enabled: true, config: adminAllowlist }, + }, + }); + const config = await loadCliConfig(settings, 'test-session', argv); + + const serverA = config.getMcpServers()?.['serverA']; + expect(serverA).toEqual({ + // eslint-disable-next-line @typescript-eslint/no-misused-spread + ...localMcpServers['serverA'], + type: 'sse', + url: 'https://admin-server-a.com/sse', + trust: true, + command: undefined, + args: undefined, + env: undefined, + cwd: undefined, + httpUrl: undefined, + tcp: undefined, + }); + }); + + it('should not initialize a server if it is in allowlist but missing locally', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const adminAllowlist: Record = { + serverC: { + type: 'sse', + url: 'https://admin-server-c.com/sse', + trust: true, + }, + }; + const settings = createTestMergedSettings({ + mcpServers: localMcpServers, + admin: { + ...baseSettings.admin, + mcp: { enabled: true, config: adminAllowlist }, + }, + }); + const config = await loadCliConfig(settings, 'test-session', argv); + + const mergedServers = config.getMcpServers() ?? {}; + expect(mergedServers).not.toHaveProperty('serverC'); + expect(Object.keys(mergedServers)).toHaveLength(0); + }); + + it('should merge local fields and prefer admin tool filters', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const adminAllowlist: Record = { + serverA: { + type: 'sse', + url: 'https://admin-server-a.com/sse', + trust: true, + includeTools: ['admin_tool'], + }, + }; + const localMcpServersWithTools: Record = { + serverA: { + // eslint-disable-next-line @typescript-eslint/no-misused-spread + ...localMcpServers['serverA'], + includeTools: ['local_tool'], + timeout: 1234, + }, + }; + const settings = createTestMergedSettings({ + mcpServers: localMcpServersWithTools, + admin: { + ...baseSettings.admin, + mcp: { enabled: true, config: adminAllowlist }, + }, + }); + const config = await loadCliConfig(settings, 'test-session', argv); + + const serverA = (config.getMcpServers() ?? {})['serverA']; + expect(serverA).toMatchObject({ + timeout: 1234, + includeTools: ['admin_tool'], + type: 'sse', + url: 'https://admin-server-a.com/sse', + trust: true, + }); + expect(serverA).not.toHaveProperty('command'); + expect(serverA).not.toHaveProperty('args'); + expect(serverA).not.toHaveProperty('env'); + expect(serverA).not.toHaveProperty('cwd'); + expect(serverA).not.toHaveProperty('httpUrl'); + expect(serverA).not.toHaveProperty('tcp'); + }); + + it('should use local tool filters when admin does not define them', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const adminAllowlist: Record = { + serverA: { + type: 'sse', + url: 'https://admin-server-a.com/sse', + trust: true, + }, + }; + const localMcpServersWithTools: Record = { + serverA: { + // eslint-disable-next-line @typescript-eslint/no-misused-spread + ...localMcpServers['serverA'], + includeTools: ['local_tool'], + }, + }; + const settings = createTestMergedSettings({ + mcpServers: localMcpServersWithTools, + admin: { + ...baseSettings.admin, + mcp: { enabled: true, config: adminAllowlist }, + }, + }); + const config = await loadCliConfig(settings, 'test-session', argv); + + const serverA = config.getMcpServers()?.['serverA']; + expect(serverA?.includeTools).toEqual(['local_tool']); + }); +}); + +describe('loadCliConfig model selection', () => { + beforeEach(() => { + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it('selects a model from settings.json if provided', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings({ + model: { + name: 'gemini-2.5-pro', + }, + }), + 'test-session', + argv, + ); + + expect(config.getModel()).toBe('gemini-2.5-pro'); + }); + + it('uses the default gemini model if nothing is set', async () => { + process.argv = ['node', 'script.js']; // No model set. + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings({ + // No model set. + }), + 'test-session', + argv, + ); + + expect(config.getModel()).toBe('auto'); + }); + + it('always prefers model from argv', async () => { + process.argv = ['node', 'script.js', '--model', 'gemini-2.5-flash-preview']; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings({ + model: { + name: 'gemini-2.5-pro', + }, + }), + 'test-session', + argv, + ); + + expect(config.getModel()).toBe('gemini-2.5-flash-preview'); + }); + + it('selects the model from argv if provided', async () => { + process.argv = ['node', 'script.js', '--model', 'gemini-2.5-flash-preview']; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings({ + // No model provided via settings. + }), + 'test-session', + argv, + ); + + expect(config.getModel()).toBe('gemini-2.5-flash-preview'); + }); + + it('selects the default auto model if provided via auto alias', async () => { + process.argv = ['node', 'script.js', '--model', 'auto']; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings({ + // No model provided via settings. + }), + 'test-session', + argv, + ); + + expect(config.getModel()).toBe('auto'); + }); +}); + +describe('loadCliConfig folderTrust', () => { + let originalVitest: string | undefined; + let originalIntegrationTest: string | undefined; + + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); + vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); + + originalVitest = process.env['VITEST']; + originalIntegrationTest = process.env['GEMINI_CLI_INTEGRATION_TEST']; + delete process.env['VITEST']; + delete process.env['GEMINI_CLI_INTEGRATION_TEST']; + }); + + afterEach(() => { + if (originalVitest !== undefined) { + process.env['VITEST'] = originalVitest; + } + if (originalIntegrationTest !== undefined) { + process.env['GEMINI_CLI_INTEGRATION_TEST'] = originalIntegrationTest; + } + + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it('should be false when folderTrust is false', async () => { + process.argv = ['node', 'script.js']; + const settings = createTestMergedSettings({ + security: { + folderTrust: { + enabled: false, + }, + }, + }); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getFolderTrust()).toBe(false); + }); + + it('should be true when folderTrust is true', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + security: { + folderTrust: { + enabled: true, + }, + }, + }); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getFolderTrust()).toBe(true); + }); + + it('should be true by default', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getFolderTrust()).toBe(true); + }); +}); + +describe('loadCliConfig with includeDirectories', () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(os.homedir).mockReturnValue( + path.resolve(path.sep, 'mock', 'home', 'user'), + ); + vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); + vi.spyOn(process, 'cwd').mockReturnValue( + path.resolve(path.sep, 'home', 'user', 'project'), + ); + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it.skip('should combine and resolve paths from settings and CLI arguments', async () => { + const mockCwd = path.resolve(path.sep, 'home', 'user', 'project'); + process.argv = [ + 'node', + + 'script.js', + '--include-directories', + `${path.resolve(path.sep, 'cli', 'path1')},${path.join(mockCwd, 'cli', 'path2')}`, + ]; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + context: { + includeDirectories: [ + path.resolve(path.sep, 'settings', 'path1'), + path.join(os.homedir(), 'settings', 'path2'), + path.join(mockCwd, 'settings', 'path3'), + ], + }, + }); + const config = await loadCliConfig(settings, 'test-session', argv); + const expected = [ + mockCwd, + path.resolve(path.sep, 'cli', 'path1'), + path.join(mockCwd, 'cli', 'path2'), + path.resolve(path.sep, 'settings', 'path1'), + path.join(os.homedir(), 'settings', 'path2'), + path.join(mockCwd, 'settings', 'path3'), + ]; + const directories = config.getWorkspaceContext().getDirectories(); + expect(directories).toEqual([mockCwd]); + expect(config.getPendingIncludeDirectories()).toEqual( + expect.arrayContaining(expected.filter((dir) => dir !== mockCwd)), + ); + expect(config.getPendingIncludeDirectories()).toHaveLength( + expected.length - 1, + ); + }); +}); + +describe('loadCliConfig compressionThreshold', () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); + vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it('should pass settings to the core config', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + model: { + compressionThreshold: 0.5, + }, + }); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(await config.getCompressionThreshold()).toBe(0.5); + }); + + it('should have default compressionThreshold if not in settings', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(await config.getCompressionThreshold()).toBe(0.5); + }); +}); + +describe('loadCliConfig useRipgrep', () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); + vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it('should be true by default when useRipgrep is not set in settings', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getUseRipgrep()).toBe(true); + }); + + it('should be false when useRipgrep is set to false in settings', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ tools: { useRipgrep: false } }); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getUseRipgrep()).toBe(false); + }); + + it('should be true when useRipgrep is explicitly set to true in settings', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ tools: { useRipgrep: true } }); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getUseRipgrep()).toBe(true); + }); +}); + +describe('loadCliConfig directWebFetch', () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); + vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it('should be false by default when directWebFetch is not set in settings', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getDirectWebFetch()).toBe(false); + }); + + it('should be true when directWebFetch is set to true in settings', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + experimental: { + directWebFetch: true, + }, + }); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getDirectWebFetch()).toBe(true); + }); +}); + +describe('loadCliConfig context management', () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); + vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it('should be false by default when generalistProfile / context management is not set in settings', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getContextManagementConfig()).haveOwnProperty( + 'enabled', + false, + ); + expect(config.isContextManagementEnabled()).toBe(false); + }); + + it('should be true when generalistProfile is set to true in settings', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + experimental: { + generalistProfile: true, + }, + }); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.isContextManagementEnabled()).toBe(true); + }); +}); + +describe('screenReader configuration', () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); + vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it('should use screenReader value from settings if CLI flag is not present (settings true)', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + ui: { accessibility: { screenReader: true } }, + }); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getScreenReader()).toBe(true); + }); + + it('should use screenReader value from settings if CLI flag is not present (settings false)', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + ui: { accessibility: { screenReader: false } }, + }); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getScreenReader()).toBe(false); + }); + + it('should prioritize --screen-reader CLI flag (true) over settings (false)', async () => { + process.argv = ['node', 'script.js', '--screen-reader']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + ui: { accessibility: { screenReader: false } }, + }); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getScreenReader()).toBe(true); + }); + + it('should be false by default when no flag or setting is present', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getScreenReader()).toBe(false); + }); +}); + +describe('loadCliConfig tool exclusions', () => { + const originalIsTTY = process.stdin.isTTY; + + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); + vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); + process.stdin.isTTY = true; + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: true, + source: undefined, + }); + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); + }); + + afterEach(() => { + process.stdin.isTTY = originalIsTTY; + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it('should not exclude interactive tools in interactive mode without YOLO', async () => { + process.stdin.isTTY = true; + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); + expect(config.getExcludeTools()).not.toContain('run_shell_command'); + expect(config.getExcludeTools()).not.toContain('replace'); + expect(config.getExcludeTools()).not.toContain('write_file'); + expect(config.getExcludeTools()).not.toContain('ask_user'); + }); + + it('should not exclude interactive tools in interactive mode with YOLO', async () => { + process.stdin.isTTY = true; + process.argv = ['node', 'script.js', '--yolo']; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); + expect(config.getExcludeTools()).not.toContain('run_shell_command'); + expect(config.getExcludeTools()).not.toContain('replace'); + expect(config.getExcludeTools()).not.toContain('write_file'); + expect(config.getExcludeTools()).not.toContain('ask_user'); + }); + + it('should exclude interactive tools in non-interactive mode without YOLO', async () => { + process.stdin.isTTY = false; + process.argv = ['node', 'script.js', '-p', 'test']; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); + expect(config.getExcludeTools()).not.toContain('run_shell_command'); + expect(config.getExcludeTools()).not.toContain('replace'); + expect(config.getExcludeTools()).not.toContain('write_file'); + expect(config.getExcludeTools()).toContain('ask_user'); + }); + + it('should exclude only ask_user in non-interactive mode with YOLO', async () => { + process.stdin.isTTY = false; + process.argv = ['node', 'script.js', '-p', 'test', '--yolo']; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); + expect(config.getExcludeTools()).not.toContain('run_shell_command'); + expect(config.getExcludeTools()).not.toContain('replace'); + expect(config.getExcludeTools()).not.toContain('write_file'); + expect(config.getExcludeTools()).toContain('ask_user'); + }); + + it('should exclude ask_user in interactive mode when --acp is provided', async () => { + process.stdin.isTTY = true; + process.argv = ['node', 'script.js', '--acp']; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); + expect(config.getExcludeTools()).toContain('ask_user'); + }); + + it('should exclude ask_user in interactive mode when --experimental-acp is provided', async () => { + process.stdin.isTTY = true; + process.argv = ['node', 'script.js', '--experimental-acp']; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); + expect(config.getExcludeTools()).toContain('ask_user'); + }); + + it('should not exclude shell tool in non-interactive mode when --allowed-tools="ShellTool" is set', async () => { + process.stdin.isTTY = false; + process.argv = [ + 'node', + 'script.js', + '-p', + 'test', + '--allowed-tools', + 'ShellTool', + ]; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); + expect(config.getExcludeTools()).not.toContain(SHELL_TOOL_NAME); + }); + + it('should not exclude web-fetch in non-interactive mode at config level', async () => { + process.stdin.isTTY = false; + process.argv = ['node', 'script.js', '-p', 'test']; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); + expect(config.getExcludeTools()).not.toContain(WEB_FETCH_TOOL_NAME); + }); + + it('should not exclude web-fetch in non-interactive mode when allowed', async () => { + process.stdin.isTTY = false; + process.argv = [ + 'node', + 'script.js', + '-p', + 'test', + '--allowed-tools', + WEB_FETCH_TOOL_NAME, + ]; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); + expect(config.getExcludeTools()).not.toContain(WEB_FETCH_TOOL_NAME); + }); + + it('should not exclude shell tool in non-interactive mode when --allowed-tools="run_shell_command" is set', async () => { + process.stdin.isTTY = false; + process.argv = [ + 'node', + 'script.js', + '-p', + 'test', + '--allowed-tools', + 'run_shell_command', + ]; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); + expect(config.getExcludeTools()).not.toContain(SHELL_TOOL_NAME); + }); + + it('should not exclude shell tool in non-interactive mode when --allowed-tools="ShellTool(wc)" is set', async () => { + process.stdin.isTTY = false; + process.argv = [ + 'node', + 'script.js', + '-p', + 'test', + '--allowed-tools', + 'ShellTool(wc)', + ]; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); + expect(config.getExcludeTools()).not.toContain(SHELL_TOOL_NAME); + }); +}); + +describe('loadCliConfig interactive', () => { + const originalIsTTY = process.stdin.isTTY; + + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); + vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); + process.stdin.isTTY = true; + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); + }); + + afterEach(() => { + process.stdin.isTTY = originalIsTTY; + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it('should be interactive if isTTY and no prompt', async () => { + process.stdin.isTTY = true; + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); + expect(config.isInteractive()).toBe(true); + }); + + it('should be interactive if prompt-interactive is set', async () => { + process.stdin.isTTY = false; + process.argv = ['node', 'script.js', '--prompt-interactive', 'test']; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); + expect(config.isInteractive()).toBe(true); + }); + + it('should not be interactive if not isTTY and no prompt', async () => { + process.stdin.isTTY = false; + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); + expect(config.isInteractive()).toBe(false); + }); + + it('should not be interactive if prompt is set', async () => { + process.stdin.isTTY = true; + process.argv = ['node', 'script.js', '--prompt', 'test']; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); + expect(config.isInteractive()).toBe(false); + }); + + it('should be interactive if positional prompt words are provided with other flags', async () => { + process.stdin.isTTY = true; + process.argv = ['node', 'script.js', '--model', 'gemini-2.5-pro', 'Hello']; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); + expect(config.isInteractive()).toBe(true); + }); + + it('should be interactive if positional prompt words are provided with multiple flags', async () => { + process.stdin.isTTY = true; + process.argv = [ + 'node', + 'script.js', + '--model', + 'gemini-2.5-pro', + '--yolo', + 'Hello world', + ]; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); + expect(config.isInteractive()).toBe(true); + // Verify the question is preserved for one-shot execution + expect(argv.prompt).toBeUndefined(); + expect(argv.promptInteractive).toBe('Hello world'); + }); + + it('should be interactive if positional prompt words are provided with extensions flag', async () => { + process.stdin.isTTY = true; + process.argv = ['node', 'script.js', '-e', 'none', 'hello']; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); + expect(config.isInteractive()).toBe(true); + expect(argv.query).toBe('hello'); + expect(argv.promptInteractive).toBe('hello'); + expect(argv.extensions).toEqual(['none']); + }); + + it('should handle multiple positional words correctly', async () => { + process.stdin.isTTY = true; + process.argv = ['node', 'script.js', 'hello world how are you']; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); + expect(config.isInteractive()).toBe(true); + expect(argv.query).toBe('hello world how are you'); + expect(argv.promptInteractive).toBe('hello world how are you'); + }); + + it('should handle multiple positional words with flags', async () => { + process.stdin.isTTY = true; + process.argv = [ + 'node', + 'script.js', + '--model', + 'gemini-2.5-pro', + 'write', + 'a', + 'function', + 'to', + 'sort', + 'array', + ]; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); + expect(config.isInteractive()).toBe(true); + expect(argv.query).toBe('write a function to sort array'); + expect(argv.promptInteractive).toBe('write a function to sort array'); + expect(argv.model).toBe('gemini-2.5-pro'); + }); + + it('should handle empty positional arguments', async () => { + process.stdin.isTTY = true; + process.argv = ['node', 'script.js', '']; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); + expect(config.isInteractive()).toBe(true); + expect(argv.query).toBeUndefined(); + }); + + it('should handle extensions flag with positional arguments correctly', async () => { + process.stdin.isTTY = true; + process.argv = [ + 'node', + 'script.js', + '-e', + 'none', + 'hello', + 'world', + 'how', + 'are', + 'you', + ]; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); + expect(config.isInteractive()).toBe(true); + expect(argv.query).toBe('hello world how are you'); + expect(argv.promptInteractive).toBe('hello world how are you'); + expect(argv.extensions).toEqual(['none']); + }); + + it('should be interactive if no positional prompt words are provided with flags', async () => { + process.stdin.isTTY = true; + process.argv = ['node', 'script.js', '--model', 'gemini-2.5-pro']; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); + expect(config.isInteractive()).toBe(true); + }); +}); + +describe('loadCliConfig approval mode', () => { + const originalArgv = process.argv; + + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); + vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); + process.argv = ['node', 'script.js']; // Reset argv for each test + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: true, + source: undefined, + }); + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); + }); + + afterEach(() => { + process.argv = originalArgv; + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it('should default to DEFAULT approval mode when no flags are set', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); + expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); + }); + + it('should set YOLO approval mode when --yolo flag is used', async () => { + process.argv = ['node', 'script.js', '--yolo']; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); + expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO); + }); + + it('should set YOLO approval mode when -y flag is used', async () => { + process.argv = ['node', 'script.js', '-y']; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); + expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO); + }); + + it('should set DEFAULT approval mode when --approval-mode=default', async () => { + process.argv = ['node', 'script.js', '--approval-mode', 'default']; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); + expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); + }); + + it('should set AUTO_EDIT approval mode when --approval-mode=auto_edit', async () => { + process.argv = ['node', 'script.js', '--approval-mode', 'auto_edit']; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); + expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.AUTO_EDIT); + }); + + it('should set YOLO approval mode when --approval-mode=yolo', async () => { + process.argv = ['node', 'script.js', '--approval-mode', 'yolo']; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); + expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO); + }); + + it('should prioritize --approval-mode over --yolo when both would be valid (but validation prevents this)', async () => { + // Note: This test documents the intended behavior, but in practice the validation + // prevents both flags from being used together + process.argv = ['node', 'script.js', '--approval-mode', 'default']; + const argv = await parseArguments(createTestMergedSettings()); + // Manually set yolo to true to simulate what would happen if validation didn't prevent it + argv.yolo = true; + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); + expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); + }); + + it('should fall back to --yolo behavior when --approval-mode is not set', async () => { + process.argv = ['node', 'script.js', '--yolo']; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); + expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO); + }); + + it('should set Plan approval mode when --approval-mode=plan is used and plan is enabled', async () => { + process.argv = ['node', 'script.js', '--approval-mode', 'plan']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + general: { + plan: { enabled: true }, + }, + }); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.PLAN); + }); + + it('should ignore "yolo" in settings.tools.approvalMode and fall back to DEFAULT', async () => { + process.argv = ['node', 'script.js']; + const settings = createTestMergedSettings({ + tools: { + // @ts-expect-error: testing invalid value + approvalMode: 'yolo', + }, + }); + const argv = await parseArguments(settings); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); + }); + + it('should throw error when --approval-mode=plan is used but plan is disabled', async () => { + process.argv = ['node', 'script.js', '--approval-mode', 'plan']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + general: { + plan: { enabled: false }, + }, + }); + + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getApprovalMode()).toBe(ApprovalMode.DEFAULT); + }); + + it('should allow plan approval mode by default when --approval-mode=plan is used', async () => { + process.argv = ['node', 'script.js', '--approval-mode', 'plan']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({}); + + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getApprovalMode()).toBe(ApprovalMode.PLAN); + }); + + it('should pass planSettings.directory from settings to config', async () => { + process.argv = ['node', 'script.js']; + const settings = createTestMergedSettings({ + general: { + plan: { + directory: '.custom-plans', + }, + }, + } as unknown as MergedSettings); + const argv = await parseArguments(settings); + const config = await loadCliConfig(settings, 'test-session', argv); + const plansDir = config.storage.getPlansDir(); + expect(plansDir).toContain('.custom-plans'); + }); + + // --- Untrusted Folder Scenarios --- + describe('when folder is NOT trusted', () => { + beforeEach(() => { + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: false, + source: 'file', + }); + }); + + it('should override --approval-mode=yolo to DEFAULT', async () => { + process.argv = ['node', 'script.js', '--approval-mode', 'yolo']; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); + expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); + }); + + it('should override --approval-mode=auto_edit to DEFAULT', async () => { + process.argv = ['node', 'script.js', '--approval-mode', 'auto_edit']; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); + expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); + }); + + it('should override --yolo flag to DEFAULT', async () => { + process.argv = ['node', 'script.js', '--yolo']; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); + expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); + }); + + it('should remain DEFAULT when --approval-mode=default', async () => { + process.argv = ['node', 'script.js', '--approval-mode', 'default']; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); + expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); + }); + }); + + describe('Persistent approvalMode setting', () => { + it('should use approvalMode from settings when no CLI flags are set', async () => { + process.argv = ['node', 'script.js']; + const settings = createTestMergedSettings({ + general: { defaultApprovalMode: 'auto_edit' }, + }); + const argv = await parseArguments(settings); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getApprovalMode()).toBe( + ServerConfig.ApprovalMode.AUTO_EDIT, + ); + }); + + it('should prioritize --approval-mode flag over settings', async () => { + process.argv = ['node', 'script.js', '--approval-mode', 'auto_edit']; + const settings = createTestMergedSettings({ + general: { defaultApprovalMode: 'default' }, + }); + const argv = await parseArguments(settings); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getApprovalMode()).toBe( + ServerConfig.ApprovalMode.AUTO_EDIT, + ); + }); + + it('should prioritize --yolo flag over settings', async () => { + process.argv = ['node', 'script.js', '--yolo']; + const settings = createTestMergedSettings({ + general: { defaultApprovalMode: 'auto_edit' }, + }); + const argv = await parseArguments(settings); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO); + }); + + it('should respect plan mode from settings when plan is enabled', async () => { + process.argv = ['node', 'script.js']; + const settings = createTestMergedSettings({ + general: { + defaultApprovalMode: 'plan', + plan: { enabled: true }, + }, + }); + const argv = await parseArguments(settings); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.PLAN); + }); + + it('should fall back to default if plan mode is in settings but disabled', async () => { + process.argv = ['node', 'script.js']; + const settings = createTestMergedSettings({ + general: { + defaultApprovalMode: 'plan', + plan: { enabled: false }, + }, + }); + const argv = await parseArguments(settings); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getApprovalMode()).toBe(ApprovalMode.DEFAULT); + }); + }); +}); + +describe('loadCliConfig gemmaModelRouter', () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); + vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it('should have gemmaModelRouter disabled by default', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getGemmaModelRouterEnabled()).toBe(false); + }); + + it('should load gemmaModelRouter settings from merged settings', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + experimental: { + gemmaModelRouter: { + enabled: true, + autoStartServer: false, + binaryPath: '/custom/lit', + classifier: { + host: 'http://custom:1234', + model: 'custom-gemma', + }, + }, + }, + }); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getGemmaModelRouterEnabled()).toBe(true); + const gemmaSettings = config.getGemmaModelRouterSettings(); + expect(gemmaSettings.autoStartServer).toBe(false); + expect(gemmaSettings.binaryPath).toBe('/custom/lit'); + expect(gemmaSettings.classifier?.host).toBe('http://custom:1234'); + expect(gemmaSettings.classifier?.model).toBe('custom-gemma'); + }); + + it('should load experimental.gemma setting from merged settings', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + experimental: { + gemma: true, + }, + }); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getExperimentalGemma()).toBe(true); + }); + + it('should handle partial gemmaModelRouter settings', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + experimental: { + gemmaModelRouter: { + enabled: true, + }, + }, + }); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getGemmaModelRouterEnabled()).toBe(true); + const gemmaSettings = config.getGemmaModelRouterSettings(); + expect(gemmaSettings.autoStartServer).toBe(false); + expect(gemmaSettings.binaryPath).toBe(''); + expect(gemmaSettings.classifier?.host).toBe('http://localhost:9379'); + expect(gemmaSettings.classifier?.model).toBe('gemma3-1b-gpu-custom'); + }); +}); + +describe('loadCliConfig fileFiltering', () => { + const originalArgv = process.argv; + + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); + vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); + process.argv = ['node', 'script.js']; // Reset argv for each test + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); + }); + + afterEach(() => { + process.argv = originalArgv; + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + type FileFilteringSettings = NonNullable< + NonNullable['fileFiltering'] + >; + const testCases: Array<{ + property: keyof FileFilteringSettings; + getter: (config: ServerConfig.Config) => boolean; + value: boolean; + }> = [ + { + property: 'enableFuzzySearch', + getter: (c) => c.getFileFilteringEnableFuzzySearch(), + value: true, + }, + { + property: 'enableFuzzySearch', + getter: (c) => c.getFileFilteringEnableFuzzySearch(), + value: false, + }, + { + property: 'respectGitIgnore', + getter: (c) => c.getFileFilteringRespectGitIgnore(), + value: true, + }, + { + property: 'respectGitIgnore', + getter: (c) => c.getFileFilteringRespectGitIgnore(), + value: false, + }, + { + property: 'respectGeminiIgnore', + getter: (c) => c.getFileFilteringRespectGeminiIgnore(), + value: true, + }, + { + property: 'respectGeminiIgnore', + getter: (c) => c.getFileFilteringRespectGeminiIgnore(), + value: false, + }, + { + property: 'enableRecursiveFileSearch', + getter: (c) => c.getEnableRecursiveFileSearch(), + value: true, + }, + { + property: 'enableRecursiveFileSearch', + getter: (c) => c.getEnableRecursiveFileSearch(), + value: false, + }, + ]; + + it.each(testCases)( + 'should pass $property from settings to config when $value', + async ({ property, getter, value }) => { + const settings = createTestMergedSettings({ + context: { + fileFiltering: { [property]: value }, + }, + }); + const argv = await parseArguments(settings); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(getter(config)).toBe(value); + }, + ); +}); + +describe('Output format', () => { + beforeEach(() => { + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it('should default to TEXT', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); + expect(config.getOutputFormat()).toBe(OutputFormat.TEXT); + }); + + it('should use the format from settings', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings({ output: { format: OutputFormat.JSON } }), + 'test-session', + argv, + ); + expect(config.getOutputFormat()).toBe(OutputFormat.JSON); + }); + + it('should prioritize the format from argv', async () => { + process.argv = ['node', 'script.js', '--output-format', 'json']; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings({ output: { format: OutputFormat.JSON } }), + 'test-session', + argv, + ); + expect(config.getOutputFormat()).toBe(OutputFormat.JSON); + }); + + it('should accept stream-json as a valid output format', async () => { + process.argv = ['node', 'script.js', '--output-format', 'stream-json']; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); + expect(config.getOutputFormat()).toBe(OutputFormat.STREAM_JSON); + }); + + it('should error on invalid --output-format argument', async () => { + process.argv = ['node', 'script.js', '--output-format', 'invalid']; + + vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + + const mockConsoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + const debugErrorSpy = vi + .spyOn(debugLogger, 'error') + .mockImplementation(() => {}); + + await expect(parseArguments(createTestMergedSettings())).rejects.toThrow( + 'process.exit called', + ); + expect(debugErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Invalid values:'), + ); + expect(mockConsoleError).toHaveBeenCalled(); + }); +}); + +describe('parseArguments with positional prompt', () => { + const originalArgv = process.argv; + + beforeEach(() => { + // Default to headless mode for these tests as they mostly expect one-shot behavior + process.stdin.isTTY = false; + Object.defineProperty(process.stdout, 'isTTY', { + value: false, + configurable: true, + writable: true, + }); + }); + + afterEach(() => { + process.argv = originalArgv; + }); + + it('should throw an error when both a positional prompt and the --prompt flag are used', async () => { + process.argv = [ + 'node', + 'script.js', + 'positional', + 'prompt', + '--prompt', + 'test prompt', + ]; + + vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + + vi.spyOn(console, 'error').mockImplementation(() => {}); + const debugErrorSpy = vi + .spyOn(debugLogger, 'error') + .mockImplementation(() => {}); + + await expect(parseArguments(createTestMergedSettings())).rejects.toThrow( + 'process.exit called', + ); + + expect(debugErrorSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'Cannot use both a positional prompt and the --prompt (-p) flag together', + ), + ); + }); + + it('should correctly parse a positional prompt to query field', async () => { + process.argv = ['node', 'script.js', 'positional', 'prompt']; + const argv = await parseArguments(createTestMergedSettings()); + expect(argv.query).toBe('positional prompt'); + // Since no explicit prompt flags are set and query doesn't start with @, should map to prompt (one-shot) + expect(argv.prompt).toBe('positional prompt'); + expect(argv.promptInteractive).toBeUndefined(); + }); + + it('should have correct positional argument description', async () => { + // Test that the positional argument has the expected description + const yargsInstance = await import('./config.js'); + // This test verifies that the positional 'query' argument is properly configured + // with the description: "Positional prompt. Defaults to one-shot; use -i/--prompt-interactive for interactive." + process.argv = ['node', 'script.js', 'test', 'query']; + const argv = await yargsInstance.parseArguments(createTestMergedSettings()); + expect(argv.query).toBe('test query'); + }); + + it('should correctly parse a prompt from the --prompt flag', async () => { + process.argv = ['node', 'script.js', '--prompt', 'test prompt']; + const argv = await parseArguments(createTestMergedSettings()); + expect(argv.prompt).toBe('test prompt'); + }); +}); + +describe('Telemetry configuration via environment variables', () => { + beforeEach(() => { + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); + }); + afterEach(() => { + vi.resetAllMocks(); + }); + + it('should prioritize GEMINI_TELEMETRY_ENABLED over settings', async () => { + vi.stubEnv('GEMINI_TELEMETRY_ENABLED', 'true'); + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + telemetry: { enabled: false }, + }); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getTelemetryEnabled()).toBe(true); + }); + + it('should prioritize GEMINI_TELEMETRY_TARGET over settings', async () => { + vi.stubEnv('GEMINI_TELEMETRY_TARGET', 'gcp'); + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + telemetry: { target: ServerConfig.TelemetryTarget.LOCAL }, + }); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getTelemetryTarget()).toBe('gcp'); + }); + + it('should throw when GEMINI_TELEMETRY_TARGET is invalid', async () => { + vi.stubEnv('GEMINI_TELEMETRY_TARGET', 'bogus'); + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + telemetry: { target: ServerConfig.TelemetryTarget.GCP }, + }); + await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow( + /Invalid telemetry configuration: .*Invalid telemetry target/i, + ); + vi.unstubAllEnvs(); + }); + + it('should prioritize GEMINI_TELEMETRY_OTLP_ENDPOINT over settings and default env var', async () => { + vi.stubEnv('OTEL_EXPORTER_OTLP_ENDPOINT', 'http://default.env.com'); + vi.stubEnv('GEMINI_TELEMETRY_OTLP_ENDPOINT', 'http://gemini.env.com'); + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + telemetry: { otlpEndpoint: 'http://settings.com' }, + }); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getTelemetryOtlpEndpoint()).toBe('http://gemini.env.com'); + }); + + it('should prioritize GEMINI_TELEMETRY_OTLP_PROTOCOL over settings', async () => { + vi.stubEnv('GEMINI_TELEMETRY_OTLP_PROTOCOL', 'http'); + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + telemetry: { otlpProtocol: 'grpc' }, + }); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getTelemetryOtlpProtocol()).toBe('http'); + }); + + it('should prioritize GEMINI_TELEMETRY_LOG_PROMPTS over settings', async () => { + vi.stubEnv('GEMINI_TELEMETRY_LOG_PROMPTS', 'false'); + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + telemetry: { logPrompts: true }, + }); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getTelemetryLogPromptsEnabled()).toBe(false); + }); + + it('should prioritize GEMINI_TELEMETRY_OUTFILE over settings', async () => { + vi.stubEnv('GEMINI_TELEMETRY_OUTFILE', '/gemini/env/telemetry.log'); + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + telemetry: { outfile: '/settings/telemetry.log' }, + }); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getTelemetryOutfile()).toBe('/gemini/env/telemetry.log'); + }); + + it('should prioritize GEMINI_TELEMETRY_USE_COLLECTOR over settings', async () => { + vi.stubEnv('GEMINI_TELEMETRY_USE_COLLECTOR', 'true'); + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + telemetry: { useCollector: false }, + }); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getTelemetryUseCollector()).toBe(true); + }); + + it('should use settings value when GEMINI_TELEMETRY_ENABLED is not set', async () => { + vi.stubEnv('GEMINI_TELEMETRY_ENABLED', undefined); + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ telemetry: { enabled: true } }); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getTelemetryEnabled()).toBe(true); + }); + + it('should use settings value when GEMINI_TELEMETRY_TARGET is not set', async () => { + vi.stubEnv('GEMINI_TELEMETRY_TARGET', undefined); + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + telemetry: { target: ServerConfig.TelemetryTarget.LOCAL }, + }); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getTelemetryTarget()).toBe('local'); + }); + + it("should treat GEMINI_TELEMETRY_ENABLED='1' as true", async () => { + vi.stubEnv('GEMINI_TELEMETRY_ENABLED', '1'); + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); + expect(config.getTelemetryEnabled()).toBe(true); + }); + + it("should treat GEMINI_TELEMETRY_ENABLED='0' as false", async () => { + vi.stubEnv('GEMINI_TELEMETRY_ENABLED', '0'); + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings({ telemetry: { enabled: true } }), + 'test-session', + argv, + ); + expect(config.getTelemetryEnabled()).toBe(false); + }); + + it("should treat GEMINI_TELEMETRY_LOG_PROMPTS='1' as true", async () => { + vi.stubEnv('GEMINI_TELEMETRY_LOG_PROMPTS', '1'); + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); + expect(config.getTelemetryLogPromptsEnabled()).toBe(true); + }); + + it("should treat GEMINI_TELEMETRY_LOG_PROMPTS='false' as false", async () => { + vi.stubEnv('GEMINI_TELEMETRY_LOG_PROMPTS', 'false'); + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings({ telemetry: { logPrompts: true } }), + 'test-session', + argv, + ); + expect(config.getTelemetryLogPromptsEnabled()).toBe(false); + }); +}); + +describe('PolicyEngine nonInteractive wiring', () => { + const originalIsTTY = process.stdin.isTTY; + + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); + }); + + afterEach(() => { + process.stdin.isTTY = originalIsTTY; + vi.restoreAllMocks(); + }); + + it('should set nonInteractive to true when -p flag is used', async () => { + process.stdin.isTTY = true; + process.argv = ['node', 'script.js', '-p', 'echo hello']; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); + expect(config.isInteractive()).toBe(false); + expect( + (config.getPolicyEngine() as unknown as { nonInteractive: boolean }) + .nonInteractive, + ).toBe(true); + }); + + it('should set nonInteractive to false in interactive mode', async () => { + process.stdin.isTTY = true; + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); + expect(config.isInteractive()).toBe(true); + expect( + (config.getPolicyEngine() as unknown as { nonInteractive: boolean }) + .nonInteractive, + ).toBe(false); + }); +}); + +describe('Policy Engine Integration in loadCliConfig', () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); + vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it('should pass merged allowed tools from CLI and settings to createPolicyEngineConfig', async () => { + process.argv = ['node', 'script.js', '--allowed-tools', 'cli-tool']; + const settings = createTestMergedSettings({ + tools: { allowed: ['settings-tool'] }, + }); + const argv = await parseArguments(createTestMergedSettings()); + + await loadCliConfig(settings, 'test-session', argv); + + expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( + expect.objectContaining({ + tools: expect.objectContaining({ + allowed: expect.arrayContaining(['cli-tool']), + }), + }), + expect.anything(), + undefined, + expect.anything(), + ); + }); + + it('should pass merged exclude tools from CLI logic and settings to createPolicyEngineConfig', async () => { + process.stdin.isTTY = false; // Non-interactive to trigger default excludes + process.argv = ['node', 'script.js', '-p', 'test']; + const settings = createTestMergedSettings({ + tools: { exclude: ['settings-exclude'] }, + }); + const argv = await parseArguments(createTestMergedSettings()); + + await loadCliConfig(settings, 'test-session', argv); + + // In non-interactive mode, only ask_user is excluded by default + expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( + expect.objectContaining({ + tools: expect.objectContaining({ + exclude: expect.arrayContaining([ASK_USER_TOOL_NAME]), + }), + }), + expect.anything(), + undefined, + expect.anything(), + ); + }); + + it('should pass user-provided policy paths from --policy flag to createPolicyEngineConfig', async () => { + process.argv = [ + 'node', + 'script.js', + '--policy', + '/path/to/policy1.toml,/path/to/policy2.toml', + ]; + const settings = createTestMergedSettings(); + const argv = await parseArguments(settings); + + await loadCliConfig(settings, 'test-session', argv); + + expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( + expect.objectContaining({ + policyPaths: [ + path.normalize('/path/to/policy1.toml'), + path.normalize('/path/to/policy2.toml'), + ], + }), + expect.anything(), + undefined, + expect.anything(), + ); + }); +}); + +describe('loadCliConfig disableYoloMode', () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); + vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: true, + source: undefined, + }); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it('should allow auto_edit mode even if yolo mode is disabled', async () => { + process.argv = ['node', 'script.js', '--approval-mode=auto_edit']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + security: { disableYoloMode: true }, + }); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getApprovalMode()).toBe(ApprovalMode.AUTO_EDIT); + }); + + it('should throw if YOLO mode is attempted when disableYoloMode is true', async () => { + process.argv = ['node', 'script.js', '--yolo']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + security: { disableYoloMode: true }, + }); + await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow( + 'YOLO mode is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli', + ); + }); +}); + +describe('loadCliConfig secureModeEnabled', () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); + vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: true, + source: undefined, + }); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it('should throw an error if YOLO mode is attempted when secureModeEnabled is true', async () => { + process.argv = ['node', 'script.js', '--yolo']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + admin: { + secureModeEnabled: true, + }, + }); + + await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow( + 'YOLO mode is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli', + ); + }); + + it('should throw an error if approval-mode=yolo is attempted when secureModeEnabled is true', async () => { + process.argv = ['node', 'script.js', '--approval-mode=yolo']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + admin: { + secureModeEnabled: true, + }, + }); + + await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow( + 'YOLO mode is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli', + ); + }); + + it('should set disableYoloMode to true when secureModeEnabled is true', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + admin: { + secureModeEnabled: true, + }, + }); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.isYoloModeDisabled()).toBe(true); + }); +}); + +describe('loadCliConfig mcpEnabled', () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); + vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + const mcpSettings = { + mcp: { + serverCommand: 'mcp-server', + allowed: ['serverA'], + excluded: ['serverB'], + }, + mcpServers: { serverA: { url: 'http://a' } }, + }; + + it('should enable MCP by default', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ ...mcpSettings }); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getMcpEnabled()).toBe(true); + expect(config.getMcpServerCommand()).toBe('mcp-server'); + expect(config.getMcpServers()).toEqual({ serverA: { url: 'http://a' } }); + expect(config.getAllowedMcpServers()).toEqual(['serverA']); + expect(config.getBlockedMcpServers()).toEqual(['serverB']); + }); + + it('should disable MCP when mcpEnabled is false', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + ...mcpSettings, + admin: { + mcp: { + enabled: false, + }, + }, + }); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getMcpEnabled()).toBe(false); + expect(config.getMcpServerCommand()).toBeUndefined(); + expect(config.getMcpServers()).toEqual({}); + expect(config.getAllowedMcpServers()).toEqual([]); + expect(config.getBlockedMcpServers()).toEqual([]); + }); + + it('should enable MCP when mcpEnabled is true', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + ...mcpSettings, + admin: { + mcp: { + enabled: true, + }, + }, + }); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getMcpEnabled()).toBe(true); + expect(config.getMcpServerCommand()).toBe('mcp-server'); + expect(config.getMcpServers()).toEqual({ serverA: { url: 'http://a' } }); + expect(config.getAllowedMcpServers()).toEqual(['serverA']); + expect(config.getBlockedMcpServers()).toEqual(['serverB']); + }); + + describe('extension plan settings', () => { + beforeEach(() => { + vi.spyOn(Storage.prototype, 'getProjectTempDir').mockReturnValue( + '/mock/home/user/.gemini/tmp/test-project', + ); + }); + + it('should use plan directory from active extension when user has not specified one', async () => { + process.argv = ['node', 'script.js']; + const settings = createTestMergedSettings({ + general: { + plan: { enabled: true }, + }, + }); + const argv = await parseArguments(settings); + + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ + { + name: 'ext-plan', + isActive: true, + plan: { directory: 'ext-plans-dir' }, + } as unknown as GeminiCLIExtension, + ]); + + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.storage.getPlansDir()).toContain('ext-plans-dir'); + }); + + it('should NOT use plan directory from active extension when user has specified one', async () => { + process.argv = ['node', 'script.js']; + const settings = createTestMergedSettings({ + general: { + plan: { + enabled: true, + directory: 'user-plans-dir', + }, + }, + }); + const argv = await parseArguments(settings); + + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ + { + name: 'ext-plan', + isActive: true, + plan: { directory: 'ext-plans-dir' }, + } as unknown as GeminiCLIExtension, + ]); + + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.storage.getPlansDir()).toContain('user-plans-dir'); + expect(config.storage.getPlansDir()).not.toContain('ext-plans-dir'); + }); + + it('should NOT use plan directory from inactive extension', async () => { + process.argv = ['node', 'script.js']; + const settings = createTestMergedSettings({ + general: { + plan: { enabled: true }, + }, + }); + const argv = await parseArguments(settings); + + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ + { + name: 'ext-plan', + isActive: false, + plan: { directory: 'ext-plans-dir-inactive' }, + } as unknown as GeminiCLIExtension, + ]); + + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.storage.getPlansDir()).not.toContain( + 'ext-plans-dir-inactive', + ); + }); + + it('should use default path if neither user nor extension settings provide a plan directory', async () => { + process.argv = ['node', 'script.js']; + const settings = createTestMergedSettings({ + general: { + plan: { enabled: true }, + }, + }); + const argv = await parseArguments(settings); + + // No extensions providing plan directory + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); + + const config = await loadCliConfig(settings, 'test-session', argv); + // Should return the default managed temp directory path + expect(config.storage.getPlansDir()).toBe( + path.join( + '/mock', + 'home', + 'user', + '.gemini', + 'tmp', + 'test-project', + 'test-session', + 'plans', + ), + ); + }); + }); +}); + +describe('loadCliConfig acpMode and clientName', () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); + vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('should set acpMode to true and detect clientName when --acp flag is used', async () => { + process.argv = ['node', 'script.js', '--acp']; + vi.stubEnv('TERM_PROGRAM', 'vscode'); + vi.stubEnv('VSCODE_GIT_ASKPASS_MAIN', ''); + vi.stubEnv('ANTIGRAVITY_CLI_ALIAS', ''); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); + expect(config.getAcpMode()).toBe(true); + expect(config.getClientName()).toBe('acp-vscode'); + }); + + it('should set acpMode to true and set clientName to acp for generic terminals', async () => { + process.argv = ['node', 'script.js', '--acp']; + vi.stubEnv('TERM_PROGRAM', 'iTerm.app'); // Generic terminal + vi.stubEnv('VSCODE_GIT_ASKPASS_MAIN', ''); + vi.stubEnv('ANTIGRAVITY_CLI_ALIAS', ''); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); + expect(config.getAcpMode()).toBe(true); + expect(config.getClientName()).toBe('acp'); + }); + + it('should set acpMode to false and clientName to tui by default', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); + expect(config.getAcpMode()).toBe(false); + expect(config.getClientName()).toBe('tui'); + }); +}); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts new file mode 100644 index 0000000000000000000000000000000000000000..f3e8a662296398f0ea95912309c8db733594eef7 --- /dev/null +++ b/packages/cli/src/config/config.ts @@ -0,0 +1,1184 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import yargs from 'yargs'; +import { hideBin } from 'yargs/helpers'; +import process from 'node:process'; +import * as path from 'node:path'; +import { execa } from 'execa'; +import { mcpCommand } from '../commands/mcp.js'; +import { extensionsCommand } from '../commands/extensions.js'; +import { skillsCommand } from '../commands/skills.js'; +import { hooksCommand } from '../commands/hooks.js'; +import { gemmaCommand } from '../commands/gemma.js'; +import { + setGeminiMdFilename as setServerGeminiMdFilename, + resetGeminiMdFilename, + DEFAULT_CONTEXT_FILENAME, + ApprovalMode, + DEFAULT_GEMINI_EMBEDDING_MODEL, + DEFAULT_FILE_FILTERING_OPTIONS, + FileDiscoveryService, + resolveTelemetrySettings, + FatalConfigError, + getErrorMessage, + getPty, + debugLogger, + ASK_USER_TOOL_NAME, + getVersion, + coreEvents, + GEMINI_MODEL_ALIAS_AUTO, + getAdminErrorMessage, + isHeadlessMode, + Config, + SimpleExtensionLoader, + resolveToRealPath, + applyAdminAllowlist, + applyRequiredServers, + getAdminBlockedMcpServersMessage, + getProjectRootForWorktree, + isGeminiWorktree, + getSafeGitEnv, + type WorktreeSettings, + type HookDefinition, + type HookEventName, + type OutputFormat, + detectIdeFromEnv, +} from '@google/gemini-cli-core'; +import { + type Settings, + type MergedSettings, + saveModelChange, + loadSettings, + isWorktreeEnabled, + type LoadedSettings, +} from './settings.js'; + +import { loadSandboxConfig } from './sandboxConfig.js'; +import { resolvePath } from '../utils/resolvePath.js'; +import { isRecord } from '../utils/settingsUtils.js'; +import { RESUME_LATEST } from '../utils/sessionUtils.js'; + +import { isWorkspaceTrusted } from './trustedFolders.js'; +import { + createPolicyEngineConfig, + resolveWorkspacePolicyState, +} from './policy.js'; +import { ExtensionManager } from './extension-manager.js'; +import { McpServerEnablementManager } from './mcp/mcpServerEnablement.js'; +import type { ExtensionEvents } from '@google/gemini-cli-core/src/utils/extensionLoader.js'; +import { requestConsentNonInteractive } from './extensions/consent.js'; +import { promptForSetting } from './extensions/extensionSettings.js'; +import type { EventEmitter } from 'node:stream'; +import { runExitCleanup } from '../utils/cleanup.js'; + +export interface CliArgs { + query: string | undefined; + model: string | undefined; + sandbox: boolean | string | undefined; + debug: boolean | undefined; + prompt: string | undefined; + promptInteractive: string | undefined; + worktree?: string; + + yolo: boolean | undefined; + approvalMode: string | undefined; + policy: string[] | undefined; + adminPolicy: string[] | undefined; + allowedMcpServerNames: string[] | undefined; + allowedTools: string[] | undefined; + acp?: boolean; + experimentalAcp?: boolean; + extensions: string[] | undefined; + listExtensions: boolean | undefined; + resume: string | typeof RESUME_LATEST | undefined; + sessionFile?: string | undefined; + sessionId: string | undefined; + listSessions: boolean | undefined; + deleteSession: string | undefined; + includeDirectories: string[] | undefined; + screenReader: boolean | undefined; + useWriteTodos: boolean | undefined; + outputFormat: string | undefined; + fakeResponses: string | undefined; + fakeResponsesNonStrict?: string | undefined; + recordResponses: string | undefined; + startupMessages?: string[]; + rawOutput: boolean | undefined; + acceptRawOutputRisk: boolean | undefined; + skipTrust: boolean | undefined; + isCommand: boolean | undefined; +} + +/** + * Helper to coerce comma-separated or multiple flag values into a flat array. + */ +const coerceCommaSeparated = (values: string[]): string[] => { + if (values.length === 1 && values[0] === '') { + return ['']; + } + return values.flatMap((v) => + v + .split(',') + .map((s) => s.trim()) + .filter(Boolean), + ); +}; + +/** + * Pre-parses the command line arguments to find the worktree flag. + * Used for early setup before full argument parsing with settings. + */ +export function getWorktreeArg(argv: string[]): string | undefined { + const result = yargs(hideBin(argv)) + .help(false) + .version(false) + .option('worktree', { alias: 'w', type: 'string' }) + .strict(false) + .exitProcess(false) + .parseSync(); + + if (result.worktree === undefined) return undefined; + return typeof result.worktree === 'string' ? result.worktree.trim() : ''; +} + +/** + * Checks if a worktree is requested via CLI and enabled in settings. + * Returns the requested name (can be empty string for auto-generated) or undefined. + */ +export function getRequestedWorktreeName( + settings: LoadedSettings, +): string | undefined { + if (!isWorktreeEnabled(settings)) { + return undefined; + } + return getWorktreeArg(process.argv); +} + +export async function parseArguments( + settings: MergedSettings, +): Promise { + const rawArgv = hideBin(process.argv); + const startupMessages: string[] = []; + const yargsInstance = yargs(rawArgv) + .locale('en') + .scriptName('gemini') + .usage( + 'Usage: gemini [options] [command]\n\nGemini CLI - Defaults to interactive mode. Use -p/--prompt for non-interactive (headless) mode.', + ) + .option('isCommand', { + type: 'boolean', + hidden: true, + description: 'Internal flag to indicate if a subcommand is being run', + }) + .option('debug', { + alias: 'd', + type: 'boolean', + description: 'Run in debug mode (open debug console with F12)', + default: false, + }) + .middleware((argv) => { + const commandModules = [ + mcpCommand, + extensionsCommand, + skillsCommand, + hooksCommand, + gemmaCommand, + ]; + + const subcommands = commandModules.flatMap((mod) => { + const names: string[] = []; + + const cmd = mod.command; + if (cmd) { + if (Array.isArray(cmd)) { + for (const c of cmd) { + names.push(String(c).split(' ')[0]); + } + } else { + names.push(String(cmd).split(' ')[0]); + } + } + + const aliases = mod.aliases; + if (aliases) { + if (Array.isArray(aliases)) { + for (const a of aliases) { + names.push(String(a).split(' ')[0]); + } + } else { + names.push(String(aliases).split(' ')[0]); + } + } + + return names; + }); + + const firstArg = argv._[0]; + if (typeof firstArg === 'string' && subcommands.includes(firstArg)) { + argv['isCommand'] = true; + } + }, true) + // Ensure validation flows through .fail() for clean UX + .fail((msg, err) => { + if (err) throw err; + throw new Error(msg); + }) + .check((argv) => { + // The 'query' positional can be a string (for one arg) or string[] (for multiple). + // This guard safely checks if any positional argument was provided. + const queryArg = argv['query']; + const query = + typeof queryArg === 'string' || Array.isArray(queryArg) + ? queryArg + : undefined; + const hasPositionalQuery = Array.isArray(query) + ? query.length > 0 + : !!query; + + const sessionFlags = [ + argv['resume'] !== undefined, + argv['session-id'] !== undefined, + argv['session-file'] !== undefined, + ].filter(Boolean).length; + + if (sessionFlags > 1) { + return 'The flags --resume, --session-id, and --session-file are mutually exclusive. Please provide only one.'; + } + + if (argv['prompt'] && hasPositionalQuery) { + return 'Cannot use both a positional prompt and the --prompt (-p) flag together'; + } + if (argv['prompt'] && argv['promptInteractive']) { + return 'Cannot use both --prompt (-p) and --prompt-interactive (-i) together'; + } + if (argv['yolo'] && argv['approvalMode']) { + return 'Cannot use both --yolo (-y) and --approval-mode together. Use --approval-mode=yolo instead.'; + } + + const outputFormat = argv['outputFormat']; + if ( + typeof outputFormat === 'string' && + !['text', 'json', 'stream-json'].includes(outputFormat) + ) { + return `Invalid values:\n Argument: output-format, Given: "${outputFormat}", Choices: "text", "json", "stream-json"`; + } + if (argv['worktree'] && !settings.experimental?.worktrees) { + return 'The --worktree flag is only available when experimental.worktrees is enabled in your settings.'; + } + return true; + }); + + yargsInstance.command(mcpCommand); + yargsInstance.command(extensionsCommand); + yargsInstance.command(skillsCommand); + yargsInstance.command(hooksCommand); + yargsInstance.command(gemmaCommand); + + yargsInstance + .command('$0 [query..]', 'Launch Gemini CLI', (yargsInstance) => + yargsInstance + .positional('query', { + description: + 'Initial prompt. Runs in interactive mode by default; use -p/--prompt for non-interactive.', + }) + .option('model', { + alias: 'm', + type: 'string', + nargs: 1, + description: `Model`, + }) + .option('prompt', { + alias: 'p', + type: 'string', + nargs: 1, + description: + 'Run in non-interactive (headless) mode with the given prompt. Appended to input on stdin (if any).', + }) + .option('prompt-interactive', { + alias: 'i', + type: 'string', + nargs: 1, + description: + 'Execute the provided prompt and continue in interactive mode', + }) + .option('skip-trust', { + type: 'boolean', + description: 'Trust the current workspace for this session.', + default: false, + }) + .option('worktree', { + alias: 'w', + type: 'string', + skipValidation: true, + description: + 'Start Gemini in a new git worktree. If no name is provided, one is generated automatically.', + coerce: (value: unknown): string => { + const trimmed = typeof value === 'string' ? value.trim() : ''; + if (trimmed === '') { + return Math.random().toString(36).substring(2, 10); + } + return trimmed; + }, + }) + .option('sandbox', { + alias: 's', + type: 'boolean', + description: 'Run in sandbox?', + }) + + .option('yolo', { + alias: 'y', + type: 'boolean', + description: + 'Automatically accept all actions (aka YOLO mode, see https://www.youtube.com/watch?v=xvFZjo5PgG0 for more details)?', + default: false, + }) + .option('approval-mode', { + type: 'string', + nargs: 1, + choices: ['default', 'auto_edit', 'yolo', 'plan'], + description: + 'Set the approval mode: default (prompt for approval), auto_edit (auto-approve edit tools), yolo (auto-approve all tools), plan (read-only mode)', + }) + .option('policy', { + type: 'array', + string: true, + nargs: 1, + description: + 'Additional policy files or directories to load (comma-separated or multiple --policy)', + coerce: coerceCommaSeparated, + }) + .option('admin-policy', { + type: 'array', + string: true, + nargs: 1, + description: + 'Additional admin policy files or directories to load (comma-separated or multiple --admin-policy)', + coerce: coerceCommaSeparated, + }) + .option('acp', { + type: 'boolean', + description: 'Starts the agent in ACP mode', + }) + .option('experimental-acp', { + type: 'boolean', + description: + 'Starts the agent in ACP mode (deprecated, use --acp instead)', + }) + .option('allowed-mcp-server-names', { + type: 'array', + string: true, + nargs: 1, + description: 'Allowed MCP server names', + coerce: coerceCommaSeparated, + }) + .option('allowed-tools', { + type: 'array', + string: true, + nargs: 1, + description: + '[DEPRECATED: Use Policy Engine instead See https://geminicli.com/docs/core/policy-engine] Tools that are allowed to run without confirmation', + coerce: coerceCommaSeparated, + }) + .option('extensions', { + alias: 'e', + type: 'array', + string: true, + nargs: 1, + description: + 'A list of extensions to use. If not provided, all extensions are used.', + coerce: coerceCommaSeparated, + }) + .option('list-extensions', { + alias: 'l', + type: 'boolean', + description: 'List all available extensions and exit.', + }) + .option('resume', { + alias: 'r', + type: 'string', + // `skipValidation` so that we can distinguish between it being passed with a value, without + // one, and not being passed at all. + skipValidation: true, + description: + 'Resume a previous session. Use "latest" for most recent or index number (e.g. --resume 5)', + coerce: (value: string): string => { + // When --resume passed with a value (`gemini --resume 123`): value = "123" (string) + // When --resume passed without a value (`gemini --resume`): value = "" (string) + // When --resume not passed at all: this `coerce` function is not called at all, and + // `yargsInstance.argv.resume` is undefined. + const trimmed = value.trim(); + if (trimmed === '') { + return RESUME_LATEST; + } + return trimmed; + }, + }) + .option('session-file', { + type: 'string', + nargs: 1, + description: 'Load a session from a JSON file', + }) + .option('session-id', { + type: 'string', + nargs: 1, + description: 'Start a new session with a manually provided UUID.', + coerce: (value: string): string => { + const trimmed = value.trim(); + if (!trimmed) { + throw new Error('The --session-id option cannot be empty.'); + } + if (!/^[a-zA-Z0-9-_]+$/.test(trimmed)) { + throw new Error( + 'Invalid session ID "' + + trimmed + + '": Only alphanumeric characters, dashes, and underscores are allowed.', + ); + } + return trimmed; + }, + }) + .option('list-sessions', { + type: 'boolean', + description: + 'List available sessions for the current project and exit.', + }) + .option('delete-session', { + type: 'string', + description: + 'Delete a session by index number (use --list-sessions to see available sessions).', + }) + .option('include-directories', { + type: 'array', + string: true, + nargs: 1, + description: + 'Additional directories to include in the workspace (comma-separated or multiple --include-directories)', + coerce: coerceCommaSeparated, + }) + .option('screen-reader', { + type: 'boolean', + description: 'Enable screen reader mode for accessibility.', + }) + .option('output-format', { + alias: 'o', + type: 'string', + nargs: 1, + description: 'The format of the CLI output.', + choices: ['text', 'json', 'stream-json'], + }) + .option('fake-responses', { + type: 'string', + description: 'Path to a file with fake model responses for testing.', + hidden: true, + }) + .option('fake-responses-non-strict', { + type: 'string', + description: + 'Path to a file with fake model responses for testing (non-strict mode).', + hidden: true, + }) + .option('record-responses', { + type: 'string', + description: 'Path to a file to record model responses for testing.', + hidden: true, + }) + .option('raw-output', { + type: 'boolean', + description: + 'Disable sanitization of model output (e.g. allow ANSI escape sequences). WARNING: This can be a security risk if the model output is untrusted.', + }) + .option('accept-raw-output-risk', { + type: 'boolean', + description: 'Suppress the security warning when using --raw-output.', + }), + ) + .version(await getVersion()) // This will enable the --version flag based on package.json + .alias('v', 'version') + .help() + .alias('h', 'help') + .strict() + .demandCommand(0, 0) // Allow base command to run with no subcommands + .exitProcess(false); + + yargsInstance.wrap(yargsInstance.terminalWidth()); + let result; + try { + const parsed = await yargsInstance.parse(); + if (!isRecord(parsed)) { + throw new Error('Failed to parse arguments'); + } + result = parsed; + if (result['skip-trust']) { + process.env['GEMINI_CLI_TRUST_WORKSPACE'] = 'true'; + } + } catch (e) { + const msg = getErrorMessage(e); + debugLogger.error(msg); + yargsInstance.showHelp(); + await runExitCleanup(); + process.exit(1); + } + + // Handle help and version flags manually since we disabled exitProcess + if (result['help'] || result['version']) { + await runExitCleanup(); + process.exit(0); + } + + // Normalize query args: handle both quoted "@path file" and unquoted @path file + const queryArg = result['query']; + let q: string | undefined; + if (Array.isArray(queryArg)) { + q = queryArg.join(' '); + } else if (typeof queryArg === 'string') { + q = queryArg; + } + + // -p/--prompt forces non-interactive mode; positional args default to interactive in TTY + if (q && !result['prompt']) { + if (!isHeadlessMode()) { + startupMessages.push( + 'Positional arguments now default to interactive mode. To run in non-interactive mode, use the --prompt (-p) flag.', + ); + result['promptInteractive'] = q; + } else { + result['prompt'] = q; + } + } + + // Keep CliArgs.query as a string for downstream typing + result['query'] = q || undefined; + result['startupMessages'] = startupMessages; + + // The import format is now only controlled by settings.memoryImportFormat + // We no longer accept it as a CLI argument + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return result as unknown as CliArgs; +} + +export function isDebugMode(argv: CliArgs): boolean { + return ( + argv.debug || + [process.env['DEBUG'], process.env['DEBUG_MODE']].some( + (v) => v === 'true' || v === '1', + ) + ); +} + +export interface LoadCliConfigOptions { + cwd?: string; + projectHooks?: { [K in HookEventName]?: HookDefinition[] } & { + disabled?: string[]; + }; + worktreeSettings?: WorktreeSettings; + skipExtensions?: boolean; + loadedSettings?: LoadedSettings; +} + +export async function loadCliConfig( + settings: MergedSettings, + sessionId: string, + argv: CliArgs, + options: LoadCliConfigOptions = {}, +): Promise { + const { + cwd = process.cwd(), + projectHooks, + skipExtensions = false, + loadedSettings, + } = options; + const debugMode = isDebugMode(argv); + + const worktreeSettings = + options.worktreeSettings ?? (await resolveWorktreeSettings(cwd)); + + if (argv.sandbox) { + process.env['GEMINI_SANDBOX'] = 'true'; + } + + const includeDirectoryTree = settings.context?.includeDirectoryTree ?? true; + + const ideMode = settings.ide?.enabled ?? false; + + const folderTrust = + process.env['GEMINI_CLI_INTEGRATION_TEST'] === 'true' || + process.env['VITEST'] === 'true' + ? false + : (settings.security?.folderTrust?.enabled ?? false); + const trustedFolder = + isWorkspaceTrusted(settings, cwd, { + prompt: argv.prompt, + query: argv.query, + })?.isTrusted ?? false; + + // Set the context filename in the server's memory file helpers before loading memory + // TODO(b/343434939): This is a bit of a hack. The contextFileName should ideally be passed + // directly to the Config constructor in core, and have core handle setGeminiMdFilename. + // However, loadHierarchicalGeminiMemory is called *before* createServerConfig. + if (settings.context?.fileName) { + setServerGeminiMdFilename(settings.context.fileName); + } else { + // Reset to default if not provided in settings. + resetGeminiMdFilename(DEFAULT_CONTEXT_FILENAME); + } + + const fileService = new FileDiscoveryService(cwd); + + const fileFiltering = { + ...DEFAULT_FILE_FILTERING_OPTIONS, + ...settings.context?.fileFiltering, + }; + + //changes the includeDirectories to be absolute paths based on the cwd, and also include any additional directories specified via CLI args + const includeDirectories = (settings.context?.includeDirectories || []) + .map(resolvePath) + .concat((argv.includeDirectories || []).map(resolvePath)); + + // When running inside VSCode with multiple workspace folders, + // automatically add the other folders as include directories + // so Gemini has context of all open folders, not just the cwd. + const ideWorkspacePath = process.env['GEMINI_CLI_IDE_WORKSPACE_PATH']; + if (ideWorkspacePath) { + const realCwd = resolveToRealPath(cwd); + const ideFolders = ideWorkspacePath.split(path.delimiter).filter((p) => { + const trimmedPath = p.trim(); + if (!trimmedPath) return false; + try { + return resolveToRealPath(trimmedPath) !== realCwd; + } catch (e) { + debugLogger.debug( + `[IDE] Skipping inaccessible workspace folder: ${trimmedPath} (${getErrorMessage(e)})`, + ); + return false; + } + }); + includeDirectories.push(...ideFolders); + } + + let extensionManager: ExtensionManager | undefined; + if (!skipExtensions) { + extensionManager = new ExtensionManager({ + settings, + requestConsent: requestConsentNonInteractive, + requestSetting: promptForSetting, + workspaceDir: cwd, + enabledExtensionOverrides: argv.extensions, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + eventEmitter: coreEvents as EventEmitter, + clientVersion: await getVersion(), + }); + await extensionManager.loadExtensions(); + } + + const extensionPlanSettings = extensionManager + ?.getExtensions() + ?.find((ext) => ext.isActive && ext.plan?.directory)?.plan; + + let extensionRegistryURI = + process.env['GEMINI_CLI_EXTENSION_REGISTRY_URI'] ?? + (trustedFolder ? settings.experimental?.extensionRegistryURI : undefined); + + if (extensionRegistryURI && !extensionRegistryURI.startsWith('http')) { + extensionRegistryURI = resolveToRealPath( + path.resolve(cwd, resolvePath(extensionRegistryURI)), + ); + } + + const finalExtensionLoader = + extensionManager ?? new SimpleExtensionLoader([]); + + const question = argv.promptInteractive || argv.prompt || ''; + + // Determine approval mode with backward compatibility + let approvalMode: ApprovalMode; + const rawApprovalMode = + argv.approvalMode || + (argv.yolo ? 'yolo' : undefined) || + ((settings.general?.defaultApprovalMode as string) !== 'yolo' + ? settings.general?.defaultApprovalMode + : undefined); + + if (rawApprovalMode) { + switch (rawApprovalMode) { + case 'yolo': + approvalMode = ApprovalMode.YOLO; + break; + case 'auto_edit': + approvalMode = ApprovalMode.AUTO_EDIT; + break; + case 'plan': + if (!(settings.general?.plan?.enabled ?? true)) { + debugLogger.warn( + 'Approval mode "plan" is disabled in your settings. Falling back to "default".', + ); + approvalMode = ApprovalMode.DEFAULT; + } else { + approvalMode = ApprovalMode.PLAN; + } + break; + case 'default': + approvalMode = ApprovalMode.DEFAULT; + break; + default: + throw new Error( + `Invalid approval mode: ${rawApprovalMode}. Valid values are: yolo, auto_edit, plan, default`, + ); + } + } else { + approvalMode = ApprovalMode.DEFAULT; + } + + // Override approval mode if disableYoloMode is set. + if (settings.security?.disableYoloMode || settings.admin?.secureModeEnabled) { + if (approvalMode === ApprovalMode.YOLO) { + if (settings.admin?.secureModeEnabled) { + debugLogger.error( + 'YOLO mode is disabled by "secureModeEnabled" setting.', + ); + } else { + debugLogger.error( + 'YOLO mode is disabled by the "disableYolo" setting.', + ); + } + throw new FatalConfigError( + getAdminErrorMessage('YOLO mode', undefined /* config */), + ); + } + } else if (approvalMode === ApprovalMode.YOLO) { + debugLogger.warn( + 'YOLO mode is enabled. All tool calls will be automatically approved.', + ); + } + + // Force approval mode to default if the folder is not trusted. + if (!trustedFolder && approvalMode !== ApprovalMode.DEFAULT) { + debugLogger.warn( + `Approval mode overridden to "default" because the current folder is not trusted.`, + ); + approvalMode = ApprovalMode.DEFAULT; + } + + let telemetrySettings; + try { + telemetrySettings = await resolveTelemetrySettings({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + env: process.env as unknown as Record, + settings: settings.telemetry, + }); + } catch (err) { + if (err instanceof FatalConfigError) { + throw new FatalConfigError( + `Invalid telemetry configuration: ${err.message}.`, + ); + } + throw err; + } + + // -p/--prompt forces non-interactive (headless) mode + // -i/--prompt-interactive forces interactive mode with an initial prompt + const interactive = + !!argv.promptInteractive || + !!argv.acp || + !!argv.experimentalAcp || + (!isHeadlessMode({ prompt: argv.prompt, query: argv.query }) && + !argv.isCommand); + + const allowedTools = argv.allowedTools || settings.tools?.allowed || []; + + const isAcpMode = !!argv.acp || !!argv.experimentalAcp; + + // In non-interactive mode, exclude tools that require a prompt. + const extraExcludes: string[] = []; + if (!interactive || isAcpMode) { + // The Policy Engine natively handles headless safety by translating ASK_USER + // decisions to DENY. However, we explicitly block ask_user here to guarantee + // it can never be allowed via a high-priority policy rule when no human is present. + // We also exclude it in ACP mode as IDEs intercept tool calls and ask for permission, + // breaking conversational flows. + extraExcludes.push(ASK_USER_TOOL_NAME); + } + + const excludeTools = mergeExcludeTools(settings, extraExcludes); + + // Create a settings object that includes CLI overrides for policy generation + const effectiveSettings: Settings = { + ...settings, + tools: { + ...settings.tools, + allowed: allowedTools, + exclude: excludeTools, + }, + mcp: { + ...settings.mcp, + allowed: argv.allowedMcpServerNames ?? settings.mcp?.allowed, + }, + policyPaths: (argv.policy ?? settings.policyPaths)?.map((p) => + resolvePath(p), + ), + adminPolicyPaths: (argv.adminPolicy ?? settings.adminPolicyPaths)?.map( + (p) => resolvePath(p), + ), + }; + + const { workspacePoliciesDir, policyUpdateConfirmationRequest } = + await resolveWorkspacePolicyState({ + cwd, + trustedFolder, + interactive, + }); + + const policyEngineConfig = await createPolicyEngineConfig( + effectiveSettings, + approvalMode, + workspacePoliciesDir, + interactive, + ); + + const defaultModel = GEMINI_MODEL_ALIAS_AUTO; + const rawModel = + argv.model || process.env['GEMINI_MODEL'] || settings.model?.name; + + // Ensure specifiedModel is a string (e.g. if yargs parsed multiple --model as an array) + const specifiedModel = Array.isArray(rawModel) + ? String(rawModel.at(-1) ?? '').trim() || '' + : rawModel === undefined + ? undefined + : String(rawModel ?? '').trim() || ''; + + const resolvedModel = + specifiedModel === GEMINI_MODEL_ALIAS_AUTO + ? defaultModel + : specifiedModel || defaultModel; + const sandboxConfig = await loadSandboxConfig(settings, argv); + if (sandboxConfig) { + const existingPaths = sandboxConfig.allowedPaths || []; + if (settings.tools.sandboxAllowedPaths?.length) { + sandboxConfig.allowedPaths = [ + ...new Set([...existingPaths, ...settings.tools.sandboxAllowedPaths]), + ]; + } + if (settings.tools.sandboxNetworkAccess !== undefined) { + sandboxConfig.networkAccess = + sandboxConfig.networkAccess || settings.tools.sandboxNetworkAccess; + } + } + + const screenReader = + argv.screenReader !== undefined + ? argv.screenReader + : (settings.ui?.accessibility?.screenReader ?? false); + + const ptyInfo = await getPty(); + + const mcpEnabled = settings.admin?.mcp?.enabled ?? true; + const extensionsEnabled = settings.admin?.extensions?.enabled ?? true; + const adminSkillsEnabled = settings.admin?.skills?.enabled ?? true; + + // Create MCP enablement manager and callbacks + const mcpEnablementManager = McpServerEnablementManager.getInstance(); + const mcpEnablementCallbacks = mcpEnabled + ? mcpEnablementManager.getEnablementCallbacks() + : undefined; + + const adminAllowlist = settings.admin?.mcp?.config; + let mcpServerCommand = mcpEnabled ? settings.mcp?.serverCommand : undefined; + let mcpServers = mcpEnabled ? settings.mcpServers : {}; + + if (mcpEnabled && adminAllowlist && Object.keys(adminAllowlist).length > 0) { + const result = applyAdminAllowlist(mcpServers, adminAllowlist); + mcpServers = result.mcpServers; + mcpServerCommand = undefined; + + if (result.blockedServerNames && result.blockedServerNames.length > 0) { + const message = getAdminBlockedMcpServersMessage( + result.blockedServerNames, + undefined, + ); + coreEvents.emitConsoleLog('warn', message); + } + } + + // Apply admin-required MCP servers (injected regardless of allowlist) + if (mcpEnabled) { + const requiredMcpConfig = settings.admin?.mcp?.requiredConfig; + if (requiredMcpConfig && Object.keys(requiredMcpConfig).length > 0) { + const requiredResult = applyRequiredServers( + mcpServers ?? {}, + requiredMcpConfig, + ); + mcpServers = requiredResult.mcpServers; + + if (requiredResult.requiredServerNames.length > 0) { + coreEvents.emitConsoleLog( + 'info', + `Admin-required MCP servers injected: ${requiredResult.requiredServerNames.join(', ')}`, + ); + } + } + } + + let clientName: string | undefined = undefined; + if (isAcpMode) { + const ide = detectIdeFromEnv(); + if ( + ide && + (ide.name !== 'vscode' || process.env['TERM_PROGRAM'] === 'vscode') + ) { + clientName = `acp-${ide.name}`; + } else { + clientName = 'acp'; + } + } else if (argv.isCommand) { + clientName = 'cli-command'; + } else { + clientName = 'tui'; + } + + // TODO(joshualitt): Clean this up alongside removal of the legacy config. + let profileSelector: string | undefined = undefined; + if (settings.experimental?.stressTestProfile) { + profileSelector = 'stressTestProfile'; + } else if (settings.experimental?.powerUserProfile) { + profileSelector = 'powerUserProfile'; + } else if ( + settings.experimental?.generalistProfile || + settings.experimental?.contextManagement + ) { + profileSelector = 'generalistProfile'; + } + + const contextManagement = { + enabled: !!profileSelector, + }; + + return new Config({ + acpMode: isAcpMode, + clientName, + sessionId, + clientVersion: await getVersion(), + embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL, + sandbox: sandboxConfig, + toolSandboxing: settings.security?.toolSandboxing ?? false, + targetDir: cwd, + includeDirectoryTree, + includeDirectories, + loadMemoryFromIncludeDirectories: + settings.context?.loadMemoryFromIncludeDirectories || false, + discoveryMaxDirs: settings.context?.discoveryMaxDirs, + memoryBoundaryMarkers: settings.context?.memoryBoundaryMarkers, + importFormat: settings.context?.importFormat, + debugMode, + question, + worktreeSettings, + + coreTools: settings.tools?.core || undefined, + experimentalContextManagementConfig: profileSelector, + allowedTools: allowedTools.length > 0 ? allowedTools : undefined, + policyEngineConfig, + policyUpdateConfirmationRequest, + excludeTools, + toolDiscoveryCommand: settings.tools?.discoveryCommand, + toolCallCommand: settings.tools?.callCommand, + mcpServerCommand, + mcpServers, + mcpEnablementCallbacks, + mcpEnabled, + extensionsEnabled, + agents: settings.agents, + adminSkillsEnabled, + allowedMcpServers: mcpEnabled + ? (argv.allowedMcpServerNames ?? + (loadedSettings + ? loadedSettings.getConsolidatedAllowedMcpServers() + : settings.mcp?.allowed)) + : undefined, + blockedMcpServers: mcpEnabled + ? argv.allowedMcpServerNames + ? undefined + : loadedSettings + ? loadedSettings.getConsolidatedExcludedMcpServers() + : settings.mcp?.excluded + : undefined, + blockedEnvironmentVariables: + settings.security?.environmentVariableRedaction?.blocked, + allowedEnvironmentVariables: + settings.security?.environmentVariableRedaction?.allowed, + enableEnvironmentVariableRedaction: + settings.security?.environmentVariableRedaction?.enabled, + approvalMode, + disableYoloMode: + settings.security?.disableYoloMode || settings.admin?.secureModeEnabled, + disableAlwaysAllow: + settings.security?.disableAlwaysAllow || + settings.admin?.secureModeEnabled, + showMemoryUsage: settings.ui?.showMemoryUsage || false, + accessibility: { + ...settings.ui?.accessibility, + screenReader, + }, + telemetry: telemetrySettings, + usageStatisticsEnabled: settings.privacy?.usageStatisticsEnabled, + fileFiltering, + checkpointing: settings.general?.checkpointing?.enabled, + proxy: + process.env['HTTPS_PROXY'] || + process.env['https_proxy'] || + process.env['HTTP_PROXY'] || + process.env['http_proxy'], + cwd, + fileDiscoveryService: fileService, + bugCommand: settings.advanced?.bugCommand, + model: resolvedModel, + maxSessionTurns: settings.model?.maxSessionTurns, + + listExtensions: argv.listExtensions || false, + listSessions: argv.listSessions || false, + deleteSession: argv.deleteSession, + enabledExtensions: argv.extensions, + extensionLoader: finalExtensionLoader, + extensionRegistryURI, + enableExtensionReloading: settings.experimental?.extensionReloading, + enableAgents: settings.experimental?.enableAgents, + plan: settings.general?.plan?.enabled ?? true, + voiceMode: settings.experimental?.voiceMode, + tracker: settings.experimental?.taskTracker, + directWebFetch: settings.experimental?.directWebFetch, + planSettings: settings.general?.plan?.directory + ? settings.general.plan + : (extensionPlanSettings ?? settings.general?.plan), + enableEventDrivenScheduler: true, + skillsSupport: settings.skills?.enabled ?? true, + disabledSkills: settings.skills?.disabled, + experimentalAutoMemory: settings.experimental?.autoMemory, + experimentalGemma: settings.experimental?.gemma, + contextManagement, + modelSteering: settings.experimental?.modelSteering, + topicUpdateNarration: + settings.general?.topicUpdateNarration ?? + settings.experimental?.topicUpdateNarration, + noBrowser: !!process.env['NO_BROWSER'], + summarizeToolOutput: settings.model?.summarizeToolOutput, + ideMode, + disableLoopDetection: settings.model?.disableLoopDetection, + compressionThreshold: settings.model?.compressionThreshold, + folderTrust, + interactive, + trustedFolder, + useBackgroundColor: settings.ui?.useBackgroundColor, + useAlternateBuffer: settings.ui?.useAlternateBuffer, + useTerminalBuffer: settings.ui?.terminalBuffer, + useRenderProcess: settings.ui?.renderProcess, + useRipgrep: settings.tools?.useRipgrep, + enableInteractiveShell: settings.tools?.shell?.enableInteractiveShell, + shellBackgroundCompletionBehavior: settings.tools?.shell + ?.backgroundCompletionBehavior as string | undefined, + shellToolInactivityTimeout: settings.tools?.shell?.inactivityTimeout, + enableShellOutputEfficiency: + settings.tools?.shell?.enableShellOutputEfficiency ?? true, + // In ACP mode, always skip the next-speaker check. This check triggers + // recursive continuation turns inside GeminiClient.processTurn() that + // conflict with ACP's explicit turn management via session/prompt, + // causing infinite agent_thought_chunk loops. + skipNextSpeakerCheck: isAcpMode || settings.model?.skipNextSpeakerCheck, + truncateToolOutputThreshold: settings.tools?.truncateToolOutputThreshold, + eventEmitter: coreEvents, + useWriteTodos: argv.useWriteTodos ?? settings.useWriteTodos, + output: { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + format: (argv.outputFormat ?? settings.output?.format) as OutputFormat, + }, + gemmaModelRouter: settings.experimental?.gemmaModelRouter, + adk: settings.experimental?.adk, + fakeResponses: argv.fakeResponses, + fakeResponsesNonStrict: argv.fakeResponsesNonStrict, + recordResponses: argv.recordResponses, + retryFetchErrors: settings.general?.retryFetchErrors, + billing: settings.billing, + vertexAiRouting: settings.billing?.vertexAi, + maxAttempts: settings.general?.maxAttempts, + ptyInfo: ptyInfo?.name, + disableLLMCorrection: settings.tools?.disableLLMCorrection, + rawOutput: argv.rawOutput, + acceptRawOutputRisk: argv.acceptRawOutputRisk, + dynamicModelConfiguration: settings.experimental?.dynamicModelConfiguration, + modelConfigServiceConfig: settings.modelConfigs, + // TODO: loading of hooks based on workspace trust + enableHooks: settings.hooksConfig.enabled, + enableHooksUI: settings.hooksConfig.enabled, + hooks: settings.hooks || {}, + disabledHooks: settings.hooksConfig?.disabled || [], + projectHooks: projectHooks || {}, + onModelChange: (model: string) => saveModelChange(loadSettings(cwd), model), + onReload: async () => { + const refreshedSettings = loadSettings(cwd); + return { + disabledSkills: refreshedSettings.merged.skills.disabled, + agents: refreshedSettings.merged.agents, + }; + }, + enableConseca: settings.security?.enableConseca, + }); +} + +function mergeExcludeTools( + settings: MergedSettings, + extraExcludes: string[] = [], +): string[] { + const allExcludeTools = new Set([ + ...(settings.tools.exclude || []), + ...extraExcludes, + ]); + return Array.from(allExcludeTools); +} + +async function resolveWorktreeSettings( + cwd: string, +): Promise { + let worktreePath: string | undefined; + try { + const { stdout } = await execa('git', ['rev-parse', '--show-toplevel'], { + cwd, + env: getSafeGitEnv(), + }); + const toplevel = stdout.trim(); + const projectRoot = await getProjectRootForWorktree(toplevel); + + if (isGeminiWorktree(toplevel, projectRoot)) { + worktreePath = toplevel; + } + } catch { + return undefined; + } + + if (!worktreePath) { + return undefined; + } + + let worktreeBaseSha: string | undefined; + try { + const { stdout } = await execa('git', ['rev-parse', 'HEAD'], { + cwd: worktreePath, + env: getSafeGitEnv(), + }); + worktreeBaseSha = stdout.trim(); + } catch (e: unknown) { + debugLogger.debug( + `Failed to resolve worktree base SHA at ${worktreePath}: ${getErrorMessage(e)}`, + ); + } + + if (!worktreeBaseSha) { + return undefined; + } + + return { + name: path.basename(worktreePath), + path: worktreePath, + baseSha: worktreeBaseSha, + }; +} diff --git a/packages/cli/src/config/extension-manager-agents.test.ts b/packages/cli/src/config/extension-manager-agents.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..c3f0557d3df56a1c95ad1d36a8a4e4089bc9e5ba --- /dev/null +++ b/packages/cli/src/config/extension-manager-agents.test.ts @@ -0,0 +1,142 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { ExtensionManager } from './extension-manager.js'; +import { debugLogger } from '@google/gemini-cli-core'; +import { createTestMergedSettings } from './settings.js'; +import { createExtension } from '../test-utils/createExtension.js'; +import { EXTENSIONS_DIRECTORY_NAME } from './extensions/variables.js'; + +const mockHomedir = vi.hoisted(() => vi.fn(() => '/tmp/mock-home')); + +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + homedir: mockHomedir, + }; +}); + +// Mock @google/gemini-cli-core +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const core = await importOriginal(); + return { + ...core, + homedir: mockHomedir, + loadAgentsFromDirectory: core.loadAgentsFromDirectory, + loadSkillsFromDir: core.loadSkillsFromDir, + }; +}); + +describe('ExtensionManager agents loading', () => { + let extensionManager: ExtensionManager; + let tempDir: string; + let extensionsDir: string; + + beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv('ANTIGRAVITY_CLI_ALIAS', ''); + vi.spyOn(debugLogger, 'warn').mockImplementation(() => {}); + + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-test-agents-')); + mockHomedir.mockReturnValue(tempDir); + vi.stubEnv('GEMINI_CLI_HOME', tempDir); + + // Create the extensions directory that ExtensionManager expects + extensionsDir = path.join(tempDir, '.gemini', EXTENSIONS_DIRECTORY_NAME); + fs.mkdirSync(extensionsDir, { recursive: true }); + + extensionManager = new ExtensionManager({ + settings: createTestMergedSettings({ + telemetry: { enabled: false }, + }), + requestConsent: vi.fn().mockResolvedValue(true), + requestSetting: vi.fn(), + workspaceDir: tempDir, + }); + }); + + afterEach(() => { + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { + // ignore + } + }); + + it('should load agents from an extension', async () => { + const sourceDir = path.join(tempDir, 'source-ext-good'); + createExtension({ + extensionsDir: sourceDir, + name: 'good-agents-ext', + version: '1.0.0', + installMetadata: { + type: 'local', + source: path.join(sourceDir, 'good-agents-ext'), + }, + }); + const extensionPath = path.join(sourceDir, 'good-agents-ext'); + + const agentsDir = path.join(extensionPath, 'agents'); + fs.mkdirSync(agentsDir, { recursive: true }); + fs.writeFileSync( + path.join(agentsDir, 'test-agent.md'), + '---\nname: test-agent\nkind: local\ndescription: test desc\n---\nbody', + ); + + await extensionManager.loadExtensions(); + + const extension = await extensionManager.installOrUpdateExtension({ + type: 'local', + source: extensionPath, + }); + + expect(extension.name).toBe('good-agents-ext'); + expect(extension.agents).toBeDefined(); + expect(extension.agents).toHaveLength(1); + expect(extension.agents![0].name).toBe('test-agent'); + expect(debugLogger.warn).not.toHaveBeenCalled(); + }); + + it('should log errors but continue if an agent fails to load', async () => { + const sourceDir = path.join(tempDir, 'source-ext-bad'); + createExtension({ + extensionsDir: sourceDir, + name: 'bad-agents-ext', + version: '1.0.0', + installMetadata: { + type: 'local', + source: path.join(sourceDir, 'bad-agents-ext'), + }, + }); + const extensionPath = path.join(sourceDir, 'bad-agents-ext'); + + const agentsDir = path.join(extensionPath, 'agents'); + fs.mkdirSync(agentsDir, { recursive: true }); + // Invalid agent (missing description) + fs.writeFileSync( + path.join(agentsDir, 'bad-agent.md'), + '---\nname: bad-agent\nkind: local\n---\nbody', + ); + + await extensionManager.loadExtensions(); + + const extension = await extensionManager.installOrUpdateExtension({ + type: 'local', + source: extensionPath, + }); + + expect(extension.name).toBe('bad-agents-ext'); + expect(extension.agents).toEqual([]); + expect(debugLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('Error loading agent from bad-agents-ext'), + ); + }); +}); diff --git a/packages/cli/src/config/extension-manager-hydration.test.ts b/packages/cli/src/config/extension-manager-hydration.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..aa97dbd898ec9dc711149e2d73ab2230923a2d3d --- /dev/null +++ b/packages/cli/src/config/extension-manager-hydration.test.ts @@ -0,0 +1,326 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { ExtensionManager } from './extension-manager.js'; +import { + debugLogger, + coreEvents, + type CommandHookConfig, +} from '@google/gemini-cli-core'; +import { createTestMergedSettings } from './settings.js'; +import { createExtension } from '../test-utils/createExtension.js'; +import { EXTENSIONS_DIRECTORY_NAME } from './extensions/variables.js'; + +const mockHomedir = vi.hoisted(() => vi.fn(() => '/tmp/mock-home')); + +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + homedir: mockHomedir, + }; +}); + +// Mock @google/gemini-cli-core +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + homedir: mockHomedir, + // Use actual implementations for loading skills and agents to test hydration + loadAgentsFromDirectory: actual.loadAgentsFromDirectory, + loadSkillsFromDir: actual.loadSkillsFromDir, + }; +}); + +describe('ExtensionManager hydration', () => { + let extensionManager: ExtensionManager; + let tempDir: string; + let extensionsDir: string; + + beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv('ANTIGRAVITY_CLI_ALIAS', ''); + vi.spyOn(coreEvents, 'emitFeedback'); + vi.spyOn(debugLogger, 'debug').mockImplementation(() => {}); + + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-test-')); + mockHomedir.mockReturnValue(tempDir); + vi.stubEnv('GEMINI_CLI_HOME', tempDir); + + // Create the extensions directory that ExtensionManager expects + extensionsDir = path.join(tempDir, '.gemini', EXTENSIONS_DIRECTORY_NAME); + fs.mkdirSync(extensionsDir, { recursive: true }); + + extensionManager = new ExtensionManager({ + settings: createTestMergedSettings({ + telemetry: { enabled: false }, + experimental: { extensionConfig: true }, + }), + requestConsent: vi.fn().mockResolvedValue(true), + requestSetting: vi.fn(), + workspaceDir: tempDir, + }); + }); + + afterEach(() => { + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { + // ignore + } + }); + + it('should hydrate skill body with extension settings', async () => { + const sourceDir = path.join(tempDir, 'source-ext-skill'); + const extensionName = 'skill-hydration-ext'; + createExtension({ + extensionsDir: sourceDir, + name: extensionName, + version: '1.0.0', + settings: [ + { + name: 'API Key', + description: 'API Key', + envVar: 'MY_API_KEY', + }, + ], + installMetadata: { + type: 'local', + source: path.join(sourceDir, extensionName), + }, + }); + const extensionPath = path.join(sourceDir, extensionName); + + // Create skill with variable + const skillsDir = path.join(extensionPath, 'skills'); + const skillSubdir = path.join(skillsDir, 'my-skill'); + fs.mkdirSync(skillSubdir, { recursive: true }); + fs.writeFileSync( + path.join(skillSubdir, 'SKILL.md'), + `--- +name: my-skill +description: test +--- +Use key: \${MY_API_KEY} +`, + ); + + await extensionManager.loadExtensions(); + + extensionManager.setRequestSetting(async (setting) => { + if (setting.envVar === 'MY_API_KEY') return 'secret-123'; + return ''; + }); + + const extension = await extensionManager.installOrUpdateExtension({ + type: 'local', + source: extensionPath, + }); + + expect(extension.skills).toHaveLength(1); + expect(extension.skills![0].body).toContain('Use key: secret-123'); + }); + + it('should hydrate agent system prompt with extension settings', async () => { + const sourceDir = path.join(tempDir, 'source-ext-agent'); + const extensionName = 'agent-hydration-ext'; + createExtension({ + extensionsDir: sourceDir, + name: extensionName, + version: '1.0.0', + settings: [ + { + name: 'Model Name', + description: 'Model', + envVar: 'MODEL_NAME', + }, + ], + installMetadata: { + type: 'local', + source: path.join(sourceDir, extensionName), + }, + }); + const extensionPath = path.join(sourceDir, extensionName); + + // Create agent with variable + const agentsDir = path.join(extensionPath, 'agents'); + fs.mkdirSync(agentsDir, { recursive: true }); + fs.writeFileSync( + path.join(agentsDir, 'my-agent.md'), + `--- +name: my-agent +description: test +--- +System using model: \${MODEL_NAME} +`, + ); + + await extensionManager.loadExtensions(); + + extensionManager.setRequestSetting(async (setting) => { + if (setting.envVar === 'MODEL_NAME') return 'gemini-pro'; + return ''; + }); + + const extension = await extensionManager.installOrUpdateExtension({ + type: 'local', + source: extensionPath, + }); + + expect(extension.agents).toHaveLength(1); + const agent = extension.agents![0]; + if (agent.kind === 'local') { + expect(agent.promptConfig.systemPrompt).toContain( + 'System using model: gemini-pro', + ); + } else { + throw new Error('Expected local agent'); + } + }); + + it('should hydrate hooks with extension settings', async () => { + const sourceDir = path.join(tempDir, 'source-ext-hooks'); + const extensionName = 'hooks-hydration-ext'; + createExtension({ + extensionsDir: sourceDir, + name: extensionName, + version: '1.0.0', + settings: [ + { + name: 'Hook Command', + description: 'Cmd', + envVar: 'HOOK_CMD', + }, + ], + installMetadata: { + type: 'local', + source: path.join(sourceDir, extensionName), + }, + }); + const extensionPath = path.join(sourceDir, extensionName); + + const hooksDir = path.join(extensionPath, 'hooks'); + fs.mkdirSync(hooksDir, { recursive: true }); + fs.writeFileSync( + path.join(hooksDir, 'hooks.json'), + JSON.stringify({ + hooks: { + BeforeTool: [ + { + hooks: [ + { + type: 'command', + command: 'echo $HOOK_CMD', + }, + ], + }, + ], + }, + }), + ); + + // Enable hooks in settings + extensionManager = new ExtensionManager({ + settings: createTestMergedSettings({ + telemetry: { enabled: false }, + experimental: { extensionConfig: true }, + hooksConfig: { enabled: true }, + }), + requestConsent: vi.fn().mockResolvedValue(true), + requestSetting: vi.fn(), + workspaceDir: tempDir, + }); + + await extensionManager.loadExtensions(); + + extensionManager.setRequestSetting(async (setting) => { + if (setting.envVar === 'HOOK_CMD') return 'hello-world'; + return ''; + }); + + const extension = await extensionManager.installOrUpdateExtension({ + type: 'local', + source: extensionPath, + }); + + expect(extension.hooks).toBeDefined(); + expect(extension.hooks?.BeforeTool).toHaveLength(1); + expect( + (extension.hooks?.BeforeTool![0].hooks[0] as CommandHookConfig).env?.[ + 'HOOK_CMD' + ], + ).toBe('hello-world'); + }); + + it('should pick up new settings after restartExtension', async () => { + const sourceDir = path.join(tempDir, 'source-ext-restart'); + const extensionName = 'restart-hydration-ext'; + createExtension({ + extensionsDir: sourceDir, + name: extensionName, + version: '1.0.0', + settings: [ + { + name: 'Value', + description: 'Val', + envVar: 'MY_VALUE', + }, + ], + installMetadata: { + type: 'local', + source: path.join(sourceDir, extensionName), + }, + }); + const extensionPath = path.join(sourceDir, extensionName); + + const skillsDir = path.join(extensionPath, 'skills'); + const skillSubdir = path.join(skillsDir, 'my-skill'); + fs.mkdirSync(skillSubdir, { recursive: true }); + fs.writeFileSync( + path.join(skillSubdir, 'SKILL.md'), + '---\nname: my-skill\ndescription: test\n---\nValue is: ${MY_VALUE}', + ); + + await extensionManager.loadExtensions(); + + // Initial setting + extensionManager.setRequestSetting(async () => 'first'); + const extension = await extensionManager.installOrUpdateExtension({ + type: 'local', + source: extensionPath, + }); + expect(extension.skills![0].body).toContain('Value is: first'); + + const { updateSetting, ExtensionSettingScope } = await import( + './extensions/extensionSettings.js' + ); + const extensionConfig = + await extensionManager.loadExtensionConfig(extensionPath); + + const mockRequestSetting = vi.fn().mockResolvedValue('second'); + await updateSetting( + extensionConfig, + extension.id, + 'MY_VALUE', + mockRequestSetting, + ExtensionSettingScope.USER, + process.cwd(), + ); + + await extensionManager.restartExtension(extension); + + const reloadedExtension = extensionManager + .getExtensions() + .find((e) => e.name === extensionName)!; + expect(reloadedExtension.skills![0].body).toContain('Value is: second'); + }); +}); diff --git a/packages/cli/src/config/extension-manager-permissions.test.ts b/packages/cli/src/config/extension-manager-permissions.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..6d6e848feffa8c460db124c81604ebe7d2fb06ca --- /dev/null +++ b/packages/cli/src/config/extension-manager-permissions.test.ts @@ -0,0 +1,133 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { copyExtension } from './extension-manager.js'; + +describe('copyExtension permissions', () => { + let tempDir: string; + let sourceDir: string; + let destDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-permission-test-')); + sourceDir = path.join(tempDir, 'source'); + destDir = path.join(tempDir, 'dest'); + fs.mkdirSync(sourceDir); + }); + + afterEach(() => { + // Ensure we can delete the temp directory by making everything writable again + const makeWritableSync = (p: string) => { + try { + const stats = fs.lstatSync(p); + fs.chmodSync(p, stats.mode | 0o700); + if (stats.isDirectory()) { + fs.readdirSync(p).forEach((child) => + makeWritableSync(path.join(p, child)), + ); + } + } catch { + // Ignore errors during cleanup + } + }; + + if (fs.existsSync(tempDir)) { + makeWritableSync(tempDir); + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('should make destination writable even if source is read-only', async () => { + const fileName = 'test.txt'; + const filePath = path.join(sourceDir, fileName); + fs.writeFileSync(filePath, 'hello'); + + // Make source read-only: 0o555 for directory, 0o444 for file + fs.chmodSync(filePath, 0o444); + fs.chmodSync(sourceDir, 0o555); + + // Verify source is read-only + expect(() => fs.writeFileSync(filePath, 'fail')).toThrow(); + + // Perform copy + await copyExtension(sourceDir, destDir); + + // Verify destination is writable + const destFilePath = path.join(destDir, fileName); + const destFileStats = fs.statSync(destFilePath); + const destDirStats = fs.statSync(destDir); + + // Check that owner write bits are set (0o200) + expect(destFileStats.mode & 0o200).toBe(0o200); + expect(destDirStats.mode & 0o200).toBe(0o200); + + // Verify we can actually write to the destination file + fs.writeFileSync(destFilePath, 'writable'); + expect(fs.readFileSync(destFilePath, 'utf-8')).toBe('writable'); + + // Verify we can delete the destination (which requires write bit on destDir) + fs.rmSync(destFilePath); + expect(fs.existsSync(destFilePath)).toBe(false); + }); + + it('should handle nested directories with restrictive permissions', async () => { + const subDir = path.join(sourceDir, 'subdir'); + fs.mkdirSync(subDir); + const fileName = 'nested.txt'; + const filePath = path.join(subDir, fileName); + fs.writeFileSync(filePath, 'nested content'); + + // Make nested structure read-only + fs.chmodSync(filePath, 0o444); + fs.chmodSync(subDir, 0o555); + fs.chmodSync(sourceDir, 0o555); + + // Perform copy + await copyExtension(sourceDir, destDir); + + // Verify nested destination is writable + const destSubDir = path.join(destDir, 'subdir'); + const destFilePath = path.join(destSubDir, fileName); + + expect(fs.statSync(destSubDir).mode & 0o200).toBe(0o200); + expect(fs.statSync(destFilePath).mode & 0o200).toBe(0o200); + + // Verify we can delete the whole destination tree + await fs.promises.rm(destDir, { recursive: true, force: true }); + expect(fs.existsSync(destDir)).toBe(false); + }); + + it('should not follow symlinks or modify symlink targets', async () => { + const symlinkTarget = path.join(tempDir, 'external-target'); + fs.writeFileSync(symlinkTarget, 'external content'); + // Target is read-only + fs.chmodSync(symlinkTarget, 0o444); + + const symlinkPath = path.join(sourceDir, 'symlink-file'); + fs.symlinkSync(symlinkTarget, symlinkPath); + + // Perform copy + await copyExtension(sourceDir, destDir); + + const destSymlinkPath = path.join(destDir, 'symlink-file'); + const destSymlinkStats = fs.lstatSync(destSymlinkPath); + + // Verify it is still a symlink in the destination + expect(destSymlinkStats.isSymbolicLink()).toBe(true); + + // Verify the target (external to the extension) was NOT modified + const targetStats = fs.statSync(symlinkTarget); + // Owner write bit should still NOT be set (0o200) + expect(targetStats.mode & 0o200).toBe(0o000); + + // Clean up + fs.chmodSync(symlinkTarget, 0o644); + }); +}); diff --git a/packages/cli/src/config/extension-manager-scope.test.ts b/packages/cli/src/config/extension-manager-scope.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..5e93face28dcd0d4e209ece3f931d9379dbd50bd --- /dev/null +++ b/packages/cli/src/config/extension-manager-scope.test.ts @@ -0,0 +1,212 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { ExtensionManager } from './extension-manager.js'; +import { createTestMergedSettings } from './settings.js'; +import { cleanupTmpDir } from '@google/gemini-cli-test-utils'; +import { + loadAgentsFromDirectory, + loadSkillsFromDir, +} from '@google/gemini-cli-core'; + +let currentTempHome = ''; + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + homedir: () => currentTempHome, + debugLogger: { + log: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + }, + loadAgentsFromDirectory: vi.fn().mockImplementation(async () => ({ + agents: [], + errors: [], + })), + loadSkillsFromDir: vi.fn().mockImplementation(async () => []), + }; +}); + +describe('ExtensionManager Settings Scope', () => { + const extensionName = 'test-extension'; + let tempWorkspace: string; + let extensionsDir: string; + let extensionDir: string; + + beforeEach(async () => { + vi.mocked(loadAgentsFromDirectory).mockResolvedValue({ + agents: [], + errors: [], + }); + vi.mocked(loadSkillsFromDir).mockResolvedValue([]); + currentTempHome = fs.mkdtempSync( + path.join(os.tmpdir(), 'gemini-cli-test-home-'), + ); + tempWorkspace = fs.mkdtempSync( + path.join(os.tmpdir(), 'gemini-cli-test-workspace-'), + ); + extensionsDir = path.join(currentTempHome, '.gemini', 'extensions'); + extensionDir = path.join(extensionsDir, extensionName); + + fs.mkdirSync(extensionDir, { recursive: true }); + + // Create gemini-extension.json + const extensionConfig = { + name: extensionName, + version: '1.0.0', + settings: [ + { + name: 'Test Setting', + envVar: 'TEST_SETTING', + description: 'A test setting', + }, + ], + }; + fs.writeFileSync( + path.join(extensionDir, 'gemini-extension.json'), + JSON.stringify(extensionConfig), + ); + + // Create install metadata + const installMetadata = { + source: extensionDir, + type: 'local', + }; + fs.writeFileSync( + path.join(extensionDir, 'install-metadata.json'), + JSON.stringify(installMetadata), + ); + }); + + afterEach(async () => { + await cleanupTmpDir(currentTempHome); + await cleanupTmpDir(tempWorkspace); + vi.clearAllMocks(); + }); + + it('should prioritize workspace settings over user settings and report correct scope', async () => { + // 1. Set User Setting + const userSettingsPath = path.join(extensionDir, '.env'); + fs.writeFileSync(userSettingsPath, 'TEST_SETTING=user-value'); + + // 2. Set Workspace Setting + const workspaceSettingsPath = path.join(tempWorkspace, '.env'); + fs.writeFileSync(workspaceSettingsPath, 'TEST_SETTING=workspace-value'); + + const extensionManager = new ExtensionManager({ + workspaceDir: tempWorkspace, + requestConsent: async () => true, + requestSetting: async () => '', + settings: createTestMergedSettings({ + telemetry: { enabled: false }, + experimental: { extensionConfig: true }, + security: { folderTrust: { enabled: false } }, + }), + }); + + const extensions = await extensionManager.loadExtensions(); + const extension = extensions.find((e) => e.name === extensionName); + + expect(extension).toBeDefined(); + + // Verify resolved settings + const setting = extension?.resolvedSettings?.find( + (s) => s.envVar === 'TEST_SETTING', + ); + expect(setting).toBeDefined(); + expect(setting?.value).toBe('workspace-value'); + expect(setting?.scope).toBe('workspace'); + expect(setting?.source).toBe(workspaceSettingsPath); + + // Verify output string contains (Workspace - ) + const output = extensionManager.toOutputString(extension!); + expect(output).toContain( + `Test Setting: workspace-value (Workspace - ${workspaceSettingsPath})`, + ); + }); + + it('should fallback to user settings if workspace setting is missing', async () => { + // 1. Set User Setting + const userSettingsPath = path.join(extensionDir, '.env'); + fs.writeFileSync(userSettingsPath, 'TEST_SETTING=user-value'); + + // 2. No Workspace Setting + + const extensionManager = new ExtensionManager({ + workspaceDir: tempWorkspace, + requestConsent: async () => true, + requestSetting: async () => '', + settings: createTestMergedSettings({ + telemetry: { enabled: false }, + experimental: { extensionConfig: true }, + security: { folderTrust: { enabled: false } }, + }), + }); + + const extensions = await extensionManager.loadExtensions(); + const extension = extensions.find((e) => e.name === extensionName); + + expect(extension).toBeDefined(); + + // Verify resolved settings + const setting = extension?.resolvedSettings?.find( + (s) => s.envVar === 'TEST_SETTING', + ); + expect(setting).toBeDefined(); + expect(setting?.value).toBe('user-value'); + expect(setting?.scope).toBe('user'); + expect(setting?.source?.endsWith(path.join(extensionName, '.env'))).toBe( + true, + ); + + // Verify output string contains (User - ) + const output = extensionManager.toOutputString(extension!); + expect(output).toContain( + `Test Setting: user-value (User - ${userSettingsPath})`, + ); + }); + + it('should report unset if neither is present', async () => { + // No settings files + + const extensionManager = new ExtensionManager({ + workspaceDir: tempWorkspace, + requestConsent: async () => true, + requestSetting: async () => '', + settings: createTestMergedSettings({ + telemetry: { enabled: false }, + experimental: { extensionConfig: true }, + security: { folderTrust: { enabled: false } }, + }), + }); + + const extensions = await extensionManager.loadExtensions(); + const extension = extensions.find((e) => e.name === extensionName); + + expect(extension).toBeDefined(); + + // Verify resolved settings + const setting = extension?.resolvedSettings?.find( + (s) => s.envVar === 'TEST_SETTING', + ); + expect(setting).toBeDefined(); + expect(setting?.value).toBeUndefined(); + expect(setting?.scope).toBeUndefined(); + + // Verify output string does not contain scope + const output = extensionManager.toOutputString(extension!); + expect(output).toContain('Test Setting: [not set]'); + expect(output).not.toContain('Test Setting: [not set] (User)'); + expect(output).not.toContain('Test Setting: [not set] (Workspace)'); + }); +}); diff --git a/packages/cli/src/config/extension-manager-skills.test.ts b/packages/cli/src/config/extension-manager-skills.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..800417de36ba04c5a47fa58c292590f2ad4dc296 --- /dev/null +++ b/packages/cli/src/config/extension-manager-skills.test.ts @@ -0,0 +1,194 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { ExtensionManager } from './extension-manager.js'; +import { debugLogger, coreEvents } from '@google/gemini-cli-core'; +import { createTestMergedSettings } from './settings.js'; +import { createExtension } from '../test-utils/createExtension.js'; +import { EXTENSIONS_DIRECTORY_NAME } from './extensions/variables.js'; + +const mockHomedir = vi.hoisted(() => vi.fn(() => '/tmp/mock-home')); +const mockIntegrityManager = vi.hoisted(() => ({ + verify: vi.fn().mockResolvedValue('verified'), + store: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + homedir: mockHomedir, + }; +}); + +// Mock @google/gemini-cli-core +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + homedir: mockHomedir, + ExtensionIntegrityManager: vi + .fn() + .mockImplementation(() => mockIntegrityManager), + loadAgentsFromDirectory: vi + .fn() + .mockImplementation(async () => ({ agents: [], errors: [] })), + loadSkillsFromDir: ( + await importOriginal() + ).loadSkillsFromDir, + }; +}); + +describe('ExtensionManager skills validation', () => { + let extensionManager: ExtensionManager; + let tempDir: string; + let extensionsDir: string; + + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(coreEvents, 'emitFeedback'); + vi.spyOn(debugLogger, 'debug').mockImplementation(() => {}); + + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-test-')); + mockHomedir.mockReturnValue(tempDir); + + // Create the extensions directory that ExtensionManager expects + extensionsDir = path.join(tempDir, '.gemini', EXTENSIONS_DIRECTORY_NAME); + fs.mkdirSync(extensionsDir, { recursive: true }); + + extensionManager = new ExtensionManager({ + settings: createTestMergedSettings({ + telemetry: { enabled: false }, + }), + requestConsent: vi.fn().mockResolvedValue(true), + requestSetting: vi.fn(), + workspaceDir: tempDir, + integrityManager: mockIntegrityManager, + }); + }); + + afterEach(() => { + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { + // ignore + } + }); + + it('should emit a warning during install if skills directory is not empty but no skills are loaded', async () => { + // Create a source extension + const sourceDir = path.join(tempDir, 'source-ext'); + createExtension({ + extensionsDir: sourceDir, // createExtension appends name + name: 'skills-ext', + version: '1.0.0', + installMetadata: { + type: 'local', + source: path.join(sourceDir, 'skills-ext'), + }, + }); + const extensionPath = path.join(sourceDir, 'skills-ext'); + + // Add invalid skills content + const skillsDir = path.join(extensionPath, 'skills'); + fs.mkdirSync(skillsDir); + fs.writeFileSync(path.join(skillsDir, 'not-a-skill.txt'), 'hello'); + + await extensionManager.loadExtensions(); + + await extensionManager.installOrUpdateExtension({ + type: 'local', + source: extensionPath, + }); + + expect(debugLogger.debug).toHaveBeenCalledWith( + expect.stringContaining('Failed to load skills from'), + ); + }); + + it('should emit a warning during load if skills directory is not empty but no skills are loaded', async () => { + // 1. Create a source extension + const sourceDir = path.join(tempDir, 'source-ext-load'); + createExtension({ + extensionsDir: sourceDir, + name: 'skills-ext-load', + version: '1.0.0', + }); + const sourceExtPath = path.join(sourceDir, 'skills-ext-load'); + + // Add invalid skills content + const skillsDir = path.join(sourceExtPath, 'skills'); + fs.mkdirSync(skillsDir); + fs.writeFileSync(path.join(skillsDir, 'not-a-skill.txt'), 'hello'); + + // 2. Install it to ensure correct disk state + await extensionManager.loadExtensions(); + await extensionManager.installOrUpdateExtension({ + type: 'local', + source: sourceExtPath, + }); + + // Clear the spy + vi.mocked(debugLogger.debug).mockClear(); + + // 3. Create a fresh ExtensionManager to force loading from disk + const newExtensionManager = new ExtensionManager({ + settings: createTestMergedSettings({ + telemetry: { enabled: false }, + }), + requestConsent: vi.fn().mockResolvedValue(true), + requestSetting: vi.fn(), + workspaceDir: tempDir, + integrityManager: mockIntegrityManager, + }); + + // 4. Load extensions + await newExtensionManager.loadExtensions(); + + expect(debugLogger.debug).toHaveBeenCalledWith( + expect.stringContaining('Failed to load skills from'), + ); + }); + + it('should succeed if skills are correctly loaded', async () => { + const sourceDir = path.join(tempDir, 'source-ext-good'); + createExtension({ + extensionsDir: sourceDir, + name: 'good-skills-ext', + version: '1.0.0', + installMetadata: { + type: 'local', + source: path.join(sourceDir, 'good-skills-ext'), + }, + }); + const extensionPath = path.join(sourceDir, 'good-skills-ext'); + + const skillsDir = path.join(extensionPath, 'skills'); + const skillSubdir = path.join(skillsDir, 'test-skill'); + fs.mkdirSync(skillSubdir, { recursive: true }); + fs.writeFileSync( + path.join(skillSubdir, 'SKILL.md'), + '---\nname: test-skill\ndescription: test desc\n---\nbody', + ); + + await extensionManager.loadExtensions(); + + const extension = await extensionManager.installOrUpdateExtension({ + type: 'local', + source: extensionPath, + }); + + expect(extension.name).toBe('good-skills-ext'); + expect(debugLogger.debug).not.toHaveBeenCalledWith( + expect.stringContaining('Failed to load skills from'), + ); + }); +}); diff --git a/packages/cli/src/config/extension-manager-themes.spec.ts b/packages/cli/src/config/extension-manager-themes.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..650bbc46afeaf2293149a9390a133aa9337f6999 --- /dev/null +++ b/packages/cli/src/config/extension-manager-themes.spec.ts @@ -0,0 +1,239 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { + beforeAll, + afterAll, + beforeEach, + describe, + expect, + it, + vi, + afterEach, +} from 'vitest'; + +import { createExtension } from '../test-utils/createExtension.js'; +import { ExtensionManager } from './extension-manager.js'; +import { themeManager, DEFAULT_THEME } from '../ui/themes/theme-manager.js'; +import { + GEMINI_DIR, + type Config, + tmpdir, + NoopSandboxManager, +} from '@google/gemini-cli-core'; +import { createTestMergedSettings, SettingScope } from './settings.js'; + +describe('ExtensionManager theme loading', () => { + let extensionManager: ExtensionManager; + let userExtensionsDir: string; + let tempHomeDir: string; + + beforeAll(async () => { + tempHomeDir = await fs.promises.mkdtemp( + path.join(tmpdir(), 'gemini-cli-test-'), + ); + }); + + afterAll(async () => { + if (tempHomeDir) { + await fs.promises.rm(tempHomeDir, { recursive: true, force: true }); + } + }); + + beforeEach(() => { + process.env['GEMINI_CLI_HOME'] = tempHomeDir; + userExtensionsDir = path.join(tempHomeDir, GEMINI_DIR, 'extensions'); + // Ensure userExtensionsDir is clean for each test + fs.rmSync(userExtensionsDir, { recursive: true, force: true }); + fs.mkdirSync(userExtensionsDir, { recursive: true }); + + extensionManager = new ExtensionManager({ + settings: createTestMergedSettings({ + experimental: { extensionConfig: true }, + security: { blockGitExtensions: false }, + admin: { extensions: { enabled: true }, mcp: { enabled: true } }, + }), + requestConsent: async () => true, + requestSetting: async () => '', + workspaceDir: tempHomeDir, + enabledExtensionOverrides: [], + }); + vi.clearAllMocks(); + themeManager.clearExtensionThemes(); + themeManager.loadCustomThemes({}); + themeManager.setActiveTheme(DEFAULT_THEME.name); + }); + + afterEach(() => { + delete process.env['GEMINI_CLI_HOME']; + }); + + it('should register themes from an extension when started', async () => { + const registerSpy = vi.spyOn(themeManager, 'registerExtensionThemes'); + createExtension({ + extensionsDir: userExtensionsDir, + name: 'my-theme-extension', + themes: [ + { + name: 'My-Awesome-Theme', + type: 'custom', + text: { + primary: '#FF00FF', + }, + }, + ], + }); + + await extensionManager.loadExtensions(); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const mockConfig = { + getEnableExtensionReloading: () => false, + getMcpClientManager: () => ({ + startExtension: vi.fn().mockResolvedValue(undefined), + }), + getGeminiClient: () => ({ + isInitialized: () => false, + updateSystemInstruction: vi.fn(), + setTools: vi.fn(), + }), + getHookSystem: () => undefined, + getWorkingDir: () => tempHomeDir, + shouldLoadMemoryFromIncludeDirectories: () => false, + getDebugMode: () => false, + getFileExclusions: () => ({ + isIgnored: () => false, + }), + getMemoryContextManager: () => undefined, + getGeminiMdFilePaths: () => [], + getMcpServers: () => ({}), + getAllowedMcpServers: () => [], + getSanitizationConfig: () => ({ + allowedEnvironmentVariables: [], + blockedEnvironmentVariables: [], + enableEnvironmentVariableRedaction: false, + }), + getShellExecutionConfig: () => ({ + terminalWidth: 80, + terminalHeight: 24, + showColor: false, + pager: 'cat', + sandboxManager: new NoopSandboxManager(), + sanitizationConfig: { + allowedEnvironmentVariables: [], + blockedEnvironmentVariables: [], + enableEnvironmentVariableRedaction: false, + }, + }), + getToolRegistry: () => ({ + getTools: () => [], + }), + getProxy: () => undefined, + getFileService: () => ({ + findFiles: async () => [], + }), + getExtensionLoader: () => ({ + getExtensions: () => [], + }), + isTrustedFolder: () => true, + getImportFormat: () => 'tree', + reloadSkills: vi.fn(), + } as unknown as Config; + + await extensionManager.start(mockConfig); + + expect(registerSpy).toHaveBeenCalledWith('my-theme-extension', [ + { + name: 'My-Awesome-Theme', + type: 'custom', + text: { + primary: '#FF00FF', + }, + }, + ]); + }); + + it('should revert to default theme when extension is stopped', async () => { + const extensionName = 'my-theme-extension'; + const themeName = 'My-Awesome-Theme'; + const namespacedThemeName = `${themeName} (${extensionName})`; + + createExtension({ + extensionsDir: userExtensionsDir, + name: extensionName, + themes: [ + { + name: themeName, + type: 'custom', + text: { + primary: '#FF00FF', + }, + }, + ], + }); + + await extensionManager.loadExtensions(); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const mockConfig = { + getWorkingDir: () => tempHomeDir, + shouldLoadMemoryFromIncludeDirectories: () => false, + getWorkspaceContext: () => ({ + getDirectories: () => [], + }), + getMemoryContextManager: () => undefined, + getDebugMode: () => false, + getFileService: () => ({ + findFiles: async () => [], + }), + getExtensionLoader: () => ({ + getExtensions: () => [], + }), + isTrustedFolder: () => true, + getImportFormat: () => 'tree', + getFileFilteringOptions: () => ({ + respectGitIgnore: true, + respectGeminiIgnore: true, + }), + getDiscoveryMaxDirs: () => 200, + getMemoryBoundaryMarkers: () => ['.git'], + getMcpClientManager: () => ({ + getMcpInstructions: () => '', + startExtension: vi.fn().mockResolvedValue(undefined), + stopExtension: vi.fn().mockResolvedValue(undefined), + }), + setUserMemory: vi.fn(), + setGeminiMdFileCount: vi.fn(), + setGeminiMdFilePaths: vi.fn(), + getEnableExtensionReloading: () => true, + getGeminiClient: () => ({ + isInitialized: () => false, + updateSystemInstruction: vi.fn(), + setTools: vi.fn(), + }), + getHookSystem: () => undefined, + getProxy: () => undefined, + getAgentRegistry: () => ({ + reload: vi.fn().mockResolvedValue(undefined), + }), + reloadSkills: vi.fn(), + } as unknown as Config; + + await extensionManager.start(mockConfig); + + // Set the active theme to the one from the extension + themeManager.setActiveTheme(namespacedThemeName); + expect(themeManager.getActiveTheme().name).toBe(namespacedThemeName); + + // Stop the extension + await extensionManager.disableExtension(extensionName, SettingScope.User); + + // Check that the active theme has reverted to the default + expect(themeManager.getActiveTheme().name).toBe(DEFAULT_THEME.name); + }); +}); diff --git a/packages/cli/src/config/extension-manager.test.ts b/packages/cli/src/config/extension-manager.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a3b1acf8626da0e124b6953bb9b18425304ae46c --- /dev/null +++ b/packages/cli/src/config/extension-manager.test.ts @@ -0,0 +1,1007 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { + ExtensionManager, + resolveContextFilePaths, + validateContextFilePath, +} from './extension-manager.js'; +import { createTestMergedSettings, type MergedSettings } from './settings.js'; +import { createExtension } from '../test-utils/createExtension.js'; +import { EXTENSIONS_DIRECTORY_NAME } from './extensions/variables.js'; +import { themeManager } from '../ui/themes/theme-manager.js'; +import { + TrustLevel, + loadTrustedFolders, + isWorkspaceTrusted, +} from './trustedFolders.js'; +import { + getRealPath, + type CustomTheme, + IntegrityDataStatus, +} from '@google/gemini-cli-core'; + +const mockHomedir = vi.hoisted(() => vi.fn(() => '/tmp/mock-home')); +const mockIntegrityManager = vi.hoisted(() => ({ + verify: vi.fn().mockResolvedValue('verified'), + store: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('os', async (importOriginal) => { + const mockedOs = await importOriginal(); + return { + ...mockedOs, + homedir: mockHomedir, + }; +}); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + homedir: mockHomedir, + ExtensionIntegrityManager: vi + .fn() + .mockImplementation(() => mockIntegrityManager), + }; +}); + +const testTheme: CustomTheme = { + type: 'custom', + name: 'MyTheme', + background: { + primary: '#282828', + diff: { added: '#2b3312', removed: '#341212' }, + }, + text: { + primary: '#ebdbb2', + secondary: '#a89984', + link: '#83a598', + accent: '#d3869b', + }, + status: { + success: '#b8bb26', + warning: '#fabd2f', + error: '#fb4934', + }, +}; + +describe('ExtensionManager', () => { + let tempHomeDir: string; + let tempWorkspaceDir: string; + let userExtensionsDir: string; + let extensionManager: ExtensionManager; + + beforeEach(() => { + vi.clearAllMocks(); + tempHomeDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'gemini-cli-test-home-'), + ); + tempWorkspaceDir = fs.mkdtempSync( + path.join(tempHomeDir, 'gemini-cli-test-workspace-'), + ); + mockHomedir.mockReturnValue(tempHomeDir); + userExtensionsDir = path.join(tempHomeDir, EXTENSIONS_DIRECTORY_NAME); + fs.mkdirSync(userExtensionsDir, { recursive: true }); + + extensionManager = new ExtensionManager({ + settings: createTestMergedSettings(), + workspaceDir: tempWorkspaceDir, + requestConsent: vi.fn().mockResolvedValue(true), + requestSetting: null, + integrityManager: mockIntegrityManager, + }); + }); + + afterEach(() => { + themeManager.clearExtensionThemes(); + try { + fs.rmSync(tempHomeDir, { recursive: true, force: true }); + } catch { + // Ignore + } + }); + + describe('loadExtensions parallel loading', () => { + it('should prevent concurrent loading and return the same promise', async () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'ext1', + version: '1.0.0', + }); + createExtension({ + extensionsDir: userExtensionsDir, + name: 'ext2', + version: '1.0.0', + }); + + // Call loadExtensions twice concurrently + const promise1 = extensionManager.loadExtensions(); + const promise2 = extensionManager.loadExtensions(); + + // They should resolve to the exact same array + const [extensions1, extensions2] = await Promise.all([ + promise1, + promise2, + ]); + + expect(extensions1).toBe(extensions2); + expect(extensions1).toHaveLength(2); + + const names = extensions1.map((ext) => ext.name).sort(); + expect(names).toEqual(['ext1', 'ext2']); + }); + + it('should throw an error if loadExtensions is called after it has already resolved', async () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'ext1', + version: '1.0.0', + }); + + await extensionManager.loadExtensions(); + + await expect(extensionManager.loadExtensions()).rejects.toThrow( + 'Extensions already loaded, only load extensions once.', + ); + }); + + it('should not throw if extension directory does not exist', async () => { + fs.rmSync(userExtensionsDir, { recursive: true, force: true }); + + const extensions = await extensionManager.loadExtensions(); + expect(extensions).toEqual([]); + }); + + it('should throw if there are duplicate extension names', async () => { + // We manually create two extensions with different dirs but same name in config + const ext1Dir = path.join(userExtensionsDir, 'ext1-dir'); + const ext2Dir = path.join(userExtensionsDir, 'ext2-dir'); + fs.mkdirSync(ext1Dir, { recursive: true }); + fs.mkdirSync(ext2Dir, { recursive: true }); + + const config = JSON.stringify({ + name: 'duplicate-ext', + version: '1.0.0', + }); + fs.writeFileSync(path.join(ext1Dir, 'gemini-extension.json'), config); + fs.writeFileSync( + path.join(ext1Dir, 'metadata.json'), + JSON.stringify({ type: 'local', source: ext1Dir }), + ); + + fs.writeFileSync(path.join(ext2Dir, 'gemini-extension.json'), config); + fs.writeFileSync( + path.join(ext2Dir, 'metadata.json'), + JSON.stringify({ type: 'local', source: ext2Dir }), + ); + + await expect(extensionManager.loadExtensions()).rejects.toThrow( + 'Extension with name duplicate-ext already was loaded.', + ); + }); + + it('should wait for loadExtensions to finish when loadExtension is called concurrently', async () => { + // Create an initial extension that loadExtensions will find + createExtension({ + extensionsDir: userExtensionsDir, + name: 'ext1', + version: '1.0.0', + }); + + // Start the parallel load (it will read ext1) + const loadAllPromise = extensionManager.loadExtensions(); + + // Create a second extension dynamically in a DIFFERENT directory + // so that loadExtensions (which scans userExtensionsDir) doesn't find it. + const externalDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'external-ext-'), + ); + fs.writeFileSync( + path.join(externalDir, 'gemini-extension.json'), + JSON.stringify({ name: 'ext2', version: '1.0.0' }), + ); + fs.writeFileSync( + path.join(externalDir, 'metadata.json'), + JSON.stringify({ type: 'local', source: externalDir }), + ); + + // Concurrently call loadExtension (simulating an install or update) + const loadSinglePromise = extensionManager.loadExtension(externalDir); + + // Wait for both to complete + await Promise.all([loadAllPromise, loadSinglePromise]); + + // Both extensions should now be present in the loadedExtensions array + const extensions = extensionManager.getExtensions(); + expect(extensions).toHaveLength(2); + const names = extensions.map((ext) => ext.name).sort(); + expect(names).toEqual(['ext1', 'ext2']); + + fs.rmSync(externalDir, { recursive: true, force: true }); + }); + }); + + describe('symlink handling', () => { + let extensionDir: string; + let symlinkDir: string; + + beforeEach(() => { + extensionDir = path.join(tempHomeDir, 'extension'); + symlinkDir = path.join(tempHomeDir, 'symlink-ext'); + + fs.mkdirSync(extensionDir, { recursive: true }); + + fs.writeFileSync( + path.join(extensionDir, 'gemini-extension.json'), + JSON.stringify({ name: 'test-ext', version: '1.0.0' }), + ); + + fs.symlinkSync(extensionDir, symlinkDir, 'dir'); + }); + + it('preserves symlinks in installMetadata.source when linking', async () => { + const manager = new ExtensionManager({ + workspaceDir: tempWorkspaceDir, + settings: { + security: { + folderTrust: { enabled: false }, // Disable trust for simplicity in this test + }, + experimental: { extensionConfig: false }, + admin: { extensions: { enabled: true }, mcp: { enabled: true } }, + hooksConfig: { enabled: true }, + } as unknown as MergedSettings, + requestConsent: () => Promise.resolve(true), + requestSetting: null, + integrityManager: mockIntegrityManager, + }); + + // Trust the workspace to allow installation + const trustedFolders = loadTrustedFolders(); + await trustedFolders.setValue(tempWorkspaceDir, TrustLevel.TRUST_FOLDER); + + const installMetadata = { + source: symlinkDir, + type: 'link' as const, + }; + + await manager.loadExtensions(); + const extension = await manager.installOrUpdateExtension(installMetadata); + + // Desired behavior: it preserves symlinks (if they were absolute or relative as provided) + expect(extension.installMetadata?.source).toBe(symlinkDir); + }); + + it('works with the new install command logic (preserves symlink but trusts real path)', async () => { + // This simulates the logic in packages/cli/src/commands/extensions/install.ts + const absolutePath = path.resolve(symlinkDir); + const realPath = getRealPath(absolutePath); + + const settings = { + security: { + folderTrust: { enabled: true }, + }, + experimental: { extensionConfig: false }, + admin: { extensions: { enabled: true }, mcp: { enabled: true } }, + hooksConfig: { enabled: true }, + } as unknown as MergedSettings; + + // Trust the REAL path + const trustedFolders = loadTrustedFolders(); + await trustedFolders.setValue(realPath, TrustLevel.TRUST_FOLDER); + + // Check trust of the symlink path + const trustResult = isWorkspaceTrusted(settings, absolutePath); + expect(trustResult.isTrusted).toBe(true); + + const manager = new ExtensionManager({ + workspaceDir: tempWorkspaceDir, + settings, + requestConsent: () => Promise.resolve(true), + requestSetting: null, + integrityManager: mockIntegrityManager, + }); + + const installMetadata = { + source: absolutePath, + type: 'link' as const, + }; + + await manager.loadExtensions(); + const extension = await manager.installOrUpdateExtension(installMetadata); + + expect(extension.installMetadata?.source).toBe(absolutePath); + expect(extension.installMetadata?.source).not.toBe(realPath); + }); + + it('enforces allowedExtensions using the real path', async () => { + const absolutePath = path.resolve(symlinkDir); + const realPath = getRealPath(absolutePath); + + const settings = { + security: { + folderTrust: { enabled: false }, + // Only allow the real path, not the symlink path + allowedExtensions: [realPath.replace(/\\/g, '\\\\')], + }, + experimental: { extensionConfig: false }, + admin: { extensions: { enabled: true }, mcp: { enabled: true } }, + hooksConfig: { enabled: true }, + } as unknown as MergedSettings; + + const manager = new ExtensionManager({ + workspaceDir: tempWorkspaceDir, + settings, + requestConsent: () => Promise.resolve(true), + requestSetting: null, + integrityManager: mockIntegrityManager, + }); + + const installMetadata = { + source: absolutePath, + type: 'link' as const, + }; + + await manager.loadExtensions(); + // This should pass because realPath is allowed + const extension = await manager.installOrUpdateExtension(installMetadata); + expect(extension.name).toBe('test-ext'); + + // Now try with a settings that only allows the symlink path string + const settingsOnlySymlink = { + security: { + folderTrust: { enabled: false }, + // Only allow the symlink path string explicitly + allowedExtensions: [absolutePath.replace(/\\/g, '\\\\')], + }, + experimental: { extensionConfig: false }, + admin: { extensions: { enabled: true }, mcp: { enabled: true } }, + hooksConfig: { enabled: true }, + } as unknown as MergedSettings; + + const manager2 = new ExtensionManager({ + workspaceDir: tempWorkspaceDir, + settings: settingsOnlySymlink, + requestConsent: () => Promise.resolve(true), + requestSetting: null, + integrityManager: mockIntegrityManager, + }); + + // This should FAIL because it checks the real path against the pattern + // (Unless symlinkDir === extensionDir, which shouldn't happen in this test setup) + if (absolutePath !== realPath) { + await expect( + manager2.installOrUpdateExtension(installMetadata), + ).rejects.toThrow( + /is not allowed by the "allowedExtensions" security setting/, + ); + } + }); + }); + + describe('Extension Renaming', () => { + it('should support renaming an extension during update', async () => { + // 1. Setup existing extension + const oldName = 'old-name'; + const newName = 'new-name'; + const extDir = path.join(userExtensionsDir, oldName); + fs.mkdirSync(extDir, { recursive: true }); + fs.writeFileSync( + path.join(extDir, 'gemini-extension.json'), + JSON.stringify({ name: oldName, version: '1.0.0' }), + ); + fs.writeFileSync( + path.join(extDir, 'metadata.json'), + JSON.stringify({ type: 'local', source: extDir }), + ); + + await extensionManager.loadExtensions(); + + // 2. Create a temporary "new" version with a different name + const newSourceDir = fs.mkdtempSync( + path.join(tempHomeDir, 'new-source-'), + ); + fs.writeFileSync( + path.join(newSourceDir, 'gemini-extension.json'), + JSON.stringify({ name: newName, version: '1.1.0' }), + ); + fs.writeFileSync( + path.join(newSourceDir, 'metadata.json'), + JSON.stringify({ type: 'local', source: newSourceDir }), + ); + + // 3. Update the extension + await extensionManager.installOrUpdateExtension( + { type: 'local', source: newSourceDir }, + { name: oldName, version: '1.0.0' }, + ); + + // 4. Verify old directory is gone and new one exists + expect(fs.existsSync(path.join(userExtensionsDir, oldName))).toBe(false); + expect(fs.existsSync(path.join(userExtensionsDir, newName))).toBe(true); + + // Verify the loaded state is updated + const extensions = extensionManager.getExtensions(); + expect(extensions.some((e) => e.name === newName)).toBe(true); + expect(extensions.some((e) => e.name === oldName)).toBe(false); + }); + + it('should carry over enablement status when renaming', async () => { + const oldName = 'old-name'; + const newName = 'new-name'; + const extDir = path.join(userExtensionsDir, oldName); + fs.mkdirSync(extDir, { recursive: true }); + fs.writeFileSync( + path.join(extDir, 'gemini-extension.json'), + JSON.stringify({ name: oldName, version: '1.0.0' }), + ); + fs.writeFileSync( + path.join(extDir, 'metadata.json'), + JSON.stringify({ type: 'local', source: extDir }), + ); + + // Enable it + const enablementManager = extensionManager.getEnablementManager(); + enablementManager.enable(oldName, true, tempHomeDir); + + await extensionManager.loadExtensions(); + const extension = extensionManager.getExtensions()[0]; + expect(extension.isActive).toBe(true); + + const newSourceDir = fs.mkdtempSync( + path.join(tempHomeDir, 'new-source-'), + ); + fs.writeFileSync( + path.join(newSourceDir, 'gemini-extension.json'), + JSON.stringify({ name: newName, version: '1.1.0' }), + ); + fs.writeFileSync( + path.join(newSourceDir, 'metadata.json'), + JSON.stringify({ type: 'local', source: newSourceDir }), + ); + + await extensionManager.installOrUpdateExtension( + { type: 'local', source: newSourceDir }, + { name: oldName, version: '1.0.0' }, + ); + + // Verify new name is enabled + expect(enablementManager.isEnabled(newName, tempHomeDir)).toBe(true); + // Verify old name is removed from enablement + expect(enablementManager.readConfig()[oldName]).toBeUndefined(); + }); + + it('should prevent renaming if the new name conflicts with an existing extension', async () => { + // Setup two extensions + const ext1Dir = path.join(userExtensionsDir, 'ext1'); + fs.mkdirSync(ext1Dir, { recursive: true }); + fs.writeFileSync( + path.join(ext1Dir, 'gemini-extension.json'), + JSON.stringify({ name: 'ext1', version: '1.0.0' }), + ); + fs.writeFileSync( + path.join(ext1Dir, 'metadata.json'), + JSON.stringify({ type: 'local', source: ext1Dir }), + ); + + const ext2Dir = path.join(userExtensionsDir, 'ext2'); + fs.mkdirSync(ext2Dir, { recursive: true }); + fs.writeFileSync( + path.join(ext2Dir, 'gemini-extension.json'), + JSON.stringify({ name: 'ext2', version: '1.0.0' }), + ); + fs.writeFileSync( + path.join(ext2Dir, 'metadata.json'), + JSON.stringify({ type: 'local', source: ext2Dir }), + ); + + await extensionManager.loadExtensions(); + + // Try to update ext1 to name 'ext2' + const newSourceDir = fs.mkdtempSync( + path.join(tempHomeDir, 'new-source-'), + ); + fs.writeFileSync( + path.join(newSourceDir, 'gemini-extension.json'), + JSON.stringify({ name: 'ext2', version: '1.1.0' }), + ); + fs.writeFileSync( + path.join(newSourceDir, 'metadata.json'), + JSON.stringify({ type: 'local', source: newSourceDir }), + ); + + await expect( + extensionManager.installOrUpdateExtension( + { type: 'local', source: newSourceDir }, + { name: 'ext1', version: '1.0.0' }, + ), + ).rejects.toThrow(/already installed/); + }); + }); + + describe('extension integrity', () => { + it('should store integrity data during installation', async () => { + const storeSpy = vi.spyOn(extensionManager, 'storeExtensionIntegrity'); + + const extDir = path.join(tempHomeDir, 'new-integrity-ext'); + fs.mkdirSync(extDir, { recursive: true }); + fs.writeFileSync( + path.join(extDir, 'gemini-extension.json'), + JSON.stringify({ name: 'integrity-ext', version: '1.0.0' }), + ); + + const installMetadata = { + source: extDir, + type: 'local' as const, + }; + + await extensionManager.loadExtensions(); + await extensionManager.installOrUpdateExtension(installMetadata); + + expect(storeSpy).toHaveBeenCalledWith('integrity-ext', installMetadata); + }); + + it('should store integrity data during first update', async () => { + const storeSpy = vi.spyOn(extensionManager, 'storeExtensionIntegrity'); + const verifySpy = vi.spyOn(extensionManager, 'verifyExtensionIntegrity'); + + // Setup existing extension + const extName = 'update-integrity-ext'; + const extDir = path.join(userExtensionsDir, extName); + fs.mkdirSync(extDir, { recursive: true }); + fs.writeFileSync( + path.join(extDir, 'gemini-extension.json'), + JSON.stringify({ name: extName, version: '1.0.0' }), + ); + fs.writeFileSync( + path.join(extDir, 'metadata.json'), + JSON.stringify({ type: 'local', source: extDir }), + ); + + await extensionManager.loadExtensions(); + + // Ensure no integrity data exists for this extension + verifySpy.mockResolvedValueOnce(IntegrityDataStatus.MISSING); + + const initialStatus = await extensionManager.verifyExtensionIntegrity( + extName, + { type: 'local', source: extDir }, + ); + expect(initialStatus).toBe('missing'); + + // Create new version of the extension + const newSourceDir = fs.mkdtempSync( + path.join(tempHomeDir, 'new-source-'), + ); + fs.writeFileSync( + path.join(newSourceDir, 'gemini-extension.json'), + JSON.stringify({ name: extName, version: '1.1.0' }), + ); + + const installMetadata = { + source: newSourceDir, + type: 'local' as const, + }; + + // Perform update and verify integrity was stored + await extensionManager.installOrUpdateExtension(installMetadata, { + name: extName, + version: '1.0.0', + }); + + expect(storeSpy).toHaveBeenCalledWith(extName, installMetadata); + }); + }); + + describe('early theme registration', () => { + it('should register themes with ThemeManager during loadExtensions for active extensions', async () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'themed-ext', + version: '1.0.0', + themes: [testTheme], + }); + + await extensionManager.loadExtensions(); + + expect(themeManager.getCustomThemeNames()).toContain( + 'MyTheme (themed-ext)', + ); + }); + + it('should not register themes for inactive extensions', async () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'disabled-ext', + version: '1.0.0', + themes: [testTheme], + }); + + // Disable the extension by creating an enablement override + const manager = new ExtensionManager({ + enabledExtensionOverrides: ['none'], + settings: createTestMergedSettings(), + workspaceDir: tempWorkspaceDir, + requestConsent: vi.fn().mockResolvedValue(true), + requestSetting: null, + }); + + await manager.loadExtensions(); + + expect(themeManager.getCustomThemeNames()).not.toContain( + 'MyTheme (disabled-ext)', + ); + }); + }); + + describe('contextFileName directory boundary validation and path resolution', () => { + it('loads legitimate relative paths within the extension directory (e.g. context/GEMINI.md, ./GEMINI.md)', async () => { + const extDir = path.join(userExtensionsDir, 'legit-ext'); + const contextDir = path.join(extDir, 'context'); + fs.mkdirSync(contextDir, { recursive: true }); + fs.writeFileSync(path.join(contextDir, 'GEMINI.md'), 'context content'); + fs.writeFileSync(path.join(extDir, 'GEMINI.md'), 'root context content'); + fs.writeFileSync(path.join(extDir, 'DOCS.md'), 'docs content'); + fs.writeFileSync( + path.join(extDir, 'gemini-extension.json'), + JSON.stringify({ + name: 'legit-ext', + version: '1.0.0', + contextFileName: ['context/GEMINI.md', './GEMINI.md', 'DOCS.md'], + }), + ); + fs.writeFileSync( + path.join(extDir, 'metadata.json'), + JSON.stringify({ type: 'local', source: extDir }), + ); + + const extensions = await extensionManager.loadExtensions(); + expect(extensions).toHaveLength(1); + const ext = extensions[0]; + expect(ext.contextFiles).toEqual([ + path.join(extDir, 'context', 'GEMINI.md'), + path.join(extDir, 'GEMINI.md'), + path.join(extDir, 'DOCS.md'), + ]); + }); + + it('rejects relative parent directory navigation using .. (e.g. ../../../../etc/passwd, ../secret.txt) and skips loading the invalid context file', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const extDir = path.join(userExtensionsDir, 'relative-parent-ext'); + fs.mkdirSync(extDir, { recursive: true }); + fs.writeFileSync(path.join(extDir, 'valid.md'), 'valid content'); + fs.writeFileSync( + path.join(extDir, 'gemini-extension.json'), + JSON.stringify({ + name: 'relative-parent-ext', + version: '1.0.0', + contextFileName: [ + 'valid.md', + '../../../../etc/passwd', + '../secret.txt', + 'subdir/../../escaped.txt', + '..\\secret.txt', + '%2e%2e%2fsecret.txt', + ], + }), + ); + fs.writeFileSync( + path.join(extDir, 'metadata.json'), + JSON.stringify({ type: 'local', source: extDir }), + ); + + const extensions = await extensionManager.loadExtensions(); + expect(extensions).toHaveLength(1); + const ext = extensions[0]; + // Only valid.md should be loaded; all invalid path attempts should be skipped + expect(ext.contextFiles).toEqual([path.join(extDir, 'valid.md')]); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('../../../../etc/passwd'), + ); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('../secret.txt'), + ); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('subdir/../../escaped.txt'), + ); + warnSpy.mockRestore(); + }); + + it('rejects absolute paths (e.g. /etc/hosts or C:\\Windows\\System32) and skips loading the invalid context file', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const extDir = path.join(userExtensionsDir, 'absolute-ext'); + fs.mkdirSync(extDir, { recursive: true }); + fs.writeFileSync(path.join(extDir, 'valid.md'), 'valid content'); + fs.writeFileSync( + path.join(extDir, 'gemini-extension.json'), + JSON.stringify({ + name: 'absolute-ext', + version: '1.0.0', + contextFileName: [ + 'valid.md', + '/etc/hosts', + '/etc/passwd', + 'C:\\Windows\\System32', + 'C:/Windows/System32', + '\\\\server\\share\\secret.txt', + ], + }), + ); + fs.writeFileSync( + path.join(extDir, 'metadata.json'), + JSON.stringify({ type: 'local', source: extDir }), + ); + + const extensions = await extensionManager.loadExtensions(); + expect(extensions).toHaveLength(1); + const ext = extensions[0]; + expect(ext.contextFiles).toEqual([path.join(extDir, 'valid.md')]); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('/etc/hosts'), + ); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('C:\\Windows\\System32'), + ); + warnSpy.mockRestore(); + }); + + it('skips non-string, empty, or whitespace-only contextFileName entries', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const extDir = path.join(userExtensionsDir, 'empty-ext'); + fs.mkdirSync(extDir, { recursive: true }); + fs.writeFileSync(path.join(extDir, 'valid.md'), 'valid content'); + fs.writeFileSync( + path.join(extDir, 'gemini-extension.json'), + JSON.stringify({ + name: 'empty-ext', + version: '1.0.0', + contextFileName: ['valid.md', '', ' ', null, 123], + }), + ); + fs.writeFileSync( + path.join(extDir, 'metadata.json'), + JSON.stringify({ type: 'local', source: extDir }), + ); + + const extensions = await extensionManager.loadExtensions(); + expect(extensions).toHaveLength(1); + expect(extensions[0].contextFiles).toEqual([ + path.join(extDir, 'valid.md'), + ]); + warnSpy.mockRestore(); + }); + + it('rejects context file path with null byte injection', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const extDir = path.join(userExtensionsDir, 'nullbyte-ext'); + fs.mkdirSync(extDir, { recursive: true }); + fs.writeFileSync( + path.join(extDir, 'gemini-extension.json'), + JSON.stringify({ + name: 'nullbyte-ext', + version: '1.0.0', + contextFileName: 'context.md\0/etc/hosts', + }), + ); + fs.writeFileSync( + path.join(extDir, 'metadata.json'), + JSON.stringify({ type: 'local', source: extDir }), + ); + + const extensions = await extensionManager.loadExtensions(); + expect(extensions).toHaveLength(1); + expect(extensions[0].contextFiles).toHaveLength(0); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('null bytes are not allowed'), + ); + warnSpy.mockRestore(); + }); + + it('sanitizes plan.directory against relative parent directory navigation', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const extDir = path.join(userExtensionsDir, 'plan-ext'); + fs.mkdirSync(extDir, { recursive: true }); + fs.writeFileSync( + path.join(extDir, 'gemini-extension.json'), + JSON.stringify({ + name: 'plan-ext', + version: '1.0.0', + plan: { + directory: '../../../../etc', + }, + }), + ); + fs.writeFileSync( + path.join(extDir, 'metadata.json'), + JSON.stringify({ type: 'local', source: extDir }), + ); + + const extensions = await extensionManager.loadExtensions(); + expect(extensions).toHaveLength(1); + expect(extensions[0].plan).toBeUndefined(); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'Invalid plan.directory in extension "plan-ext"', + ), + ); + warnSpy.mockRestore(); + }); + + describe('resolveContextFilePaths unit tests', () => { + it('returns empty array when context files do not exist', () => { + const config = { + name: 'test', + version: '1.0.0', + contextFileName: 'non-existent.md', + }; + const result = resolveContextFilePaths(config, tempWorkspaceDir); + expect(result).toEqual([]); + }); + + it('returns resolved paths for existing valid context files', () => { + const testFile = path.join(tempWorkspaceDir, 'valid.md'); + fs.writeFileSync(testFile, 'test'); + const config = { + name: 'test', + version: '1.0.0', + contextFileName: 'valid.md', + }; + const result = resolveContextFilePaths(config, tempWorkspaceDir); + expect(result).toEqual([testFile]); + }); + + it('filters out relative parent directory paths even if target file exists on disk', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const outsideFile = path.join(tempHomeDir, 'outside-secret.txt'); + fs.writeFileSync(outsideFile, 'secret'); + const extDir = path.join(tempHomeDir, 'ext-sub'); + fs.mkdirSync(extDir, { recursive: true }); + + const config = { + name: 'test', + version: '1.0.0', + contextFileName: '../outside-secret.txt', + }; + const result = resolveContextFilePaths(config, extDir); + expect(result).toEqual([]); + expect(warnSpy).toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + it('loads context file with percent characters without skipping it', () => { + const testFile = path.join(tempWorkspaceDir, '100%_complete.md'); + fs.writeFileSync(testFile, '100% completed content'); + const config = { + name: 'test', + version: '1.0.0', + contextFileName: '100%_complete.md', + }; + const result = resolveContextFilePaths(config, tempWorkspaceDir); + expect(result).toEqual([testFile]); + }); + }); + + describe('validateContextFilePath unit tests', () => { + it('validates standard relative context file path', () => { + const result = validateContextFilePath('GEMINI.md', tempWorkspaceDir); + expect(result.isValid).toBe(true); + expect(result.resolvedPath).toBe( + path.resolve(tempWorkspaceDir, 'GEMINI.md'), + ); + }); + + it('validates context file path in nested subdirectory', () => { + const result = validateContextFilePath( + 'docs/context.md', + tempWorkspaceDir, + ); + expect(result.isValid).toBe(true); + expect(result.resolvedPath).toBe( + path.resolve(tempWorkspaceDir, 'docs/context.md'), + ); + }); + + it('validates context file path containing percent characters', () => { + const result = validateContextFilePath( + 'progress_100%_report.md', + tempWorkspaceDir, + ); + expect(result.isValid).toBe(true); + expect(result.resolvedPath).toBe( + path.resolve(tempWorkspaceDir, 'progress_100%_report.md'), + ); + }); + + it('rejects relative parent directory navigation', () => { + const result = validateContextFilePath( + '../secret.txt', + tempWorkspaceDir, + ); + expect(result.isValid).toBe(false); + expect(result.errorMessage).toContain( + 'Relative parent directory references ("..") are not allowed.', + ); + }); + + it('rejects URL-encoded relative parent directory navigation', () => { + const resultLower = validateContextFilePath( + '%2e%2e/secret.txt', + tempWorkspaceDir, + ); + expect(resultLower.isValid).toBe(false); + expect(resultLower.errorMessage).toContain( + 'Relative parent directory references ("..") are not allowed.', + ); + + const resultUpper = validateContextFilePath( + '%2E%2E/secret.txt', + tempWorkspaceDir, + ); + expect(resultUpper.isValid).toBe(false); + expect(resultUpper.errorMessage).toContain( + 'Relative parent directory references ("..") are not allowed.', + ); + }); + + it('rejects POSIX and Windows absolute paths', () => { + const resultPosix = validateContextFilePath( + '/etc/hosts', + tempWorkspaceDir, + ); + expect(resultPosix.isValid).toBe(false); + expect(resultPosix.errorMessage).toContain( + 'Absolute paths are not allowed.', + ); + + const resultWin = validateContextFilePath( + 'C:\\Windows\\System32', + tempWorkspaceDir, + ); + expect(resultWin.isValid).toBe(false); + expect(resultWin.errorMessage).toContain( + 'Absolute paths are not allowed.', + ); + }); + + it('rejects null bytes', () => { + const result = validateContextFilePath( + 'context.md\0outside.txt', + tempWorkspaceDir, + ); + expect(result.isValid).toBe(false); + expect(result.errorMessage).toContain('null bytes are not allowed.'); + }); + + it('rejects empty or non-string inputs', () => { + expect(validateContextFilePath('', tempWorkspaceDir).isValid).toBe( + false, + ); + expect(validateContextFilePath(' ', tempWorkspaceDir).isValid).toBe( + false, + ); + expect(validateContextFilePath(null, tempWorkspaceDir).isValid).toBe( + false, + ); + expect(validateContextFilePath(123, tempWorkspaceDir).isValid).toBe( + false, + ); + }); + + it('validates context files when root path casing varies on case-insensitive platforms', () => { + const lowerRoot = tempWorkspaceDir.toLowerCase(); + const result = validateContextFilePath('GEMINI.md', lowerRoot); + if (process.platform === 'darwin' || process.platform === 'win32') { + expect(result.isValid).toBe(true); + } + }); + }); + }); +}); diff --git a/packages/cli/src/config/extension-manager.ts b/packages/cli/src/config/extension-manager.ts new file mode 100644 index 0000000000000000000000000000000000000000..ff43404d5461b312481bab53bb816a3df0c568a3 --- /dev/null +++ b/packages/cli/src/config/extension-manager.ts @@ -0,0 +1,1478 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { stat } from 'node:fs/promises'; +import chalk from 'chalk'; +import { ExtensionEnablementManager } from './extensions/extensionEnablement.js'; +import { type MergedSettings, SettingScope } from './settings.js'; +import { createHash, randomUUID } from 'node:crypto'; +import { loadInstallMetadata, type ExtensionConfig } from './extension.js'; +import { + isWorkspaceTrusted, + loadTrustedFolders, + TrustLevel, +} from './trustedFolders.js'; +import { + cloneFromGit, + downloadFromGitHubRelease, + tryParseGithubUrl, +} from './extensions/github.js'; +import { + Config, + debugLogger, + ExtensionDisableEvent, + ExtensionEnableEvent, + ExtensionInstallEvent, + ExtensionLoader, + ExtensionUninstallEvent, + ExtensionUpdateEvent, + getErrorMessage, + getRealPath, + logExtensionDisable, + logExtensionEnable, + logExtensionInstallEvent, + logExtensionUninstall, + logExtensionUpdateEvent, + loadSkillsFromDir, + loadAgentsFromDirectory, + homedir, + ExtensionIntegrityManager, + type IExtensionIntegrity, + type IntegrityDataStatus, + type ExtensionEvents, + type MCPServerConfig, + type ExtensionInstallMetadata, + type GeminiCLIExtension, + type HookDefinition, + type HookEventName, + type ResolvedExtensionSetting, + coreEvents, + applyAdminAllowlist, + getAdminBlockedMcpServersMessage, + CoreToolCallStatus, + loadExtensionPolicies, + isSubpath, + type PolicyRule, + type SafetyCheckerRule, + HookType, +} from '@google/gemini-cli-core'; +import { maybeRequestConsentOrFail } from './extensions/consent.js'; +import { resolveEnvVarsInObject } from '../utils/envVarResolver.js'; +import { ExtensionStorage } from './extensions/storage.js'; +import { + EXTENSIONS_CONFIG_FILENAME, + INSTALL_METADATA_FILENAME, + recursivelyHydrateStrings, + type JsonObject, + type VariableContext, +} from './extensions/variables.js'; +import { + getEnvContents, + getEnvFilePath, + maybePromptForSettings, + getMissingSettings, + type ExtensionSetting, + getScopedEnvContents, + ExtensionSettingScope, +} from './extensions/extensionSettings.js'; +import type { EventEmitter } from 'node:stream'; +import { themeManager } from '../ui/themes/theme-manager.js'; +import { getFormattedSettingValue } from '../commands/extensions/utils.js'; + +interface ExtensionManagerParams { + enabledExtensionOverrides?: string[]; + settings: MergedSettings; + requestConsent: (consent: string) => Promise; + requestSetting: + | ((setting: ExtensionSetting) => Promise) + | null; + workspaceDir: string; + eventEmitter?: EventEmitter; + clientVersion?: string; + integrityManager?: IExtensionIntegrity; +} + +/** + * Actual implementation of an ExtensionLoader. + * + * You must call `loadExtensions` prior to calling other methods on this class. + */ +export class ExtensionManager extends ExtensionLoader { + private extensionEnablementManager: ExtensionEnablementManager; + private integrityManager: IExtensionIntegrity; + private settings: MergedSettings; + private requestConsent: (consent: string) => Promise; + private requestSetting: + | ((setting: ExtensionSetting) => Promise) + | undefined; + private telemetryConfig: Config; + private workspaceDir: string; + private loadedExtensions: GeminiCLIExtension[] | undefined; + private loadingPromise: Promise | null = null; + + constructor(options: ExtensionManagerParams) { + super(options.eventEmitter); + this.workspaceDir = options.workspaceDir; + this.extensionEnablementManager = new ExtensionEnablementManager( + options.enabledExtensionOverrides, + ); + this.settings = options.settings; + this.telemetryConfig = new Config({ + telemetry: options.settings.telemetry, + interactive: false, + sessionId: randomUUID(), + clientVersion: options.clientVersion ?? 'unknown', + targetDir: options.workspaceDir, + cwd: options.workspaceDir, + model: '', + debugMode: false, + }); + this.requestConsent = options.requestConsent; + this.requestSetting = options.requestSetting ?? undefined; + this.integrityManager = + options.integrityManager ?? new ExtensionIntegrityManager(); + } + + getEnablementManager(): ExtensionEnablementManager { + return this.extensionEnablementManager; + } + + async verifyExtensionIntegrity( + extensionName: string, + metadata: ExtensionInstallMetadata | undefined, + ): Promise { + return this.integrityManager.verify(extensionName, metadata); + } + + async storeExtensionIntegrity( + extensionName: string, + metadata: ExtensionInstallMetadata, + ): Promise { + return this.integrityManager.store(extensionName, metadata); + } + + setRequestConsent( + requestConsent: (consent: string) => Promise, + ): void { + this.requestConsent = requestConsent; + } + + setRequestSetting( + requestSetting?: (setting: ExtensionSetting) => Promise, + ): void { + this.requestSetting = requestSetting; + } + + getExtensions(): GeminiCLIExtension[] { + if (!this.loadedExtensions) { + throw new Error( + 'Extensions not yet loaded, must call `loadExtensions` first', + ); + } + return this.loadedExtensions; + } + + async installOrUpdateExtension( + installMetadata: ExtensionInstallMetadata, + previousExtensionConfig?: ExtensionConfig, + requestConsentOverride?: (consent: string) => Promise, + ): Promise { + if ((this.settings.security?.allowedExtensions?.length ?? 0) > 0) { + const extensionAllowed = this.settings.security?.allowedExtensions.some( + (pattern) => { + try { + return new RegExp(pattern).test( + getRealPath(installMetadata.source), + ); + } catch (e) { + throw new Error( + `Invalid regex pattern in allowedExtensions setting: "${pattern}. Error: ${getErrorMessage(e)}`, + ); + } + }, + ); + if (!extensionAllowed) { + throw new Error( + `Installing extension from source "${installMetadata.source}" is not allowed by the "allowedExtensions" security setting.`, + ); + } + } else if ( + (installMetadata.type === 'git' || + installMetadata.type === 'github-release') && + this.settings.security.blockGitExtensions + ) { + throw new Error( + 'Installing extensions from remote sources is disallowed by your current settings.', + ); + } + + const isUpdate = !!previousExtensionConfig; + let newExtensionConfig: ExtensionConfig | null = null; + let localSourcePath: string | undefined; + let extension: GeminiCLIExtension | null; + try { + if (!isWorkspaceTrusted(this.settings).isTrusted) { + if ( + await this.requestConsent( + `The current workspace at "${this.workspaceDir}" is not trusted. Do you want to trust this workspace to install extensions?`, + ) + ) { + const trustedFolders = loadTrustedFolders(); + await trustedFolders.setValue( + this.workspaceDir, + TrustLevel.TRUST_FOLDER, + ); + } else { + throw new Error( + `Could not install extension because the current workspace at ${this.workspaceDir} is not trusted.`, + ); + } + } + const extensionsDir = ExtensionStorage.getUserExtensionsDir(); + await fs.promises.mkdir(extensionsDir, { recursive: true }); + + if (installMetadata.type === 'local' || installMetadata.type === 'link') { + installMetadata.source = path.isAbsolute(installMetadata.source) + ? installMetadata.source + : path.resolve(this.workspaceDir, installMetadata.source); + } + + let tempDir: string | undefined; + + if ( + installMetadata.type === 'git' || + installMetadata.type === 'github-release' + ) { + tempDir = await ExtensionStorage.createTmpDir(); + const parsedGithubParts = tryParseGithubUrl(installMetadata.source); + if (!parsedGithubParts) { + await cloneFromGit(installMetadata, tempDir); + installMetadata.type = 'git'; + } else { + const result = await downloadFromGitHubRelease( + installMetadata, + tempDir, + parsedGithubParts, + ); + if (result.success) { + installMetadata.type = result.type; + installMetadata.releaseTag = result.tagName; + } else if ( + // This repo has no github releases, and wasn't explicitly installed + // from a github release, unconditionally just clone it. + (result.failureReason === 'no release data' && + installMetadata.type === 'git') || + // Otherwise ask the user if they would like to try a git clone. + (await (requestConsentOverride ?? this.requestConsent)( + `Error downloading github release for ${installMetadata.source} with the following error: ${result.errorMessage}. + +Would you like to attempt to install via "git clone" instead?`, + )) + ) { + await cloneFromGit(installMetadata, tempDir); + installMetadata.type = 'git'; + } else { + throw new Error( + `Failed to install extension ${installMetadata.source}: ${result.errorMessage}`, + ); + } + } + localSourcePath = tempDir; + } else if ( + installMetadata.type === 'local' || + installMetadata.type === 'link' + ) { + localSourcePath = getRealPath(installMetadata.source); + } else { + throw new Error(`Unsupported install type: ${installMetadata.type}`); + } + + try { + newExtensionConfig = await this.loadExtensionConfig(localSourcePath); + + const newExtensionName = newExtensionConfig.name; + const previousName = previousExtensionConfig?.name ?? newExtensionName; + const previous = this.getExtensions().find( + (installed) => installed.name === previousName, + ); + const nameConflict = this.getExtensions().find( + (installed) => + installed.name === newExtensionName && + installed.name !== previousName, + ); + + if (isUpdate && !previous) { + throw new Error( + `Extension "${previousName}" was not already installed, cannot update it.`, + ); + } else if (!isUpdate && previous) { + throw new Error( + `Extension "${newExtensionName}" is already installed. Please uninstall it first.`, + ); + } else if (isUpdate && nameConflict) { + throw new Error( + `Cannot update to "${newExtensionName}" because an extension with that name is already installed.`, + ); + } + + const newHasHooks = fs.existsSync( + path.join(localSourcePath, 'hooks', 'hooks.json'), + ); + const previousHasHooks = !!( + isUpdate && + previous && + previous.hooks && + Object.keys(previous.hooks).length > 0 + ); + + const newSkills = await loadSkillsFromDir( + path.join(localSourcePath, 'skills'), + ); + const previousSkills = previous?.skills ?? []; + const isMigrating = Boolean( + previous && + previous.installMetadata && + previous.installMetadata.source !== installMetadata.source, + ); + + await maybeRequestConsentOrFail( + newExtensionConfig, + requestConsentOverride ?? this.requestConsent, + newHasHooks, + previousExtensionConfig, + previousHasHooks, + newSkills, + previousSkills, + isMigrating, + ); + const extensionId = getExtensionId(newExtensionConfig, installMetadata); + const destinationPath = new ExtensionStorage( + newExtensionName, + ).getExtensionDir(); + + if ( + (!isUpdate || newExtensionName !== previousName) && + fs.existsSync(destinationPath) + ) { + throw new Error( + `Cannot install extension "${newExtensionName}" because a directory with that name already exists. Please remove it manually.`, + ); + } + + let previousSettings: Record | undefined; + let wasEnabledGlobally = false; + let wasEnabledWorkspace = false; + if (isUpdate && previousExtensionConfig) { + const previousExtensionId = previous?.installMetadata + ? getExtensionId(previousExtensionConfig, previous.installMetadata) + : extensionId; + previousSettings = await getEnvContents( + previousExtensionConfig, + previousExtensionId, + this.workspaceDir, + ); + if (newExtensionName !== previousName) { + wasEnabledGlobally = this.extensionEnablementManager.isEnabled( + previousName, + homedir(), + ); + wasEnabledWorkspace = this.extensionEnablementManager.isEnabled( + previousName, + this.workspaceDir, + ); + this.extensionEnablementManager.remove(previousName); + } + await this.uninstallExtension(previousName, isUpdate); + } + + await fs.promises.mkdir(destinationPath, { recursive: true }); + if (this.requestSetting && this.settings.experimental.extensionConfig) { + if (isUpdate) { + await maybePromptForSettings( + newExtensionConfig, + extensionId, + this.requestSetting, + previousExtensionConfig, + previousSettings, + ); + } else { + await maybePromptForSettings( + newExtensionConfig, + extensionId, + this.requestSetting, + ); + } + } + + const missingSettings = this.settings.experimental.extensionConfig + ? await getMissingSettings( + newExtensionConfig, + extensionId, + this.workspaceDir, + ) + : []; + if (missingSettings.length > 0) { + const message = `Extension "${newExtensionConfig.name}" has missing settings: ${missingSettings + .map((s) => s.name) + .join( + ', ', + )}. Please run "gemini extensions config ${newExtensionConfig.name} [setting-name]" to configure them.`; + debugLogger.warn(message); + coreEvents.emitFeedback('warning', message); + } + + if ( + installMetadata.type === 'local' || + installMetadata.type === 'git' || + installMetadata.type === 'github-release' + ) { + await copyExtension(localSourcePath, destinationPath); + } + + const metadataString = JSON.stringify(installMetadata, null, 2); + const metadataPath = path.join( + destinationPath, + INSTALL_METADATA_FILENAME, + ); + await fs.promises.writeFile(metadataPath, metadataString); + + // Establish trust at point of installation + await this.storeExtensionIntegrity( + newExtensionConfig.name, + installMetadata, + ); + + // TODO: Gracefully handle this call failing, we should back up the old + // extension prior to overwriting it and then restore and restart it. + extension = await this.loadExtension(destinationPath); + if (!extension) { + throw new Error(`Extension not found`); + } + if (isUpdate) { + await logExtensionUpdateEvent( + this.telemetryConfig, + new ExtensionUpdateEvent( + newExtensionConfig.name, + hashValue(newExtensionConfig.name), + getExtensionId(newExtensionConfig, installMetadata), + newExtensionConfig.version, + previousExtensionConfig.version, + installMetadata.type, + CoreToolCallStatus.Success, + ), + ); + + if (newExtensionName !== previousName) { + if (wasEnabledGlobally) { + await this.enableExtension(newExtensionName, SettingScope.User); + } + if (wasEnabledWorkspace) { + await this.enableExtension( + newExtensionName, + SettingScope.Workspace, + ); + } + } + } else { + await logExtensionInstallEvent( + this.telemetryConfig, + new ExtensionInstallEvent( + newExtensionConfig.name, + hashValue(newExtensionConfig.name), + getExtensionId(newExtensionConfig, installMetadata), + newExtensionConfig.version, + installMetadata.type, + CoreToolCallStatus.Success, + ), + ); + await this.enableExtension( + newExtensionConfig.name, + SettingScope.User, + ); + } + } finally { + if (tempDir) { + await fs.promises.rm(tempDir, { recursive: true, force: true }); + } + } + return extension; + } catch (error) { + // Attempt to load config from the source path even if installation fails + // to get the name and version for logging. + if (!newExtensionConfig && localSourcePath) { + try { + newExtensionConfig = await this.loadExtensionConfig(localSourcePath); + } catch { + // Ignore error, this is just for logging. + } + } + const config = newExtensionConfig ?? previousExtensionConfig; + const extensionId = config + ? getExtensionId(config, installMetadata) + : undefined; + if (isUpdate) { + await logExtensionUpdateEvent( + this.telemetryConfig, + new ExtensionUpdateEvent( + config?.name ?? '', + hashValue(config?.name ?? ''), + extensionId ?? '', + newExtensionConfig?.version ?? '', + previousExtensionConfig.version, + installMetadata.type, + CoreToolCallStatus.Error, + ), + ); + } else { + await logExtensionInstallEvent( + this.telemetryConfig, + new ExtensionInstallEvent( + newExtensionConfig?.name ?? '', + hashValue(newExtensionConfig?.name ?? ''), + extensionId ?? '', + newExtensionConfig?.version ?? '', + installMetadata.type, + CoreToolCallStatus.Error, + ), + ); + } + throw error; + } + } + + async uninstallExtension( + extensionIdentifier: string, + isUpdate: boolean, + ): Promise { + const installedExtensions = this.getExtensions(); + const extension = installedExtensions.find( + (installed) => + installed.name.toLowerCase() === extensionIdentifier.toLowerCase() || + installed.installMetadata?.source.toLowerCase() === + extensionIdentifier.toLowerCase(), + ); + if (!extension) { + throw new Error(`Extension not found.`); + } + await this.unloadExtension(extension); + const storage = new ExtensionStorage( + extension.installMetadata?.type === 'link' + ? extension.name + : path.basename(extension.path), + ); + + await fs.promises.rm(storage.getExtensionDir(), { + recursive: true, + force: true, + }); + + // The rest of the cleanup below here is only for true uninstalls, not + // uninstalls related to updates. + if (isUpdate) return; + + this.extensionEnablementManager.remove(extension.name); + + await logExtensionUninstall( + this.telemetryConfig, + new ExtensionUninstallEvent( + extension.name, + hashValue(extension.name), + extension.id, + CoreToolCallStatus.Success, + ), + ); + } + + protected override async startExtension(extension: GeminiCLIExtension) { + await super.startExtension(extension); + if (extension.themes && !themeManager.hasExtensionThemes(extension.name)) { + themeManager.registerExtensionThemes(extension.name, extension.themes); + } + } + + protected override async stopExtension(extension: GeminiCLIExtension) { + await super.stopExtension(extension); + if (extension.themes) { + themeManager.unregisterExtensionThemes(extension.name, extension.themes); + } + } + + /** + * Loads all installed extensions, should only be called once. + */ + async loadExtensions(): Promise { + if (this.loadedExtensions) { + throw new Error('Extensions already loaded, only load extensions once.'); + } + + if (this.loadingPromise) { + return this.loadingPromise; + } + + this.loadingPromise = (async () => { + try { + if (this.settings.admin?.extensions?.enabled === false) { + this.loadedExtensions = []; + return this.loadedExtensions; + } + + const extensionsDir = ExtensionStorage.getUserExtensionsDir(); + if (!fs.existsSync(extensionsDir)) { + this.loadedExtensions = []; + return this.loadedExtensions; + } + + const subdirs = await fs.promises.readdir(extensionsDir); + const extensionPromises = subdirs.map((subdir) => { + const extensionDir = path.join(extensionsDir, subdir); + return this._buildExtension(extensionDir); + }); + + const builtExtensionsOrNull = await Promise.all(extensionPromises); + const builtExtensions = builtExtensionsOrNull.filter( + (ext): ext is GeminiCLIExtension => ext !== null, + ); + + const seenNames = new Set(); + for (const ext of builtExtensions) { + if (seenNames.has(ext.name)) { + throw new Error( + `Extension with name ${ext.name} already was loaded.`, + ); + } + seenNames.add(ext.name); + } + + this.loadedExtensions = builtExtensions; + + // Register extension themes early so they're available at startup. + for (const ext of this.loadedExtensions) { + if (ext.isActive && ext.themes) { + themeManager.registerExtensionThemes(ext.name, ext.themes); + } + } + + await Promise.all( + this.loadedExtensions.map((ext) => this.maybeStartExtension(ext)), + ); + + return this.loadedExtensions; + } finally { + this.loadingPromise = null; + } + })(); + + return this.loadingPromise; + } + + /** + * Adds `extension` to the list of extensions and starts it if appropriate. + * + * @internal visible for testing only + */ + async loadExtension( + extensionDir: string, + ): Promise { + if (this.loadingPromise) { + await this.loadingPromise; + } + this.loadedExtensions ??= []; + const extension = await this._buildExtension(extensionDir); + if (!extension) { + return null; + } + + if ( + this.getExtensions().find( + (installed) => installed.name === extension.name, + ) + ) { + throw new Error( + `Extension with name ${extension.name} already was loaded.`, + ); + } + + this.loadedExtensions = [...this.loadedExtensions, extension]; + await this.maybeStartExtension(extension); + return extension; + } + + /** + * Builds an extension without side effects (does not mutate loadedExtensions or start it). + */ + private async _buildExtension( + extensionDir: string, + ): Promise { + try { + const stats = await fs.promises.stat(extensionDir); + if (!stats.isDirectory()) { + return null; + } + } catch { + return null; + } + + const installMetadata = loadInstallMetadata(extensionDir); + let effectiveExtensionPath = extensionDir; + if ((this.settings.security?.allowedExtensions?.length ?? 0) > 0) { + if (!installMetadata?.source) { + throw new Error( + `Failed to load extension ${extensionDir}. The ${INSTALL_METADATA_FILENAME} file is missing or misconfigured.`, + ); + } + const extensionAllowed = this.settings.security?.allowedExtensions.some( + (pattern) => { + try { + return new RegExp(pattern).test( + getRealPath(installMetadata?.source ?? ''), + ); + } catch (e) { + throw new Error( + `Invalid regex pattern in allowedExtensions setting: "${pattern}. Error: ${getErrorMessage(e)}`, + ); + } + }, + ); + if (!extensionAllowed) { + debugLogger.warn( + `Failed to load extension ${extensionDir}. This extension is not allowed by the "allowedExtensions" security setting.`, + ); + return null; + } + } else if ( + (installMetadata?.type === 'git' || + installMetadata?.type === 'github-release') && + this.settings.security.blockGitExtensions + ) { + debugLogger.warn( + `Failed to load extension ${extensionDir}. Extensions from remote sources is disallowed by your current settings.`, + ); + return null; + } + + if (installMetadata?.type === 'link') { + effectiveExtensionPath = installMetadata.source; + } + + try { + let config = await this.loadExtensionConfig(effectiveExtensionPath); + + const extensionId = getExtensionId(config, installMetadata); + + let userSettings: Record = {}; + let workspaceSettings: Record = {}; + + if (this.settings.experimental.extensionConfig) { + userSettings = await getScopedEnvContents( + config, + extensionId, + ExtensionSettingScope.USER, + ); + if (isWorkspaceTrusted(this.settings).isTrusted) { + workspaceSettings = await getScopedEnvContents( + config, + extensionId, + ExtensionSettingScope.WORKSPACE, + this.workspaceDir, + ); + } + } + + const customEnv = { ...userSettings, ...workspaceSettings }; + config = resolveEnvVarsInObject(config, customEnv); + + const resolvedSettings: ResolvedExtensionSetting[] = []; + if (config.settings && this.settings.experimental.extensionConfig) { + for (const setting of config.settings) { + const value = customEnv[setting.envVar]; + let scope: 'user' | 'workspace' | undefined; + let source: string | undefined; + + // Note: strict check for undefined, as empty string is a valid value + if (workspaceSettings[setting.envVar] !== undefined) { + scope = 'workspace'; + if (setting.sensitive) { + source = 'Keychain'; + } else { + source = getEnvFilePath( + config.name, + ExtensionSettingScope.WORKSPACE, + this.workspaceDir, + ); + } + } else if (userSettings[setting.envVar] !== undefined) { + scope = 'user'; + if (setting.sensitive) { + source = 'Keychain'; + } else { + source = getEnvFilePath(config.name, ExtensionSettingScope.USER); + } + } + + resolvedSettings.push({ + name: setting.name, + envVar: setting.envVar, + value, + sensitive: setting.sensitive ?? false, + scope, + source, + }); + } + } + + if (config.mcpServers) { + if (this.settings.admin?.mcp?.enabled === false) { + config.mcpServers = undefined; + } else { + // Apply admin allowlist if configured + const adminAllowlist = this.settings.admin?.mcp?.config; + if (adminAllowlist && Object.keys(adminAllowlist).length > 0) { + const result = applyAdminAllowlist( + config.mcpServers, + adminAllowlist, + ); + config.mcpServers = result.mcpServers; + + if (result.blockedServerNames.length > 0) { + const message = getAdminBlockedMcpServersMessage( + result.blockedServerNames, + undefined, + ); + coreEvents.emitConsoleLog('warn', message); + } + } + + // Then apply local filtering/sanitization + if (config.mcpServers) { + config.mcpServers = Object.fromEntries( + Object.entries(config.mcpServers).map(([key, value]) => [ + key, + filterMcpConfig(value), + ]), + ); + } + } + } + + const contextFiles = resolveContextFilePaths( + config, + effectiveExtensionPath, + ); + + const hydrationContext: VariableContext = { + extensionPath: effectiveExtensionPath, + workspacePath: this.workspaceDir, + '/': path.sep, + pathSeparator: path.sep, + ...customEnv, + }; + + let hooks: { [K in HookEventName]?: HookDefinition[] } | undefined; + if (this.settings.hooksConfig.enabled) { + hooks = await this.loadExtensionHooks( + effectiveExtensionPath, + hydrationContext, + ); + } + + // Hydrate hooks with extension settings as environment variables + if (hooks && config.settings) { + const hookEnv: Record = {}; + for (const setting of config.settings) { + const value = customEnv[setting.envVar]; + if (value !== undefined) { + hookEnv[setting.envVar] = value; + } + } + + if (Object.keys(hookEnv).length > 0) { + for (const eventName of Object.keys(hooks)) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const eventHooks = hooks[eventName as HookEventName]; + if (eventHooks) { + for (const definition of eventHooks) { + for (const hook of definition.hooks) { + if (hook.type === HookType.Command) { + // Merge existing env with new env vars, giving extension settings precedence. + hook.env = { ...hook.env, ...hookEnv }; + } + } + } + } + } + } + } + + let skills = await loadSkillsFromDir( + path.join(effectiveExtensionPath, 'skills'), + ); + skills = skills.map((skill) => ({ + ...recursivelyHydrateStrings(skill, hydrationContext), + extensionName: config.name, + })); + + let rules: PolicyRule[] | undefined; + let checkers: SafetyCheckerRule[] | undefined; + + const policyDir = path.join(effectiveExtensionPath, 'policies'); + if (fs.existsSync(policyDir)) { + const result = await loadExtensionPolicies(config.name, policyDir); + rules = result.rules; + checkers = result.checkers; + + if (result.errors.length > 0) { + for (const error of result.errors) { + debugLogger.warn( + `[ExtensionManager] Error loading policies from ${config.name}: ${error.message}${error.details ? `\nDetails: ${error.details}` : ''}`, + ); + } + } + } + + const agentLoadResult = await loadAgentsFromDirectory( + path.join(effectiveExtensionPath, 'agents'), + ); + agentLoadResult.agents = agentLoadResult.agents.map((agent) => ({ + ...recursivelyHydrateStrings(agent, hydrationContext), + extensionName: config.name, + })); + + // Log errors but don't fail the entire extension load + for (const error of agentLoadResult.errors) { + debugLogger.warn( + `[ExtensionManager] Error loading agent from ${config.name}: ${error.message}`, + ); + } + + let plan = config.plan; + if (plan?.directory) { + if ( + typeof plan.directory !== 'string' || + path.isAbsolute(plan.directory) || + path.win32.isAbsolute(plan.directory) || + plan.directory.includes('..') + ) { + debugLogger.warn( + `[ExtensionManager] Invalid plan.directory in extension "${config.name}": "${plan.directory}". Relative parent navigation and absolute paths are not allowed.`, + ); + plan = undefined; + } + } + + return { + name: config.name, + version: config.version, + path: effectiveExtensionPath, + contextFiles, + installMetadata, + migratedTo: config.migratedTo, + mcpServers: config.mcpServers, + excludeTools: config.excludeTools, + hooks, + isActive: this.extensionEnablementManager.isEnabled( + config.name, + this.workspaceDir, + ), + id: getExtensionId(config, installMetadata), + settings: config.settings, + resolvedSettings, + skills, + agents: agentLoadResult.agents, + themes: config.themes, + rules, + checkers, + plan, + }; + } catch (e) { + debugLogger.error( + `Warning: Skipping extension in ${effectiveExtensionPath}: ${getErrorMessage( + e, + )}`, + ); + return null; + } + } + + override async restartExtension( + extension: GeminiCLIExtension, + ): Promise { + const extensionDir = extension.path; + await this.unloadExtension(extension); + await this.loadExtension(extensionDir); + } + + /** + * Removes `extension` from the list of extensions and stops it if + * appropriate. + */ + private unloadExtension( + extension: GeminiCLIExtension, + ): Promise | undefined { + this.loadedExtensions = this.getExtensions().filter( + (entry) => extension !== entry, + ); + return this.maybeStopExtension(extension); + } + + async loadExtensionConfig(extensionDir: string): Promise { + const configFilePath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME); + if (!fs.existsSync(configFilePath)) { + throw new Error(`Configuration file not found at ${configFilePath}`); + } + try { + const configContent = await fs.promises.readFile(configFilePath, 'utf-8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const rawConfig = JSON.parse(configContent) as ExtensionConfig; + if (!rawConfig.name || !rawConfig.version) { + throw new Error( + `Invalid configuration in ${configFilePath}: missing ${!rawConfig.name ? '"name"' : '"version"'}`, + ); + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const config = recursivelyHydrateStrings( + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + rawConfig as unknown as JsonObject, + { + extensionPath: extensionDir, + workspacePath: this.workspaceDir, + '/': path.sep, + pathSeparator: path.sep, + }, + ) as unknown as ExtensionConfig; + + validateName(config.name); + return config; + } catch (e) { + throw new Error( + `Failed to load extension config from ${configFilePath}: ${getErrorMessage( + e, + )}`, + ); + } + } + + private async loadExtensionHooks( + extensionDir: string, + context: VariableContext, + ): Promise<{ [K in HookEventName]?: HookDefinition[] } | undefined> { + const hooksFilePath = path.join(extensionDir, 'hooks', 'hooks.json'); + + try { + const hooksContent = await fs.promises.readFile(hooksFilePath, 'utf-8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const rawHooks = JSON.parse(hooksContent); + + if ( + !rawHooks || + typeof rawHooks !== 'object' || + typeof rawHooks.hooks !== 'object' || + rawHooks.hooks === null || + Array.isArray(rawHooks.hooks) + ) { + debugLogger.warn( + `Invalid hooks configuration in ${hooksFilePath}: "hooks" property must be an object`, + ); + return undefined; + } + + // Hydrate variables in the hooks configuration + const hydratedHooks = recursivelyHydrateStrings( + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + rawHooks.hooks as unknown as JsonObject, + { + ...context, + '/': path.sep, + pathSeparator: path.sep, + }, + ) as { [K in HookEventName]?: HookDefinition[] }; + + return hydratedHooks; + } catch (e) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + if ((e as NodeJS.ErrnoException).code === 'ENOENT') { + return undefined; // File not found is not an error here. + } + debugLogger.warn( + `Failed to load extension hooks from ${hooksFilePath}: ${getErrorMessage( + e, + )}`, + ); + return undefined; + } + } + + toOutputString(extension: GeminiCLIExtension): string { + const userEnabled = this.extensionEnablementManager.isEnabled( + extension.name, + homedir(), + ); + const workspaceEnabled = this.extensionEnablementManager.isEnabled( + extension.name, + this.workspaceDir, + ); + + const status = workspaceEnabled ? chalk.green('✓') : chalk.red('✗'); + let output = `${status} ${extension.name} (${extension.version})`; + output += `\n ID: ${extension.id}`; + output += `\n name: ${hashValue(extension.name)}`; + + output += `\n Path: ${extension.path}`; + if (extension.installMetadata) { + output += `\n Source: ${extension.installMetadata.source} (Type: ${extension.installMetadata.type})`; + if (extension.installMetadata.ref) { + output += `\n Ref: ${extension.installMetadata.ref}`; + } + if (extension.installMetadata.releaseTag) { + output += `\n Release tag: ${extension.installMetadata.releaseTag}`; + } + } + output += `\n Enabled (User): ${userEnabled}`; + output += `\n Enabled (Workspace): ${workspaceEnabled}`; + if (extension.contextFiles.length > 0) { + output += `\n Context files:`; + extension.contextFiles.forEach((contextFile) => { + output += `\n ${contextFile}`; + }); + } + if (extension.mcpServers) { + output += `\n MCP servers:`; + Object.keys(extension.mcpServers).forEach((key) => { + output += `\n ${key}`; + }); + } + if (extension.excludeTools) { + output += `\n Excluded tools:`; + extension.excludeTools.forEach((tool) => { + output += `\n ${tool}`; + }); + } + if (extension.skills && extension.skills.length > 0) { + output += `\n Agent skills:`; + extension.skills.forEach((skill) => { + output += `\n ${skill.name}: ${skill.description}`; + }); + } + const resolvedSettings = extension.resolvedSettings; + if (resolvedSettings && resolvedSettings.length > 0) { + output += `\n Settings:`; + resolvedSettings.forEach((setting) => { + let scope = ''; + if (setting.scope) { + scope = setting.scope === 'workspace' ? '(Workspace' : '(User'; + if (setting.source) { + scope += ` - ${setting.source}`; + } + scope += ')'; + } + output += `\n ${setting.name}: ${getFormattedSettingValue(setting)} ${scope}`; + }); + } + return output; + } + + async disableExtension(name: string, scope: SettingScope) { + if ( + scope === SettingScope.System || + scope === SettingScope.SystemDefaults + ) { + throw new Error('System and SystemDefaults scopes are not supported.'); + } + const extension = this.getExtensions().find( + (extension) => extension.name === name, + ); + if (!extension) { + throw new Error(`Extension with name ${name} does not exist.`); + } + + if (scope !== SettingScope.Session) { + const scopePath = + scope === SettingScope.Workspace ? this.workspaceDir : homedir(); + this.extensionEnablementManager.disable(name, true, scopePath); + } + await logExtensionDisable( + this.telemetryConfig, + new ExtensionDisableEvent(name, hashValue(name), extension.id, scope), + ); + if (!this.config || this.config.getEnableExtensionReloading()) { + // Only toggle the isActive state if we are actually going to disable it + // in the current session, or we haven't been initialized yet. + extension.isActive = false; + } + await this.maybeStopExtension(extension); + } + + /** + * Enables an existing extension for a given scope, and starts it if + * appropriate. + */ + async enableExtension(name: string, scope: SettingScope) { + if ( + scope === SettingScope.System || + scope === SettingScope.SystemDefaults + ) { + throw new Error('System and SystemDefaults scopes are not supported.'); + } + const extension = this.getExtensions().find( + (extension) => extension.name === name, + ); + if (!extension) { + throw new Error(`Extension with name ${name} does not exist.`); + } + + if (scope !== SettingScope.Session) { + const scopePath = + scope === SettingScope.Workspace ? this.workspaceDir : homedir(); + this.extensionEnablementManager.enable(name, true, scopePath); + } + await logExtensionEnable( + this.telemetryConfig, + new ExtensionEnableEvent(name, hashValue(name), extension.id, scope), + ); + if (!this.config || this.config.getEnableExtensionReloading()) { + // Only toggle the isActive state if we are actually going to disable it + // in the current session, or we haven't been initialized yet. + extension.isActive = true; + } + await this.maybeStartExtension(extension); + } +} + +function filterMcpConfig(original: MCPServerConfig): MCPServerConfig { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { trust, ...rest } = original; + return Object.freeze(rest); +} + +/** + * Recursively ensures that the owner has write permissions for all files + * and directories within the target path. + */ +async function makeWritableRecursive(targetPath: string): Promise { + const stats = await fs.promises.lstat(targetPath); + + if (stats.isDirectory()) { + // Ensure directory is rwx for the owner (0o700) + await fs.promises.chmod(targetPath, stats.mode | 0o700); + const children = await fs.promises.readdir(targetPath); + for (const child of children) { + await makeWritableRecursive(path.join(targetPath, child)); + } + } else if (stats.isFile()) { + // Ensure file is rw for the owner (0o600) + await fs.promises.chmod(targetPath, stats.mode | 0o600); + } +} + +export async function copyExtension( + source: string, + destination: string, +): Promise { + await fs.promises.cp(source, destination, { recursive: true }); + await makeWritableRecursive(destination); +} + +function getContextFileNames(config: ExtensionConfig): string[] { + if (!config.contextFileName) { + return ['GEMINI.md']; + } else if (!Array.isArray(config.contextFileName)) { + return [config.contextFileName]; + } + return config.contextFileName; +} + +/** + * Context file candidate path validation result. + */ +export interface ContextPathValidationResult { + isValid: boolean; + resolvedPath?: string; + errorMessage?: string; +} + +/** + * Validates a single context file candidate path against the extension root. + * Enforces directory boundary and path formatting by: + * - Requiring a non-empty string without null bytes. + * - Rejecting absolute paths (both POSIX and Windows formats). + * - Disallowing relative parent directory references (e.g. '..'), including URL-encoded variations. + * - Verifying that the resolved path stays strictly within the extension root directory. + */ +export function validateContextFilePath( + contextFileName: unknown, + effectiveExtensionPath: string, +): ContextPathValidationResult { + if (typeof contextFileName !== 'string' || !contextFileName.trim()) { + return { + isValid: false, + errorMessage: 'expected a non-empty string.', + }; + } + + if (contextFileName.includes('\0')) { + return { + isValid: false, + errorMessage: 'null bytes are not allowed.', + }; + } + + // Reject absolute paths upfront (both POSIX and Windows formats) + if ( + path.isAbsolute(contextFileName) || + path.win32.isAbsolute(contextFileName) + ) { + return { + isValid: false, + errorMessage: 'Absolute paths are not allowed.', + }; + } + + // Disallow relative parent directory references (e.g. ..) + let decodedFileName = contextFileName; + try { + decodedFileName = decodeURIComponent(contextFileName); + } catch { + debugLogger.debug( + `[ExtensionManager] Could not decode URI component in contextFileName: "${contextFileName}". Proceeding with undecoded name.`, + ); + } + + if (contextFileName.includes('..') || decodedFileName.includes('..')) { + return { + isValid: false, + errorMessage: + 'Relative parent directory references ("..") are not allowed.', + }; + } + + const resolvedExtensionRoot = path.resolve(effectiveExtensionPath); + const contextFilePath = path.resolve(resolvedExtensionRoot, contextFileName); + + // Verify that contextFilePath is strictly within resolvedExtensionRoot + if (!isSubpath(resolvedExtensionRoot, contextFilePath)) { + return { + isValid: false, + errorMessage: `escapes the extension directory boundary ("${resolvedExtensionRoot}").`, + }; + } + + return { + isValid: true, + resolvedPath: contextFilePath, + }; +} + +/** + * Resolves and validates context file paths defined in an extension configuration. + * Hardens path resolution and enforces directory boundaries by: + * - Validating each candidate context file path using validateContextFilePath. + * - Logging a warning when an invalid path is detected and skipping loading that context file. + * - Returning only paths that exist on disk. + */ +export function resolveContextFilePaths( + config: ExtensionConfig, + effectiveExtensionPath: string, +): string[] { + return getContextFileNames(config) + .map((contextFileName) => { + const validation = validateContextFilePath( + contextFileName, + effectiveExtensionPath, + ); + + if (!validation.isValid) { + debugLogger.warn( + `[ExtensionManager] Invalid contextFileName: "${contextFileName}" in extension "${config.name}". ${validation.errorMessage}`, + ); + return null; + } + + return validation.resolvedPath!; + }) + .filter( + (contextFilePath): contextFilePath is string => + contextFilePath !== null && fs.existsSync(contextFilePath), + ); +} + +function validateName(name: string) { + if (!/^[a-zA-Z0-9-]+$/.test(name)) { + throw new Error( + `Invalid extension name: "${name}". Only letters (a-z, A-Z), numbers (0-9), and dashes (-) are allowed.`, + ); + } +} + +export async function inferInstallMetadata( + source: string, + args: { + ref?: string; + autoUpdate?: boolean; + allowPreRelease?: boolean; + } = {}, +): Promise { + if ( + source.startsWith('http://') || + source.startsWith('https://') || + source.startsWith('git@') || + source.startsWith('sso://') || + source.startsWith('github:') || + source.startsWith('gitlab:') || + source.startsWith('ssh://') + ) { + return { + source, + type: 'git', + ref: args.ref, + autoUpdate: args.autoUpdate, + allowPreRelease: args.allowPreRelease, + }; + } else { + if (args.ref || args.autoUpdate) { + throw new Error( + '--ref and --auto-update are not applicable for local extensions.', + ); + } + try { + await stat(source); + return { + source, + type: 'local', + }; + } catch { + throw new Error('Install source not found.'); + } + } +} + +export function getExtensionId( + config: ExtensionConfig, + installMetadata?: ExtensionInstallMetadata, +): string { + // IDs are created by hashing details of the installation source in order to + // deduplicate extensions with conflicting names and also obfuscate any + // potentially sensitive information such as private git urls, system paths, + // or project names. + let idValue = config.name; + const githubUrlParts = + installMetadata && + (installMetadata.type === 'git' || + installMetadata.type === 'github-release') + ? tryParseGithubUrl(installMetadata.source) + : null; + if (githubUrlParts) { + // For github repos, we use the https URI to the repo as the ID. + idValue = `https://github.com/${githubUrlParts.owner}/${githubUrlParts.repo}`; + } else { + idValue = installMetadata?.source ?? config.name; + } + return hashValue(idValue); +} + +export function hashValue(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} diff --git a/packages/cli/src/config/extension.test.ts b/packages/cli/src/config/extension.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..c560993ec75aa25919bccf7be0849ed508374863 --- /dev/null +++ b/packages/cli/src/config/extension.test.ts @@ -0,0 +1,2362 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + vi, + type MockedFunction, + describe, + it, + expect, + beforeEach, + afterEach, + afterAll, +} from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { + type GeminiCLIExtension, + ExtensionUninstallEvent, + ExtensionDisableEvent, + ExtensionEnableEvent, + KeychainTokenStorage, + loadAgentsFromDirectory, + loadSkillsFromDir, + getRealPath, + normalizePath, +} from '@google/gemini-cli-core'; +import { + loadSettings, + createTestMergedSettings, + SettingScope, + resetSettingsCacheForTesting, +} from './settings.js'; +import { + isWorkspaceTrusted, + resetTrustedFoldersForTesting, +} from './trustedFolders.js'; +import { createExtension } from '../test-utils/createExtension.js'; +import { ExtensionEnablementManager } from './extensions/extensionEnablement.js'; +import { join } from 'node:path'; +import { + EXTENSIONS_CONFIG_FILENAME, + EXTENSIONS_DIRECTORY_NAME, + INSTALL_METADATA_FILENAME, +} from './extensions/variables.js'; +import { hashValue, ExtensionManager } from './extension-manager.js'; +import { ExtensionStorage } from './extensions/storage.js'; +import { INSTALL_WARNING_MESSAGE } from './extensions/consent.js'; +import type { ExtensionSetting } from './extensions/extensionSettings.js'; + +const mockGit = { + clone: vi.fn(), + getRemotes: vi.fn(), + fetch: vi.fn(), + checkout: vi.fn(), + listRemote: vi.fn(), + revparse: vi.fn(), + env: vi.fn(() => mockGit), + // Not a part of the actual API, but we need to use this to do the correct + // file system interactions. + path: vi.fn(), +}; + +const mockDownloadFromGithubRelease = vi.hoisted(() => vi.fn()); + +vi.mock('./extensions/github.js', async (importOriginal) => { + const original = + await importOriginal(); + return { + ...original, + downloadFromGitHubRelease: mockDownloadFromGithubRelease, + }; +}); + +vi.mock('simple-git', () => ({ + simpleGit: vi.fn((path: string) => { + mockGit.path.mockReturnValue(path); + return mockGit; + }), +})); + +const mockHomedir = vi.hoisted(() => vi.fn(() => '/tmp/mock-home')); + +vi.mock('os', async (importOriginal) => { + const mockedOs = await importOriginal(); + return { + ...mockedOs, + homedir: mockHomedir, + }; +}); + +vi.mock('./trustedFolders.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + isWorkspaceTrusted: vi.fn(), + }; +}); + +const mockLogExtensionEnable = vi.hoisted(() => vi.fn()); +const mockLogExtensionInstallEvent = vi.hoisted(() => vi.fn()); +const mockLogExtensionUninstall = vi.hoisted(() => vi.fn()); +const mockLogExtensionUpdateEvent = vi.hoisted(() => vi.fn()); +const mockLogExtensionDisable = vi.hoisted(() => vi.fn()); +const mockIntegrityManager = vi.hoisted(() => ({ + verify: vi.fn().mockResolvedValue('verified'), + store: vi.fn().mockResolvedValue(undefined), +})); +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + logExtensionEnable: mockLogExtensionEnable, + logExtensionInstallEvent: mockLogExtensionInstallEvent, + logExtensionUninstall: mockLogExtensionUninstall, + logExtensionUpdateEvent: mockLogExtensionUpdateEvent, + logExtensionDisable: mockLogExtensionDisable, + homedir: mockHomedir, + ExtensionEnableEvent: vi.fn(), + ExtensionInstallEvent: vi.fn(), + ExtensionUninstallEvent: vi.fn(), + ExtensionDisableEvent: vi.fn(), + ExtensionIntegrityManager: vi + .fn() + .mockImplementation(() => mockIntegrityManager), + KeychainTokenStorage: vi.fn().mockImplementation(() => ({ + getSecret: vi.fn(), + setSecret: vi.fn(), + deleteSecret: vi.fn(), + listSecrets: vi.fn(), + isAvailable: vi.fn().mockResolvedValue(true), + })), + loadAgentsFromDirectory: vi + .fn() + .mockImplementation(async () => ({ agents: [], errors: [] })), + loadSkillsFromDir: vi.fn().mockImplementation(async () => []), + }; +}); + +vi.mock('child_process', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + execSync: vi.fn(), + }; +}); + +interface MockKeychainStorage { + getSecret: ReturnType; + setSecret: ReturnType; + deleteSecret: ReturnType; + listSecrets: ReturnType; + isAvailable: ReturnType; +} + +describe('extension tests', () => { + let tempHomeDir: string; + let tempWorkspaceDir: string; + let userExtensionsDir: string; + let extensionManager: ExtensionManager; + let mockRequestConsent: MockedFunction<(consent: string) => Promise>; + let mockPromptForSettings: MockedFunction< + (setting: ExtensionSetting) => Promise + >; + let mockKeychainStorage: MockKeychainStorage; + let keychainData: Record; + + beforeEach(() => { + vi.clearAllMocks(); + resetSettingsCacheForTesting(); + keychainData = {}; + mockKeychainStorage = { + getSecret: vi + .fn() + .mockImplementation(async (key: string) => keychainData[key] || null), + setSecret: vi + .fn() + .mockImplementation(async (key: string, value: string) => { + keychainData[key] = value; + }), + deleteSecret: vi.fn().mockImplementation(async (key: string) => { + delete keychainData[key]; + }), + listSecrets: vi + .fn() + .mockImplementation(async () => Object.keys(keychainData)), + isAvailable: vi.fn().mockResolvedValue(true), + }; + ( + KeychainTokenStorage as unknown as ReturnType + ).mockImplementation(() => mockKeychainStorage); + vi.mocked(loadAgentsFromDirectory).mockResolvedValue({ + agents: [], + errors: [], + }); + vi.mocked(loadSkillsFromDir).mockResolvedValue([]); + tempHomeDir = getRealPath( + fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-cli-test-home-')), + ); + tempWorkspaceDir = getRealPath( + fs.mkdtempSync(path.join(tempHomeDir, 'gemini-cli-test-workspace-')), + ); + userExtensionsDir = path.join(tempHomeDir, EXTENSIONS_DIRECTORY_NAME); + mockRequestConsent = vi.fn(); + mockRequestConsent.mockResolvedValue(true); + mockPromptForSettings = vi.fn(); + mockPromptForSettings.mockResolvedValue(''); + fs.mkdirSync(userExtensionsDir, { recursive: true }); + vi.mocked(os.homedir).mockReturnValue(tempHomeDir); + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: true, + source: undefined, + }); + vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir); + const settings = loadSettings(tempWorkspaceDir).merged; + settings.experimental.extensionConfig = true; + extensionManager = new ExtensionManager({ + workspaceDir: tempWorkspaceDir, + requestConsent: mockRequestConsent, + requestSetting: mockPromptForSettings, + settings, + integrityManager: mockIntegrityManager, + }); + resetTrustedFoldersForTesting(); + }); + + afterEach(() => { + fs.rmSync(tempHomeDir, { recursive: true, force: true }); + fs.rmSync(tempWorkspaceDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + describe('loadExtensions', () => { + it('should include extension path in loaded extension', async () => { + const extensionDir = path.join(userExtensionsDir, 'test-extension'); + fs.mkdirSync(extensionDir, { recursive: true }); + + createExtension({ + extensionsDir: userExtensionsDir, + name: 'test-extension', + version: '1.0.0', + }); + + const extensions = await extensionManager.loadExtensions(); + expect(extensions).toHaveLength(1); + expect(extensions[0].path).toBe(extensionDir); + expect(extensions[0].name).toBe('test-extension'); + }); + + it('should skip loading the context file if a context file path is outside the extension directory and log a warning', async () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + createExtension({ + extensionsDir: userExtensionsDir, + name: 'boundary-extension', + version: '1.0.0', + contextFileName: '../secret.txt', + }); + + const extensions = await extensionManager.loadExtensions(); + expect(extensions).toHaveLength(1); + expect(extensions[0].contextFiles).toHaveLength(0); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Invalid contextFileName: "../secret.txt"'), + ); + consoleSpy.mockRestore(); + }); + + it('should load context file path when GEMINI.md is present', async () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'ext1', + version: '1.0.0', + addContextFile: true, + }); + createExtension({ + extensionsDir: userExtensionsDir, + name: 'ext2', + version: '2.0.0', + }); + + const extensions = await extensionManager.loadExtensions(); + + expect(extensions).toHaveLength(2); + const ext1 = extensions.find((e) => e.name === 'ext1'); + const ext2 = extensions.find((e) => e.name === 'ext2'); + expect(ext1?.contextFiles).toEqual([ + path.join(userExtensionsDir, 'ext1', 'GEMINI.md'), + ]); + expect(ext2?.contextFiles).toEqual([]); + }); + + it('should load context file path from the extension config', async () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'ext1', + version: '1.0.0', + addContextFile: false, + contextFileName: 'my-context-file.md', + }); + + const extensions = await extensionManager.loadExtensions(); + + expect(extensions).toHaveLength(1); + const ext1 = extensions.find((e) => e.name === 'ext1'); + expect(ext1?.contextFiles).toEqual([ + path.join(userExtensionsDir, 'ext1', 'my-context-file.md'), + ]); + }); + + it('should annotate disabled extensions', async () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'disabled-extension', + version: '1.0.0', + }); + createExtension({ + extensionsDir: userExtensionsDir, + name: 'enabled-extension', + version: '2.0.0', + }); + await extensionManager.loadExtensions(); + await extensionManager.disableExtension( + 'disabled-extension', + SettingScope.User, + ); + const extensions = extensionManager.getExtensions(); + expect(extensions).toHaveLength(2); + expect(extensions[0].name).toBe('disabled-extension'); + expect(extensions[0].isActive).toBe(false); + expect(extensions[1].name).toBe('enabled-extension'); + expect(extensions[1].isActive).toBe(true); + }); + + it('should hydrate variables', async () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'test-extension', + version: '1.0.0', + addContextFile: false, + contextFileName: undefined, + mcpServers: { + 'test-server': { + cwd: '${extensionPath}${/}server', + }, + }, + }); + + const extensions = await extensionManager.loadExtensions(); + expect(extensions).toHaveLength(1); + const expectedCwd = path.join( + userExtensionsDir, + 'test-extension', + 'server', + ); + expect(extensions[0].mcpServers?.['test-server'].cwd).toBe(expectedCwd); + }); + + it('should load a linked extension correctly', async () => { + const sourceExtDir = getRealPath( + createExtension({ + extensionsDir: tempWorkspaceDir, + name: 'my-linked-extension', + version: '1.0.0', + contextFileName: 'context.md', + }), + ); + fs.writeFileSync(path.join(sourceExtDir, 'context.md'), 'linked context'); + + await extensionManager.loadExtensions(); + const extension = await extensionManager.installOrUpdateExtension({ + source: sourceExtDir, + type: 'link', + }); + + expect(extension.name).toEqual('my-linked-extension'); + const extensions = extensionManager.getExtensions(); + expect(extensions).toHaveLength(1); + + const linkedExt = extensions[0]; + expect(linkedExt.name).toBe('my-linked-extension'); + + expect(linkedExt.path).toBe(sourceExtDir); + expect(linkedExt.installMetadata).toEqual({ + source: sourceExtDir, + type: 'link', + }); + expect(linkedExt.contextFiles).toEqual([ + path.join(sourceExtDir, 'context.md'), + ]); + }); + + it('should load extension policies from the policies directory', async () => { + const extDir = createExtension({ + extensionsDir: userExtensionsDir, + name: 'policy-extension', + version: '1.0.0', + }); + + const policiesDir = path.join(extDir, 'policies'); + fs.mkdirSync(policiesDir); + + const policiesContent = ` +[[rule]] +toolName = "deny_tool" +decision = "deny" +priority = 500 + +[[rule]] +toolName = "ask_tool" +decision = "ask_user" +priority = 100 +`; + fs.writeFileSync( + path.join(policiesDir, 'policies.toml'), + policiesContent, + ); + + const extensions = await extensionManager.loadExtensions(); + expect(extensions).toHaveLength(1); + const extension = extensions[0]; + + expect(extension.rules).toBeDefined(); + expect(extension.rules).toHaveLength(2); + expect( + extension.rules!.find((r) => r.toolName === 'deny_tool')?.decision, + ).toBe('deny'); + expect( + extension.rules!.find((r) => r.toolName === 'ask_tool')?.decision, + ).toBe('ask_user'); + // Verify source is prefixed + expect(extension.rules![0].source).toContain( + 'Extension (policy-extension):', + ); + }); + + it('should ignore ALLOW rules and YOLO mode from extension policies for security', async () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const extDir = createExtension({ + extensionsDir: userExtensionsDir, + name: 'security-test-extension', + version: '1.0.0', + }); + + const policiesDir = path.join(extDir, 'policies'); + fs.mkdirSync(policiesDir); + + const policiesContent = ` +[[rule]] +toolName = "allow_tool" +decision = "allow" +priority = 100 + +[[rule]] +toolName = "yolo_tool" +decision = "ask_user" +priority = 100 +modes = ["yolo"] + +[[safety_checker]] +toolName = "yolo_check" +priority = 100 +modes = ["yolo"] +[safety_checker.checker] +type = "external" +name = "yolo-checker" +`; + fs.writeFileSync( + path.join(policiesDir, 'policies.toml'), + policiesContent, + ); + + const extensions = await extensionManager.loadExtensions(); + expect(extensions).toHaveLength(1); + const extension = extensions[0]; + + // ALLOW rules and YOLO rules/checkers should be filtered out + expect(extension.rules).toBeDefined(); + expect(extension.rules).toHaveLength(0); + expect(extension.checkers).toBeDefined(); + expect(extension.checkers).toHaveLength(0); + + // Should have logged warnings + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('attempted to contribute an ALLOW rule'), + ); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('attempted to contribute a rule for YOLO mode'), + ); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'attempted to contribute a safety checker for YOLO mode', + ), + ); + consoleSpy.mockRestore(); + }); + + it('should hydrate ${extensionPath} correctly for linked extensions', async () => { + const sourceExtDir = getRealPath( + createExtension({ + extensionsDir: tempWorkspaceDir, + name: 'my-linked-extension-with-path', + version: '1.0.0', + mcpServers: { + 'test-server': { + command: 'node', + args: ['${extensionPath}${/}server${/}index.js'], + cwd: '${extensionPath}${/}server', + }, + }, + }), + ); + + await extensionManager.loadExtensions(); + await extensionManager.installOrUpdateExtension({ + source: sourceExtDir, + type: 'link', + }); + + const extensions = extensionManager.getExtensions(); + expect(extensions).toHaveLength(1); + expect(extensions[0].mcpServers?.['test-server'].cwd).toBe( + path.join(sourceExtDir, 'server'), + ); + expect(extensions[0].mcpServers?.['test-server'].args).toEqual([ + path.join(sourceExtDir, 'server', 'index.js'), + ]); + }); + + it('should resolve environment variables in extension configuration', async () => { + process.env['TEST_API_KEY'] = 'test-api-key-123'; + process.env['TEST_DB_URL'] = 'postgresql://localhost:5432/testdb'; + + try { + const userExtensionsDir = path.join( + tempHomeDir, + EXTENSIONS_DIRECTORY_NAME, + ); + fs.mkdirSync(userExtensionsDir, { recursive: true }); + + const extDir = path.join(userExtensionsDir, 'test-extension'); + fs.mkdirSync(extDir); + + // Write config to a separate file for clarity and good practices + const configPath = path.join(extDir, EXTENSIONS_CONFIG_FILENAME); + const extensionConfig = { + name: 'test-extension', + version: '1.0.0', + mcpServers: { + 'test-server': { + command: 'node', + args: ['server.js'], + env: { + API_KEY: '$TEST_API_KEY', + DATABASE_URL: '${TEST_DB_URL}', + STATIC_VALUE: 'no-substitution', + }, + }, + }, + }; + fs.writeFileSync(configPath, JSON.stringify(extensionConfig)); + + const extensions = await extensionManager.loadExtensions(); + + expect(extensions).toHaveLength(1); + const extension = extensions[0]; + expect(extension.name).toBe('test-extension'); + expect(extension.mcpServers).toBeDefined(); + + const serverConfig = extension.mcpServers?.['test-server']; + expect(serverConfig).toBeDefined(); + expect(serverConfig?.env).toBeDefined(); + expect(serverConfig?.env?.['API_KEY']).toBe('test-api-key-123'); + expect(serverConfig?.env?.['DATABASE_URL']).toBe( + 'postgresql://localhost:5432/testdb', + ); + expect(serverConfig?.env?.['STATIC_VALUE']).toBe('no-substitution'); + } finally { + delete process.env['TEST_API_KEY']; + delete process.env['TEST_DB_URL']; + } + }); + + it('should resolve environment variables from an extension .env file', async () => { + const extDir = createExtension({ + extensionsDir: userExtensionsDir, + name: 'test-extension', + version: '1.0.0', + mcpServers: { + 'test-server': { + command: 'node', + args: ['server.js'], + env: { + API_KEY: '$MY_API_KEY', + STATIC_VALUE: 'no-substitution', + }, + }, + }, + settings: [ + { + name: 'My API Key', + description: 'API key for testing.', + envVar: 'MY_API_KEY', + }, + ], + }); + + const envFilePath = path.join(extDir, '.env'); + fs.writeFileSync(envFilePath, 'MY_API_KEY=test-key-from-file\n'); + + const extensions = await extensionManager.loadExtensions(); + + expect(extensions).toHaveLength(1); + const extension = extensions[0]; + const serverConfig = extension.mcpServers!['test-server']; + expect(serverConfig.env).toBeDefined(); + expect(serverConfig.env!['API_KEY']).toBe('test-key-from-file'); + expect(serverConfig.env!['STATIC_VALUE']).toBe('no-substitution'); + }); + + it('should handle missing environment variables gracefully', async () => { + const userExtensionsDir = path.join( + tempHomeDir, + EXTENSIONS_DIRECTORY_NAME, + ); + fs.mkdirSync(userExtensionsDir, { recursive: true }); + + const extDir = path.join(userExtensionsDir, 'test-extension'); + fs.mkdirSync(extDir); + + const extensionConfig = { + name: 'test-extension', + version: '1.0.0', + mcpServers: { + 'test-server': { + command: 'node', + args: ['server.js'], + env: { + MISSING_VAR: '$UNDEFINED_ENV_VAR', + MISSING_VAR_BRACES: '${ALSO_UNDEFINED}', + }, + }, + }, + }; + + fs.writeFileSync( + path.join(extDir, EXTENSIONS_CONFIG_FILENAME), + JSON.stringify(extensionConfig), + ); + + const extensions = await extensionManager.loadExtensions(); + + expect(extensions).toHaveLength(1); + const extension = extensions[0]; + const serverConfig = extension.mcpServers!['test-server']; + expect(serverConfig.env).toBeDefined(); + expect(serverConfig.env!['MISSING_VAR']).toBe('$UNDEFINED_ENV_VAR'); + expect(serverConfig.env!['MISSING_VAR_BRACES']).toBe('${ALSO_UNDEFINED}'); + }); + + it('should skip an extension with invalid JSON config and log an error', async () => { + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + // Good extension + createExtension({ + extensionsDir: userExtensionsDir, + name: 'good-ext', + version: '1.0.0', + }); + + // Bad extension + const badExtDir = path.join(userExtensionsDir, 'bad-ext'); + fs.mkdirSync(badExtDir, { recursive: true }); + const badConfigPath = path.join(badExtDir, EXTENSIONS_CONFIG_FILENAME); + fs.writeFileSync(badConfigPath, '{ "name": "bad-ext"'); // Malformed + + const extensions = await extensionManager.loadExtensions(); + + expect(extensions).toHaveLength(1); + expect(extensions[0].name).toBe('good-ext'); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining( + `Warning: Skipping extension in ${badExtDir}: Failed to load extension config from ${badConfigPath}`, + ), + ); + + consoleSpy.mockRestore(); + }); + + it('should skip an extension with missing "name" in config and log an error', async () => { + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + // Good extension + createExtension({ + extensionsDir: userExtensionsDir, + name: 'good-ext', + version: '1.0.0', + }); + + // Bad extension + const badExtDir = path.join(userExtensionsDir, 'bad-ext-no-name'); + fs.mkdirSync(badExtDir, { recursive: true }); + const badConfigPath = path.join(badExtDir, EXTENSIONS_CONFIG_FILENAME); + fs.writeFileSync(badConfigPath, JSON.stringify({ version: '1.0.0' })); + + const extensions = await extensionManager.loadExtensions(); + + expect(extensions).toHaveLength(1); + expect(extensions[0].name).toBe('good-ext'); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining( + `Warning: Skipping extension in ${badExtDir}: Failed to load extension config from ${badConfigPath}: Invalid configuration in ${badConfigPath}: missing "name"`, + ), + ); + + consoleSpy.mockRestore(); + }); + + it('should filter trust out of mcp servers', async () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'test-extension', + version: '1.0.0', + mcpServers: { + 'test-server': { + command: 'node', + args: ['server.js'], + trust: true, + }, + }, + }); + + const extensions = await extensionManager.loadExtensions(); + expect(extensions).toHaveLength(1); + expect(extensions[0].mcpServers?.['test-server'].trust).toBeUndefined(); + }); + + it('should log an error for invalid extension names during loading', async () => { + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + createExtension({ + extensionsDir: userExtensionsDir, + name: 'bad_name', + version: '1.0.0', + }); + const extensions = await extensionManager.loadExtensions(); + const extension = extensions.find((e) => e.name === 'bad_name'); + + expect(extension).toBeUndefined(); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Invalid extension name: "bad_name"'), + ); + consoleSpy.mockRestore(); + }); + + it('should not load github extensions and log a warning if blockGitExtensions is set', async () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + createExtension({ + extensionsDir: userExtensionsDir, + name: 'my-ext', + version: '1.0.0', + installMetadata: { + type: 'git', + source: 'http://somehost.com/foo/bar', + }, + }); + + const blockGitExtensionsSetting = createTestMergedSettings({ + security: { blockGitExtensions: true }, + }); + extensionManager = new ExtensionManager({ + workspaceDir: tempWorkspaceDir, + requestConsent: mockRequestConsent, + requestSetting: mockPromptForSettings, + settings: blockGitExtensionsSetting, + integrityManager: mockIntegrityManager, + }); + const extensions = await extensionManager.loadExtensions(); + const extension = extensions.find((e) => e.name === 'my-ext'); + + expect(extension).toBeUndefined(); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'Extensions from remote sources is disallowed by your current settings.', + ), + ); + consoleSpy.mockRestore(); + }); + + it('should load allowed extensions if the allowlist is set.', async () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'my-ext', + version: '1.0.0', + installMetadata: { + type: 'git', + source: 'http://allowed.com/foo/bar', + }, + }); + const extensionAllowlistSetting = createTestMergedSettings({ + security: { + allowedExtensions: ['\\b(https?:\\/\\/)?(www\\.)?allowed\\.com\\S*'], + }, + }); + extensionManager = new ExtensionManager({ + workspaceDir: tempWorkspaceDir, + requestConsent: mockRequestConsent, + requestSetting: mockPromptForSettings, + settings: extensionAllowlistSetting, + integrityManager: mockIntegrityManager, + }); + const extensions = await extensionManager.loadExtensions(); + + expect(extensions).toHaveLength(1); + expect(extensions[0].name).toBe('my-ext'); + }); + + it('should not load disallowed extensions and log a warning if the allowlist is set.', async () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + createExtension({ + extensionsDir: userExtensionsDir, + name: 'my-ext', + version: '1.0.0', + installMetadata: { + type: 'git', + source: 'http://notallowed.com/foo/bar', + }, + }); + const extensionAllowlistSetting = createTestMergedSettings({ + security: { + allowedExtensions: ['\\b(https?:\\/\\/)?(www\\.)?allowed\\.com\\S*'], + }, + }); + extensionManager = new ExtensionManager({ + workspaceDir: tempWorkspaceDir, + requestConsent: mockRequestConsent, + requestSetting: mockPromptForSettings, + settings: extensionAllowlistSetting, + integrityManager: mockIntegrityManager, + }); + const extensions = await extensionManager.loadExtensions(); + const extension = extensions.find((e) => e.name === 'my-ext'); + + expect(extension).toBeUndefined(); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'This extension is not allowed by the "allowedExtensions" security setting', + ), + ); + consoleSpy.mockRestore(); + }); + + it('should not load any extensions if admin.extensions.enabled is false', async () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'test-extension', + version: '1.0.0', + }); + const loadedSettings = loadSettings(tempWorkspaceDir).merged; + loadedSettings.admin.extensions.enabled = false; + + extensionManager = new ExtensionManager({ + workspaceDir: tempWorkspaceDir, + requestConsent: mockRequestConsent, + requestSetting: mockPromptForSettings, + settings: loadedSettings, + integrityManager: mockIntegrityManager, + }); + + const extensions = await extensionManager.loadExtensions(); + expect(extensions).toEqual([]); + }); + + it('should not load mcpServers if admin.mcp.enabled is false', async () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'test-extension', + version: '1.0.0', + mcpServers: { + 'test-server': { command: 'echo', args: ['hello'] }, + }, + }); + const loadedSettings = loadSettings(tempWorkspaceDir).merged; + loadedSettings.admin.mcp.enabled = false; + + extensionManager = new ExtensionManager({ + workspaceDir: tempWorkspaceDir, + requestConsent: mockRequestConsent, + requestSetting: mockPromptForSettings, + settings: loadedSettings, + integrityManager: mockIntegrityManager, + }); + + const extensions = await extensionManager.loadExtensions(); + expect(extensions).toHaveLength(1); + expect(extensions[0].mcpServers).toBeUndefined(); + }); + + it('should load mcpServers if admin.mcp.enabled is true', async () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'test-extension', + version: '1.0.0', + mcpServers: { + 'test-server': { command: 'echo', args: ['hello'] }, + }, + }); + const loadedSettings = loadSettings(tempWorkspaceDir).merged; + loadedSettings.admin.mcp.enabled = true; + + extensionManager = new ExtensionManager({ + workspaceDir: tempWorkspaceDir, + requestConsent: mockRequestConsent, + requestSetting: mockPromptForSettings, + settings: loadedSettings, + integrityManager: mockIntegrityManager, + }); + + const extensions = await extensionManager.loadExtensions(); + expect(extensions).toHaveLength(1); + expect(extensions[0].mcpServers).toEqual({ + 'test-server': { command: 'echo', args: ['hello'] }, + }); + }); + + describe('id generation', () => { + it.each([ + { + description: 'should generate id from source for non-github git urls', + installMetadata: { + type: 'git' as const, + source: 'http://somehost.com/foo/bar', + }, + expectedIdSource: 'http://somehost.com/foo/bar', + }, + { + description: + 'should generate id from owner/repo for github http urls', + installMetadata: { + type: 'git' as const, + source: 'http://github.com/foo/bar', + }, + expectedIdSource: 'https://github.com/foo/bar', + }, + { + description: 'should generate id from owner/repo for github ssh urls', + installMetadata: { + type: 'git' as const, + source: 'git@github.com:foo/bar', + }, + expectedIdSource: 'https://github.com/foo/bar', + }, + { + description: + 'should generate id from source for github-release extension', + installMetadata: { + type: 'github-release' as const, + source: 'https://github.com/foo/bar', + }, + expectedIdSource: 'https://github.com/foo/bar', + }, + { + description: + 'should generate id from the original source for local extension', + installMetadata: { + type: 'local' as const, + source: '/some/path', + }, + expectedIdSource: '/some/path', + }, + ])('$description', async ({ installMetadata, expectedIdSource }) => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'my-ext', + version: '1.0.0', + installMetadata, + }); + const extensions = await extensionManager.loadExtensions(); + const extension = extensions.find((e) => e.name === 'my-ext'); + expect(extension?.id).toBe(hashValue(expectedIdSource)); + }); + + it('should generate id from the original source for linked extensions', async () => { + const extDevelopmentDir = path.join(tempHomeDir, 'local_extensions'); + const actualExtensionDir = getRealPath( + createExtension({ + extensionsDir: extDevelopmentDir, + name: 'link-ext-name', + version: '1.0.0', + }), + ); + await extensionManager.loadExtensions(); + await extensionManager.installOrUpdateExtension({ + type: 'link', + source: actualExtensionDir, + }); + + const extension = extensionManager + .getExtensions() + .find((e) => e.name === 'link-ext-name'); + expect(extension?.id).toBe(hashValue(actualExtensionDir)); + }); + + it('should generate id from name for extension with no install metadata', async () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'no-meta-name', + version: '1.0.0', + }); + const extensions = await extensionManager.loadExtensions(); + const extension = extensions.find((e) => e.name === 'no-meta-name'); + expect(extension?.id).toBe(hashValue('no-meta-name')); + }); + + it('should load extension hooks and hydrate variables', async () => { + const extDir = createExtension({ + extensionsDir: userExtensionsDir, + name: 'hook-extension', + version: '1.0.0', + }); + + const hooksDir = path.join(extDir, 'hooks'); + fs.mkdirSync(hooksDir); + + const hooksConfig = { + enabled: false, + hooks: { + BeforeTool: [ + { + matcher: '.*', + hooks: [ + { + type: 'command', + command: 'echo ${extensionPath}', + }, + ], + }, + ], + }, + }; + + fs.writeFileSync( + path.join(hooksDir, 'hooks.json'), + JSON.stringify(hooksConfig), + ); + + const settings = loadSettings(tempWorkspaceDir).merged; + settings.hooksConfig.enabled = true; + + extensionManager = new ExtensionManager({ + workspaceDir: tempWorkspaceDir, + requestConsent: mockRequestConsent, + requestSetting: mockPromptForSettings, + settings, + integrityManager: mockIntegrityManager, + }); + + const extensions = await extensionManager.loadExtensions(); + expect(extensions).toHaveLength(1); + const extension = extensions[0]; + + expect(extension.hooks).toBeDefined(); + expect(extension.hooks?.BeforeTool).toHaveLength(1); + expect(extension.hooks?.BeforeTool?.[0].hooks[0].command).toBe( + `echo ${extDir}`, + ); + }); + + it('should not load hooks if hooks.enabled is false', async () => { + const extDir = createExtension({ + extensionsDir: userExtensionsDir, + name: 'hook-extension-disabled', + version: '1.0.0', + }); + + const hooksDir = path.join(extDir, 'hooks'); + fs.mkdirSync(hooksDir); + fs.writeFileSync( + path.join(hooksDir, 'hooks.json'), + JSON.stringify({ hooks: { BeforeTool: [] }, enabled: false }), + ); + + const settings = loadSettings(tempWorkspaceDir).merged; + settings.hooksConfig.enabled = false; + + extensionManager = new ExtensionManager({ + workspaceDir: tempWorkspaceDir, + requestConsent: mockRequestConsent, + requestSetting: mockPromptForSettings, + settings, + integrityManager: mockIntegrityManager, + }); + + const extensions = await extensionManager.loadExtensions(); + expect(extensions).toHaveLength(1); + expect(extensions[0].hooks).toBeUndefined(); + }); + + it('should warn about hooks during installation', async () => { + const requestConsentSpy = vi.fn().mockResolvedValue(true); + extensionManager.setRequestConsent(requestConsentSpy); + + const sourceExtDir = path.join( + tempWorkspaceDir, + 'hook-extension-source', + ); + fs.mkdirSync(sourceExtDir, { recursive: true }); + + const hooksDir = path.join(sourceExtDir, 'hooks'); + fs.mkdirSync(hooksDir); + fs.writeFileSync( + path.join(hooksDir, 'hooks.json'), + JSON.stringify({ hooks: {} }), + ); + + fs.writeFileSync( + path.join(sourceExtDir, 'gemini-extension.json'), + JSON.stringify({ + name: 'hook-extension-install', + version: '1.0.0', + }), + ); + + await extensionManager.loadExtensions(); + await extensionManager.installOrUpdateExtension({ + source: sourceExtDir, + type: 'local', + }); + + expect(requestConsentSpy).toHaveBeenCalledWith( + expect.stringContaining('⚠️ This extension contains Hooks'), + ); + }); + }); + }); + + describe('installExtension', () => { + it('should install an extension from a local path', async () => { + const sourceExtDir = getRealPath( + createExtension({ + extensionsDir: tempHomeDir, + name: 'my-local-extension', + version: '1.0.0', + }), + ); + const targetExtDir = path.join(userExtensionsDir, 'my-local-extension'); + const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME); + + await extensionManager.loadExtensions(); + await extensionManager.installOrUpdateExtension({ + source: sourceExtDir, + type: 'local', + }); + + expect(fs.existsSync(targetExtDir)).toBe(true); + expect(fs.existsSync(metadataPath)).toBe(true); + const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8')); + expect(metadata).toEqual({ + source: sourceExtDir, + type: 'local', + }); + fs.rmSync(targetExtDir, { recursive: true, force: true }); + }); + + it('should throw an error if the extension already exists', async () => { + const sourceExtDir = createExtension({ + extensionsDir: tempHomeDir, + name: 'my-local-extension', + version: '1.0.0', + }); + await extensionManager.loadExtensions(); + await extensionManager.installOrUpdateExtension({ + source: sourceExtDir, + type: 'local', + }); + await expect( + extensionManager.installOrUpdateExtension({ + source: sourceExtDir, + type: 'local', + }), + ).rejects.toThrow( + 'Extension "my-local-extension" is already installed. Please uninstall it first.', + ); + }); + + it('should throw an error and cleanup if gemini-extension.json is missing', async () => { + const sourceExtDir = getRealPath(path.join(tempHomeDir, 'bad-extension')); + fs.mkdirSync(sourceExtDir, { recursive: true }); + const configPath = path.join(sourceExtDir, EXTENSIONS_CONFIG_FILENAME); + + await expect( + extensionManager.installOrUpdateExtension({ + source: sourceExtDir, + type: 'local', + }), + ).rejects.toThrow(`Configuration file not found at ${configPath}`); + + const targetExtDir = path.join(userExtensionsDir, 'bad-extension'); + expect(fs.existsSync(targetExtDir)).toBe(false); + }); + + it('should throw an error for invalid JSON in gemini-extension.json', async () => { + const sourceExtDir = getRealPath(path.join(tempHomeDir, 'bad-json-ext')); + fs.mkdirSync(sourceExtDir, { recursive: true }); + const configPath = path.join(sourceExtDir, EXTENSIONS_CONFIG_FILENAME); + fs.writeFileSync(configPath, '{ "name": "bad-json", "version": "1.0.0"'); // Malformed JSON + + await expect( + extensionManager.installOrUpdateExtension({ + source: sourceExtDir, + type: 'local', + }), + ).rejects.toThrow(`Failed to load extension config from ${configPath}`); + }); + + it('should throw an error for missing name in gemini-extension.json', async () => { + const sourceExtDir = getRealPath( + createExtension({ + extensionsDir: tempHomeDir, + name: 'missing-name-ext', + version: '1.0.0', + }), + ); + const configPath = path.join(sourceExtDir, EXTENSIONS_CONFIG_FILENAME); + // Overwrite with invalid config + fs.writeFileSync(configPath, JSON.stringify({ version: '1.0.0' })); + + await expect( + extensionManager.installOrUpdateExtension({ + source: sourceExtDir, + type: 'local', + }), + ).rejects.toThrow( + `Invalid configuration in ${configPath}: missing "name"`, + ); + }); + + it('should install an extension from a git URL', async () => { + const gitUrl = 'https://somehost.com/somerepo.git'; + const extensionName = 'some-extension'; + const targetExtDir = path.join(userExtensionsDir, extensionName); + const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME); + + mockGit.clone.mockImplementation(async (_, destination) => { + fs.mkdirSync(path.join(mockGit.path(), destination), { + recursive: true, + }); + fs.writeFileSync( + path.join(mockGit.path(), destination, EXTENSIONS_CONFIG_FILENAME), + JSON.stringify({ name: extensionName, version: '1.0.0' }), + ); + }); + mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]); + mockDownloadFromGithubRelease.mockResolvedValue({ + success: false, + failureReason: 'no release data', + type: 'github-release', + }); + + await extensionManager.loadExtensions(); + await extensionManager.installOrUpdateExtension({ + source: gitUrl, + type: 'git', + }); + + expect(fs.existsSync(targetExtDir)).toBe(true); + expect(fs.existsSync(metadataPath)).toBe(true); + const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8')); + expect(metadata).toEqual({ + source: gitUrl, + type: 'git', + }); + }); + + it('should install a linked extension', async () => { + const sourceExtDir = getRealPath( + createExtension({ + extensionsDir: tempHomeDir, + name: 'my-linked-extension', + version: '1.0.0', + }), + ); + const targetExtDir = path.join(userExtensionsDir, 'my-linked-extension'); + const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME); + const configPath = path.join(targetExtDir, EXTENSIONS_CONFIG_FILENAME); + + await extensionManager.loadExtensions(); + await extensionManager.installOrUpdateExtension({ + source: sourceExtDir, + type: 'link', + }); + + expect(fs.existsSync(targetExtDir)).toBe(true); + expect(fs.existsSync(metadataPath)).toBe(true); + + expect(fs.existsSync(configPath)).toBe(false); + + const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8')); + expect(metadata).toEqual({ + source: sourceExtDir, + type: 'link', + }); + fs.rmSync(targetExtDir, { recursive: true, force: true }); + }); + + it('should not install a github extension if blockGitExtensions is set', async () => { + const gitUrl = 'https://somehost.com/somerepo.git'; + const blockGitExtensionsSetting = createTestMergedSettings({ + security: { blockGitExtensions: true }, + }); + extensionManager = new ExtensionManager({ + workspaceDir: tempWorkspaceDir, + requestConsent: mockRequestConsent, + requestSetting: mockPromptForSettings, + settings: blockGitExtensionsSetting, + integrityManager: mockIntegrityManager, + }); + await extensionManager.loadExtensions(); + await expect( + extensionManager.installOrUpdateExtension({ + source: gitUrl, + type: 'git', + }), + ).rejects.toThrow( + 'Installing extensions from remote sources is disallowed by your current settings.', + ); + }); + + it('should not install a disallowed extension if the allowlist is set', async () => { + const gitUrl = 'https://somehost.com/somerepo.git'; + const allowedExtensionsSetting = createTestMergedSettings({ + security: { + allowedExtensions: ['\\b(https?:\\/\\/)?(www\\.)?allowed\\.com\\S*'], + }, + }); + extensionManager = new ExtensionManager({ + workspaceDir: tempWorkspaceDir, + requestConsent: mockRequestConsent, + requestSetting: mockPromptForSettings, + settings: allowedExtensionsSetting, + integrityManager: mockIntegrityManager, + }); + await extensionManager.loadExtensions(); + await expect( + extensionManager.installOrUpdateExtension({ + source: gitUrl, + type: 'git', + }), + ).rejects.toThrow( + `Installing extension from source "${gitUrl}" is not allowed by the "allowedExtensions" security setting.`, + ); + }); + + it('should prompt for trust if workspace is not trusted', async () => { + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: false, + source: undefined, + }); + const sourceExtDir = createExtension({ + extensionsDir: tempHomeDir, + name: 'my-local-extension', + version: '1.0.0', + }); + + await extensionManager.loadExtensions(); + await extensionManager.installOrUpdateExtension({ + source: sourceExtDir, + type: 'local', + }); + + expect(mockRequestConsent).toHaveBeenCalledWith( + `The current workspace at "${tempWorkspaceDir}" is not trusted. Do you want to trust this workspace to install extensions?`, + ); + }); + + it('should not install if user denies trust', async () => { + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: false, + source: undefined, + }); + mockRequestConsent.mockImplementation(async (message) => { + if ( + message.includes( + 'is not trusted. Do you want to trust this workspace to install extensions?', + ) + ) { + return false; + } + return true; + }); + const sourceExtDir = createExtension({ + extensionsDir: tempHomeDir, + name: 'my-local-extension', + version: '1.0.0', + }); + + await extensionManager.loadExtensions(); + await expect( + extensionManager.installOrUpdateExtension({ + source: sourceExtDir, + type: 'local', + }), + ).rejects.toThrow( + `Could not install extension because the current workspace at ${tempWorkspaceDir} is not trusted.`, + ); + }); + + it('should add the workspace to trusted folders if user consents', async () => { + const trustedFoldersPath = path.join( + tempHomeDir, + '.gemini', + 'trustedFolders.json', + ); + vi.stubEnv('GEMINI_CLI_TRUSTED_FOLDERS_PATH', trustedFoldersPath); + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: false, + source: undefined, + }); + const sourceExtDir = createExtension({ + extensionsDir: tempHomeDir, + name: 'my-local-extension', + version: '1.0.0', + }); + await extensionManager.loadExtensions(); + await extensionManager.installOrUpdateExtension({ + source: sourceExtDir, + type: 'local', + }); + expect(fs.existsSync(trustedFoldersPath)).toBe(true); + const trustedFolders = JSON.parse( + fs.readFileSync(trustedFoldersPath, 'utf-8'), + ); + expect(trustedFolders[normalizePath(tempWorkspaceDir)]).toBe( + 'TRUST_FOLDER', + ); + }); + + describe.each([true, false])( + 'with previous extension config: %s', + (isUpdate: boolean) => { + let sourceExtDir: string; + + beforeEach(async () => { + sourceExtDir = createExtension({ + extensionsDir: tempHomeDir, + name: 'my-local-extension', + version: '1.1.0', + }); + await extensionManager.loadExtensions(); + if (isUpdate) { + await extensionManager.installOrUpdateExtension({ + source: sourceExtDir, + type: 'local', + }); + } + // Clears out any calls to mocks from the above function calls. + vi.clearAllMocks(); + }); + + it(`should log an ${isUpdate ? 'update' : 'install'} event to clearcut on success`, async () => { + await extensionManager.installOrUpdateExtension( + { source: sourceExtDir, type: 'local' }, + isUpdate + ? { + name: 'my-local-extension', + version: '1.0.0', + } + : undefined, + ); + + if (isUpdate) { + expect(mockLogExtensionUpdateEvent).toHaveBeenCalled(); + expect(mockLogExtensionInstallEvent).not.toHaveBeenCalled(); + } else { + expect(mockLogExtensionInstallEvent).toHaveBeenCalled(); + expect(mockLogExtensionUpdateEvent).not.toHaveBeenCalled(); + } + }); + + it(`should ${isUpdate ? 'not ' : ''} alter the extension enablement configuration`, async () => { + const enablementManager = new ExtensionEnablementManager(); + enablementManager.enable('my-local-extension', true, '/some/scope'); + + await extensionManager.installOrUpdateExtension( + { source: sourceExtDir, type: 'local' }, + isUpdate + ? { + name: 'my-local-extension', + version: '1.0.0', + } + : undefined, + ); + + const config = enablementManager.readConfig()['my-local-extension']; + if (isUpdate) { + expect(config).not.toBeUndefined(); + expect(config.overrides).toContain('/some/scope/*'); + } else { + expect(config).not.toContain('/some/scope/*'); + } + }); + }, + ); + + it('should show users information on their ansi escaped mcp servers when installing', async () => { + const sourceExtDir = createExtension({ + extensionsDir: tempHomeDir, + name: 'my-local-extension', + version: '1.0.0', + mcpServers: { + 'test-server': { + command: 'node dobadthing \u001b[12D\u001b[K', + args: ['server.js'], + description: 'a local mcp server', + }, + 'test-server-2': { + description: 'a remote mcp server', + httpUrl: 'https://google.com', + }, + }, + }); + + await extensionManager.loadExtensions(); + await expect( + extensionManager.installOrUpdateExtension({ + source: sourceExtDir, + type: 'local', + }), + ).resolves.toMatchObject({ + name: 'my-local-extension', + }); + + expect(mockRequestConsent).toHaveBeenCalledWith( + `Installing extension "my-local-extension". +This extension will run the following MCP servers: + * test-server (local): node dobadthing \\u001b[12D\\u001b[K server.js + Description: a local mcp server + Config signature: 2e78d87d1e8ad2180a3b7c7cadf540ccd376375312fa9d94371b1b4ea486d992 + * test-server-2 (remote): https://google.com + Description: a remote mcp server + Config signature: 08fe6539fb2663eceb2f28aeca2ee49ced91a937780986616f989b496f7c8e83 + +${INSTALL_WARNING_MESSAGE}`, + ); + }); + + it('should continue installation if user accepts prompt for local extension with mcp servers', async () => { + const sourceExtDir = createExtension({ + extensionsDir: tempHomeDir, + name: 'my-local-extension', + version: '1.0.0', + mcpServers: { + 'test-server': { + command: 'node', + args: ['server.js'], + }, + }, + }); + + await extensionManager.loadExtensions(); + await expect( + extensionManager.installOrUpdateExtension({ + source: sourceExtDir, + type: 'local', + }), + ).resolves.toMatchObject({ name: 'my-local-extension' }); + }); + + it('should cancel installation if user declines prompt for local extension with mcp servers', async () => { + const sourceExtDir = createExtension({ + extensionsDir: tempHomeDir, + name: 'my-local-extension', + version: '1.0.0', + mcpServers: { + 'test-server': { + command: 'node', + args: ['server.js'], + }, + }, + }); + mockRequestConsent.mockResolvedValue(false); + await extensionManager.loadExtensions(); + await expect( + extensionManager.installOrUpdateExtension({ + source: sourceExtDir, + type: 'local', + }), + ).rejects.toThrow('Installation cancelled for "my-local-extension".'); + }); + + it('should save the autoUpdate flag to the install metadata', async () => { + const sourceExtDir = getRealPath( + createExtension({ + extensionsDir: tempHomeDir, + name: 'my-local-extension', + version: '1.0.0', + }), + ); + const targetExtDir = path.join(userExtensionsDir, 'my-local-extension'); + const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME); + + await extensionManager.loadExtensions(); + await extensionManager.installOrUpdateExtension({ + source: sourceExtDir, + type: 'local', + autoUpdate: true, + }); + + expect(fs.existsSync(targetExtDir)).toBe(true); + expect(fs.existsSync(metadataPath)).toBe(true); + const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8')); + expect(metadata).toEqual({ + source: sourceExtDir, + type: 'local', + autoUpdate: true, + }); + fs.rmSync(targetExtDir, { recursive: true, force: true }); + }); + + it('should ignore consent flow if not required', async () => { + const sourceExtDir = createExtension({ + extensionsDir: tempHomeDir, + name: 'my-local-extension', + version: '1.0.0', + mcpServers: { + 'test-server': { + command: 'node', + args: ['server.js'], + }, + }, + }); + + await extensionManager.loadExtensions(); + // Install it with hard coded consent first. + await extensionManager.installOrUpdateExtension({ + source: sourceExtDir, + type: 'local', + }); + expect(mockRequestConsent).toHaveBeenCalledOnce(); + + // Now update it without changing anything. + await expect( + extensionManager.installOrUpdateExtension( + { source: sourceExtDir, type: 'local' }, + // Provide its own existing config as the previous config. + await extensionManager.loadExtensionConfig(sourceExtDir), + ), + ).resolves.toMatchObject({ name: 'my-local-extension' }); + + // Still only called once + expect(mockRequestConsent).toHaveBeenCalledOnce(); + }); + + it('should prompt for settings if promptForSettings', async () => { + const sourceExtDir = createExtension({ + extensionsDir: tempHomeDir, + name: 'my-local-extension', + version: '1.0.0', + settings: [ + { + name: 'API Key', + description: 'Your API key for the service.', + envVar: 'MY_API_KEY', + }, + ], + }); + + await extensionManager.loadExtensions(); + await extensionManager.installOrUpdateExtension({ + source: sourceExtDir, + type: 'local', + }); + + expect(mockPromptForSettings).toHaveBeenCalled(); + }); + + it('should not prompt for settings if promptForSettings is false', async () => { + const sourceExtDir = createExtension({ + extensionsDir: tempHomeDir, + name: 'my-local-extension', + version: '1.0.0', + settings: [ + { + name: 'API Key', + description: 'Your API key for the service.', + envVar: 'MY_API_KEY', + }, + ], + }); + + extensionManager = new ExtensionManager({ + workspaceDir: tempWorkspaceDir, + requestConsent: mockRequestConsent, + requestSetting: null, + settings: loadSettings(tempWorkspaceDir).merged, + integrityManager: mockIntegrityManager, + }); + + await extensionManager.loadExtensions(); + await extensionManager.installOrUpdateExtension({ + source: sourceExtDir, + type: 'local', + }); + }); + + it('should only prompt for new settings on update, and preserve old settings', async () => { + // 1. Create and install the "old" version of the extension. + const oldSourceExtDir = createExtension({ + extensionsDir: tempHomeDir, // Create it in a temp location first + name: 'my-local-extension', + version: '1.0.0', + settings: [ + { + name: 'API Key', + description: 'Your API key for the service.', + envVar: 'MY_API_KEY', + }, + ], + }); + + mockPromptForSettings.mockResolvedValueOnce('old-api-key'); + await extensionManager.loadExtensions(); + // Install it so it exists in the userExtensionsDir + await extensionManager.installOrUpdateExtension({ + source: oldSourceExtDir, + type: 'local', + }); + + const envPath = new ExtensionStorage( + 'my-local-extension', + ).getEnvFilePath(); + expect(fs.existsSync(envPath)).toBe(true); + let envContent = fs.readFileSync(envPath, 'utf-8'); + expect(envContent).toContain('MY_API_KEY=old-api-key'); + expect(mockPromptForSettings).toHaveBeenCalledTimes(1); + + // 2. Create the "new" version of the extension in a new source directory. + const newSourceExtDir = createExtension({ + extensionsDir: path.join(tempHomeDir, 'new-source'), // Another temp location + name: 'my-local-extension', // Same name + version: '1.1.0', // New version + settings: [ + { + name: 'API Key', + description: 'Your API key for the service.', + envVar: 'MY_API_KEY', + }, + { + name: 'New Setting', + description: 'A new setting.', + envVar: 'NEW_SETTING', + }, + ], + }); + + const previousExtensionConfig = + await extensionManager.loadExtensionConfig( + path.join(userExtensionsDir, 'my-local-extension'), + ); + mockPromptForSettings.mockResolvedValueOnce('new-setting-value'); + + // 3. Call installOrUpdateExtension to perform the update. + await extensionManager.installOrUpdateExtension( + { source: newSourceExtDir, type: 'local' }, + previousExtensionConfig, + ); + + expect(mockPromptForSettings).toHaveBeenCalledTimes(2); + expect(mockPromptForSettings).toHaveBeenCalledWith( + expect.objectContaining({ name: 'New Setting' }), + ); + + expect(fs.existsSync(envPath)).toBe(true); + envContent = fs.readFileSync(envPath, 'utf-8'); + expect(envContent).toContain('MY_API_KEY=old-api-key'); + expect(envContent).toContain('NEW_SETTING=new-setting-value'); + }); + + it('should auto-update if settings have changed', async () => { + // 1. Install initial version with autoUpdate: true + const oldSourceExtDir = createExtension({ + extensionsDir: tempHomeDir, + name: 'my-auto-update-ext', + version: '1.0.0', + settings: [ + { + name: 'OLD_SETTING', + envVar: 'OLD_SETTING', + description: 'An old setting', + }, + ], + }); + await extensionManager.loadExtensions(); + await extensionManager.installOrUpdateExtension({ + source: oldSourceExtDir, + type: 'local', + autoUpdate: true, + }); + + // 2. Create new version with different settings + const extensionDir = createExtension({ + extensionsDir: tempHomeDir, + name: 'my-auto-update-ext', + version: '1.1.0', + settings: [ + { + name: 'NEW_SETTING', + envVar: 'NEW_SETTING', + description: 'A new setting', + }, + ], + }); + + const previousExtensionConfig = + await extensionManager.loadExtensionConfig( + path.join(userExtensionsDir, 'my-auto-update-ext'), + ); + + // 3. Attempt to update and assert it fails + const updatedExtension = await extensionManager.installOrUpdateExtension( + { + source: extensionDir, + type: 'local', + autoUpdate: true, + }, + previousExtensionConfig, + ); + + expect(updatedExtension.version).toBe('1.1.0'); + expect(extensionManager.getExtensions()[0].version).toBe('1.1.0'); + }); + + it('should throw an error for invalid extension names', async () => { + const sourceExtDir = createExtension({ + extensionsDir: tempHomeDir, + name: 'bad_name', + version: '1.0.0', + }); + + await expect( + extensionManager.installOrUpdateExtension({ + source: sourceExtDir, + type: 'local', + }), + ).rejects.toThrow('Invalid extension name: "bad_name"'); + }); + + describe('installing from github', () => { + const gitUrl = 'https://github.com/google/gemini-test-extension.git'; + const extensionName = 'gemini-test-extension'; + + beforeEach(() => { + // Mock the git clone behavior for github installs that fallback to it. + mockGit.clone.mockImplementation(async (_, destination) => { + fs.mkdirSync(path.join(mockGit.path(), destination), { + recursive: true, + }); + fs.writeFileSync( + path.join(mockGit.path(), destination, EXTENSIONS_CONFIG_FILENAME), + JSON.stringify({ name: extensionName, version: '1.0.0' }), + ); + }); + mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should install from a github release successfully', async () => { + const targetExtDir = path.join(userExtensionsDir, extensionName); + mockDownloadFromGithubRelease.mockResolvedValue({ + success: true, + tagName: 'v1.0.0', + type: 'github-release', + }); + + const tempDir = path.join(tempHomeDir, 'temp-ext'); + fs.mkdirSync(tempDir, { recursive: true }); + createExtension({ + extensionsDir: tempDir, + name: extensionName, + version: '1.0.0', + }); + vi.spyOn(ExtensionStorage, 'createTmpDir').mockResolvedValue( + join(tempDir, extensionName), + ); + + await extensionManager.loadExtensions(); + await extensionManager.installOrUpdateExtension({ + source: gitUrl, + type: 'github-release', + }); + + expect(fs.existsSync(targetExtDir)).toBe(true); + const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME); + expect(fs.existsSync(metadataPath)).toBe(true); + const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8')); + expect(metadata).toEqual({ + source: gitUrl, + type: 'github-release', + releaseTag: 'v1.0.0', + }); + }); + + it('should fallback to git clone if github release download fails and user consents', async () => { + mockDownloadFromGithubRelease.mockResolvedValue({ + success: false, + failureReason: 'failed to download asset', + errorMessage: 'download failed', + type: 'github-release', + }); + + await extensionManager.loadExtensions(); + await extensionManager.installOrUpdateExtension( + { source: gitUrl, type: 'github-release' }, // Use github-release to force consent + ); + + // It gets called once to ask for a git clone, and once to consent to + // the actual extension features. + expect(mockRequestConsent).toHaveBeenCalledTimes(2); + expect(mockRequestConsent).toHaveBeenCalledWith( + expect.stringContaining( + 'Would you like to attempt to install via "git clone" instead?', + ), + ); + expect(mockGit.clone).toHaveBeenCalled(); + const metadataPath = path.join( + userExtensionsDir, + extensionName, + INSTALL_METADATA_FILENAME, + ); + const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8')); + expect(metadata.type).toBe('git'); + }); + + it('should throw an error if github release download fails and user denies consent', async () => { + mockDownloadFromGithubRelease.mockResolvedValue({ + success: false, + errorMessage: 'download failed', + type: 'github-release', + }); + mockRequestConsent.mockResolvedValue(false); + + await extensionManager.loadExtensions(); + await expect( + extensionManager.installOrUpdateExtension({ + source: gitUrl, + type: 'github-release', + }), + ).rejects.toThrow( + `Failed to install extension ${gitUrl}: download failed`, + ); + + expect(mockRequestConsent).toHaveBeenCalledExactlyOnceWith( + expect.stringContaining( + 'Would you like to attempt to install via "git clone" instead?', + ), + ); + expect(mockGit.clone).not.toHaveBeenCalled(); + }); + + it('should fallback to git clone without consent if no release data is found on first install', async () => { + mockDownloadFromGithubRelease.mockResolvedValue({ + success: false, + failureReason: 'no release data', + type: 'github-release', + }); + + await extensionManager.loadExtensions(); + await extensionManager.installOrUpdateExtension({ + source: gitUrl, + type: 'git', + }); + + // We should not see the request to use git clone, this is a repo that + // has no github releases so it is the only install method. + expect(mockRequestConsent).toHaveBeenCalledExactlyOnceWith( + expect.stringContaining( + 'Installing extension "gemini-test-extension"', + ), + ); + expect(mockGit.clone).toHaveBeenCalled(); + const metadataPath = path.join( + userExtensionsDir, + extensionName, + INSTALL_METADATA_FILENAME, + ); + const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8')); + expect(metadata.type).toBe('git'); + }); + + it('should ask for consent if no release data is found for an existing github-release extension', async () => { + mockDownloadFromGithubRelease.mockResolvedValue({ + success: false, + failureReason: 'no release data', + errorMessage: 'No release data found', + type: 'github-release', + }); + + await extensionManager.loadExtensions(); + await extensionManager.installOrUpdateExtension( + { source: gitUrl, type: 'github-release' }, // Note the type + ); + + expect(mockRequestConsent).toHaveBeenCalledWith( + expect.stringContaining( + 'Would you like to attempt to install via "git clone" instead?', + ), + ); + expect(mockGit.clone).toHaveBeenCalled(); + }); + }); + }); + + describe('uninstallExtension', () => { + it('should uninstall an extension by name', async () => { + const sourceExtDir = createExtension({ + extensionsDir: userExtensionsDir, + name: 'my-local-extension', + version: '1.0.0', + }); + await extensionManager.loadExtensions(); + await extensionManager.uninstallExtension('my-local-extension', false); + + expect(fs.existsSync(sourceExtDir)).toBe(false); + }); + + it('should uninstall an extension by name and retain existing extensions', async () => { + const sourceExtDir = createExtension({ + extensionsDir: userExtensionsDir, + name: 'my-local-extension', + version: '1.0.0', + }); + const otherExtDir = createExtension({ + extensionsDir: userExtensionsDir, + name: 'other-extension', + version: '1.0.0', + }); + + await extensionManager.loadExtensions(); + await extensionManager.uninstallExtension('my-local-extension', false); + + expect(fs.existsSync(sourceExtDir)).toBe(false); + expect(extensionManager.getExtensions()).toHaveLength(1); + expect(fs.existsSync(otherExtDir)).toBe(true); + }); + + it('should uninstall an extension on non-matching extension directory name', async () => { + // Create an extension with a name that differs from the directory name. + const sourceExtDir = createExtension({ + extensionsDir: userExtensionsDir, + name: 'My-Local-Extension', + version: '1.0.0', + }); + const newSourceExtDir = path.join( + userExtensionsDir, + 'my-local-extension', + ); + fs.renameSync(sourceExtDir, newSourceExtDir); + + const otherExtDir = createExtension({ + extensionsDir: userExtensionsDir, + name: 'other-extension', + version: '1.0.0', + }); + + await extensionManager.loadExtensions(); + await extensionManager.uninstallExtension('my-local-extension', false); + + expect(fs.existsSync(sourceExtDir)).toBe(false); + expect(fs.existsSync(newSourceExtDir)).toBe(false); + expect(extensionManager.getExtensions()).toHaveLength(1); + expect(fs.existsSync(otherExtDir)).toBe(true); + }); + + it('should throw an error if the extension does not exist', async () => { + await extensionManager.loadExtensions(); + await expect( + extensionManager.uninstallExtension('nonexistent-extension', false), + ).rejects.toThrow('Extension not found.'); + }); + + describe.each([true, false])('with isUpdate: %s', (isUpdate: boolean) => { + it(`should ${isUpdate ? 'not ' : ''}log uninstall event`, async () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'my-local-extension', + version: '1.0.0', + installMetadata: { + source: userExtensionsDir, + type: 'local', + }, + }); + + await extensionManager.loadExtensions(); + await extensionManager.uninstallExtension( + 'my-local-extension', + isUpdate, + ); + + if (isUpdate) { + expect(mockLogExtensionUninstall).not.toHaveBeenCalled(); + expect(ExtensionUninstallEvent).not.toHaveBeenCalled(); + } else { + expect(mockLogExtensionUninstall).toHaveBeenCalled(); + expect(ExtensionUninstallEvent).toHaveBeenCalledWith( + 'my-local-extension', + hashValue('my-local-extension'), + hashValue(userExtensionsDir), + 'success', + ); + } + }); + + it(`should ${isUpdate ? 'not ' : ''} alter the extension enablement configuration`, async () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'test-extension', + version: '1.0.0', + }); + const enablementManager = new ExtensionEnablementManager(); + enablementManager.enable('test-extension', true, '/some/scope'); + + await extensionManager.loadExtensions(); + await extensionManager.uninstallExtension('test-extension', isUpdate); + + const config = enablementManager.readConfig()['test-extension']; + if (isUpdate) { + expect(config).not.toBeUndefined(); + expect(config.overrides).toEqual(['/some/scope/*']); + } else { + expect(config).toBeUndefined(); + } + }); + }); + + it('should uninstall an extension by its source URL', async () => { + const gitUrl = 'https://github.com/google/gemini-sql-extension.git'; + const sourceExtDir = createExtension({ + extensionsDir: userExtensionsDir, + name: 'gemini-sql-extension', + version: '1.0.0', + installMetadata: { + source: gitUrl, + type: 'git', + }, + }); + + await extensionManager.loadExtensions(); + await extensionManager.uninstallExtension(gitUrl, false); + + expect(fs.existsSync(sourceExtDir)).toBe(false); + expect(mockLogExtensionUninstall).toHaveBeenCalled(); + expect(ExtensionUninstallEvent).toHaveBeenCalledWith( + 'gemini-sql-extension', + hashValue('gemini-sql-extension'), + hashValue('https://github.com/google/gemini-sql-extension'), + 'success', + ); + }); + + it('should fail to uninstall by URL if an extension has no install metadata', async () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'no-metadata-extension', + version: '1.0.0', + // No installMetadata provided + }); + + await extensionManager.loadExtensions(); + await expect( + extensionManager.uninstallExtension( + 'https://github.com/google/no-metadata-extension', + false, + ), + ).rejects.toThrow('Extension not found.'); + }); + }); + + describe('disableExtension', () => { + it('should disable an extension at the user scope', async () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'my-extension', + version: '1.0.0', + }); + + await extensionManager.loadExtensions(); + await extensionManager.disableExtension( + 'my-extension', + SettingScope.User, + ); + expect( + isEnabled({ + name: 'my-extension', + enabledForPath: tempWorkspaceDir, + }), + ).toBe(false); + }); + + it('should disable an extension at the workspace scope', async () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'my-extension', + version: '1.0.0', + }); + + await extensionManager.loadExtensions(); + await extensionManager.disableExtension( + 'my-extension', + SettingScope.Workspace, + ); + expect( + isEnabled({ + name: 'my-extension', + enabledForPath: tempHomeDir, + }), + ).toBe(true); + expect( + isEnabled({ + name: 'my-extension', + enabledForPath: tempWorkspaceDir, + }), + ).toBe(false); + }); + + it('should handle disabling the same extension twice', async () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'my-extension', + version: '1.0.0', + }); + + await extensionManager.loadExtensions(); + await extensionManager.disableExtension( + 'my-extension', + SettingScope.User, + ); + await extensionManager.disableExtension( + 'my-extension', + SettingScope.User, + ); + expect( + isEnabled({ + name: 'my-extension', + enabledForPath: tempWorkspaceDir, + }), + ).toBe(false); + }); + + it('should throw an error if you request system scope', async () => { + await expect(async () => + extensionManager.disableExtension('my-extension', SettingScope.System), + ).rejects.toThrow('System and SystemDefaults scopes are not supported.'); + }); + + it('should log a disable event', async () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'ext1', + version: '1.0.0', + installMetadata: { + source: userExtensionsDir, + type: 'local', + }, + }); + + await extensionManager.loadExtensions(); + await extensionManager.disableExtension('ext1', SettingScope.Workspace); + + expect(mockLogExtensionDisable).toHaveBeenCalled(); + expect(ExtensionDisableEvent).toHaveBeenCalledWith( + 'ext1', + hashValue('ext1'), + hashValue(userExtensionsDir), + SettingScope.Workspace, + ); + }); + }); + + describe('enableExtension', () => { + afterAll(() => { + vi.restoreAllMocks(); + }); + + const getActiveExtensions = (): GeminiCLIExtension[] => { + const extensions = extensionManager.getExtensions(); + return extensions.filter((e) => e.isActive); + }; + + it('should enable an extension at the user scope', async () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'ext1', + version: '1.0.0', + }); + await extensionManager.loadExtensions(); + await extensionManager.disableExtension('ext1', SettingScope.User); + let activeExtensions = getActiveExtensions(); + expect(activeExtensions).toHaveLength(0); + + await extensionManager.enableExtension('ext1', SettingScope.User); + activeExtensions = getActiveExtensions(); + expect(activeExtensions).toHaveLength(1); + expect(activeExtensions[0].name).toBe('ext1'); + }); + + it('should enable an extension at the workspace scope', async () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'ext1', + version: '1.0.0', + }); + await extensionManager.loadExtensions(); + await extensionManager.disableExtension('ext1', SettingScope.Workspace); + let activeExtensions = getActiveExtensions(); + expect(activeExtensions).toHaveLength(0); + + await extensionManager.enableExtension('ext1', SettingScope.Workspace); + activeExtensions = getActiveExtensions(); + expect(activeExtensions).toHaveLength(1); + expect(activeExtensions[0].name).toBe('ext1'); + }); + + it('should log an enable event', async () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'ext1', + version: '1.0.0', + installMetadata: { + source: userExtensionsDir, + type: 'local', + }, + }); + await extensionManager.loadExtensions(); + await extensionManager.disableExtension('ext1', SettingScope.Workspace); + await extensionManager.enableExtension('ext1', SettingScope.Workspace); + + expect(mockLogExtensionEnable).toHaveBeenCalled(); + expect(ExtensionEnableEvent).toHaveBeenCalledWith( + 'ext1', + hashValue('ext1'), + hashValue(userExtensionsDir), + SettingScope.Workspace, + ); + }); + }); +}); + +function isEnabled(options: { name: string; enabledForPath: string }) { + const manager = new ExtensionEnablementManager(); + return manager.isEnabled(options.name, options.enabledForPath); +} diff --git a/packages/cli/src/config/extension.ts b/packages/cli/src/config/extension.ts new file mode 100644 index 0000000000000000000000000000000000000000..20a70734649861628dc2944ab71cdecb47fd8659 --- /dev/null +++ b/packages/cli/src/config/extension.ts @@ -0,0 +1,69 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + MCPServerConfig, + ExtensionInstallMetadata, + CustomTheme, +} from '@google/gemini-cli-core'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { INSTALL_METADATA_FILENAME } from './extensions/variables.js'; +import type { ExtensionSetting } from './extensions/extensionSettings.js'; + +/** + * Extension definition as written to disk in gemini-extension.json files. + * This should *not* be referenced outside of the logic for reading files. + * If information is required for manipulating extensions (load, unload, update) + * outside of the loading process that data needs to be stored on the + * GeminiCLIExtension class defined in Core. + */ +export interface ExtensionConfig { + name: string; + version: string; + mcpServers?: Record; + contextFileName?: string | string[]; + excludeTools?: string[]; + settings?: ExtensionSetting[]; + /** + * Custom themes contributed by this extension. + * These themes will be registered when the extension is activated. + */ + themes?: CustomTheme[]; + /** + * Planning features configuration contributed by this extension. + */ + plan?: { + /** + * The directory where planning artifacts are stored. + */ + directory?: string; + }; + /** + * Used to migrate an extension to a new repository source. + */ + migratedTo?: string; +} + +export interface ExtensionUpdateInfo { + name: string; + originalVersion: string; + updatedVersion: string; +} + +export function loadInstallMetadata( + extensionDir: string, +): ExtensionInstallMetadata | undefined { + const metadataFilePath = path.join(extensionDir, INSTALL_METADATA_FILENAME); + try { + const configContent = fs.readFileSync(metadataFilePath, 'utf-8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const metadata = JSON.parse(configContent) as ExtensionInstallMetadata; + return metadata; + } catch { + return undefined; + } +} diff --git a/packages/cli/src/config/extensionRegistryClient.test.ts b/packages/cli/src/config/extensionRegistryClient.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..66eaab914b20be465f6740e4b62cedc742c30ce7 --- /dev/null +++ b/packages/cli/src/config/extensionRegistryClient.test.ts @@ -0,0 +1,320 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, + type Mock, +} from 'vitest'; +import * as fs from 'node:fs/promises'; +import { + ExtensionRegistryClient, + type RegistryExtension, +} from './extensionRegistryClient.js'; +import { fetchWithTimeout, resolveToRealPath } from '@google/gemini-cli-core'; + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + fetchWithTimeout: vi.fn(), + }; +}); + +vi.mock('node:fs/promises', () => ({ + readFile: vi.fn(), +})); + +const mockExtensions: RegistryExtension[] = [ + { + id: 'ext1', + rank: 1, + url: 'https://github.com/test/ext1', + fullName: 'test/ext1', + repoDescription: 'Test extension 1', + stars: 100, + lastUpdated: '2025-01-01T00:00:00Z', + extensionName: 'extension-one', + extensionVersion: '1.0.0', + extensionDescription: 'First test extension', + avatarUrl: 'https://example.com/avatar1.png', + hasMCP: true, + hasContext: false, + isGoogleOwned: false, + licenseKey: 'mit', + hasHooks: false, + hasCustomCommands: false, + hasSkills: false, + }, + { + id: 'ext2', + rank: 2, + url: 'https://github.com/test/ext2', + fullName: 'test/ext2', + repoDescription: 'Test extension 2', + stars: 50, + lastUpdated: '2025-01-02T00:00:00Z', + extensionName: 'extension-two', + extensionVersion: '0.5.0', + extensionDescription: 'Second test extension', + avatarUrl: 'https://example.com/avatar2.png', + hasMCP: false, + hasContext: true, + isGoogleOwned: true, + licenseKey: 'apache-2.0', + hasHooks: false, + hasCustomCommands: false, + hasSkills: false, + }, + { + id: 'ext3', + rank: 3, + url: 'https://github.com/test/ext3', + fullName: 'test/ext3', + repoDescription: 'Test extension 3', + stars: 10, + lastUpdated: '2025-01-03T00:00:00Z', + extensionName: 'extension-three', + extensionVersion: '0.1.0', + extensionDescription: 'Third test extension', + avatarUrl: 'https://example.com/avatar3.png', + hasMCP: true, + hasContext: true, + isGoogleOwned: false, + licenseKey: 'gpl-3.0', + hasHooks: false, + hasCustomCommands: false, + hasSkills: false, + }, +]; + +describe('ExtensionRegistryClient', () => { + let client: ExtensionRegistryClient; + let fetchMock: Mock; + + beforeEach(() => { + ExtensionRegistryClient.resetCache(); + client = new ExtensionRegistryClient(); + fetchMock = fetchWithTimeout as Mock; + fetchMock.mockReset(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should fetch and return extensions with pagination (default ranking)', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => mockExtensions, + }); + + const result = await client.getExtensions(1, 2); + expect(result.extensions).toHaveLength(2); + expect(result.extensions[0].id).toBe('ext1'); // rank 1 + expect(result.extensions[1].id).toBe('ext2'); // rank 2 + expect(result.total).toBe(3); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + 'https://geminicli.com/extensions.json', + 10000, + ); + }); + + it('should return extensions sorted alphabetically', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => mockExtensions, + }); + + const result = await client.getExtensions(1, 3, 'alphabetical'); + expect(result.extensions).toHaveLength(3); + expect(result.extensions[0].id).toBe('ext1'); + expect(result.extensions[1].id).toBe('ext3'); + expect(result.extensions[2].id).toBe('ext2'); + }); + + it('should return the second page of extensions', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => mockExtensions, + }); + + const result = await client.getExtensions(2, 2); + expect(result.extensions).toHaveLength(1); + expect(result.extensions[0].id).toBe('ext3'); + expect(result.total).toBe(3); + }); + + it('should search extensions by name', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => mockExtensions, + }); + + const results = await client.searchExtensions('one'); + expect(results.length).toBeGreaterThanOrEqual(1); + expect(results[0].id).toBe('ext1'); + }); + + it('should search extensions by description', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => mockExtensions, + }); + + const results = await client.searchExtensions('Second'); + expect(results.length).toBeGreaterThanOrEqual(1); + expect(results[0].id).toBe('ext2'); + }); + + it('should get an extension by ID', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => mockExtensions, + }); + + const result = await client.getExtension('ext2'); + expect(result).toBeDefined(); + expect(result?.id).toBe('ext2'); + }); + + it('should return undefined if extension not found', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => mockExtensions, + }); + + const result = await client.getExtension('non-existent'); + expect(result).toBeUndefined(); + }); + + it('should cache the fetch result', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => mockExtensions, + }); + + await client.getExtensions(); + await client.getExtensions(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('should share the fetch result across instances', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => mockExtensions, + }); + + const client1 = new ExtensionRegistryClient(); + const client2 = new ExtensionRegistryClient(); + + await client1.getExtensions(); + await client2.getExtensions(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('should throw an error if fetch fails', async () => { + fetchMock.mockResolvedValue({ + ok: false, + statusText: 'Not Found', + }); + + await expect(client.getExtensions()).rejects.toThrow( + 'Failed to fetch extensions: Not Found', + ); + }); + + it('should not return irrelevant results', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => [ + ...mockExtensions, + { + id: 'dataplex', + extensionName: 'dataplex', + extensionDescription: 'Connect to Dataplex Universal Catalog...', + fullName: 'google-cloud/dataplex', + rank: 6, + stars: 6, + url: '', + repoDescription: '', + lastUpdated: '', + extensionVersion: '1.0.0', + avatarUrl: '', + hasMCP: false, + hasContext: false, + isGoogleOwned: true, + licenseKey: '', + hasHooks: false, + hasCustomCommands: false, + hasSkills: false, + }, + { + id: 'conductor', + extensionName: 'conductor', + extensionDescription: 'A conductor extension that actually matches.', + fullName: 'someone/conductor', + rank: 100, + stars: 100, + url: '', + repoDescription: '', + lastUpdated: '', + extensionVersion: '1.0.0', + avatarUrl: '', + hasMCP: false, + hasContext: false, + isGoogleOwned: false, + licenseKey: '', + hasHooks: false, + hasCustomCommands: false, + hasSkills: false, + }, + ], + }); + + const results = await client.searchExtensions('conductor'); + const ids = results.map((r) => r.id); + + expect(ids).not.toContain('dataplex'); + expect(ids).toContain('conductor'); + }); + + it('should fetch extensions from a local file path', async () => { + const filePath = '/path/to/extensions.json'; + const clientWithFile = new ExtensionRegistryClient(filePath); + const mockReadFile = vi.mocked(fs.readFile); + mockReadFile.mockResolvedValue(JSON.stringify(mockExtensions)); + + const result = await clientWithFile.getExtensions(); + expect(result.extensions).toHaveLength(3); + expect(mockReadFile).toHaveBeenCalledWith( + resolveToRealPath(filePath), + 'utf-8', + ); + }); + + it('should fetch extensions from a file:// URL', async () => { + const fileUrl = 'file:///path/to/extensions.json'; + const clientWithFileUrl = new ExtensionRegistryClient(fileUrl); + const mockReadFile = vi.mocked(fs.readFile); + mockReadFile.mockResolvedValue(JSON.stringify(mockExtensions)); + + const result = await clientWithFileUrl.getExtensions(); + expect(result.extensions).toHaveLength(3); + expect(mockReadFile).toHaveBeenCalledWith( + resolveToRealPath(fileUrl), + 'utf-8', + ); + }); +}); diff --git a/packages/cli/src/config/extensionRegistryClient.ts b/packages/cli/src/config/extensionRegistryClient.ts new file mode 100644 index 0000000000000000000000000000000000000000..41f1c253c9d9c93b87dac6cfcbf77ebccbcd2f1e --- /dev/null +++ b/packages/cli/src/config/extensionRegistryClient.ts @@ -0,0 +1,147 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import { + fetchWithTimeout, + resolveToRealPath, + isPrivateIp, +} from '@google/gemini-cli-core'; +import { AsyncFzf } from 'fzf'; + +export interface RegistryExtension { + id: string; + rank: number; + url: string; + fullName: string; + repoDescription: string; + stars: number; + lastUpdated: string; + extensionName: string; + extensionVersion: string; + extensionDescription: string; + avatarUrl: string; + hasMCP: boolean; + hasContext: boolean; + hasHooks: boolean; + hasSkills: boolean; + hasCustomCommands: boolean; + isGoogleOwned: boolean; + licenseKey: string; +} + +export class ExtensionRegistryClient { + static readonly DEFAULT_REGISTRY_URL = + 'https://geminicli.com/extensions.json'; + private static readonly FETCH_TIMEOUT_MS = 10000; // 10 seconds + + private static fetchPromise: Promise | null = null; + + private readonly registryURI: string; + + constructor(registryURI?: string) { + this.registryURI = + registryURI || ExtensionRegistryClient.DEFAULT_REGISTRY_URL; + } + + /** @internal */ + static resetCache() { + ExtensionRegistryClient.fetchPromise = null; + } + + async getExtensions( + page: number = 1, + limit: number = 10, + orderBy: 'ranking' | 'alphabetical' = 'ranking', + ): Promise<{ extensions: RegistryExtension[]; total: number }> { + const allExtensions = [...(await this.fetchAllExtensions())]; + + switch (orderBy) { + case 'ranking': + allExtensions.sort((a, b) => a.rank - b.rank); + break; + case 'alphabetical': + allExtensions.sort((a, b) => + a.extensionName.localeCompare(b.extensionName), + ); + break; + default: { + const _exhaustiveCheck: never = orderBy; + throw new Error(`Unhandled orderBy: ${_exhaustiveCheck}`); + } + } + + const startIndex = (page - 1) * limit; + const endIndex = startIndex + limit; + return { + extensions: allExtensions.slice(startIndex, endIndex), + total: allExtensions.length, + }; + } + + async searchExtensions(query: string): Promise { + const allExtensions = await this.fetchAllExtensions(); + if (!query.trim()) { + return allExtensions; + } + + const fzf = new AsyncFzf(allExtensions, { + selector: (ext: RegistryExtension) => + `${ext.extensionName} ${ext.extensionDescription} ${ext.fullName}`, + fuzzy: true, + }); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const results: Array<{ item: RegistryExtension }> = await fzf.find(query); + return results.map((r) => r.item); + } + + async getExtension(id: string): Promise { + const allExtensions = await this.fetchAllExtensions(); + return allExtensions.find((ext) => ext.id === id); + } + + private async fetchAllExtensions(): Promise { + if (ExtensionRegistryClient.fetchPromise) { + return ExtensionRegistryClient.fetchPromise; + } + + const uri = this.registryURI; + ExtensionRegistryClient.fetchPromise = (async () => { + try { + if (uri.startsWith('http')) { + if (await isPrivateIp(uri)) { + throw new Error( + 'Private IP addresses are not allowed for the extension registry.', + ); + } + const response = await fetchWithTimeout( + uri, + ExtensionRegistryClient.FETCH_TIMEOUT_MS, + ); + if (!response.ok) { + throw new Error( + `Failed to fetch extensions: ${response.statusText}`, + ); + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return (await response.json()) as RegistryExtension[]; + } else { + // Handle local file path + const filePath = resolveToRealPath(uri); + const content = await fs.readFile(filePath, 'utf-8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return JSON.parse(content) as RegistryExtension[]; + } + } catch (error) { + ExtensionRegistryClient.fetchPromise = null; + throw error; + } + })(); + + return ExtensionRegistryClient.fetchPromise; + } +} diff --git a/packages/cli/src/config/footerItems.test.ts b/packages/cli/src/config/footerItems.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a5b758bfb237618a2f5f88cd8192e657839b600f --- /dev/null +++ b/packages/cli/src/config/footerItems.test.ts @@ -0,0 +1,202 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { + deriveItemsFromLegacySettings, + resolveFooterState, +} from './footerItems.js'; +import { createMockSettings } from '../test-utils/settings.js'; + +describe('footerItems', () => { + describe('deriveItemsFromLegacySettings', () => { + it('returns defaults when no legacy settings are customized', () => { + const settings = createMockSettings({ + ui: { footer: { hideContextPercentage: true } }, + }).merged; + const items = deriveItemsFromLegacySettings(settings); + expect(items).toEqual([ + 'workspace', + 'git-branch', + 'sandbox', + 'model-name', + 'quota', + ]); + }); + + it('removes workspace when hideCWD is true', () => { + const settings = createMockSettings({ + ui: { footer: { hideCWD: true, hideContextPercentage: true } }, + }).merged; + const items = deriveItemsFromLegacySettings(settings); + expect(items).not.toContain('workspace'); + }); + + it('removes sandbox when hideSandboxStatus is true', () => { + const settings = createMockSettings({ + ui: { + footer: { hideSandboxStatus: true, hideContextPercentage: true }, + }, + }).merged; + const items = deriveItemsFromLegacySettings(settings); + expect(items).not.toContain('sandbox'); + }); + + it('removes model-name, context-used, and quota when hideModelInfo is true', () => { + const settings = createMockSettings({ + ui: { footer: { hideModelInfo: true, hideContextPercentage: true } }, + }).merged; + const items = deriveItemsFromLegacySettings(settings); + expect(items).not.toContain('model-name'); + expect(items).not.toContain('context-used'); + expect(items).not.toContain('quota'); + }); + + it('includes context-used when hideContextPercentage is false', () => { + const settings = createMockSettings({ + ui: { footer: { hideContextPercentage: false } }, + }).merged; + const items = deriveItemsFromLegacySettings(settings); + expect(items).toContain('context-used'); + // Should be after model-name + const modelIdx = items.indexOf('model-name'); + const contextIdx = items.indexOf('context-used'); + expect(contextIdx).toBe(modelIdx + 1); + }); + + it('includes memory-usage when showMemoryUsage is true', () => { + const settings = createMockSettings({ + ui: { showMemoryUsage: true, footer: { hideContextPercentage: true } }, + }).merged; + const items = deriveItemsFromLegacySettings(settings); + expect(items).toContain('memory-usage'); + }); + + it('handles combination of settings', () => { + const settings = createMockSettings({ + ui: { + showMemoryUsage: true, + footer: { + hideCWD: true, + hideModelInfo: true, + hideContextPercentage: false, + }, + }, + }).merged; + const items = deriveItemsFromLegacySettings(settings); + expect(items).toEqual([ + 'git-branch', + 'sandbox', + 'context-used', + 'memory-usage', + ]); + }); + }); + + describe('resolveFooterState', () => { + it('filters out auth item when showUserIdentity is false', () => { + const settings = createMockSettings({ + ui: { + showUserIdentity: false, + footer: { + items: ['workspace', 'auth', 'model-name'], + }, + }, + }).merged; + + const state = resolveFooterState(settings); + expect(state.orderedIds).not.toContain('auth'); + expect(state.selectedIds.has('auth')).toBe(false); + // It should also not be in the 'others' part of orderedIds + expect(state.orderedIds).toEqual([ + 'workspace', + 'model-name', + 'git-branch', + 'sandbox', + 'context-used', + 'quota', + 'memory-usage', + 'session-id', + 'hostname', + 'code-changes', + 'token-count', + ]); + }); + + it('includes auth item when showUserIdentity is true', () => { + const settings = createMockSettings({ + ui: { + showUserIdentity: true, + footer: { + items: ['workspace', 'auth', 'model-name'], + }, + }, + }).merged; + + const state = resolveFooterState(settings); + expect(state.orderedIds).toContain('auth'); + expect(state.selectedIds.has('auth')).toBe(true); + }); + + it('includes auth item by default when showUserIdentity is undefined (defaults to true)', () => { + const settings = createMockSettings({ + ui: { + footer: { + items: ['workspace', 'auth', 'model-name'], + }, + }, + }).merged; + + const state = resolveFooterState(settings); + expect(state.orderedIds).toContain('auth'); + expect(state.selectedIds.has('auth')).toBe(true); + }); + + it('includes context-used in selectedIds when hideContextPercentage is false and items is undefined', () => { + const settings = createMockSettings({ + ui: { + footer: { + hideContextPercentage: false, + }, + }, + }).merged; + + const state = resolveFooterState(settings); + expect(state.selectedIds.has('context-used')).toBe(true); + expect(state.orderedIds).toContain('context-used'); + }); + + it('does not include context-used in selectedIds when hideContextPercentage is true (default)', () => { + const settings = createMockSettings({ + ui: { + footer: { + hideContextPercentage: true, + }, + }, + }).merged; + + const state = resolveFooterState(settings); + expect(state.selectedIds.has('context-used')).toBe(false); + // context-used should still be in orderedIds (as unselected) + expect(state.orderedIds).toContain('context-used'); + }); + + it('persisted items array takes precedence over hideContextPercentage', () => { + const settings = createMockSettings({ + ui: { + footer: { + items: ['workspace', 'model-name'], + hideContextPercentage: false, + }, + }, + }).merged; + + const state = resolveFooterState(settings); + // items array explicitly omits context-used, so it should not be selected + expect(state.selectedIds.has('context-used')).toBe(false); + }); + }); +}); diff --git a/packages/cli/src/config/footerItems.ts b/packages/cli/src/config/footerItems.ts new file mode 100644 index 0000000000000000000000000000000000000000..faed6efd21126d34c153b6542c573fe203b8a0e3 --- /dev/null +++ b/packages/cli/src/config/footerItems.ts @@ -0,0 +1,153 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { MergedSettings } from './settings.js'; + +export const ALL_ITEMS = [ + { + id: 'workspace', + header: 'workspace (/directory)', + description: 'Current working directory', + }, + { + id: 'git-branch', + header: 'branch', + description: 'Current git branch name (not shown when unavailable)', + }, + { + id: 'sandbox', + header: 'sandbox', + description: 'Sandbox type and trust indicator', + }, + { + id: 'model-name', + header: '/model', + description: 'Current model identifier', + }, + { + id: 'context-used', + header: 'context', + description: 'Percentage of context window used', + }, + { + id: 'quota', + header: 'quota', + description: 'Percentage of daily limit used (not shown when unavailable)', + }, + { + id: 'memory-usage', + header: 'memory', + description: 'Memory used by the application', + }, + { + id: 'session-id', + header: 'session', + description: 'Unique identifier for the current session', + }, + { + id: 'hostname', + header: 'machine', + description: 'Current machine hostname', + }, + { + id: 'auth', + header: '/auth', + description: 'Current authentication info', + }, + { + id: 'code-changes', + header: 'diff', + description: 'Lines added/removed in the session (not shown when zero)', + }, + { + id: 'token-count', + header: 'tokens', + description: 'Total tokens used in the session (not shown when zero)', + }, +] as const; + +export type FooterItemId = (typeof ALL_ITEMS)[number]['id']; + +export const DEFAULT_ORDER = [ + 'workspace', + 'git-branch', + 'sandbox', + 'model-name', + 'context-used', + 'quota', + 'memory-usage', + 'session-id', + 'hostname', + 'auth', + 'code-changes', + 'token-count', +]; + +export function deriveItemsFromLegacySettings( + settings: MergedSettings, +): string[] { + const defaults = [ + 'workspace', + 'git-branch', + 'sandbox', + 'model-name', + 'quota', + ]; + const items = [...defaults]; + + const remove = (arr: string[], id: string) => { + const idx = arr.indexOf(id); + if (idx !== -1) arr.splice(idx, 1); + }; + + if (settings.ui.footer.hideCWD) remove(items, 'workspace'); + if (settings.ui.footer.hideSandboxStatus) remove(items, 'sandbox'); + if (settings.ui.footer.hideModelInfo) { + remove(items, 'model-name'); + remove(items, 'context-used'); + remove(items, 'quota'); + } + if ( + !settings.ui.footer.hideContextPercentage && + !items.includes('context-used') + ) { + const modelIdx = items.indexOf('model-name'); + if (modelIdx !== -1) items.splice(modelIdx + 1, 0, 'context-used'); + else items.push('context-used'); + } + if (settings.ui.showMemoryUsage) items.push('memory-usage'); + + return items; +} + +const VALID_IDS: Set = new Set(ALL_ITEMS.map((i) => i.id)); + +/** + * Resolves the ordered list and selected set of footer items from settings. + * Used by FooterConfigDialog to initialize and reset state. + */ +export function resolveFooterState(settings: MergedSettings): { + orderedIds: string[]; + selectedIds: Set; +} { + const showUserIdentity = settings.ui?.showUserIdentity !== false; + const filteredValidIds = showUserIdentity + ? VALID_IDS + : new Set([...VALID_IDS].filter((id) => id !== 'auth')); + + const source = ( + settings.ui?.footer?.items ?? deriveItemsFromLegacySettings(settings) + ).filter((id: string) => filteredValidIds.has(id)); + + const others = DEFAULT_ORDER.filter( + (id) => !source.includes(id) && filteredValidIds.has(id), + ); + + return { + orderedIds: [...source, ...others], + selectedIds: new Set(source), + }; +} diff --git a/packages/cli/src/config/mutual-exclusivity.test.ts b/packages/cli/src/config/mutual-exclusivity.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..9ed9378231aaec528ea239a746e8d5c4b7083a5a --- /dev/null +++ b/packages/cli/src/config/mutual-exclusivity.test.ts @@ -0,0 +1,44 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { parseArguments } from './config.js'; +import { createTestMergedSettings } from './settings.js'; + +describe('parseArguments mutual exclusivity', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + const combinations = [ + ['--resume', '--session-id', 'test-id'], + ['--resume', '--session-file', 'test.json'], + ['--session-id', 'test-id', '--session-file', 'test.json'], + ['--resume', '--session-id', 'test-id', '--session-file', 'test.json'], + ]; + + combinations.forEach((args) => { + it(`should fail if ${args.filter((a) => a.startsWith('--')).join(' and ')} are provided`, async () => { + process.argv = ['node', 'script.js', ...args]; + const mockConsoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + + await expect(parseArguments(createTestMergedSettings())).rejects.toThrow( + 'process.exit called', + ); + + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining( + 'The flags --resume, --session-id, and --session-file are mutually exclusive. Please provide only one.', + ), + ); + }); + }); +}); diff --git a/packages/cli/src/config/policy-engine.integration.test.ts b/packages/cli/src/config/policy-engine.integration.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..1420a051f29bef2c134606adf0bf19c0e2d3a668 --- /dev/null +++ b/packages/cli/src/config/policy-engine.integration.test.ts @@ -0,0 +1,728 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + ApprovalMode, + PolicyDecision, + PolicyEngine, +} from '@google/gemini-cli-core'; +import { createPolicyEngineConfig } from './policy.js'; +import type { Settings } from './settings.js'; + +// Mock Storage to ensure tests are hermetic and don't read from user's home directory +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + const Storage = actual.Storage; + // Monkey-patch static methods + Storage.getUserPoliciesDir = () => '/non-existent/user/policies'; + Storage.getSystemPoliciesDir = () => '/non-existent/system/policies'; + + return { + ...actual, + Storage, + }; +}); + +describe('Policy Engine Integration Tests', () => { + beforeEach(() => vi.stubEnv('GEMINI_SYSTEM_MD', '')); + + afterEach(() => vi.unstubAllEnvs()); + + describe('Policy configuration produces valid PolicyEngine config', () => { + it('should create a working PolicyEngine from basic settings', async () => { + const settings: Settings = { + tools: { + allowed: ['run_shell_command'], + exclude: ['write_file'], + }, + }; + + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); + const engine = new PolicyEngine(config); + + // Allowed tool should be allowed + expect( + (await engine.check({ name: 'run_shell_command' }, undefined)).decision, + ).toBe(PolicyDecision.ALLOW); + + // Excluded tool should be denied + expect( + (await engine.check({ name: 'write_file' }, undefined)).decision, + ).toBe(PolicyDecision.DENY); + + // Other write tools should ask user + expect( + (await engine.check({ name: 'replace' }, undefined)).decision, + ).toBe(PolicyDecision.ASK_USER); + + // Unknown tools should use default + expect( + (await engine.check({ name: 'unknown_tool' }, undefined)).decision, + ).toBe(PolicyDecision.ASK_USER); + + // invoke_agent should be allowed by default (via agents.toml) + expect( + (await engine.check({ name: 'invoke_agent' }, undefined)).decision, + ).toBe(PolicyDecision.ALLOW); + }); + + it('should handle MCP server wildcard patterns correctly', async () => { + const settings: Settings = { + mcp: { + allowed: ['allowed-server'], + excluded: ['blocked-server'], + }, + mcpServers: { + 'trusted-server': { + command: 'node', + args: ['server.js'], + trust: true, + }, + }, + }; + + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); + const engine = new PolicyEngine(config); + + // Tools from allowed server should be allowed + // Tools from allowed server should be allowed + expect( + (await engine.check({ name: 'mcp_allowed-server_tool1' }, undefined)) + .decision, + ).toBe(PolicyDecision.ALLOW); + expect( + ( + await engine.check( + { name: 'mcp_allowed-server_another_tool' }, + undefined, + ) + ).decision, + ).toBe(PolicyDecision.ALLOW); + + // Tools from trusted server should be allowed + expect( + (await engine.check({ name: 'mcp_trusted-server_tool1' }, undefined)) + .decision, + ).toBe(PolicyDecision.ALLOW); + expect( + ( + await engine.check( + { name: 'mcp_trusted-server_special_tool' }, + undefined, + ) + ).decision, + ).toBe(PolicyDecision.ALLOW); + + // Tools from blocked server should be denied + expect( + (await engine.check({ name: 'mcp_blocked-server_tool1' }, undefined)) + .decision, + ).toBe(PolicyDecision.DENY); + expect( + (await engine.check({ name: 'mcp_blocked-server_any_tool' }, undefined)) + .decision, + ).toBe(PolicyDecision.DENY); + + // Tools from unknown servers should use default + expect( + (await engine.check({ name: 'mcp_unknown-server_tool' }, undefined)) + .decision, + ).toBe(PolicyDecision.ASK_USER); + }); + + it('should handle global MCP wildcard (*) in settings', async () => { + const settings: Settings = { + mcp: { + allowed: ['*'], + }, + }; + + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); + const engine = new PolicyEngine(config); + + // ANY tool with a server name should be allowed + expect( + (await engine.check({ name: 'mcp_mcp-server_tool' }, 'mcp-server')) + .decision, + ).toBe(PolicyDecision.ALLOW); + expect( + ( + await engine.check( + { name: 'mcp_another-server_tool' }, + 'another-server', + ) + ).decision, + ).toBe(PolicyDecision.ALLOW); + + // Built-in tools should NOT be allowed by the MCP wildcard + expect( + (await engine.check({ name: 'run_shell_command' }, undefined)).decision, + ).toBe(PolicyDecision.ASK_USER); + }); + + it('should correctly prioritize specific tool excludes over MCP server wildcards', async () => { + const settings: Settings = { + mcp: { + allowed: ['my-server'], + }, + tools: { + exclude: ['mcp_my-server_dangerous-tool'], + }, + }; + + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); + const engine = new PolicyEngine(config); + + // MCP server allowed (priority 4.1) provides general allow for server + // MCP server allowed (priority 4.1) provides general allow for server + expect( + (await engine.check({ name: 'mcp_my-server_safe-tool' }, undefined)) + .decision, + ).toBe(PolicyDecision.ALLOW); + // But specific tool exclude (priority 4.4) wins over server allow + expect( + ( + await engine.check( + { name: 'mcp_my-server_dangerous-tool' }, + undefined, + ) + ).decision, + ).toBe(PolicyDecision.DENY); + }); + + it('should handle complex mixed configurations', async () => { + const settings: Settings = { + tools: { + allowed: ['custom-tool', 'mcp_my-server_special-tool'], + exclude: ['glob', 'dangerous-tool'], + }, + mcp: { + allowed: ['allowed-server'], + excluded: ['blocked-server'], + }, + mcpServers: { + 'trusted-server': { + command: 'node', + args: ['server.js'], + trust: true, + }, + }, + }; + + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); + const engine = new PolicyEngine(config); + + // Read-only tools should be allowed (autoAccept) + expect( + (await engine.check({ name: 'read_file' }, undefined)).decision, + ).toBe(PolicyDecision.ALLOW); + expect( + (await engine.check({ name: 'list_directory' }, undefined)).decision, + ).toBe(PolicyDecision.ALLOW); + + // But glob is explicitly excluded, so it should be denied + expect((await engine.check({ name: 'glob' }, undefined)).decision).toBe( + PolicyDecision.DENY, + ); + + // Replace should ask user (normal write tool behavior) + expect( + (await engine.check({ name: 'replace' }, undefined)).decision, + ).toBe(PolicyDecision.ASK_USER); + + // Explicitly allowed tools + expect( + (await engine.check({ name: 'custom-tool' }, undefined)).decision, + ).toBe(PolicyDecision.ALLOW); + expect( + (await engine.check({ name: 'mcp_my-server_special-tool' }, undefined)) + .decision, + ).toBe(PolicyDecision.ALLOW); + + // MCP server tools + expect( + (await engine.check({ name: 'mcp_allowed-server_tool' }, undefined)) + .decision, + ).toBe(PolicyDecision.ALLOW); + expect( + (await engine.check({ name: 'mcp_trusted-server_tool' }, undefined)) + .decision, + ).toBe(PolicyDecision.ALLOW); + expect( + (await engine.check({ name: 'mcp_blocked-server_tool' }, undefined)) + .decision, + ).toBe(PolicyDecision.DENY); + + // Write tools should ask by default + expect( + (await engine.check({ name: 'write_file' }, undefined)).decision, + ).toBe(PolicyDecision.ASK_USER); + }); + + it('should handle YOLO mode correctly', async () => { + const settings: Settings = { + tools: { + exclude: ['dangerous-tool'], // Even in YOLO, excludes should be respected + }, + }; + + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.YOLO, + ); + const engine = new PolicyEngine(config); + + // Most tools should be allowed in YOLO mode + expect( + (await engine.check({ name: 'run_shell_command' }, undefined)).decision, + ).toBe(PolicyDecision.ALLOW); + expect( + (await engine.check({ name: 'write_file' }, undefined)).decision, + ).toBe(PolicyDecision.ALLOW); + expect( + (await engine.check({ name: 'unknown_tool' }, undefined)).decision, + ).toBe(PolicyDecision.ALLOW); + + // But explicitly excluded tools should still be denied + expect( + (await engine.check({ name: 'dangerous-tool' }, undefined)).decision, + ).toBe(PolicyDecision.DENY); + }); + + it('should handle AUTO_EDIT mode correctly', async () => { + const settings: Settings = {}; + + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.AUTO_EDIT, + ); + const engine = new PolicyEngine(config); + + // Edit tools should be allowed in AUTO_EDIT mode + expect( + (await engine.check({ name: 'replace' }, undefined)).decision, + ).toBe(PolicyDecision.ALLOW); + expect( + (await engine.check({ name: 'write_file' }, undefined)).decision, + ).toBe(PolicyDecision.ALLOW); + + // Other tools should follow normal rules + expect( + (await engine.check({ name: 'run_shell_command' }, undefined)).decision, + ).toBe(PolicyDecision.ASK_USER); + }); + + it('should handle Plan mode correctly', async () => { + const settings: Settings = {}; + + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.PLAN, + ); + const engine = new PolicyEngine(config); + + // Read and search tools should be allowed + expect( + (await engine.check({ name: 'read_file' }, undefined)).decision, + ).toBe(PolicyDecision.ALLOW); + expect( + (await engine.check({ name: 'google_web_search' }, undefined)).decision, + ).toBe(PolicyDecision.ALLOW); + expect( + (await engine.check({ name: 'list_directory' }, undefined)).decision, + ).toBe(PolicyDecision.ALLOW); + expect( + (await engine.check({ name: 'get_internal_docs' }, undefined)).decision, + ).toBe(PolicyDecision.ALLOW); + expect( + ( + await engine.check( + { name: 'invoke_agent', args: { agent_name: 'cli_help' } }, + undefined, + ) + ).decision, + ).toBe(PolicyDecision.ALLOW); + + // codebase_investigator should be allowed in Plan mode + expect( + ( + await engine.check( + { + name: 'invoke_agent', + args: { agent_name: 'codebase_investigator' }, + }, + undefined, + ) + ).decision, + ).toBe(PolicyDecision.ALLOW); + + // Unknown agents should be denied in Plan mode (via catch-all) + expect( + ( + await engine.check( + { name: 'invoke_agent', args: { agent_name: 'unknown_agent' } }, + undefined, + ) + ).decision, + ).toBe(PolicyDecision.DENY); + + // Other tools should be denied via catch all + expect( + (await engine.check({ name: 'replace' }, undefined)).decision, + ).toBe(PolicyDecision.DENY); + expect( + (await engine.check({ name: 'write_file' }, undefined)).decision, + ).toBe(PolicyDecision.DENY); + expect( + (await engine.check({ name: 'run_shell_command' }, undefined)).decision, + ).toBe(PolicyDecision.DENY); + + // Unknown tools should be denied via catch-all + expect( + (await engine.check({ name: 'unknown_tool' }, undefined)).decision, + ).toBe(PolicyDecision.DENY); + }); + + it('should correctly match tool annotations', async () => { + const settings: Settings = {}; + + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); + + // Add a manual rule with annotations to the config + config.rules = config.rules || []; + config.rules.push({ + toolName: '*', + toolAnnotations: { readOnlyHint: true }, + decision: PolicyDecision.ALLOW, + priority: 10, + }); + + const engine = new PolicyEngine(config); + + // A tool with readOnlyHint=true should be ALLOWED + const roCall = { name: 'some_tool', args: {} }; + const roMeta = { readOnlyHint: true }; + expect((await engine.check(roCall, undefined, roMeta)).decision).toBe( + PolicyDecision.ALLOW, + ); + + // A tool without the hint (or with false) should follow default decision (ASK_USER) + const rwMeta = { readOnlyHint: false }; + expect((await engine.check(roCall, undefined, rwMeta)).decision).toBe( + PolicyDecision.ASK_USER, + ); + }); + + describe.each(['write_file', 'replace'])( + 'Plan Mode policy for %s', + (toolName) => { + it(`should allow ${toolName} to plans directory`, async () => { + const settings: Settings = {}; + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.PLAN, + ); + const engine = new PolicyEngine(config); + + // Valid plan file paths + const validPaths = [ + '/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/session-1/plans/my-plan.md', + '/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/session-1/plans/feature_auth.md', + '/home/user/.gemini/tmp/new-temp_dir_123/session-1/plans/plan.md', // new style of temp directory + 'C:\\Users\\user\\.gemini\\tmp\\project-id\\session-id\\plans\\plan.md', + 'D:\\gemini-cli\\.gemini\\tmp\\project-id\\session-1\\plans\\plan.md', // no session ID + ]; + + for (const file_path of validPaths) { + expect( + ( + await engine.check( + { name: toolName, args: { file_path } }, + undefined, + ) + ).decision, + ).toBe(PolicyDecision.ALLOW); + } + }); + + it(`should deny ${toolName} outside plans directory`, async () => { + const settings: Settings = {}; + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.PLAN, + ); + const engine = new PolicyEngine(config); + + const invalidPaths = [ + '/project/src/file.ts', // Workspace + '/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/script.js', // Wrong extension + '/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/../../../etc/passwd.md', // Path traversal (Unix) + 'C:\\Users\\user\\.gemini\\tmp\\id\\session\\plans\\..\\..\\..\\Windows\\System32\\config\\SAM', // Path traversal (Windows) + '/home/user/.gemini/non-tmp/new-temp_dir_123/plans/plan.md', // outside of temp dir + ]; + + for (const file_path of invalidPaths) { + expect( + ( + await engine.check( + { name: toolName, args: { file_path } }, + undefined, + ) + ).decision, + ).toBe(PolicyDecision.DENY); + } + }); + }, + ); + + it('should verify priority ordering works correctly in practice', async () => { + const settings: Settings = { + tools: { + allowed: ['specific-tool'], // Priority 100 + exclude: ['blocked-tool'], // Priority 200 + }, + mcp: { + allowed: ['mcp-server'], // Priority 85 + excluded: ['blocked-server'], // Priority 195 + }, + mcpServers: { + 'trusted-server': { + command: 'node', + args: ['server.js'], + trust: true, // Priority 90 + }, + }, + }; + + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); + const engine = new PolicyEngine(config); + + // Test that priorities are applied correctly + const rules = config.rules || []; + + // Find rules and verify their priorities + const blockedToolRule = rules.find((r) => r.toolName === 'blocked-tool'); + expect(blockedToolRule?.priority).toBe(4.4); // Command line exclude + + const blockedServerRule = rules.find( + (r) => r.toolName === 'mcp_blocked-server_*', + ); + expect(blockedServerRule?.priority).toBe(4.9); // MCP server exclude + + const specificToolRule = rules.find( + (r) => r.toolName === 'specific-tool', + ); + expect(specificToolRule?.priority).toBe(4.3); // Command line allow + + const trustedServerRule = rules.find( + (r) => r.toolName === 'mcp_trusted-server_*', + ); + expect(trustedServerRule?.priority).toBe(4.2); // MCP trusted server + + const mcpServerRule = rules.find( + (r) => r.toolName === 'mcp_mcp-server_*', + ); + expect(mcpServerRule?.priority).toBe(4.1); // MCP allowed server + + const readOnlyToolRule = rules.find( + (r) => r.toolName === 'glob' && !r.subagent, + ); + // Priority 50 in default tier → 1.05 (Overriding Plan Mode Deny) + expect(readOnlyToolRule?.priority).toBeCloseTo(1.05, 5); + + // Verify the engine applies these priorities correctly + expect( + (await engine.check({ name: 'blocked-tool' }, undefined)).decision, + ).toBe(PolicyDecision.DENY); + expect( + (await engine.check({ name: 'mcp_blocked-server_any' }, undefined)) + .decision, + ).toBe(PolicyDecision.DENY); + expect( + (await engine.check({ name: 'specific-tool' }, undefined)).decision, + ).toBe(PolicyDecision.ALLOW); + expect( + (await engine.check({ name: 'mcp_trusted-server_any' }, undefined)) + .decision, + ).toBe(PolicyDecision.ALLOW); + expect( + (await engine.check({ name: 'mcp_mcp-server_any' }, undefined)) + .decision, + ).toBe(PolicyDecision.ALLOW); + expect((await engine.check({ name: 'glob' }, undefined)).decision).toBe( + PolicyDecision.ALLOW, + ); + }); + + it('should handle edge case: MCP server with both trust and exclusion', async () => { + const settings: Settings = { + mcpServers: { + 'conflicted-server': { + command: 'node', + args: ['server.js'], + trust: true, // Priority 90 - ALLOW + }, + }, + mcp: { + excluded: ['conflicted-server'], // Priority 195 - DENY + }, + }; + + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); + const engine = new PolicyEngine(config); + + // Exclusion (195) should win over trust (90) + expect( + (await engine.check({ name: 'mcp_conflicted-server_tool' }, undefined)) + .decision, + ).toBe(PolicyDecision.DENY); + }); + + it('should handle edge case: specific tool allowed but server excluded', async () => { + const settings: Settings = { + mcp: { + excluded: ['my-server'], // Priority 195 - DENY + }, + tools: { + allowed: ['mcp_my-server_special-tool'], // Priority 100 - ALLOW + }, + }; + + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); + const engine = new PolicyEngine(config); + + // Server exclusion (195) wins over specific tool allow (100) + // This might be counterintuitive but follows the priority system + expect( + (await engine.check({ name: 'mcp_my-server_special-tool' }, undefined)) + .decision, + ).toBe(PolicyDecision.DENY); + expect( + (await engine.check({ name: 'mcp_my-server_other-tool' }, undefined)) + .decision, + ).toBe(PolicyDecision.DENY); + }); + + it('should verify non-interactive mode transformation', async () => { + const settings: Settings = {}; + + const engineConfig = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + undefined, + false, + ); + const engine = new PolicyEngine(engineConfig); + + // ASK_USER should become DENY in non-interactive mode + expect( + (await engine.check({ name: 'unknown_tool' }, undefined)).decision, + ).toBe(PolicyDecision.DENY); + expect( + (await engine.check({ name: 'run_shell_command' }, undefined)).decision, + ).toBe(PolicyDecision.DENY); + }); + + it('should handle empty settings gracefully', async () => { + const settings: Settings = {}; + + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); + const engine = new PolicyEngine(config); + + // Should have default rules for write tools + expect( + (await engine.check({ name: 'write_file' }, undefined)).decision, + ).toBe(PolicyDecision.ASK_USER); + expect( + (await engine.check({ name: 'replace' }, undefined)).decision, + ).toBe(PolicyDecision.ASK_USER); + + // Unknown tools should use default + expect( + (await engine.check({ name: 'unknown' }, undefined)).decision, + ).toBe(PolicyDecision.ASK_USER); + }); + + it('should verify rules are created with correct priorities', async () => { + const settings: Settings = { + tools: { + allowed: ['tool1', 'tool2'], + exclude: ['tool3'], + }, + mcp: { + allowed: ['server1'], + excluded: ['server2'], + }, + }; + + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); + const rules = config.rules || []; + + // Verify each rule has the expected priority + const tool3Rule = rules.find((r) => r.toolName === 'tool3'); + expect(tool3Rule?.priority).toBe(4.4); // Excluded tools (user tier) + + const server2Rule = rules.find((r) => r.toolName === 'mcp_server2_*'); + expect(server2Rule?.priority).toBe(4.9); // Excluded servers (user tier) + + const tool1Rule = rules.find((r) => r.toolName === 'tool1'); + expect(tool1Rule?.priority).toBe(4.3); // Allowed tools (user tier) + + const server1Rule = rules.find((r) => r.toolName === 'mcp_server1_*'); + expect(server1Rule?.priority).toBe(4.1); // Allowed servers (user tier) + + const globRule = rules.find((r) => r.toolName === 'glob' && !r.subagent); + // Priority 50 in default tier → 1.05 + expect(globRule?.priority).toBeCloseTo(1.05, 5); // Auto-accept read-only + + // The PolicyEngine will sort these by priority when it's created + const engine = new PolicyEngine(config); + const sortedRules = engine.getRules(); + + // Verify the engine sorted them correctly + for (let i = 1; i < sortedRules.length; i++) { + const prevPriority = sortedRules[i - 1].priority ?? 0; + const currPriority = sortedRules[i].priority ?? 0; + expect(prevPriority).toBeGreaterThanOrEqual(currPriority); + } + }); + }); +}); diff --git a/packages/cli/src/config/policy.test.ts b/packages/cli/src/config/policy.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..8d368bfb9199fd56785638485678d51170ec06f6 --- /dev/null +++ b/packages/cli/src/config/policy.test.ts @@ -0,0 +1,248 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { + resolveWorkspacePolicyState, + autoAcceptWorkspacePolicies, + setAutoAcceptWorkspacePolicies, + disableWorkspacePolicies, + setDisableWorkspacePolicies, +} from './policy.js'; +import { writeToStderr } from '@google/gemini-cli-core'; + +// Mock debugLogger to avoid noise in test output +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + debugLogger: { + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, + writeToStderr: vi.fn(), + }; +}); + +describe('resolveWorkspacePolicyState', () => { + let tempDir: string; + let workspaceDir: string; + let policiesDir: string; + + beforeEach(() => { + // Create a temporary directory for the test + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-cli-test-')); + // Redirect GEMINI_CLI_HOME to the temp directory to isolate integrity storage + vi.stubEnv('GEMINI_CLI_HOME', tempDir); + + workspaceDir = path.join(tempDir, 'workspace'); + fs.mkdirSync(workspaceDir); + policiesDir = path.join(workspaceDir, '.gemini', 'policies'); + + // Enable policies for these tests to verify loading logic + setDisableWorkspacePolicies(false); + + vi.clearAllMocks(); + }); + + afterEach(() => { + // Clean up temporary directory + fs.rmSync(tempDir, { recursive: true, force: true }); + vi.unstubAllEnvs(); + }); + + it('should return empty state if folder is not trusted', async () => { + const result = await resolveWorkspacePolicyState({ + cwd: workspaceDir, + trustedFolder: false, + interactive: true, + }); + + expect(result).toEqual({ + workspacePoliciesDir: undefined, + policyUpdateConfirmationRequest: undefined, + }); + }); + + it('should have disableWorkspacePolicies set to true by default', () => { + // We explicitly set it to false in beforeEach for other tests, + // so here we test that setting it to true works. + setDisableWorkspacePolicies(true); + expect(disableWorkspacePolicies).toBe(true); + }); + + it('should return policy directory if integrity matches', async () => { + // Set up policies directory with a file + fs.mkdirSync(policiesDir, { recursive: true }); + fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []'); + + // First call to establish integrity (interactive auto-accept) + const firstResult = await resolveWorkspacePolicyState({ + cwd: workspaceDir, + trustedFolder: true, + interactive: true, + }); + expect(firstResult.workspacePoliciesDir).toBe(policiesDir); + expect(firstResult.policyUpdateConfirmationRequest).toBeUndefined(); + expect(writeToStderr).not.toHaveBeenCalled(); + + // Second call should match + + const result = await resolveWorkspacePolicyState({ + cwd: workspaceDir, + trustedFolder: true, + interactive: true, + }); + + expect(result.workspacePoliciesDir).toBe(policiesDir); + expect(result.policyUpdateConfirmationRequest).toBeUndefined(); + }); + + it('should return undefined if integrity is NEW but fileCount is 0', async () => { + const result = await resolveWorkspacePolicyState({ + cwd: workspaceDir, + trustedFolder: true, + interactive: true, + }); + + expect(result.workspacePoliciesDir).toBeUndefined(); + expect(result.policyUpdateConfirmationRequest).toBeUndefined(); + }); + + it('should return confirmation request if changed in interactive mode when AUTO_ACCEPT is false', async () => { + const originalValue = autoAcceptWorkspacePolicies; + setAutoAcceptWorkspacePolicies(false); + + try { + fs.mkdirSync(policiesDir, { recursive: true }); + fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []'); + + const result = await resolveWorkspacePolicyState({ + cwd: workspaceDir, + trustedFolder: true, + interactive: true, + }); + + expect(result.workspacePoliciesDir).toBeUndefined(); + expect(result.policyUpdateConfirmationRequest).toEqual({ + scope: 'workspace', + identifier: workspaceDir, + policyDir: policiesDir, + newHash: expect.any(String), + }); + } finally { + setAutoAcceptWorkspacePolicies(originalValue); + } + }); + + it('should warn and auto-accept if changed in non-interactive mode when AUTO_ACCEPT is true', async () => { + fs.mkdirSync(policiesDir, { recursive: true }); + fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []'); + + const result = await resolveWorkspacePolicyState({ + cwd: workspaceDir, + trustedFolder: true, + interactive: false, + }); + + expect(result.workspacePoliciesDir).toBe(policiesDir); + expect(result.policyUpdateConfirmationRequest).toBeUndefined(); + expect(writeToStderr).toHaveBeenCalledWith( + expect.stringContaining('Automatically accepting and loading'), + ); + }); + + it('should warn and auto-accept if changed in non-interactive mode when AUTO_ACCEPT is false', async () => { + const originalValue = autoAcceptWorkspacePolicies; + setAutoAcceptWorkspacePolicies(false); + + try { + fs.mkdirSync(policiesDir, { recursive: true }); + fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []'); + + const result = await resolveWorkspacePolicyState({ + cwd: workspaceDir, + trustedFolder: true, + interactive: false, + }); + + expect(result.workspacePoliciesDir).toBe(policiesDir); + expect(result.policyUpdateConfirmationRequest).toBeUndefined(); + expect(writeToStderr).toHaveBeenCalledWith( + expect.stringContaining('Automatically accepting and loading'), + ); + } finally { + setAutoAcceptWorkspacePolicies(originalValue); + } + }); + it('should not return workspace policies if cwd is the home directory', async () => { + const policiesDir = path.join(tempDir, '.gemini', 'policies'); + fs.mkdirSync(policiesDir, { recursive: true }); + fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []'); + + // Run from HOME directory (tempDir is mocked as HOME in beforeEach) + const result = await resolveWorkspacePolicyState({ + cwd: tempDir, + trustedFolder: true, + interactive: true, + }); + + expect(result.workspacePoliciesDir).toBeUndefined(); + expect(result.policyUpdateConfirmationRequest).toBeUndefined(); + }); + + it('should return empty state if disableWorkspacePolicies is true even if folder is trusted', async () => { + setDisableWorkspacePolicies(true); + + // Set up policies directory with a file + fs.mkdirSync(policiesDir, { recursive: true }); + fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []'); + + const result = await resolveWorkspacePolicyState({ + cwd: workspaceDir, + trustedFolder: true, + interactive: true, + }); + + expect(result).toEqual({ + workspacePoliciesDir: undefined, + policyUpdateConfirmationRequest: undefined, + }); + }); + + it('should return empty state if cwd is a symlink to the home directory', async () => { + const policiesDir = path.join(tempDir, '.gemini', 'policies'); + fs.mkdirSync(policiesDir, { recursive: true }); + fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []'); + + // Create a symlink to the home directory + const symlinkDir = path.join( + os.tmpdir(), + `gemini-cli-symlink-${Date.now()}`, + ); + fs.symlinkSync(tempDir, symlinkDir, 'dir'); + + try { + // Run from symlink to HOME directory + const result = await resolveWorkspacePolicyState({ + cwd: symlinkDir, + trustedFolder: true, + interactive: true, + }); + + expect(result.workspacePoliciesDir).toBeUndefined(); + expect(result.policyUpdateConfirmationRequest).toBeUndefined(); + } finally { + // Clean up symlink + fs.unlinkSync(symlinkDir); + } + }); +}); diff --git a/packages/cli/src/config/policy.ts b/packages/cli/src/config/policy.ts new file mode 100644 index 0000000000000000000000000000000000000000..317d2e848d7d6f29862c22311191f803b51c2180 --- /dev/null +++ b/packages/cli/src/config/policy.ts @@ -0,0 +1,163 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + type PolicyEngineConfig, + type ApprovalMode, + type PolicyEngine, + type MessageBus, + type PolicySettings, + createPolicyEngineConfig as createCorePolicyEngineConfig, + createPolicyUpdater as createCorePolicyUpdater, + PolicyIntegrityManager, + IntegrityStatus, + Storage, + type PolicyUpdateConfirmationRequest, + writeToStderr, + debugLogger, +} from '@google/gemini-cli-core'; +import { type Settings } from './settings.js'; + +/** + * Temporary flag to automatically accept workspace policies to reduce friction. + * Exported as 'let' to allow monkey patching in tests via the setter. + */ +export let autoAcceptWorkspacePolicies = true; + +/** + * Sets the autoAcceptWorkspacePolicies flag. + * Used primarily for testing purposes. + */ +export function setAutoAcceptWorkspacePolicies(value: boolean) { + autoAcceptWorkspacePolicies = value; +} + +/** + * Temporary flag to disable workspace level policies altogether. + * Exported as 'let' to allow monkey patching in tests via the setter. + */ +export let disableWorkspacePolicies = true; + +/** + * Sets the disableWorkspacePolicies flag. + * Used primarily for testing purposes. + */ +export function setDisableWorkspacePolicies(value: boolean) { + disableWorkspacePolicies = value; +} + +export async function createPolicyEngineConfig( + settings: Settings, + approvalMode: ApprovalMode, + workspacePoliciesDir?: string, + interactive: boolean = true, +): Promise { + // Explicitly construct PolicySettings from Settings to ensure type safety + // and avoid accidental leakage of other settings properties. + const policySettings: PolicySettings = { + mcp: settings.mcp, + tools: settings.tools, + mcpServers: settings.mcpServers, + policyPaths: settings.policyPaths, + adminPolicyPaths: settings.adminPolicyPaths, + workspacePoliciesDir, + disableAlwaysAllow: + settings.security?.disableAlwaysAllow || + settings.admin?.secureModeEnabled, + }; + + return createCorePolicyEngineConfig( + policySettings, + approvalMode, + undefined, + interactive, + ); +} + +export function createPolicyUpdater( + policyEngine: PolicyEngine, + messageBus: MessageBus, + storage: Storage, +) { + return createCorePolicyUpdater(policyEngine, messageBus, storage); +} + +export interface WorkspacePolicyState { + workspacePoliciesDir?: string; + policyUpdateConfirmationRequest?: PolicyUpdateConfirmationRequest; +} + +/** + * Resolves the workspace policy state by checking folder trust and policy integrity. + */ +export async function resolveWorkspacePolicyState(options: { + cwd: string; + trustedFolder: boolean; + interactive: boolean; +}): Promise { + const { cwd, trustedFolder, interactive } = options; + + let workspacePoliciesDir: string | undefined; + let policyUpdateConfirmationRequest: + | PolicyUpdateConfirmationRequest + | undefined; + + if (trustedFolder && !disableWorkspacePolicies) { + const storage = new Storage(cwd); + + // If we are in the home directory (or rather, our target Gemini dir is the global one), + // don't treat it as a workspace to avoid loading global policies twice. + if (storage.isWorkspaceHomeDir()) { + return { workspacePoliciesDir: undefined }; + } + + const potentialWorkspacePoliciesDir = storage.getWorkspacePoliciesDir(); + const integrityManager = new PolicyIntegrityManager(); + const integrityResult = await integrityManager.checkIntegrity( + 'workspace', + cwd, + potentialWorkspacePoliciesDir, + ); + + if (integrityResult.status === IntegrityStatus.MATCH) { + workspacePoliciesDir = potentialWorkspacePoliciesDir; + } else if ( + integrityResult.status === IntegrityStatus.NEW && + integrityResult.fileCount === 0 + ) { + // No workspace policies found + workspacePoliciesDir = undefined; + } else if (interactive && !autoAcceptWorkspacePolicies) { + // Policies changed or are new, and we are in interactive mode and auto-accept is disabled + policyUpdateConfirmationRequest = { + scope: 'workspace', + identifier: cwd, + policyDir: potentialWorkspacePoliciesDir, + newHash: integrityResult.hash, + }; + } else { + // Non-interactive mode or auto-accept is enabled: automatically accept/load + await integrityManager.acceptIntegrity( + 'workspace', + cwd, + integrityResult.hash, + ); + workspacePoliciesDir = potentialWorkspacePoliciesDir; + + if (!interactive) { + writeToStderr( + 'WARNING: Workspace policies changed or are new. Automatically accepting and loading them.\n', + ); + } else { + debugLogger.warn( + 'Workspace policies changed or are new. Automatically accepting and loading them.', + ); + } + } + } + + return { workspacePoliciesDir, policyUpdateConfirmationRequest }; +} diff --git a/packages/cli/src/config/sandboxConfig.test.ts b/packages/cli/src/config/sandboxConfig.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..34c97d6ecdee583c82a620d9ab2a2726ed632523 --- /dev/null +++ b/packages/cli/src/config/sandboxConfig.test.ts @@ -0,0 +1,512 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { getPackageJson } from '@google/gemini-cli-core'; +import commandExists from 'command-exists'; +import * as os from 'node:os'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { loadSandboxConfig } from './sandboxConfig.js'; + +// Mock dependencies +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...(actual as object), + getPackageJson: vi.fn(), + FatalSandboxError: class extends Error { + constructor(message: string) { + super(message); + this.name = 'FatalSandboxError'; + } + }, + }; +}); + +vi.mock('command-exists', () => { + const sync = vi.fn(); + return { + sync, + default: { + sync, + }, + }; +}); + +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...(actual as object), + platform: vi.fn(), + }; +}); + +const mockedGetPackageJson = vi.mocked(getPackageJson); +const mockedCommandExistsSync = vi.mocked(commandExists.sync); +const mockedOsPlatform = vi.mocked(os.platform); + +describe('loadSandboxConfig', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + vi.resetAllMocks(); + process.env = { ...originalEnv }; + delete process.env['SANDBOX']; + delete process.env['GEMINI_SANDBOX']; + mockedGetPackageJson.mockResolvedValue({ + config: { sandboxImageUri: 'default/image' }, + }); + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('should return undefined if sandbox is explicitly disabled via argv', async () => { + const config = await loadSandboxConfig({}, { sandbox: false }); + expect(config).toBeUndefined(); + }); + + it('should return undefined if sandbox is explicitly disabled via settings', async () => { + const config = await loadSandboxConfig({ tools: { sandbox: false } }, {}); + expect(config).toBeUndefined(); + }); + + it('should return undefined if sandbox is not configured', async () => { + const config = await loadSandboxConfig({}, {}); + expect(config).toBeUndefined(); + }); + + it('should return undefined if already inside a sandbox (SANDBOX env var is set)', async () => { + process.env['SANDBOX'] = '1'; + const config = await loadSandboxConfig({}, { sandbox: true }); + expect(config).toBeUndefined(); + }); + + describe('with GEMINI_SANDBOX environment variable', () => { + it('should use docker if GEMINI_SANDBOX=docker and it exists', async () => { + process.env['GEMINI_SANDBOX'] = 'docker'; + mockedCommandExistsSync.mockReturnValue(true); + const config = await loadSandboxConfig({}, {}); + expect(config).toEqual({ + enabled: true, + allowedPaths: [], + networkAccess: true, + command: 'docker', + image: 'default/image', + }); + expect(mockedCommandExistsSync).toHaveBeenCalledWith('docker'); + }); + + it('should throw if GEMINI_SANDBOX is an invalid command', async () => { + process.env['GEMINI_SANDBOX'] = 'invalid-command'; + await expect(loadSandboxConfig({}, {})).rejects.toThrow( + "Invalid sandbox command 'invalid-command'. Must be one of docker, podman, sandbox-exec, runsc, lxc", + ); + }); + + it('should throw if GEMINI_SANDBOX command does not exist', async () => { + process.env['GEMINI_SANDBOX'] = 'docker'; + mockedCommandExistsSync.mockReturnValue(false); + await expect(loadSandboxConfig({}, {})).rejects.toThrow( + "Missing sandbox command 'docker' (from GEMINI_SANDBOX)", + ); + }); + + it('should use lxc if GEMINI_SANDBOX=lxc and it exists', async () => { + process.env['GEMINI_SANDBOX'] = 'lxc'; + mockedCommandExistsSync.mockReturnValue(true); + const config = await loadSandboxConfig({}, {}); + expect(config).toEqual({ + enabled: true, + allowedPaths: [], + networkAccess: true, + command: 'lxc', + image: 'default/image', + }); + expect(mockedCommandExistsSync).toHaveBeenCalledWith('lxc'); + }); + + it('should throw if GEMINI_SANDBOX=lxc but lxc command does not exist', async () => { + process.env['GEMINI_SANDBOX'] = 'lxc'; + mockedCommandExistsSync.mockReturnValue(false); + await expect(loadSandboxConfig({}, {})).rejects.toThrow( + "Missing sandbox command 'lxc' (from GEMINI_SANDBOX)", + ); + }); + }); + + describe('with sandbox: true', () => { + it('should use sandbox-exec on darwin if available', async () => { + mockedOsPlatform.mockReturnValue('darwin'); + mockedCommandExistsSync.mockImplementation( + (cmd) => cmd === 'sandbox-exec', + ); + const config = await loadSandboxConfig({}, { sandbox: true }); + expect(config).toEqual({ + enabled: true, + allowedPaths: [], + networkAccess: true, + command: 'sandbox-exec', + image: 'default/image', + }); + }); + + it('should prefer sandbox-exec over docker on darwin', async () => { + mockedOsPlatform.mockReturnValue('darwin'); + mockedCommandExistsSync.mockReturnValue(true); // all commands exist + const config = await loadSandboxConfig({}, { sandbox: true }); + expect(config).toEqual({ + enabled: true, + allowedPaths: [], + networkAccess: true, + command: 'sandbox-exec', + image: 'default/image', + }); + }); + + it('should use docker if available and sandbox is true', async () => { + mockedOsPlatform.mockReturnValue('linux'); + mockedCommandExistsSync.mockImplementation((cmd) => cmd === 'docker'); + const config = await loadSandboxConfig({ tools: { sandbox: true } }, {}); + expect(config).toEqual({ + enabled: true, + allowedPaths: [], + networkAccess: true, + command: 'docker', + image: 'default/image', + }); + }); + + it('should use podman if available and docker is not', async () => { + mockedOsPlatform.mockReturnValue('linux'); + mockedCommandExistsSync.mockImplementation((cmd) => cmd === 'podman'); + const config = await loadSandboxConfig({}, { sandbox: true }); + expect(config).toEqual({ + enabled: true, + allowedPaths: [], + networkAccess: true, + command: 'podman', + image: 'default/image', + }); + }); + + it('should throw if sandbox: true but no command is found', async () => { + mockedOsPlatform.mockReturnValue('linux'); + mockedCommandExistsSync.mockReturnValue(false); + await expect(loadSandboxConfig({}, { sandbox: true })).rejects.toThrow( + 'GEMINI_SANDBOX is true but failed to determine command for sandbox; ' + + 'install docker or podman or specify command in GEMINI_SANDBOX', + ); + }); + }); + + describe("with sandbox: 'command'", () => { + it('should use the specified command if it exists', async () => { + mockedCommandExistsSync.mockReturnValue(true); + const config = await loadSandboxConfig({}, { sandbox: 'podman' }); + expect(config).toEqual({ + enabled: true, + allowedPaths: [], + networkAccess: true, + command: 'podman', + image: 'default/image', + }); + expect(mockedCommandExistsSync).toHaveBeenCalledWith('podman'); + }); + + it('should throw if the specified command does not exist', async () => { + mockedCommandExistsSync.mockReturnValue(false); + await expect( + loadSandboxConfig({}, { sandbox: 'podman' }), + ).rejects.toThrow( + "Missing sandbox command 'podman' (from GEMINI_SANDBOX)", + ); + }); + + it('should throw if the specified command is invalid', async () => { + await expect( + loadSandboxConfig({}, { sandbox: 'invalid-command' }), + ).rejects.toThrow( + "Invalid sandbox command 'invalid-command'. Must be one of docker, podman, sandbox-exec, runsc, lxc", + ); + }); + }); + + describe('image configuration', () => { + it('should use image from GEMINI_SANDBOX_IMAGE env var if set', async () => { + process.env['GEMINI_SANDBOX_IMAGE'] = 'env/image'; + process.env['GEMINI_SANDBOX'] = 'docker'; + mockedCommandExistsSync.mockReturnValue(true); + const config = await loadSandboxConfig({}, {}); + expect(config).toEqual({ + enabled: true, + allowedPaths: [], + networkAccess: true, + command: 'docker', + image: 'env/image', + }); + }); + + it('should use image from package.json if env var is not set', async () => { + process.env['GEMINI_SANDBOX'] = 'docker'; + mockedCommandExistsSync.mockReturnValue(true); + const config = await loadSandboxConfig({}, {}); + expect(config).toEqual({ + enabled: true, + allowedPaths: [], + networkAccess: true, + command: 'docker', + image: 'default/image', + }); + }); + + it('should return undefined if command is found but no image is configured', async () => { + mockedGetPackageJson.mockResolvedValue({}); // no sandboxImageUri + process.env['GEMINI_SANDBOX'] = 'docker'; + mockedCommandExistsSync.mockReturnValue(true); + const config = await loadSandboxConfig({}, {}); + expect(config).toBeUndefined(); + }); + }); + + describe('truthy/falsy sandbox values', () => { + beforeEach(() => { + mockedOsPlatform.mockReturnValue('linux'); + mockedCommandExistsSync.mockImplementation((cmd) => cmd === 'docker'); + }); + + it.each([true, 'true', '1'])( + 'should enable sandbox for value: %s', + async (value) => { + const config = await loadSandboxConfig({}, { sandbox: value }); + expect(config).toEqual({ + enabled: true, + allowedPaths: [], + networkAccess: true, + command: 'docker', + image: 'default/image', + }); + }, + ); + + it.each([false, 'false', '0', undefined, null, ''])( + 'should disable sandbox for value: %s', + async (value) => { + // `null` is not a valid type for the arg, but good to test falsiness + const config = await loadSandboxConfig({}, { sandbox: value }); + expect(config).toBeUndefined(); + }, + ); + }); + + describe('with SandboxConfig object in settings', () => { + beforeEach(() => { + mockedOsPlatform.mockReturnValue('linux'); + mockedCommandExistsSync.mockImplementation((cmd) => cmd === 'docker'); + }); + + it('should support object structure with enabled: true', async () => { + const config = await loadSandboxConfig( + { + tools: { + sandbox: { + enabled: true, + allowedPaths: ['/tmp'], + networkAccess: true, + }, + }, + }, + {}, + ); + expect(config).toEqual({ + enabled: true, + allowedPaths: ['/tmp'], + networkAccess: true, + command: 'docker', + image: 'default/image', + }); + }); + + it('should support object structure with explicit command', async () => { + mockedCommandExistsSync.mockImplementation((cmd) => cmd === 'podman'); + const config = await loadSandboxConfig( + { + tools: { + sandbox: { + enabled: true, + command: 'podman', + allowedPaths: [], + networkAccess: true, + }, + }, + }, + {}, + ); + expect(config?.command).toBe('podman'); + }); + + it('should support object structure with custom image', async () => { + const config = await loadSandboxConfig( + { + tools: { + sandbox: { + enabled: true, + image: 'custom/image', + allowedPaths: [], + networkAccess: true, + }, + }, + }, + {}, + ); + expect(config?.image).toBe('custom/image'); + }); + + it('should return undefined if enabled is false in object', async () => { + const config = await loadSandboxConfig( + { + tools: { + sandbox: { + enabled: false, + allowedPaths: [], + networkAccess: true, + }, + }, + }, + {}, + ); + expect(config).toBeUndefined(); + }); + + it('should prioritize CLI flag over settings object', async () => { + const config = await loadSandboxConfig( + { + tools: { + sandbox: { + enabled: true, + allowedPaths: ['/settings-path'], + networkAccess: true, + }, + }, + }, + { sandbox: false }, + ); + expect(config).toBeUndefined(); + }); + }); + + describe('with sandbox: runsc (gVisor)', () => { + beforeEach(() => { + mockedOsPlatform.mockReturnValue('linux'); + mockedCommandExistsSync.mockReturnValue(true); + }); + + it('should use runsc via CLI argument on Linux', async () => { + const config = await loadSandboxConfig({}, { sandbox: 'runsc' }); + + expect(config).toEqual({ + enabled: true, + allowedPaths: [], + networkAccess: true, + command: 'runsc', + image: 'default/image', + }); + expect(mockedCommandExistsSync).toHaveBeenCalledWith('runsc'); + expect(mockedCommandExistsSync).toHaveBeenCalledWith('docker'); + }); + + it('should use runsc via GEMINI_SANDBOX environment variable', async () => { + process.env['GEMINI_SANDBOX'] = 'runsc'; + const config = await loadSandboxConfig({}, {}); + + expect(config).toEqual({ + enabled: true, + allowedPaths: [], + networkAccess: true, + command: 'runsc', + image: 'default/image', + }); + expect(mockedCommandExistsSync).toHaveBeenCalledWith('runsc'); + expect(mockedCommandExistsSync).toHaveBeenCalledWith('docker'); + }); + + it('should use runsc via settings file', async () => { + const config = await loadSandboxConfig( + { tools: { sandbox: 'runsc' } }, + {}, + ); + + expect(config).toEqual({ + enabled: true, + allowedPaths: [], + networkAccess: true, + command: 'runsc', + image: 'default/image', + }); + expect(mockedCommandExistsSync).toHaveBeenCalledWith('runsc'); + expect(mockedCommandExistsSync).toHaveBeenCalledWith('docker'); + }); + + it('should prioritize GEMINI_SANDBOX over CLI and settings', async () => { + process.env['GEMINI_SANDBOX'] = 'runsc'; + const config = await loadSandboxConfig( + { tools: { sandbox: 'docker' } }, + { sandbox: 'podman' }, + ); + + expect(config).toEqual({ + enabled: true, + allowedPaths: [], + networkAccess: true, + command: 'runsc', + image: 'default/image', + }); + }); + + it('should reject runsc on macOS (Linux-only)', async () => { + mockedOsPlatform.mockReturnValue('darwin'); + + await expect(loadSandboxConfig({}, { sandbox: 'runsc' })).rejects.toThrow( + 'gVisor (runsc) sandboxing is only supported on Linux', + ); + }); + + it('should reject runsc on Windows (Linux-only)', async () => { + mockedOsPlatform.mockReturnValue('win32'); + + await expect(loadSandboxConfig({}, { sandbox: 'runsc' })).rejects.toThrow( + 'gVisor (runsc) sandboxing is only supported on Linux', + ); + }); + + it('should throw if runsc binary not found', async () => { + mockedCommandExistsSync.mockReturnValue(false); + + await expect(loadSandboxConfig({}, { sandbox: 'runsc' })).rejects.toThrow( + "Missing sandbox command 'runsc' (from GEMINI_SANDBOX)", + ); + }); + + it('should throw if Docker not available (runsc requires Docker)', async () => { + mockedCommandExistsSync.mockImplementation((cmd) => cmd === 'runsc'); + + await expect(loadSandboxConfig({}, { sandbox: 'runsc' })).rejects.toThrow( + "runsc (gVisor) requires Docker. Install Docker, or use sandbox: 'docker'.", + ); + }); + + it('should NOT auto-detect runsc when both runsc and docker available', async () => { + mockedCommandExistsSync.mockImplementation( + (cmd) => cmd === 'runsc' || cmd === 'docker', + ); + + const config = await loadSandboxConfig({}, { sandbox: true }); + + expect(config?.command).toBe('docker'); + expect(config?.command).not.toBe('runsc'); + }); + }); +}); diff --git a/packages/cli/src/config/sandboxConfig.ts b/packages/cli/src/config/sandboxConfig.ts new file mode 100644 index 0000000000000000000000000000000000000000..07685e9beaffe72b0c41db599355dfb9cc6f12f0 --- /dev/null +++ b/packages/cli/src/config/sandboxConfig.ts @@ -0,0 +1,168 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + getPackageJson, + type SandboxConfig, + FatalSandboxError, +} from '@google/gemini-cli-core'; +import commandExists from 'command-exists'; +import * as os from 'node:os'; +import type { Settings } from './settings.js'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// This is a stripped-down version of the CliArgs interface from config.ts +// to avoid circular dependencies. +interface SandboxCliArgs { + sandbox?: boolean | string | null; +} +const VALID_SANDBOX_COMMANDS = [ + 'docker', + 'podman', + 'sandbox-exec', + 'runsc', + 'lxc', + 'windows-native', +]; + +function isSandboxCommand( + value: string, +): value is Exclude { + return (VALID_SANDBOX_COMMANDS as ReadonlyArray).includes( + value, + ); +} + +function getSandboxCommand( + sandbox?: boolean | string | null, +): SandboxConfig['command'] | '' { + // If the SANDBOX env var is set, we're already inside the sandbox. + if (process.env['SANDBOX']) { + return ''; + } + + // note environment variable takes precedence over argument (from command line or settings) + const environmentConfiguredSandbox = + process.env['GEMINI_SANDBOX']?.toLowerCase().trim() ?? ''; + sandbox = + environmentConfiguredSandbox?.length > 0 + ? environmentConfiguredSandbox + : sandbox; + if (sandbox === '1' || sandbox === 'true') sandbox = true; + else if (sandbox === '0' || sandbox === 'false' || !sandbox) sandbox = false; + + if (sandbox === false) { + return ''; + } + + if (typeof sandbox === 'string' && sandbox) { + if (!isSandboxCommand(sandbox)) { + throw new FatalSandboxError( + `Invalid sandbox command '${sandbox}'. Must be one of ${VALID_SANDBOX_COMMANDS.join( + ', ', + )}`, + ); + } + // runsc (gVisor) is only supported on Linux + if (sandbox === 'runsc' && os.platform() !== 'linux') { + throw new FatalSandboxError( + 'gVisor (runsc) sandboxing is only supported on Linux', + ); + } + // windows-native is only supported on Windows + if (sandbox === 'windows-native' && os.platform() !== 'win32') { + throw new FatalSandboxError( + 'Windows native sandboxing is only supported on Windows', + ); + } + + // confirm that specified command exists (unless it's built-in) + if (sandbox !== 'windows-native' && !commandExists.sync(sandbox)) { + throw new FatalSandboxError( + `Missing sandbox command '${sandbox}' (from GEMINI_SANDBOX)`, + ); + } + // runsc uses Docker with --runtime=runsc; both must be available (prioritize runsc when explicitly chosen) + if (sandbox === 'runsc' && !commandExists.sync('docker')) { + throw new FatalSandboxError( + "runsc (gVisor) requires Docker. Install Docker, or use sandbox: 'docker'.", + ); + } + return sandbox; + } + + // look for seatbelt, docker, or podman, in that order + // for container-based sandboxing, require sandbox to be enabled explicitly + // note: runsc is NOT auto-detected, it must be explicitly specified + if (os.platform() === 'darwin' && commandExists.sync('sandbox-exec')) { + return 'sandbox-exec'; + } else if (commandExists.sync('docker') && sandbox === true) { + return 'docker'; + } else if (commandExists.sync('podman') && sandbox === true) { + return 'podman'; + } + + // throw an error if user requested sandbox but no command was found + if (sandbox === true) { + throw new FatalSandboxError( + 'GEMINI_SANDBOX is true but failed to determine command for sandbox; ' + + 'install docker or podman or specify command in GEMINI_SANDBOX', + ); + } + + return ''; + // Note: 'lxc' is intentionally not auto-detected because it requires a + // pre-existing, running container managed by the user. Use + // GEMINI_SANDBOX=lxc or sandbox: "lxc" in settings to enable it. +} + +export async function loadSandboxConfig( + settings: Settings, + argv: SandboxCliArgs, +): Promise { + const sandboxOption = argv.sandbox ?? settings.tools?.sandbox; + + let sandboxValue: boolean | string | null | undefined; + let allowedPaths: string[] = []; + let networkAccess = true; + let customImage: string | undefined; + + if ( + typeof sandboxOption === 'object' && + sandboxOption !== null && + !Array.isArray(sandboxOption) + ) { + const config = sandboxOption; + sandboxValue = config.enabled ? (config.command ?? true) : false; + allowedPaths = config.allowedPaths ?? []; + networkAccess = config.networkAccess ?? true; + customImage = config.image; + } else if (typeof sandboxOption !== 'object' || sandboxOption === null) { + sandboxValue = sandboxOption; + } + + const command = getSandboxCommand(sandboxValue); + + const packageJson = await getPackageJson(__dirname); + const image = + process.env['GEMINI_SANDBOX_IMAGE'] ?? + process.env['GEMINI_SANDBOX_IMAGE_DEFAULT'] ?? + customImage ?? + packageJson?.config?.sandboxImageUri; + + const isNative = + command === 'windows-native' || + command === 'sandbox-exec' || + command === 'lxc'; + + return command && (image || isNative) + ? { enabled: true, allowedPaths, networkAccess, command, image } + : undefined; +} diff --git a/packages/cli/src/config/settingPaths.test.ts b/packages/cli/src/config/settingPaths.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..0c98a7c83ad0fc1887bc1f26726842077a0a0944 --- /dev/null +++ b/packages/cli/src/config/settingPaths.test.ts @@ -0,0 +1,26 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { SettingPaths } from './settingPaths.js'; + +describe('SettingPaths', () => { + it('should have the correct structure', () => { + expect(SettingPaths).toEqual({ + General: { + PreferredEditor: 'general.preferredEditor', + }, + }); + }); + + it('should be immutable', () => { + expect(Object.isFrozen(SettingPaths)).toBe(false); // It's not frozen by default in JS unless Object.freeze is called, but it's `as const` in TS. + // However, we can check if the values are correct. + expect(SettingPaths.General.PreferredEditor).toBe( + 'general.preferredEditor', + ); + }); +}); diff --git a/packages/cli/src/config/settingPaths.ts b/packages/cli/src/config/settingPaths.ts new file mode 100644 index 0000000000000000000000000000000000000000..9ba046d9ff0167d0f3a909798445ebf12f03bd18 --- /dev/null +++ b/packages/cli/src/config/settingPaths.ts @@ -0,0 +1,11 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export const SettingPaths = { + General: { + PreferredEditor: 'general.preferredEditor', + }, +} as const; diff --git a/packages/cli/src/config/settings-env-isolation.test.ts b/packages/cli/src/config/settings-env-isolation.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..526b85ef855cdf900ef2f2bde3fb1f843bfa007b --- /dev/null +++ b/packages/cli/src/config/settings-env-isolation.test.ts @@ -0,0 +1,238 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as path from 'node:path'; +import type * as osActual from 'node:os'; + +vi.mock('node:os', async (importOriginal) => { + const actualOs = await importOriginal(); + return { + ...actualOs, + homedir: vi.fn(() => path.resolve('/mock/home')), + platform: vi.fn(() => 'linux'), + }; +}); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + homedir: vi.fn(() => path.resolve('/mock/home')), + }; +}); + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import { loadEnvironment, type Settings } from './settings.js'; +import { GEMINI_DIR, homedir as coreHomedir } from '@google/gemini-cli-core'; + +vi.mock('node:fs'); + +describe('Environment Isolation', () => { + const mockHome = path.resolve('/mock/home'); + const mockWorkspace = path.resolve('/mock/workspace'); + const originalArgv = process.argv; + const originalEnv = { ...process.env }; + + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(os.homedir).mockReturnValue(mockHome); + vi.mocked(coreHomedir).mockReturnValue(mockHome); + // Default to no files existing + vi.mocked(fs.existsSync).mockReturnValue(false); + process.argv = ['node', 'gemini']; + + // Clear env vars that might leak from the host environment + delete process.env['GEMINI_API_KEY']; + delete process.env['OTHER_VAR']; + }); + + afterEach(() => { + process.argv = originalArgv; + process.env = { ...originalEnv }; + }); + + it('should load local .env by default', () => { + const workspaceEnv = path.join(mockWorkspace, '.env'); + vi.mocked(fs.existsSync).mockImplementation( + (p) => p.toString() === workspaceEnv, + ); + vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=local'); + + const settings = { advanced: { ignoreLocalEnv: false } } as Settings; + loadEnvironment(settings, mockWorkspace, () => ({ + isTrusted: true, + source: 'file', + })); + + expect(process.env['GEMINI_API_KEY']).toBe('local'); + delete process.env['GEMINI_API_KEY']; + }); + + it('should ignore local .env when ignoreLocalEnv is true', () => { + const workspaceEnv = path.join(mockWorkspace, '.env'); + const homeEnv = path.join(mockHome, '.env'); + + vi.mocked(fs.existsSync).mockImplementation((p) => { + const ps = p.toString(); + return ps === workspaceEnv || ps === homeEnv; + }); + vi.mocked(fs.readFileSync).mockImplementation((p) => { + const ps = p.toString(); + if (ps === workspaceEnv) return 'GEMINI_API_KEY=local'; + if (ps === homeEnv) return 'GEMINI_API_KEY=home'; + return ''; + }); + + const settings = { advanced: { ignoreLocalEnv: true } } as Settings; + loadEnvironment(settings, mockWorkspace, () => ({ + isTrusted: true, + source: 'file', + })); + + // Should skip local and find home + expect(process.env['GEMINI_API_KEY']).toBe('home'); + delete process.env['GEMINI_API_KEY']; + }); + + it('should still load .gemini/.env even if ignoreLocalEnv is true', () => { + const workspaceGeminiEnv = path.join(mockWorkspace, GEMINI_DIR, '.env'); + vi.mocked(fs.existsSync).mockImplementation( + (p) => p.toString() === workspaceGeminiEnv, + ); + vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=gemini-local'); + + const settings = { advanced: { ignoreLocalEnv: true } } as Settings; + loadEnvironment(settings, mockWorkspace, () => ({ + isTrusted: true, + source: 'file', + })); + + expect(process.env['GEMINI_API_KEY']).toBe('gemini-local'); + delete process.env['GEMINI_API_KEY']; + }); + + it('should respect --ignore-env flag', () => { + const workspaceEnv = path.join(mockWorkspace, '.env'); + vi.mocked(fs.existsSync).mockImplementation( + (p) => p.toString() === workspaceEnv, + ); + vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=local'); + + process.argv = ['node', 'gemini', '--ignore-env']; + const settings = { advanced: { ignoreLocalEnv: false } } as Settings; + loadEnvironment(settings, mockWorkspace, () => ({ + isTrusted: true, + source: 'file', + })); + + expect(process.env['GEMINI_API_KEY']).toBeUndefined(); + }); + + it('should allow home .env even with ignoreLocalEnv true', () => { + const homeEnv = path.join(mockHome, '.env'); + vi.mocked(fs.existsSync).mockImplementation( + (p) => p.toString() === homeEnv, + ); + vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=home'); + + const settings = { advanced: { ignoreLocalEnv: true } } as Settings; + // Running from home dir + loadEnvironment(settings, mockHome, () => ({ + isTrusted: true, + source: 'file', + })); + + expect(process.env['GEMINI_API_KEY']).toBe('home'); + delete process.env['GEMINI_API_KEY']; + }); + + it('should skip local .env and its parents until home when ignoreLocalEnv is true', () => { + const deepProject = path.join(mockWorkspace, 'deep', 'dir'); + const deepEnv = path.join(deepProject, '.env'); + const parentEnv = path.join(mockWorkspace, '.env'); + const homeEnv = path.join(mockHome, '.env'); + + vi.mocked(fs.existsSync).mockImplementation((p) => { + const ps = p.toString(); + return ps === deepEnv || ps === parentEnv || ps === homeEnv; + }); + vi.mocked(fs.readFileSync).mockImplementation((p) => { + const ps = p.toString(); + if (ps === deepEnv) return 'GEMINI_API_KEY=deep'; + if (ps === parentEnv) return 'GEMINI_API_KEY=parent'; + if (ps === homeEnv) return 'GEMINI_API_KEY=home'; + return ''; + }); + + const settings = { advanced: { ignoreLocalEnv: true } } as Settings; + loadEnvironment(settings, deepProject, () => ({ + isTrusted: true, + source: 'file', + })); + + expect(process.env['GEMINI_API_KEY']).toBe('home'); + delete process.env['GEMINI_API_KEY']; + }); + + it('should respect trust whitelist even when loading from home .env', () => { + const homeEnv = path.join(mockHome, '.env'); + vi.mocked(fs.existsSync).mockImplementation( + (p) => p.toString() === homeEnv, + ); + // Include one whitelisted and one non-whitelisted variable + vi.mocked(fs.readFileSync).mockReturnValue( + 'GEMINI_API_KEY=home\nOTHER_VAR=secret', + ); + + const settings = { advanced: { ignoreLocalEnv: true } } as Settings; + // Running from an UNTRUSTED workspace + loadEnvironment(settings, mockWorkspace, () => ({ + isTrusted: false, + source: 'file', + })); + + expect(process.env['GEMINI_API_KEY']).toBe('home'); + expect(process.env['OTHER_VAR']).toBeUndefined(); + delete process.env['GEMINI_API_KEY']; + }); + + it('should prioritize --ignore-env flag even if setting is false', () => { + const workspaceEnv = path.join(mockWorkspace, '.env'); + vi.mocked(fs.existsSync).mockImplementation( + (p) => p.toString() === workspaceEnv, + ); + vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=local'); + + process.argv = ['node', 'gemini', '--ignore-env']; + const settings = { advanced: { ignoreLocalEnv: false } } as Settings; + loadEnvironment(settings, mockWorkspace, () => ({ + isTrusted: true, + source: 'file', + })); + + expect(process.env['GEMINI_API_KEY']).toBeUndefined(); + }); + + it('should respect both -s and --ignore-env flags simultaneously', () => { + const workspaceEnv = path.join(mockWorkspace, '.env'); + vi.mocked(fs.existsSync).mockImplementation( + (p) => p.toString() === workspaceEnv, + ); + vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=local'); + + process.argv = ['node', 'gemini', '-s', '--ignore-env']; + const settings = { advanced: { ignoreLocalEnv: false } } as Settings; + loadEnvironment(settings, mockWorkspace, () => ({ + isTrusted: true, + source: 'file', + })); + + expect(process.env['GEMINI_API_KEY']).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/config/settings-validation.test.ts b/packages/cli/src/config/settings-validation.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..01cd545cdea98eb1f345c037d51606c59b89265f --- /dev/null +++ b/packages/cli/src/config/settings-validation.test.ts @@ -0,0 +1,560 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/// + +import { describe, it, expect } from 'vitest'; +import { + validateSettings, + formatValidationError, + settingsZodSchema, +} from './settings-validation.js'; +import { z } from 'zod'; +import { type Settings } from './settingsSchema.js'; + +describe('settings-validation', () => { + describe('validateSettings', () => { + it('should accept valid settings with correct model.name as string', () => { + const validSettings = { + model: { + name: 'gemini-2.0-flash-exp', + maxSessionTurns: 10, + }, + ui: { + theme: 'dark', + }, + }; + + const result = validateSettings(validSettings); + expect(result.success).toBe(true); + }); + + it('should reject model.name as object instead of string', () => { + const invalidSettings = { + model: { + name: { + skipNextSpeakerCheck: true, + }, + }, + }; + + const result = validateSettings(invalidSettings); + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + + if (result.error) { + const issues = result.error.issues; + expect(issues.length).toBeGreaterThan(0); + expect(issues[0]?.path).toEqual(['model', 'name']); + expect(issues[0]?.code).toBe('invalid_type'); + } + }); + + it('should accept valid model.summarizeToolOutput structure', () => { + const validSettings = { + model: { + summarizeToolOutput: { + run_shell_command: { + tokenBudget: 500, + }, + }, + }, + }; + + const result = validateSettings(validSettings); + expect(result.success).toBe(true); + }); + + it('should reject invalid model.summarizeToolOutput structure', () => { + const invalidSettings = { + model: { + summarizeToolOutput: { + run_shell_command: { + tokenBudget: 500, + }, + }, + }, + }; + + // First test with valid structure + let result = validateSettings(invalidSettings); + expect(result.success).toBe(true); + + // Now test with wrong type (string instead of object) + const actuallyInvalidSettings = { + model: { + summarizeToolOutput: 'invalid', + }, + }; + + result = validateSettings(actuallyInvalidSettings); + expect(result.success).toBe(false); + if (result.error) { + expect(result.error.issues.length).toBeGreaterThan(0); + } + }); + + it('should accept empty settings object', () => { + const emptySettings = {}; + const result = validateSettings(emptySettings); + expect(result.success).toBe(true); + }); + + it('should accept unknown top-level keys (for migration compatibility)', () => { + const settingsWithUnknownKey = { + unknownKey: 'some value', + }; + + const result = validateSettings(settingsWithUnknownKey); + expect(result.success).toBe(true); + // Unknown keys are allowed via .passthrough() for migration scenarios + }); + + it('should accept nested valid settings', () => { + const validSettings = { + ui: { + theme: 'dark', + hideWindowTitle: true, + footer: { + hideCWD: false, + hideModelInfo: true, + }, + }, + tools: { + sandbox: 'inherit', + }, + }; + + const result = validateSettings(validSettings); + expect(result.success).toBe(true); + }); + + it('should validate array types correctly', () => { + const validSettings = { + tools: { + allowed: ['git', 'npm'], + exclude: ['dangerous-tool'], + }, + context: { + includeDirectories: ['/path/1', '/path/2'], + }, + }; + + const result = validateSettings(validSettings); + expect(result.success).toBe(true); + }); + + it('should reject invalid types in arrays', () => { + const invalidSettings = { + tools: { + allowed: ['git', 123], + }, + }; + + const result = validateSettings(invalidSettings); + expect(result.success).toBe(false); + }); + + it('should validate boolean fields correctly', () => { + const validSettings = { + general: { + vimMode: true, + disableAutoUpdate: false, + }, + }; + + const result = validateSettings(validSettings); + expect(result.success).toBe(true); + }); + + it('should reject non-boolean values for boolean fields', () => { + const invalidSettings = { + general: { + vimMode: 'yes', + }, + }; + + const result = validateSettings(invalidSettings); + expect(result.success).toBe(false); + }); + + it('should validate number fields correctly', () => { + const validSettings = { + model: { + maxSessionTurns: 50, + compressionThreshold: 0.2, + }, + }; + + const result = validateSettings(validSettings); + expect(result.success).toBe(true); + }); + + it('should validate complex nested mcpServers configuration', () => { + const invalidSettings = { + mcpServers: { + 'my-server': { + command: 123, // Should be string + args: ['arg1'], + env: { + VAR: 'value', + }, + }, + }, + }; + + const result = validateSettings(invalidSettings); + expect(result.success).toBe(false); + if (result.error) { + expect(result.error.issues.length).toBeGreaterThan(0); + // Path should be mcpServers.my-server.command + const issue = result.error.issues.find((i) => + i.path.includes('command'), + ); + expect(issue).toBeDefined(); + expect(issue?.code).toBe('invalid_type'); + } + }); + + it('should validate mcpServers with type field for all transport types', () => { + const validSettings = { + mcpServers: { + 'sse-server': { + url: 'https://example.com/sse', + type: 'sse', + headers: { 'X-API-Key': 'key' }, + }, + 'http-server': { + url: 'https://example.com/mcp', + type: 'http', + }, + 'stdio-server': { + command: '/usr/bin/mcp-server', + type: 'stdio', + }, + }, + }; + + const result = validateSettings(validSettings); + expect(result.success).toBe(true); + }); + + it('should reject invalid type values in mcpServers', () => { + const invalidSettings = { + mcpServers: { + 'bad-server': { + url: 'https://example.com/mcp', + type: 'invalid-type', + }, + }, + }; + + const result = validateSettings(invalidSettings); + expect(result.success).toBe(false); + }); + + it('should validate mcpServers without type field', () => { + const validSettings = { + mcpServers: { + 'stdio-server': { + command: '/usr/bin/mcp-server', + args: ['--port', '8080'], + }, + 'url-server': { + url: 'https://example.com/mcp', + }, + }, + }; + + const result = validateSettings(validSettings); + expect(result.success).toBe(true); + }); + + it('should validate complex nested customThemes configuration', () => { + const invalidSettings = { + ui: { + customThemes: { + 'my-theme': { + type: 'custom', + // Missing 'name' property which is required + text: { + primary: '#ffffff', + }, + }, + }, + }, + }; + + const result = validateSettings(invalidSettings); + expect(result.success).toBe(false); + if (result.error) { + expect(result.error.issues.length).toBeGreaterThan(0); + // Should complain about missing 'name' + const issue = result.error.issues.find( + (i) => i.code === 'invalid_type' && i.message.includes('Required'), + ); + expect(issue).toBeDefined(); + } + }); + + it('should accept customThemes with text.response color override', () => { + // Regression test for #25610: `response` is a documented and + // implemented color override for model responses (see + // packages/cli/src/ui/themes/theme.ts and semantic-tokens.ts), + // but was missing from the CustomTheme validation schema. + const validSettings = { + ui: { + theme: 'LimeWhite', + customThemes: { + LimeWhite: { + type: 'custom', + name: 'LimeWhite', + text: { + primary: '#00FF00', + response: '#FFFFFF', + secondary: '#a0a0a0', + accent: '#00FF00', + }, + }, + }, + }, + }; + + const result = validateSettings(validSettings); + expect(result.success).toBe(true); + }); + + describe('type casting', () => { + it('should cast "true" and "false" strings to booleans', () => { + const settings = { + ui: { + autoThemeSwitching: 'true', + hideWindowTitle: 'false', + }, + }; + + const result = validateSettings(settings); + expect(result.success).toBe(true); + const data = result.data as Settings; + expect(data.ui?.autoThemeSwitching).toBe(true); + expect(data.ui?.hideWindowTitle).toBe(false); + }); + + it('should cast boolean strings case-insensitively', () => { + const settings = { + ui: { + autoThemeSwitching: 'TRUE', + hideWindowTitle: 'fAlSe', + }, + }; + + const result = validateSettings(settings); + expect(result.success).toBe(true); + const data = result.data as Settings; + expect(data.ui?.autoThemeSwitching).toBe(true); + expect(data.ui?.hideWindowTitle).toBe(false); + }); + + it('should cast numeric strings to numbers', () => { + const settings = { + model: { + maxSessionTurns: '42', + compressionThreshold: '0.5', + }, + }; + + const result = validateSettings(settings); + expect(result.success).toBe(true); + const data = result.data as Settings; + expect(data.model?.maxSessionTurns).toBe(42); + expect(data.model?.compressionThreshold).toBe(0.5); + }); + + it('should reject invalid castable strings', () => { + const settings = { + ui: { + autoThemeSwitching: 'not-a-boolean', + }, + model: { + maxSessionTurns: 'not-a-number', + }, + }; + + const result = validateSettings(settings); + expect(result.success).toBe(false); + expect(result.error?.issues).toHaveLength(2); + expect(result.error?.issues[0].message).toContain( + 'Expected boolean, received string', + ); + expect(result.error?.issues[1].message).toContain( + 'Expected number, received string', + ); + }); + + it('should cast strings to booleans/numbers in shared definitions (refs)', () => { + const settings = { + mcpServers: { + 'test-server': { + command: 'node', + trust: 'true', // from boolean ref + }, + }, + }; + + const result = validateSettings(settings); + expect(result.success).toBe(true); + const data = result.data as Settings; + expect(data.mcpServers?.['test-server'].trust).toBe(true); + }); + }); + }); + + describe('formatValidationError', () => { + it('should format error with file path and helpful message for model.name', () => { + const invalidSettings = { + model: { + name: { + skipNextSpeakerCheck: true, + }, + }, + }; + + const result = validateSettings(invalidSettings); + expect(result.success).toBe(false); + + if (result.error) { + const formatted = formatValidationError( + result.error, + '/path/to/settings.json', + ); + + expect(formatted).toContain('/path/to/settings.json'); + expect(formatted).toContain('model.name'); + expect(formatted).toContain('Expected: string, but received: object'); + expect(formatted).toContain('Please fix the configuration.'); + expect(formatted).toContain( + 'https://geminicli.com/docs/reference/configuration/', + ); + } + }); + + it('should format error for model.summarizeToolOutput', () => { + const invalidSettings = { + model: { + summarizeToolOutput: 'wrong type', + }, + }; + + const result = validateSettings(invalidSettings); + expect(result.success).toBe(false); + + if (result.error) { + const formatted = formatValidationError( + result.error, + '~/.gemini/settings.json', + ); + + expect(formatted).toContain('~/.gemini/settings.json'); + expect(formatted).toContain('model.summarizeToolOutput'); + } + }); + + it('should include link to documentation', () => { + const invalidSettings = { + model: { + name: { invalid: 'object' }, // model.name should be a string + }, + }; + + const result = validateSettings(invalidSettings); + expect(result.success).toBe(false); + + if (result.error) { + const formatted = formatValidationError(result.error, 'test.json'); + + expect(formatted).toContain( + 'https://geminicli.com/docs/reference/configuration/', + ); + } + }); + + it('should list all validation errors', () => { + const invalidSettings = { + model: { + name: { invalid: 'object' }, + maxSessionTurns: 'not a number', + }, + }; + + const result = validateSettings(invalidSettings); + expect(result.success).toBe(false); + + if (result.error) { + const formatted = formatValidationError(result.error, 'test.json'); + + // Should have multiple errors listed + expect(formatted.match(/Error in:/g)?.length).toBeGreaterThan(1); + } + }); + + it('should format array paths correctly (e.g. tools.allowed[0])', () => { + const invalidSettings = { + tools: { + allowed: ['git', 123], // 123 is invalid, expected string + }, + }; + + const result = validateSettings(invalidSettings); + expect(result.success).toBe(false); + + if (result.error) { + const formatted = formatValidationError(result.error, 'test.json'); + expect(formatted).toContain('tools.allowed[1]'); + } + }); + + it('should limit the number of displayed errors', () => { + const invalidSettings = { + tools: { + // Create 6 invalid items to trigger the limit + allowed: [1, 2, 3, 4, 5, 6], + }, + }; + + const result = validateSettings(invalidSettings); + expect(result.success).toBe(false); + + if (result.error) { + const formatted = formatValidationError(result.error, 'test.json'); + // Should see the first 5 + expect(formatted).toContain('tools.allowed[0]'); + expect(formatted).toContain('tools.allowed[4]'); + // Should NOT see the 6th + expect(formatted).not.toContain('tools.allowed[5]'); + // Should see the summary + expect(formatted).toContain('...and 1 more errors.'); + } + }); + }); + + describe('settingsZodSchema', () => { + it('should be a valid Zod object schema', () => { + expect(settingsZodSchema).toBeInstanceOf(z.ZodObject); + }); + + it('should have optional fields', () => { + // All top-level fields should be optional + const shape = settingsZodSchema.shape; + expect(shape['model']).toBeDefined(); + expect(shape['ui']).toBeDefined(); + expect(shape['tools']).toBeDefined(); + + // Test that empty object is valid (all fields optional) + const result = settingsZodSchema.safeParse({}); + expect(result.success).toBe(true); + }); + }); +}); diff --git a/packages/cli/src/config/settings-validation.ts b/packages/cli/src/config/settings-validation.ts new file mode 100644 index 0000000000000000000000000000000000000000..829cec506e7e0f08275c5923563f195845fb371e --- /dev/null +++ b/packages/cli/src/config/settings-validation.ts @@ -0,0 +1,348 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { z } from 'zod'; +import { + getSettingsSchema, + type SettingDefinition, + type SettingCollectionDefinition, + SETTINGS_SCHEMA_DEFINITIONS, +} from './settingsSchema.js'; + +// Helper to build Zod schema from the JSON-schema-like definitions +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function buildZodSchemaFromJsonSchema(def: any): z.ZodTypeAny { + if (def.anyOf) { + return z.union( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + def.anyOf.map((d: any) => buildZodSchemaFromJsonSchema(d)), + ); + } + + if (def.type === 'string') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + if (def.enum) return z.enum(def.enum as [string, ...string[]]); + return buildPrimitiveSchema('string'); + } + if (def.type === 'number') return buildPrimitiveSchema('number'); + if (def.type === 'boolean') return buildPrimitiveSchema('boolean'); + + if (def.type === 'array') { + if (def.items) { + return z.array(buildZodSchemaFromJsonSchema(def.items)); + } + return z.array(z.unknown()); + } + + if (def.type === 'object') { + let schema; + if (def.properties) { + const shape: Record = {}; + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion + for (const [key, propDef] of Object.entries(def.properties) as any) { + let propSchema = buildZodSchemaFromJsonSchema(propDef); + if ( + def.required && + Array.isArray(def.required) && + def.required.includes(key) + ) { + // keep it required + } else { + propSchema = propSchema.optional(); + } + shape[key] = propSchema; + } + schema = z.object(shape).passthrough(); + } else { + schema = z.object({}).passthrough(); + } + + if (def.additionalProperties === false) { + schema = schema.strict(); + } else if (typeof def.additionalProperties === 'object') { + schema = schema.catchall( + buildZodSchemaFromJsonSchema(def.additionalProperties), + ); + } + + return schema; + } + + return z.unknown(); +} + +/** + * Builds a Zod enum schema from options array + */ +function buildEnumSchema( + options: ReadonlyArray<{ value: string | number | boolean; label: string }>, +): z.ZodTypeAny { + if (!options || options.length === 0) { + throw new Error( + `Enum type must have options defined. Check your settings schema definition.`, + ); + } + const values = options.map((opt) => opt.value); + if (values.every((v) => typeof v === 'string')) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return z.enum(values as [string, ...string[]]); + } else if (values.every((v) => typeof v === 'number')) { + return z.union( + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + values.map((v) => z.literal(v)) as [ + z.ZodLiteral, + z.ZodLiteral, + ...Array>, + ], + ); + } else { + return z.union( + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + values.map((v) => z.literal(v)) as [ + z.ZodLiteral, + z.ZodLiteral, + ...Array>, + ], + ); + } +} + +/** + * Builds a Zod object shape from properties record + */ +function buildObjectShapeFromProperties( + properties: Record, +): Record { + const shape: Record = {}; + for (const [key, childDef] of Object.entries(properties)) { + shape[key] = buildZodSchemaFromDefinition(childDef); + } + return shape; +} + +/** + * Builds a Zod schema for primitive types (string, number, boolean) + */ +function buildPrimitiveSchema( + type: 'string' | 'number' | 'boolean', +): z.ZodTypeAny { + switch (type) { + case 'string': + return z.string(); + case 'number': + return z.preprocess((val) => { + if (typeof val === 'string' && val.trim() !== '') { + const num = Number(val); + if (!isNaN(num)) return num; + } + return val; + }, z.number()); + case 'boolean': + return z.preprocess((val) => { + if (typeof val === 'string') { + const lower = val.toLowerCase(); + if (lower === 'true') return true; + if (lower === 'false') return false; + } + return val; + }, z.boolean()); + default: + return z.unknown(); + } +} + +const REF_SCHEMAS: Record = {}; + +// Initialize REF_SCHEMAS +for (const [name, def] of Object.entries(SETTINGS_SCHEMA_DEFINITIONS)) { + REF_SCHEMAS[name] = buildZodSchemaFromJsonSchema(def); +} + +/** + * Recursively builds a Zod schema from a SettingDefinition + */ +function buildZodSchemaFromDefinition( + definition: SettingDefinition, +): z.ZodTypeAny { + let baseSchema: z.ZodTypeAny; + + // Special handling for TelemetrySettings which can be boolean or object + if (definition.ref === 'TelemetrySettings') { + const objectSchema = REF_SCHEMAS['TelemetrySettings']; + if (objectSchema) { + return z + .union([buildPrimitiveSchema('boolean'), objectSchema]) + .optional(); + } + } + + // Handle refs using registry + if (definition.ref && definition.ref in REF_SCHEMAS) { + return REF_SCHEMAS[definition.ref].optional(); + } + + switch (definition.type) { + case 'string': + case 'number': + case 'boolean': + baseSchema = buildPrimitiveSchema(definition.type); + break; + + case 'enum': { + baseSchema = buildEnumSchema(definition.options!); + break; + } + + case 'array': + if (definition.items) { + const itemSchema = buildZodSchemaFromCollection(definition.items); + baseSchema = z.array(itemSchema); + } else { + baseSchema = z.array(z.unknown()); + } + break; + + case 'object': + if (definition.properties) { + const shape = buildObjectShapeFromProperties(definition.properties); + baseSchema = z.object(shape).passthrough(); + + if (definition.additionalProperties) { + const additionalSchema = buildZodSchemaFromCollection( + definition.additionalProperties, + ); + baseSchema = z.object(shape).catchall(additionalSchema); + } + } else if (definition.additionalProperties) { + const valueSchema = buildZodSchemaFromCollection( + definition.additionalProperties, + ); + baseSchema = z.record(z.string(), valueSchema); + } else { + baseSchema = z.record(z.string(), z.unknown()); + } + break; + + default: + baseSchema = z.unknown(); + } + + // Make all fields optional since settings are partial + return baseSchema.optional(); +} + +/** + * Builds a Zod schema from a SettingCollectionDefinition + */ +function buildZodSchemaFromCollection( + collection: SettingCollectionDefinition, +): z.ZodTypeAny { + if (collection.ref && collection.ref in REF_SCHEMAS) { + return REF_SCHEMAS[collection.ref]; + } + + switch (collection.type) { + case 'string': + case 'number': + case 'boolean': + return buildPrimitiveSchema(collection.type); + + case 'enum': { + return buildEnumSchema(collection.options!); + } + + case 'array': + if (collection.properties) { + const shape = buildObjectShapeFromProperties(collection.properties); + return z.array(z.object(shape)); + } + return z.array(z.unknown()); + + case 'object': + if (collection.properties) { + const shape = buildObjectShapeFromProperties(collection.properties); + return z.object(shape).passthrough(); + } + return z.record(z.string(), z.unknown()); + + default: + return z.unknown(); + } +} + +/** + * Builds the complete Zod schema for Settings from SETTINGS_SCHEMA + */ +function buildSettingsZodSchema(): z.ZodObject> { + const schema = getSettingsSchema(); + const shape: Record = {}; + + for (const [key, definition] of Object.entries(schema)) { + shape[key] = buildZodSchemaFromDefinition(definition); + } + + return z.object(shape).passthrough(); +} + +export const settingsZodSchema = buildSettingsZodSchema(); + +/** + * Validates settings data against the Zod schema + */ +export function validateSettings(data: unknown): { + success: boolean; + data?: unknown; + error?: z.ZodError; +} { + const result = settingsZodSchema.safeParse(data); + return result; +} + +/** + * Format a Zod error into a helpful error message + */ +export function formatValidationError( + error: z.ZodError, + filePath: string, +): string { + const lines: string[] = []; + lines.push(`Invalid configuration in ${filePath}:`); + lines.push(''); + + const MAX_ERRORS_TO_DISPLAY = 5; + const displayedIssues = error.issues.slice(0, MAX_ERRORS_TO_DISPLAY); + + for (const issue of displayedIssues) { + const path = issue.path.reduce( + (acc, curr) => + typeof curr === 'number' + ? `${acc}[${curr}]` + : `${acc ? acc + '.' : ''}${curr}`, + '', + ); + lines.push(`Error in: ${path || '(root)'}`); + lines.push(` ${issue.message}`); + + if (issue.code === 'invalid_type') { + const expected = issue.expected; + const received = issue.received; + lines.push(`Expected: ${expected}, but received: ${received}`); + } + lines.push(''); + } + + if (error.issues.length > MAX_ERRORS_TO_DISPLAY) { + lines.push( + `...and ${error.issues.length - MAX_ERRORS_TO_DISPLAY} more errors.`, + ); + lines.push(''); + } + + lines.push('Please fix the configuration.'); + lines.push('See: https://geminicli.com/docs/reference/configuration/'); + + return lines.join('\n'); +} diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..39d0900e81c7857310d834b8e2bc8240515cc847 --- /dev/null +++ b/packages/cli/src/config/settings.test.ts @@ -0,0 +1,3751 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/// + +// Mock 'os' first. +import * as osActual from 'node:os'; // Import for type info for the mock factory + +vi.mock('os', async (importOriginal) => { + const actualOs = await importOriginal(); + return { + ...actualOs, + homedir: vi.fn(() => path.resolve('/mock/home/user')), + platform: vi.fn(() => 'linux'), + }; +}); + +// Mock './settings.js' to ensure it uses the mocked 'os.homedir()' for its internal constants. +vi.mock('./settings.js', async (importActual) => { + const originalModule = await importActual(); + return { + __esModule: true, // Ensure correct module shape + ...originalModule, // Re-export all original members + // We are relying on originalModule's USER_SETTINGS_PATH being constructed with mocked os.homedir() + }; +}); + +// Mock trustedFolders +import * as trustedFolders from './trustedFolders.js'; +vi.mock('./trustedFolders.js', () => ({ + isWorkspaceTrusted: vi.fn(), + isFolderTrustEnabled: vi.fn(), + loadTrustedFolders: vi.fn(), +})); + +vi.mock('./settingsSchema.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getSettingsSchema: vi.fn(actual.getSettingsSchema), + }; +}); + +// NOW import everything else, including the (now effectively re-exported) settings.js +import * as path from 'node:path'; // Restored for MOCK_WORKSPACE_SETTINGS_PATH +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, + type Mocked, + type Mock, +} from 'vitest'; +import * as fs from 'node:fs'; // fs will be mocked separately +import stripJsonComments from 'strip-json-comments'; // Will be mocked separately +import { isWorkspaceTrusted } from './trustedFolders.js'; + +// These imports will get the versions from the vi.mock('./settings.js', ...) factory. +import { + loadSettings, + USER_SETTINGS_PATH, // This IS the mocked path. + getSystemSettingsPath, + getSystemDefaultsPath, + type Settings, + type SettingsFile, + saveSettings, + getDefaultsFromSchema, + loadEnvironment, + migrateDeprecatedSettings, + SettingScope, + LoadedSettings, + sanitizeEnvVar, + createTestMergedSettings, + resetSettingsCacheForTesting, +} from './settings.js'; +import { + FatalConfigError, + GEMINI_DIR, + Storage, + AuthType, + type MCPServerConfig, +} from '@google/gemini-cli-core'; +import * as core from '@google/gemini-cli-core'; +import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js'; +import { + getSettingsSchema, + MergeStrategy, + type SettingsSchema, +} from './settingsSchema.js'; +import { createMockSettings } from '../test-utils/settings.js'; + +const MOCK_WORKSPACE_DIR = path.resolve(path.resolve('/mock/workspace')); +// Use the (mocked) GEMINI_DIR for consistency +const MOCK_WORKSPACE_SETTINGS_PATH = path.join( + MOCK_WORKSPACE_DIR, + GEMINI_DIR, + 'settings.json', +); + +// A more flexible type for test data that allows arbitrary properties. +type TestSettings = Settings & { [key: string]: unknown }; + +// Helper to normalize paths for test assertions, making them OS-agnostic +const normalizePath = (p: string | fs.PathOrFileDescriptor) => + path.normalize(p.toString()); + +vi.mock('fs', async (importOriginal) => { + // Get all the functions from the real 'fs' module + const actualFs = await importOriginal(); + + return { + ...actualFs, // Keep all the real functions + // Now, just override the ones we need for the test + existsSync: vi.fn(), + readFileSync: vi.fn(), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), + realpathSync: vi.fn((p: string) => p), + }; +}); + +vi.mock('./extension.js'); + +const mockCoreEvents = vi.hoisted(() => ({ + emitFeedback: vi.fn(), + emitSettingsChanged: vi.fn(), +})); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + const os = await import('node:os'); + const pathMod = await import('node:path'); + const fsMod = await import('node:fs'); + + // Helper to resolve paths using the test's mocked environment + const testResolve = (p: string | undefined) => { + if (!p) return ''; + try { + // Use the mocked fs.realpathSync if available, otherwise fallback + return fsMod.realpathSync(pathMod.resolve(p)); + } catch { + return pathMod.resolve(p); + } + }; + + // Create a smarter mock for isWorkspaceHomeDir + vi.spyOn(actual.Storage.prototype, 'isWorkspaceHomeDir').mockImplementation( + function (this: Storage) { + const target = testResolve(pathMod.dirname(this.getGeminiDir())); + // Pick up the mocked home directory specifically from the 'os' mock + const home = testResolve(os.homedir()); + return actual.normalizePath(target) === actual.normalizePath(home); + }, + ); + + return { + ...actual, + coreEvents: mockCoreEvents, + homedir: vi.fn(() => os.homedir()), + }; +}); + +vi.mock('../utils/commentJson.js', () => ({ + updateSettingsFilePreservingFormat: vi.fn(), +})); + +vi.mock('strip-json-comments', () => ({ + default: vi.fn((content) => content), +})); + +describe('Settings Loading and Merging', () => { + let mockFsExistsSync: Mocked; + let mockStripJsonComments: Mocked; + let mockFsMkdirSync: Mocked; + + beforeEach(() => { + vi.resetAllMocks(); + resetSettingsCacheForTesting(); + + mockFsExistsSync = vi.mocked(fs.existsSync); + mockFsMkdirSync = vi.mocked(fs.mkdirSync); + mockStripJsonComments = vi.mocked(stripJsonComments); + + vi.mocked(osActual.homedir).mockReturnValue( + path.resolve('/mock/home/user'), + ); + (mockStripJsonComments as unknown as Mock).mockImplementation( + (jsonString: string) => jsonString, + ); + (mockFsExistsSync as Mock).mockReturnValue(false); + (fs.readFileSync as Mock).mockReturnValue('{}'); // Return valid empty JSON + (mockFsMkdirSync as Mock).mockImplementation(() => undefined); + vi.spyOn(trustedFolders, 'isWorkspaceTrusted').mockReturnValue({ + isTrusted: true, + source: 'file', + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + describe('loadSettings', () => { + it.each([ + { + scope: 'system', + path: getSystemSettingsPath(), + content: { + ui: { theme: 'system-default' }, + tools: { sandbox: false }, + }, + }, + { + scope: 'user', + path: USER_SETTINGS_PATH, + content: { + ui: { theme: 'dark' }, + context: { fileName: 'USER_CONTEXT.md' }, + }, + }, + { + scope: 'workspace', + path: MOCK_WORKSPACE_SETTINGS_PATH, + content: { + tools: { sandbox: true }, + context: { fileName: 'WORKSPACE_CONTEXT.md' }, + }, + }, + ])( + 'should load $scope settings if only $scope file exists', + ({ scope, path: p, content }) => { + (mockFsExistsSync as Mock).mockImplementation( + (pathLike: fs.PathLike) => + path.normalize(pathLike.toString()) === path.normalize(p), + ); + (fs.readFileSync as Mock).mockImplementation( + (pathDesc: fs.PathOrFileDescriptor) => { + if (path.normalize(pathDesc.toString()) === path.normalize(p)) + return JSON.stringify(content); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + + expect(fs.readFileSync).toHaveBeenCalledWith( + expect.stringContaining(path.basename(p)), + 'utf-8', + ); + expect( + settings[scope as 'system' | 'user' | 'workspace'].settings, + ).toEqual(content); + expect(settings.merged).toMatchObject(content); + }, + ); + + it('should merge system, user and workspace settings, with system taking precedence over workspace, and workspace over user', () => { + (mockFsExistsSync as Mock).mockImplementation((p: fs.PathLike) => { + const normP = path.normalize(p.toString()); + return ( + normP === path.normalize(getSystemSettingsPath()) || + normP === path.normalize(USER_SETTINGS_PATH) || + normP === path.normalize(MOCK_WORKSPACE_SETTINGS_PATH) + ); + }); + const systemSettingsContent = { + ui: { + theme: 'system-theme', + }, + tools: { + sandbox: false, + }, + mcp: { + allowed: ['server1', 'server2'], + }, + telemetry: { enabled: false }, + }; + const userSettingsContent = { + ui: { + theme: 'dark', + }, + tools: { + sandbox: true, + }, + context: { + fileName: 'USER_CONTEXT.md', + }, + }; + const workspaceSettingsContent = { + tools: { + sandbox: false, + core: ['tool1'], + }, + context: { + fileName: 'WORKSPACE_CONTEXT.md', + }, + mcp: { + allowed: ['server1', 'server2', 'server3'], + }, + }; + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + const normP = path.normalize(p.toString()); + if (normP === path.normalize(getSystemSettingsPath())) + return JSON.stringify(systemSettingsContent); + if (normP === path.normalize(USER_SETTINGS_PATH)) + return JSON.stringify(userSettingsContent); + if (normP === path.normalize(MOCK_WORKSPACE_SETTINGS_PATH)) + return JSON.stringify(workspaceSettingsContent); + return ''; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + + expect(settings.system.settings).toEqual(systemSettingsContent); + expect(settings.user.settings).toEqual(userSettingsContent); + expect(settings.workspace.settings).toEqual(workspaceSettingsContent); + expect(settings.merged).toMatchObject({ + ui: { + theme: 'system-theme', + }, + tools: { + sandbox: false, + core: ['tool1'], + }, + telemetry: { enabled: false }, + context: { + fileName: 'WORKSPACE_CONTEXT.md', + }, + mcp: { + allowed: ['server1', 'server2'], + }, + }); + }); + + it('should merge all settings files with the correct precedence', () => { + // Mock schema to test defaults application + const mockSchema = { + ui: { type: 'object', default: {}, properties: {} }, + tools: { type: 'object', default: {}, properties: {} }, + context: { + type: 'object', + default: {}, + properties: { + discoveryMaxDirs: { type: 'number', default: 200 }, + includeDirectories: { + type: 'array', + default: [], + mergeStrategy: MergeStrategy.CONCAT, + }, + }, + }, + mcpServers: { type: 'object', default: {} }, + }; + + (getSettingsSchema as Mock).mockReturnValue( + mockSchema as unknown as SettingsSchema, + ); + + (mockFsExistsSync as Mock).mockReturnValue(true); + const systemDefaultsContent = { + ui: { + theme: 'default-theme', + }, + tools: { + sandbox: true, + }, + telemetry: true, + context: { + includeDirectories: ['/system/defaults/dir'], + }, + }; + const userSettingsContent = { + ui: { + theme: 'user-theme', + }, + context: { + fileName: 'USER_CONTEXT.md', + includeDirectories: ['/user/dir1', '/user/dir2'], + }, + }; + const workspaceSettingsContent = { + tools: { + sandbox: false, + }, + context: { + fileName: 'WORKSPACE_CONTEXT.md', + includeDirectories: ['/workspace/dir'], + }, + }; + const systemSettingsContent = { + ui: { + theme: 'system-theme', + }, + telemetry: false, + context: { + includeDirectories: ['/system/dir'], + }, + }; + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (normalizePath(p) === normalizePath(getSystemDefaultsPath())) + return JSON.stringify(systemDefaultsContent); + if (normalizePath(p) === normalizePath(getSystemSettingsPath())) + return JSON.stringify(systemSettingsContent); + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) + return JSON.stringify(userSettingsContent); + if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) + return JSON.stringify(workspaceSettingsContent); + return ''; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + + expect(settings.systemDefaults.settings).toEqual(systemDefaultsContent); + expect(settings.system.settings).toEqual(systemSettingsContent); + expect(settings.user.settings).toEqual(userSettingsContent); + expect(settings.workspace.settings).toEqual(workspaceSettingsContent); + expect(settings.merged).toEqual({ + context: { + discoveryMaxDirs: 200, + includeDirectories: [ + '/system/defaults/dir', + '/user/dir1', + '/user/dir2', + '/workspace/dir', + '/system/dir', + ], + fileName: 'WORKSPACE_CONTEXT.md', + }, + mcpServers: {}, + ui: { theme: 'system-theme' }, + tools: { sandbox: false }, + telemetry: false, + }); + }); + + it('should use folderTrust from workspace settings when trusted', () => { + (mockFsExistsSync as Mock).mockReturnValue(true); + const userSettingsContent = { + security: { + folderTrust: { + enabled: true, + }, + }, + }; + const workspaceSettingsContent = { + security: { + folderTrust: { + enabled: false, // This should be used + }, + }, + }; + const systemSettingsContent = { + // No folderTrust here + }; + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (normalizePath(p) === normalizePath(getSystemSettingsPath())) + return JSON.stringify(systemSettingsContent); + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) + return JSON.stringify(userSettingsContent); + if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) + return JSON.stringify(workspaceSettingsContent); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + expect(settings.merged.security?.folderTrust?.enabled).toBe(false); // Workspace setting should be used + }); + + it('should resolve environment variables and cast them to correct types before validation', () => { + vi.stubEnv('TEST_AUTO_THEME', 'false'); + vi.stubEnv('TEST_MAX_TURNS', '15'); + + (mockFsExistsSync as Mock).mockImplementation( + (p: fs.PathLike) => + path.normalize(p.toString()) === path.normalize(USER_SETTINGS_PATH), + ); + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if ( + path.normalize(p.toString()) === path.normalize(USER_SETTINGS_PATH) + ) { + return JSON.stringify({ + ui: { autoThemeSwitching: '$TEST_AUTO_THEME' }, + model: { maxSessionTurns: '$TEST_MAX_TURNS' }, + }); + } + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + + expect(settings.merged.ui.autoThemeSwitching).toBe(false); + expect(settings.merged.model.maxSessionTurns).toBe(15); + expect(settings.errors).toHaveLength(0); + }); + + it('should use default values from environment variable placeholders', () => { + vi.stubEnv('TEST_AUTO_THEME', ''); // Should trigger default + delete process.env['TEST_AUTO_THEME']; + + (mockFsExistsSync as Mock).mockImplementation( + (p: fs.PathLike) => + path.normalize(p.toString()) === path.normalize(USER_SETTINGS_PATH), + ); + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if ( + path.normalize(p.toString()) === path.normalize(USER_SETTINGS_PATH) + ) { + return JSON.stringify({ + ui: { autoThemeSwitching: '${TEST_AUTO_THEME:-true}' }, + }); + } + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + + expect(settings.merged.ui.autoThemeSwitching).toBe(true); + expect(settings.errors).toHaveLength(0); + }); + + it('should record validation errors if expansion result is invalid', () => { + vi.stubEnv('TEST_MAX_TURNS', 'not-a-number'); + + (mockFsExistsSync as Mock).mockImplementation( + (p: fs.PathLike) => + path.normalize(p.toString()) === path.normalize(USER_SETTINGS_PATH), + ); + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if ( + path.normalize(p.toString()) === path.normalize(USER_SETTINGS_PATH) + ) { + return JSON.stringify({ + model: { maxSessionTurns: '$TEST_MAX_TURNS' }, + }); + } + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + + expect(settings.errors.length).toBeGreaterThan(0); + expect(settings.errors[0].message).toContain( + 'Expected number, received string', + ); + // Should fall back to the expanded string value + expect(settings.merged.model.maxSessionTurns).toBe('not-a-number'); + }); + + it('should preserve environment variable placeholders on save', () => { + vi.stubEnv('TEST_AUTO_THEME', 'true'); + const placeholder = '${TEST_AUTO_THEME:-false}'; + + (mockFsExistsSync as Mock).mockImplementation( + (p: fs.PathLike) => + path.normalize(p.toString()) === path.normalize(USER_SETTINGS_PATH), + ); + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if ( + path.normalize(p.toString()) === path.normalize(USER_SETTINGS_PATH) + ) { + return JSON.stringify({ + ui: { autoThemeSwitching: placeholder }, + }); + } + return '{}'; + }, + ); + + // Load settings - this will expand the placeholder for runtime use + const loaded = loadSettings(MOCK_WORKSPACE_DIR); + expect(loaded.merged.ui.autoThemeSwitching).toBe(true); + + // Verify that the original settings for the user scope still have the placeholder + const userFile = loaded.forScope(SettingScope.User); + expect(userFile.originalSettings.ui?.autoThemeSwitching).toBe( + placeholder, + ); + + // Save settings - this should use the originalSettings (with placeholders) + const mockUpdate = vi.mocked(updateSettingsFilePreservingFormat); + saveSettings(userFile); + + expect(mockUpdate).toHaveBeenCalledWith( + USER_SETTINGS_PATH, + expect.objectContaining({ + ui: expect.objectContaining({ + autoThemeSwitching: placeholder, + }), + }), + ); + }); + + it('should use system folderTrust over user setting', () => { + (mockFsExistsSync as Mock).mockReturnValue(true); + const userSettingsContent = { + security: { + folderTrust: { + enabled: false, + }, + }, + }; + const workspaceSettingsContent = { + security: { + folderTrust: { + enabled: true, // This should be ignored + }, + }, + }; + const systemSettingsContent = { + security: { + folderTrust: { + enabled: true, + }, + }, + }; + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (normalizePath(p) === normalizePath(getSystemSettingsPath())) + return JSON.stringify(systemSettingsContent); + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) + return JSON.stringify(userSettingsContent); + if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) + return JSON.stringify(workspaceSettingsContent); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + expect(settings.merged.security?.folderTrust?.enabled).toBe(true); // System setting should be used + }); + + it('should not allow user or workspace to override system disableYoloMode', () => { + (mockFsExistsSync as Mock).mockReturnValue(true); + const userSettingsContent = { + security: { + disableYoloMode: false, + disableAlwaysAllow: false, + }, + }; + const workspaceSettingsContent = { + security: { + disableYoloMode: false, // This should be ignored + disableAlwaysAllow: false, // This should be ignored + }, + }; + const systemSettingsContent = { + security: { + disableYoloMode: true, + disableAlwaysAllow: true, + }, + }; + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (normalizePath(p) === normalizePath(getSystemSettingsPath())) + return JSON.stringify(systemSettingsContent); + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) + return JSON.stringify(userSettingsContent); + if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) + return JSON.stringify(workspaceSettingsContent); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + expect(settings.merged.security?.disableYoloMode).toBe(true); // System setting should be used + expect(settings.merged.security?.disableAlwaysAllow).toBe(true); // System setting should be used + }); + + it.each([ + { + description: 'contextFileName in user settings', + path: USER_SETTINGS_PATH, + content: { context: { fileName: 'CUSTOM.md' } }, + expected: { key: 'context.fileName', value: 'CUSTOM.md' }, + }, + { + description: 'contextFileName in workspace settings', + path: MOCK_WORKSPACE_SETTINGS_PATH, + content: { context: { fileName: 'PROJECT_SPECIFIC.md' } }, + expected: { key: 'context.fileName', value: 'PROJECT_SPECIFIC.md' }, + }, + { + description: 'excludedProjectEnvVars in user settings', + path: USER_SETTINGS_PATH, + content: { + advanced: { excludedEnvVars: ['DEBUG', 'NODE_ENV', 'CUSTOM_VAR'] }, + }, + expected: { + key: 'advanced.excludedEnvVars', + value: ['DEBUG', 'DEBUG_MODE', 'NODE_ENV', 'CUSTOM_VAR'], + }, + }, + { + description: 'excludedProjectEnvVars in workspace settings', + path: MOCK_WORKSPACE_SETTINGS_PATH, + content: { + advanced: { excludedEnvVars: ['WORKSPACE_DEBUG', 'WORKSPACE_VAR'] }, + }, + expected: { + key: 'advanced.excludedEnvVars', + value: ['DEBUG', 'DEBUG_MODE', 'WORKSPACE_DEBUG', 'WORKSPACE_VAR'], + }, + }, + ])( + 'should handle $description correctly', + ({ path, content, expected }) => { + (mockFsExistsSync as Mock).mockImplementation( + (p: fs.PathLike) => normalizePath(p) === normalizePath(path), + ); + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (normalizePath(p) === normalizePath(path)) + return JSON.stringify(content); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + const keys = expected.key.split('.'); + let result: unknown = settings.merged; + for (const key of keys) { + result = (result as { [key: string]: unknown })[key]; + } + expect(result).toEqual(expected.value); + }, + ); + + it('should merge excludedProjectEnvVars with workspace taking precedence over user', () => { + (mockFsExistsSync as Mock).mockImplementation( + (p: fs.PathLike) => + normalizePath(p) === normalizePath(USER_SETTINGS_PATH) || + normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH), + ); + const userSettingsContent = { + general: {}, + advanced: { excludedEnvVars: ['DEBUG', 'NODE_ENV', 'USER_VAR'] }, + }; + const workspaceSettingsContent = { + general: {}, + advanced: { excludedEnvVars: ['WORKSPACE_DEBUG', 'WORKSPACE_VAR'] }, + }; + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) + return JSON.stringify(userSettingsContent); + if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) + return JSON.stringify(workspaceSettingsContent); + return ''; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + + expect(settings.user.settings.advanced?.excludedEnvVars).toEqual([ + 'DEBUG', + 'NODE_ENV', + 'USER_VAR', + ]); + expect(settings.workspace.settings.advanced?.excludedEnvVars).toEqual([ + 'WORKSPACE_DEBUG', + 'WORKSPACE_VAR', + ]); + expect(settings.merged.advanced?.excludedEnvVars).toEqual([ + 'DEBUG', + 'DEBUG_MODE', + 'NODE_ENV', + 'USER_VAR', + 'WORKSPACE_DEBUG', + 'WORKSPACE_VAR', + ]); + }); + + it('should default contextFileName to undefined if not in any settings file', () => { + (mockFsExistsSync as Mock).mockImplementation( + (p: fs.PathLike) => + normalizePath(p) === normalizePath(USER_SETTINGS_PATH) || + normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH), + ); + const userSettingsContent = { ui: { theme: 'dark' } }; + const workspaceSettingsContent = { tools: { sandbox: true } }; + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) + return JSON.stringify(userSettingsContent); + if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) + return JSON.stringify(workspaceSettingsContent); + return ''; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + expect(settings.merged.context?.fileName).toBeUndefined(); + }); + + it.each([ + { + scope: 'user', + path: USER_SETTINGS_PATH, + content: { telemetry: { enabled: true } }, + expected: true, + }, + { + scope: 'workspace', + path: MOCK_WORKSPACE_SETTINGS_PATH, + content: { telemetry: { enabled: false } }, + expected: false, + }, + ])( + 'should load telemetry setting from $scope settings', + ({ path, content, expected }) => { + (mockFsExistsSync as Mock).mockImplementation( + (p: fs.PathLike) => normalizePath(p) === normalizePath(path), + ); + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (normalizePath(p) === normalizePath(path)) + return JSON.stringify(content); + return '{}'; + }, + ); + const settings = loadSettings(MOCK_WORKSPACE_DIR); + expect(settings.merged.telemetry?.enabled).toBe(expected); + }, + ); + + it('should prioritize workspace telemetry setting over user setting', () => { + (mockFsExistsSync as Mock).mockReturnValue(true); + const userSettingsContent = { telemetry: { enabled: true } }; + const workspaceSettingsContent = { telemetry: { enabled: false } }; + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) + return JSON.stringify(userSettingsContent); + if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) + return JSON.stringify(workspaceSettingsContent); + return '{}'; + }, + ); + const settings = loadSettings(MOCK_WORKSPACE_DIR); + expect(settings.merged.telemetry?.enabled).toBe(false); + }); + + it('should have telemetry as undefined if not in any settings file', () => { + (mockFsExistsSync as Mock).mockReturnValue(false); // No settings files exist + (fs.readFileSync as Mock).mockReturnValue('{}'); + const settings = loadSettings(MOCK_WORKSPACE_DIR); + expect(settings.merged.telemetry).toBeUndefined(); + expect(settings.merged.ui).toBeDefined(); + expect(settings.merged.mcpServers).toEqual({}); + }); + + it('should merge MCP servers correctly, with workspace taking precedence', () => { + (mockFsExistsSync as Mock).mockImplementation( + (p: fs.PathLike) => + normalizePath(p) === normalizePath(USER_SETTINGS_PATH) || + normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH), + ); + const userSettingsContent = { + mcpServers: { + 'user-server': { + command: 'user-command', + args: ['--user-arg'], + description: 'User MCP server', + }, + 'shared-server': { + command: 'user-shared-command', + description: 'User shared server config', + }, + }, + }; + const workspaceSettingsContent = { + mcpServers: { + 'workspace-server': { + command: 'workspace-command', + args: ['--workspace-arg'], + description: 'Workspace MCP server', + }, + 'shared-server': { + command: 'workspace-shared-command', + description: 'Workspace shared server config', + }, + }, + }; + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) + return JSON.stringify(userSettingsContent); + if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) + return JSON.stringify(workspaceSettingsContent); + return ''; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + + expect(settings.user.settings).toEqual(userSettingsContent); + expect(settings.workspace.settings).toEqual(workspaceSettingsContent); + expect(settings.merged.mcpServers).toEqual({ + 'user-server': { + command: 'user-command', + args: ['--user-arg'], + description: 'User MCP server', + }, + 'workspace-server': { + command: 'workspace-command', + args: ['--workspace-arg'], + description: 'Workspace MCP server', + }, + 'shared-server': { + command: 'workspace-shared-command', + description: 'Workspace shared server config', + }, + }); + }); + + it.each([ + { + scope: 'user', + path: USER_SETTINGS_PATH, + content: { + mcpServers: { + 'user-only-server': { + command: 'user-only-command', + description: 'User only server', + }, + }, + }, + expected: { + 'user-only-server': { + command: 'user-only-command', + description: 'User only server', + }, + }, + }, + { + scope: 'workspace', + path: MOCK_WORKSPACE_SETTINGS_PATH, + content: { + mcpServers: { + 'workspace-only-server': { + command: 'workspace-only-command', + description: 'Workspace only server', + }, + }, + }, + expected: { + 'workspace-only-server': { + command: 'workspace-only-command', + description: 'Workspace only server', + }, + }, + }, + ])( + 'should handle MCP servers when only in $scope settings', + ({ path, content, expected }) => { + (mockFsExistsSync as Mock).mockImplementation( + (p: fs.PathLike) => normalizePath(p) === normalizePath(path), + ); + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (normalizePath(p) === normalizePath(path)) + return JSON.stringify(content); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + expect(settings.merged.mcpServers).toEqual(expected); + }, + ); + + it('should have mcpServers as undefined if not in any settings file', () => { + (mockFsExistsSync as Mock).mockReturnValue(false); // No settings files exist + (fs.readFileSync as Mock).mockReturnValue('{}'); + const settings = loadSettings(MOCK_WORKSPACE_DIR); + expect(settings.merged.mcpServers).toEqual({}); + }); + + it('should merge MCP servers from system, user, and workspace with system taking precedence', () => { + (mockFsExistsSync as Mock).mockReturnValue(true); + const systemSettingsContent = { + mcpServers: { + 'shared-server': { + command: 'system-command', + args: ['--system-arg'], + }, + 'system-only-server': { + command: 'system-only-command', + }, + }, + }; + const userSettingsContent = { + mcpServers: { + 'user-server': { + command: 'user-command', + }, + 'shared-server': { + command: 'user-command', + description: 'from user', + }, + }, + }; + const workspaceSettingsContent = { + mcpServers: { + 'workspace-server': { + command: 'workspace-command', + }, + 'shared-server': { + command: 'workspace-command', + args: ['--workspace-arg'], + }, + }, + }; + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (normalizePath(p) === normalizePath(getSystemSettingsPath())) + return JSON.stringify(systemSettingsContent); + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) + return JSON.stringify(userSettingsContent); + if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) + return JSON.stringify(workspaceSettingsContent); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + + expect(settings.merged.mcpServers).toEqual({ + 'user-server': { + command: 'user-command', + }, + 'workspace-server': { + command: 'workspace-command', + }, + 'system-only-server': { + command: 'system-only-command', + }, + 'shared-server': { + command: 'system-command', + args: ['--system-arg'], + }, + }); + }); + + it('should merge mcp allowed/excluded lists with system taking precedence over workspace', () => { + (mockFsExistsSync as Mock).mockReturnValue(true); + const systemSettingsContent = { + mcp: { + allowed: ['system-allowed'], + }, + }; + const userSettingsContent = { + mcp: { + allowed: ['user-allowed'], + excluded: ['user-excluded'], + }, + }; + const workspaceSettingsContent = { + mcp: { + allowed: ['workspace-allowed'], + excluded: ['workspace-excluded'], + }, + }; + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (normalizePath(p) === normalizePath(getSystemSettingsPath())) + return JSON.stringify(systemSettingsContent); + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) + return JSON.stringify(userSettingsContent); + if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) + return JSON.stringify(workspaceSettingsContent); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + + expect(settings.merged.mcp).toEqual({ + allowed: ['system-allowed'], + excluded: ['workspace-excluded'], + }); + }); + + describe('LoadedSettings MCP consolidation', () => { + it('should consolidate mcp excluded list across all scopes', () => { + const loaded = new LoadedSettings( + { + path: '', + settings: { mcp: { excluded: ['system-excluded'] } }, + originalSettings: {}, + }, + { + path: '', + settings: { mcp: { excluded: ['defaults-excluded'] } }, + originalSettings: {}, + }, + { + path: '', + settings: { mcp: { excluded: ['user-excluded'] } }, + originalSettings: {}, + }, + { + path: '', + settings: { mcp: { excluded: ['workspace-excluded'] } }, + originalSettings: {}, + }, + true, + ); + + expect(loaded.getConsolidatedExcludedMcpServers()).toEqual([ + 'system-excluded', + 'defaults-excluded', + 'user-excluded', + 'workspace-excluded', + ]); + }); + + it('should consolidate allowed mcp list via case-insensitive intersection', () => { + const loaded = new LoadedSettings( + { + path: '', + settings: { mcp: { allowed: ['Server-A', 'Server-B'] } }, + originalSettings: {}, + }, + { + path: '', + settings: { mcp: { allowed: ['server-a', 'Server-C'] } }, + originalSettings: {}, + }, + { path: '', settings: {}, originalSettings: {} }, // no allowlist in user + { + path: '', + settings: { mcp: { allowed: ['SERVER-A', 'Server-D'] } }, + originalSettings: {}, + }, + true, + ); + + expect(loaded.getConsolidatedAllowedMcpServers()).toEqual(['Server-A']); + }); + + it('should return undefined allowed list if no scopes define one', () => { + const loaded = new LoadedSettings( + { path: '', settings: {}, originalSettings: {} }, + { path: '', settings: {}, originalSettings: {} }, + { path: '', settings: {}, originalSettings: {} }, + { path: '', settings: {}, originalSettings: {} }, + true, + ); + + expect(loaded.getConsolidatedAllowedMcpServers()).toBeUndefined(); + }); + }); + + describe('compressionThreshold settings', () => { + it.each([ + { + description: + 'should be taken from user settings if only present there', + userContent: { model: { compressionThreshold: 0.5 } }, + workspaceContent: {}, + expected: 0.5, + }, + { + description: + 'should be taken from workspace settings if only present there', + userContent: {}, + workspaceContent: { model: { compressionThreshold: 0.8 } }, + expected: 0.8, + }, + { + description: + 'should prioritize workspace settings over user settings', + userContent: { model: { compressionThreshold: 0.5 } }, + workspaceContent: { model: { compressionThreshold: 0.8 } }, + expected: 0.8, + }, + { + description: 'should be default if not in any settings file', + userContent: {}, + workspaceContent: {}, + expected: 0.5, + }, + ])('$description', ({ userContent, workspaceContent, expected }) => { + (mockFsExistsSync as Mock).mockReturnValue(true); + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) + return JSON.stringify(userContent); + if ( + normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH) + ) + return JSON.stringify(workspaceContent); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + expect(settings.merged.model?.compressionThreshold).toEqual(expected); + }); + }); + + it('should use user compressionThreshold if workspace does not define it', () => { + (mockFsExistsSync as Mock).mockReturnValue(true); + const userSettingsContent = { + general: {}, + model: { compressionThreshold: 0.5 }, + }; + const workspaceSettingsContent = { + general: {}, + model: {}, + }; + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) + return JSON.stringify(userSettingsContent); + if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) + return JSON.stringify(workspaceSettingsContent); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + + expect(settings.merged.model?.compressionThreshold).toEqual(0.5); + }); + + it('should merge includeDirectories from all scopes', () => { + (mockFsExistsSync as Mock).mockReturnValue(true); + const systemSettingsContent = { + context: { includeDirectories: ['/system/dir'] }, + }; + const systemDefaultsContent = { + context: { includeDirectories: ['/system/defaults/dir'] }, + }; + const userSettingsContent = { + context: { includeDirectories: ['/user/dir1', '/user/dir2'] }, + }; + const workspaceSettingsContent = { + context: { includeDirectories: ['/workspace/dir'] }, + }; + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (normalizePath(p) === normalizePath(getSystemSettingsPath())) + return JSON.stringify(systemSettingsContent); + if (normalizePath(p) === normalizePath(getSystemDefaultsPath())) + return JSON.stringify(systemDefaultsContent); + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) + return JSON.stringify(userSettingsContent); + if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) + return JSON.stringify(workspaceSettingsContent); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + + expect(settings.merged.context?.includeDirectories).toEqual([ + '/system/defaults/dir', + '/user/dir1', + '/user/dir2', + '/workspace/dir', + '/system/dir', + ]); + }); + + it('should handle JSON parsing errors gracefully', () => { + (mockFsExistsSync as Mock).mockReturnValue(true); // Both files "exist" + const invalidJsonContent = 'invalid json'; + const userReadError = new SyntaxError( + "Expected ',' or '}' after property value in JSON at position 10", + ); + const workspaceReadError = new SyntaxError( + 'Unexpected token i in JSON at position 0', + ); + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) { + // Simulate JSON.parse throwing for user settings + vi.spyOn(JSON, 'parse').mockImplementationOnce(() => { + throw userReadError; + }); + return invalidJsonContent; // Content that would cause JSON.parse to throw + } + if ( + normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH) + ) { + // Simulate JSON.parse throwing for workspace settings + vi.spyOn(JSON, 'parse').mockImplementationOnce(() => { + throw workspaceReadError; + }); + return invalidJsonContent; + } + return '{}'; // Default for other reads + }, + ); + + try { + loadSettings(MOCK_WORKSPACE_DIR); + throw new Error('loadSettings should have thrown a FatalConfigError'); + } catch (e) { + expect(e).toBeInstanceOf(FatalConfigError); + const error = e as FatalConfigError; + expect(error.message).toContain( + `Error in ${USER_SETTINGS_PATH}: ${userReadError.message}`, + ); + expect(error.message).toContain( + `Error in ${MOCK_WORKSPACE_SETTINGS_PATH}: ${workspaceReadError.message}`, + ); + expect(error.message).toContain( + 'Please fix the configuration file(s) and try again.', + ); + } + + // Restore JSON.parse mock if it was spied on specifically for this test + vi.restoreAllMocks(); // Or more targeted restore if needed + }); + + it('should resolve environment variables in user settings', () => { + process.env['TEST_API_KEY'] = 'user_api_key_from_env'; + const userSettingsContent: TestSettings = { + apiKey: '$TEST_API_KEY', + someUrl: 'https://test.com/${TEST_API_KEY}', + }; + (mockFsExistsSync as Mock).mockImplementation( + (p: fs.PathLike) => + normalizePath(p) === normalizePath(USER_SETTINGS_PATH), + ); + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) + return JSON.stringify(userSettingsContent); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + expect((settings.user.settings as TestSettings)['apiKey']).toBe( + 'user_api_key_from_env', + ); + expect((settings.user.settings as TestSettings)['someUrl']).toBe( + 'https://test.com/user_api_key_from_env', + ); + expect((settings.merged as TestSettings)['apiKey']).toBe( + 'user_api_key_from_env', + ); + delete process.env['TEST_API_KEY']; + }); + + it('should resolve environment variables in workspace settings', () => { + process.env['WORKSPACE_ENDPOINT'] = 'workspace_endpoint_from_env'; + const workspaceSettingsContent: TestSettings = { + endpoint: '${WORKSPACE_ENDPOINT}/api', + nested: { value: '$WORKSPACE_ENDPOINT' }, + }; + (mockFsExistsSync as Mock).mockImplementation( + (p: fs.PathLike) => + normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH), + ); + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) + return JSON.stringify(workspaceSettingsContent); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + expect((settings.workspace.settings as TestSettings)['endpoint']).toBe( + 'workspace_endpoint_from_env/api', + ); + const nested = (settings.workspace.settings as TestSettings)[ + 'nested' + ] as Record; + expect(nested['value']).toBe('workspace_endpoint_from_env'); + expect((settings.merged as TestSettings)['endpoint']).toBe( + 'workspace_endpoint_from_env/api', + ); + delete process.env['WORKSPACE_ENDPOINT']; + }); + + it('should correctly resolve and merge env variables from different scopes', () => { + process.env['SYSTEM_VAR'] = 'system_value'; + process.env['USER_VAR'] = 'user_value'; + process.env['WORKSPACE_VAR'] = 'workspace_value'; + process.env['SHARED_VAR'] = 'final_value'; + + const systemSettingsContent: TestSettings = { + configValue: '$SHARED_VAR', + systemOnly: '$SYSTEM_VAR', + }; + const userSettingsContent: TestSettings = { + configValue: '$SHARED_VAR', + userOnly: '$USER_VAR', + ui: { + theme: 'dark', + }, + }; + const workspaceSettingsContent: TestSettings = { + configValue: '$SHARED_VAR', + workspaceOnly: '$WORKSPACE_VAR', + ui: { + theme: 'light', + }, + }; + + (mockFsExistsSync as Mock).mockReturnValue(true); + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (normalizePath(p) === normalizePath(getSystemSettingsPath())) { + return JSON.stringify(systemSettingsContent); + } + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) { + return JSON.stringify(userSettingsContent); + } + if ( + normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH) + ) { + return JSON.stringify(workspaceSettingsContent); + } + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + + // Check resolved values in individual scopes + expect((settings.system.settings as TestSettings)['configValue']).toBe( + 'final_value', + ); + expect((settings.system.settings as TestSettings)['systemOnly']).toBe( + 'system_value', + ); + expect((settings.user.settings as TestSettings)['configValue']).toBe( + 'final_value', + ); + expect((settings.user.settings as TestSettings)['userOnly']).toBe( + 'user_value', + ); + expect((settings.workspace.settings as TestSettings)['configValue']).toBe( + 'final_value', + ); + expect( + (settings.workspace.settings as TestSettings)['workspaceOnly'], + ).toBe('workspace_value'); + + // Check merged values (system > workspace > user) + expect((settings.merged as TestSettings)['configValue']).toBe( + 'final_value', + ); + expect((settings.merged as TestSettings)['systemOnly']).toBe( + 'system_value', + ); + expect((settings.merged as TestSettings)['userOnly']).toBe('user_value'); + expect((settings.merged as TestSettings)['workspaceOnly']).toBe( + 'workspace_value', + ); + expect(settings.merged.ui?.theme).toBe('light'); // workspace overrides user + + delete process.env['SYSTEM_VAR']; + delete process.env['USER_VAR']; + delete process.env['WORKSPACE_VAR']; + delete process.env['SHARED_VAR']; + }); + + it('should correctly merge dnsResolutionOrder with workspace taking precedence', () => { + (mockFsExistsSync as Mock).mockReturnValue(true); + const userSettingsContent = { + advanced: { dnsResolutionOrder: 'ipv4first' }, + }; + const workspaceSettingsContent = { + advanced: { dnsResolutionOrder: 'verbatim' }, + }; + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) + return JSON.stringify(userSettingsContent); + if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) + return JSON.stringify(workspaceSettingsContent); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + expect(settings.merged.advanced?.dnsResolutionOrder).toBe('verbatim'); + }); + + it('should use user dnsResolutionOrder if workspace is not defined', () => { + (mockFsExistsSync as Mock).mockImplementation( + (p: fs.PathLike) => + normalizePath(p) === normalizePath(USER_SETTINGS_PATH), + ); + const userSettingsContent = { + advanced: { dnsResolutionOrder: 'verbatim' }, + }; + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) + return JSON.stringify(userSettingsContent); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + expect(settings.merged.advanced?.dnsResolutionOrder).toBe('verbatim'); + }); + + it('should leave unresolved environment variables as is', () => { + const userSettingsContent: TestSettings = { apiKey: '$UNDEFINED_VAR' }; + (mockFsExistsSync as Mock).mockImplementation( + (p: fs.PathLike) => + normalizePath(p) === normalizePath(USER_SETTINGS_PATH), + ); + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) + return JSON.stringify(userSettingsContent); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + expect((settings.user.settings as TestSettings)['apiKey']).toBe( + '$UNDEFINED_VAR', + ); + expect((settings.merged as TestSettings)['apiKey']).toBe( + '$UNDEFINED_VAR', + ); + }); + + it('should resolve multiple environment variables in a single string', () => { + process.env['VAR_A'] = 'valueA'; + process.env['VAR_B'] = 'valueB'; + const userSettingsContent: TestSettings = { + path: '/path/$VAR_A/${VAR_B}/end', + }; + (mockFsExistsSync as Mock).mockImplementation( + (p: fs.PathLike) => + normalizePath(p) === normalizePath(USER_SETTINGS_PATH), + ); + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) + return JSON.stringify(userSettingsContent); + return '{}'; + }, + ); + const settings = loadSettings(MOCK_WORKSPACE_DIR); + expect((settings.user.settings as TestSettings)['path']).toBe( + '/path/valueA/valueB/end', + ); + delete process.env['VAR_A']; + delete process.env['VAR_B']; + }); + + it('should resolve environment variables in arrays', () => { + process.env['ITEM_1'] = 'item1_env'; + process.env['ITEM_2'] = 'item2_env'; + const userSettingsContent: TestSettings = { + list: ['$ITEM_1', '${ITEM_2}', 'literal'], + }; + (mockFsExistsSync as Mock).mockImplementation( + (p: fs.PathLike) => + normalizePath(p) === normalizePath(USER_SETTINGS_PATH), + ); + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) + return JSON.stringify(userSettingsContent); + return '{}'; + }, + ); + const settings = loadSettings(MOCK_WORKSPACE_DIR); + expect((settings.user.settings as TestSettings)['list']).toEqual([ + 'item1_env', + 'item2_env', + 'literal', + ]); + delete process.env['ITEM_1']; + delete process.env['ITEM_2']; + }); + + it('should correctly pass through null, boolean, and number types, and handle undefined properties', () => { + process.env['MY_ENV_STRING'] = 'env_string_value'; + process.env['MY_ENV_STRING_NESTED'] = 'env_string_nested_value'; + + const userSettingsContent: TestSettings = { + nullVal: null, + trueVal: true, + falseVal: false, + numberVal: 123.45, + stringVal: '$MY_ENV_STRING', + nestedObj: { + nestedNull: null, + nestedBool: true, + nestedNum: 0, + nestedString: 'literal', + anotherEnv: '${MY_ENV_STRING_NESTED}', + }, + }; + + (mockFsExistsSync as Mock).mockImplementation( + (p: fs.PathLike) => + normalizePath(p) === normalizePath(USER_SETTINGS_PATH), + ); + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) + return JSON.stringify(userSettingsContent); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + + expect((settings.user.settings as TestSettings)['nullVal']).toBeNull(); + expect((settings.user.settings as TestSettings)['trueVal']).toBe(true); + expect((settings.user.settings as TestSettings)['falseVal']).toBe(false); + expect((settings.user.settings as TestSettings)['numberVal']).toBe( + 123.45, + ); + expect((settings.user.settings as TestSettings)['stringVal']).toBe( + 'env_string_value', + ); + expect( + (settings.user.settings as TestSettings)['undefinedVal'], + ).toBeUndefined(); + + const nestedObj = (settings.user.settings as TestSettings)[ + 'nestedObj' + ] as Record; + expect(nestedObj['nestedNull']).toBeNull(); + expect(nestedObj['nestedBool']).toBe(true); + expect(nestedObj['nestedNum']).toBe(0); + expect(nestedObj['nestedString']).toBe('literal'); + expect(nestedObj['anotherEnv']).toBe('env_string_nested_value'); + + delete process.env['MY_ENV_STRING']; + delete process.env['MY_ENV_STRING_NESTED']; + }); + + it('should resolve multiple concatenated environment variables in a single string value', () => { + process.env['TEST_HOST'] = 'myhost'; + process.env['TEST_PORT'] = '9090'; + const userSettingsContent: TestSettings = { + serverAddress: '${TEST_HOST}:${TEST_PORT}/api', + }; + (mockFsExistsSync as Mock).mockImplementation( + (p: fs.PathLike) => + normalizePath(p) === normalizePath(USER_SETTINGS_PATH), + ); + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) + return JSON.stringify(userSettingsContent); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + expect((settings.user.settings as TestSettings)['serverAddress']).toBe( + 'myhost:9090/api', + ); + + delete process.env['TEST_HOST']; + delete process.env['TEST_PORT']; + }); + + describe('when GEMINI_CLI_SYSTEM_SETTINGS_PATH is set', () => { + const MOCK_ENV_SYSTEM_SETTINGS_PATH = path.resolve( + '/mock/env/system/settings.json', + ); + + beforeEach(() => { + process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH'] = + MOCK_ENV_SYSTEM_SETTINGS_PATH; + }); + + afterEach(() => { + delete process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH']; + }); + + it('should load system settings from the path specified in the environment variable', () => { + (mockFsExistsSync as Mock).mockImplementation( + (p: fs.PathLike) => p === MOCK_ENV_SYSTEM_SETTINGS_PATH, + ); + const systemSettingsContent = { + ui: { theme: 'env-var-theme' }, + tools: { sandbox: true }, + }; + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === MOCK_ENV_SYSTEM_SETTINGS_PATH) + return JSON.stringify(systemSettingsContent); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + + expect(fs.readFileSync).toHaveBeenCalledWith( + MOCK_ENV_SYSTEM_SETTINGS_PATH, + 'utf-8', + ); + expect(settings.system.path).toBe(MOCK_ENV_SYSTEM_SETTINGS_PATH); + expect(settings.system.settings).toEqual(systemSettingsContent); + expect(settings.merged).toMatchObject({ + ...systemSettingsContent, + }); + }); + }); + + it('should correctly skip workspace-level loading if workspaceDir is a symlink to home', () => { + const mockHomeDir = path.resolve('/mock/home/user'); + const mockSymlinkDir = path.resolve('/mock/symlink/to/home'); + const mockWorkspaceSettingsPath = path.join( + mockSymlinkDir, + GEMINI_DIR, + 'settings.json', + ); + + vi.mocked(osActual.homedir).mockReturnValue(mockHomeDir); + vi.mocked(fs.realpathSync).mockImplementation((p: fs.PathLike) => { + const pStr = p.toString(); + const resolved = path.resolve(pStr); + if ( + resolved === path.resolve(mockSymlinkDir) || + resolved === path.resolve(mockHomeDir) + ) { + return mockHomeDir; + } + return pStr; + }); + + // Force the storage check to return true for this specific test + const isWorkspaceHomeDirSpy = vi + .spyOn(Storage.prototype, 'isWorkspaceHomeDir') + .mockReturnValue(true); + + (mockFsExistsSync as Mock).mockImplementation( + (p: string) => + // Only return true for workspace settings path to see if it gets loaded + p === mockWorkspaceSettingsPath, + ); + + try { + const settings = loadSettings(mockSymlinkDir); + + // Verify that even though the file exists, it was NOT loaded because realpath matched home + expect(fs.readFileSync).not.toHaveBeenCalledWith( + mockWorkspaceSettingsPath, + 'utf-8', + ); + expect(settings.workspace.settings).toEqual({}); + } finally { + isWorkspaceHomeDirSpy.mockRestore(); + } + }); + + describe('caching', () => { + it('should cache loadSettings results', () => { + const mockedRead = vi.mocked(fs.readFileSync); + mockedRead.mockClear(); + mockedRead.mockReturnValue('{}'); + (mockFsExistsSync as Mock).mockReturnValue(true); + + const settings1 = loadSettings(MOCK_WORKSPACE_DIR); + const settings2 = loadSettings(MOCK_WORKSPACE_DIR); + + expect(mockedRead).toHaveBeenCalledTimes(5); // system, systemDefaults, user, workspace, and potentially an env file + expect(settings1).toBe(settings2); + }); + + it('should use separate cache for different workspace directories', () => { + const mockedRead = vi.mocked(fs.readFileSync); + mockedRead.mockClear(); + mockedRead.mockReturnValue('{}'); + (mockFsExistsSync as Mock).mockReturnValue(true); + + const workspace1 = path.resolve('/mock/workspace1'); + const workspace2 = path.resolve('/mock/workspace2'); + + const settings1 = loadSettings(workspace1); + const settings2 = loadSettings(workspace2); + + expect(mockedRead).toHaveBeenCalledTimes(10); // 5 for each workspace + expect(settings1).not.toBe(settings2); + }); + + it('should clear cache when saveSettings is called for user settings', () => { + const mockedRead = vi.mocked(fs.readFileSync); + mockedRead.mockClear(); + mockedRead.mockReturnValue('{}'); + (mockFsExistsSync as Mock).mockReturnValue(true); + + const settings1 = loadSettings(MOCK_WORKSPACE_DIR); + expect(mockedRead).toHaveBeenCalledTimes(5); + + saveSettings(settings1.user); + + const settings2 = loadSettings(MOCK_WORKSPACE_DIR); + expect(mockedRead).toHaveBeenCalledTimes(10); // Should have re-read from disk + expect(settings1).not.toBe(settings2); + }); + + it('should clear all caches when saveSettings is called for workspace settings', () => { + const mockedRead = vi.mocked(fs.readFileSync); + mockedRead.mockClear(); + mockedRead.mockReturnValue('{}'); + (mockFsExistsSync as Mock).mockReturnValue(true); + + const workspace1 = path.resolve('/mock/workspace1'); + const workspace2 = path.resolve('/mock/workspace2'); + + const settings1W1 = loadSettings(workspace1); + const settings1W2 = loadSettings(workspace2); + + expect(mockedRead).toHaveBeenCalledTimes(10); + + // Save settings for workspace 1 + saveSettings(settings1W1.workspace); + + const settings2W1 = loadSettings(workspace1); + const settings2W2 = loadSettings(workspace2); + + // Both workspace caches should have been cleared and re-read from disk (+10 reads) + expect(mockedRead).toHaveBeenCalledTimes(20); + expect(settings1W1).not.toBe(settings2W1); + expect(settings1W2).not.toBe(settings2W2); + }); + }); + }); + + describe('excludedProjectEnvVars integration', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('should exclude DEBUG and DEBUG_MODE from project .env files by default', () => { + // Create a workspace settings file with excludedProjectEnvVars + const workspaceSettingsContent = { + general: {}, + advanced: { excludedEnvVars: ['DEBUG', 'DEBUG_MODE'] }, + }; + + (mockFsExistsSync as Mock).mockImplementation( + (p: fs.PathLike) => + normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH), + ); + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) + return JSON.stringify(workspaceSettingsContent); + return '{}'; + }, + ); + + // Mock findEnvFile to return a project .env file + const originalFindEnvFile = ( + loadSettings as unknown as { findEnvFile: () => string } + ).findEnvFile; + (loadSettings as unknown as { findEnvFile: () => string }).findEnvFile = + () => path.resolve('/mock/project/.env'); + + // Mock fs.readFileSync for .env file content + const originalReadFileSync = fs.readFileSync; + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === path.resolve('/mock/project/.env')) { + return 'DEBUG=true\nDEBUG_MODE=1\nGEMINI_API_KEY=test-key'; + } + if ( + normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH) + ) { + return JSON.stringify(workspaceSettingsContent); + } + return '{}'; + }, + ); + + try { + // This will call loadEnvironment internally with the merged settings + const settings = loadSettings(MOCK_WORKSPACE_DIR); + + // Verify the settings were loaded correctly + expect(settings.merged.advanced?.excludedEnvVars).toEqual([ + 'DEBUG', + 'DEBUG_MODE', + ]); + + // Note: We can't directly test process.env changes here because the mocking + // prevents the actual file system operations, but we can verify the settings + // are correctly merged and passed to loadEnvironment + } finally { + (loadSettings as unknown as { findEnvFile: () => string }).findEnvFile = + originalFindEnvFile; + (fs.readFileSync as Mock).mockImplementation(originalReadFileSync); + } + }); + + it('should respect custom excludedProjectEnvVars from user settings', () => { + const userSettingsContent = { + general: {}, + advanced: { excludedEnvVars: ['NODE_ENV', 'DEBUG'] }, + }; + + (mockFsExistsSync as Mock).mockImplementation( + (p: fs.PathLike) => + normalizePath(p) === normalizePath(USER_SETTINGS_PATH), + ); + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) + return JSON.stringify(userSettingsContent); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + expect(settings.user.settings.advanced?.excludedEnvVars).toEqual([ + 'NODE_ENV', + 'DEBUG', + ]); + expect(settings.merged.advanced?.excludedEnvVars).toEqual([ + 'DEBUG', + 'DEBUG_MODE', + 'NODE_ENV', + ]); + }); + + it('should merge excludedProjectEnvVars with workspace taking precedence', () => { + const userSettingsContent = { + general: {}, + advanced: { excludedEnvVars: ['DEBUG', 'NODE_ENV', 'USER_VAR'] }, + }; + const workspaceSettingsContent = { + general: {}, + advanced: { excludedEnvVars: ['WORKSPACE_DEBUG', 'WORKSPACE_VAR'] }, + }; + + (mockFsExistsSync as Mock).mockReturnValue(true); + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) + return JSON.stringify(userSettingsContent); + if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) + return JSON.stringify(workspaceSettingsContent); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + + expect(settings.user.settings.advanced?.excludedEnvVars).toEqual([ + 'DEBUG', + 'NODE_ENV', + 'USER_VAR', + ]); + expect(settings.workspace.settings.advanced?.excludedEnvVars).toEqual([ + 'WORKSPACE_DEBUG', + 'WORKSPACE_VAR', + ]); + expect(settings.merged.advanced?.excludedEnvVars).toEqual([ + 'DEBUG', + 'DEBUG_MODE', + 'NODE_ENV', + 'USER_VAR', + 'WORKSPACE_DEBUG', + 'WORKSPACE_VAR', + ]); + }); + }); + + describe('with workspace trust', () => { + it('should merge workspace settings when workspace is trusted', () => { + (mockFsExistsSync as Mock).mockReturnValue(true); + const userSettingsContent = { + ui: { theme: 'dark' }, + tools: { sandbox: false }, + }; + const workspaceSettingsContent = { + tools: { sandbox: true }, + context: { fileName: 'WORKSPACE.md' }, + }; + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) + return JSON.stringify(userSettingsContent); + if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) + return JSON.stringify(workspaceSettingsContent); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + expect(settings.merged.tools?.sandbox).toBe(true); + expect(settings.merged.context?.fileName).toBe('WORKSPACE.md'); + expect(settings.merged.ui?.theme).toBe('dark'); + }); + + it('should NOT merge workspace settings when workspace is not trusted', () => { + vi.spyOn(trustedFolders, 'isWorkspaceTrusted').mockReturnValue({ + isTrusted: false, + source: 'file', + }); + (mockFsExistsSync as Mock).mockReturnValue(true); + const userSettingsContent = { + ui: { theme: 'dark' }, + tools: { sandbox: false }, + context: { fileName: 'USER.md' }, + }; + const workspaceSettingsContent = { + tools: { sandbox: true }, + context: { fileName: 'WORKSPACE.md' }, + }; + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) + return JSON.stringify(userSettingsContent); + if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) + return JSON.stringify(workspaceSettingsContent); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + + expect(settings.merged.tools?.sandbox).toBe(false); // User setting + expect(settings.merged.context?.fileName).toBe('USER.md'); // User setting + expect(settings.merged.ui?.theme).toBe('dark'); // User setting + }); + + it('should NOT merge workspace settings when workspace trust is undefined', () => { + vi.spyOn(trustedFolders, 'isWorkspaceTrusted').mockReturnValue({ + isTrusted: undefined, + source: undefined, + }); + (mockFsExistsSync as Mock).mockReturnValue(true); + const userSettingsContent = { + ui: { theme: 'dark' }, + tools: { sandbox: false }, + context: { fileName: 'USER.md' }, + }; + const workspaceSettingsContent = { + tools: { sandbox: true }, + context: { fileName: 'WORKSPACE.md' }, + }; + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) + return JSON.stringify(userSettingsContent); + if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) + return JSON.stringify(workspaceSettingsContent); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + + expect(settings.merged.tools?.sandbox).toBe(false); // User setting + expect(settings.merged.context?.fileName).toBe('USER.md'); // User setting + }); + }); + + describe('loadEnvironment', () => { + function setup({ + isFolderTrustEnabled = true, + isWorkspaceTrustedValue = true as boolean | undefined, + }) { + delete process.env['GEMINI_API_KEY']; // reset + delete process.env['TESTTEST']; // reset + const geminiEnvPath = path.resolve( + path.join(MOCK_WORKSPACE_DIR, GEMINI_DIR, '.env'), + ); + const workspaceEnvPath = path.resolve( + path.join(MOCK_WORKSPACE_DIR, '.env'), + ); + + vi.spyOn(trustedFolders, 'isWorkspaceTrusted').mockReturnValue({ + isTrusted: isWorkspaceTrustedValue, + source: 'file', + }); + (mockFsExistsSync as Mock).mockImplementation((p: fs.PathLike) => { + const normalizedP = path.resolve(p.toString()); + return [ + path.resolve(USER_SETTINGS_PATH), + geminiEnvPath, + workspaceEnvPath, + ].includes(normalizedP); + }); + const userSettingsContent: Settings = { + ui: { + theme: 'dark', + }, + security: { + folderTrust: { + enabled: isFolderTrustEnabled, + }, + }, + context: { + fileName: 'USER_CONTEXT.md', + }, + }; + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + const normalizedP = path.resolve(p.toString()); + if (normalizedP === path.resolve(USER_SETTINGS_PATH)) + return JSON.stringify(userSettingsContent); + if (normalizedP === geminiEnvPath || normalizedP === workspaceEnvPath) + return 'TESTTEST=1234\nGEMINI_API_KEY=test-key'; + return '{}'; + }, + ); + } + + it('sets environment variables from .env files', () => { + setup({ isFolderTrustEnabled: false, isWorkspaceTrustedValue: true }); + const settings = { + security: { folderTrust: { enabled: false } }, + } as Settings; + loadEnvironment(settings, MOCK_WORKSPACE_DIR, isWorkspaceTrusted); + + expect(process.env['TESTTEST']).toEqual('1234'); + expect(process.env['GEMINI_API_KEY']).toEqual('test-key'); + }); + + it('does not load env files from untrusted spaces when sandboxed', () => { + setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false }); + const settings = { + security: { folderTrust: { enabled: true } }, + tools: { sandbox: true }, + } as Settings; + loadEnvironment(settings, MOCK_WORKSPACE_DIR, isWorkspaceTrusted); + + expect(process.env['TESTTEST']).not.toEqual('1234'); + }); + + it('does NOT load non-whitelisted env files from untrusted spaces even when NOT sandboxed', () => { + setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false }); + const settings = { + security: { folderTrust: { enabled: true } }, + tools: { sandbox: false }, + } as Settings; + loadEnvironment(settings, MOCK_WORKSPACE_DIR, isWorkspaceTrusted); + + expect(process.env['TESTTEST']).not.toEqual('1234'); + expect(process.env['GEMINI_API_KEY']).toEqual('test-key'); + }); + + it('does not load env files when trust is undefined and sandboxed', () => { + delete process.env['TESTTEST']; + // isWorkspaceTrusted returns {isTrusted: undefined} for matched rules with no trust value, or no matching rules. + setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: undefined }); + const settings = { + security: { folderTrust: { enabled: true } }, + tools: { sandbox: true }, + } as Settings; + + const mockTrustFn = vi.fn().mockReturnValue({ isTrusted: undefined }); + loadEnvironment(settings, MOCK_WORKSPACE_DIR, mockTrustFn); + + expect(process.env['TESTTEST']).not.toEqual('1234'); + expect(process.env['GEMINI_API_KEY']).toEqual('test-key'); + }); + + it('loads whitelisted env files from untrusted spaces if sandboxing is enabled', () => { + setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false }); + const settings = createTestMergedSettings({ + tools: { sandbox: true }, + }); + loadEnvironment(settings, MOCK_WORKSPACE_DIR, isWorkspaceTrusted); + + // GEMINI_API_KEY is in the whitelist, so it should be loaded. + expect(process.env['GEMINI_API_KEY']).toEqual('test-key'); + // TESTTEST is NOT in the whitelist, so it should be blocked. + expect(process.env['TESTTEST']).not.toEqual('1234'); + }); + + it('loads whitelisted env files from untrusted spaces if sandboxing is enabled via CLI flag', () => { + const originalArgv = [...process.argv]; + process.argv.push('-s'); + try { + setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false }); + const settings = createTestMergedSettings({ + tools: { sandbox: false }, + }); + loadEnvironment(settings, MOCK_WORKSPACE_DIR, isWorkspaceTrusted); + + expect(process.env['GEMINI_API_KEY']).toEqual('test-key'); + expect(process.env['TESTTEST']).not.toEqual('1234'); + } finally { + process.argv = originalArgv; + } + }); + }); + + describe('migrateDeprecatedSettings', () => { + let mockFsExistsSync: Mock; + let mockFsReadFileSync: Mock; + + beforeEach(() => { + vi.resetAllMocks(); + mockFsExistsSync = vi.mocked(fs.existsSync); + mockFsExistsSync.mockReturnValue(true); + mockFsReadFileSync = vi.mocked(fs.readFileSync); + mockFsReadFileSync.mockReturnValue('{}'); + vi.spyOn(trustedFolders, 'isWorkspaceTrusted').mockReturnValue({ + isTrusted: true, + source: undefined, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should not do anything if there are no deprecated settings', () => { + const userSettingsContent = { + extensions: { + enabled: ['user-ext-1'], + }, + }; + const workspaceSettingsContent = { + someOtherSetting: 'value', + }; + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) + return JSON.stringify(userSettingsContent); + if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) + return JSON.stringify(workspaceSettingsContent); + return '{}'; + }, + ); + + const setValueSpy = vi.spyOn(LoadedSettings.prototype, 'setValue'); + const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR); + setValueSpy.mockClear(); + + migrateDeprecatedSettings(loadedSettings, true); + + expect(setValueSpy).not.toHaveBeenCalled(); + }); + + it('should migrate general.disableAutoUpdate to general.enableAutoUpdate with inverted value', () => { + const userSettingsContent = { + general: { + disableAutoUpdate: true, + }, + }; + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) + return JSON.stringify(userSettingsContent); + return '{}'; + }, + ); + + const setValueSpy = vi.spyOn(LoadedSettings.prototype, 'setValue'); + const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR); + + migrateDeprecatedSettings(loadedSettings, true); + + // Should set new value to false (inverted from true) + expect(setValueSpy).toHaveBeenCalledWith( + SettingScope.User, + 'general', + expect.objectContaining({ enableAutoUpdate: false }), + ); + }); + + it('should migrate tools.approvalMode to general.defaultApprovalMode', () => { + const userSettingsContent = { + tools: { + approvalMode: 'plan', + }, + }; + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) + return JSON.stringify(userSettingsContent); + return '{}'; + }, + ); + + const setValueSpy = vi.spyOn(LoadedSettings.prototype, 'setValue'); + const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR); + + migrateDeprecatedSettings(loadedSettings, true); + + expect(setValueSpy).toHaveBeenCalledWith( + SettingScope.User, + 'general', + expect.objectContaining({ defaultApprovalMode: 'plan' }), + ); + + // Verify removal + expect(setValueSpy).toHaveBeenCalledWith( + SettingScope.User, + 'tools', + expect.not.objectContaining({ approvalMode: 'plan' }), + ); + }); + + it('should migrate all 4 inverted boolean settings', () => { + const userSettingsContent = { + general: { + disableAutoUpdate: false, + disableUpdateNag: true, + }, + context: { + fileFiltering: { + disableFuzzySearch: false, + }, + }, + ui: { + accessibility: { + disableLoadingPhrases: true, + }, + }, + }; + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) + return JSON.stringify(userSettingsContent); + return '{}'; + }, + ); + + const setValueSpy = vi.spyOn(LoadedSettings.prototype, 'setValue'); + const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR); + + migrateDeprecatedSettings(loadedSettings, true); + + // Check that general settings were migrated with inverted values + expect(setValueSpy).toHaveBeenCalledWith( + SettingScope.User, + 'general', + expect.objectContaining({ enableAutoUpdate: true }), + ); + expect(setValueSpy).toHaveBeenCalledWith( + SettingScope.User, + 'general', + expect.objectContaining({ enableAutoUpdateNotification: false }), + ); + + // Check context.fileFiltering was migrated + expect(setValueSpy).toHaveBeenCalledWith( + SettingScope.User, + 'context', + expect.objectContaining({ + fileFiltering: expect.objectContaining({ enableFuzzySearch: true }), + }), + ); + + // Check ui.accessibility was migrated + expect(setValueSpy).toHaveBeenCalledWith( + SettingScope.User, + 'ui', + expect.objectContaining({ + accessibility: expect.objectContaining({ + enableLoadingPhrases: false, + }), + }), + ); + + // Check that enableLoadingPhrases: false was further migrated to loadingPhrases: 'off' + expect(setValueSpy).toHaveBeenCalledWith( + SettingScope.User, + 'ui', + expect.objectContaining({ + loadingPhrases: 'off', + }), + ); + }); + + it('should migrate enableLoadingPhrases: false to loadingPhrases: off', () => { + const userSettingsContent = { + ui: { + accessibility: { + enableLoadingPhrases: false, + }, + }, + }; + + const loadedSettings = createMockSettings(userSettingsContent); + const setValueSpy = vi.spyOn(loadedSettings, 'setValue'); + + migrateDeprecatedSettings(loadedSettings); + + expect(setValueSpy).toHaveBeenCalledWith( + SettingScope.User, + 'ui', + expect.objectContaining({ + loadingPhrases: 'off', + }), + ); + }); + + it('should not migrate enableLoadingPhrases: true to loadingPhrases', () => { + const userSettingsContent = { + ui: { + accessibility: { + enableLoadingPhrases: true, + }, + }, + }; + + const loadedSettings = createMockSettings(userSettingsContent); + const setValueSpy = vi.spyOn(loadedSettings, 'setValue'); + + migrateDeprecatedSettings(loadedSettings); + + // Should not set loadingPhrases when enableLoadingPhrases is true + const uiCalls = setValueSpy.mock.calls.filter((call) => call[1] === 'ui'); + for (const call of uiCalls) { + const uiValue = call[2] as Record; + expect(uiValue).not.toHaveProperty('loadingPhrases'); + } + }); + + it('should not overwrite existing loadingPhrases during migration', () => { + const userSettingsContent = { + ui: { + loadingPhrases: 'witty', + accessibility: { + enableLoadingPhrases: false, + }, + }, + }; + + const loadedSettings = createMockSettings(userSettingsContent); + const setValueSpy = vi.spyOn(loadedSettings, 'setValue'); + + migrateDeprecatedSettings(loadedSettings); + + // Should not overwrite existing loadingPhrases + const uiCalls = setValueSpy.mock.calls.filter((call) => call[1] === 'ui'); + for (const call of uiCalls) { + const uiValue = call[2] as Record; + if (uiValue['loadingPhrases'] !== undefined) { + expect(uiValue['loadingPhrases']).toBe('witty'); + } + } + }); + + it('should remove deprecated settings by default and prioritize new ones', () => { + const userSettingsContent = { + general: { + disableAutoUpdate: true, + enableAutoUpdate: true, // Trust this (true) over disableAutoUpdate (true -> false) + }, + context: { + fileFiltering: { + disableFuzzySearch: false, + enableFuzzySearch: false, // Trust this (false) over disableFuzzySearch (false -> true) + }, + }, + }; + + const loadedSettings = createMockSettings(userSettingsContent); + const setValueSpy = vi.spyOn(loadedSettings, 'setValue'); + + // Default is now removeDeprecated = true + migrateDeprecatedSettings(loadedSettings); + + // Should remove disableAutoUpdate and trust enableAutoUpdate: true + expect(setValueSpy).toHaveBeenCalledWith(SettingScope.User, 'general', { + enableAutoUpdate: true, + }); + + // Should remove disableFuzzySearch and trust enableFuzzySearch: false + expect(setValueSpy).toHaveBeenCalledWith(SettingScope.User, 'context', { + fileFiltering: { enableFuzzySearch: false }, + }); + }); + + it('should preserve deprecated settings when removeDeprecated is explicitly false', () => { + const userSettingsContent = { + general: { + disableAutoUpdate: true, + enableAutoUpdate: true, + }, + context: { + fileFiltering: { + disableFuzzySearch: false, + enableFuzzySearch: false, + }, + }, + }; + + const loadedSettings = createMockSettings(userSettingsContent); + + migrateDeprecatedSettings(loadedSettings, false); + + // Should still have old settings since removeDeprecated = false + expect( + loadedSettings.forScope(SettingScope.User).settings.general, + ).toHaveProperty('disableAutoUpdate'); + expect( + ( + loadedSettings.forScope(SettingScope.User).settings.context as { + fileFiltering: { disableFuzzySearch: boolean }; + } + ).fileFiltering, + ).toHaveProperty('disableFuzzySearch'); + }); + + it('should trigger migration automatically during loadSettings', () => { + mockFsExistsSync.mockImplementation( + (p: fs.PathLike) => + normalizePath(p) === normalizePath(USER_SETTINGS_PATH), + ); + const userSettingsContent = { + general: { + disableAutoUpdate: true, + }, + }; + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) + return JSON.stringify(userSettingsContent); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + + // Verify it was migrated in the merged settings + expect(settings.merged.general?.enableAutoUpdate).toBe(false); + + // Verify it was saved back to disk (via setValue calling updateSettingsFilePreservingFormat) + expect(updateSettingsFilePreservingFormat).toHaveBeenCalledWith( + USER_SETTINGS_PATH, + expect.objectContaining({ + general: expect.objectContaining({ enableAutoUpdate: false }), + }), + ); + }); + + it('should migrate disableUpdateNag to enableAutoUpdateNotification in memory but not save for system and system defaults settings', () => { + const systemSettingsContent = { + general: { + disableUpdateNag: true, + }, + }; + const systemDefaultsContent = { + general: { + disableUpdateNag: false, + }, + }; + + vi.mocked(fs.existsSync).mockReturnValue(true); + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (normalizePath(p) === normalizePath(getSystemSettingsPath())) { + return JSON.stringify(systemSettingsContent); + } + if (normalizePath(p) === normalizePath(getSystemDefaultsPath())) { + return JSON.stringify(systemDefaultsContent); + } + return '{}'; + }, + ); + + const feedbackSpy = mockCoreEvents.emitFeedback; + const settings = loadSettings(MOCK_WORKSPACE_DIR); + + // Verify system settings were migrated in memory + expect(settings.system.settings.general).toHaveProperty( + 'enableAutoUpdateNotification', + ); + expect( + (settings.system.settings.general as Record)[ + 'enableAutoUpdateNotification' + ], + ).toBe(false); + + // Verify system defaults settings were migrated in memory + expect(settings.systemDefaults.settings.general).toHaveProperty( + 'enableAutoUpdateNotification', + ); + expect( + (settings.systemDefaults.settings.general as Record)[ + 'enableAutoUpdateNotification' + ], + ).toBe(true); + + // Merged should also reflect it (system overrides defaults, but both are migrated) + expect(settings.merged.general?.enableAutoUpdateNotification).toBe(false); + + // Verify it was NOT saved back to disk + expect(updateSettingsFilePreservingFormat).not.toHaveBeenCalledWith( + getSystemSettingsPath(), + expect.anything(), + ); + expect(updateSettingsFilePreservingFormat).not.toHaveBeenCalledWith( + getSystemDefaultsPath(), + expect.anything(), + ); + + // Verify warnings were shown + expect(feedbackSpy).toHaveBeenCalledWith( + 'warning', + expect.stringContaining( + 'The system configuration contains deprecated settings', + ), + ); + expect(feedbackSpy).toHaveBeenCalledWith( + 'warning', + expect.stringContaining( + 'The system default configuration contains deprecated settings', + ), + ); + }); + + it('should migrate experimental agent settings in system scope in memory but not save', () => { + const systemSettingsContent = { + experimental: { + codebaseInvestigatorSettings: { + enabled: true, + }, + }, + }; + + vi.mocked(fs.existsSync).mockReturnValue(true); + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (normalizePath(p) === normalizePath(getSystemSettingsPath())) { + return JSON.stringify(systemSettingsContent); + } + return '{}'; + }, + ); + + const feedbackSpy = mockCoreEvents.emitFeedback; + const settings = loadSettings(MOCK_WORKSPACE_DIR); + + // Verify it was migrated in memory + expect(settings.system.settings.agents?.overrides).toMatchObject({ + codebase_investigator: { + enabled: true, + }, + }); + + // Verify it was NOT saved back to disk + expect(updateSettingsFilePreservingFormat).not.toHaveBeenCalledWith( + getSystemSettingsPath(), + expect.anything(), + ); + + // Verify warnings were shown + expect(feedbackSpy).toHaveBeenCalledWith( + 'warning', + expect.stringContaining( + 'The system configuration contains deprecated settings: [experimental.codebaseInvestigatorSettings]', + ), + ); + }); + + it('should migrate experimental agent settings to agents overrides', () => { + const userSettingsContent = { + experimental: { + codebaseInvestigatorSettings: { + enabled: true, + maxNumTurns: 15, + maxTimeMinutes: 5, + thinkingBudget: 16384, + model: 'gemini-1.5-pro', + }, + cliHelpAgentSettings: { + enabled: false, + }, + }, + }; + + vi.mocked(fs.existsSync).mockReturnValue(true); + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) + return JSON.stringify(userSettingsContent); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + + // Verify migration to agents.overrides + expect(settings.user.settings.agents?.overrides).toMatchObject({ + codebase_investigator: { + enabled: true, + runConfig: { + maxTurns: 15, + maxTimeMinutes: 5, + }, + modelConfig: { + model: 'gemini-1.5-pro', + generateContentConfig: { + thinkingConfig: { + thinkingBudget: 16384, + }, + }, + }, + }, + cli_help: { + enabled: false, + }, + }); + }); + }); + + describe('saveSettings', () => { + it('should save settings using updateSettingsFilePreservingFormat', () => { + const mockUpdateSettings = vi.mocked(updateSettingsFilePreservingFormat); + const settingsFile = createMockSettings({ ui: { theme: 'dark' } }).user; + settingsFile.path = path.resolve('/mock/settings.json'); + + saveSettings(settingsFile); + + expect(mockUpdateSettings).toHaveBeenCalledWith( + path.resolve('/mock/settings.json'), + { + ui: { theme: 'dark' }, + }, + ); + }); + + it('should create directory if it does not exist', () => { + const mockFsExistsSync = vi.mocked(fs.existsSync); + const mockFsMkdirSync = vi.mocked(fs.mkdirSync); + mockFsExistsSync.mockReturnValue(false); + + const settingsFile = createMockSettings({}).user; + settingsFile.path = path.resolve('/mock/new/dir/settings.json'); + + saveSettings(settingsFile); + + expect(mockFsExistsSync).toHaveBeenCalledWith( + path.resolve('/mock/new/dir'), + ); + expect(mockFsMkdirSync).toHaveBeenCalledWith( + path.resolve('/mock/new/dir'), + { + recursive: true, + }, + ); + }); + + it('should emit error feedback if saving fails', () => { + const mockUpdateSettings = vi.mocked(updateSettingsFilePreservingFormat); + const error = new Error('Write failed'); + mockUpdateSettings.mockImplementation(() => { + throw error; + }); + + const settingsFile = createMockSettings({}).user; + settingsFile.path = path.resolve('/mock/settings.json'); + + saveSettings(settingsFile); + + expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith( + 'error', + 'Failed to save settings: Write failed', + error, + ); + }); + }); + + describe('LoadedSettings and remote admin settings', () => { + it('should prioritize remote admin settings over file-based admin settings', () => { + (mockFsExistsSync as Mock).mockReturnValue(true); + const systemSettingsContent = { + admin: { + // These should be ignored + secureModeEnabled: true, + mcp: { enabled: false }, + extensions: { enabled: false }, + }, + // A non-admin setting to ensure it's still processed + ui: { theme: 'system-theme' }, + }; + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (normalizePath(p) === normalizePath(getSystemSettingsPath())) { + return JSON.stringify(systemSettingsContent); + } + return '{}'; + }, + ); + + const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR); + + // 1. Verify that on initial load, file-based admin settings are ignored + // and schema defaults are used instead. + expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false); // default: false + expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(true); // default: true + expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(true); // default: true + expect(loadedSettings.merged.ui?.theme).toBe('system-theme'); // non-admin setting should be loaded + + // 2. Now, set remote admin settings. + loadedSettings.setRemoteAdminSettings({ + strictModeDisabled: false, + mcpSetting: { mcpEnabled: false, mcpConfig: {} }, + cliFeatureSetting: { + extensionsSetting: { extensionsEnabled: false }, + unmanagedCapabilitiesEnabled: false, + }, + }); + + // 3. Verify that remote admin settings take precedence. + expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(true); + expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(false); + expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(false); + // non-admin setting should remain unchanged + expect(loadedSettings.merged.ui?.theme).toBe('system-theme'); + }); + + it('should set remote admin settings and recompute merged settings', () => { + (mockFsExistsSync as Mock).mockReturnValue(true); + const systemSettingsContent = { + admin: { + secureModeEnabled: false, + mcp: { enabled: false }, + extensions: { enabled: false }, + }, + ui: { theme: 'initial-theme' }, + }; + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (normalizePath(p) === normalizePath(getSystemSettingsPath())) { + return JSON.stringify(systemSettingsContent); + } + return '{}'; + }, + ); + + const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR); + // Ensure initial state from defaults (as file-based admin settings are ignored) + expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false); + expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(true); + expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(true); + expect(loadedSettings.merged.ui?.theme).toBe('initial-theme'); + + const newRemoteSettings = { + strictModeDisabled: false, + mcpSetting: { mcpEnabled: false, mcpConfig: {} }, + cliFeatureSetting: { + extensionsSetting: { extensionsEnabled: false }, + unmanagedCapabilitiesEnabled: false, + }, + }; + + loadedSettings.setRemoteAdminSettings(newRemoteSettings); + + // Verify that remote admin settings are applied + expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(true); + expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(false); + expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(false); + // Non-admin settings should remain untouched + expect(loadedSettings.merged.ui?.theme).toBe('initial-theme'); + }); + + it('should correctly handle undefined remote admin settings', () => { + (mockFsExistsSync as Mock).mockReturnValue(true); + const systemSettingsContent = { + ui: { theme: 'initial-theme' }, + }; + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (normalizePath(p) === normalizePath(getSystemSettingsPath())) { + return JSON.stringify(systemSettingsContent); + } + return '{}'; + }, + ); + + const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR); + // Should have default admin settings + expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false); + expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(true); + expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(true); + + loadedSettings.setRemoteAdminSettings({}); // Set empty remote settings + + // Admin settings should revert to defaults because there are no remote overrides + expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false); + expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(true); + expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(true); + }); + + it('should un-nest MCP configuration from remote settings', () => { + const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR); + const mcpServers: Record = { + 'admin-server': { + url: 'http://admin-mcp.com', + type: 'sse', + trust: true, + }, + }; + + loadedSettings.setRemoteAdminSettings({ + mcpSetting: { + mcpEnabled: true, + mcpConfig: { + mcpServers, + }, + }, + }); + + expect(loadedSettings.merged.admin?.mcp?.config).toEqual(mcpServers); + }); + + it('should map requiredMcpConfig from remote settings', () => { + const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR); + const requiredMcpConfig = { + 'corp-tool': { + url: 'https://mcp.corp/tool', + type: 'http' as const, + trust: true, + }, + }; + + loadedSettings.setRemoteAdminSettings({ + mcpSetting: { + mcpEnabled: true, + requiredMcpConfig, + }, + }); + + expect(loadedSettings.merged.admin?.mcp?.requiredConfig).toEqual( + requiredMcpConfig, + ); + }); + + it('should set skills based on unmanagedCapabilitiesEnabled', () => { + const loadedSettings = loadSettings(); + loadedSettings.setRemoteAdminSettings({ + cliFeatureSetting: { + unmanagedCapabilitiesEnabled: true, + }, + }); + expect(loadedSettings.merged.admin.skills?.enabled).toBe(true); + + loadedSettings.setRemoteAdminSettings({ + cliFeatureSetting: { + unmanagedCapabilitiesEnabled: false, + }, + }); + expect(loadedSettings.merged.admin.skills?.enabled).toBe(false); + }); + + it('should handle completely empty remote admin settings response', () => { + const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR); + + loadedSettings.setRemoteAdminSettings({}); + + // Should default to schema defaults (standard defaults) + expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false); + expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(true); + expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(true); + }); + }); + + describe('getDefaultsFromSchema', () => { + it('should extract defaults from a schema', () => { + const mockSchema = { + prop1: { + type: 'string', + default: 'default1', + label: 'Prop 1', + category: 'General', + requiresRestart: false, + }, + nested: { + type: 'object', + label: 'Nested', + category: 'General', + requiresRestart: false, + default: {}, + properties: { + prop2: { + type: 'number', + default: 42, + label: 'Prop 2', + category: 'General', + requiresRestart: false, + }, + }, + }, + }; + + const defaults = getDefaultsFromSchema(mockSchema as SettingsSchema); + expect(defaults).toEqual({ + prop1: 'default1', + nested: { + prop2: 42, + }, + }); + }); + }); + + describe('Reactivity & Snapshots', () => { + let loadedSettings: LoadedSettings; + + beforeEach(() => { + const emptySettingsFile: SettingsFile = { + path: path.resolve('/mock/path'), + settings: {}, + originalSettings: {}, + }; + + loadedSettings = new LoadedSettings( + { ...emptySettingsFile, path: getSystemSettingsPath() }, + { ...emptySettingsFile, path: getSystemDefaultsPath() }, + { ...emptySettingsFile, path: USER_SETTINGS_PATH }, + { ...emptySettingsFile, path: MOCK_WORKSPACE_SETTINGS_PATH }, + true, // isTrusted + [], + ); + }); + + it('getSnapshot() should return stable reference if no changes occur', () => { + const snap1 = loadedSettings.getSnapshot(); + const snap2 = loadedSettings.getSnapshot(); + expect(snap1).toBe(snap2); + }); + + it('getSnapshot() should preserve readOnly metadata for each scope', () => { + const readonlySettings = new LoadedSettings( + { + path: getSystemSettingsPath(), + settings: {}, + originalSettings: {}, + readOnly: true, + }, + { + path: getSystemDefaultsPath(), + settings: {}, + originalSettings: {}, + readOnly: true, + }, + { + path: USER_SETTINGS_PATH, + settings: {}, + originalSettings: {}, + readOnly: false, + }, + { + path: MOCK_WORKSPACE_SETTINGS_PATH, + settings: {}, + originalSettings: {}, + readOnly: true, + }, + true, + [], + ); + + const snapshot = readonlySettings.getSnapshot(); + + expect(snapshot.system.readOnly).toBe(true); + expect(snapshot.systemDefaults.readOnly).toBe(true); + expect(snapshot.user.readOnly).toBe(false); + expect(snapshot.workspace.readOnly).toBe(true); + }); + + it('setValue() should create a new snapshot reference and emit event', () => { + const oldSnapshot = loadedSettings.getSnapshot(); + const oldUserRef = oldSnapshot.user.settings; + + loadedSettings.setValue(SettingScope.User, 'ui.theme', 'high-contrast'); + + const newSnapshot = loadedSettings.getSnapshot(); + + expect(newSnapshot).not.toBe(oldSnapshot); + expect(newSnapshot.user.settings).not.toBe(oldUserRef); + expect(newSnapshot.user.settings.ui?.theme).toBe('high-contrast'); + + expect(newSnapshot.system.settings).not.toBe(oldSnapshot.system.settings); + + expect(mockCoreEvents.emitSettingsChanged).toHaveBeenCalled(); + }); + }); + + describe('Security and Sandbox', () => { + let originalArgv: string[]; + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + originalArgv = [...process.argv]; + originalEnv = { ...process.env }; + // Clear relevant env vars + delete process.env['GEMINI_API_KEY']; + delete process.env['GOOGLE_API_KEY']; + delete process.env['GOOGLE_CLOUD_PROJECT']; + delete process.env['GOOGLE_CLOUD_LOCATION']; + delete process.env['CLOUD_SHELL']; + delete process.env['MALICIOUS_VAR']; + delete process.env['FOO']; + delete process.env['_GEMINI_USER_GCP_PROJECT']; + vi.resetAllMocks(); + vi.mocked(fs.existsSync).mockReturnValue(false); + }); + + afterEach(() => { + process.argv = originalArgv; + process.env = originalEnv; + }); + + describe('sandbox detection', () => { + it('should detect sandbox when -s is a real flag', () => { + process.argv = ['node', 'gemini', '-s', 'some prompt']; + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: false, + source: 'file', + }); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue( + 'FOO=bar\nGEMINI_API_KEY=secret', + ); + + loadEnvironment( + createMockSettings({ tools: { sandbox: false } }).merged, + MOCK_WORKSPACE_DIR, + ); + + // If sandboxed and untrusted, FOO should NOT be loaded, but GEMINI_API_KEY should be. + expect(process.env['FOO']).toBeUndefined(); + expect(process.env['GEMINI_API_KEY']).toBe('secret'); + }); + + it('should detect sandbox when --sandbox is a real flag', () => { + process.argv = ['node', 'gemini', '--sandbox', 'prompt']; + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: false, + source: 'file', + }); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=secret'); + + loadEnvironment( + createMockSettings({ tools: { sandbox: false } }).merged, + MOCK_WORKSPACE_DIR, + ); + + expect(process.env['GEMINI_API_KEY']).toBe('secret'); + }); + + it('should ignore sandbox flags if they appear after --', () => { + process.argv = ['node', 'gemini', '--', '-s', 'some prompt']; + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: false, + source: 'file', + }); + vi.mocked(fs.existsSync).mockImplementation((path) => + path.toString().endsWith('.env'), + ); + vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=secret'); + + loadEnvironment( + createMockSettings({ tools: { sandbox: false } }).merged, + MOCK_WORKSPACE_DIR, + ); + + expect(process.env['GEMINI_API_KEY']).toEqual('secret'); + }); + + it('should NOT be tricked by positional arguments that look like flags', () => { + process.argv = ['node', 'gemini', 'my -s prompt']; + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: false, + source: 'file', + }); + vi.mocked(fs.existsSync).mockImplementation((path) => + path.toString().endsWith('.env'), + ); + vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=secret'); + + loadEnvironment( + createMockSettings({ tools: { sandbox: false } }).merged, + MOCK_WORKSPACE_DIR, + ); + + expect(process.env['GEMINI_API_KEY']).toEqual('secret'); + }); + }); + + describe('env var sanitization', () => { + it('should strictly enforce whitelist in untrusted/sandboxed mode', () => { + process.argv = ['node', 'gemini', '-s', 'prompt']; + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: false, + source: 'file', + }); + vi.mocked(fs.existsSync).mockImplementation((path) => + path.toString().endsWith('.env'), + ); + vi.mocked(fs.readFileSync).mockReturnValue(` +GEMINI_API_KEY=secret-key +MALICIOUS_VAR=should-be-ignored +GOOGLE_API_KEY=another-secret + `); + + loadEnvironment( + createMockSettings({ tools: { sandbox: false } }).merged, + MOCK_WORKSPACE_DIR, + ); + + expect(process.env['GEMINI_API_KEY']).toBe('secret-key'); + expect(process.env['GOOGLE_API_KEY']).toBe('another-secret'); + expect(process.env['MALICIOUS_VAR']).toBeUndefined(); + }); + + it('should sanitize shell injection characters in whitelisted env vars in untrusted mode', () => { + process.argv = ['node', 'gemini', '--sandbox', 'prompt']; + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: false, + source: 'file', + }); + vi.mocked(fs.existsSync).mockImplementation((path) => + path.toString().endsWith('.env'), + ); + + const maliciousPayload = 'key-$(whoami)-`id`-&|;><*?[]{}'; + vi.mocked(fs.readFileSync).mockReturnValue( + `GEMINI_API_KEY=${maliciousPayload}`, + ); + + loadEnvironment( + createMockSettings({ tools: { sandbox: false } }).merged, + MOCK_WORKSPACE_DIR, + ); + + // sanitizeEnvVar: value.replace(/[^a-zA-Z0-9\-_./]/g, '') + expect(process.env['GEMINI_API_KEY']).toBe('key-whoami-id-'); + }); + + it('should allow . and / in whitelisted env vars but sanitize other characters in untrusted mode', () => { + process.argv = ['node', 'gemini', '--sandbox', 'prompt']; + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: false, + source: 'file', + }); + vi.mocked(fs.existsSync).mockImplementation((path) => + path.toString().endsWith('.env'), + ); + + const complexPayload = 'secret-123/path.to/somewhere;rm -rf /'; + vi.mocked(fs.readFileSync).mockReturnValue( + `GEMINI_API_KEY=${complexPayload}`, + ); + + loadEnvironment( + createMockSettings({ tools: { sandbox: false } }).merged, + MOCK_WORKSPACE_DIR, + ); + + expect(process.env['GEMINI_API_KEY']).toBe( + 'secret-123/path.to/somewhererm-rf/', + ); + }); + + it('should NOT sanitize variables from trusted sources', () => { + process.argv = ['node', 'gemini', 'prompt']; + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: true, + source: 'file', + }); + vi.mocked(fs.existsSync).mockReturnValue(true); + + vi.mocked(fs.readFileSync).mockReturnValue('FOO=$(bar)'); + + loadEnvironment( + createMockSettings({ tools: { sandbox: false } }).merged, + MOCK_WORKSPACE_DIR, + ); + + // Trusted source, no sanitization + expect(process.env['FOO']).toBe('$(bar)'); + }); + + it('should load environment variables normally when workspace is TRUSTED even if "sandboxed"', () => { + process.argv = ['node', 'gemini', '-s', 'prompt']; + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: true, + source: 'file', + }); + vi.mocked(fs.existsSync).mockImplementation((path) => + path.toString().endsWith('.env'), + ); + vi.mocked(fs.readFileSync).mockReturnValue(` +GEMINI_API_KEY=un-sanitized;key! +MALICIOUS_VAR=allowed-because-trusted + `); + + loadEnvironment( + createMockSettings({ tools: { sandbox: false } }).merged, + MOCK_WORKSPACE_DIR, + ); + + expect(process.env['GEMINI_API_KEY']).toBe('un-sanitized;key!'); + expect(process.env['MALICIOUS_VAR']).toBe('allowed-because-trusted'); + }); + + it('should sanitize value in sanitizeEnvVar helper', () => { + expect(sanitizeEnvVar('$(calc)')).toBe('calc'); + expect(sanitizeEnvVar('`rm -rf /`')).toBe('rm-rf/'); + expect(sanitizeEnvVar('normal-project-123')).toBe('normal-project-123'); + expect(sanitizeEnvVar('us-central1')).toBe('us-central1'); + }); + }); + + describe('Cloud Shell security', () => { + it('should handle Cloud Shell special defaults securely when untrusted', () => { + process.env['CLOUD_SHELL'] = 'true'; + process.argv = ['node', 'gemini', '-s', 'prompt']; + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: false, + source: 'file', + }); + + // No .env file + vi.mocked(fs.existsSync).mockReturnValue(false); + + loadEnvironment( + createMockSettings({ tools: { sandbox: false } }).merged, + MOCK_WORKSPACE_DIR, + ); + + expect(process.env['GOOGLE_CLOUD_PROJECT']).toBe('cloudshell-gca'); + }); + + it('should not override GOOGLE_CLOUD_PROJECT in Cloud Shell when auth type is vertex-ai', () => { + vi.stubEnv('CLOUD_SHELL', 'true'); + vi.stubEnv('GOOGLE_CLOUD_PROJECT', 'my-vertex-project'); + process.argv = ['node', 'gemini', '-s', 'prompt']; + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: false, + source: 'file', + }); + + // No .env file + vi.mocked(fs.existsSync).mockReturnValue(false); + + loadEnvironment( + createMockSettings({ + tools: { sandbox: false }, + security: { auth: { selectedType: AuthType.USE_VERTEX_AI } }, + }).merged, + MOCK_WORKSPACE_DIR, + ); + + expect(process.env['GOOGLE_CLOUD_PROJECT']).toBe('my-vertex-project'); + }); + + it('should respect .env override for GOOGLE_CLOUD_PROJECT in Cloud Shell when auth type is vertex-ai', () => { + vi.stubEnv('CLOUD_SHELL', 'true'); + vi.stubEnv('GOOGLE_CLOUD_PROJECT', 'my-vertex-project'); + process.argv = ['node', 'gemini', '-s', 'prompt']; + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: true, + source: 'file', + }); + + // Mock .env file to override the shell project + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue( + 'GOOGLE_CLOUD_PROJECT=env-vertex-project', + ); + + loadEnvironment( + createMockSettings({ + tools: { sandbox: false }, + security: { auth: { selectedType: AuthType.USE_VERTEX_AI } }, + }).merged, + MOCK_WORKSPACE_DIR, + ); + + expect(process.env['GOOGLE_CLOUD_PROJECT']).toBe('env-vertex-project'); + }); + + it('should clear cloudshell-gca when switching to Vertex AI without an original project', () => { + process.env['CLOUD_SHELL'] = 'true'; + process.argv = ['node', 'gemini', '-s', 'prompt']; + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: false, + source: 'file', + }); + vi.mocked(fs.existsSync).mockReturnValue(false); + + // First call: normal Cloud Shell auth sets cloudshell-gca + loadEnvironment( + createMockSettings({ tools: { sandbox: false } }).merged, + MOCK_WORKSPACE_DIR, + ); + expect(process.env['GOOGLE_CLOUD_PROJECT']).toBe('cloudshell-gca'); + + // Second call: user switched to Vertex AI, should remove cloudshell-gca + loadEnvironment( + createMockSettings({ + tools: { sandbox: false }, + security: { auth: { selectedType: AuthType.USE_VERTEX_AI } }, + }).merged, + MOCK_WORKSPACE_DIR, + ); + expect(process.env['GOOGLE_CLOUD_PROJECT']).toBeUndefined(); + }); + + it('should restore original project when switching to Vertex AI after Cloud Shell override', () => { + process.env['CLOUD_SHELL'] = 'true'; + process.env['GOOGLE_CLOUD_PROJECT'] = 'my-real-project'; + process.argv = ['node', 'gemini', '-s', 'prompt']; + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: false, + source: 'file', + }); + vi.mocked(fs.existsSync).mockReturnValue(false); + + // First call: saves original to _GEMINI_USER_GCP_PROJECT, sets cloudshell-gca + loadEnvironment( + createMockSettings({ tools: { sandbox: false } }).merged, + MOCK_WORKSPACE_DIR, + ); + expect(process.env['GOOGLE_CLOUD_PROJECT']).toBe('cloudshell-gca'); + expect(process.env['_GEMINI_USER_GCP_PROJECT']).toBe('my-real-project'); + + // Second call: switching to Vertex AI should restore the saved value + loadEnvironment( + createMockSettings({ + tools: { sandbox: false }, + security: { auth: { selectedType: AuthType.USE_VERTEX_AI } }, + }).merged, + MOCK_WORKSPACE_DIR, + ); + expect(process.env['GOOGLE_CLOUD_PROJECT']).toBe('my-real-project'); + }); + + it('should restore project after restart when child inherits cloudshell-gca', () => { + // Simulate child process after restart: inherits cloudshell-gca and + // the saved original from the parent process. + process.env['CLOUD_SHELL'] = 'true'; + process.env['GOOGLE_CLOUD_PROJECT'] = 'cloudshell-gca'; + process.env['_GEMINI_USER_GCP_PROJECT'] = 'my-real-project'; + process.argv = ['node', 'gemini', '-s', 'prompt']; + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: false, + source: 'file', + }); + vi.mocked(fs.existsSync).mockReturnValue(false); + + loadEnvironment( + createMockSettings({ + tools: { sandbox: false }, + security: { auth: { selectedType: AuthType.USE_VERTEX_AI } }, + }).merged, + MOCK_WORKSPACE_DIR, + ); + expect(process.env['GOOGLE_CLOUD_PROJECT']).toBe('my-real-project'); + }); + + it('should sanitize GOOGLE_CLOUD_PROJECT in Cloud Shell when loaded from .env in untrusted mode', () => { + process.env['CLOUD_SHELL'] = 'true'; + process.argv = ['node', 'gemini', '-s', 'prompt']; + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: false, + source: 'file', + }); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue( + 'GOOGLE_CLOUD_PROJECT=attacker-project;inject', + ); + + loadEnvironment( + createMockSettings({ tools: { sandbox: false } }).merged, + MOCK_WORKSPACE_DIR, + ); + + expect(process.env['GOOGLE_CLOUD_PROJECT']).toBe( + 'attacker-projectinject', + ); + }); + }); + + describe('system configuration security', () => { + beforeEach(() => { + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: true, + source: 'file', + }); + }); + + it('should skip system-defaults.json when insecure and record a warning', () => { + resetSettingsCacheForTesting(); + vi.mocked(fs.existsSync).mockImplementation( + (p) => String(p) === getSystemDefaultsPath(), + ); + vi.mocked(fs.readFileSync).mockImplementation((p) => { + if (String(p) === getSystemDefaultsPath()) { + return JSON.stringify({ + hooks: { + onSessionStart: { command: 'malicious-command.exe' }, + }, + }); + } + return ''; + }); + vi.spyOn(core, 'isFileAndDirectorySecureSync').mockReturnValue({ + secure: false, + reason: + 'Directory is insecure. User group Users has write permissions.', + }); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + + expect(settings.systemDefaults.settings).toEqual({}); + expect(settings.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('Skipping system defaults file'), + severity: 'warning', + }), + ]), + ); + }); + + it('should skip system settings.json when insecure and record a warning', () => { + resetSettingsCacheForTesting(); + vi.mocked(fs.existsSync).mockImplementation( + (p) => String(p) === getSystemSettingsPath(), + ); + vi.mocked(fs.readFileSync).mockImplementation((p) => { + if (String(p) === getSystemSettingsPath()) { + return JSON.stringify({ + hooks: { + onSessionStart: { command: 'malicious-command.exe' }, + }, + }); + } + return ''; + }); + vi.spyOn(core, 'isFileAndDirectorySecureSync').mockReturnValue({ + secure: false, + reason: 'File is not owned by root (uid 0).', + }); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + + expect(settings.system.settings).toEqual({}); + expect(settings.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('Skipping system settings file'), + severity: 'warning', + }), + ]), + ); + }); + + it('should load system-defaults.json when secure', () => { + resetSettingsCacheForTesting(); + vi.mocked(fs.existsSync).mockImplementation( + (p) => String(p) === getSystemDefaultsPath(), + ); + vi.mocked(fs.readFileSync).mockImplementation((p) => { + if (String(p) === getSystemDefaultsPath()) { + return JSON.stringify({ + ui: { theme: 'corporate-theme' }, + }); + } + return ''; + }); + vi.spyOn(core, 'isFileAndDirectorySecureSync').mockReturnValue({ + secure: true, + }); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + + expect(settings.systemDefaults.settings).toEqual({ + ui: { theme: 'corporate-theme' }, + }); + }); + }); + }); +}); + +describe('LoadedSettings Isolation and Serializability', () => { + let loadedSettings: LoadedSettings; + + interface TestData { + a: { + b: number; + }; + } + + beforeEach(() => { + vi.resetAllMocks(); + + // Create a minimal LoadedSettings instance + const emptyScope = { + path: path.resolve('/mock/settings.json'), + settings: {}, + originalSettings: {}, + } as unknown as SettingsFile; + + loadedSettings = new LoadedSettings( + emptyScope, // system + emptyScope, // systemDefaults + { ...emptyScope }, // user + emptyScope, // workspace + true, // isTrusted + ); + }); + + describe('setValue Isolation', () => { + it('should isolate state between settings and originalSettings', () => { + const complexValue: TestData = { a: { b: 1 } }; + loadedSettings.setValue(SettingScope.User, 'test', complexValue); + + const userSettings = loadedSettings.forScope(SettingScope.User); + const settingsValue = (userSettings.settings as Record)[ + 'test' + ] as TestData; + const originalValue = ( + userSettings.originalSettings as Record + )['test'] as TestData; + + // Verify they are equal but different references + expect(settingsValue).toEqual(complexValue); + expect(originalValue).toEqual(complexValue); + expect(settingsValue).not.toBe(complexValue); + expect(originalValue).not.toBe(complexValue); + expect(settingsValue).not.toBe(originalValue); + + // Modify the in-memory setting object + settingsValue.a.b = 2; + + // originalSettings should NOT be affected + expect(originalValue.a.b).toBe(1); + }); + + it('should not share references between settings and originalSettings (original servers test)', () => { + const mcpServers = { + 'test-server': { command: 'echo' }, + }; + + loadedSettings.setValue(SettingScope.User, 'mcpServers', mcpServers); + + // Modify the original object + delete (mcpServers as Record)['test-server']; + + // The settings in LoadedSettings should still have the server + const userSettings = loadedSettings.forScope(SettingScope.User); + expect( + (userSettings.settings.mcpServers as Record)[ + 'test-server' + ], + ).toBeDefined(); + expect( + (userSettings.originalSettings.mcpServers as Record)[ + 'test-server' + ], + ).toBeDefined(); + + // They should also be different objects from each other + expect(userSettings.settings.mcpServers).not.toBe( + userSettings.originalSettings.mcpServers, + ); + }); + }); + + describe('setValue Serializability', () => { + it('should preserve Map/Set types (via structuredClone)', () => { + const mapValue = { myMap: new Map([['key', 'value']]) }; + loadedSettings.setValue(SettingScope.User, 'test', mapValue); + + const userSettings = loadedSettings.forScope(SettingScope.User); + const settingsValue = (userSettings.settings as Record)[ + 'test' + ] as { myMap: Map }; + + // Map is preserved by structuredClone + expect(settingsValue.myMap).toBeInstanceOf(Map); + expect(settingsValue.myMap.get('key')).toBe('value'); + + // But it should be a different reference + expect(settingsValue.myMap).not.toBe(mapValue.myMap); + }); + + it('should handle circular references (structuredClone supports them, but deepMerge may not)', () => { + const circular: Record = { a: 1 }; + circular['self'] = circular; + + // structuredClone(circular) works, but LoadedSettings.setValue calls + // computeMergedSettings() -> customDeepMerge() which blows up on circularity. + expect(() => { + loadedSettings.setValue(SettingScope.User, 'test', circular); + }).toThrow(/Maximum call stack size exceeded/); + }); + }); +}); diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts new file mode 100644 index 0000000000000000000000000000000000000000..0b28aa784143583377f77fb2493dbd7270844b18 --- /dev/null +++ b/packages/cli/src/config/settings.ts @@ -0,0 +1,1400 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { platform } from 'node:os'; +import * as dotenv from 'dotenv'; +import process from 'node:process'; +import { + CoreEvent, + FatalConfigError, + GEMINI_DIR, + getErrorMessage, + getFsErrorMessage, + Storage, + coreEvents, + homedir, + AuthType, + type AdminControlsSettings, + createCache, + isFileAndDirectorySecureSync, + createPathSecurityCache, +} from '@google/gemini-cli-core'; +import stripJsonComments from 'strip-json-comments'; +import { DefaultLight } from '../ui/themes/builtin/light/default-light.js'; +import { DefaultDark } from '../ui/themes/builtin/dark/default-dark.js'; +import { isWorkspaceTrusted } from './trustedFolders.js'; +import { + type Settings, + type MergedSettings, + type MemoryImportFormat, + type MergeStrategy, + type SettingsSchema, + type SettingDefinition, + getSettingsSchema, +} from './settingsSchema.js'; + +export { + type Settings, + type MergedSettings, + type MemoryImportFormat, + type MergeStrategy, + type SettingsSchema, + type SettingDefinition, + getSettingsSchema, +}; + +import { resolveEnvVarsInObject } from '../utils/envVarResolver.js'; +import { customDeepMerge } from '../utils/deepMerge.js'; +import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js'; +import { + validateSettings, + formatValidationError, +} from './settings-validation.js'; + +export function getMergeStrategyForPath( + path: string[], +): MergeStrategy | undefined { + let current: SettingDefinition | undefined = undefined; + let currentSchema: SettingsSchema | undefined = getSettingsSchema(); + let parent: SettingDefinition | undefined = undefined; + + for (const key of path) { + if (!currentSchema || !currentSchema[key]) { + // Key not found in schema - check if parent has additionalProperties + if (parent?.additionalProperties?.mergeStrategy) { + return parent.additionalProperties.mergeStrategy; + } + return undefined; + } + parent = current; + current = currentSchema[key]; + currentSchema = current.properties; + } + + return current?.mergeStrategy; +} + +export const USER_SETTINGS_PATH = Storage.getGlobalSettingsPath(); +export const USER_SETTINGS_DIR = path.dirname(USER_SETTINGS_PATH); +export const DEFAULT_EXCLUDED_ENV_VARS = [ + 'DEBUG', + 'DEBUG_MODE', + 'GEMINI_CLI_IDE_SERVER_STDIO_COMMAND', + 'GEMINI_CLI_IDE_SERVER_STDIO_ARGS', +]; + +const AUTH_ENV_VAR_WHITELIST = [ + 'GEMINI_API_KEY', + 'GOOGLE_API_KEY', + 'GOOGLE_CLOUD_PROJECT', + 'GOOGLE_CLOUD_LOCATION', +]; + +/** + * Sanitizes an environment variable value to prevent shell injection. + * Restricts values to a safe character set: alphanumeric, -, _, ., / + */ +export function sanitizeEnvVar(value: string): string { + return value.replace(/[^a-zA-Z0-9\-_./]/g, ''); +} + +export function getSystemSettingsPath(): string { + if (process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH']) { + return process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH']; + } + if (platform() === 'darwin') { + return '/Library/Application Support/GeminiCli/settings.json'; + } else if (platform() === 'win32') { + return 'C:\\ProgramData\\gemini-cli\\settings.json'; + } else { + return '/etc/gemini-cli/settings.json'; + } +} + +export function getSystemDefaultsPath(): string { + if (process.env['GEMINI_CLI_SYSTEM_DEFAULTS_PATH']) { + return process.env['GEMINI_CLI_SYSTEM_DEFAULTS_PATH']; + } + return path.join( + path.dirname(getSystemSettingsPath()), + 'system-defaults.json', + ); +} + +export type { DnsResolutionOrder } from './settingsSchema.js'; + +export enum SettingScope { + User = 'User', + Workspace = 'Workspace', + System = 'System', + SystemDefaults = 'SystemDefaults', + // Note that this scope is not supported in the settings dialog at this time, + // it is only supported for extensions. + Session = 'Session', +} + +/** + * A type representing the settings scopes that are supported for LoadedSettings. + */ +export type LoadableSettingScope = + | SettingScope.User + | SettingScope.Workspace + | SettingScope.System + | SettingScope.SystemDefaults; + +/** + * The actual values of the loadable settings scopes. + */ +const _loadableSettingScopes = [ + SettingScope.User, + SettingScope.Workspace, + SettingScope.System, + SettingScope.SystemDefaults, +]; + +/** + * A type guard function that checks if `scope` is a loadable settings scope, + * and allows promotion to the `LoadableSettingsScope` type based on the result. + */ +export function isLoadableSettingScope( + scope: SettingScope, +): scope is LoadableSettingScope { + return _loadableSettingScopes.includes(scope); +} + +export interface CheckpointingSettings { + enabled?: boolean; +} + +export interface SummarizeToolOutputSettings { + tokenBudget?: number; +} + +export type LoadingPhrasesMode = 'tips' | 'witty' | 'all' | 'off'; + +export interface AccessibilitySettings { + /** @deprecated Use ui.loadingPhrases instead. */ + enableLoadingPhrases?: boolean; + screenReader?: boolean; +} + +export interface SessionRetentionSettings { + /** Enable automatic session cleanup */ + enabled?: boolean; + + /** Maximum age of sessions to keep (e.g., "30d", "7d", "24h", "1w") */ + maxAge?: string; + + /** Alternative: Maximum number of sessions to keep (most recent) */ + maxCount?: number; + + /** Minimum retention period (safety limit, defaults to "1d") */ + minRetention?: string; +} + +export interface SettingsError { + message: string; + path: string; + severity: 'error' | 'warning'; +} + +export interface SettingsFile { + settings: Settings; + originalSettings: Settings; + path: string; + rawJson?: string; + readOnly?: boolean; +} + +function setNestedProperty( + obj: Record, + path: string, + value: unknown, +) { + const keys = path.split('.'); + const lastKey = keys.pop(); + if (!lastKey) return; + + let current: Record = obj; + for (const key of keys) { + if (current[key] === undefined) { + current[key] = {}; + } + const next = current[key]; + if (typeof next === 'object' && next !== null) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + current = next as Record; + } else { + // This path is invalid, so we stop. + return; + } + } + current[lastKey] = value; +} + +export function getDefaultsFromSchema( + schema: SettingsSchema = getSettingsSchema(), +): Settings { + const defaults: Record = {}; + for (const key in schema) { + const definition = schema[key]; + if (definition.properties) { + defaults[key] = getDefaultsFromSchema(definition.properties); + } else if (definition.default !== undefined) { + defaults[key] = definition.default; + } + } + return defaults as Settings; +} + +export function mergeSettings( + system: Settings, + systemDefaults: Settings, + user: Settings, + workspace: Settings, + isTrusted: boolean, +): MergedSettings { + const safeWorkspace = isTrusted ? workspace : ({} as Settings); + const schemaDefaults = getDefaultsFromSchema(); + + // Settings are merged with the following precedence (last one wins for + // single values): + // 1. Schema Defaults (Built-in) + // 2. System Defaults + // 3. User Settings + // 4. Workspace Settings + // 5. System Settings (as overrides) + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return customDeepMerge( + getMergeStrategyForPath, + schemaDefaults, + systemDefaults, + user, + safeWorkspace, + system, + ) as MergedSettings; +} + +/** + * Creates a fully populated MergedSettings object for testing purposes. + * It merges the provided overrides with the default settings from the schema. + * + * @param overrides Partial settings to override the defaults. + * @returns A complete MergedSettings object. + */ +export function createTestMergedSettings( + overrides: Partial = {}, +): MergedSettings { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return customDeepMerge( + getMergeStrategyForPath, + getDefaultsFromSchema(), + overrides, + ) as MergedSettings; +} + +/** + * An immutable snapshot of settings state. + * Used with useSyncExternalStore for reactive updates. + */ +export interface LoadedSettingsSnapshot { + system: SettingsFile; + systemDefaults: SettingsFile; + user: SettingsFile; + workspace: SettingsFile; + isTrusted: boolean; + errors: SettingsError[]; + merged: MergedSettings; +} + +export class LoadedSettings { + constructor( + system: SettingsFile, + systemDefaults: SettingsFile, + user: SettingsFile, + workspace: SettingsFile, + isTrusted: boolean, + errors: SettingsError[] = [], + ) { + this.system = system; + this.systemDefaults = systemDefaults; + this.user = user; + this._workspaceFile = workspace; + this.isTrusted = isTrusted; + this.workspace = isTrusted + ? workspace + : this.createEmptyWorkspace(workspace); + this.errors = errors; + this._merged = this.computeMergedSettings(); + this._snapshot = this.computeSnapshot(); + } + + readonly system: SettingsFile; + readonly systemDefaults: SettingsFile; + readonly user: SettingsFile; + workspace: SettingsFile; + isTrusted: boolean; + readonly errors: SettingsError[]; + + private _workspaceFile: SettingsFile; + private _merged: MergedSettings; + private _snapshot: LoadedSettingsSnapshot; + private _remoteAdminSettings: Partial | undefined; + + get merged(): MergedSettings { + return this._merged; + } + + /** + * Returns a merged settings object as if the folder were trusted. + * This is useful for commands like 'mcp list' that want to show + * what's configured even if it's currently disabled for security reasons. + */ + getMergedSettingsAsIfTrusted(): MergedSettings { + return this.computeMergedSettings(true); + } + + setTrusted(isTrusted: boolean): void { + if (this.isTrusted === isTrusted) { + return; + } + this.isTrusted = isTrusted; + this.workspace = isTrusted + ? this._workspaceFile + : this.createEmptyWorkspace(this._workspaceFile); + this._merged = this.computeMergedSettings(); + coreEvents.emitSettingsChanged(); + } + + private createEmptyWorkspace(workspace: SettingsFile): SettingsFile { + return { + ...workspace, + settings: {}, + originalSettings: {}, + }; + } + + private computeMergedSettings(forceTrusted = false): MergedSettings { + const isTrusted = forceTrusted || this.isTrusted; + const workspace = forceTrusted ? this._workspaceFile : this.workspace; + + const merged = mergeSettings( + this.system.settings, + this.systemDefaults.settings, + this.user.settings, + workspace.settings, + isTrusted, + ); + + // Remote admin settings always take precedence and file-based admin settings + // are ignored. + const adminSettingSchema = getSettingsSchema().admin; + if (adminSettingSchema?.properties) { + const adminSchema = adminSettingSchema.properties; + const adminDefaults = getDefaultsFromSchema(adminSchema); + + // The final admin settings are the defaults overridden by remote settings. + // Any admin settings from files are ignored. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + merged.admin = customDeepMerge( + (path: string[]) => getMergeStrategyForPath(['admin', ...path]), + adminDefaults, + this._remoteAdminSettings?.admin ?? {}, + ) as MergedSettings['admin']; + } + return merged; + } + + private computeSnapshot(): LoadedSettingsSnapshot { + const cloneSettingsFile = (file: SettingsFile): SettingsFile => ({ + ...file, + settings: structuredClone(file.settings), + originalSettings: structuredClone(file.originalSettings), + }); + return { + system: cloneSettingsFile(this.system), + systemDefaults: cloneSettingsFile(this.systemDefaults), + user: cloneSettingsFile(this.user), + workspace: cloneSettingsFile(this.workspace), + isTrusted: this.isTrusted, + errors: [...this.errors], + merged: structuredClone(this._merged), + }; + } + + // Passing this along with getSnapshot to useSyncExternalStore allows for idiomatic reactivity on settings changes + // React will pass a listener fn into this subscribe fn + // that listener fn will perform an object identity check on the snapshot and trigger a React re render if the snapshot has changed + subscribe(listener: () => void): () => void { + coreEvents.on(CoreEvent.SettingsChanged, listener); + return () => coreEvents.off(CoreEvent.SettingsChanged, listener); + } + + getSnapshot(): LoadedSettingsSnapshot { + return this._snapshot; + } + + forScope(scope: LoadableSettingScope): SettingsFile { + switch (scope) { + case SettingScope.User: + return this.user; + case SettingScope.Workspace: + return this.workspace; + case SettingScope.System: + return this.system; + case SettingScope.SystemDefaults: + return this.systemDefaults; + default: + throw new Error(`Invalid scope: ${scope}`); + } + } + + private isPersistable(settingsFile: SettingsFile): boolean { + return !settingsFile.readOnly; + } + + setValue(scope: LoadableSettingScope, key: string, value: unknown): void { + const settingsFile = this.forScope(scope); + + // Clone value to prevent reference sharing + const valueToSet = + typeof value === 'object' && value !== null + ? structuredClone(value) + : value; + + setNestedProperty(settingsFile.settings, key, valueToSet); + + if (this.isPersistable(settingsFile)) { + // Use a fresh clone for originalSettings to ensure total independence + setNestedProperty( + settingsFile.originalSettings, + key, + structuredClone(valueToSet), + ); + saveSettings(settingsFile); + } + + this._merged = this.computeMergedSettings(); + this._snapshot = this.computeSnapshot(); + coreEvents.emitSettingsChanged(); + } + + setRemoteAdminSettings(remoteSettings: AdminControlsSettings): void { + const admin: Settings['admin'] = {}; + const { strictModeDisabled, mcpSetting, cliFeatureSetting } = + remoteSettings; + + if (Object.keys(remoteSettings).length === 0) { + this._remoteAdminSettings = { admin }; + this._merged = this.computeMergedSettings(); + return; + } + + admin.secureModeEnabled = !strictModeDisabled; + admin.mcp = { + enabled: mcpSetting?.mcpEnabled, + config: mcpSetting?.mcpConfig?.mcpServers, + requiredConfig: mcpSetting?.requiredMcpConfig, + }; + admin.extensions = { + enabled: cliFeatureSetting?.extensionsSetting?.extensionsEnabled, + }; + admin.skills = { + enabled: cliFeatureSetting?.unmanagedCapabilitiesEnabled, + }; + + this._remoteAdminSettings = { admin }; + this._merged = this.computeMergedSettings(); + } + + /** + * Returns a consolidated list of excluded MCP servers across all settings files. + */ + getConsolidatedExcludedMcpServers(): string[] { + const scopes = [ + this.system, + this.systemDefaults, + this.user, + this.workspace, + ]; + return scopes.flatMap((scope) => { + const excluded = scope?.settings?.mcp?.excluded; + return Array.isArray(excluded) ? excluded : []; + }); + } + + /** + * Returns a consolidated list of allowed MCP servers (via intersection of all defined lists). + */ + getConsolidatedAllowedMcpServers(): string[] | undefined { + const scopes = [ + this.system, + this.systemDefaults, + this.user, + this.workspace, + ]; + const definedAllowlists = scopes.flatMap((scope) => { + const allowed = scope?.settings?.mcp?.allowed; + return Array.isArray(allowed) ? [allowed] : []; + }); + + if (definedAllowlists.length === 0) { + return undefined; + } + + return definedAllowlists.reduce((acc, current) => { + const normalizedCurrent = new Set( + current.map((item) => item.toLowerCase().trim()), + ); + return acc.filter((item) => + normalizedCurrent.has(item.toLowerCase().trim()), + ); + }); + } +} + +function findEnvFile( + startDir: string, + isTrusted: boolean, + ignoreLocalEnv: boolean, +): string | null { + let currentDir = path.resolve(startDir); + while (true) { + // prefer gemini-specific .env under GEMINI_DIR + if (isTrusted) { + const geminiEnvPath = path.join(currentDir, GEMINI_DIR, '.env'); + if (fs.existsSync(geminiEnvPath)) { + return geminiEnvPath; + } + } + const envPath = path.join(currentDir, '.env'); + if (fs.existsSync(envPath)) { + if (!ignoreLocalEnv || currentDir === homedir()) { + return envPath; + } + } + const parentDir = path.dirname(currentDir); + if (parentDir === currentDir || !parentDir) { + // check .env under home as fallback, again preferring gemini-specific .env + if (isTrusted) { + const homeGeminiEnvPath = path.join(homedir(), GEMINI_DIR, '.env'); + if (fs.existsSync(homeGeminiEnvPath)) { + return homeGeminiEnvPath; + } + } + const homeEnvPath = path.join(homedir(), '.env'); + if (fs.existsSync(homeEnvPath)) { + return homeEnvPath; + } + return null; + } + currentDir = parentDir; + } +} + +// Internal env var used to preserve the user's original GOOGLE_CLOUD_PROJECT +// across process restarts in Cloud Shell. This survives relaunch because child +// processes inherit the parent's environment. +const USER_GCP_PROJECT = '_GEMINI_USER_GCP_PROJECT'; + +export function setUpCloudShellEnvironment( + envFilePath: string | null, + isTrusted: boolean, + isSandboxed: boolean, + selectedAuthType?: string, +): void { + // Special handling for GOOGLE_CLOUD_PROJECT in Cloud Shell: + // Because GOOGLE_CLOUD_PROJECT in Cloud Shell tracks the project + // set by the user using "gcloud config set project" we do not want to + // use its value. So, unless the user overrides GOOGLE_CLOUD_PROJECT in + // one of the .env files, we set the Cloud Shell-specific default here. + // + // However, if the user has explicitly selected Vertex AI auth, they intend + // to use their own GCP project, so we restore the original value and skip + // the Cloud Shell override to respect their .env settings. + + // Save the user's original value before overwriting, so it can be restored + // if the user later switches to Vertex AI (even after a process restart). + if (!process.env[USER_GCP_PROJECT]) { + const current = process.env['GOOGLE_CLOUD_PROJECT']; + if (current && current !== 'cloudshell-gca') { + process.env[USER_GCP_PROJECT] = current; + } + } + + let value: string | undefined = 'cloudshell-gca'; + + if (selectedAuthType === AuthType.USE_VERTEX_AI) { + value = process.env[USER_GCP_PROJECT]; + } + + if (envFilePath && fs.existsSync(envFilePath)) { + const envFileContent = fs.readFileSync(envFilePath); + const parsedEnv = dotenv.parse(envFileContent); + if (parsedEnv['GOOGLE_CLOUD_PROJECT']) { + // .env file takes precedence in Cloud Shell + value = parsedEnv['GOOGLE_CLOUD_PROJECT']; + if (!isTrusted && isSandboxed) { + value = sanitizeEnvVar(value); + } + } + } + + if (value !== undefined) { + process.env['GOOGLE_CLOUD_PROJECT'] = value; + } else if (process.env['GOOGLE_CLOUD_PROJECT'] === 'cloudshell-gca') { + delete process.env['GOOGLE_CLOUD_PROJECT']; + } +} + +export function loadEnvironment( + settings: Settings, + workspaceDir: string, + isWorkspaceTrustedFn = isWorkspaceTrusted, +): void { + const trustResult = isWorkspaceTrustedFn(settings, workspaceDir); + const isTrusted = trustResult.isTrusted ?? false; + + // Check settings OR check process.argv directly since this might be called + // before arguments are fully parsed. This is a best-effort sniffing approach + // that happens early in the CLI lifecycle. It is designed to detect the + // sandbox flag before the full command-line parser is initialized to ensure + // security constraints are applied when loading environment variables. + const args = process.argv.slice(2); + const doubleDashIndex = args.indexOf('--'); + const relevantArgs = + doubleDashIndex === -1 ? args : args.slice(0, doubleDashIndex); + + const isSandboxed = + !!settings.tools?.sandbox || + relevantArgs.includes('-s') || + relevantArgs.includes('--sandbox'); + + const shouldIgnoreEnv = + !!settings.advanced?.ignoreLocalEnv || + relevantArgs.includes('--ignore-env'); + + const envFilePath = findEnvFile(workspaceDir, isTrusted, shouldIgnoreEnv); + + // Cloud Shell environment variable handling + if (process.env['CLOUD_SHELL'] === 'true') { + const selectedAuthType = settings.security?.auth?.selectedType; + setUpCloudShellEnvironment( + envFilePath, + isTrusted, + isSandboxed, + selectedAuthType, + ); + } + + if (envFilePath) { + // Manually parse and load environment variables to handle exclusions correctly. + // This avoids modifying environment variables that were already set from the shell. + try { + const envFileContent = fs.readFileSync(envFilePath, 'utf-8'); + const parsedEnv = dotenv.parse(envFileContent); + + const excludedVars = + settings?.advanced?.excludedEnvVars || DEFAULT_EXCLUDED_ENV_VARS; + const isProjectEnvFile = !envFilePath.includes(GEMINI_DIR); + + for (const key in parsedEnv) { + if (Object.hasOwn(parsedEnv, key)) { + let value = parsedEnv[key]; + // If the workspace is untrusted, only allow whitelisted variables. + if (!isTrusted) { + if (!AUTH_ENV_VAR_WHITELIST.includes(key)) { + continue; + } + // Sanitize the value for untrusted sources + value = sanitizeEnvVar(value); + } + + // If it's a project .env file, skip loading excluded variables. + if (isProjectEnvFile && excludedVars.includes(key)) { + continue; + } + + // Load variable only if it's not already set in the environment. + if (!Object.hasOwn(process.env, key)) { + process.env[key] = value; + } + } + } + } catch { + // Errors are ignored to match the behavior of `dotenv.config({ quiet: true })`. + } + } +} + +// Cache to store the results of loadSettings to avoid redundant disk I/O. +const settingsCache = createCache({ + storage: 'map', + defaultTtl: 10000, // 10 seconds +}); + +/** + * Resets the settings cache. Used exclusively for test isolation. + * @internal + */ +export function resetSettingsCacheForTesting() { + settingsCache.clear(); +} + +export function isWorktreeEnabled(settings: LoadedSettings): boolean { + return settings.merged.experimental.worktrees; +} + +/** + * Loads settings from user and workspace directories. + * Project settings override user settings. + */ +export function loadSettings( + workspaceDir: string = process.cwd(), +): LoadedSettings { + const normalizedWorkspaceDir = path.resolve(workspaceDir); + return settingsCache.getOrCreate(normalizedWorkspaceDir, () => + _doLoadSettings(normalizedWorkspaceDir), + ); +} + +/** + * Internal implementation of the settings loading logic. + */ +function _doLoadSettings(workspaceDir: string): LoadedSettings { + let systemSettings: Settings = {}; + let systemDefaultSettings: Settings = {}; + let userSettings: Settings = {}; + let workspaceSettings: Settings = {}; + const settingsErrors: SettingsError[] = []; + const systemSettingsPath = getSystemSettingsPath(); + const systemDefaultsPath = getSystemDefaultsPath(); + + const storage = new Storage(workspaceDir); + const workspaceSettingsPath = storage.getWorkspaceSettingsPath(); + + const load = ( + filePath: string, + ): { settings: Settings; rawSettings: Settings; rawJson?: string } => { + try { + if (fs.existsSync(filePath)) { + const content = fs.readFileSync(filePath, 'utf-8'); + const rawSettings: unknown = JSON.parse(stripJsonComments(content)); + + if ( + typeof rawSettings !== 'object' || + rawSettings === null || + Array.isArray(rawSettings) + ) { + settingsErrors.push({ + message: 'Settings file is not a valid JSON object.', + path: filePath, + severity: 'error', + }); + return { settings: {}, rawSettings: {} }; + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const settingsObject = rawSettings as Record; + + // Expand environment variables + const expandedSettings = resolveEnvVarsInObject( + settingsObject as Settings, + ); + + // Validate settings structure with Zod after environment variable expansion + const validationResult = validateSettings(expandedSettings); + if (!validationResult.success && validationResult.error) { + const errorMessage = formatValidationError( + validationResult.error, + filePath, + ); + settingsErrors.push({ + message: errorMessage, + path: filePath, + severity: 'warning', + }); + return { + settings: expandedSettings, + rawSettings: settingsObject as Settings, + rawJson: content, + }; + } + + // Return the successfully cast and validated data + return { + // Since we've successfully validated expandedSettings against settingsZodSchema, + // it's safe to cast the resulting data to the Settings type. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + settings: (validationResult.data as Settings) ?? expandedSettings, + rawSettings: settingsObject as Settings, + rawJson: content, + }; + } + } catch (error: unknown) { + settingsErrors.push({ + message: getErrorMessage(error), + path: filePath, + severity: 'error', + }); + } + return { settings: {}, rawSettings: {} }; + }; + + const securityCache = createPathSecurityCache(); + + const loadSystemFile = ( + filePath: string, + fileLabel: string, + ): { settings: Settings; rawSettings: Settings; rawJson?: string } => { + if (!fs.existsSync(filePath)) { + return { settings: {}, rawSettings: {} }; + } + + const check = isFileAndDirectorySecureSync(filePath, securityCache); + if (!check.secure) { + settingsErrors.push({ + message: `Security Warning: Skipping ${fileLabel} file '${filePath}': ${check.reason}`, + path: filePath, + severity: 'warning', + }); + return { settings: {}, rawSettings: {} }; + } + + return load(filePath); + }; + + const systemResult = loadSystemFile(systemSettingsPath, 'system settings'); + const systemDefaultsResult = loadSystemFile( + systemDefaultsPath, + 'system defaults', + ); + const userResult = load(USER_SETTINGS_PATH); + + let workspaceResult: { + settings: Settings; + rawSettings: Settings; + rawJson?: string; + } = { + settings: {} as Settings, + rawSettings: {} as Settings, + rawJson: undefined, + }; + if (!storage.isWorkspaceHomeDir()) { + workspaceResult = load(workspaceSettingsPath); + } + + const systemOriginalSettings = structuredClone(systemResult.rawSettings); + const systemDefaultsOriginalSettings = structuredClone( + systemDefaultsResult.rawSettings, + ); + const userOriginalSettings = structuredClone(userResult.rawSettings); + const workspaceOriginalSettings = structuredClone( + workspaceResult.rawSettings, + ); + + // Environment variables for runtime use are already resolved and validated in load() + systemSettings = systemResult.settings; + systemDefaultSettings = systemDefaultsResult.settings; + userSettings = userResult.settings; + workspaceSettings = workspaceResult.settings; + + // Support legacy theme names + if (userSettings.ui?.theme === 'VS') { + userSettings.ui.theme = DefaultLight.name; + } else if (userSettings.ui?.theme === 'VS2015') { + userSettings.ui.theme = DefaultDark.name; + } + if (workspaceSettings.ui?.theme === 'VS') { + workspaceSettings.ui.theme = DefaultLight.name; + } else if (workspaceSettings.ui?.theme === 'VS2015') { + workspaceSettings.ui.theme = DefaultDark.name; + } + + // For the initial trust check, we can only use user and system settings. + const initialTrustCheckSettings = customDeepMerge( + getMergeStrategyForPath, + getDefaultsFromSchema(), + systemDefaultSettings, + userSettings, + systemSettings, + ); + const isTrusted = + isWorkspaceTrusted(initialTrustCheckSettings as Settings, workspaceDir) + ?.isTrusted ?? false; + + // Create a temporary merged settings object to pass to loadEnvironment. + const tempMergedSettings = mergeSettings( + systemSettings, + systemDefaultSettings, + userSettings, + workspaceSettings, + isTrusted, + ); + + // loadEnvironment depends on settings so we have to create a temp version of + // the settings to avoid a cycle + loadEnvironment(tempMergedSettings, workspaceDir); + + // Check for any fatal errors before proceeding + const fatalErrors = settingsErrors.filter((e) => e.severity === 'error'); + if (fatalErrors.length > 0) { + const errorMessages = fatalErrors.map( + (error) => `Error in ${error.path}: ${error.message}`, + ); + throw new FatalConfigError( + `${errorMessages.join('\n')}\nPlease fix the configuration file(s) and try again.`, + ); + } + + const loadedSettings = new LoadedSettings( + { + path: systemSettingsPath, + settings: systemSettings, + originalSettings: systemOriginalSettings, + rawJson: systemResult.rawJson, + readOnly: true, + }, + { + path: systemDefaultsPath, + settings: systemDefaultSettings, + originalSettings: systemDefaultsOriginalSettings, + rawJson: systemDefaultsResult.rawJson, + readOnly: true, + }, + { + path: USER_SETTINGS_PATH, + settings: userSettings, + originalSettings: userOriginalSettings, + rawJson: userResult.rawJson, + readOnly: false, + }, + { + path: storage.isWorkspaceHomeDir() ? '' : workspaceSettingsPath, + settings: workspaceSettings, + originalSettings: workspaceOriginalSettings, + rawJson: workspaceResult.rawJson, + readOnly: storage.isWorkspaceHomeDir(), + }, + isTrusted, + settingsErrors, + ); + + // Automatically migrate deprecated settings when loading. + migrateDeprecatedSettings(loadedSettings); + + return loadedSettings; +} + +/** + * Migrates deprecated settings to their new counterparts. + * + * Deprecated settings are removed from settings files by default. + * + * @returns true if any changes were made and need to be saved. + */ +export function migrateDeprecatedSettings( + loadedSettings: LoadedSettings, + removeDeprecated = true, +): boolean { + let anyModified = false; + const systemWarnings: Map = new Map(); + + /** + * Helper to migrate a boolean setting and track it if it's deprecated. + */ + const migrateBoolean = ( + settings: Record, + oldKey: string, + newKey: string, + prefix: string, + foundDeprecated?: string[], + ): boolean => { + let modified = false; + const oldValue = settings[oldKey]; + const newValue = settings[newKey]; + + if (typeof oldValue === 'boolean') { + if (foundDeprecated) { + foundDeprecated.push(prefix ? `${prefix}.${oldKey}` : oldKey); + } + if (typeof newValue === 'boolean') { + // Both exist, trust the new one + if (removeDeprecated) { + delete settings[oldKey]; + modified = true; + } + } else { + // Only old exists, migrate to new (inverted) + settings[newKey] = !oldValue; + if (removeDeprecated) { + delete settings[oldKey]; + } + modified = true; + } + } + return modified; + }; + + const processScope = (scope: LoadableSettingScope) => { + const settingsFile = loadedSettings.forScope(scope); + const settings = settingsFile.settings; + const foundDeprecated: string[] = []; + + // Migrate general settings + const generalSettings = settings.general as + | Record + | undefined; + if (generalSettings) { + const newGeneral = { ...generalSettings }; + let modified = false; + + modified = + migrateBoolean( + newGeneral, + 'disableAutoUpdate', + 'enableAutoUpdate', + 'general', + foundDeprecated, + ) || modified; + modified = + migrateBoolean( + newGeneral, + 'disableUpdateNag', + 'enableAutoUpdateNotification', + 'general', + foundDeprecated, + ) || modified; + + if (modified) { + loadedSettings.setValue(scope, 'general', newGeneral); + if (!settingsFile.readOnly) { + anyModified = true; + } + } + } + + // Migrate ui settings + const uiSettings = settings.ui as Record | undefined; + if (uiSettings) { + const newUi = { ...uiSettings }; + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const accessibilitySettings = newUi['accessibility'] as + | Record + | undefined; + + if (accessibilitySettings) { + const newAccessibility = { ...accessibilitySettings }; + if ( + migrateBoolean( + newAccessibility, + 'disableLoadingPhrases', + 'enableLoadingPhrases', + 'ui.accessibility', + foundDeprecated, + ) + ) { + newUi['accessibility'] = newAccessibility; + loadedSettings.setValue(scope, 'ui', newUi); + if (!settingsFile.readOnly) { + anyModified = true; + } + } + + // Migrate enableLoadingPhrases: false → loadingPhrases: 'off' + const enableLP = newAccessibility['enableLoadingPhrases']; + if ( + typeof enableLP === 'boolean' && + newUi['loadingPhrases'] === undefined + ) { + if (!enableLP) { + newUi['loadingPhrases'] = 'off'; + loadedSettings.setValue(scope, 'ui', newUi); + if (!settingsFile.readOnly) { + anyModified = true; + } + } + foundDeprecated.push('ui.accessibility.enableLoadingPhrases'); + } + } + } + + // Migrate context settings + const contextSettings = settings.context as + | Record + | undefined; + if (contextSettings) { + const newContext = { ...contextSettings }; + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const fileFilteringSettings = newContext['fileFiltering'] as + | Record + | undefined; + + if (fileFilteringSettings) { + const newFileFiltering = { ...fileFilteringSettings }; + if ( + migrateBoolean( + newFileFiltering, + 'disableFuzzySearch', + 'enableFuzzySearch', + 'context.fileFiltering', + foundDeprecated, + ) + ) { + newContext['fileFiltering'] = newFileFiltering; + loadedSettings.setValue(scope, 'context', newContext); + if (!settingsFile.readOnly) { + anyModified = true; + } + } + } + } + + // Migrate tools settings + const toolsSettings = settings.tools as Record | undefined; + if (toolsSettings) { + if (toolsSettings['approvalMode'] !== undefined) { + foundDeprecated.push('tools.approvalMode'); + + const generalSettings = + (settings.general as Record | undefined) || {}; + const newGeneral = { ...generalSettings }; + + // Only set defaultApprovalMode if it's not already set + if (newGeneral['defaultApprovalMode'] === undefined) { + newGeneral['defaultApprovalMode'] = toolsSettings['approvalMode']; + loadedSettings.setValue(scope, 'general', newGeneral); + if (!settingsFile.readOnly) { + anyModified = true; + } + } + + if (removeDeprecated) { + const newTools = { ...toolsSettings }; + delete newTools['approvalMode']; + loadedSettings.setValue(scope, 'tools', newTools); + if (!settingsFile.readOnly) { + anyModified = true; + } + } + } + } + + // Migrate experimental agent settings + const experimentalModified = migrateExperimentalSettings( + settings, + loadedSettings, + scope, + removeDeprecated, + foundDeprecated, + ); + + if (experimentalModified) { + if (!settingsFile.readOnly) { + anyModified = true; + } + } + + if (settingsFile.readOnly && foundDeprecated.length > 0) { + systemWarnings.set(scope, foundDeprecated); + } + }; + + processScope(SettingScope.User); + processScope(SettingScope.Workspace); + processScope(SettingScope.System); + processScope(SettingScope.SystemDefaults); + + if (systemWarnings.size > 0) { + for (const [scope, flags] of systemWarnings) { + const scopeName = + scope === SettingScope.SystemDefaults + ? 'system default' + : scope.toLowerCase(); + coreEvents.emitFeedback( + 'warning', + `The ${scopeName} configuration contains deprecated settings: [${flags.join(', ')}]. These could not be migrated automatically as system settings are read-only. Please update the system configuration manually.`, + ); + } + } + + return anyModified; +} + +export function saveSettings(settingsFile: SettingsFile): void { + // Clear the entire cache on any save. + settingsCache.clear(); + + try { + // Ensure the directory exists + const dirPath = path.dirname(settingsFile.path); + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + } + + const settingsToSave = settingsFile.originalSettings; + + // Use the format-preserving update function + updateSettingsFilePreservingFormat( + settingsFile.path, + settingsToSave as Record, + ); + } catch (error) { + const detailedErrorMessage = getFsErrorMessage(error); + coreEvents.emitFeedback( + 'error', + `Failed to save settings: ${detailedErrorMessage}`, + error, + ); + } +} + +export function saveModelChange( + loadedSettings: LoadedSettings, + model: string, +): void { + try { + loadedSettings.setValue(SettingScope.User, 'model.name', model); + } catch (error) { + const detailedErrorMessage = getFsErrorMessage(error); + coreEvents.emitFeedback( + 'error', + `Failed to save preferred model: ${detailedErrorMessage}`, + error, + ); + } +} + +function migrateExperimentalSettings( + settings: Settings, + loadedSettings: LoadedSettings, + scope: LoadableSettingScope, + removeDeprecated: boolean, + foundDeprecated?: string[], +): boolean { + const experimentalSettings = settings.experimental as + | Record + | undefined; + + if (experimentalSettings) { + const agentsSettings = { + ...(settings.agents as Record | undefined), + }; + const agentsOverrides = { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + ...((agentsSettings['overrides'] as Record) || {}), + }; + let modified = false; + + const migrateExperimental = >( + oldKey: string, + migrateFn: (oldValue: T) => void, + ) => { + const old = experimentalSettings[oldKey]; + if (old !== undefined) { + foundDeprecated?.push(`experimental.${oldKey}`); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + migrateFn(old as T); + modified = true; + } + }; + + // Migrate codebaseInvestigatorSettings -> agents.overrides.codebase_investigator + migrateExperimental('codebaseInvestigatorSettings', (old) => { + const override = { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + ...(agentsOverrides['codebase_investigator'] as + | Record + | undefined), + }; + + if (old['enabled'] !== undefined) override['enabled'] = old['enabled']; + + const runConfig = { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + ...(override['runConfig'] as Record | undefined), + }; + if (old['maxNumTurns'] !== undefined) + runConfig['maxTurns'] = old['maxNumTurns']; + if (old['maxTimeMinutes'] !== undefined) + runConfig['maxTimeMinutes'] = old['maxTimeMinutes']; + if (Object.keys(runConfig).length > 0) override['runConfig'] = runConfig; + + if (old['model'] !== undefined || old['thinkingBudget'] !== undefined) { + const modelConfig = { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + ...(override['modelConfig'] as Record | undefined), + }; + if (old['model'] !== undefined) modelConfig['model'] = old['model']; + if (old['thinkingBudget'] !== undefined) { + const generateContentConfig = { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + ...(modelConfig['generateContentConfig'] as + | Record + | undefined), + }; + const thinkingConfig = { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + ...(generateContentConfig['thinkingConfig'] as + | Record + | undefined), + }; + thinkingConfig['thinkingBudget'] = old['thinkingBudget']; + generateContentConfig['thinkingConfig'] = thinkingConfig; + modelConfig['generateContentConfig'] = generateContentConfig; + } + override['modelConfig'] = modelConfig; + } + + agentsOverrides['codebase_investigator'] = override; + }); + + // Migrate cliHelpAgentSettings -> agents.overrides.cli_help + migrateExperimental('cliHelpAgentSettings', (old) => { + const override = { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + ...(agentsOverrides['cli_help'] as Record | undefined), + }; + if (old['enabled'] !== undefined) override['enabled'] = old['enabled']; + agentsOverrides['cli_help'] = override; + }); + + // Migrate experimental.plan -> general.plan.enabled + migrateExperimental('plan', (planValue) => { + const generalSettings = + (settings.general as Record | undefined) || {}; + const newGeneral = { ...generalSettings }; + const planSettings = + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + (newGeneral['plan'] as Record | undefined) || {}; + const newPlan = { ...planSettings }; + + if (newPlan['enabled'] === undefined) { + newPlan['enabled'] = planValue; + newGeneral['plan'] = newPlan; + loadedSettings.setValue(scope, 'general', newGeneral); + modified = true; + } + }); + + if (modified) { + agentsSettings['overrides'] = agentsOverrides; + loadedSettings.setValue(scope, 'agents', agentsSettings); + + if (removeDeprecated) { + const newExperimental = { ...experimentalSettings }; + delete newExperimental['codebaseInvestigatorSettings']; + delete newExperimental['cliHelpAgentSettings']; + delete newExperimental['plan']; + loadedSettings.setValue(scope, 'experimental', newExperimental); + } + return true; + } + } + return false; +} diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..914ef21a64e80aed87730c538aae361806689e9b --- /dev/null +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -0,0 +1,658 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { + getSettingsSchema, + SETTINGS_SCHEMA_DEFINITIONS, + type SettingCollectionDefinition, + type SettingDefinition, + type Settings, + type SettingsSchema, +} from './settingsSchema.js'; + +describe('SettingsSchema', () => { + describe('getSettingsSchema', () => { + it('should contain all expected top-level settings', () => { + const expectedSettings: Array = [ + 'mcpServers', + 'general', + 'ui', + 'ide', + 'privacy', + 'telemetry', + 'model', + 'context', + 'tools', + 'mcp', + 'security', + 'advanced', + ]; + + expectedSettings.forEach((setting) => { + expect(getSettingsSchema()[setting]).toBeDefined(); + }); + }); + + it('should have correct structure for each setting', () => { + Object.entries(getSettingsSchema()).forEach(([_key, definition]) => { + expect(definition).toHaveProperty('type'); + expect(definition).toHaveProperty('label'); + expect(definition).toHaveProperty('category'); + expect(definition).toHaveProperty('requiresRestart'); + expect(definition).toHaveProperty('default'); + expect(typeof definition.type).toBe('string'); + expect(typeof definition.label).toBe('string'); + expect(typeof definition.category).toBe('string'); + expect(typeof definition.requiresRestart).toBe('boolean'); + }); + }); + + it('should have correct nested setting structure', () => { + const nestedSettings: Array = [ + 'general', + 'ui', + 'ide', + 'privacy', + 'model', + 'context', + 'tools', + 'mcp', + 'security', + 'advanced', + ]; + + nestedSettings.forEach((setting) => { + const definition = getSettingsSchema()[setting] as SettingDefinition; + expect(definition.type).toBe('object'); + expect(definition.properties).toBeDefined(); + expect(typeof definition.properties).toBe('object'); + }); + }); + + it('should have accessibility nested properties', () => { + expect( + getSettingsSchema().ui?.properties?.accessibility?.properties, + ).toBeDefined(); + expect( + getSettingsSchema().ui?.properties?.accessibility.properties + ?.enableLoadingPhrases.type, + ).toBe('boolean'); + }); + + it('should have loadingPhrases enum property', () => { + const definition = getSettingsSchema().ui?.properties?.loadingPhrases; + expect(definition).toBeDefined(); + expect(definition?.type).toBe('enum'); + expect(definition?.default).toBe('off'); + expect(definition?.options?.map((o) => o.value)).toEqual([ + 'tips', + 'witty', + 'all', + 'off', + ]); + }); + + it('should have errorVerbosity enum property', () => { + const definition = getSettingsSchema().ui?.properties?.errorVerbosity; + expect(definition).toBeDefined(); + expect(definition?.type).toBe('enum'); + expect(definition?.default).toBe('low'); + expect(definition?.options?.map((o) => o.value)).toEqual(['low', 'full']); + }); + + it('should have checkpointing nested properties', () => { + expect( + getSettingsSchema().general?.properties?.checkpointing.properties + ?.enabled, + ).toBeDefined(); + expect( + getSettingsSchema().general?.properties?.checkpointing.properties + ?.enabled.type, + ).toBe('boolean'); + }); + + it('should have plan nested properties', () => { + expect( + getSettingsSchema().general?.properties?.plan?.properties?.directory, + ).toBeDefined(); + expect( + getSettingsSchema().general?.properties?.plan?.properties?.directory + .type, + ).toBe('string'); + }); + + it('should have fileFiltering nested properties', () => { + expect( + getSettingsSchema().context.properties.fileFiltering.properties + ?.respectGitIgnore, + ).toBeDefined(); + expect( + getSettingsSchema().context.properties.fileFiltering.properties + ?.respectGeminiIgnore, + ).toBeDefined(); + expect( + getSettingsSchema().context.properties.fileFiltering.properties + ?.enableRecursiveFileSearch, + ).toBeDefined(); + expect( + getSettingsSchema().context.properties.fileFiltering.properties + ?.enableFileWatcher, + ).toBeDefined(); + expect( + getSettingsSchema().context.properties.fileFiltering.properties + ?.customIgnoreFilePaths, + ).toBeDefined(); + expect( + getSettingsSchema().context.properties.fileFiltering.properties + ?.customIgnoreFilePaths.type, + ).toBe('array'); + }); + + it('should have unique categories', () => { + const categories = new Set(); + + // Collect categories from top-level settings + Object.values(getSettingsSchema()).forEach((definition) => { + categories.add(definition.category); + // Also collect from nested properties + const defWithProps = definition as typeof definition & { + properties?: Record; + }; + if (defWithProps.properties) { + Object.values(defWithProps.properties).forEach( + (nestedDef: unknown) => { + const nestedDefTyped = nestedDef as { category?: string }; + if (nestedDefTyped.category) { + categories.add(nestedDefTyped.category); + } + }, + ); + } + }); + + expect(categories.size).toBeGreaterThan(0); + expect(categories).toContain('General'); + expect(categories).toContain('UI'); + expect(categories).toContain('Advanced'); + }); + + it('should have consistent default values for boolean settings', () => { + const checkBooleanDefaults = (schema: SettingsSchema) => { + Object.entries(schema).forEach(([, definition]) => { + const def = definition; + if (def.type === 'boolean') { + // Boolean settings can have boolean or undefined defaults (for optional settings) + expect(['boolean', 'undefined']).toContain(typeof def.default); + } + if (def.properties) { + checkBooleanDefaults(def.properties); + } + }); + }; + + checkBooleanDefaults(getSettingsSchema() as SettingsSchema); + }); + + it('should have showInDialog property configured', () => { + // Check that user-facing settings are marked for dialog display + expect( + getSettingsSchema().ui.properties.showMemoryUsage.showInDialog, + ).toBe(true); + expect( + getSettingsSchema().ui.properties.footer.properties + .hideContextPercentage.showInDialog, + ).toBe(true); + expect(getSettingsSchema().general.properties.vimMode.showInDialog).toBe( + true, + ); + expect(getSettingsSchema().ide.properties.enabled.showInDialog).toBe( + true, + ); + expect( + getSettingsSchema().general.properties.enableAutoUpdate.showInDialog, + ).toBe(true); + expect( + getSettingsSchema().ui.properties.hideWindowTitle.showInDialog, + ).toBe(true); + expect(getSettingsSchema().ui.properties.hideTips.showInDialog).toBe( + true, + ); + expect( + getSettingsSchema().ui.properties.showShortcutsHint.showInDialog, + ).toBe(true); + expect(getSettingsSchema().ui.properties.hideBanner.showInDialog).toBe( + true, + ); + expect( + getSettingsSchema().privacy.properties.usageStatisticsEnabled + .showInDialog, + ).toBe(false); + + // Check that advanced settings are hidden from dialog + expect(getSettingsSchema().security.properties.auth.showInDialog).toBe( + false, + ); + expect(getSettingsSchema().tools.properties.core.showInDialog).toBe( + false, + ); + expect(getSettingsSchema().mcpServers.showInDialog).toBe(false); + expect(getSettingsSchema().telemetry.showInDialog).toBe(false); + + // Check that some settings are appropriately hidden + expect(getSettingsSchema().ui.properties.theme.showInDialog).toBe(false); // Changed to false + expect(getSettingsSchema().ui.properties.customThemes.showInDialog).toBe( + false, + ); // Managed via theme editor + expect( + getSettingsSchema().general.properties.checkpointing.showInDialog, + ).toBe(false); // Experimental feature + expect(getSettingsSchema().ui.properties.accessibility.showInDialog).toBe( + false, + ); // Changed to false + expect( + getSettingsSchema().context.properties.fileFiltering.showInDialog, + ).toBe(false); // Changed to false + expect( + getSettingsSchema().general.properties.preferredEditor.showInDialog, + ).toBe(false); // Changed to false + expect( + getSettingsSchema().advanced.properties.autoConfigureMemory + .showInDialog, + ).toBe(true); + }); + + it('should infer Settings type correctly', () => { + // This test ensures that the Settings type is properly inferred from the schema + const settings: Settings = { + ui: { + theme: 'dark', + }, + context: { + includeDirectories: ['/path/to/dir'], + loadMemoryFromIncludeDirectories: true, + }, + }; + + // TypeScript should not complain about these properties + expect(settings.ui?.theme).toBe('dark'); + expect(settings.context?.includeDirectories).toEqual(['/path/to/dir']); + expect(settings.context?.loadMemoryFromIncludeDirectories).toBe(true); + }); + + it('should have includeDirectories setting in schema', () => { + expect( + getSettingsSchema().context?.properties.includeDirectories, + ).toBeDefined(); + expect( + getSettingsSchema().context?.properties.includeDirectories.type, + ).toBe('array'); + expect( + getSettingsSchema().context?.properties.includeDirectories.category, + ).toBe('Context'); + expect( + getSettingsSchema().context?.properties.includeDirectories.default, + ).toEqual([]); + }); + + it('should have loadMemoryFromIncludeDirectories setting in schema', () => { + expect( + getSettingsSchema().context?.properties + .loadMemoryFromIncludeDirectories, + ).toBeDefined(); + expect( + getSettingsSchema().context?.properties.loadMemoryFromIncludeDirectories + .type, + ).toBe('boolean'); + expect( + getSettingsSchema().context?.properties.loadMemoryFromIncludeDirectories + .category, + ).toBe('Context'); + expect( + getSettingsSchema().context?.properties.loadMemoryFromIncludeDirectories + .default, + ).toBe(false); + }); + + it('should have Vertex AI routing settings in schema', () => { + const vertexAi = + getSettingsSchema().billing.properties.vertexAi.properties; + + expect(vertexAi.requestType).toBeDefined(); + expect(vertexAi.requestType.type).toBe('enum'); + expect( + vertexAi.requestType.options?.map((option) => option.value), + ).toEqual(['dedicated', 'shared']); + expect(vertexAi.sharedRequestType).toBeDefined(); + expect(vertexAi.sharedRequestType.type).toBe('enum'); + expect( + vertexAi.sharedRequestType.options?.map((option) => option.value), + ).toEqual(['priority', 'flex']); + }); + + it('should have folderTrustFeature setting in schema', () => { + expect( + getSettingsSchema().security.properties.folderTrust.properties.enabled, + ).toBeDefined(); + expect( + getSettingsSchema().security.properties.folderTrust.properties.enabled + .type, + ).toBe('boolean'); + expect( + getSettingsSchema().security.properties.folderTrust.properties.enabled + .category, + ).toBe('Security'); + expect( + getSettingsSchema().security.properties.folderTrust.properties.enabled + .default, + ).toBe(true); + expect( + getSettingsSchema().security.properties.folderTrust.properties.enabled + .showInDialog, + ).toBe(true); + }); + + it('should have debugKeystrokeLogging setting in schema', () => { + expect( + getSettingsSchema().general.properties.debugKeystrokeLogging, + ).toBeDefined(); + expect( + getSettingsSchema().general.properties.debugKeystrokeLogging.type, + ).toBe('boolean'); + expect( + getSettingsSchema().general.properties.debugKeystrokeLogging.category, + ).toBe('General'); + expect( + getSettingsSchema().general.properties.debugKeystrokeLogging.default, + ).toBe(false); + expect( + getSettingsSchema().general.properties.debugKeystrokeLogging + .requiresRestart, + ).toBe(false); + expect( + getSettingsSchema().general.properties.debugKeystrokeLogging + .showInDialog, + ).toBe(true); + expect( + getSettingsSchema().general.properties.debugKeystrokeLogging + .description, + ).toBe('Enable debug logging of keystrokes to the console.'); + }); + + it('should have showShortcutsHint setting in schema', () => { + expect(getSettingsSchema().ui.properties.showShortcutsHint).toBeDefined(); + expect(getSettingsSchema().ui.properties.showShortcutsHint.type).toBe( + 'boolean', + ); + expect(getSettingsSchema().ui.properties.showShortcutsHint.category).toBe( + 'UI', + ); + expect(getSettingsSchema().ui.properties.showShortcutsHint.default).toBe( + true, + ); + expect( + getSettingsSchema().ui.properties.showShortcutsHint.requiresRestart, + ).toBe(false); + expect( + getSettingsSchema().ui.properties.showShortcutsHint.showInDialog, + ).toBe(true); + expect( + getSettingsSchema().ui.properties.showShortcutsHint.description, + ).toBe('Show the "? for shortcuts" hint above the input.'); + }); + + it('should have enableNotifications setting in schema', () => { + const setting = + getSettingsSchema().general.properties.enableNotifications; + expect(setting).toBeDefined(); + expect(setting.type).toBe('boolean'); + expect(setting.category).toBe('General'); + expect(setting.default).toBe(false); + expect(setting.requiresRestart).toBe(false); + expect(setting.showInDialog).toBe(true); + }); + + it('should have enableAgents setting in schema', () => { + const setting = getSettingsSchema().experimental.properties.enableAgents; + expect(setting).toBeDefined(); + expect(setting.type).toBe('boolean'); + expect(setting.category).toBe('Experimental'); + expect(setting.default).toBe(true); + expect(setting.requiresRestart).toBe(true); + expect(setting.showInDialog).toBe(false); + expect(setting.description).toBe('Enable local and remote subagents.'); + }); + + it('should have skills setting enabled by default', () => { + const setting = getSettingsSchema().skills.properties.enabled; + expect(setting).toBeDefined(); + expect(setting.type).toBe('boolean'); + expect(setting.category).toBe('Advanced'); + expect(setting.default).toBe(true); + expect(setting.requiresRestart).toBe(true); + expect(setting.showInDialog).toBe(true); + expect(setting.description).toBe('Enable Agent Skills.'); + }); + + it('should have plan setting in schema', () => { + const setting = + getSettingsSchema().general.properties.plan.properties.enabled; + expect(setting).toBeDefined(); + expect(setting.type).toBe('boolean'); + expect(setting.category).toBe('General'); + expect(setting.default).toBe(true); + expect(setting.requiresRestart).toBe(true); + expect(setting.showInDialog).toBe(true); + expect(setting.description).toBe( + 'Enable Plan Mode for read-only safety during planning.', + ); + }); + + it('should have hooksConfig.notifications setting in schema', () => { + const setting = getSettingsSchema().hooksConfig?.properties.notifications; + expect(setting).toBeDefined(); + expect(setting.type).toBe('boolean'); + expect(setting.category).toBe('Advanced'); + expect(setting.default).toBe(true); + expect(setting.showInDialog).toBe(true); + }); + + it('should have name and description in hook definitions', () => { + const hookDef = SETTINGS_SCHEMA_DEFINITIONS['HookDefinitionArray']; + expect(hookDef).toBeDefined(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const hookItemProperties = (hookDef as any).items.properties.hooks.items + .properties; + expect(hookItemProperties.name).toBeDefined(); + expect(hookItemProperties.name.type).toBe('string'); + expect(hookItemProperties.description).toBeDefined(); + expect(hookItemProperties.description.type).toBe('string'); + }); + + it('should have gemmaModelRouter setting in schema', () => { + const gemmaModelRouter = + getSettingsSchema().experimental.properties.gemmaModelRouter; + expect(gemmaModelRouter).toBeDefined(); + expect(gemmaModelRouter.type).toBe('object'); + expect(gemmaModelRouter.category).toBe('Experimental'); + expect(gemmaModelRouter.default).toEqual({}); + expect(gemmaModelRouter.requiresRestart).toBe(true); + expect(gemmaModelRouter.showInDialog).toBe(false); + expect(gemmaModelRouter.description).toBe( + 'Enable Gemma model router (experimental).', + ); + + const enabled = gemmaModelRouter.properties.enabled; + expect(enabled).toBeDefined(); + expect(enabled.type).toBe('boolean'); + expect(enabled.category).toBe('Experimental'); + expect(enabled.default).toBe(false); + expect(enabled.requiresRestart).toBe(true); + expect(enabled.showInDialog).toBe(true); + expect(enabled.description).toBe( + 'Enable the Gemma Model Router (experimental). Requires a local endpoint serving Gemma via the Gemini API using LiteRT-LM shim.', + ); + + const autoStartServer = gemmaModelRouter.properties.autoStartServer; + expect(autoStartServer).toBeDefined(); + expect(autoStartServer.type).toBe('boolean'); + expect(autoStartServer.category).toBe('Experimental'); + expect(autoStartServer.default).toBe(false); + expect(autoStartServer.requiresRestart).toBe(true); + expect(autoStartServer.showInDialog).toBe(true); + expect(autoStartServer.description).toBe( + 'Automatically start the LiteRT-LM server when Gemini CLI starts and the Gemma router is enabled.', + ); + + const binaryPath = gemmaModelRouter.properties.binaryPath; + expect(binaryPath).toBeDefined(); + expect(binaryPath.type).toBe('string'); + expect(binaryPath.category).toBe('Experimental'); + expect(binaryPath.default).toBe(''); + expect(binaryPath.requiresRestart).toBe(true); + expect(binaryPath.showInDialog).toBe(false); + expect(binaryPath.description).toBe( + 'Custom path to the LiteRT-LM binary. Leave empty to use the default location (~/.gemini/bin/litert/).', + ); + + const classifier = gemmaModelRouter.properties.classifier; + expect(classifier).toBeDefined(); + expect(classifier.type).toBe('object'); + expect(classifier.category).toBe('Experimental'); + expect(classifier.default).toEqual({}); + expect(classifier.requiresRestart).toBe(true); + expect(classifier.showInDialog).toBe(false); + expect(classifier.description).toBe('Classifier configuration.'); + + const host = classifier.properties.host; + expect(host).toBeDefined(); + expect(host.type).toBe('string'); + expect(host.category).toBe('Experimental'); + expect(host.default).toBe('http://localhost:9379'); + expect(host.requiresRestart).toBe(true); + expect(host.showInDialog).toBe(false); + expect(host.description).toBe('The host of the classifier.'); + + const model = classifier.properties.model; + expect(model).toBeDefined(); + expect(model.type).toBe('string'); + expect(model.category).toBe('Experimental'); + expect(model.default).toBe('gemma3-1b-gpu-custom'); + expect(model.requiresRestart).toBe(true); + expect(model.showInDialog).toBe(false); + expect(model.description).toBe( + 'The model to use for the classifier. Only tested on `gemma3-1b-gpu-custom`.', + ); + }); + + it('should have adk setting in schema', () => { + const adk = getSettingsSchema().experimental.properties.adk; + expect(adk).toBeDefined(); + expect(adk.type).toBe('object'); + expect(adk.category).toBe('Experimental'); + expect(adk.default).toEqual({}); + expect(adk.requiresRestart).toBe(true); + expect(adk.showInDialog).toBe(false); + expect(adk.description).toBe( + 'Settings for the Agent Development Kit (ADK).', + ); + + const agentSessionNoninteractiveEnabled = + adk.properties.agentSessionNoninteractiveEnabled; + expect(agentSessionNoninteractiveEnabled).toBeDefined(); + expect(agentSessionNoninteractiveEnabled.type).toBe('boolean'); + expect(agentSessionNoninteractiveEnabled.category).toBe('Experimental'); + expect(agentSessionNoninteractiveEnabled.default).toBe(false); + expect(agentSessionNoninteractiveEnabled.requiresRestart).toBe(true); + expect(agentSessionNoninteractiveEnabled.showInDialog).toBe(false); + expect(agentSessionNoninteractiveEnabled.description).toBe( + 'Enable non-interactive agent sessions.', + ); + + const agentSessionSubagentEnabled = + adk.properties.agentSessionSubagentEnabled; + expect(agentSessionSubagentEnabled).toBeDefined(); + expect(agentSessionSubagentEnabled.type).toBe('boolean'); + expect(agentSessionSubagentEnabled.category).toBe('Experimental'); + expect(agentSessionSubagentEnabled.default).toBe(false); + expect(agentSessionSubagentEnabled.requiresRestart).toBe(true); + expect(agentSessionSubagentEnabled.showInDialog).toBe(false); + expect(agentSessionSubagentEnabled.description).toBe( + 'Route subagent invocations through the AgentSession protocol instead of legacy executors.', + ); + }); + }); + + it('has JSON schema definitions for every referenced ref', () => { + const schema = getSettingsSchema(); + const referenced = new Set(); + + const visitDefinition = (definition: SettingDefinition) => { + if (definition.ref) { + referenced.add(definition.ref); + expect(SETTINGS_SCHEMA_DEFINITIONS).toHaveProperty(definition.ref); + } + if (definition.properties) { + Object.values(definition.properties).forEach(visitDefinition); + } + if (definition.items) { + visitCollection(definition.items); + } + if (definition.additionalProperties) { + visitCollection(definition.additionalProperties); + } + }; + + const visitCollection = (collection: SettingCollectionDefinition) => { + if (collection.ref) { + referenced.add(collection.ref); + expect(SETTINGS_SCHEMA_DEFINITIONS).toHaveProperty(collection.ref); + return; + } + if (collection.properties) { + Object.values(collection.properties).forEach(visitDefinition); + } + if (collection.type === 'array' && collection.properties) { + Object.values(collection.properties).forEach(visitDefinition); + } + }; + + const visitJsonSchema = (jsonSchema: Record) => { + const ref = jsonSchema['ref']; + if (typeof ref === 'string') { + referenced.add(ref); + } + const properties = jsonSchema['properties']; + if ( + properties && + typeof properties === 'object' && + !Array.isArray(properties) + ) { + Object.values(properties as Record).forEach((prop) => + visitJsonSchema(prop as Record), + ); + } + const items = jsonSchema['items']; + if (items && typeof items === 'object' && !Array.isArray(items)) { + visitJsonSchema(items as Record); + } + }; + + Object.values(schema).forEach(visitDefinition); + + // Also visit all definitions to find nested references + Object.values(SETTINGS_SCHEMA_DEFINITIONS).forEach(visitJsonSchema); + + // Ensure definitions map doesn't accumulate stale entries. + Object.keys(SETTINGS_SCHEMA_DEFINITIONS).forEach((key) => { + if (!referenced.has(key)) { + throw new Error( + `Definition "${key}" is exported but never referenced in the schema`, + ); + } + }); + }); +}); diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts new file mode 100644 index 0000000000000000000000000000000000000000..3a87cba3ab6c85a8f481d2626f1c37ff2c219dbf --- /dev/null +++ b/packages/cli/src/config/settingsSchema.ts @@ -0,0 +1,3617 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// -------------------------------------------------------------------------- +// IMPORTANT: After adding or updating settings, run `npm run docs:settings` +// to regenerate the settings reference in `docs/get-started/configuration.md`. +// -------------------------------------------------------------------------- + +import { + DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, + DEFAULT_MODEL_CONFIGS, + EDITOR_OPTIONS, + AuthProviderType, + type MCPServerConfig, + type RequiredMcpServerConfig, + type BugCommandSettings, + type TelemetrySettings, + type AuthType, + type AgentOverride, + type CustomTheme, + type SandboxConfig, + type VertexAiRoutingConfig, +} from '@google/gemini-cli-core'; +import type { SessionRetentionSettings } from './settings.js'; +import { DEFAULT_MIN_RETENTION } from '../utils/sessionCleanup.js'; + +export type SettingsType = + | 'boolean' + | 'string' + | 'number' + | 'array' + | 'object' + | 'enum'; + +export type SettingsValue = + | boolean + | string + | number + | string[] + | object + | undefined; + +/** + * Setting datatypes that "toggle" through a fixed list of options + * (e.g. an enum or true/false) rather than allowing for free form input + * (like a number or string). + */ +export const TOGGLE_TYPES: ReadonlySet = new Set([ + 'boolean', + 'enum', +]); + +export interface SettingEnumOption { + value: string | number; + label: string; +} + +function oneLine(strings: TemplateStringsArray, ...values: unknown[]): string { + let result = ''; + for (let i = 0; i < strings.length; i++) { + result += strings[i]; + if (i < values.length) { + result += String(values[i]); + } + } + return result.replace(/\s+/g, ' ').trim(); +} + +export interface SettingCollectionDefinition { + type: SettingsType; + description?: string; + properties?: SettingsSchema; + /** Enum type options */ + options?: readonly SettingEnumOption[]; + /** + * Optional reference identifier for generators that emit a `$ref`. + * For example, a JSON schema generator can use this to point to a shared definition. + */ + ref?: string; + /** + * Optional merge strategy for dynamically added properties. + * Used when this collection definition is referenced via additionalProperties. + */ + mergeStrategy?: MergeStrategy; +} + +export enum MergeStrategy { + // Replace the old value with the new value. This is the default. + REPLACE = 'replace', + // Concatenate arrays. + CONCAT = 'concat', + // Merge arrays, ensuring unique values. + UNION = 'union', + // Shallow merge objects. + SHALLOW_MERGE = 'shallow_merge', +} + +export interface SettingDefinition { + type: SettingsType; + label: string; + category: string; + requiresRestart: boolean; + default: SettingsValue; + description?: string; + parentKey?: string; + childKey?: string; + key?: string; + properties?: SettingsSchema; + showInDialog?: boolean; + ignoreInDocs?: boolean; + mergeStrategy?: MergeStrategy; + /** Enum type options */ + options?: readonly SettingEnumOption[]; + /** + * For collection types (e.g. arrays), describes the shape of each item. + */ + items?: SettingCollectionDefinition; + /** + * For map-like objects without explicit `properties`, describes the shape of the values. + */ + additionalProperties?: SettingCollectionDefinition; + /** + * Optional unit to display after the value (e.g. '%'). + */ + unit?: string; + /** + * Optional reference identifier for generators that emit a `$ref`. + */ + ref?: string; +} + +export interface SettingsSchema { + [key: string]: SettingDefinition; +} + +export type MemoryImportFormat = 'tree' | 'flat'; +export type DnsResolutionOrder = 'ipv4first' | 'verbatim'; + +const pathArraySetting = (label: string, description: string) => ({ + type: 'array' as const, + label, + category: 'Advanced' as const, + requiresRestart: true as const, + default: [] as string[], + description, + showInDialog: false as const, + items: { type: 'string' as const }, + mergeStrategy: MergeStrategy.UNION, +}); + +/** + * The canonical schema for all settings. + * The structure of this object defines the structure of the `Settings` type. + * `as const` is crucial for TypeScript to infer the most specific types possible. + */ +const SETTINGS_SCHEMA = { + // Maintained for compatibility/criticality + mcpServers: { + type: 'object', + label: 'MCP Servers', + category: 'Advanced', + requiresRestart: true, + default: {} as Record, + description: 'Configuration for MCP servers.', + showInDialog: false, + mergeStrategy: MergeStrategy.SHALLOW_MERGE, + additionalProperties: { + type: 'object', + ref: 'MCPServerConfig', + }, + }, + + policyPaths: pathArraySetting( + 'Policy Paths', + 'Additional policy files or directories to load.', + ), + + adminPolicyPaths: pathArraySetting( + 'Admin Policy Paths', + 'Additional admin policy files or directories to load.', + ), + + general: { + type: 'object', + label: 'General', + category: 'General', + requiresRestart: false, + default: {}, + description: 'General application settings.', + showInDialog: false, + properties: { + preferredEditor: { + type: 'enum', + label: 'Preferred Editor', + category: 'General', + requiresRestart: false, + default: undefined as string | undefined, + description: oneLine` + The preferred editor to open files in. Must be one of the built-in + supported identifiers. Use /editor in the CLI to pick interactively, + or leave unset to use $VISUAL/$EDITOR. + `, + showInDialog: false, + options: EDITOR_OPTIONS, + }, + openEditorInNewWindow: { + type: 'boolean', + label: 'Open Editor in New Window', + category: 'General', + requiresRestart: false, + default: false, + description: + 'Open VS Code-family editors in a new window when editing files.', + showInDialog: false, + }, + vimMode: { + type: 'boolean', + label: 'Vim Mode', + category: 'General', + requiresRestart: false, + default: false, + description: 'Enable Vim keybindings', + showInDialog: true, + }, + defaultApprovalMode: { + type: 'enum', + label: 'Default Approval Mode', + category: 'General', + requiresRestart: false, + default: 'default', + description: oneLine` + The default approval mode for tool execution. + 'default' prompts for approval, 'auto_edit' auto-approves edit tools, + and 'plan' is read-only mode. YOLO mode (auto-approve all actions) can + only be enabled via command line (--yolo or --approval-mode=yolo). + `, + showInDialog: true, + options: [ + { value: 'default', label: 'Default' }, + { value: 'auto_edit', label: 'Auto Edit' }, + { value: 'plan', label: 'Plan' }, + ], + }, + devtools: { + type: 'boolean', + label: 'DevTools', + category: 'General', + requiresRestart: false, + default: false, + description: 'Enable DevTools inspector on launch.', + showInDialog: false, + }, + enableAutoUpdate: { + type: 'boolean', + label: 'Enable Auto Update', + category: 'General', + requiresRestart: false, + default: true, + description: 'Enable automatic updates.', + showInDialog: true, + }, + enableAutoUpdateNotification: { + type: 'boolean', + label: 'Enable Auto Update Notification', + category: 'General', + requiresRestart: false, + default: true, + description: 'Enable update notification prompts.', + showInDialog: false, + }, + enableNotifications: { + type: 'boolean', + label: 'Enable Terminal Notifications', + category: 'General', + requiresRestart: false, + default: false, + description: + 'Enable terminal run-event notifications for action-required prompts and session completion.', + showInDialog: true, + }, + notificationMethod: { + type: 'enum', + label: 'Terminal Notification Method', + category: 'General', + requiresRestart: false, + default: 'auto', + description: 'How to send terminal notifications.', + showInDialog: true, + options: [ + { value: 'auto', label: 'Auto' }, + { value: 'osc9', label: 'OSC 9' }, + { value: 'osc777', label: 'OSC 777' }, + { value: 'bell', label: 'Bell' }, + ], + }, + checkpointing: { + type: 'object', + label: 'Checkpointing', + category: 'General', + requiresRestart: true, + default: {}, + description: 'Session checkpointing settings.', + showInDialog: false, + properties: { + enabled: { + type: 'boolean', + label: 'Enable Checkpointing', + category: 'General', + requiresRestart: true, + default: false, + description: 'Enable session checkpointing for recovery', + showInDialog: false, + }, + }, + }, + plan: { + type: 'object', + label: 'Plan', + category: 'General', + requiresRestart: true, + default: {}, + description: 'Planning features configuration.', + showInDialog: false, + properties: { + enabled: { + type: 'boolean', + label: 'Enable Plan Mode', + category: 'General', + requiresRestart: true, + default: true, + description: + 'Enable Plan Mode for read-only safety during planning.', + showInDialog: true, + }, + directory: { + type: 'string', + label: 'Plan Directory', + category: 'General', + requiresRestart: true, + default: undefined as string | undefined, + description: + 'The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory. A custom directory requires a policy to allow write access in Plan Mode.', + showInDialog: true, + }, + modelRouting: { + type: 'boolean', + label: 'Plan Model Routing', + category: 'General', + requiresRestart: false, + default: true, + description: + 'Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pro for the planning phase and Flash for the implementation phase.', + showInDialog: true, + }, + }, + }, + retryFetchErrors: { + type: 'boolean', + label: 'Retry Fetch Errors', + category: 'General', + requiresRestart: false, + default: true, + description: + 'Retry on "exception TypeError: fetch failed sending request" errors.', + showInDialog: true, + }, + maxAttempts: { + type: 'number', + label: 'Max Chat Model Attempts', + category: 'General', + requiresRestart: false, + default: 10, + description: + 'Maximum number of attempts for requests to the main chat model. Cannot exceed 10.', + showInDialog: true, + }, + debugKeystrokeLogging: { + type: 'boolean', + label: 'Debug Keystroke Logging', + category: 'General', + requiresRestart: false, + default: false, + description: 'Enable debug logging of keystrokes to the console.', + showInDialog: true, + }, + sessionRetention: { + type: 'object', + label: 'Session Retention', + category: 'General', + requiresRestart: false, + default: undefined as SessionRetentionSettings | undefined, + showInDialog: false, + properties: { + enabled: { + type: 'boolean', + label: 'Enable Session Cleanup', + category: 'General', + requiresRestart: false, + default: true as boolean, + description: 'Enable automatic session cleanup', + showInDialog: true, + }, + maxAge: { + type: 'string', + label: 'Keep chat history', + category: 'General', + requiresRestart: false, + default: '30d' as string, + description: + 'Automatically delete chats older than this time period (e.g., "30d", "7d", "24h", "1w")', + showInDialog: true, + }, + maxCount: { + type: 'number', + label: 'Max Session Count', + category: 'General', + requiresRestart: false, + default: undefined as number | undefined, + description: + 'Alternative: Maximum number of sessions to keep (most recent)', + showInDialog: false, + }, + minRetention: { + type: 'string', + label: 'Min Retention Period', + category: 'General', + requiresRestart: false, + default: DEFAULT_MIN_RETENTION, + description: `Minimum retention period (safety limit, defaults to "${DEFAULT_MIN_RETENTION}")`, + showInDialog: false, + }, + }, + description: 'Settings for automatic session cleanup.', + }, + topicUpdateNarration: { + type: 'boolean', + label: 'Topic & Update Narration', + category: 'General', + requiresRestart: false, + default: true, + description: + 'Enable the Topic & Update communication model for reduced chattiness and structured progress reporting.', + showInDialog: true, + }, + logRagSnippets: { + type: 'boolean', + label: 'Log RAG Snippets', + category: 'General', + requiresRestart: false, + default: false, + description: + 'Log full Code Customization (RAG) retrieved snippets to a local file for debugging.', + showInDialog: true, + }, + }, + }, + output: { + type: 'object', + label: 'Output', + category: 'General', + requiresRestart: false, + default: {}, + description: 'Settings for the CLI output.', + showInDialog: false, + properties: { + format: { + type: 'enum', + label: 'Output Format', + category: 'General', + requiresRestart: false, + default: 'text', + description: 'The format of the CLI output. Can be `text` or `json`.', + showInDialog: true, + options: [ + { value: 'text', label: 'Text' }, + { value: 'json', label: 'JSON' }, + ], + }, + }, + }, + + ui: { + type: 'object', + label: 'UI', + category: 'UI', + requiresRestart: false, + default: {}, + description: 'User interface settings.', + showInDialog: false, + properties: { + debugRainbow: { + type: 'boolean', + label: 'Debug Rainbow', + category: 'UI', + requiresRestart: true, + default: false, + description: + 'Enable debug rainbow rendering. Only useful for debugging rendering bugs and performance issues.', + showInDialog: false, + }, + theme: { + type: 'string', + label: 'Theme', + category: 'UI', + requiresRestart: false, + default: undefined as string | undefined, + description: + 'The color theme for the UI. See the CLI themes guide for available options.', + showInDialog: false, + }, + autoThemeSwitching: { + type: 'boolean', + label: 'Auto Theme Switching', + category: 'UI', + requiresRestart: false, + default: true, + description: + 'Automatically switch between default light and dark themes based on terminal background color.', + showInDialog: true, + }, + terminalBackgroundPollingInterval: { + type: 'number', + label: 'Terminal Background Polling Interval', + category: 'UI', + requiresRestart: false, + default: 60, + description: + 'Interval in seconds to poll the terminal background color.', + showInDialog: true, + }, + customThemes: { + type: 'object', + label: 'Custom Themes', + category: 'UI', + requiresRestart: false, + default: {} as Record, + description: 'Custom theme definitions.', + showInDialog: false, + additionalProperties: { + type: 'object', + ref: 'CustomTheme', + }, + }, + hideWindowTitle: { + type: 'boolean', + label: 'Hide Window Title', + category: 'UI', + requiresRestart: true, + default: false, + description: 'Hide the window title bar', + showInDialog: true, + }, + inlineThinkingMode: { + type: 'enum', + label: 'Inline Thinking', + category: 'UI', + requiresRestart: false, + default: 'off', + description: 'Display model thinking inline: off or full.', + showInDialog: true, + options: [ + { value: 'off', label: 'Off' }, + { value: 'full', label: 'Full' }, + ], + }, + showStatusInTitle: { + type: 'boolean', + label: 'Show Thoughts in Title', + category: 'UI', + requiresRestart: false, + default: false, + description: + 'Show Gemini CLI model thoughts in the terminal window title during the working phase', + showInDialog: true, + }, + dynamicWindowTitle: { + type: 'boolean', + label: 'Dynamic Window Title', + category: 'UI', + requiresRestart: false, + default: true, + description: + 'Update the terminal window title with current status icons (Ready: ◇, Action Required: ✋, Working: ✦)', + showInDialog: true, + }, + showHomeDirectoryWarning: { + type: 'boolean', + label: 'Show Home Directory Warning', + category: 'UI', + requiresRestart: true, + default: true, + description: + 'Show a warning when running Gemini CLI in the home directory.', + showInDialog: true, + }, + showCompatibilityWarnings: { + type: 'boolean', + label: 'Show Compatibility Warnings', + category: 'UI', + requiresRestart: true, + default: true, + description: 'Show warnings about terminal or OS compatibility issues.', + showInDialog: true, + }, + hideTips: { + type: 'boolean', + label: 'Hide Tips', + category: 'UI', + requiresRestart: false, + default: false, + description: 'Hide helpful tips in the UI', + showInDialog: true, + }, + escapePastedAtSymbols: { + type: 'boolean', + label: 'Escape Pasted @ Symbols', + category: 'UI', + requiresRestart: false, + default: false, + description: + 'When enabled, @ symbols in pasted text are escaped to prevent unintended @path expansion.', + showInDialog: true, + }, + showShortcutsHint: { + type: 'boolean', + label: 'Show Shortcuts Hint', + category: 'UI', + requiresRestart: false, + default: true, + description: 'Show the "? for shortcuts" hint above the input.', + showInDialog: true, + }, + compactToolOutput: { + type: 'boolean', + label: 'Compact Tool Output', + category: 'UI', + requiresRestart: false, + default: true, + description: + 'Display tool outputs (like directory listings and file reads) in a compact, structured format.', + showInDialog: true, + }, + hideBanner: { + type: 'boolean', + label: 'Hide Banner', + category: 'UI', + requiresRestart: false, + default: false, + description: 'Hide the application banner', + showInDialog: true, + }, + hideContextSummary: { + type: 'boolean', + label: 'Hide Context Summary', + category: 'UI', + requiresRestart: false, + default: false, + description: + 'Hide the context summary (GEMINI.md, MCP servers) above the input.', + showInDialog: true, + }, + footer: { + type: 'object', + label: 'Footer', + category: 'UI', + requiresRestart: false, + default: {}, + description: 'Settings for the footer.', + showInDialog: false, + properties: { + items: { + type: 'array', + label: 'Footer Items', + category: 'UI', + requiresRestart: false, + default: undefined as string[] | undefined, + description: + 'List of item IDs to display in the footer. Rendered in order', + showInDialog: false, + items: { type: 'string' }, + }, + showLabels: { + type: 'boolean', + label: 'Show Footer Labels', + category: 'UI', + requiresRestart: false, + default: true, + description: + 'Display a second line above the footer items with descriptive headers (e.g., /model).', + showInDialog: false, + }, + hideCWD: { + type: 'boolean', + label: 'Hide CWD', + category: 'UI', + requiresRestart: false, + default: false, + description: 'Hide the current working directory in the footer.', + showInDialog: true, + }, + hideSandboxStatus: { + type: 'boolean', + label: 'Hide Sandbox Status', + category: 'UI', + requiresRestart: false, + default: false, + description: 'Hide the sandbox status indicator in the footer.', + showInDialog: true, + }, + hideModelInfo: { + type: 'boolean', + label: 'Hide Model Info', + category: 'UI', + requiresRestart: false, + default: false, + description: 'Hide the model name and context usage in the footer.', + showInDialog: true, + }, + hideContextPercentage: { + type: 'boolean', + label: 'Hide Context Window Percentage', + category: 'UI', + requiresRestart: false, + default: true, + description: 'Hides the context window usage percentage.', + showInDialog: true, + }, + }, + }, + hideFooter: { + type: 'boolean', + label: 'Hide Footer', + category: 'UI', + requiresRestart: false, + default: false, + description: 'Hide the footer from the UI', + showInDialog: true, + }, + collapseDrawerDuringApproval: { + type: 'boolean', + label: 'Collapse Drawer During Approval', + category: 'UI', + requiresRestart: false, + default: true, + description: + 'Whether to collapse the UI drawer when a tool is awaiting confirmation.', + showInDialog: false, + }, + showMemoryUsage: { + type: 'boolean', + label: 'Show Memory Usage', + category: 'UI', + requiresRestart: false, + default: false, + description: 'Display memory usage information in the UI', + showInDialog: true, + }, + showLineNumbers: { + type: 'boolean', + label: 'Show Line Numbers', + category: 'UI', + requiresRestart: false, + default: true, + description: 'Show line numbers in the chat.', + showInDialog: true, + }, + showCitations: { + type: 'boolean', + label: 'Show Citations', + category: 'UI', + requiresRestart: false, + default: false, + description: 'Show citations for generated text in the chat.', + showInDialog: true, + }, + showModelInfoInChat: { + type: 'boolean', + label: 'Show Model Info In Chat', + category: 'UI', + requiresRestart: false, + default: false, + description: 'Show the model name in the chat for each model turn.', + showInDialog: true, + }, + showUserIdentity: { + type: 'boolean', + label: 'Show User Identity', + category: 'UI', + requiresRestart: false, + default: true, + description: + "Show the signed-in user's identity (e.g. email) in the UI.", + showInDialog: true, + }, + useAlternateBuffer: { + type: 'boolean', + label: 'Use Alternate Screen Buffer', + category: 'UI', + requiresRestart: true, + default: false, + description: + 'Use an alternate screen buffer for the UI, preserving shell history.', + showInDialog: true, + }, + renderProcess: { + type: 'boolean', + label: 'Render Process', + category: 'UI', + requiresRestart: true, + default: true, + description: 'Enable Ink render process for the UI.', + showInDialog: true, + }, + terminalBuffer: { + type: 'boolean', + label: 'Terminal Buffer', + category: 'UI', + requiresRestart: true, + default: false, + description: 'Use the new terminal buffer architecture for rendering.', + showInDialog: true, + }, + useBackgroundColor: { + type: 'boolean', + label: 'Use Background Color', + category: 'UI', + requiresRestart: false, + default: true, + description: 'Whether to use background colors in the UI.', + showInDialog: true, + }, + incrementalRendering: { + type: 'boolean', + label: 'Incremental Rendering', + category: 'UI', + requiresRestart: true, + default: true, + description: + 'Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled.', + showInDialog: true, + }, + showSpinner: { + type: 'boolean', + label: 'Show Spinner', + category: 'UI', + requiresRestart: false, + default: true, + description: 'Show the spinner during operations.', + showInDialog: true, + }, + loadingPhrases: { + type: 'enum', + label: 'Loading Phrases', + category: 'UI', + requiresRestart: false, + default: 'off', + description: + 'What to show while the model is working: tips, witty comments, all, or off.', + showInDialog: true, + options: [ + { value: 'tips', label: 'Tips' }, + { value: 'witty', label: 'Witty' }, + { value: 'all', label: 'All' }, + { value: 'off', label: 'Off' }, + ], + }, + errorVerbosity: { + type: 'enum', + label: 'Error Verbosity', + category: 'UI', + requiresRestart: false, + default: 'low', + description: + 'Controls whether recoverable errors are hidden (low) or fully shown (full).', + showInDialog: true, + options: [ + { value: 'low', label: 'Low' }, + { value: 'full', label: 'Full' }, + ], + }, + customWittyPhrases: { + type: 'array', + label: 'Custom Witty Phrases', + category: 'UI', + requiresRestart: false, + default: [] as string[], + description: oneLine` + Custom witty phrases to display during loading. + When provided, the CLI cycles through these instead of the defaults. + `, + showInDialog: false, + items: { type: 'string' }, + }, + accessibility: { + type: 'object', + label: 'Accessibility', + category: 'UI', + requiresRestart: true, + default: {}, + description: 'Accessibility settings.', + showInDialog: false, + properties: { + enableLoadingPhrases: { + type: 'boolean', + label: 'Enable Loading Phrases', + category: 'UI', + requiresRestart: true, + default: true, + description: + '@deprecated Use ui.loadingPhrases instead. Enable loading phrases during operations.', + showInDialog: false, + }, + screenReader: { + type: 'boolean', + label: 'Screen Reader Mode', + category: 'UI', + requiresRestart: true, + default: false, + description: + 'Render output in plain-text to be more screen reader accessible', + showInDialog: true, + }, + }, + }, + }, + }, + + ide: { + type: 'object', + label: 'IDE', + category: 'IDE', + requiresRestart: true, + default: {}, + description: 'IDE integration settings.', + showInDialog: false, + properties: { + enabled: { + type: 'boolean', + label: 'IDE Mode', + category: 'IDE', + requiresRestart: true, + default: false, + description: 'Enable IDE integration mode.', + showInDialog: true, + }, + hasSeenNudge: { + type: 'boolean', + label: 'Has Seen IDE Integration Nudge', + category: 'IDE', + requiresRestart: false, + default: false, + description: 'Whether the user has seen the IDE integration nudge.', + showInDialog: false, + }, + }, + }, + + privacy: { + type: 'object', + label: 'Privacy', + category: 'Privacy', + requiresRestart: true, + default: {}, + description: 'Privacy-related settings.', + showInDialog: false, + properties: { + usageStatisticsEnabled: { + type: 'boolean', + label: 'Enable Usage Statistics', + category: 'Privacy', + requiresRestart: true, + default: true, + description: 'Enable collection of usage statistics', + showInDialog: false, + }, + }, + }, + + telemetry: { + type: 'object', + label: 'Telemetry', + category: 'Advanced', + requiresRestart: true, + default: undefined as TelemetrySettings | undefined, + description: 'Telemetry configuration.', + showInDialog: false, + ref: 'TelemetrySettings', + }, + + billing: { + type: 'object', + label: 'Billing', + category: 'Advanced', + requiresRestart: false, + default: {}, + description: 'Billing and AI credits settings.', + showInDialog: false, + properties: { + overageStrategy: { + type: 'enum', + label: 'Overage Strategy', + category: 'Advanced', + requiresRestart: false, + default: 'ask', + description: oneLine` + How to handle quota exhaustion when AI credits are available. + 'ask' prompts each time, 'always' automatically uses credits, + 'never' disables credit usage. + `, + showInDialog: true, + options: [ + { value: 'ask', label: 'Ask each time' }, + { value: 'always', label: 'Always use credits' }, + { value: 'never', label: 'Never use credits' }, + ], + }, + vertexAi: { + type: 'object', + label: 'Vertex AI', + category: 'Advanced', + requiresRestart: true, + default: undefined as VertexAiRoutingConfig | undefined, + description: 'Vertex AI request routing settings.', + showInDialog: false, + properties: { + requestType: { + type: 'enum', + label: 'Vertex AI Request Type', + category: 'Advanced', + requiresRestart: true, + default: undefined as VertexAiRoutingConfig['requestType'], + description: + 'Sets the X-Vertex-AI-LLM-Request-Type header for Vertex AI requests.', + showInDialog: false, + options: [ + { value: 'dedicated', label: 'Dedicated' }, + { value: 'shared', label: 'Shared' }, + ], + }, + sharedRequestType: { + type: 'enum', + label: 'Vertex AI Shared Request Type', + category: 'Advanced', + requiresRestart: true, + default: undefined as VertexAiRoutingConfig['sharedRequestType'], + description: + 'Sets the X-Vertex-AI-LLM-Shared-Request-Type header for Vertex AI requests.', + showInDialog: false, + options: [ + { value: 'priority', label: 'Priority' }, + { value: 'flex', label: 'Flex' }, + ], + }, + }, + }, + }, + }, + + model: { + type: 'object', + label: 'Model', + category: 'Model', + requiresRestart: false, + default: {}, + description: 'Settings related to the generative model.', + showInDialog: false, + properties: { + name: { + type: 'string', + label: 'Model', + category: 'Model', + requiresRestart: false, + default: undefined as string | undefined, + description: 'The Gemini model to use for conversations.', + showInDialog: true, + }, + maxSessionTurns: { + type: 'number', + label: 'Max Session Turns', + category: 'Model', + requiresRestart: false, + default: -1, + description: + 'Maximum number of user/model/tool turns to keep in a session. -1 means unlimited.', + showInDialog: true, + }, + summarizeToolOutput: { + type: 'object', + label: 'Summarize Tool Output', + category: 'Model', + requiresRestart: false, + default: undefined as + | Record + | undefined, + description: oneLine` + Enables or disables summarization of tool output. + Configure per-tool token budgets (for example {"run_shell_command": {"tokenBudget": 2000}}). + Currently only the run_shell_command tool supports summarization. + `, + showInDialog: false, + additionalProperties: { + type: 'object', + description: + 'Per-tool summarization settings with an optional tokenBudget.', + ref: 'SummarizeToolOutputSettings', + }, + }, + compressionThreshold: { + type: 'number', + label: 'Context Compression Threshold', + category: 'Model', + requiresRestart: true, + default: 0.5 as number, + description: + 'The fraction of context usage at which to trigger context compression (e.g. 0.2, 0.3).', + showInDialog: true, + unit: '%', + }, + disableLoopDetection: { + type: 'boolean', + label: 'Disable Loop Detection', + category: 'Model', + requiresRestart: true, + default: false, + description: + 'Disable automatic detection and prevention of infinite loops.', + showInDialog: true, + }, + skipNextSpeakerCheck: { + type: 'boolean', + label: 'Skip Next Speaker Check', + category: 'Model', + requiresRestart: false, + default: true, + description: 'Skip the next speaker check.', + showInDialog: true, + }, + }, + }, + + modelConfigs: { + type: 'object', + label: 'Model Configs', + category: 'Model', + requiresRestart: false, + default: DEFAULT_MODEL_CONFIGS, + description: 'Model configurations.', + showInDialog: false, + properties: { + aliases: { + type: 'object', + label: 'Model Config Aliases', + category: 'Model', + requiresRestart: false, + default: DEFAULT_MODEL_CONFIGS.aliases, + description: + 'Named presets for model configs. Can be used in place of a model name and can inherit from other aliases using an `extends` property.', + showInDialog: false, + }, + customAliases: { + type: 'object', + label: 'Custom Model Config Aliases', + category: 'Model', + requiresRestart: false, + default: {}, + description: + 'Custom named presets for model configs. These are merged with (and override) the built-in aliases.', + showInDialog: false, + }, + customOverrides: { + type: 'array', + label: 'Custom Model Config Overrides', + category: 'Model', + requiresRestart: false, + default: [], + description: + 'Custom model config overrides. These are merged with (and added to) the built-in overrides.', + showInDialog: false, + }, + overrides: { + type: 'array', + label: 'Model Config Overrides', + category: 'Model', + requiresRestart: false, + default: [], + description: + 'Apply specific configuration overrides based on matches, with a primary key of model (or alias). The most specific match will be used.', + showInDialog: false, + }, + modelDefinitions: { + type: 'object', + label: 'Model Definitions', + category: 'Model', + requiresRestart: true, + default: DEFAULT_MODEL_CONFIGS.modelDefinitions, + description: + 'Registry of model metadata, including tier, family, and features.', + showInDialog: false, + additionalProperties: { + type: 'object', + ref: 'ModelDefinition', + }, + }, + modelIdResolutions: { + type: 'object', + label: 'Model ID Resolutions', + category: 'Model', + requiresRestart: true, + default: DEFAULT_MODEL_CONFIGS.modelIdResolutions, + description: + 'Rules for resolving requested model names to concrete model IDs based on context.', + showInDialog: false, + additionalProperties: { + type: 'object', + ref: 'ModelResolution', + }, + }, + classifierIdResolutions: { + type: 'object', + label: 'Classifier ID Resolutions', + category: 'Model', + requiresRestart: true, + default: DEFAULT_MODEL_CONFIGS.classifierIdResolutions, + description: + 'Rules for resolving classifier tiers (flash, pro) to concrete model IDs.', + showInDialog: false, + additionalProperties: { + type: 'object', + ref: 'ModelResolution', + }, + }, + modelChains: { + type: 'object', + label: 'Model Chains', + category: 'Model', + requiresRestart: true, + default: DEFAULT_MODEL_CONFIGS.modelChains, + description: + 'Availability policy chains defining fallback behavior for models.', + showInDialog: false, + additionalProperties: { + type: 'array', + ref: 'ModelPolicyChain', + }, + }, + }, + }, + + agents: { + type: 'object', + label: 'Agents', + category: 'Advanced', + requiresRestart: true, + default: {}, + description: 'Settings for subagents.', + showInDialog: false, + properties: { + overrides: { + type: 'object', + label: 'Agent Overrides', + category: 'Advanced', + requiresRestart: true, + default: {} as Record, + description: + 'Override settings for specific agents, e.g. to disable the agent, set a custom model config, or run config.', + showInDialog: false, + additionalProperties: { + type: 'object', + ref: 'AgentOverride', + }, + }, + browser: { + type: 'object', + label: 'Browser Agent', + category: 'Advanced', + requiresRestart: true, + default: {}, + description: 'Settings specific to the browser agent.', + showInDialog: false, + properties: { + sessionMode: { + type: 'enum', + label: 'Browser Session Mode', + category: 'Advanced', + requiresRestart: true, + default: 'persistent', + description: + "Session mode: 'persistent', 'isolated', or 'existing'.", + showInDialog: false, + options: [ + { value: 'persistent', label: 'Persistent' }, + { value: 'isolated', label: 'Isolated' }, + { value: 'existing', label: 'Existing' }, + ], + }, + headless: { + type: 'boolean', + label: 'Browser Headless', + category: 'Advanced', + requiresRestart: true, + default: false, + description: 'Run browser in headless mode.', + showInDialog: false, + }, + profilePath: { + type: 'string', + label: 'Browser Profile Path', + category: 'Advanced', + requiresRestart: true, + default: undefined as string | undefined, + description: + 'Path to browser profile directory for session persistence.', + showInDialog: false, + }, + visualModel: { + type: 'string', + label: 'Browser Visual Model', + category: 'Advanced', + requiresRestart: true, + default: undefined as string | undefined, + description: + "Model for the visual agent's analyze_screenshot tool. When set, enables the tool.", + showInDialog: false, + }, + allowedDomains: { + type: 'array', + label: 'Allowed Domains', + category: 'Advanced', + requiresRestart: true, + default: ['github.com', '*.google.com', 'localhost'] as string[], + description: oneLine` + A list of allowed domains for the browser agent + (e.g., ["github.com", "*.google.com"]). + `, + showInDialog: false, + items: { type: 'string' }, + }, + disableUserInput: { + type: 'boolean', + label: 'Disable User Input', + category: 'Advanced', + requiresRestart: false, + default: true, + description: + 'Disable user input on browser window during automation.', + showInDialog: false, + }, + maxActionsPerTask: { + type: 'number', + label: 'Max Actions Per Task', + category: 'Advanced', + requiresRestart: false, + default: 100, + description: + 'The maximum number of tool calls allowed per browser task. Enforcement is hard: the agent will be terminated when the limit is reached.', + showInDialog: false, + }, + confirmSensitiveActions: { + type: 'boolean', + label: 'Confirm Sensitive Actions', + category: 'Advanced', + requiresRestart: true, + default: false, + description: + 'Require manual confirmation for sensitive browser actions (e.g., fill_form, evaluate_script).', + showInDialog: true, + }, + blockFileUploads: { + type: 'boolean', + label: 'Block File Uploads', + category: 'Advanced', + requiresRestart: true, + default: false, + description: + 'Hard-block file upload requests from the browser agent.', + showInDialog: true, + }, + }, + }, + }, + }, + + context: { + type: 'object', + label: 'Context', + category: 'Context', + requiresRestart: false, + default: {}, + description: 'Settings for managing context provided to the model.', + showInDialog: false, + properties: { + fileName: { + type: 'string', + label: 'Context File Name', + category: 'Context', + requiresRestart: false, + default: undefined as string | string[] | undefined, + ref: 'StringOrStringArray', + description: + 'The name of the context file or files to load into memory. Accepts either a single string or an array of strings.', + showInDialog: false, + }, + importFormat: { + type: 'string', + label: 'Memory Import Format', + category: 'Context', + requiresRestart: false, + default: undefined as MemoryImportFormat | undefined, + description: 'The format to use when importing memory.', + showInDialog: false, + }, + includeDirectoryTree: { + type: 'boolean', + label: 'Include Directory Tree', + category: 'Context', + requiresRestart: false, + default: true, + description: + 'Whether to include the directory tree of the current working directory in the initial request to the model.', + showInDialog: false, + }, + discoveryMaxDirs: { + type: 'number', + label: 'Memory Discovery Max Dirs', + category: 'Context', + requiresRestart: false, + default: 200, + description: 'Maximum number of directories to search for memory.', + showInDialog: true, + }, + memoryBoundaryMarkers: { + type: 'array', + label: 'Memory Boundary Markers', + category: 'Context', + requiresRestart: true, + default: ['.git'] as string[], + description: + 'File or directory names that mark the boundary for GEMINI.md discovery. ' + + 'The upward traversal stops at the first directory containing any of these markers. ' + + 'An empty array disables parent traversal.', + showInDialog: false, + items: { type: 'string' }, + }, + includeDirectories: { + type: 'array', + label: 'Include Directories', + category: 'Context', + requiresRestart: false, + default: [] as string[], + description: oneLine` + Additional directories to include in the workspace context. + Missing directories will be skipped with a warning. + `, + showInDialog: false, + items: { type: 'string' }, + mergeStrategy: MergeStrategy.CONCAT, + }, + loadMemoryFromIncludeDirectories: { + type: 'boolean', + label: 'Load Memory From Include Directories', + category: 'Context', + requiresRestart: false, + default: false, + description: oneLine` + Controls how /memory reload loads GEMINI.md files. + When true, include directories are scanned; when false, only the current directory is used. + `, + showInDialog: true, + }, + fileFiltering: { + type: 'object', + label: 'File Filtering', + category: 'Context', + requiresRestart: true, + default: {}, + description: 'Settings for git-aware file filtering.', + showInDialog: false, + properties: { + respectGitIgnore: { + type: 'boolean', + label: 'Respect .gitignore', + category: 'Context', + requiresRestart: true, + default: true, + description: 'Respect .gitignore files when searching.', + showInDialog: true, + }, + respectGeminiIgnore: { + type: 'boolean', + label: 'Respect .geminiignore', + category: 'Context', + requiresRestart: true, + default: true, + description: 'Respect .geminiignore files when searching.', + showInDialog: true, + }, + enableFileWatcher: { + type: 'boolean', + label: 'Enable File Watcher', + category: 'Context', + requiresRestart: true, + default: false, + description: oneLine` + Enable file watcher updates for @ file suggestions (experimental). + `, + showInDialog: false, + }, + enableRecursiveFileSearch: { + type: 'boolean', + label: 'Enable Recursive File Search', + category: 'Context', + requiresRestart: true, + default: true, + description: oneLine` + Enable recursive file search functionality when completing @ references in the prompt. + `, + showInDialog: true, + }, + enableFuzzySearch: { + type: 'boolean', + label: 'Enable Fuzzy Search', + category: 'Context', + requiresRestart: true, + default: true, + description: 'Enable fuzzy search when searching for files.', + showInDialog: true, + }, + customIgnoreFilePaths: { + type: 'array', + label: 'Custom Ignore File Paths', + category: 'Context', + requiresRestart: true, + default: [] as string[], + description: + 'Additional ignore file paths to respect. These files take precedence over .geminiignore and .gitignore. Files earlier in the array take precedence over files later in the array, e.g. the first file takes precedence over the second one.', + showInDialog: true, + items: { type: 'string' }, + mergeStrategy: MergeStrategy.UNION, + }, + }, + }, + }, + }, + + tools: { + type: 'object', + label: 'Tools', + category: 'Tools', + requiresRestart: true, + default: {}, + description: 'Settings for built-in and custom tools.', + showInDialog: false, + properties: { + sandbox: { + type: 'string', + label: 'Sandbox', + category: 'Tools', + requiresRestart: true, + default: undefined as boolean | string | SandboxConfig | undefined, + ref: 'BooleanOrStringOrObject', + description: oneLine` + Legacy full-process sandbox execution environment. + Set to a boolean to enable or disable the sandbox, provide a string path to a sandbox profile, + or specify an explicit sandbox command (e.g., "docker", "podman", "lxc", "windows-native"). + `, + showInDialog: false, + }, + sandboxAllowedPaths: { + type: 'array', + label: 'Sandbox Allowed Paths', + category: 'Tools', + requiresRestart: true, + default: [] as string[], + description: + 'List of additional paths that the sandbox is allowed to access.', + showInDialog: true, + items: { type: 'string' }, + }, + sandboxNetworkAccess: { + type: 'boolean', + label: 'Sandbox Network Access', + category: 'Tools', + requiresRestart: true, + default: false, + description: 'Whether the sandbox is allowed to access the network.', + showInDialog: true, + }, + shell: { + type: 'object', + label: 'Shell', + category: 'Tools', + requiresRestart: false, + default: {}, + description: 'Settings for shell execution.', + showInDialog: false, + properties: { + enableInteractiveShell: { + type: 'boolean', + label: 'Enable Interactive Shell', + category: 'Tools', + requiresRestart: true, + default: true, + description: oneLine` + Use node-pty for an interactive shell experience. + Fallback to child_process still applies. + `, + showInDialog: true, + }, + backgroundCompletionBehavior: { + type: 'enum', + label: 'Background Completion Behavior', + category: 'Tools', + requiresRestart: false, + default: 'silent', + description: + "Controls what happens when a background shell command finishes. 'silent' (default): quietly exits in background. 'inject': automatically returns output to agent. 'notify': shows brief message in chat.", + showInDialog: false, + options: [ + { label: 'Silent', value: 'silent' }, + { label: 'Inject', value: 'inject' }, + { label: 'Notify', value: 'notify' }, + ], + }, + pager: { + type: 'string', + label: 'Pager', + category: 'Tools', + requiresRestart: false, + default: 'cat' as string | undefined, + description: + 'The pager command to use for shell output. Defaults to `cat`.', + showInDialog: false, + }, + showColor: { + type: 'boolean', + label: 'Show Color', + category: 'Tools', + requiresRestart: false, + default: true, + description: 'Show color in shell output.', + showInDialog: true, + }, + inactivityTimeout: { + type: 'number', + label: 'Inactivity Timeout', + category: 'Tools', + requiresRestart: false, + default: 300, + description: + 'The maximum time in seconds allowed without output from the shell command. Defaults to 5 minutes.', + showInDialog: false, + }, + enableShellOutputEfficiency: { + type: 'boolean', + label: 'Enable Shell Output Efficiency', + category: 'Tools', + requiresRestart: false, + default: true, + description: + 'Enable shell output efficiency optimizations for better performance.', + showInDialog: false, + }, + }, + }, + + core: { + type: 'array', + label: 'Core Tools', + category: 'Tools', + requiresRestart: true, + default: undefined as string[] | undefined, + description: oneLine` + Restrict the set of built-in tools with an allowlist. + Match semantics mirror tools.allowed; see the built-in tools documentation for available names. + `, + showInDialog: false, + items: { type: 'string' }, + }, + allowed: { + type: 'array', + label: 'Allowed Tools', + category: 'Advanced', + requiresRestart: true, + default: undefined as string[] | undefined, + description: oneLine` + Tool names that bypass the confirmation dialog. + Useful for trusted commands (for example ["run_shell_command(git)", "run_shell_command(npm test)"]). + See shell tool command restrictions for matching details. + `, + showInDialog: false, + items: { type: 'string' }, + }, + confirmationRequired: { + type: 'array', + label: 'Confirmation Required', + category: 'Advanced', + requiresRestart: true, + default: undefined as string[] | undefined, + description: oneLine` + Tool names that always require user confirmation. + Takes precedence over allowed tools and core tool allowlists. + `, + showInDialog: false, + items: { type: 'string' }, + }, + exclude: { + type: 'array', + label: 'Exclude Tools', + category: 'Tools', + requiresRestart: true, + default: undefined as string[] | undefined, + description: 'Tool names to exclude from discovery.', + showInDialog: false, + items: { type: 'string' }, + mergeStrategy: MergeStrategy.UNION, + }, + discoveryCommand: { + type: 'string', + label: 'Tool Discovery Command', + category: 'Tools', + requiresRestart: true, + default: undefined as string | undefined, + description: 'Command to run for tool discovery.', + showInDialog: false, + }, + callCommand: { + type: 'string', + label: 'Tool Call Command', + category: 'Tools', + requiresRestart: true, + default: undefined as string | undefined, + description: oneLine` + Defines a custom shell command for invoking discovered tools. + The command must take the tool name as the first argument, read JSON arguments from stdin, and emit JSON results on stdout. + `, + showInDialog: false, + }, + useRipgrep: { + type: 'boolean', + label: 'Use Ripgrep', + category: 'Tools', + requiresRestart: false, + default: true, + description: + 'Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance.', + showInDialog: true, + }, + truncateToolOutputThreshold: { + type: 'number', + label: 'Tool Output Truncation Threshold', + category: 'General', + requiresRestart: true, + default: DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, + description: + 'Maximum characters to show when truncating large tool outputs. Set to 0 or negative to disable truncation.', + showInDialog: true, + }, + disableLLMCorrection: { + type: 'boolean', + label: 'Disable LLM Correction', + category: 'Tools', + requiresRestart: true, + default: true, + description: oneLine` + Disable LLM-based error correction for edit tools. + When enabled, tools will fail immediately if exact string matches are not found, instead of attempting to self-correct. + `, + showInDialog: true, + }, + }, + }, + + mcp: { + type: 'object', + label: 'MCP', + category: 'MCP', + requiresRestart: true, + default: {}, + description: 'Settings for Model Context Protocol (MCP) servers.', + showInDialog: false, + properties: { + serverCommand: { + type: 'string', + label: 'MCP Server Command', + category: 'MCP', + requiresRestart: true, + default: undefined as string | undefined, + description: 'Command to start an MCP server.', + showInDialog: false, + }, + allowed: { + type: 'array', + label: 'Allow MCP Servers', + category: 'MCP', + requiresRestart: true, + default: undefined as string[] | undefined, + description: 'A list of MCP servers to allow.', + showInDialog: false, + items: { type: 'string' }, + }, + excluded: { + type: 'array', + label: 'Exclude MCP Servers', + category: 'MCP', + requiresRestart: true, + default: undefined as string[] | undefined, + description: 'A list of MCP servers to exclude.', + showInDialog: false, + items: { type: 'string' }, + }, + }, + }, + + useWriteTodos: { + type: 'boolean', + label: 'Use WriteTodos', + category: 'Advanced', + requiresRestart: false, + default: true, + description: 'Enable the write_todos tool.', + showInDialog: false, + }, + security: { + type: 'object', + label: 'Security', + category: 'Security', + requiresRestart: true, + default: {}, + description: 'Security-related settings.', + showInDialog: false, + properties: { + toolSandboxing: { + type: 'boolean', + label: 'Tool Sandboxing', + category: 'Security', + requiresRestart: true, + default: false, + description: + 'Tool-level sandboxing. Isolates individual tools instead of the entire CLI process.', + showInDialog: true, + }, + disableYoloMode: { + type: 'boolean', + label: 'Disable YOLO Mode', + category: 'Security', + requiresRestart: true, + default: false, + description: 'Disable YOLO mode, even if enabled by a flag.', + showInDialog: true, + }, + disableAlwaysAllow: { + type: 'boolean', + label: 'Disable Always Allow', + category: 'Security', + requiresRestart: true, + default: false, + description: + 'Disable "Always allow" options in tool confirmation dialogs.', + showInDialog: true, + }, + enablePermanentToolApproval: { + type: 'boolean', + label: 'Allow Permanent Tool Approval', + category: 'Security', + requiresRestart: false, + default: false, + description: + 'Enable the "Allow for all future sessions" option in tool confirmation dialogs.', + showInDialog: true, + }, + autoAddToPolicyByDefault: { + type: 'boolean', + label: 'Auto-add to Policy by Default', + category: 'Security', + requiresRestart: false, + default: false, + description: oneLine` + When enabled, the "Allow for all future sessions" option becomes the + default choice for low-risk tools in trusted workspaces. + `, + showInDialog: true, + }, + blockGitExtensions: { + type: 'boolean', + label: 'Blocks extensions from Git', + category: 'Security', + requiresRestart: true, + default: false, + description: 'Blocks installing and loading extensions from Git.', + showInDialog: true, + }, + allowedExtensions: { + type: 'array', + label: 'Extension Source Regex Allowlist', + category: 'Security', + requiresRestart: true, + default: [] as string[], + description: + 'List of Regex patterns for allowed extensions. If nonempty, only extensions that match the patterns in this list are allowed. Overrides the blockGitExtensions setting.', + showInDialog: true, + items: { type: 'string' }, + }, + folderTrust: { + type: 'object', + label: 'Folder Trust', + category: 'Security', + requiresRestart: false, + default: {}, + description: 'Settings for folder trust.', + showInDialog: false, + properties: { + enabled: { + type: 'boolean', + label: 'Folder Trust', + category: 'Security', + requiresRestart: true, + default: true, + description: 'Setting to track whether Folder trust is enabled.', + showInDialog: true, + }, + }, + }, + environmentVariableRedaction: { + type: 'object', + label: 'Environment Variable Redaction', + category: 'Security', + requiresRestart: false, + default: {}, + description: 'Settings for environment variable redaction.', + showInDialog: false, + properties: { + allowed: { + type: 'array', + label: 'Allowed Environment Variables', + category: 'Security', + requiresRestart: true, + default: [] as string[], + description: + 'Environment variables to always allow (bypass redaction).', + showInDialog: false, + items: { type: 'string' }, + }, + blocked: { + type: 'array', + label: 'Blocked Environment Variables', + category: 'Security', + requiresRestart: true, + default: [] as string[], + description: 'Environment variables to always redact.', + showInDialog: false, + items: { type: 'string' }, + }, + enabled: { + type: 'boolean', + label: 'Enable Environment Variable Redaction', + category: 'Security', + requiresRestart: true, + default: false, + description: + 'Enable redaction of environment variables that may contain secrets.', + showInDialog: true, + }, + }, + }, + auth: { + type: 'object', + label: 'Authentication', + category: 'Security', + requiresRestart: true, + default: {}, + description: 'Authentication settings.', + showInDialog: false, + properties: { + selectedType: { + type: 'string', + label: 'Selected Auth Type', + category: 'Security', + requiresRestart: true, + default: undefined as AuthType | undefined, + description: 'The currently selected authentication type.', + showInDialog: false, + }, + enforcedType: { + type: 'string', + label: 'Enforced Auth Type', + category: 'Advanced', + requiresRestart: true, + default: undefined as AuthType | undefined, + description: + 'The required auth type. If this does not match the selected auth type, the user will be prompted to re-authenticate.', + showInDialog: false, + }, + useExternal: { + type: 'boolean', + label: 'Use External Auth', + category: 'Security', + requiresRestart: true, + default: undefined as boolean | undefined, + description: 'Whether to use an external authentication flow.', + showInDialog: false, + }, + }, + }, + enableConseca: { + type: 'boolean', + label: 'Enable Context-Aware Security', + category: 'Security', + requiresRestart: true, + default: false, + description: + 'Enable the context-aware security checker. This feature uses an LLM to dynamically generate and enforce security policies for tool use based on your prompt, providing an additional layer of protection against unintended actions.', + showInDialog: true, + }, + }, + }, + + advanced: { + type: 'object', + label: 'Advanced', + category: 'Advanced', + requiresRestart: true, + default: {}, + description: 'Advanced settings for power users.', + showInDialog: false, + properties: { + autoConfigureMemory: { + type: 'boolean', + label: 'Auto Configure Max Old Space Size', + category: 'Advanced', + requiresRestart: true, + default: true, + description: + 'Automatically configure Node.js memory limits. Note: Because memory is allocated during the initial process boot, this setting is only read from the global user settings file and ignores workspace-level overrides.', + showInDialog: true, + }, + dnsResolutionOrder: { + type: 'string', + label: 'DNS Resolution Order', + category: 'Advanced', + requiresRestart: true, + default: undefined as DnsResolutionOrder | undefined, + description: 'The DNS resolution order.', + showInDialog: false, + }, + excludedEnvVars: { + type: 'array', + label: 'Excluded Project Environment Variables', + category: 'Advanced', + requiresRestart: false, + default: ['DEBUG', 'DEBUG_MODE'] as string[], + description: 'Environment variables to exclude from project context.', + showInDialog: false, + items: { type: 'string' }, + mergeStrategy: MergeStrategy.UNION, + }, + ignoreLocalEnv: { + type: 'boolean', + label: 'Ignore Local .env', + category: 'Advanced', + requiresRestart: true, + default: false, + description: + 'Whether to ignore generic .env files in the project directory.', + showInDialog: true, + }, + bugCommand: { + type: 'object', + label: 'Bug Command', + category: 'Advanced', + requiresRestart: false, + default: undefined as BugCommandSettings | undefined, + description: 'Configuration for the bug report command.', + showInDialog: false, + ref: 'BugCommandSettings', + }, + }, + }, + + experimental: { + type: 'object', + label: 'Experimental', + category: 'Experimental', + requiresRestart: true, + default: {}, + description: 'Setting to enable experimental features', + showInDialog: false, + properties: { + gemma: { + type: 'boolean', + label: 'Gemma Models', + category: 'Experimental', + requiresRestart: true, + default: true, + description: 'Enable access to Gemma 4 models via Gemini API.', + showInDialog: true, + }, + voiceMode: { + type: 'boolean', + label: 'Voice Mode', + category: 'Experimental', + requiresRestart: false, + default: false, + description: + 'Enable experimental voice dictation and commands (/voice, /voice model).', + showInDialog: true, + }, + voice: { + type: 'object', + label: 'Voice', + category: 'Experimental', + requiresRestart: false, + default: {}, + description: 'Settings for voice mode and transcription.', + showInDialog: false, + properties: { + activationMode: { + type: 'enum', + label: 'Voice Activation Mode', + category: 'Experimental', + requiresRestart: false, + default: 'push-to-talk', + description: 'How to trigger voice recording with the Space key.', + showInDialog: true, + options: [ + { value: 'push-to-talk', label: 'Push-To-Talk (Hold Space)' }, + { value: 'toggle', label: 'Toggle (Press Space to start/stop)' }, + ], + }, + backend: { + type: 'enum', + label: 'Voice Transcription Backend', + category: 'Experimental', + requiresRestart: false, + default: 'gemini-live', + description: oneLine` + The backend to use for voice transcription. Note: When using the + Gemini Live backend, voice recordings are sent to Google Cloud for + transcription. + `, + showInDialog: true, + options: [ + { value: 'gemini-live', label: 'Gemini Live API (Cloud)' }, + { value: 'whisper', label: 'Whisper (Local)' }, + ], + }, + whisperModel: { + type: 'enum', + label: 'Whisper Model', + category: 'Experimental', + requiresRestart: false, + default: 'ggml-base.en.bin', + description: 'The Whisper model to use for local transcription.', + showInDialog: true, + options: [ + { value: 'ggml-tiny.en.bin', label: 'Tiny (EN) - Fast (~75MB)' }, + { + value: 'ggml-base.en.bin', + label: 'Base (EN) - Balanced (~142MB)', + }, + { + value: 'ggml-large-v3-turbo-q5_0.bin', + label: 'Large v3 Turbo (Q5_0) - High Accuracy (~547MB)', + }, + { + value: 'ggml-large-v3-turbo-q8_0.bin', + label: 'Large v3 Turbo (Q8_0) - Max Accuracy (~834MB)', + }, + ], + }, + stopGracePeriodMs: { + type: 'number', + label: 'Voice Stop Grace Period (ms)', + category: 'Experimental', + requiresRestart: false, + default: 4000, + description: + 'How long to wait for final transcription after stopping recording.', + showInDialog: true, + }, + }, + }, + adk: { + type: 'object', + label: 'ADK', + category: 'Experimental', + requiresRestart: true, + default: {}, + description: 'Settings for the Agent Development Kit (ADK).', + showInDialog: false, + properties: { + agentSessionNoninteractiveEnabled: { + type: 'boolean', + label: 'Agent Session Non-interactive Enabled', + category: 'Experimental', + requiresRestart: true, + default: false, + description: 'Enable non-interactive agent sessions.', + showInDialog: false, + }, + agentSessionInteractiveEnabled: { + type: 'boolean', + label: 'Interactive Agent Session Enabled', + category: 'Experimental', + requiresRestart: true, + default: false, + description: + 'Enable the agent session implementation for the interactive CLI.', + showInDialog: false, + }, + agentSessionSubagentEnabled: { + type: 'boolean', + label: 'Agent Session Subagent Enabled', + category: 'Experimental', + requiresRestart: true, + default: false, + description: + 'Route subagent invocations through the AgentSession protocol instead of legacy executors.', + showInDialog: false, + }, + }, + }, + enableAgents: { + type: 'boolean', + label: 'Enable Agents', + category: 'Experimental', + requiresRestart: true, + default: true, + description: 'Enable local and remote subagents.', + showInDialog: false, + }, + worktrees: { + type: 'boolean', + label: 'Enable Git Worktrees', + category: 'Experimental', + requiresRestart: true, + default: false, + description: + 'Enable automated Git worktree management for parallel work.', + showInDialog: true, + }, + extensionManagement: { + type: 'boolean', + label: 'Extension Management', + category: 'Experimental', + requiresRestart: true, + default: true, + description: 'Enable extension management features.', + showInDialog: false, + }, + extensionConfig: { + type: 'boolean', + label: 'Extension Configuration', + category: 'Experimental', + requiresRestart: true, + default: true, + description: 'Enable requesting and fetching of extension settings.', + showInDialog: false, + }, + extensionRegistry: { + type: 'boolean', + label: 'Extension Registry Explore UI', + category: 'Experimental', + requiresRestart: true, + default: false, + description: 'Enable extension registry explore UI.', + showInDialog: false, + }, + extensionRegistryURI: { + type: 'string', + label: 'Extension Registry URI', + category: 'Experimental', + requiresRestart: true, + default: 'https://geminicli.com/extensions.json', + description: + 'The URI (web URL or local file path) of the extension registry.', + showInDialog: false, + }, + extensionReloading: { + type: 'boolean', + label: 'Extension Reloading', + category: 'Experimental', + requiresRestart: true, + default: false, + description: + 'Enables extension loading/unloading within the CLI session.', + showInDialog: false, + }, + useOSC52Paste: { + type: 'boolean', + label: 'Use OSC 52 Paste', + category: 'Experimental', + requiresRestart: false, + default: false, + description: + 'Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it).', + showInDialog: true, + }, + useOSC52Copy: { + type: 'boolean', + label: 'Use OSC 52 Copy', + category: 'Experimental', + requiresRestart: false, + default: false, + description: + 'Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it).', + showInDialog: true, + }, + taskTracker: { + type: 'boolean', + label: 'Task Tracker', + category: 'Experimental', + requiresRestart: true, + default: false, + description: 'Enable task tracker tools.', + showInDialog: false, + }, + modelSteering: { + type: 'boolean', + label: 'Model Steering', + category: 'Experimental', + requiresRestart: false, + default: false, + description: + 'Enable model steering (user hints) to guide the model during tool execution.', + showInDialog: true, + }, + directWebFetch: { + type: 'boolean', + label: 'Direct Web Fetch', + category: 'Experimental', + requiresRestart: true, + default: false, + description: + 'Enable web fetch behavior that bypasses LLM summarization.', + showInDialog: true, + }, + dynamicModelConfiguration: { + type: 'boolean', + label: 'Dynamic Model Configuration', + category: 'Experimental', + requiresRestart: true, + default: false, + description: + 'Enable dynamic model configuration (definitions, resolutions, and chains) via settings.', + showInDialog: false, + }, + gemmaModelRouter: { + type: 'object', + label: 'Gemma Model Router', + category: 'Experimental', + requiresRestart: true, + default: {}, + description: 'Enable Gemma model router (experimental).', + showInDialog: false, + properties: { + enabled: { + type: 'boolean', + label: 'Enable Gemma Model Router', + category: 'Experimental', + requiresRestart: true, + default: false, + description: + 'Enable the Gemma Model Router (experimental). Requires a local endpoint serving Gemma via the Gemini API using LiteRT-LM shim.', + showInDialog: true, + }, + autoStartServer: { + type: 'boolean', + label: 'Auto-start LiteRT Server', + category: 'Experimental', + requiresRestart: true, + default: false, + description: + 'Automatically start the LiteRT-LM server when Gemini CLI starts and the Gemma router is enabled.', + showInDialog: true, + }, + binaryPath: { + type: 'string', + label: 'LiteRT Binary Path', + category: 'Experimental', + requiresRestart: true, + default: '', + description: + 'Custom path to the LiteRT-LM binary. Leave empty to use the default location (~/.gemini/bin/litert/).', + showInDialog: false, + }, + classifier: { + type: 'object', + label: 'Classifier', + category: 'Experimental', + requiresRestart: true, + default: {}, + description: 'Classifier configuration.', + showInDialog: false, + properties: { + host: { + type: 'string', + label: 'Host', + category: 'Experimental', + requiresRestart: true, + default: 'http://localhost:9379', + description: 'The host of the classifier.', + showInDialog: false, + }, + model: { + type: 'string', + label: 'Model', + category: 'Experimental', + requiresRestart: true, + default: 'gemma3-1b-gpu-custom', + description: + 'The model to use for the classifier. Only tested on `gemma3-1b-gpu-custom`.', + showInDialog: false, + }, + }, + }, + }, + }, + stressTestProfile: { + type: 'boolean', + label: + 'Use the stress test profile to aggressively trigger context management.', + category: 'Experimental', + requiresRestart: true, + default: false, + description: + 'Significantly lowers token limits to force early garbage collection and distillation for testing purposes.', + showInDialog: false, + }, + autoMemory: { + type: 'boolean', + label: 'Auto Memory', + category: 'Experimental', + requiresRestart: true, + default: false, + description: + 'Automatically extract memory patches and skills from past sessions in the background. Every change is written as a unified diff `.patch` file under `/.inbox//` and held for review in /memory inbox; nothing is applied until you approve it.', + showInDialog: true, + }, + generalistProfile: { + type: 'boolean', + label: 'Use the generalist profile to manage agent contexts.', + category: 'Experimental', + requiresRestart: true, + default: false, + description: + 'Suitable for general coding and software development tasks.', + showInDialog: true, + }, + powerUserProfile: { + type: 'boolean', + label: 'Use the power user profile to manage agent contexts.', + category: 'Experimental', + requiresRestart: true, + default: false, + description: 'Less cache friendly version of the generalist profile.', + showInDialog: false, + }, + contextManagement: { + type: 'boolean', + label: 'Enable Context Management', + category: 'Experimental', + requiresRestart: true, + default: false, + description: 'Enable logic for context management.', + showInDialog: true, + }, + topicUpdateNarration: { + type: 'boolean', + label: 'Topic & Update Narration', + category: 'Experimental', + requiresRestart: false, + default: false, + description: 'Deprecated: Use general.topicUpdateNarration instead.', + showInDialog: false, + }, + }, + }, + extensions: { + type: 'object', + label: 'Extensions', + category: 'Extensions', + requiresRestart: true, + default: {}, + description: 'Settings for extensions.', + showInDialog: false, + properties: { + disabled: { + type: 'array', + label: 'Disabled Extensions', + category: 'Extensions', + requiresRestart: true, + default: [] as string[], + description: 'List of disabled extensions.', + showInDialog: false, + items: { type: 'string' }, + mergeStrategy: MergeStrategy.UNION, + }, + workspacesWithMigrationNudge: { + type: 'array', + label: 'Workspaces with Migration Nudge', + category: 'Extensions', + requiresRestart: false, + default: [] as string[], + description: + 'List of workspaces for which the migration nudge has been shown.', + showInDialog: false, + items: { type: 'string' }, + mergeStrategy: MergeStrategy.UNION, + }, + }, + }, + + skills: { + type: 'object', + label: 'Skills', + category: 'Advanced', + requiresRestart: true, + default: {}, + description: 'Settings for agent skills.', + showInDialog: false, + properties: { + enabled: { + type: 'boolean', + label: 'Enable Agent Skills', + category: 'Advanced', + requiresRestart: true, + default: true, + description: 'Enable Agent Skills.', + showInDialog: true, + }, + disabled: { + type: 'array', + label: 'Disabled Skills', + category: 'Advanced', + requiresRestart: true, + default: [] as string[], + description: 'List of disabled skills.', + showInDialog: false, + items: { type: 'string' }, + mergeStrategy: MergeStrategy.UNION, + }, + }, + }, + + hooksConfig: { + type: 'object', + label: 'HooksConfig', + category: 'Advanced', + requiresRestart: false, + default: {}, + description: + 'Hook configurations for intercepting and customizing agent behavior.', + showInDialog: false, + properties: { + enabled: { + type: 'boolean', + label: 'Enable Hooks', + category: 'Advanced', + requiresRestart: true, + default: true, + description: + 'Canonical toggle for the hooks system. When disabled, no hooks will be executed.', + showInDialog: true, + }, + disabled: { + type: 'array', + label: 'Disabled Hooks', + category: 'Advanced', + requiresRestart: false, + default: [] as string[], + description: + 'List of hook names (commands) that should be disabled. Hooks in this list will not execute even if configured.', + showInDialog: false, + items: { + type: 'string', + description: 'Hook command name', + }, + mergeStrategy: MergeStrategy.UNION, + }, + notifications: { + type: 'boolean', + label: 'Hook Notifications', + category: 'Advanced', + requiresRestart: false, + default: true, + description: 'Show visual indicators when hooks are executing.', + showInDialog: true, + }, + }, + }, + + hooks: { + type: 'object', + label: 'Hook Events', + category: 'Advanced', + requiresRestart: false, + default: {}, + description: 'Event-specific hook configurations.', + showInDialog: false, + properties: { + BeforeTool: { + type: 'array', + label: 'Before Tool Hooks', + category: 'Advanced', + requiresRestart: false, + default: [], + description: + 'Hooks that execute before tool execution. Can intercept, validate, or modify tool calls.', + showInDialog: false, + ref: 'HookDefinitionArray', + mergeStrategy: MergeStrategy.CONCAT, + }, + AfterTool: { + type: 'array', + label: 'After Tool Hooks', + category: 'Advanced', + requiresRestart: false, + default: [], + description: + 'Hooks that execute after tool execution. Can process results, log outputs, or trigger follow-up actions.', + showInDialog: false, + ref: 'HookDefinitionArray', + mergeStrategy: MergeStrategy.CONCAT, + }, + BeforeAgent: { + type: 'array', + label: 'Before Agent Hooks', + category: 'Advanced', + requiresRestart: false, + default: [], + description: + 'Hooks that execute before agent loop starts. Can set up context or initialize resources.', + showInDialog: false, + ref: 'HookDefinitionArray', + mergeStrategy: MergeStrategy.CONCAT, + }, + AfterAgent: { + type: 'array', + label: 'After Agent Hooks', + category: 'Advanced', + requiresRestart: false, + default: [], + description: + 'Hooks that execute after agent loop completes. Can perform cleanup or summarize results.', + showInDialog: false, + ref: 'HookDefinitionArray', + mergeStrategy: MergeStrategy.CONCAT, + }, + Notification: { + type: 'array', + label: 'Notification Hooks', + category: 'Advanced', + requiresRestart: false, + default: [], + description: + 'Hooks that execute on notification events (errors, warnings, info). Can log or alert on specific conditions.', + showInDialog: false, + ref: 'HookDefinitionArray', + mergeStrategy: MergeStrategy.CONCAT, + }, + SessionStart: { + type: 'array', + label: 'Session Start Hooks', + category: 'Advanced', + requiresRestart: false, + default: [], + description: + 'Hooks that execute when a session starts. Can initialize session-specific resources or state.', + showInDialog: false, + ref: 'HookDefinitionArray', + mergeStrategy: MergeStrategy.CONCAT, + }, + SessionEnd: { + type: 'array', + label: 'Session End Hooks', + category: 'Advanced', + requiresRestart: false, + default: [], + description: + 'Hooks that execute when a session ends. Can perform cleanup or persist session data.', + showInDialog: false, + ref: 'HookDefinitionArray', + mergeStrategy: MergeStrategy.CONCAT, + }, + PreCompress: { + type: 'array', + label: 'Pre-Compress Hooks', + category: 'Advanced', + requiresRestart: false, + default: [], + description: + 'Hooks that execute before chat history compression. Can back up or analyze conversation before compression.', + showInDialog: false, + ref: 'HookDefinitionArray', + mergeStrategy: MergeStrategy.CONCAT, + }, + BeforeModel: { + type: 'array', + label: 'Before Model Hooks', + category: 'Advanced', + requiresRestart: false, + default: [], + description: + 'Hooks that execute before LLM requests. Can modify prompts, inject context, or control model parameters.', + showInDialog: false, + ref: 'HookDefinitionArray', + mergeStrategy: MergeStrategy.CONCAT, + }, + AfterModel: { + type: 'array', + label: 'After Model Hooks', + category: 'Advanced', + requiresRestart: false, + default: [], + description: + 'Hooks that execute after LLM responses. Can process outputs, extract information, or log interactions.', + showInDialog: false, + ref: 'HookDefinitionArray', + mergeStrategy: MergeStrategy.CONCAT, + }, + BeforeToolSelection: { + type: 'array', + label: 'Before Tool Selection Hooks', + category: 'Advanced', + requiresRestart: false, + default: [], + description: + 'Hooks that execute before tool selection. Can filter or prioritize available tools dynamically.', + showInDialog: false, + ref: 'HookDefinitionArray', + mergeStrategy: MergeStrategy.CONCAT, + }, + }, + additionalProperties: { + type: 'array', + description: + 'Custom hook event arrays that contain hook definitions for user-defined events', + mergeStrategy: MergeStrategy.CONCAT, + }, + }, + + contextManagement: { + type: 'object', + label: 'Context Management', + category: 'Experimental', + requiresRestart: true, + default: {}, + description: + 'Settings for agent history and tool distillation context management.', + showInDialog: false, + properties: { + historyWindow: { + type: 'object', + label: 'History Window Settings', + category: 'Context Management', + requiresRestart: true, + default: {}, + showInDialog: false, + properties: { + maxTokens: { + type: 'number', + label: 'Max Tokens', + category: 'Context Management', + requiresRestart: true, + default: 150_000, + description: + 'The number of tokens to allow before triggering compression.', + showInDialog: false, + }, + retainedTokens: { + type: 'number', + label: 'Retained Tokens', + category: 'Context Management', + requiresRestart: true, + default: 40_000, + description: 'The number of tokens to always retain.', + showInDialog: false, + }, + }, + }, + messageLimits: { + type: 'object', + label: 'Message Limits', + category: 'Context Management', + requiresRestart: true, + default: {}, + showInDialog: false, + properties: { + normalMaxTokens: { + type: 'number', + label: 'Normal Maximum Tokens', + category: 'Context Management', + requiresRestart: true, + default: 2500, + description: + 'The target number of tokens to budget for a normal conversation turn.', + showInDialog: false, + }, + retainedMaxTokens: { + type: 'number', + label: 'Retained Maximum Tokens', + category: 'Context Management', + requiresRestart: true, + default: 12000, + description: + 'The maximum number of tokens a single conversation turn can consume before truncation.', + showInDialog: false, + }, + normalizationHeadRatio: { + type: 'number', + label: 'Normalization Head Ratio', + category: 'Context Management', + requiresRestart: true, + default: 0.25, + description: + 'The ratio of tokens to retain from the beginning of a truncated message (0.0 to 1.0).', + showInDialog: false, + }, + }, + }, + tools: { + type: 'object', + label: 'Context Management Tools', + category: 'Context Management', + requiresRestart: true, + default: {}, + showInDialog: false, + properties: { + distillation: { + type: 'object', + label: 'Tool Distillation', + category: 'Context Management', + requiresRestart: true, + default: {}, + showInDialog: false, + properties: { + maxOutputTokens: { + type: 'number', + label: 'Max Output Tokens', + category: 'Context Management', + requiresRestart: true, + default: 10_000, + description: + 'Maximum tokens to show to the model when truncating large tool outputs.', + showInDialog: false, + }, + summarizationThresholdTokens: { + type: 'number', + label: 'Tool Summarization Threshold', + category: 'Context Management', + requiresRestart: true, + default: 20_000, + description: + 'Threshold above which truncated tool outputs will be summarized by an LLM.', + showInDialog: false, + }, + }, + }, + outputMasking: { + type: 'object', + label: 'Tool Output Masking', + category: 'Context Management', + requiresRestart: true, + ignoreInDocs: false, + default: {}, + description: + 'Advanced settings for tool output masking to manage context window efficiency.', + showInDialog: false, + properties: { + protectionThresholdTokens: { + type: 'number', + label: 'Tool Protection Threshold (Tokens)', + category: 'Context Management', + requiresRestart: true, + default: 50_000, + description: + 'Minimum number of tokens to protect from masking (most recent tool outputs).', + showInDialog: false, + }, + minPrunableThresholdTokens: { + type: 'number', + label: 'Min Prunable Tokens Threshold', + category: 'Context Management', + requiresRestart: true, + default: 30_000, + description: + 'Minimum prunable tokens required to trigger a masking pass.', + showInDialog: false, + }, + protectLatestTurn: { + type: 'boolean', + label: 'Protect Latest Turn', + category: 'Context Management', + requiresRestart: true, + default: true, + description: + 'Ensures the absolute latest turn is never masked, regardless of token count.', + showInDialog: false, + }, + }, + }, + }, + }, + }, + }, + + admin: { + type: 'object', + label: 'Admin', + category: 'Admin', + requiresRestart: false, + default: {}, + description: 'Settings configured remotely by enterprise admins.', + showInDialog: false, + mergeStrategy: MergeStrategy.REPLACE, + properties: { + secureModeEnabled: { + type: 'boolean', + label: 'Secure Mode Enabled', + category: 'Admin', + requiresRestart: false, + default: false, + description: + 'If true, disallows YOLO mode and "Always allow" options from being used.', + showInDialog: false, + mergeStrategy: MergeStrategy.REPLACE, + }, + extensions: { + type: 'object', + label: 'Extensions Settings', + category: 'Admin', + requiresRestart: false, + default: {}, + description: 'Extensions-specific admin settings.', + showInDialog: false, + mergeStrategy: MergeStrategy.REPLACE, + properties: { + enabled: { + type: 'boolean', + label: 'Extensions Enabled', + category: 'Admin', + requiresRestart: false, + default: true, + description: + 'If false, disallows extensions from being installed or used.', + showInDialog: false, + mergeStrategy: MergeStrategy.REPLACE, + }, + }, + }, + mcp: { + type: 'object', + label: 'MCP Settings', + category: 'Admin', + requiresRestart: false, + default: {}, + description: 'MCP-specific admin settings.', + showInDialog: false, + mergeStrategy: MergeStrategy.REPLACE, + properties: { + enabled: { + type: 'boolean', + label: 'MCP Enabled', + category: 'Admin', + requiresRestart: false, + default: true, + description: 'If false, disallows MCP servers from being used.', + showInDialog: false, + mergeStrategy: MergeStrategy.REPLACE, + }, + config: { + type: 'object', + label: 'MCP Config', + category: 'Admin', + requiresRestart: false, + default: {} as Record, + description: 'Admin-configured MCP servers (allowlist).', + showInDialog: false, + mergeStrategy: MergeStrategy.REPLACE, + additionalProperties: { + type: 'object', + ref: 'MCPServerConfig', + }, + }, + requiredConfig: { + type: 'object', + label: 'Required MCP Config', + category: 'Admin', + requiresRestart: false, + default: {} as Record, + description: 'Admin-required MCP servers that are always injected.', + showInDialog: false, + mergeStrategy: MergeStrategy.REPLACE, + additionalProperties: { + type: 'object', + ref: 'RequiredMcpServerConfig', + }, + }, + }, + }, + skills: { + type: 'object', + label: 'Skills Settings', + category: 'Admin', + requiresRestart: false, + default: {}, + description: 'Agent Skills-specific admin settings.', + showInDialog: false, + mergeStrategy: MergeStrategy.REPLACE, + properties: { + enabled: { + type: 'boolean', + label: 'Skills Enabled', + category: 'Admin', + requiresRestart: false, + default: true, + description: 'If false, disallows agent skills from being used.', + showInDialog: false, + mergeStrategy: MergeStrategy.REPLACE, + }, + }, + }, + }, + }, +} as const satisfies SettingsSchema; + +export type SettingsSchemaType = typeof SETTINGS_SCHEMA; + +export type SettingsJsonSchemaDefinition = Record; + +export const SETTINGS_SCHEMA_DEFINITIONS: Record< + string, + SettingsJsonSchemaDefinition +> = { + MCPServerConfig: { + type: 'object', + description: + 'Definition of a Model Context Protocol (MCP) server configuration.', + additionalProperties: false, + properties: { + command: { + type: 'string', + description: 'Executable invoked for stdio transport.', + }, + args: { + type: 'array', + description: 'Command-line arguments for the stdio transport command.', + items: { type: 'string' }, + }, + env: { + type: 'object', + description: 'Environment variables to set for the server process.', + additionalProperties: { type: 'string' }, + }, + cwd: { + type: 'string', + description: 'Working directory for the server process.', + }, + url: { + type: 'string', + description: + 'URL for SSE or HTTP transport. Use with "type" field to specify transport type.', + }, + httpUrl: { + type: 'string', + description: 'Streaming HTTP transport URL.', + }, + headers: { + type: 'object', + description: 'Additional HTTP headers sent to the server.', + additionalProperties: { type: 'string' }, + }, + tcp: { + type: 'string', + description: 'TCP address for websocket transport.', + }, + type: { + type: 'string', + description: + 'Transport type. Use "stdio" for local command, "sse" for Server-Sent Events, or "http" for Streamable HTTP.', + enum: ['stdio', 'sse', 'http'], + }, + timeout: { + type: 'number', + description: 'Timeout in milliseconds for MCP requests.', + }, + trust: { + type: 'boolean', + description: + 'Marks the server as trusted. Trusted servers may gain additional capabilities.', + }, + description: { + type: 'string', + description: 'Human-readable description of the server.', + }, + includeTools: { + type: 'array', + description: + 'Subset of tools that should be enabled for this server. When omitted all tools are enabled.', + items: { type: 'string' }, + }, + excludeTools: { + type: 'array', + description: + 'Tools that should be disabled for this server even if exposed.', + items: { type: 'string' }, + }, + extension: { + type: 'object', + description: + 'Metadata describing the Gemini CLI extension that owns this MCP server.', + additionalProperties: { type: ['string', 'boolean', 'number'] }, + }, + oauth: { + type: 'object', + description: 'OAuth configuration for authenticating with the server.', + additionalProperties: true, + }, + authProviderType: { + type: 'string', + description: + 'Authentication provider used for acquiring credentials (for example `dynamic_discovery`).', + enum: Object.values(AuthProviderType), + }, + targetAudience: { + type: 'string', + description: + 'OAuth target audience (CLIENT_ID.apps.googleusercontent.com).', + }, + targetServiceAccount: { + type: 'string', + description: + 'Service account email to impersonate (name@project.iam.gserviceaccount.com).', + }, + }, + }, + RequiredMcpServerConfig: { + type: 'object', + description: + 'Admin-required MCP server configuration (remote transports only).', + additionalProperties: false, + properties: { + url: { + type: 'string', + description: 'URL for the required MCP server.', + }, + type: { + type: 'string', + description: 'Transport type for the required server.', + enum: ['sse', 'http'], + }, + headers: { + type: 'object', + description: 'Additional HTTP headers sent to the server.', + additionalProperties: { type: 'string' }, + }, + timeout: { + type: 'number', + description: 'Timeout in milliseconds for MCP requests.', + }, + trust: { + type: 'boolean', + description: + 'Marks the server as trusted. Defaults to true for admin-required servers.', + }, + description: { + type: 'string', + description: 'Human-readable description of the server.', + }, + includeTools: { + type: 'array', + description: 'Subset of tools enabled for this server.', + items: { type: 'string' }, + }, + excludeTools: { + type: 'array', + description: 'Tools disabled for this server.', + items: { type: 'string' }, + }, + oauth: { + type: 'object', + description: 'OAuth configuration for authenticating with the server.', + additionalProperties: true, + }, + authProviderType: { + type: 'string', + description: 'Authentication provider used for acquiring credentials.', + enum: Object.values(AuthProviderType), + }, + targetAudience: { + type: 'string', + description: + 'OAuth target audience (CLIENT_ID.apps.googleusercontent.com).', + }, + targetServiceAccount: { + type: 'string', + description: + 'Service account email to impersonate (name@project.iam.gserviceaccount.com).', + }, + }, + }, + TelemetrySettings: { + type: 'object', + description: 'Telemetry configuration for Gemini CLI.', + additionalProperties: false, + properties: { + enabled: { + type: 'boolean', + description: 'Enables telemetry emission.', + }, + target: { + type: 'string', + description: + 'Telemetry destination (for example `stderr`, `stdout`, or `otlp`).', + }, + otlpEndpoint: { + type: 'string', + description: 'Endpoint for OTLP exporters.', + }, + otlpProtocol: { + type: 'string', + description: 'Protocol for OTLP exporters.', + enum: ['grpc', 'http'], + }, + traces: { + type: 'boolean', + description: + 'Whether detailed traces with large attributes are captured.', + }, + logPrompts: { + type: 'boolean', + description: 'Whether prompts are logged in telemetry payloads.', + }, + outfile: { + type: 'string', + description: 'File path for writing telemetry output.', + }, + useCollector: { + type: 'boolean', + description: 'Whether to forward telemetry to an OTLP collector.', + }, + useCliAuth: { + type: 'boolean', + description: + 'Whether to use CLI authentication for telemetry (only for in-process exporters).', + }, + }, + }, + BugCommandSettings: { + type: 'object', + description: 'Configuration for the bug report helper command.', + additionalProperties: false, + properties: { + urlTemplate: { + type: 'string', + description: + 'Template used to open a bug report URL. Variables in the template are populated at runtime.', + }, + }, + required: ['urlTemplate'], + }, + SummarizeToolOutputSettings: { + type: 'object', + description: + 'Controls summarization behavior for individual tools. All properties are optional.', + additionalProperties: false, + properties: { + tokenBudget: { + type: 'number', + description: + 'Maximum number of tokens used when summarizing tool output.', + }, + }, + }, + AgentOverride: { + type: 'object', + description: 'Override settings for a specific agent.', + additionalProperties: false, + properties: { + modelConfig: { + type: 'object', + additionalProperties: true, + }, + runConfig: { + type: 'object', + description: 'Run configuration for an agent.', + additionalProperties: false, + properties: { + maxTimeMinutes: { + type: 'number', + description: 'The maximum execution time for the agent in minutes.', + }, + maxTurns: { + type: 'number', + description: 'The maximum number of conversational turns.', + }, + }, + }, + enabled: { + type: 'boolean', + description: 'Whether to enable the agent.', + }, + }, + }, + CustomTheme: { + type: 'object', + description: + 'Custom theme definition used for styling Gemini CLI output. Colors are provided as hex strings or named ANSI colors.', + additionalProperties: false, + properties: { + type: { + type: 'string', + enum: ['custom'], + default: 'custom', + }, + name: { + type: 'string', + description: 'Theme display name.', + }, + text: { + type: 'object', + additionalProperties: false, + properties: { + primary: { type: 'string' }, + secondary: { type: 'string' }, + link: { type: 'string' }, + accent: { type: 'string' }, + response: { type: 'string' }, + }, + }, + background: { + type: 'object', + additionalProperties: false, + properties: { + primary: { type: 'string' }, + diff: { + type: 'object', + additionalProperties: false, + properties: { + added: { type: 'string' }, + removed: { type: 'string' }, + }, + }, + }, + }, + border: { + type: 'object', + additionalProperties: false, + properties: { + default: { type: 'string' }, + focused: { type: 'string' }, + }, + }, + ui: { + type: 'object', + additionalProperties: false, + properties: { + comment: { type: 'string' }, + symbol: { type: 'string' }, + gradient: { + type: 'array', + items: { type: 'string' }, + }, + }, + }, + status: { + type: 'object', + additionalProperties: false, + properties: { + error: { type: 'string' }, + success: { type: 'string' }, + warning: { type: 'string' }, + }, + }, + Background: { type: 'string' }, + Foreground: { type: 'string' }, + LightBlue: { type: 'string' }, + AccentBlue: { type: 'string' }, + AccentPurple: { type: 'string' }, + AccentCyan: { type: 'string' }, + AccentGreen: { type: 'string' }, + AccentYellow: { type: 'string' }, + AccentRed: { type: 'string' }, + DiffAdded: { type: 'string' }, + DiffRemoved: { type: 'string' }, + Comment: { type: 'string' }, + Gray: { type: 'string' }, + DarkGray: { type: 'string' }, + GradientColors: { + type: 'array', + items: { type: 'string' }, + }, + }, + required: ['type', 'name'], + }, + StringOrStringArray: { + description: 'Accepts either a single string or an array of strings.', + anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }], + }, + BooleanOrStringOrObject: { + description: + 'Accepts either a boolean flag, a string command name, or a configuration object.', + anyOf: [ + { type: 'boolean' }, + { type: 'string' }, + { + type: 'object', + description: 'Sandbox configuration object.', + additionalProperties: false, + properties: { + enabled: { + type: 'boolean', + description: 'Enables or disables the sandbox.', + }, + command: { + type: 'string', + description: + 'The sandbox command to use (docker, podman, sandbox-exec, runsc, lxc).', + enum: ['docker', 'podman', 'sandbox-exec', 'runsc', 'lxc'], + }, + image: { + type: 'string', + description: 'The sandbox image to use.', + }, + allowedPaths: { + type: 'array', + description: + 'A list of absolute host paths that should be accessible within the sandbox.', + items: { type: 'string' }, + }, + networkAccess: { + type: 'boolean', + description: 'Whether the sandbox should have internet access.', + }, + }, + }, + ], + }, + HookDefinitionArray: { + type: 'array', + description: 'Array of hook definition objects for a specific event.', + items: { + type: 'object', + description: + 'Hook definition specifying matcher pattern and hook configurations.', + properties: { + matcher: { + type: 'string', + description: + 'Pattern to match against the event context (tool name, notification type, etc.). Supports exact match, regex (/pattern/), and wildcards (*).', + }, + hooks: { + type: 'array', + description: 'Hooks to execute when the matcher matches.', + items: { + type: 'object', + description: 'Individual hook configuration.', + properties: { + name: { + type: 'string', + description: 'Unique identifier for the hook.', + }, + type: { + type: 'string', + description: + 'Type of hook (currently only "command" supported).', + }, + command: { + type: 'string', + description: + 'Shell command to execute. Receives JSON input via stdin and returns JSON output via stdout.', + }, + description: { + type: 'string', + description: 'A description of the hook.', + }, + timeout: { + type: 'number', + description: 'Timeout in milliseconds for hook execution.', + }, + }, + }, + }, + }, + }, + }, + ModelDefinition: { + type: 'object', + description: 'Model metadata registry entry.', + properties: { + displayName: { type: 'string' }, + tier: { enum: ['pro', 'flash', 'flash-lite', 'custom', 'auto'] }, + family: { type: 'string' }, + isPreview: { type: 'boolean' }, + isVisible: { type: 'boolean' }, + dialogDescription: { + type: 'string', + description: + "A description of the model to display in the model selection dialog. For the 'auto' alias, this value is dynamically generated and any value provided here will be ignored.", + }, + features: { + type: 'object', + properties: { + thinking: { type: 'boolean' }, + multimodalToolUse: { type: 'boolean' }, + }, + }, + }, + }, + ModelResolution: { + type: 'object', + description: 'Model resolution rule.', + properties: { + default: { type: 'string' }, + contexts: { + type: 'array', + items: { + type: 'object', + properties: { + condition: { + type: 'object', + properties: { + useGemini3_1: { type: 'boolean' }, + useGemini3_1FlashLite: { type: 'boolean' }, + useCustomTools: { type: 'boolean' }, + hasAccessToPreview: { type: 'boolean' }, + requestedModels: { + type: 'array', + items: { type: 'string' }, + }, + }, + }, + target: { type: 'string' }, + }, + }, + }, + }, + }, + ModelPolicyChain: { + type: 'array', + description: 'A chain of model policies for fallback behavior.', + items: { + type: 'object', + ref: 'ModelPolicy', + }, + }, + ModelPolicy: { + type: 'object', + description: + 'Defines the policy for a single model in the availability chain.', + properties: { + model: { type: 'string' }, + isLastResort: { type: 'boolean' }, + actions: { + type: 'object', + properties: { + terminal: { type: 'string', enum: ['silent', 'prompt'] }, + transient: { type: 'string', enum: ['silent', 'prompt'] }, + not_found: { type: 'string', enum: ['silent', 'prompt'] }, + unknown: { type: 'string', enum: ['silent', 'prompt'] }, + }, + }, + stateTransitions: { + type: 'object', + properties: { + terminal: { type: 'string', enum: ['terminal', 'sticky_retry'] }, + transient: { type: 'string', enum: ['terminal', 'sticky_retry'] }, + not_found: { type: 'string', enum: ['terminal', 'sticky_retry'] }, + unknown: { type: 'string', enum: ['terminal', 'sticky_retry'] }, + }, + }, + }, + required: ['model'], + }, +}; + +export function getSettingsSchema(): SettingsSchemaType { + return SETTINGS_SCHEMA; +} + +type InferSettings = { + -readonly [K in keyof T]?: T[K] extends { properties: SettingsSchema } + ? InferSettings + : T[K]['type'] extends 'enum' + ? T[K]['options'] extends readonly SettingEnumOption[] + ? T[K]['options'][number]['value'] + : T[K]['default'] + : T[K]['default'] extends boolean + ? boolean + : T[K]['default'] extends string + ? string + : T[K]['default'] extends ReadonlyArray + ? U[] + : T[K]['default']; +}; + +type InferMergedSettings = { + -readonly [K in keyof T]-?: T[K] extends { properties: SettingsSchema } + ? InferMergedSettings + : T[K]['type'] extends 'enum' + ? T[K]['options'] extends readonly SettingEnumOption[] + ? T[K]['options'][number]['value'] + : T[K]['default'] + : T[K]['default'] extends boolean + ? boolean + : T[K]['default'] extends string + ? string + : T[K]['default'] extends ReadonlyArray + ? U[] + : T[K]['default']; +}; + +export type Settings = InferSettings; +export type MergedSettings = InferMergedSettings; diff --git a/packages/cli/src/config/settings_repro.test.ts b/packages/cli/src/config/settings_repro.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..36495a99c45aa882707ccc498325abafaa70e91e --- /dev/null +++ b/packages/cli/src/config/settings_repro.test.ts @@ -0,0 +1,201 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/// + +// Mock 'os' first. +import * as osActual from 'node:os'; + +vi.mock('os', async (importOriginal) => { + const actualOs = await importOriginal(); + return { + ...actualOs, + homedir: vi.fn(() => '/mock/home/user'), + platform: vi.fn(() => 'linux'), + }; +}); + +// Mock './settings.js' to ensure it uses the mocked 'os.homedir()' for its internal constants. +vi.mock('./settings.js', async (importActual) => { + const originalModule = await importActual(); + return { + __esModule: true, + ...originalModule, + }; +}); + +// Mock trustedFolders +vi.mock('./trustedFolders.js', () => ({ + isWorkspaceTrusted: vi + .fn() + .mockReturnValue({ isTrusted: true, source: 'file' }), +})); + +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, + type Mocked, + type Mock, +} from 'vitest'; +import * as fs from 'node:fs'; +import stripJsonComments from 'strip-json-comments'; +import { isWorkspaceTrusted } from './trustedFolders.js'; + +import { loadSettings, USER_SETTINGS_PATH } from './settings.js'; + +const MOCK_WORKSPACE_DIR = '/mock/workspace'; + +vi.mock('fs', async (importOriginal) => { + const actualFs = await importOriginal(); + return { + ...actualFs, + existsSync: vi.fn(), + readFileSync: vi.fn(), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), + renameSync: vi.fn(), + realpathSync: (p: string) => p, + }; +}); + +vi.mock('./extension.js'); + +const mockCoreEvents = vi.hoisted(() => ({ + emitFeedback: vi.fn(), +})); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + coreEvents: mockCoreEvents, + }; +}); + +vi.mock('../utils/commentJson.js', () => ({ + updateSettingsFilePreservingFormat: vi.fn(), +})); + +vi.mock('strip-json-comments', () => ({ + default: vi.fn((content) => content), +})); + +describe('Settings Repro', () => { + let mockFsExistsSync: Mocked; + let mockStripJsonComments: Mocked; + let mockFsMkdirSync: Mocked; + + beforeEach(() => { + vi.resetAllMocks(); + + mockFsExistsSync = vi.mocked(fs.existsSync); + mockFsMkdirSync = vi.mocked(fs.mkdirSync); + mockStripJsonComments = vi.mocked(stripJsonComments); + + vi.mocked(osActual.homedir).mockReturnValue('/mock/home/user'); + (mockStripJsonComments as unknown as Mock).mockImplementation( + (jsonString: string) => jsonString, + ); + (mockFsExistsSync as Mock).mockReturnValue(false); + (fs.readFileSync as Mock).mockReturnValue('{}'); + (mockFsMkdirSync as Mock).mockImplementation(() => undefined); + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: true, + source: 'file', + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should handle the problematic settings.json without crashing', () => { + (mockFsExistsSync as Mock).mockImplementation( + (p: fs.PathLike) => p === USER_SETTINGS_PATH, + ); + const problemSettingsContent = { + accessibility: { + screenReader: true, + }, + ide: { + enabled: false, + hasSeenNudge: true, + }, + general: { + debugKeystrokeLogging: false, + preferredEditor: 'vim', + vimMode: false, + }, + security: { + auth: { + selectedType: 'gemini-api-key', + }, + folderTrust: { + enabled: true, + }, + }, + tools: { + useRipgrep: true, + shell: { + showColor: true, + enableInteractiveShell: true, + }, + }, + experimental: { + useModelRouter: false, + enableSubagents: false, + }, + agents: { + overrides: { + codebase_investigator: { + enabled: true, + }, + }, + }, + ui: { + accessibility: { + screenReader: false, + }, + showMemoryUsage: true, + showStatusInTitle: true, + showCitations: true, + useInkScrolling: true, + footer: { + hideContextPercentage: false, + hideModelInfo: false, + }, + }, + useWriteTodos: true, + output: { + format: 'text', + }, + model: { + compressionThreshold: 0.8, + }, + }; + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === USER_SETTINGS_PATH) + return JSON.stringify(problemSettingsContent); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + + // If it doesn't throw, check if it merged correctly. + // The model.compressionThreshold should be present. + // And model.name should probably be undefined or default, but certainly NOT { compressionThreshold: 0.8 } + expect(settings.merged.model?.compressionThreshold).toBe(0.8); + expect(typeof settings.merged.model?.name).not.toBe('object'); + }); +}); diff --git a/packages/cli/src/config/settings_validation_warning.test.ts b/packages/cli/src/config/settings_validation_warning.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..435c797d81a2eae9187c1500ffc6dc182f10bae4 --- /dev/null +++ b/packages/cli/src/config/settings_validation_warning.test.ts @@ -0,0 +1,163 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/// + +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import * as fs from 'node:fs'; + +const mockCoreEvents = vi.hoisted(() => ({ + emitFeedback: vi.fn(), + emitConsoleLog: vi.fn(), + emitOutput: vi.fn(), + emitModelChanged: vi.fn(), + drainBacklogs: vi.fn(), +})); + +const mockIsWorkspaceTrusted = vi.hoisted(() => + vi.fn().mockReturnValue({ isTrusted: true, source: 'file' }), +); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + coreEvents: mockCoreEvents, + homedir: () => '/mock/home/user', + Storage: class extends actual.Storage { + static override getGlobalSettingsPath = () => + '/mock/home/user/.gemini/settings.json'; + override getWorkspaceSettingsPath = () => + '/mock/workspace/.gemini/settings.json'; + static override getGlobalGeminiDir = () => '/mock/home/user/.gemini'; + }, + }; +}); + +vi.mock('./trustedFolders.js', () => ({ + isWorkspaceTrusted: mockIsWorkspaceTrusted, + loadTrustedFolders: vi.fn().mockReturnValue({ + isPathTrusted: vi.fn().mockReturnValue(true), + user: { config: {} }, + errors: [], + }), + isFolderTrustEnabled: vi.fn().mockReturnValue(false), + TrustLevel: { + TRUST_FOLDER: 'TRUST_FOLDER', + TRUST_PARENT: 'TRUST_PARENT', + DO_NOT_TRUST: 'DO_NOT_TRUST', + }, +})); + +vi.mock('os', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + homedir: () => '/mock/home/user', + platform: () => 'linux', + totalmem: () => 16 * 1024 * 1024 * 1024, + }; +}); + +vi.mock('fs', async (importOriginal) => { + const actualFs = await importOriginal(); + return { + ...actualFs, + existsSync: vi.fn(), + readFileSync: vi.fn(), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), + renameSync: vi.fn(), + realpathSync: (p: string) => p, + }; +}); + +// Import loadSettings after all mocks are defined +import { + loadSettings, + USER_SETTINGS_PATH, + type LoadedSettings, + resetSettingsCacheForTesting, +} from './settings.js'; + +const MOCK_WORKSPACE_DIR = '/mock/workspace'; + +describe('Settings Validation Warning', () => { + beforeEach(() => { + vi.clearAllMocks(); + resetSettingsCacheForTesting(); + (fs.readFileSync as Mock).mockReturnValue('{}'); + (fs.existsSync as Mock).mockReturnValue(false); + }); + + it('should emit a warning and NOT throw when settings are invalid', () => { + (fs.existsSync as Mock).mockImplementation( + (p: string) => p === USER_SETTINGS_PATH, + ); + + const invalidSettingsContent = { + ui: { + customThemes: { + terafox: { + name: 'terafox', + type: 'custom', + DiffModified: '#ffffff', // Invalid key + }, + }, + }, + }; + + (fs.readFileSync as Mock).mockImplementation((p: string) => { + if (p === USER_SETTINGS_PATH) + return JSON.stringify(invalidSettingsContent); + return '{}'; + }); + + // Should NOT throw + let settings: LoadedSettings | undefined; + expect(() => { + settings = loadSettings(MOCK_WORKSPACE_DIR); + }).not.toThrow(); + + // Should have recorded a warning in the settings object + expect( + settings?.errors.some((e) => + e.message.includes("Unrecognized key(s) in object: 'DiffModified'"), + ), + ).toBe(true); + }); + + it('should throw a fatal error when settings file is not a valid JSON object', () => { + (fs.existsSync as Mock).mockImplementation( + (p: string) => p === USER_SETTINGS_PATH, + ); + + (fs.readFileSync as Mock).mockImplementation((p: string) => { + if (p === USER_SETTINGS_PATH) return '[]'; + return '{}'; + }); + + expect(() => { + loadSettings(MOCK_WORKSPACE_DIR); + }).toThrow(); + }); + + it('should throw a fatal error when settings file contains invalid JSON', () => { + (fs.existsSync as Mock).mockImplementation( + (p: string) => p === USER_SETTINGS_PATH, + ); + + (fs.readFileSync as Mock).mockImplementation((p: string) => { + if (p === USER_SETTINGS_PATH) return '{ "invalid": "json", }'; // Trailing comma is invalid in standard JSON + return '{}'; + }); + + expect(() => { + loadSettings(MOCK_WORKSPACE_DIR); + }).toThrow(); + }); +}); diff --git a/packages/cli/src/config/skipExtensions.test.ts b/packages/cli/src/config/skipExtensions.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..415c3a9529e72c0e9832c0eb1a521365f87be6b6 --- /dev/null +++ b/packages/cli/src/config/skipExtensions.test.ts @@ -0,0 +1,54 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { loadCliConfig, type CliArgs } from './config.js'; +import { ExtensionManager } from './extension-manager.js'; +import { createTestMergedSettings } from './settings.js'; + +vi.mock('./extension-manager.js', () => ({ + ExtensionManager: vi.fn().mockImplementation(() => ({ + loadExtensions: vi.fn().mockResolvedValue([]), + getExtensions: vi.fn().mockReturnValue([]), + })), +})); + +describe('loadCliConfig skipExtensions', () => { + const settings = createTestMergedSettings(); + const argv = { + query: undefined, + model: undefined, + sandbox: undefined, + debug: undefined, + prompt: undefined, + promptInteractive: undefined, + yolo: undefined, + approvalMode: undefined, + policy: undefined, + adminPolicy: undefined, + allowedMcpServerNames: undefined, + allowedTools: undefined, + extensions: undefined, + listExtensions: undefined, + resume: undefined, + sessionId: undefined, + listSessions: undefined, + } as unknown as CliArgs; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should load extensions by default', async () => { + await loadCliConfig(settings, 'session-id', argv); + expect(ExtensionManager).toHaveBeenCalled(); + }); + + it('should skip extensions when skipExtensions is true', async () => { + await loadCliConfig(settings, 'session-id', argv, { skipExtensions: true }); + expect(ExtensionManager).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/config/trustedFolders.test.ts b/packages/cli/src/config/trustedFolders.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..8af750db0769e132a6e471121bcd9039c37985ca --- /dev/null +++ b/packages/cli/src/config/trustedFolders.test.ts @@ -0,0 +1,554 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { + FatalConfigError, + ideContextStore, + normalizePath, +} from '@google/gemini-cli-core'; +import { + loadTrustedFolders, + TrustLevel, + isWorkspaceTrusted, + resetTrustedFoldersForTesting, +} from './trustedFolders.js'; +import { loadEnvironment, type Settings } from './settings.js'; +import { createMockSettings } from '../test-utils/settings.js'; + +// We explicitly do NOT mock 'fs' or 'proper-lockfile' here to ensure +// we are testing the actual behavior on the real file system. + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + homedir: () => '/mock/home/user', + isHeadlessMode: vi.fn(() => false), + coreEvents: Object.assign( + Object.create(Object.getPrototypeOf(actual.coreEvents)), + actual.coreEvents, + { + emitFeedback: vi.fn(), + }, + ), + FatalConfigError: actual.FatalConfigError, + }; +}); + +describe('Trusted Folders', () => { + let tempDir: string; + let trustedFoldersPath: string; + + beforeEach(() => { + // Create a temporary directory for each test + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-cli-test-')); + trustedFoldersPath = path.join(tempDir, 'trustedFolders.json'); + + // Set the environment variable to point to the temp file + vi.stubEnv('GEMINI_CLI_TRUSTED_FOLDERS_PATH', trustedFoldersPath); + + // Reset the internal state + resetTrustedFoldersForTesting(); + vi.clearAllMocks(); + delete process.env['GEMINI_CLI_TRUST_WORKSPACE']; + }); + + afterEach(() => { + // Clean up the temporary directory + fs.rmSync(tempDir, { recursive: true, force: true }); + vi.unstubAllEnvs(); + }); + + describe('Locking & Concurrency', () => { + it('setValue should handle concurrent calls correctly using real lockfile', async () => { + // Initialize the file + fs.writeFileSync(trustedFoldersPath, '{}', 'utf-8'); + + const loadedFolders = loadTrustedFolders(); + + // Start two concurrent calls + // These will race to acquire the lock on the real file system + const p1 = loadedFolders.setValue( + path.resolve('/path1'), + TrustLevel.TRUST_FOLDER, + ); + const p2 = loadedFolders.setValue( + path.resolve('/path2'), + TrustLevel.TRUST_FOLDER, + ); + + await Promise.all([p1, p2]); + + // Verify final state in the file + const content = fs.readFileSync(trustedFoldersPath, 'utf-8'); + const config = JSON.parse(content); + + expect(config).toEqual({ + [normalizePath('/path1')]: TrustLevel.TRUST_FOLDER, + [normalizePath('/path2')]: TrustLevel.TRUST_FOLDER, + }); + }); + }); + + describe('Loading & Parsing', () => { + it('should load empty rules if no files exist', () => { + const { rules, errors } = loadTrustedFolders(); + expect(rules).toEqual([]); + expect(errors).toEqual([]); + }); + + it('should load rules from the configuration file', () => { + const config = { + [normalizePath('/user/folder')]: TrustLevel.TRUST_FOLDER, + }; + fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); + + const { rules, errors } = loadTrustedFolders(); + expect(rules).toEqual([ + { + path: normalizePath('/user/folder'), + trustLevel: TrustLevel.TRUST_FOLDER, + }, + ]); + expect(errors).toEqual([]); + }); + + it('should handle JSON parsing errors gracefully', () => { + fs.writeFileSync(trustedFoldersPath, 'invalid json', 'utf-8'); + + const { rules, errors } = loadTrustedFolders(); + expect(rules).toEqual([]); + expect(errors.length).toBe(1); + expect(errors[0].path).toBe(trustedFoldersPath); + expect(errors[0].message).toContain('Unexpected token'); + }); + + it('should handle non-object JSON gracefully', () => { + fs.writeFileSync(trustedFoldersPath, 'null', 'utf-8'); + + const { rules, errors } = loadTrustedFolders(); + expect(rules).toEqual([]); + expect(errors.length).toBe(1); + expect(errors[0].message).toContain('not a valid JSON object'); + }); + + it('should handle invalid trust levels gracefully', () => { + const config = { + '/path': 'INVALID_LEVEL', + }; + fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); + + const { rules, errors } = loadTrustedFolders(); + expect(rules).toEqual([]); + expect(errors.length).toBe(1); + expect(errors[0].message).toContain( + 'Invalid trust level "INVALID_LEVEL"', + ); + }); + + it('should support JSON with comments', () => { + const content = ` + { + // This is a comment + "${normalizePath('/path').replaceAll('\\', '\\\\')}": "TRUST_FOLDER" + } + `; + fs.writeFileSync(trustedFoldersPath, content, 'utf-8'); + + const { rules, errors } = loadTrustedFolders(); + expect(rules).toEqual([ + { path: normalizePath('/path'), trustLevel: TrustLevel.TRUST_FOLDER }, + ]); + expect(errors).toEqual([]); + }); + }); + + describe('isPathTrusted', () => { + function setup(config: Record) { + fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); + return loadTrustedFolders(); + } + + it('provides a method to determine if a path is trusted', () => { + const folders = setup({ + './myfolder': TrustLevel.TRUST_FOLDER, + '/trustedparent/trustme': TrustLevel.TRUST_PARENT, + '/user/folder': TrustLevel.TRUST_FOLDER, + '/secret': TrustLevel.DO_NOT_TRUST, + '/secret/publickeys': TrustLevel.TRUST_FOLDER, + }); + + // We need to resolve relative paths for comparison since the implementation uses realpath + const resolvedMyFolder = path.resolve('./myfolder'); + + expect(folders.isPathTrusted('/secret')).toBe(false); + expect(folders.isPathTrusted('/user/folder')).toBe(true); + expect(folders.isPathTrusted('/secret/publickeys/public.pem')).toBe(true); + expect(folders.isPathTrusted('/user/folder/harhar')).toBe(true); + expect( + folders.isPathTrusted(path.join(resolvedMyFolder, 'somefile.jpg')), + ).toBe(true); + expect(folders.isPathTrusted('/trustedparent/someotherfolder')).toBe( + true, + ); + expect(folders.isPathTrusted('/trustedparent/trustme')).toBe(true); + + // No explicit rule covers this file + expect(folders.isPathTrusted('/secret/bankaccounts.json')).toBe(false); + expect(folders.isPathTrusted('/secret/mine/privatekey.pem')).toBe(false); + expect(folders.isPathTrusted('/user/someotherfolder')).toBe(undefined); + }); + + it('prioritizes the longest matching path (precedence)', () => { + const folders = setup({ + '/a': TrustLevel.TRUST_FOLDER, + '/a/b': TrustLevel.DO_NOT_TRUST, + '/a/b/c': TrustLevel.TRUST_FOLDER, + '/parent/trustme': TrustLevel.TRUST_PARENT, + '/parent/trustme/butnotthis': TrustLevel.DO_NOT_TRUST, + }); + + expect(folders.isPathTrusted('/a/b/c/d')).toBe(true); + expect(folders.isPathTrusted('/a/b/x')).toBe(false); + expect(folders.isPathTrusted('/a/x')).toBe(true); + expect(folders.isPathTrusted('/parent/trustme/butnotthis/file')).toBe( + false, + ); + expect(folders.isPathTrusted('/parent/other')).toBe(true); + }); + }); + + describe('setValue', () => { + it('should update the user config and save it atomically', async () => { + fs.writeFileSync(trustedFoldersPath, '{}', 'utf-8'); + const loadedFolders = loadTrustedFolders(); + + await loadedFolders.setValue( + normalizePath('/new/path'), + TrustLevel.TRUST_FOLDER, + ); + + expect(loadedFolders.user.config[normalizePath('/new/path')]).toBe( + TrustLevel.TRUST_FOLDER, + ); + + const content = fs.readFileSync(trustedFoldersPath, 'utf-8'); + const config = JSON.parse(content); + expect(config[normalizePath('/new/path')]).toBe(TrustLevel.TRUST_FOLDER); + }); + + it('should throw FatalConfigError if there were load errors', async () => { + fs.writeFileSync(trustedFoldersPath, 'invalid json', 'utf-8'); + + const loadedFolders = loadTrustedFolders(); + expect(loadedFolders.errors.length).toBe(1); + + await expect( + loadedFolders.setValue('/some/path', TrustLevel.TRUST_FOLDER), + ).rejects.toThrow(FatalConfigError); + }); + }); + + describe('isWorkspaceTrusted Integration', () => { + const mockSettings: Settings = { + security: { + folderTrust: { + enabled: true, + }, + }, + }; + + it('should return true for a directly trusted folder', () => { + const config = { '/projectA': TrustLevel.TRUST_FOLDER }; + fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); + + expect(isWorkspaceTrusted(mockSettings, '/projectA')).toEqual({ + isTrusted: true, + source: 'file', + }); + }); + + it('should return true for a child of a trusted folder', () => { + const config = { '/projectA': TrustLevel.TRUST_FOLDER }; + fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); + + expect(isWorkspaceTrusted(mockSettings, '/projectA/src')).toEqual({ + isTrusted: true, + source: 'file', + }); + }); + + it('should return true for a child of a trusted parent folder', () => { + const config = { '/projectB/somefile.txt': TrustLevel.TRUST_PARENT }; + fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); + + expect(isWorkspaceTrusted(mockSettings, '/projectB')).toEqual({ + isTrusted: true, + source: 'file', + }); + }); + + it('should return false for a directly untrusted folder', () => { + const config = { '/untrusted': TrustLevel.DO_NOT_TRUST }; + fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); + + expect(isWorkspaceTrusted(mockSettings, '/untrusted')).toEqual({ + isTrusted: false, + source: 'file', + }); + }); + + it('should return false for a child of an untrusted folder', () => { + const config = { '/untrusted': TrustLevel.DO_NOT_TRUST }; + fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); + + expect(isWorkspaceTrusted(mockSettings, '/untrusted/src').isTrusted).toBe( + false, + ); + }); + + it('should return undefined when no rules match', () => { + fs.writeFileSync(trustedFoldersPath, '{}', 'utf-8'); + expect( + isWorkspaceTrusted(mockSettings, '/other').isTrusted, + ).toBeUndefined(); + }); + + it('should prioritize specific distrust over parent trust', () => { + const config = { + '/projectA': TrustLevel.TRUST_FOLDER, + '/projectA/untrusted': TrustLevel.DO_NOT_TRUST, + }; + fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); + + expect(isWorkspaceTrusted(mockSettings, '/projectA/untrusted')).toEqual({ + isTrusted: false, + source: 'file', + }); + }); + + it('should use workspaceDir instead of process.cwd() when provided', () => { + const config = { + '/projectA': TrustLevel.TRUST_FOLDER, + '/untrusted': TrustLevel.DO_NOT_TRUST, + }; + fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); + + vi.spyOn(process, 'cwd').mockImplementation(() => '/untrusted'); + + // process.cwd() is untrusted, but workspaceDir is trusted + expect(isWorkspaceTrusted(mockSettings, '/projectA')).toEqual({ + isTrusted: true, + source: 'file', + }); + }); + + it('should handle path normalization', () => { + const config = { '/home/user/projectA': TrustLevel.TRUST_FOLDER }; + fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); + + expect( + isWorkspaceTrusted(mockSettings, '/home/user/../user/projectA'), + ).toEqual({ + isTrusted: true, + source: 'file', + }); + }); + + it('should prioritize IDE override over file config', () => { + const config = { '/projectA': TrustLevel.DO_NOT_TRUST }; + fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); + + ideContextStore.set({ workspaceState: { isTrusted: true } }); + + try { + expect(isWorkspaceTrusted(mockSettings, '/projectA')).toEqual({ + isTrusted: true, + source: 'ide', + }); + } finally { + ideContextStore.clear(); + } + }); + + it('should return false when IDE override is false', () => { + const config = { '/projectA': TrustLevel.TRUST_FOLDER }; + fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); + + ideContextStore.set({ workspaceState: { isTrusted: false } }); + + try { + expect(isWorkspaceTrusted(mockSettings, '/projectA')).toEqual({ + isTrusted: false, + source: 'ide', + }); + } finally { + ideContextStore.clear(); + } + }); + + it('should throw FatalConfigError when the config file is invalid', () => { + fs.writeFileSync(trustedFoldersPath, 'invalid json', 'utf-8'); + + expect(() => isWorkspaceTrusted(mockSettings, '/any')).toThrow( + FatalConfigError, + ); + }); + + it('should always return true if folderTrust setting is disabled', () => { + const disabledSettings: Settings = { + security: { folderTrust: { enabled: false } }, + }; + expect(isWorkspaceTrusted(disabledSettings, '/any')).toEqual({ + isTrusted: true, + source: undefined, + }); + }); + }); + + describe('isWorkspaceTrusted headless mode', () => { + const mockSettings: Settings = { + security: { + folderTrust: { + enabled: true, + }, + }, + }; + + it('should NOT return true when isHeadlessMode is true, ignoring config', async () => { + const geminiCore = await import('@google/gemini-cli-core'); + vi.spyOn(geminiCore, 'isHeadlessMode').mockReturnValue(true); + + expect(isWorkspaceTrusted(mockSettings)).toEqual({ + isTrusted: undefined, + source: undefined, + }); + }); + + it('should return true when GEMINI_CLI_TRUST_WORKSPACE is true', async () => { + process.env['GEMINI_CLI_TRUST_WORKSPACE'] = 'true'; + try { + expect(isWorkspaceTrusted(mockSettings)).toEqual({ + isTrusted: true, + source: 'env', + }); + } finally { + delete process.env['GEMINI_CLI_TRUST_WORKSPACE']; + } + }); + + it('should fall back to config when isHeadlessMode is false', async () => { + const geminiCore = await import('@google/gemini-cli-core'); + vi.spyOn(geminiCore, 'isHeadlessMode').mockReturnValue(false); + + const config = { '/projectA': TrustLevel.DO_NOT_TRUST }; + fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); + + expect(isWorkspaceTrusted(mockSettings, '/projectA').isTrusted).toBe( + false, + ); + }); + + it('should return undefined for isPathTrusted when isHeadlessMode is true', async () => { + const geminiCore = await import('@google/gemini-cli-core'); + vi.spyOn(geminiCore, 'isHeadlessMode').mockReturnValue(true); + + const folders = loadTrustedFolders(); + expect(folders.isPathTrusted('/any-untrusted-path')).toBe(undefined); + }); + }); + + describe('Trusted Folders Caching', () => { + it('should cache the loaded folders object', () => { + // First call should load and cache + const folders1 = loadTrustedFolders(); + + // Second call should return the same instance from cache + const folders2 = loadTrustedFolders(); + expect(folders1).toBe(folders2); + + // Resetting should clear the cache + resetTrustedFoldersForTesting(); + + // Third call should return a new instance + const folders3 = loadTrustedFolders(); + expect(folders3).not.toBe(folders1); + }); + }); + + describe('invalid trust levels', () => { + it('should create a comprehensive error message for invalid trust level', () => { + const config = { '/user/folder': 'INVALID_TRUST_LEVEL' }; + fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); + + const { errors } = loadTrustedFolders(); + const possibleValues = Object.values(TrustLevel).join(', '); + expect(errors.length).toBe(1); + expect(errors[0].message).toBe( + `Invalid trust level "INVALID_TRUST_LEVEL" for path "/user/folder". Possible values are: ${possibleValues}.`, + ); + }); + }); + + const itif = (condition: boolean) => (condition ? it : it.skip); + + describe('Symlinks Support', () => { + const mockSettings: Settings = { + security: { folderTrust: { enabled: true } }, + }; + + // TODO: issue 19387 - Enable symlink tests on Windows + itif(process.platform !== 'win32')( + 'should trust a folder if the rule matches the realpath', + () => { + // Create a real directory and a symlink + const realDir = path.join(tempDir, 'real'); + const symlinkDir = path.join(tempDir, 'symlink'); + fs.mkdirSync(realDir); + fs.symlinkSync(realDir, symlinkDir, 'dir'); + + // Rule uses realpath + const config = { [realDir]: TrustLevel.TRUST_FOLDER }; + fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); + + // Check against symlink path + expect(isWorkspaceTrusted(mockSettings, symlinkDir).isTrusted).toBe( + true, + ); + }, + ); + }); + + describe('Verification: Auth and Trust Interaction', () => { + it('should verify loadEnvironment returns early when untrusted', () => { + const untrustedDir = path.join(tempDir, 'untrusted'); + fs.mkdirSync(untrustedDir); + + const config = { [untrustedDir]: TrustLevel.DO_NOT_TRUST }; + fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); + + const envPath = path.join(untrustedDir, '.env'); + fs.writeFileSync(envPath, 'GEMINI_API_KEY=secret', 'utf-8'); + + vi.stubEnv('GEMINI_API_KEY', ''); + + const settings = createMockSettings({ + security: { folderTrust: { enabled: true } }, + }); + + loadEnvironment(settings.merged, untrustedDir); + + expect(process.env['GEMINI_API_KEY']).toBe(''); + + vi.unstubAllEnvs(); + }); + }); +}); diff --git a/packages/cli/src/config/trustedFolders.ts b/packages/cli/src/config/trustedFolders.ts new file mode 100644 index 0000000000000000000000000000000000000000..f901ed13db50c278aaefad04663afb176778c28c --- /dev/null +++ b/packages/cli/src/config/trustedFolders.ts @@ -0,0 +1,57 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + type HeadlessModeOptions, + checkPathTrust, + isHeadlessMode, + loadTrustedFolders as loadCoreTrustedFolders, + type LoadedTrustedFolders, +} from '@google/gemini-cli-core'; +import type { Settings } from './settings.js'; + +export { + TrustLevel, + isTrustLevel, + resetTrustedFoldersForTesting, + saveTrustedFolders, +} from '@google/gemini-cli-core'; + +export type { + TrustRule, + TrustedFoldersError, + TrustedFoldersFile, + TrustResult, + LoadedTrustedFolders, +} from '@google/gemini-cli-core'; + +/** Is folder trust feature enabled per the current applied settings */ +export function isFolderTrustEnabled(settings: Settings): boolean { + const folderTrustSetting = settings.security?.folderTrust?.enabled ?? true; + return folderTrustSetting; +} + +export function loadTrustedFolders(): LoadedTrustedFolders { + return loadCoreTrustedFolders(); +} + +/** + * Returns true or false if the workspace is considered "trusted". + */ +export function isWorkspaceTrusted( + settings: Settings, + workspaceDir: string = process.cwd(), + headlessOptions?: HeadlessModeOptions, +): { + isTrusted: boolean | undefined; + source: 'ide' | 'file' | 'env' | undefined; +} { + return checkPathTrust({ + path: workspaceDir, + isFolderTrustEnabled: isFolderTrustEnabled(settings), + isHeadless: isHeadlessMode(headlessOptions), + }); +} diff --git a/packages/cli/src/config/workspace-policy-cli.test.ts b/packages/cli/src/config/workspace-policy-cli.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..542c56130c76b24bc26b6da7e5267e6f8a787012 --- /dev/null +++ b/packages/cli/src/config/workspace-policy-cli.test.ts @@ -0,0 +1,296 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import * as path from 'node:path'; +import { loadCliConfig, type CliArgs } from './config.js'; +import { createTestMergedSettings } from './settings.js'; +import * as ServerConfig from '@google/gemini-cli-core'; +import { isWorkspaceTrusted } from './trustedFolders.js'; +import * as Policy from './policy.js'; + +// Mock dependencies +vi.mock('./trustedFolders.js', () => ({ + isWorkspaceTrusted: vi.fn(), +})); + +const mockCheckIntegrity = vi.fn(); +const mockAcceptIntegrity = vi.fn(); + +vi.mock('@google/gemini-cli-core', async () => { + const actual = await vi.importActual( + '@google/gemini-cli-core', + ); + return { + ...actual, + createPolicyEngineConfig: vi.fn().mockResolvedValue({ + rules: [], + checkers: [], + }), + getVersion: vi.fn().mockResolvedValue('test-version'), + PolicyIntegrityManager: vi.fn().mockImplementation(() => ({ + checkIntegrity: mockCheckIntegrity, + acceptIntegrity: mockAcceptIntegrity, + })), + IntegrityStatus: { MATCH: 'match', NEW: 'new', MISMATCH: 'mismatch' }, + debugLogger: { + warn: vi.fn(), + error: vi.fn(), + }, + isHeadlessMode: vi.fn().mockReturnValue(false), // Default to interactive + }; +}); + +describe('Workspace-Level Policy CLI Integration', () => { + const MOCK_CWD = process.cwd(); + + beforeEach(() => { + vi.clearAllMocks(); + Policy.setDisableWorkspacePolicies(false); + // Default to MATCH for existing tests + mockCheckIntegrity.mockResolvedValue({ + status: 'match', + hash: 'test-hash', + fileCount: 1, + }); + vi.mocked(ServerConfig.isHeadlessMode).mockReturnValue(false); + }); + + it('should have getWorkspacePoliciesDir on Storage class', () => { + const storage = new ServerConfig.Storage(MOCK_CWD); + expect(storage.getWorkspacePoliciesDir).toBeDefined(); + expect(typeof storage.getWorkspacePoliciesDir).toBe('function'); + }); + + it('should pass workspacePoliciesDir to createPolicyEngineConfig when folder is trusted', async () => { + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: true, + source: 'file', + }); + + const settings = createTestMergedSettings(); + const argv = { query: 'test' } as unknown as CliArgs; + + await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD }); + + expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( + expect.objectContaining({ + workspacePoliciesDir: expect.stringContaining( + path.join('.gemini', 'policies'), + ), + }), + expect.anything(), + undefined, + expect.anything(), + ); + }); + + it('should NOT pass workspacePoliciesDir to createPolicyEngineConfig when folder is NOT trusted', async () => { + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: false, + source: 'file', + }); + + const settings = createTestMergedSettings(); + const argv = { query: 'test' } as unknown as CliArgs; + + await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD }); + + expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( + expect.objectContaining({ + workspacePoliciesDir: undefined, + }), + expect.anything(), + undefined, + expect.anything(), + ); + }); + + it('should NOT pass workspacePoliciesDir if integrity is NEW but fileCount is 0', async () => { + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: true, + source: 'file', + }); + mockCheckIntegrity.mockResolvedValue({ + status: 'new', + hash: 'hash', + fileCount: 0, + }); + + const settings = createTestMergedSettings(); + const argv = { query: 'test' } as unknown as CliArgs; + + await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD }); + + expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( + expect.objectContaining({ + workspacePoliciesDir: undefined, + }), + expect.anything(), + undefined, + expect.anything(), + ); + }); + + it('should automatically accept and load workspacePoliciesDir if integrity MISMATCH in non-interactive mode', async () => { + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: true, + source: 'file', + }); + mockCheckIntegrity.mockResolvedValue({ + status: 'mismatch', + hash: 'new-hash', + fileCount: 1, + }); + vi.mocked(ServerConfig.isHeadlessMode).mockReturnValue(true); // Non-interactive + + const settings = createTestMergedSettings(); + const argv = { prompt: 'do something' } as unknown as CliArgs; + + await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD }); + + expect(mockAcceptIntegrity).toHaveBeenCalledWith( + 'workspace', + MOCK_CWD, + 'new-hash', + ); + expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( + expect.objectContaining({ + workspacePoliciesDir: expect.stringContaining( + path.join('.gemini', 'policies'), + ), + }), + expect.anything(), + undefined, + expect.anything(), + ); + }); + + it('should automatically accept and load workspacePoliciesDir if integrity MISMATCH in interactive mode when AUTO_ACCEPT is true', async () => { + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: true, + source: 'file', + }); + mockCheckIntegrity.mockResolvedValue({ + status: 'mismatch', + hash: 'new-hash', + fileCount: 1, + }); + vi.mocked(ServerConfig.isHeadlessMode).mockReturnValue(false); // Interactive + + const settings = createTestMergedSettings(); + const argv = { + query: 'test', + promptInteractive: 'test', + } as unknown as CliArgs; + + const config = await loadCliConfig(settings, 'test-session', argv, { + cwd: MOCK_CWD, + }); + + expect(config.getPolicyUpdateConfirmationRequest()).toBeUndefined(); + expect(mockAcceptIntegrity).toHaveBeenCalledWith( + 'workspace', + MOCK_CWD, + 'new-hash', + ); + expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( + expect.objectContaining({ + workspacePoliciesDir: expect.stringContaining( + path.join('.gemini', 'policies'), + ), + }), + expect.anything(), + undefined, + expect.anything(), + ); + }); + + it('should automatically accept and load workspacePoliciesDir if integrity is NEW in interactive mode when AUTO_ACCEPT is true', async () => { + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: true, + source: 'file', + }); + mockCheckIntegrity.mockResolvedValue({ + status: 'new', + hash: 'new-hash', + fileCount: 5, + }); + vi.mocked(ServerConfig.isHeadlessMode).mockReturnValue(false); // Interactive + + const settings = createTestMergedSettings(); + const argv = { query: 'test' } as unknown as CliArgs; + + const config = await loadCliConfig(settings, 'test-session', argv, { + cwd: MOCK_CWD, + }); + + expect(config.getPolicyUpdateConfirmationRequest()).toBeUndefined(); + expect(mockAcceptIntegrity).toHaveBeenCalledWith( + 'workspace', + MOCK_CWD, + 'new-hash', + ); + + expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( + expect.objectContaining({ + workspacePoliciesDir: expect.stringContaining( + path.join('.gemini', 'policies'), + ), + }), + expect.anything(), + undefined, + expect.anything(), + ); + }); + + it('should set policyUpdateConfirmationRequest if integrity MISMATCH in interactive mode when AUTO_ACCEPT is false', async () => { + // Monkey patch autoAcceptWorkspacePolicies using setter + const originalValue = Policy.autoAcceptWorkspacePolicies; + Policy.setAutoAcceptWorkspacePolicies(false); + + try { + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: true, + source: 'file', + }); + mockCheckIntegrity.mockResolvedValue({ + status: 'mismatch', + hash: 'new-hash', + fileCount: 1, + }); + vi.mocked(ServerConfig.isHeadlessMode).mockReturnValue(false); // Interactive + + const settings = createTestMergedSettings(); + const argv = { + query: 'test', + promptInteractive: 'test', + } as unknown as CliArgs; + + const config = await loadCliConfig(settings, 'test-session', argv, { + cwd: MOCK_CWD, + }); + + expect(config.getPolicyUpdateConfirmationRequest()).toEqual({ + scope: 'workspace', + identifier: MOCK_CWD, + policyDir: expect.stringContaining(path.join('.gemini', 'policies')), + newHash: 'new-hash', + }); + expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( + expect.objectContaining({ + workspacePoliciesDir: undefined, + }), + expect.anything(), + undefined, + expect.anything(), + ); + } finally { + // Restore for other tests + Policy.setAutoAcceptWorkspacePolicies(originalValue); + } + }); +}); diff --git a/packages/cli/src/core/auth.test.ts b/packages/cli/src/core/auth.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..639ed20a898b5e3b7e71a2deb6a6218ae7963a85 --- /dev/null +++ b/packages/cli/src/core/auth.test.ts @@ -0,0 +1,138 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { performInitialAuth } from './auth.js'; +import { + type Config, + ValidationRequiredError, + ProjectIdRequiredError, + AuthType, +} from '@google/gemini-cli-core'; + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + }; +}); + +describe('auth', () => { + let mockConfig: Config; + + beforeEach(() => { + mockConfig = { + refreshAuth: vi.fn(), + } as unknown as Config; + }); + + it('should return null if authType is undefined', async () => { + const result = await performInitialAuth(mockConfig, undefined); + expect(result).toEqual({ authError: null, accountSuspensionInfo: null }); + expect(mockConfig.refreshAuth).not.toHaveBeenCalled(); + }); + + it('should return null on successful auth', async () => { + const result = await performInitialAuth( + mockConfig, + AuthType.LOGIN_WITH_GOOGLE, + ); + expect(result).toEqual({ authError: null, accountSuspensionInfo: null }); + expect(mockConfig.refreshAuth).toHaveBeenCalledWith( + AuthType.LOGIN_WITH_GOOGLE, + ); + }); + + it('should return error message on failed auth', async () => { + const error = new Error('Authentication failed'); + vi.mocked(mockConfig.refreshAuth).mockRejectedValue(error); + const result = await performInitialAuth( + mockConfig, + AuthType.LOGIN_WITH_GOOGLE, + ); + expect(result).toEqual({ + authError: 'Failed to sign in. Message: Authentication failed', + accountSuspensionInfo: null, + }); + expect(mockConfig.refreshAuth).toHaveBeenCalledWith( + AuthType.LOGIN_WITH_GOOGLE, + ); + }); + + it('should return null if refreshAuth throws ValidationRequiredError', async () => { + vi.mocked(mockConfig.refreshAuth).mockRejectedValue( + new ValidationRequiredError('Validation required'), + ); + const result = await performInitialAuth( + mockConfig, + AuthType.LOGIN_WITH_GOOGLE, + ); + expect(result).toEqual({ authError: null, accountSuspensionInfo: null }); + expect(mockConfig.refreshAuth).toHaveBeenCalledWith( + AuthType.LOGIN_WITH_GOOGLE, + ); + }); + + it('should return accountSuspensionInfo for 403 TOS_VIOLATION error', async () => { + vi.mocked(mockConfig.refreshAuth).mockRejectedValue({ + response: { + data: { + error: { + code: 403, + message: + 'This service has been disabled for violation of Terms of Service.', + details: [ + { + '@type': 'type.googleapis.com/google.rpc.ErrorInfo', + reason: 'TOS_VIOLATION', + domain: 'example.googleapis.com', + metadata: { + appeal_url: 'https://example.com/appeal', + appeal_url_link_text: 'Appeal Here', + }, + }, + ], + }, + }, + }, + }); + const result = await performInitialAuth( + mockConfig, + AuthType.LOGIN_WITH_GOOGLE, + ); + expect(result).toEqual({ + authError: null, + accountSuspensionInfo: { + message: + 'This service has been disabled for violation of Terms of Service.', + appealUrl: 'https://example.com/appeal', + appealLinkText: 'Appeal Here', + }, + }); + expect(mockConfig.refreshAuth).toHaveBeenCalledWith( + AuthType.LOGIN_WITH_GOOGLE, + ); + }); + + it('should return ProjectIdRequiredError message without "Failed to login" prefix', async () => { + const projectIdError = new ProjectIdRequiredError(); + vi.mocked(mockConfig.refreshAuth).mockRejectedValue(projectIdError); + const result = await performInitialAuth( + mockConfig, + AuthType.LOGIN_WITH_GOOGLE, + ); + expect(result).toEqual({ + authError: + 'This account requires setting the GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID env var. See https://goo.gle/gemini-cli-auth-docs#workspace-gca', + accountSuspensionInfo: null, + }); + expect(result.authError).not.toContain('Failed to login'); + expect(mockConfig.refreshAuth).toHaveBeenCalledWith( + AuthType.LOGIN_WITH_GOOGLE, + ); + }); +}); diff --git a/packages/cli/src/core/auth.ts b/packages/cli/src/core/auth.ts new file mode 100644 index 0000000000000000000000000000000000000000..0bc89f5bdac0d397c176e40befbcdb029c9d4f36 --- /dev/null +++ b/packages/cli/src/core/auth.ts @@ -0,0 +1,73 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + type AuthType, + type Config, + getErrorMessage, + ValidationRequiredError, + isAccountSuspendedError, + ProjectIdRequiredError, +} from '@google/gemini-cli-core'; + +import type { AccountSuspensionInfo } from '../ui/contexts/UIStateContext.js'; + +export interface InitialAuthResult { + authError: string | null; + accountSuspensionInfo: AccountSuspensionInfo | null; +} + +/** + * Handles the initial authentication flow. + * @param config The application config. + * @param authType The selected auth type. + * @returns The auth result with error message and account suspension status. + */ +export async function performInitialAuth( + config: Config, + authType: AuthType | undefined, +): Promise { + if (!authType) { + return { authError: null, accountSuspensionInfo: null }; + } + + try { + await config.refreshAuth(authType); + // The console.log is intentionally left out here. + // We can add a dedicated startup message later if needed. + } catch (e) { + if (e instanceof ValidationRequiredError) { + // Don't treat validation required as a fatal auth error during startup. + // This allows the React UI to load and show the ValidationDialog. + return { authError: null, accountSuspensionInfo: null }; + } + const suspendedError = isAccountSuspendedError(e); + if (suspendedError) { + return { + authError: null, + accountSuspensionInfo: { + message: suspendedError.message, + appealUrl: suspendedError.appealUrl, + appealLinkText: suspendedError.appealLinkText, + }, + }; + } + if (e instanceof ProjectIdRequiredError) { + // OAuth succeeded but account setup requires project ID + // Show the error message directly without "Failed to login" prefix + return { + authError: getErrorMessage(e), + accountSuspensionInfo: null, + }; + } + return { + authError: `Failed to sign in. Message: ${getErrorMessage(e)}`, + accountSuspensionInfo: null, + }; + } + + return { authError: null, accountSuspensionInfo: null }; +} diff --git a/packages/cli/src/core/initializer.test.ts b/packages/cli/src/core/initializer.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..9093ad54eec4056256f5f3918e773555418c6d4c --- /dev/null +++ b/packages/cli/src/core/initializer.test.ts @@ -0,0 +1,159 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { initializeApp } from './initializer.js'; +import { + IdeClient, + logIdeConnection, + logCliConfiguration, + type Config, +} from '@google/gemini-cli-core'; +import { performInitialAuth } from './auth.js'; +import { validateTheme } from './theme.js'; +import { type LoadedSettings } from '../config/settings.js'; + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + IdeClient: { + getInstance: vi.fn(), + }, + logIdeConnection: vi.fn(), + logCliConfiguration: vi.fn(), + StartSessionEvent: vi.fn(), + IdeConnectionEvent: vi.fn(), + }; +}); + +vi.mock('./auth.js', () => ({ + performInitialAuth: vi.fn(), +})); + +vi.mock('./theme.js', () => ({ + validateTheme: vi.fn(), +})); + +describe('initializer', () => { + let mockConfig: { + getToolRegistry: ReturnType; + getIdeMode: ReturnType; + getGeminiMdFileCount: ReturnType; + }; + let mockSettings: LoadedSettings; + let mockIdeClient: { + connect: ReturnType; + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockConfig = { + getToolRegistry: vi.fn(), + getIdeMode: vi.fn().mockReturnValue(false), + getGeminiMdFileCount: vi.fn().mockReturnValue(5), + }; + mockSettings = { + merged: { + security: { + auth: { + selectedType: 'oauth', + }, + }, + }, + } as unknown as LoadedSettings; + mockIdeClient = { + connect: vi.fn(), + }; + vi.mocked(IdeClient.getInstance).mockResolvedValue( + mockIdeClient as unknown as IdeClient, + ); + vi.mocked(performInitialAuth).mockResolvedValue({ + authError: null, + accountSuspensionInfo: null, + }); + vi.mocked(validateTheme).mockReturnValue(null); + }); + + it('should initialize correctly in non-IDE mode', async () => { + const result = await initializeApp( + mockConfig as unknown as Config, + mockSettings, + ); + + expect(result).toEqual({ + authError: null, + accountSuspensionInfo: null, + themeError: null, + shouldOpenAuthDialog: false, + geminiMdFileCount: 5, + }); + expect(performInitialAuth).toHaveBeenCalledWith(mockConfig, 'oauth'); + expect(validateTheme).toHaveBeenCalledWith(mockSettings); + expect(logCliConfiguration).toHaveBeenCalled(); + expect(IdeClient.getInstance).not.toHaveBeenCalled(); + }); + + it('should initialize correctly in IDE mode', async () => { + mockConfig.getIdeMode.mockReturnValue(true); + const result = await initializeApp( + mockConfig as unknown as Config, + mockSettings, + ); + + // Wait for the background promise to resolve + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(result).toEqual({ + authError: null, + accountSuspensionInfo: null, + themeError: null, + shouldOpenAuthDialog: false, + geminiMdFileCount: 5, + }); + expect(IdeClient.getInstance).toHaveBeenCalled(); + expect(mockIdeClient.connect).toHaveBeenCalled(); + expect(logIdeConnection).toHaveBeenCalledWith( + mockConfig as unknown as Config, + expect.any(Object), + ); + }); + + it('should handle auth error', async () => { + vi.mocked(performInitialAuth).mockResolvedValue({ + authError: 'Auth failed', + accountSuspensionInfo: null, + }); + const result = await initializeApp( + mockConfig as unknown as Config, + mockSettings, + ); + + expect(result.authError).toBe('Auth failed'); + expect(result.shouldOpenAuthDialog).toBe(true); + }); + + it('should handle undefined auth type', async () => { + mockSettings.merged.security.auth.selectedType = undefined; + const result = await initializeApp( + mockConfig as unknown as Config, + mockSettings, + ); + + expect(result.shouldOpenAuthDialog).toBe(true); + }); + + it('should handle theme error', async () => { + vi.mocked(validateTheme).mockReturnValue('Theme not found'); + const result = await initializeApp( + mockConfig as unknown as Config, + mockSettings, + ); + + expect(result.themeError).toBe('Theme not found'); + }); +}); diff --git a/packages/cli/src/core/initializer.ts b/packages/cli/src/core/initializer.ts new file mode 100644 index 0000000000000000000000000000000000000000..607129ae3e8ab80e1c9b3dd9b113066535696390 --- /dev/null +++ b/packages/cli/src/core/initializer.ts @@ -0,0 +1,80 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + IdeClient, + IdeConnectionEvent, + IdeConnectionType, + logIdeConnection, + type Config, + StartSessionEvent, + logCliConfiguration, + startupProfiler, + debugLogger, +} from '@google/gemini-cli-core'; +import { type LoadedSettings } from '../config/settings.js'; +import { performInitialAuth } from './auth.js'; +import { validateTheme } from './theme.js'; +import type { AccountSuspensionInfo } from '../ui/contexts/UIStateContext.js'; + +export interface InitializationResult { + authError: string | null; + accountSuspensionInfo: AccountSuspensionInfo | null; + themeError: string | null; + shouldOpenAuthDialog: boolean; + geminiMdFileCount: number; +} + +/** + * Orchestrates the application's startup initialization. + * This runs BEFORE the React UI is rendered. + * @param config The application config. + * @param settings The loaded application settings. + * @returns The results of the initialization. + */ +export async function initializeApp( + config: Config, + settings: LoadedSettings, +): Promise { + const authHandle = startupProfiler.start('authenticate'); + const { authError, accountSuspensionInfo } = await performInitialAuth( + config, + settings.merged.security.auth.selectedType, + ); + authHandle?.end(); + const themeError = validateTheme(settings); + + const shouldOpenAuthDialog = + settings.merged.security.auth.selectedType === undefined || !!authError; + + logCliConfiguration( + config, + new StartSessionEvent(config, config.getToolRegistry()), + ); + + if (config.getIdeMode()) { + IdeClient.getInstance() + .then(async (ideClient) => { + await ideClient.connect(); + logIdeConnection( + config, + new IdeConnectionEvent(IdeConnectionType.START), + ); + }) + .catch((e) => { + // We log locally if IDE connection setup fails in the background. + debugLogger.error('Failed to initialize IDE client:', e); + }); + } + + return { + authError, + accountSuspensionInfo, + themeError, + shouldOpenAuthDialog, + geminiMdFileCount: config.getGeminiMdFileCount(), + }; +} diff --git a/packages/cli/src/core/theme.test.ts b/packages/cli/src/core/theme.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..eb87a9ee10d681d1d05fc3ccc9b41c6f79919035 --- /dev/null +++ b/packages/cli/src/core/theme.test.ts @@ -0,0 +1,54 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { validateTheme } from './theme.js'; +import { themeManager } from '../ui/themes/theme-manager.js'; +import { type LoadedSettings } from '../config/settings.js'; + +vi.mock('../ui/themes/theme-manager.js', () => ({ + themeManager: { + findThemeByName: vi.fn(), + }, +})); + +describe('theme', () => { + let mockSettings: LoadedSettings; + + beforeEach(() => { + vi.clearAllMocks(); + mockSettings = { + merged: { + ui: { + theme: 'test-theme', + }, + }, + } as unknown as LoadedSettings; + }); + + it('should return null if theme is found', () => { + vi.mocked(themeManager.findThemeByName).mockReturnValue( + {} as unknown as ReturnType, + ); + const result = validateTheme(mockSettings); + expect(result).toBeNull(); + expect(themeManager.findThemeByName).toHaveBeenCalledWith('test-theme'); + }); + + it('should return error message if theme is not found', () => { + vi.mocked(themeManager.findThemeByName).mockReturnValue(undefined); + const result = validateTheme(mockSettings); + expect(result).toBe('Theme "test-theme" not found.'); + expect(themeManager.findThemeByName).toHaveBeenCalledWith('test-theme'); + }); + + it('should return null if theme is undefined', () => { + mockSettings.merged.ui.theme = undefined; + const result = validateTheme(mockSettings); + expect(result).toBeNull(); + expect(themeManager.findThemeByName).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/core/theme.ts b/packages/cli/src/core/theme.ts new file mode 100644 index 0000000000000000000000000000000000000000..f0f58fdbba4be72fa7665259f4bc8d678c165f21 --- /dev/null +++ b/packages/cli/src/core/theme.ts @@ -0,0 +1,21 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { themeManager } from '../ui/themes/theme-manager.js'; +import { type LoadedSettings } from '../config/settings.js'; + +/** + * Validates the configured theme. + * @param settings The loaded application settings. + * @returns An error message if the theme is not found, otherwise null. + */ +export function validateTheme(settings: LoadedSettings): string | null { + const effectiveTheme = settings.merged.ui.theme; + if (effectiveTheme && !themeManager.findThemeByName(effectiveTheme)) { + return `Theme "${effectiveTheme}" not found.`; + } + return null; +} diff --git a/packages/cli/src/integration-tests/modelSteering.test.tsx b/packages/cli/src/integration-tests/modelSteering.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..80640045a054bc4845220a7ab57a99edd00969ae --- /dev/null +++ b/packages/cli/src/integration-tests/modelSteering.test.tsx @@ -0,0 +1,80 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, afterEach } from 'vitest'; +import { AppRig } from '../test-utils/AppRig.js'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { PolicyDecision } from '@google/gemini-cli-core'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +describe('Model Steering Integration', () => { + let rig: AppRig | undefined; + + afterEach(async () => { + await rig?.unmount(); + }); + + it('should steer the model using a hint during a tool turn', async () => { + const fakeResponsesPath = path.join( + __dirname, + '../test-utils/fixtures/steering.responses', + ); + rig = new AppRig({ + fakeResponsesPath, + configOverrides: { modelSteering: true }, + }); + await rig.initialize(); + await rig.render(); + await rig.waitForIdle(); + + rig.setToolPolicy('list_directory', PolicyDecision.ASK_USER); + rig.setToolPolicy('read_file', PolicyDecision.ASK_USER); + + rig.setMockCommands([ + { + command: /list_directory/, + result: { + output: 'file1.txt\nfile2.js\nfile3.md', + exitCode: 0, + }, + }, + { + command: /read_file file1.txt/, + result: { + output: 'This is file1.txt content.', + exitCode: 0, + }, + }, + ]); + + // Start a long task + await rig.type('Start long task'); + await rig.pressEnter(); + + // Wait for the model to call 'list_directory' (Confirming state) + await rig.waitForOutput('ReadFolder'); + + // Injected a hint while the model is in a tool turn + await rig.addUserHint('focus on .txt'); + + // Resolve list_directory (Proceed) + await rig.resolveTool('ReadFolder'); + + // Then it should proceed with the next action + await rig.waitForOutput( + /Since you want me to focus on \.txt[\s\S]*files,[\s\S]*I will read file1\.txt/, + ); + await rig.waitForOutput('ReadFile'); + + // Resolve read_file (Proceed) + await rig.resolveTool('ReadFile'); + + // Wait for final completion + await rig.waitForOutput('Task complete.'); + }); +}); diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts new file mode 100644 index 0000000000000000000000000000000000000000..ce3092b81f9cccce3641077dd32db249e254780b --- /dev/null +++ b/packages/cli/src/nonInteractiveCli.ts @@ -0,0 +1,636 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + Config, + ToolCallRequestInfo, + ResumedSessionData, + UserFeedbackPayload, +} from '@google/gemini-cli-core'; +import { isSlashCommand } from './ui/utils/commandUtils.js'; +import type { LoadedSettings } from './config/settings.js'; +import { + convertSessionToClientHistory, + GeminiEventType, + FatalInputError, + promptIdContext, + OutputFormat, + JsonFormatter, + StreamJsonFormatter, + JsonStreamEventType, + uiTelemetryService, + debugLogger, + coreEvents, + CoreEvent, + createWorkingStdio, + recordToolCallInteractions, + ToolErrorType, + Scheduler, + ROOT_SCHEDULER_ID, + THINKING_ONLY_COMPRESS_SUGGESTION, + MAX_TOKENS_EXCEEDED_SUGGESTION, + SAFETY_BLOCKED_MESSAGE, + RECITATION_BLOCKED_MESSAGE, + OTHER_BLOCKED_MESSAGE, + TRUE_EMPTY_RESPONSE_MESSAGE, +} from '@google/gemini-cli-core'; + +import type { Content, Part } from '@google/genai'; +import readline from 'node:readline'; +import stripAnsi from 'strip-ansi'; + +import { handleSlashCommand } from './nonInteractiveCliCommands.js'; +import { ConsolePatcher } from './ui/utils/ConsolePatcher.js'; +import { handleAtCommand } from './ui/hooks/atCommandProcessor.js'; +import { + handleError, + handleToolError, + handleCancellationError, + handleMaxTurnsExceededError, +} from './utils/errors.js'; +import { TextOutput } from './ui/utils/textOutput.js'; +import { runNonInteractive as runNonInteractiveAgentSession } from './nonInteractiveCliAgentSession.js'; + +interface RunNonInteractiveParams { + config: Config; + settings: LoadedSettings; + input: string; + prompt_id: string; + resumedSessionData?: ResumedSessionData; +} + +/** + * Runs the non-interactive CLI loop. + * + * Programmatic output formats (JSON, STREAM_JSON) use lenient sanitization + * by stripping ANSI escape sequences from messages to ensure clean, + * parseable output for downstream consumers. + */ +export async function runNonInteractive( + params: RunNonInteractiveParams, +): Promise { + const useAgentSession = params.config.getAgentSessionNoninteractiveEnabled(); + if (useAgentSession) { + debugLogger.debug( + '[ADK] Running non-interactive mode with ADK agent session', + ); + return runNonInteractiveAgentSession(params); + } + + const { config, settings, input, prompt_id, resumedSessionData } = params; + + return promptIdContext.run(prompt_id, async () => { + const consolePatcher = new ConsolePatcher({ + stderr: true, + interactive: false, + debugMode: config.getDebugMode(), + onNewMessage: (msg) => { + coreEvents.emitConsoleLog(msg.type, msg.content); + }, + }); + + if (process.env['GEMINI_CLI_ACTIVITY_LOG_TARGET']) { + const { setupInitialActivityLogger } = await import( + './utils/devtoolsService.js' + ); + setupInitialActivityLogger(config); + } + + const { stdout: workingStdout } = createWorkingStdio(); + const textOutput = new TextOutput(workingStdout); + + const handleUserFeedback = (payload: UserFeedbackPayload) => { + const prefix = payload.severity.toUpperCase(); + process.stderr.write(`[${prefix}] ${payload.message}\n`); + if (payload.error && config.getDebugMode()) { + const errorToLog = + payload.error instanceof Error + ? payload.error.stack || payload.error.message + : String(payload.error); + process.stderr.write(`${errorToLog}\n`); + } + }; + + const startTime = Date.now(); + const streamFormatter = + config.getOutputFormat() === OutputFormat.STREAM_JSON + ? new StreamJsonFormatter() + : null; + + const abortController = new AbortController(); + + // Track cancellation state + let isAborting = false; + let cancelMessageTimer: NodeJS.Timeout | null = null; + + // Setup stdin listener for Ctrl+C detection + let stdinWasRaw = false; + let rl: readline.Interface | null = null; + + const setupStdinCancellation = () => { + // Only setup if stdin is a TTY (user can interact) + if (!process.stdin.isTTY) { + return; + } + + // Save original raw mode state + stdinWasRaw = process.stdin.isRaw || false; + + // Enable raw mode to capture individual keypresses + process.stdin.setRawMode(true); + process.stdin.resume(); + + // Setup readline to emit keypress events + rl = readline.createInterface({ + input: process.stdin, + escapeCodeTimeout: 0, + }); + readline.emitKeypressEvents(process.stdin, rl); + + // Listen for Ctrl+C + const keypressHandler = ( + str: string, + key: { name?: string; ctrl?: boolean }, + ) => { + // Detect Ctrl+C: either ctrl+c key combo or raw character code 3 + if ((key && key.ctrl && key.name === 'c') || str === '\u0003') { + // Only handle once + if (isAborting) { + return; + } + + isAborting = true; + + // Only show message if cancellation takes longer than 200ms + // This reduces verbosity for fast cancellations + cancelMessageTimer = setTimeout(() => { + process.stderr.write('\nCancelling...\n'); + }, 200); + + abortController.abort(); + // Note: Don't exit here - let the abort flow through the system + // and trigger handleCancellationError() which will exit with proper code + } + }; + + process.stdin.on('keypress', keypressHandler); + }; + + const cleanupStdinCancellation = () => { + // Clear any pending cancel message timer + if (cancelMessageTimer) { + clearTimeout(cancelMessageTimer); + cancelMessageTimer = null; + } + + // Cleanup readline and stdin listeners + if (rl) { + rl.close(); + rl = null; + } + + // Remove keypress listener + process.stdin.removeAllListeners('keypress'); + + // Restore stdin to original state + if (process.stdin.isTTY) { + process.stdin.setRawMode(stdinWasRaw); + process.stdin.pause(); + } + }; + + let errorToHandle: unknown | undefined; + let scheduler: Scheduler | undefined; + try { + consolePatcher.patch(); + + if ( + config.getRawOutput() && + !config.getAcceptRawOutputRisk() && + config.getOutputFormat() === OutputFormat.TEXT + ) { + process.stderr.write( + '[WARNING] --raw-output is enabled. Model output is not sanitized and may contain harmful ANSI sequences (e.g. for phishing or command injection). Use --accept-raw-output-risk to suppress this warning.\n', + ); + } + + // Setup stdin cancellation listener + setupStdinCancellation(); + + coreEvents.on(CoreEvent.UserFeedback, handleUserFeedback); + coreEvents.drainBacklogs(); + + // Handle EPIPE errors when the output is piped to a command that closes early. + process.stdout.on('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'EPIPE') { + // Exit gracefully if the pipe is closed. + process.exit(0); + } + }); + + const geminiClient = config.getGeminiClient(); + scheduler = new Scheduler({ + context: config, + messageBus: config.getMessageBus(), + getPreferredEditor: () => undefined, + schedulerId: ROOT_SCHEDULER_ID, + }); + + // Initialize chat. Resume if resume data is passed. + if (resumedSessionData) { + await geminiClient.resumeChat( + convertSessionToClientHistory( + resumedSessionData.conversation.messages, + ), + resumedSessionData, + ); + } + + // Emit init event for streaming JSON + if (streamFormatter) { + streamFormatter.emitEvent({ + type: JsonStreamEventType.INIT, + timestamp: new Date().toISOString(), + session_id: config.getSessionId(), + model: config.getModel(), + }); + } + + let query: Part[] | undefined; + + if (isSlashCommand(input)) { + const slashCommandResult = await handleSlashCommand( + input, + abortController, + config, + settings, + ); + // If a slash command is found and returns a prompt, use it. + // Otherwise, slashCommandResult falls through to the default prompt + // handling. + if (slashCommandResult) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + query = slashCommandResult as Part[]; + } + } + + if (!query) { + const { processedQuery, error } = await handleAtCommand({ + query: input, + config, + addItem: (_item, _timestamp) => 0, + onDebugMessage: () => {}, + messageId: Date.now(), + signal: abortController.signal, + escapePastedAtSymbols: false, + }); + if (error || !processedQuery) { + // An error occurred during @include processing (e.g., file not found). + // The error message is already logged by handleAtCommand. + throw new FatalInputError( + error || 'Exiting due to an error processing the @ command.', + ); + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + query = processedQuery as Part[]; + } + + // Emit user message event for streaming JSON + if (streamFormatter) { + streamFormatter.emitEvent({ + type: JsonStreamEventType.MESSAGE, + timestamp: new Date().toISOString(), + role: 'user', + content: input, + }); + } + + let currentMessages: Content[] = [{ role: 'user', parts: query }]; + + let turnCount = 0; + let invalidStreamError: string | undefined; + const warnings: string[] = []; + while (true) { + turnCount++; + if ( + config.getMaxSessionTurns() >= 0 && + turnCount > config.getMaxSessionTurns() + ) { + handleMaxTurnsExceededError(config); + } + const toolCallRequests: ToolCallRequestInfo[] = []; + + const responseStream = geminiClient.sendMessageStream( + currentMessages[0]?.parts || [], + abortController.signal, + prompt_id, + undefined, + turnCount === 1 ? input : undefined, + ); + + let responseText = ''; + for await (const event of responseStream) { + if (abortController.signal.aborted) { + handleCancellationError(config); + } + + if (event.type === GeminiEventType.Content) { + const isRaw = + config.getRawOutput() || config.getAcceptRawOutputRisk(); + const output = isRaw ? event.value : stripAnsi(event.value); + if (streamFormatter) { + streamFormatter.emitEvent({ + type: JsonStreamEventType.MESSAGE, + timestamp: new Date().toISOString(), + role: 'assistant', + content: output, + delta: true, + }); + } else if (config.getOutputFormat() === OutputFormat.JSON) { + responseText += output; + } else { + if (event.value) { + textOutput.write(output); + } + } + } else if (event.type === GeminiEventType.ToolCallRequest) { + if (streamFormatter) { + streamFormatter.emitEvent({ + type: JsonStreamEventType.TOOL_USE, + timestamp: new Date().toISOString(), + tool_name: event.value.name, + tool_id: event.value.callId, + parameters: event.value.args, + }); + } + toolCallRequests.push(event.value); + } else if (event.type === GeminiEventType.LoopDetected) { + const message = 'Loop detected, stopping execution'; + if (streamFormatter) { + streamFormatter.emitEvent({ + type: JsonStreamEventType.ERROR, + timestamp: new Date().toISOString(), + severity: 'warning', + message, + }); + } + warnings.push(message); + } else if (event.type === GeminiEventType.MaxSessionTurns) { + const message = 'Maximum session turns exceeded'; + if (streamFormatter) { + streamFormatter.emitEvent({ + type: JsonStreamEventType.ERROR, + timestamp: new Date().toISOString(), + severity: 'error', + message, + }); + } + warnings.push(message); + } else if (event.type === GeminiEventType.Error) { + throw event.value.error; + } else if (event.type === GeminiEventType.AgentExecutionStopped) { + const stopMessage = `Agent execution stopped: ${event.value.systemMessage?.trim() || event.value.reason}`; + if (config.getOutputFormat() === OutputFormat.TEXT) { + process.stderr.write(`${stopMessage}\n`); + } + // Emit final result event for streaming JSON if needed + if (streamFormatter) { + const metrics = uiTelemetryService.getMetrics(); + const durationMs = Date.now() - startTime; + streamFormatter.emitEvent({ + type: JsonStreamEventType.RESULT, + timestamp: new Date().toISOString(), + status: 'success', + stats: streamFormatter.convertToStreamStats( + metrics, + durationMs, + ), + }); + } else if (config.getOutputFormat() === OutputFormat.JSON) { + const formatter = new JsonFormatter(); + const stats = uiTelemetryService.getMetrics(); + textOutput.write( + formatter.format( + config.getSessionId(), + responseText, + stats, + undefined, + [...warnings, stopMessage], + ), + ); + } else { + textOutput.ensureTrailingNewline(); // Ensure a final newline + } + return; + } else if (event.type === GeminiEventType.AgentExecutionBlocked) { + const blockMessage = `Agent execution blocked: ${event.value.systemMessage?.trim() || event.value.reason}`; + if (config.getOutputFormat() === OutputFormat.TEXT) { + process.stderr.write(`[WARNING] ${blockMessage}\n`); + } else if (streamFormatter) { + streamFormatter.emitEvent({ + type: JsonStreamEventType.ERROR, + timestamp: new Date().toISOString(), + severity: 'warning', + message: stripAnsi(blockMessage), + }); + } + warnings.push(blockMessage); + } else if (event.type === GeminiEventType.InvalidStream) { + const eventValue = event.value; + if (eventValue?.type === 'NO_RESPONSE_TEXT') { + invalidStreamError = TRUE_EMPTY_RESPONSE_MESSAGE; + } else if (eventValue?.type === 'THINKING_ONLY_RESPONSE') { + invalidStreamError = THINKING_ONLY_COMPRESS_SUGGESTION; + } else if (eventValue?.type === 'MAX_TOKENS_EXCEEDED') { + invalidStreamError = MAX_TOKENS_EXCEEDED_SUGGESTION; + } else if (eventValue?.type === 'SAFETY_BLOCKED') { + invalidStreamError = SAFETY_BLOCKED_MESSAGE; + } else if (eventValue?.type === 'RECITATION_BLOCKED') { + invalidStreamError = RECITATION_BLOCKED_MESSAGE; + } else if (eventValue?.type === 'OTHER_BLOCKED') { + invalidStreamError = OTHER_BLOCKED_MESSAGE; + } else { + invalidStreamError = + eventValue?.message?.trim() || + 'Invalid stream: The model returned an empty response or malformed tool call.'; + } + + // Log semantic error telemetry without double-counting requests + uiTelemetryService.recordSemanticValidationError( + geminiClient.getCurrentSequenceModel() ?? config.getModel(), + eventValue?.type || 'INVALID_STREAM', + ); + + if (streamFormatter) { + streamFormatter.emitEvent({ + type: JsonStreamEventType.ERROR, + timestamp: new Date().toISOString(), + severity: 'error', + message: invalidStreamError, + }); + } else if (config.getOutputFormat() === OutputFormat.TEXT) { + process.stderr.write(`[ERROR] ${invalidStreamError}\n`); + } + toolCallRequests.length = 0; + break; + } + } + + if (toolCallRequests.length > 0) { + textOutput.ensureTrailingNewline(); + const completedToolCalls = await scheduler.schedule( + toolCallRequests, + abortController.signal, + ); + const toolResponseParts: Part[] = []; + + for (const completedToolCall of completedToolCalls) { + const toolResponse = completedToolCall.response; + const requestInfo = completedToolCall.request; + + if (streamFormatter) { + streamFormatter.emitEvent({ + type: JsonStreamEventType.TOOL_RESULT, + timestamp: new Date().toISOString(), + tool_id: requestInfo.callId, + status: + completedToolCall.status === 'error' ? 'error' : 'success', + output: + typeof toolResponse.resultDisplay === 'string' + ? toolResponse.resultDisplay + : undefined, + error: toolResponse.error + ? { + type: toolResponse.errorType || 'TOOL_EXECUTION_ERROR', + message: toolResponse.error.message, + } + : undefined, + }); + } + + if (toolResponse.error) { + handleToolError( + requestInfo.name, + toolResponse.error, + config, + toolResponse.errorType || 'TOOL_EXECUTION_ERROR', + typeof toolResponse.resultDisplay === 'string' + ? toolResponse.resultDisplay + : undefined, + ); + } + + if (toolResponse.responseParts) { + toolResponseParts.push(...toolResponse.responseParts); + } + } + + // Record tool calls with full metadata before sending responses to Gemini + try { + const currentModel = + geminiClient.getCurrentSequenceModel() ?? config.getModel(); + geminiClient + .getChat() + .recordCompletedToolCalls(currentModel, completedToolCalls); + + await recordToolCallInteractions(config, completedToolCalls); + } catch (error) { + debugLogger.error( + `Error recording completed tool call information: ${error}`, + ); + } + + // Check if any tool requested to stop execution immediately + const stopExecutionTool = completedToolCalls.find( + (tc) => tc.response.errorType === ToolErrorType.STOP_EXECUTION, + ); + + if (stopExecutionTool && stopExecutionTool.response.error) { + const stopMessage = `Agent execution stopped: ${stopExecutionTool.response.error.message}`; + + if (config.getOutputFormat() === OutputFormat.TEXT) { + process.stderr.write(`${stopMessage}\n`); + } + + // Emit final result event for streaming JSON + if (streamFormatter) { + const metrics = uiTelemetryService.getMetrics(); + const durationMs = Date.now() - startTime; + streamFormatter.emitEvent({ + type: JsonStreamEventType.RESULT, + timestamp: new Date().toISOString(), + status: 'success', + stats: streamFormatter.convertToStreamStats( + metrics, + durationMs, + ), + }); + } else if (config.getOutputFormat() === OutputFormat.JSON) { + const formatter = new JsonFormatter(); + const stats = uiTelemetryService.getMetrics(); + textOutput.write( + formatter.format( + config.getSessionId(), + responseText, + stats, + undefined, + warnings, + ), + ); + } else { + textOutput.ensureTrailingNewline(); // Ensure a final newline + } + return; + } + + currentMessages = [{ role: 'user', parts: toolResponseParts }]; + } else { + // Emit final result event for streaming JSON + if (streamFormatter) { + const metrics = uiTelemetryService.getMetrics(); + const durationMs = Date.now() - startTime; + streamFormatter.emitEvent({ + type: JsonStreamEventType.RESULT, + timestamp: new Date().toISOString(), + status: invalidStreamError ? 'error' : 'success', + stats: streamFormatter.convertToStreamStats(metrics, durationMs), + }); + } else if (config.getOutputFormat() === OutputFormat.JSON) { + const formatter = new JsonFormatter(); + const stats = uiTelemetryService.getMetrics(); + textOutput.write( + formatter.format( + config.getSessionId(), + responseText, + stats, + invalidStreamError + ? { type: 'INVALID_STREAM', message: invalidStreamError } + : undefined, + warnings, + ), + ); + } else { + textOutput.ensureTrailingNewline(); // Ensure a final newline + } + return; + } + } + } catch (error) { + errorToHandle = error; + } finally { + // Cleanup stdin cancellation before other cleanup + cleanupStdinCancellation(); + + scheduler?.dispose(); + consolePatcher.cleanup(); + coreEvents.off(CoreEvent.UserFeedback, handleUserFeedback); + } + + if (errorToHandle) { + handleError(errorToHandle, config); + } + }); +} diff --git a/packages/cli/src/patches/http-proxy-agent.ts b/packages/cli/src/patches/http-proxy-agent.ts new file mode 100644 index 0000000000000000000000000000000000000000..1f13f9bb98e6b45467f4cfd5b2956cd5ddf9f232 --- /dev/null +++ b/packages/cli/src/patches/http-proxy-agent.ts @@ -0,0 +1,8 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// eslint-disable-next-line import/no-relative-packages +export { HttpProxyAgent } from '../../../../node_modules/http-proxy-agent/dist/index.js'; diff --git a/packages/cli/src/patches/https-proxy-agent.ts b/packages/cli/src/patches/https-proxy-agent.ts new file mode 100644 index 0000000000000000000000000000000000000000..a7c16557979cc938dbf354973c84421de1058548 --- /dev/null +++ b/packages/cli/src/patches/https-proxy-agent.ts @@ -0,0 +1,8 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// eslint-disable-next-line import/no-relative-packages +export { HttpsProxyAgent } from '../../../../node_modules/https-proxy-agent/dist/index.js'; diff --git a/packages/cli/src/patches/is-in-ci.ts b/packages/cli/src/patches/is-in-ci.ts new file mode 100644 index 0000000000000000000000000000000000000000..a37c8678a6e667f5a0c031da7f18ec8017341782 --- /dev/null +++ b/packages/cli/src/patches/is-in-ci.ts @@ -0,0 +1,17 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// This is a replacement for the `is-in-ci` package that always returns false. +// We are doing this to avoid the issue where `ink` does not render the UI +// when it detects that it is running in a CI environment. +// This is safe because `ink` (and thus `is-in-ci`) is only used in the +// interactive code path of the CLI. +// See issue #1563 for more details. + +const isInCi = false; + +// eslint-disable-next-line import/no-default-export +export default isInCi; diff --git a/packages/cli/src/services/BuiltinCommandLoader.test.ts b/packages/cli/src/services/BuiltinCommandLoader.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..dabe0f03ee67e3311061321f8f37e8a9c7fab393 --- /dev/null +++ b/packages/cli/src/services/BuiltinCommandLoader.test.ts @@ -0,0 +1,429 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +vi.mock('../ui/commands/profileCommand.js', async () => { + const { CommandKind } = await import('../ui/commands/types.js'); + return { + profileCommand: { + name: 'profile', + description: 'Profile command', + kind: CommandKind.BUILT_IN, + }, + }; +}); + +vi.mock('../ui/commands/aboutCommand.js', async () => { + const { CommandKind } = await import('../ui/commands/types.js'); + return { + aboutCommand: { + name: 'about', + description: 'About the CLI', + kind: CommandKind.BUILT_IN, + }, + }; +}); + +vi.mock('../ui/commands/ideCommand.js', async () => { + const { CommandKind } = await import('../ui/commands/types.js'); + return { + ideCommand: vi.fn().mockResolvedValue({ + name: 'ide', + description: 'IDE command', + kind: CommandKind.BUILT_IN, + }), + }; +}); +vi.mock('../ui/commands/restoreCommand.js', () => ({ + restoreCommand: vi.fn(), +})); +vi.mock('../ui/commands/permissionsCommand.js', async () => { + const { CommandKind } = await import('../ui/commands/types.js'); + return { + permissionsCommand: { + name: 'permissions', + description: 'Permissions command', + kind: CommandKind.BUILT_IN, + }, + }; +}); + +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { BuiltinCommandLoader } from './BuiltinCommandLoader.js'; +import { isNightly, type Config } from '@google/gemini-cli-core'; +import { CommandKind } from '../ui/commands/types.js'; + +import { restoreCommand } from '../ui/commands/restoreCommand.js'; + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + isNightly: vi.fn().mockResolvedValue(false), + }; +}); + +vi.mock('../ui/commands/authCommand.js', () => ({ authCommand: {} })); +vi.mock('../ui/commands/agentsCommand.js', () => ({ + agentsCommand: { name: 'agents' }, +})); +vi.mock('../ui/commands/bugCommand.js', () => ({ bugCommand: {} })); +vi.mock('../ui/commands/bugMemoryCommand.js', () => ({ + bugMemoryCommand: { name: 'bug-memory' }, +})); +vi.mock('../ui/commands/chatCommand.js', () => ({ + chatCommand: { + name: 'chat', + subCommands: [ + { name: 'list' }, + { name: 'save' }, + { name: 'resume' }, + { name: 'delete' }, + { name: 'share' }, + { name: 'checkpoints', hidden: true, subCommands: [{ name: 'list' }] }, + ], + }, + debugCommand: { name: 'debug' }, +})); +vi.mock('../ui/commands/clearCommand.js', () => ({ clearCommand: {} })); +vi.mock('../ui/commands/compressCommand.js', () => ({ compressCommand: {} })); +vi.mock('../ui/commands/corgiCommand.js', () => ({ corgiCommand: {} })); +vi.mock('../ui/commands/docsCommand.js', () => ({ docsCommand: {} })); +vi.mock('../ui/commands/editorCommand.js', () => ({ editorCommand: {} })); +vi.mock('../ui/commands/extensionsCommand.js', () => ({ + extensionsCommand: () => ({}), +})); +vi.mock('../ui/commands/helpCommand.js', () => ({ helpCommand: {} })); +vi.mock('../ui/commands/shortcutsCommand.js', () => ({ + shortcutsCommand: {}, +})); +vi.mock('../ui/commands/memoryCommand.js', () => ({ + memoryCommand: () => ({}), +})); +vi.mock('../ui/commands/modelCommand.js', () => ({ + modelCommand: { name: 'model' }, +})); +vi.mock('../ui/commands/privacyCommand.js', () => ({ privacyCommand: {} })); +vi.mock('../ui/commands/quitCommand.js', () => ({ quitCommand: {} })); +vi.mock('../ui/commands/resumeCommand.js', () => ({ + resumeCommand: { + name: 'resume', + subCommands: [ + { name: 'list' }, + { name: 'save' }, + { name: 'resume' }, + { name: 'delete' }, + { name: 'share' }, + { name: 'checkpoints', hidden: true, subCommands: [{ name: 'list' }] }, + ], + }, +})); +vi.mock('../ui/commands/statsCommand.js', () => ({ statsCommand: {} })); +vi.mock('../ui/commands/themeCommand.js', () => ({ themeCommand: {} })); +vi.mock('../ui/commands/toolsCommand.js', () => ({ toolsCommand: {} })); +vi.mock('../ui/commands/skillsCommand.js', () => ({ + skillsCommand: { name: 'skills' }, +})); +vi.mock('../ui/commands/planCommand.js', async () => { + const { CommandKind } = await import('../ui/commands/types.js'); + return { + planCommand: { + name: 'plan', + description: 'Plan command', + kind: CommandKind.BUILT_IN, + }, + }; +}); + +vi.mock('../ui/commands/mcpCommand.js', () => ({ + mcpCommand: { + name: 'mcp', + description: 'MCP command', + kind: 'BUILT_IN', + }, +})); + +vi.mock('../ui/commands/upgradeCommand.js', () => ({ + upgradeCommand: { + name: 'upgrade', + description: 'Upgrade command', + kind: 'BUILT_IN', + }, +})); + +describe('BuiltinCommandLoader', () => { + let mockConfig: Config; + + const restoreCommandMock = restoreCommand as Mock; + + beforeEach(() => { + vi.clearAllMocks(); + mockConfig = { + getFolderTrust: vi.fn().mockReturnValue(true), + isPlanEnabled: vi.fn().mockReturnValue(true), + getEnableExtensionReloading: () => false, + getEnableHooks: () => false, + getEnableHooksUI: () => false, + getExtensionsEnabled: vi.fn().mockReturnValue(true), + isSkillsSupportEnabled: vi.fn().mockReturnValue(true), + isAgentsEnabled: vi.fn().mockReturnValue(false), + getMcpEnabled: vi.fn().mockReturnValue(true), + getSkillManager: vi.fn().mockReturnValue({ + getAllSkills: vi.fn().mockReturnValue([]), + isAdminEnabled: vi.fn().mockReturnValue(true), + }), + isVoiceModeEnabled: vi.fn().mockReturnValue(true), + getContentGeneratorConfig: vi.fn().mockReturnValue({ + authType: 'other', + }), + } as unknown as Config; + + restoreCommandMock.mockReturnValue({ + name: 'restore', + description: 'Restore command', + kind: CommandKind.BUILT_IN, + }); + }); + + it('should include upgrade command when authType is login_with_google', async () => { + const { AuthType } = await import('@google/gemini-cli-core'); + (mockConfig.getContentGeneratorConfig as Mock).mockReturnValue({ + authType: AuthType.LOGIN_WITH_GOOGLE, + }); + const loader = new BuiltinCommandLoader(mockConfig); + const commands = await loader.loadCommands(new AbortController().signal); + const upgradeCmd = commands.find((c) => c.name === 'upgrade'); + expect(upgradeCmd).toBeDefined(); + }); + + it('should exclude upgrade command when authType is NOT login_with_google', async () => { + (mockConfig.getContentGeneratorConfig as Mock).mockReturnValue({ + authType: 'other', + }); + const loader = new BuiltinCommandLoader(mockConfig); + const commands = await loader.loadCommands(new AbortController().signal); + const upgradeCmd = commands.find((c) => c.name === 'upgrade'); + expect(upgradeCmd).toBeUndefined(); + }); + + it('should correctly pass the config object to restore command factory', async () => { + const loader = new BuiltinCommandLoader(mockConfig); + await loader.loadCommands(new AbortController().signal); + + // ideCommand is now a constant, no longer needs config + expect(restoreCommandMock).toHaveBeenCalledTimes(1); + expect(restoreCommandMock).toHaveBeenCalledWith(mockConfig); + }); + + it('should filter out null command definitions returned by factories', async () => { + // ideCommand is now a constant SlashCommand + const loader = new BuiltinCommandLoader(mockConfig); + const commands = await loader.loadCommands(new AbortController().signal); + + // The 'ide' command should be present. + const ideCmd = commands.find((c) => c.name === 'ide'); + expect(ideCmd).toBeDefined(); + + // Other commands should still be present. + const aboutCmd = commands.find((c) => c.name === 'about'); + expect(aboutCmd).toBeDefined(); + }); + + it('should handle a null config gracefully when calling factories', async () => { + const loader = new BuiltinCommandLoader(null); + await loader.loadCommands(new AbortController().signal); + // ideCommand is now a constant, no longer needs config + expect(restoreCommandMock).toHaveBeenCalledTimes(1); + expect(restoreCommandMock).toHaveBeenCalledWith(null); + }); + + it('should return a list of all loaded commands', async () => { + const loader = new BuiltinCommandLoader(mockConfig); + const commands = await loader.loadCommands(new AbortController().signal); + + const aboutCmd = commands.find((c) => c.name === 'about'); + expect(aboutCmd).toBeDefined(); + expect(aboutCmd?.kind).toBe(CommandKind.BUILT_IN); + + const ideCmd = commands.find((c) => c.name === 'ide'); + expect(ideCmd).toBeDefined(); + + const mcpCmd = commands.find((c) => c.name === 'mcp'); + expect(mcpCmd).toBeDefined(); + }); + + it('should include permissions command when folder trust is enabled', async () => { + const loader = new BuiltinCommandLoader(mockConfig); + const commands = await loader.loadCommands(new AbortController().signal); + const permissionsCmd = commands.find((c) => c.name === 'permissions'); + expect(permissionsCmd).toBeDefined(); + }); + + it('should exclude permissions command when folder trust is disabled', async () => { + (mockConfig.getFolderTrust as Mock).mockReturnValue(false); + const loader = new BuiltinCommandLoader(mockConfig); + const commands = await loader.loadCommands(new AbortController().signal); + const permissionsCmd = commands.find((c) => c.name === 'permissions'); + expect(permissionsCmd).toBeUndefined(); + }); + + it('should include policies command when message bus integration is enabled', async () => { + const mockConfigWithMessageBus = { + // eslint-disable-next-line @typescript-eslint/no-misused-spread + ...mockConfig, + getEnableHooks: () => false, + getMcpEnabled: () => true, + } as unknown as Config; + const loader = new BuiltinCommandLoader(mockConfigWithMessageBus); + const commands = await loader.loadCommands(new AbortController().signal); + const policiesCmd = commands.find((c) => c.name === 'policies'); + expect(policiesCmd).toBeDefined(); + }); + + it('should include agents command when agents are enabled', async () => { + mockConfig.isAgentsEnabled = vi.fn().mockReturnValue(true); + const loader = new BuiltinCommandLoader(mockConfig); + const commands = await loader.loadCommands(new AbortController().signal); + const agentsCmd = commands.find((c) => c.name === 'agents'); + expect(agentsCmd).toBeDefined(); + }); + + it('should include plan command when plan mode is enabled', async () => { + (mockConfig.isPlanEnabled as Mock).mockReturnValue(true); + const loader = new BuiltinCommandLoader(mockConfig); + const commands = await loader.loadCommands(new AbortController().signal); + const planCmd = commands.find((c) => c.name === 'plan'); + expect(planCmd).toBeDefined(); + }); + + it('should exclude plan command when plan mode is disabled', async () => { + (mockConfig.isPlanEnabled as Mock).mockReturnValue(false); + const loader = new BuiltinCommandLoader(mockConfig); + const commands = await loader.loadCommands(new AbortController().signal); + const planCmd = commands.find((c) => c.name === 'plan'); + expect(planCmd).toBeUndefined(); + }); + + it('should exclude agents command when agents are disabled', async () => { + mockConfig.isAgentsEnabled = vi.fn().mockReturnValue(false); + const loader = new BuiltinCommandLoader(mockConfig); + const commands = await loader.loadCommands(new AbortController().signal); + const agentsCmd = commands.find((c) => c.name === 'agents'); + expect(agentsCmd).toBeUndefined(); + }); + + describe('chat debug command', () => { + it('should NOT add debug subcommand to chat/resume commands if not a nightly build', async () => { + vi.mocked(isNightly).mockResolvedValue(false); + const loader = new BuiltinCommandLoader(mockConfig); + const commands = await loader.loadCommands(new AbortController().signal); + + const chatCmd = commands.find((c) => c.name === 'chat'); + expect(chatCmd?.subCommands).toBeDefined(); + const hasDebug = chatCmd!.subCommands!.some((c) => c.name === 'debug'); + expect(hasDebug).toBe(false); + + const resumeCmd = commands.find((c) => c.name === 'resume'); + const resumeHasDebug = + resumeCmd?.subCommands?.some((c) => c.name === 'debug') ?? false; + expect(resumeHasDebug).toBe(false); + + const chatCheckpointsCmd = chatCmd?.subCommands?.find( + (c) => c.name === 'checkpoints', + ); + const chatCheckpointHasDebug = + chatCheckpointsCmd?.subCommands?.some((c) => c.name === 'debug') ?? + false; + expect(chatCheckpointHasDebug).toBe(false); + + const resumeCheckpointsCmd = resumeCmd?.subCommands?.find( + (c) => c.name === 'checkpoints', + ); + const resumeCheckpointHasDebug = + resumeCheckpointsCmd?.subCommands?.some((c) => c.name === 'debug') ?? + false; + expect(resumeCheckpointHasDebug).toBe(false); + }); + + it('should add debug subcommand to chat/resume commands if it is a nightly build', async () => { + vi.mocked(isNightly).mockResolvedValue(true); + const loader = new BuiltinCommandLoader(mockConfig); + const commands = await loader.loadCommands(new AbortController().signal); + + const chatCmd = commands.find((c) => c.name === 'chat'); + expect(chatCmd?.subCommands).toBeDefined(); + const hasDebug = chatCmd!.subCommands!.some((c) => c.name === 'debug'); + expect(hasDebug).toBe(true); + + const resumeCmd = commands.find((c) => c.name === 'resume'); + const resumeHasDebug = + resumeCmd?.subCommands?.some((c) => c.name === 'debug') ?? false; + expect(resumeHasDebug).toBe(true); + + const chatCheckpointsCmd = chatCmd?.subCommands?.find( + (c) => c.name === 'checkpoints', + ); + const chatCheckpointHasDebug = + chatCheckpointsCmd?.subCommands?.some((c) => c.name === 'debug') ?? + false; + expect(chatCheckpointHasDebug).toBe(true); + + const resumeCheckpointsCmd = resumeCmd?.subCommands?.find( + (c) => c.name === 'checkpoints', + ); + const resumeCheckpointHasDebug = + resumeCheckpointsCmd?.subCommands?.some((c) => c.name === 'debug') ?? + false; + expect(resumeCheckpointHasDebug).toBe(true); + }); + }); +}); + +describe('BuiltinCommandLoader profile', () => { + let mockConfig: Config; + + beforeEach(() => { + vi.resetModules(); + mockConfig = { + getFolderTrust: vi.fn().mockReturnValue(false), + isPlanEnabled: vi.fn().mockReturnValue(true), + getCheckpointingEnabled: () => false, + getEnableExtensionReloading: () => false, + getEnableHooks: () => false, + getEnableHooksUI: () => false, + getExtensionsEnabled: vi.fn().mockReturnValue(true), + isSkillsSupportEnabled: vi.fn().mockReturnValue(true), + isAgentsEnabled: vi.fn().mockReturnValue(false), + getMcpEnabled: vi.fn().mockReturnValue(true), + getSkillManager: vi.fn().mockReturnValue({ + getAllSkills: vi.fn().mockReturnValue([]), + isAdminEnabled: vi.fn().mockReturnValue(true), + }), + isVoiceModeEnabled: vi.fn().mockReturnValue(true), + getContentGeneratorConfig: vi.fn().mockReturnValue({ + authType: 'other', + }), + } as unknown as Config; + }); + + it('should not include profile command when isDevelopment is false', async () => { + process.env['NODE_ENV'] = 'production'; + const { BuiltinCommandLoader } = await import('./BuiltinCommandLoader.js'); + const loader = new BuiltinCommandLoader(mockConfig); + const commands = await loader.loadCommands(new AbortController().signal); + const profileCmd = commands.find((c) => c.name === 'profile'); + expect(profileCmd).toBeUndefined(); + }); + + it('should include profile command when isDevelopment is true', async () => { + process.env['NODE_ENV'] = 'development'; + const { BuiltinCommandLoader } = await import('./BuiltinCommandLoader.js'); + const loader = new BuiltinCommandLoader(mockConfig); + const commands = await loader.loadCommands(new AbortController().signal); + const profileCmd = commands.find((c) => c.name === 'profile'); + expect(profileCmd).toBeDefined(); + }); +}); diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts new file mode 100644 index 0000000000000000000000000000000000000000..67b0861eb5d2cc548aec919f23049c4b96342559 --- /dev/null +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -0,0 +1,244 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { isDevelopment } from '../utils/installationInfo.js'; +import type { ICommandLoader } from './types.js'; +import { + CommandKind, + type SlashCommand, + type CommandContext, +} from '../ui/commands/types.js'; +import type { MessageActionReturn, Config } from '@google/gemini-cli-core'; +import { + isNightly, + startupProfiler, + getAdminErrorMessage, + AuthType, +} from '@google/gemini-cli-core'; +import { aboutCommand } from '../ui/commands/aboutCommand.js'; +import { agentsCommand } from '../ui/commands/agentsCommand.js'; +import { authCommand } from '../ui/commands/authCommand.js'; +import { bugCommand } from '../ui/commands/bugCommand.js'; +import { bugMemoryCommand } from '../ui/commands/bugMemoryCommand.js'; +import { chatCommand, debugCommand } from '../ui/commands/chatCommand.js'; +import { clearCommand } from '../ui/commands/clearCommand.js'; +import { commandsCommand } from '../ui/commands/commandsCommand.js'; +import { compressCommand } from '../ui/commands/compressCommand.js'; +import { copyCommand } from '../ui/commands/copyCommand.js'; +import { corgiCommand } from '../ui/commands/corgiCommand.js'; +import { docsCommand } from '../ui/commands/docsCommand.js'; +import { exportSessionCommand } from '../ui/commands/exportSessionCommand.js'; +import { directoryCommand } from '../ui/commands/directoryCommand.js'; +import { editorCommand } from '../ui/commands/editorCommand.js'; +import { extensionsCommand } from '../ui/commands/extensionsCommand.js'; +import { footerCommand } from '../ui/commands/footerCommand.js'; +import { helpCommand } from '../ui/commands/helpCommand.js'; +import { shortcutsCommand } from '../ui/commands/shortcutsCommand.js'; +import { rewindCommand } from '../ui/commands/rewindCommand.js'; +import { hooksCommand } from '../ui/commands/hooksCommand.js'; +import { ideCommand } from '../ui/commands/ideCommand.js'; +import { initCommand } from '../ui/commands/initCommand.js'; +import { mcpCommand } from '../ui/commands/mcpCommand.js'; +import { memoryCommand } from '../ui/commands/memoryCommand.js'; +import { modelCommand } from '../ui/commands/modelCommand.js'; +import { oncallCommand } from '../ui/commands/oncallCommand.js'; +import { permissionsCommand } from '../ui/commands/permissionsCommand.js'; +import { planCommand } from '../ui/commands/planCommand.js'; +import { policiesCommand } from '../ui/commands/policiesCommand.js'; +import { privacyCommand } from '../ui/commands/privacyCommand.js'; +import { profileCommand } from '../ui/commands/profileCommand.js'; +import { quitCommand } from '../ui/commands/quitCommand.js'; +import { restoreCommand } from '../ui/commands/restoreCommand.js'; +import { resumeCommand } from '../ui/commands/resumeCommand.js'; +import { statsCommand } from '../ui/commands/statsCommand.js'; +import { themeCommand } from '../ui/commands/themeCommand.js'; +import { toolsCommand } from '../ui/commands/toolsCommand.js'; +import { skillsCommand } from '../ui/commands/skillsCommand.js'; +import { settingsCommand } from '../ui/commands/settingsCommand.js'; +import { tasksCommand } from '../ui/commands/tasksCommand.js'; +import { vimCommand } from '../ui/commands/vimCommand.js'; +import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js'; +import { terminalSetupCommand } from '../ui/commands/terminalSetupCommand.js'; +import { upgradeCommand } from '../ui/commands/upgradeCommand.js'; +import { gemmaStatusCommand } from '../ui/commands/gemmaStatusCommand.js'; +import { voiceCommand } from '../ui/commands/voiceCommand.js'; + +/** + * Loads the core, hard-coded slash commands that are an integral part + * of the Gemini CLI application. + */ +export class BuiltinCommandLoader implements ICommandLoader { + constructor(private config: Config | null) {} + + /** + * Gathers all raw built-in command definitions, injects dependencies where + * needed (e.g., config) and filters out any that are not available. + * + * @param _signal An AbortSignal (unused for this synchronous loader). + * @returns A promise that resolves to an array of `SlashCommand` objects. + */ + async loadCommands(_signal: AbortSignal): Promise { + const handle = startupProfiler.start('load_builtin_commands'); + + const isNightlyBuild = await isNightly(process.cwd()); + const addDebugToChatResumeSubCommands = ( + subCommands: SlashCommand[] | undefined, + ): SlashCommand[] | undefined => { + if (!subCommands) { + return subCommands; + } + + const withNestedCompatibility = subCommands.map((subCommand) => { + if (subCommand.name !== 'checkpoints') { + return subCommand; + } + + return { + ...subCommand, + subCommands: addDebugToChatResumeSubCommands(subCommand.subCommands), + }; + }); + + if (!isNightlyBuild) { + return withNestedCompatibility; + } + + return withNestedCompatibility.some( + (cmd) => cmd.name === debugCommand.name, + ) + ? withNestedCompatibility + : [ + ...withNestedCompatibility, + { ...debugCommand, suggestionGroup: 'checkpoints' }, + ]; + }; + + const chatResumeSubCommands = addDebugToChatResumeSubCommands( + chatCommand.subCommands, + ); + + const allDefinitions: Array = [ + aboutCommand, + ...(this.config?.isAgentsEnabled() ? [agentsCommand] : []), + authCommand, + bugCommand, + bugMemoryCommand, + { + ...chatCommand, + subCommands: chatResumeSubCommands, + }, + clearCommand, + commandsCommand, + compressCommand, + copyCommand, + corgiCommand, + docsCommand, + exportSessionCommand, + directoryCommand, + editorCommand, + ...(this.config?.getExtensionsEnabled() === false + ? [ + { + name: 'extensions', + description: 'Manage extensions', + kind: CommandKind.BUILT_IN, + autoExecute: false, + subCommands: [], + action: async ( + _context: CommandContext, + ): Promise => ({ + type: 'message', + messageType: 'error', + content: getAdminErrorMessage( + 'Extensions', + this.config ?? undefined, + ), + }), + }, + ] + : [extensionsCommand(this.config?.getEnableExtensionReloading())]), + helpCommand, + footerCommand, + shortcutsCommand, + ...(this.config?.getEnableHooksUI() ? [hooksCommand] : []), + rewindCommand, + await ideCommand(), + initCommand, + ...(isNightlyBuild ? [oncallCommand] : []), + ...(this.config?.getMcpEnabled() === false + ? [ + { + name: 'mcp', + description: + 'Manage configured Model Context Protocol (MCP) servers', + kind: CommandKind.BUILT_IN, + autoExecute: false, + subCommands: [], + action: async ( + _context: CommandContext, + ): Promise => ({ + type: 'message', + messageType: 'error', + content: getAdminErrorMessage('MCP', this.config ?? undefined), + }), + }, + ] + : [mcpCommand]), + memoryCommand(this.config), + modelCommand, + ...(this.config?.getFolderTrust() ? [permissionsCommand] : []), + ...(this.config?.isPlanEnabled() ? [planCommand] : []), + policiesCommand, + privacyCommand, + ...(isDevelopment ? [profileCommand] : []), + quitCommand, + restoreCommand(this.config), + { + ...resumeCommand, + subCommands: addDebugToChatResumeSubCommands(resumeCommand.subCommands), + }, + statsCommand, + themeCommand, + toolsCommand, + ...(this.config?.isSkillsSupportEnabled() + ? this.config?.getSkillManager()?.isAdminEnabled() === false + ? [ + { + name: 'skills', + description: 'Manage agent skills', + kind: CommandKind.BUILT_IN, + autoExecute: false, + subCommands: [], + action: async ( + _context: CommandContext, + ): Promise => ({ + type: 'message', + messageType: 'error', + content: getAdminErrorMessage( + 'Agent skills', + this.config ?? undefined, + ), + }), + }, + ] + : [skillsCommand] + : []), + settingsCommand, + gemmaStatusCommand, + tasksCommand, + vimCommand, + setupGithubCommand, + terminalSetupCommand, + ...(this.config?.isVoiceModeEnabled() ? [voiceCommand] : []), + ...(this.config?.getContentGeneratorConfig()?.authType === + AuthType.LOGIN_WITH_GOOGLE + ? [upgradeCommand] + : []), + ]; + handle?.end(); + return allDefinitions.filter((cmd): cmd is SlashCommand => cmd !== null); + } +} diff --git a/packages/cli/src/services/CommandService.test.ts b/packages/cli/src/services/CommandService.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..eae7ec7c40f6d25d38d9ae7bc9792698705f4962 --- /dev/null +++ b/packages/cli/src/services/CommandService.test.ts @@ -0,0 +1,104 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { CommandService } from './CommandService.js'; +import { type ICommandLoader } from './types.js'; +import { CommandKind, type SlashCommand } from '../ui/commands/types.js'; +import { debugLogger } from '@google/gemini-cli-core'; + +const createMockCommand = (name: string, kind: CommandKind): SlashCommand => ({ + name, + description: `Description for ${name}`, + kind, + action: vi.fn(), +}); + +class MockCommandLoader implements ICommandLoader { + constructor(private readonly commands: SlashCommand[]) {} + loadCommands = vi.fn(async () => Promise.resolve(this.commands)); +} + +describe('CommandService', () => { + beforeEach(() => { + vi.spyOn(debugLogger, 'debug').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('basic loading', () => { + it('should aggregate commands from multiple successful loaders', async () => { + const cmdA = createMockCommand('a', CommandKind.BUILT_IN); + const cmdB = createMockCommand('b', CommandKind.USER_FILE); + const service = await CommandService.create( + [new MockCommandLoader([cmdA]), new MockCommandLoader([cmdB])], + new AbortController().signal, + ); + + expect(service.getCommands()).toHaveLength(2); + expect(service.getCommands()).toEqual( + expect.arrayContaining([cmdA, cmdB]), + ); + }); + + it('should handle empty loaders and failed loaders gracefully', async () => { + const cmdA = createMockCommand('a', CommandKind.BUILT_IN); + const failingLoader = new MockCommandLoader([]); + vi.spyOn(failingLoader, 'loadCommands').mockRejectedValue( + new Error('fail'), + ); + + const service = await CommandService.create( + [ + new MockCommandLoader([cmdA]), + new MockCommandLoader([]), + failingLoader, + ], + new AbortController().signal, + ); + + expect(service.getCommands()).toHaveLength(1); + expect(service.getCommands()[0].name).toBe('a'); + expect(debugLogger.debug).toHaveBeenCalledWith( + 'A command loader failed:', + expect.any(Error), + ); + }); + + it('should return a readonly array of commands', async () => { + const service = await CommandService.create( + [new MockCommandLoader([createMockCommand('a', CommandKind.BUILT_IN)])], + new AbortController().signal, + ); + expect(() => (service.getCommands() as unknown[]).push({})).toThrow(); + }); + + it('should pass the abort signal to all loaders', async () => { + const controller = new AbortController(); + const loader = new MockCommandLoader([]); + await CommandService.create([loader], controller.signal); + expect(loader.loadCommands).toHaveBeenCalledWith(controller.signal); + }); + }); + + describe('conflict delegation', () => { + it('should delegate conflict resolution to SlashCommandResolver', async () => { + const builtin = createMockCommand('help', CommandKind.BUILT_IN); + const user = createMockCommand('help', CommandKind.USER_FILE); + + const service = await CommandService.create( + [new MockCommandLoader([builtin, user])], + new AbortController().signal, + ); + + expect(service.getCommands().map((c) => c.name)).toContain('help'); + expect(service.getCommands().map((c) => c.name)).toContain('user.help'); + expect(service.getConflicts()).toHaveLength(1); + }); + }); +}); diff --git a/packages/cli/src/services/CommandService.ts b/packages/cli/src/services/CommandService.ts new file mode 100644 index 0000000000000000000000000000000000000000..61f94576196813c060819f14e480176142badf4c --- /dev/null +++ b/packages/cli/src/services/CommandService.ts @@ -0,0 +1,124 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { debugLogger, coreEvents } from '@google/gemini-cli-core'; +import type { SlashCommand } from '../ui/commands/types.js'; +import type { ICommandLoader, CommandConflict } from './types.js'; +import { SlashCommandResolver } from './SlashCommandResolver.js'; + +/** + * Orchestrates the discovery and loading of all slash commands for the CLI. + * + * This service operates on a provider-based loader pattern. It is initialized + * with an array of `ICommandLoader` instances, each responsible for fetching + * commands from a specific source (e.g., built-in code, local files). + * + * It uses a delegating resolver to reconcile name conflicts, ensuring that + * all commands are uniquely addressable via source-specific prefixes while + * allowing built-in commands to retain their primary names. + */ +export class CommandService { + /** + * Private constructor to enforce the use of the async factory. + * @param commands A readonly array of the fully loaded and de-duplicated commands. + * @param conflicts A readonly array of conflicts that occurred during loading. + */ + private constructor( + private readonly commands: readonly SlashCommand[], + private readonly conflicts: readonly CommandConflict[], + ) {} + + /** + * Asynchronously creates and initializes a new CommandService instance. + * + * This factory method orchestrates the loading process and delegates + * conflict resolution to the SlashCommandResolver. + * + * @param loaders An array of loaders to fetch commands from. + * @param signal An AbortSignal to allow cancellation. + * @returns A promise that resolves to a fully initialized CommandService. + */ + static async create( + loaders: ICommandLoader[], + signal: AbortSignal, + ): Promise { + const allCommands = await this.loadAllCommands(loaders, signal); + const { finalCommands, conflicts } = + SlashCommandResolver.resolve(allCommands); + + if (conflicts.length > 0) { + this.emitConflictEvents(conflicts); + } + + return new CommandService( + Object.freeze(finalCommands), + Object.freeze(conflicts), + ); + } + + /** + * Invokes all loaders in parallel and flattens the results. + */ + private static async loadAllCommands( + loaders: ICommandLoader[], + signal: AbortSignal, + ): Promise { + const results = await Promise.allSettled( + loaders.map((loader) => loader.loadCommands(signal)), + ); + + const commands: SlashCommand[] = []; + for (const result of results) { + if (result.status === 'fulfilled') { + commands.push(...result.value); + } else { + debugLogger.debug('A command loader failed:', result.reason); + } + } + return commands; + } + + /** + * Formats and emits telemetry for command conflicts. + */ + private static emitConflictEvents(conflicts: CommandConflict[]): void { + coreEvents.emitSlashCommandConflicts( + conflicts.flatMap((c) => + c.losers.map((l) => ({ + name: c.name, + renamedTo: l.renamedTo, + loserExtensionName: l.command.extensionName, + winnerExtensionName: l.reason.extensionName, + loserMcpServerName: l.command.mcpServerName, + winnerMcpServerName: l.reason.mcpServerName, + loserKind: l.command.kind, + winnerKind: l.reason.kind, + })), + ), + ); + } + + /** + * Retrieves the currently loaded and de-duplicated list of slash commands. + * + * This method is a safe accessor for the service's state. It returns a + * readonly array, preventing consumers from modifying the service's internal state. + * + * @returns A readonly, unified array of available `SlashCommand` objects. + */ + getCommands(): readonly SlashCommand[] { + return this.commands; + } + + /** + * Retrieves the list of conflicts that occurred during command loading. + * + * @returns A readonly array of command conflicts. + */ + getConflicts(): readonly CommandConflict[] { + return this.conflicts; + } +} diff --git a/packages/cli/src/services/FileCommandLoader.test.ts b/packages/cli/src/services/FileCommandLoader.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..6325569f25e4325d1cc5712097facc10f27a41fb --- /dev/null +++ b/packages/cli/src/services/FileCommandLoader.test.ts @@ -0,0 +1,1514 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as glob from 'glob'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { + GEMINI_DIR, + Storage, + type Config, + homedir, +} from '@google/gemini-cli-core'; +import mock from 'mock-fs'; +import { FileCommandLoader } from './FileCommandLoader.js'; +import { assert, vi } from 'vitest'; +import { createMockCommandContext } from '../test-utils/mockCommandContext.js'; +import { + SHELL_INJECTION_TRIGGER, + SHORTHAND_ARGS_PLACEHOLDER, + type PromptPipelineContent, +} from './prompt-processors/types.js'; +import { + ConfirmationRequiredError, + ShellProcessor, +} from './prompt-processors/shellProcessor.js'; +import { DefaultArgumentProcessor } from './prompt-processors/argumentProcessor.js'; +import { CommandKind, type CommandContext } from '../ui/commands/types.js'; +import { AtFileProcessor } from './prompt-processors/atFileProcessor.js'; + +const mockShellProcess = vi.hoisted(() => vi.fn()); +const mockAtFileProcess = vi.hoisted(() => vi.fn()); +vi.mock('./prompt-processors/atFileProcessor.js', () => ({ + AtFileProcessor: vi.fn().mockImplementation(() => ({ + process: mockAtFileProcess, + })), +})); +vi.mock('./prompt-processors/shellProcessor.js', () => ({ + ShellProcessor: vi.fn().mockImplementation(() => ({ + process: mockShellProcess, + })), + ConfirmationRequiredError: class extends Error { + constructor( + message: string, + public commandsToConfirm: string[], + ) { + super(message); + this.name = 'ConfirmationRequiredError'; + } + }, +})); + +vi.mock('./prompt-processors/argumentProcessor.js', async (importOriginal) => { + const original = + await importOriginal< + typeof import('./prompt-processors/argumentProcessor.js') + >(); + return { + DefaultArgumentProcessor: vi + .fn() + .mockImplementation(() => new original.DefaultArgumentProcessor()), + }; +}); +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const original = + await importOriginal(); + return { + ...original, + Storage: original.Storage, + homedir: vi.fn(original.homedir), + isCommandAllowed: vi.fn(), + ShellExecutionService: { + execute: vi.fn(), + }, + }; +}); + +vi.mock('glob', () => ({ + glob: vi.fn(), +})); + +describe('FileCommandLoader', () => { + const signal: AbortSignal = new AbortController().signal; + + beforeEach(async () => { + vi.clearAllMocks(); + const { glob: actualGlob } = + await vi.importActual('glob'); + vi.mocked(glob.glob).mockImplementation(actualGlob); + mockShellProcess.mockImplementation( + (prompt: PromptPipelineContent, context: CommandContext) => { + const userArgsRaw = context?.invocation?.args || ''; + // This is a simplified mock. A real implementation would need to iterate + // through all parts and process only the text parts. + const firstTextPart = prompt.find( + (p) => typeof p === 'string' || 'text' in p, + ); + let textContent = ''; + if (typeof firstTextPart === 'string') { + textContent = firstTextPart; + } else if (firstTextPart && 'text' in firstTextPart) { + textContent = firstTextPart.text ?? ''; + } + + const processedText = textContent.replaceAll( + SHORTHAND_ARGS_PLACEHOLDER, + userArgsRaw, + ); + return Promise.resolve([{ text: processedText }]); + }, + ); + mockAtFileProcess.mockImplementation(async (prompt: string) => prompt); + }); + + afterEach(() => { + mock.restore(); + }); + + it('loads a single command from a file', async () => { + const userCommandsDir = Storage.getUserCommandsDir(); + mock({ + [userCommandsDir]: { + 'test.toml': 'prompt = "This is a test prompt"', + }, + }); + + const loader = new FileCommandLoader(null); + const commands = await loader.loadCommands(signal); + + expect(commands).toHaveLength(1); + const command = commands[0]; + expect(command).toBeDefined(); + expect(command.name).toBe('test'); + + const result = await command.action?.( + createMockCommandContext({ + invocation: { + raw: '/test', + name: 'test', + args: '', + }, + }), + '', + ); + if (result?.type === 'submit_prompt') { + expect(result.content).toEqual([{ text: 'This is a test prompt' }]); + } else { + assert.fail('Incorrect action type'); + } + }); + + // Symlink creation on Windows requires special permissions that are not + // available in the standard CI environment. Therefore, we skip these tests + // on Windows to prevent CI failures. The core functionality is still + // validated on Linux and macOS. + const itif = (condition: boolean) => (condition ? it : it.skip); + + itif(process.platform !== 'win32')( + 'loads commands from a symlinked directory', + async () => { + const userCommandsDir = Storage.getUserCommandsDir(); + const realCommandsDir = '/real/commands'; + mock({ + [realCommandsDir]: { + 'test.toml': 'prompt = "This is a test prompt"', + }, + // Symlink the user commands directory to the real one + [userCommandsDir]: mock.symlink({ + path: realCommandsDir, + }), + }); + + const loader = new FileCommandLoader(null as unknown as Config); + const commands = await loader.loadCommands(signal); + + expect(commands).toHaveLength(1); + const command = commands[0]; + expect(command).toBeDefined(); + expect(command.name).toBe('test'); + }, + ); + + itif(process.platform !== 'win32')( + 'loads commands from a symlinked subdirectory', + async () => { + const userCommandsDir = Storage.getUserCommandsDir(); + const realNamespacedDir = '/real/namespaced-commands'; + mock({ + [userCommandsDir]: { + namespaced: mock.symlink({ + path: realNamespacedDir, + }), + }, + [realNamespacedDir]: { + 'my-test.toml': 'prompt = "This is a test prompt"', + }, + }); + + const loader = new FileCommandLoader(null as unknown as Config); + const commands = await loader.loadCommands(signal); + + expect(commands).toHaveLength(1); + const command = commands[0]; + expect(command).toBeDefined(); + expect(command.name).toBe('namespaced:my-test'); + }, + ); + + it('loads multiple commands', async () => { + const userCommandsDir = Storage.getUserCommandsDir(); + mock({ + [userCommandsDir]: { + 'test1.toml': 'prompt = "Prompt 1"', + 'test2.toml': 'prompt = "Prompt 2"', + }, + }); + + const loader = new FileCommandLoader(null); + const commands = await loader.loadCommands(signal); + + expect(commands).toHaveLength(2); + }); + + it('creates deeply nested namespaces correctly', async () => { + const userCommandsDir = Storage.getUserCommandsDir(); + + mock({ + [userCommandsDir]: { + gcp: { + pipelines: { + 'run.toml': 'prompt = "run pipeline"', + }, + }, + }, + }); + const mockConfig = { + getProjectRoot: vi.fn(() => '/path/to/project'), + getExtensions: vi.fn(() => []), + getFolderTrust: vi.fn(() => false), + isTrustedFolder: vi.fn(() => false), + } as unknown as Config; + const loader = new FileCommandLoader(mockConfig); + const commands = await loader.loadCommands(signal); + expect(commands).toHaveLength(1); + expect(commands[0].name).toBe('gcp:pipelines:run'); + }); + + it('creates namespaces from nested directories', async () => { + const userCommandsDir = Storage.getUserCommandsDir(); + mock({ + [userCommandsDir]: { + git: { + 'commit.toml': 'prompt = "git commit prompt"', + }, + }, + }); + + const loader = new FileCommandLoader(null); + const commands = await loader.loadCommands(signal); + + expect(commands).toHaveLength(1); + const command = commands[0]; + expect(command).toBeDefined(); + expect(command.name).toBe('git:commit'); + }); + + it('returns both user and project commands in order', async () => { + const userCommandsDir = Storage.getUserCommandsDir(); + const projectCommandsDir = new Storage( + process.cwd(), + ).getProjectCommandsDir(); + mock({ + [userCommandsDir]: { + 'test.toml': 'prompt = "User prompt"', + }, + [projectCommandsDir]: { + 'test.toml': 'prompt = "Project prompt"', + }, + }); + + const mockConfig = { + getProjectRoot: vi.fn(() => process.cwd()), + getExtensions: vi.fn(() => []), + getFolderTrust: vi.fn(() => false), + isTrustedFolder: vi.fn(() => false), + } as unknown as Config; + const loader = new FileCommandLoader(mockConfig); + const commands = await loader.loadCommands(signal); + + expect(commands).toHaveLength(2); + const userResult = await commands[0].action?.( + createMockCommandContext({ + invocation: { + raw: '/test', + name: 'test', + args: '', + }, + }), + '', + ); + if (userResult?.type === 'submit_prompt') { + expect(userResult.content).toEqual([{ text: 'User prompt' }]); + } else { + assert.fail('Incorrect action type for user command'); + } + const projectResult = await commands[1].action?.( + createMockCommandContext({ + invocation: { + raw: '/test', + name: 'test', + args: '', + }, + }), + '', + ); + if (projectResult?.type === 'submit_prompt') { + expect(projectResult.content).toEqual([{ text: 'Project prompt' }]); + } else { + assert.fail('Incorrect action type for project command'); + } + }); + + it('does not duplicate commands when project root is the home directory', async () => { + const homeDir = homedir(); + const userCommandsDir = Storage.getUserCommandsDir(); + mock({ + [userCommandsDir]: { + 'test.toml': 'prompt = "User prompt"', + 'another.toml': 'prompt = "Another prompt"', + }, + }); + + const mockConfig = { + getProjectRoot: vi.fn(() => homeDir), + getExtensions: vi.fn(() => []), + getFolderTrust: vi.fn(() => false), + isTrustedFolder: vi.fn(() => false), + } as unknown as Config; + const loader = new FileCommandLoader(mockConfig); + const commands = await loader.loadCommands(signal); + + // Should load each command only once (as user commands), not twice + expect(commands).toHaveLength(2); + const names = commands.map((c) => c.name); + expect(names).toContain('test'); + expect(names).toContain('another'); + // Verify they are loaded as user commands, not duplicated as workspace commands + expect(commands.every((c) => c.kind === CommandKind.USER_FILE)).toBe(true); + }); + + it('handles empty homedir gracefully without throwing when loading commands', async () => { + vi.mocked(homedir).mockReturnValue(''); + const projectCommandsDir = path.join('/workspace', GEMINI_DIR, 'commands'); + mock({ + [projectCommandsDir]: { + 'project.toml': 'prompt = "Project prompt"', + }, + }); + + const mockConfig = { + getProjectRoot: vi.fn(() => '/workspace'), + getExtensions: vi.fn(() => []), + getFolderTrust: vi.fn(() => true), + isTrustedFolder: vi.fn(() => true), + } as unknown as Config; + const loader = new FileCommandLoader(mockConfig); + const commands = await loader.loadCommands(signal); + expect(commands).toBeDefined(); + expect(commands).toHaveLength(1); + expect(commands[0].name).toBe('project'); + expect(commands[0].kind).toBe(CommandKind.WORKSPACE_FILE); + vi.mocked(homedir).mockReturnValue(os.homedir()); + }); + + it('ignores files with TOML syntax errors', async () => { + const userCommandsDir = Storage.getUserCommandsDir(); + mock({ + [userCommandsDir]: { + 'invalid.toml': 'this is not valid toml', + 'good.toml': 'prompt = "This one is fine"', + }, + }); + + const loader = new FileCommandLoader(null); + const commands = await loader.loadCommands(signal); + + expect(commands).toHaveLength(1); + expect(commands[0].name).toBe('good'); + }); + + it('ignores files that are semantically invalid (missing prompt)', async () => { + const userCommandsDir = Storage.getUserCommandsDir(); + mock({ + [userCommandsDir]: { + 'no_prompt.toml': 'description = "This file is missing a prompt"', + 'good.toml': 'prompt = "This one is fine"', + }, + }); + + const loader = new FileCommandLoader(null); + const commands = await loader.loadCommands(signal); + + expect(commands).toHaveLength(1); + expect(commands[0].name).toBe('good'); + }); + + it('handles filename edge cases correctly', async () => { + const userCommandsDir = Storage.getUserCommandsDir(); + mock({ + [userCommandsDir]: { + 'test.v1.toml': 'prompt = "Test prompt"', + }, + }); + + const loader = new FileCommandLoader(null); + const commands = await loader.loadCommands(signal); + const command = commands[0]; + expect(command).toBeDefined(); + expect(command.name).toBe('test.v1'); + }); + + it('handles file system errors gracefully', async () => { + mock({}); // Mock an empty file system + const loader = new FileCommandLoader(null); + const commands = await loader.loadCommands(signal); + expect(commands).toHaveLength(0); + }); + + it('uses a default description if not provided', async () => { + const userCommandsDir = Storage.getUserCommandsDir(); + mock({ + [userCommandsDir]: { + 'test.toml': 'prompt = "Test prompt"', + }, + }); + + const loader = new FileCommandLoader(null); + const commands = await loader.loadCommands(signal); + const command = commands[0]; + expect(command).toBeDefined(); + expect(command.description).toBe('Custom command from test.toml'); + }); + + it('uses the provided description', async () => { + const userCommandsDir = Storage.getUserCommandsDir(); + mock({ + [userCommandsDir]: { + 'test.toml': 'prompt = "Test prompt"\ndescription = "My test command"', + }, + }); + + const loader = new FileCommandLoader(null); + const commands = await loader.loadCommands(signal); + const command = commands[0]; + expect(command).toBeDefined(); + expect(command.description).toBe('My test command'); + }); + + it('should sanitize colons in filenames to prevent namespace conflicts', async () => { + const userCommandsDir = Storage.getUserCommandsDir(); + mock({ + [userCommandsDir]: { + 'legacy:command.toml': 'prompt = "This is a legacy command"', + }, + }); + + const loader = new FileCommandLoader(null); + const commands = await loader.loadCommands(signal); + + expect(commands).toHaveLength(1); + const command = commands[0]; + expect(command).toBeDefined(); + + // Verify that the ':' in the filename was replaced with an '_' + expect(command.name).toBe('legacy_command'); + }); + + describe('Processor Instantiation Logic', () => { + it('instantiates only DefaultArgumentProcessor if no {{args}} or !{} are present', async () => { + const userCommandsDir = Storage.getUserCommandsDir(); + mock({ + [userCommandsDir]: { + 'simple.toml': `prompt = "Just a regular prompt"`, + }, + }); + + const loader = new FileCommandLoader(null as unknown as Config); + await loader.loadCommands(signal); + + expect(ShellProcessor).not.toHaveBeenCalled(); + expect(DefaultArgumentProcessor).toHaveBeenCalledTimes(1); + }); + + it('instantiates only ShellProcessor if {{args}} is present (but not !{})', async () => { + const userCommandsDir = Storage.getUserCommandsDir(); + mock({ + [userCommandsDir]: { + 'args.toml': `prompt = "Prompt with {{args}}"`, + }, + }); + + const loader = new FileCommandLoader(null as unknown as Config); + await loader.loadCommands(signal); + + expect(ShellProcessor).toHaveBeenCalledTimes(1); + expect(DefaultArgumentProcessor).not.toHaveBeenCalled(); + }); + + it('instantiates ShellProcessor and DefaultArgumentProcessor if !{} is present (but not {{args}})', async () => { + const userCommandsDir = Storage.getUserCommandsDir(); + mock({ + [userCommandsDir]: { + 'shell.toml': `prompt = "Prompt with !{cmd}"`, + }, + }); + + const loader = new FileCommandLoader(null as unknown as Config); + await loader.loadCommands(signal); + + expect(ShellProcessor).toHaveBeenCalledTimes(1); + expect(DefaultArgumentProcessor).toHaveBeenCalledTimes(1); + }); + + it('instantiates only ShellProcessor if both {{args}} and !{} are present', async () => { + const userCommandsDir = Storage.getUserCommandsDir(); + mock({ + [userCommandsDir]: { + 'both.toml': `prompt = "Prompt with {{args}} and !{cmd}"`, + }, + }); + + const loader = new FileCommandLoader(null as unknown as Config); + await loader.loadCommands(signal); + + expect(ShellProcessor).toHaveBeenCalledTimes(1); + expect(DefaultArgumentProcessor).not.toHaveBeenCalled(); + }); + + it('instantiates AtFileProcessor and DefaultArgumentProcessor if @{} is present', async () => { + const userCommandsDir = Storage.getUserCommandsDir(); + mock({ + [userCommandsDir]: { + 'at-file.toml': `prompt = "Context: @{./my-file.txt}"`, + }, + }); + + const loader = new FileCommandLoader(null as unknown as Config); + await loader.loadCommands(signal); + + expect(AtFileProcessor).toHaveBeenCalledTimes(1); + expect(ShellProcessor).not.toHaveBeenCalled(); + expect(DefaultArgumentProcessor).toHaveBeenCalledTimes(1); + }); + + it('instantiates ShellProcessor and AtFileProcessor if !{} and @{} are present', async () => { + const userCommandsDir = Storage.getUserCommandsDir(); + mock({ + [userCommandsDir]: { + 'shell-and-at.toml': `prompt = "Run !{cmd} with @{file.txt}"`, + }, + }); + + const loader = new FileCommandLoader(null as unknown as Config); + await loader.loadCommands(signal); + + expect(ShellProcessor).toHaveBeenCalledTimes(1); + expect(AtFileProcessor).toHaveBeenCalledTimes(1); + expect(DefaultArgumentProcessor).toHaveBeenCalledTimes(1); // because no {{args}} + }); + + it('instantiates only ShellProcessor and AtFileProcessor if {{args}} and @{} are present', async () => { + const userCommandsDir = Storage.getUserCommandsDir(); + mock({ + [userCommandsDir]: { + 'args-and-at.toml': `prompt = "Run {{args}} with @{file.txt}"`, + }, + }); + + const loader = new FileCommandLoader(null as unknown as Config); + await loader.loadCommands(signal); + + expect(ShellProcessor).toHaveBeenCalledTimes(1); + expect(AtFileProcessor).toHaveBeenCalledTimes(1); + expect(DefaultArgumentProcessor).not.toHaveBeenCalled(); + }); + }); + + describe('Extension Command Loading', () => { + it('loads commands from active extensions', async () => { + const userCommandsDir = Storage.getUserCommandsDir(); + const projectCommandsDir = new Storage( + process.cwd(), + ).getProjectCommandsDir(); + const extensionDir = path.join( + process.cwd(), + GEMINI_DIR, + 'extensions', + 'test-ext', + ); + + mock({ + [userCommandsDir]: { + 'user.toml': 'prompt = "User command"', + }, + [projectCommandsDir]: { + 'project.toml': 'prompt = "Project command"', + }, + [extensionDir]: { + 'gemini-extension.json': JSON.stringify({ + name: 'test-ext', + version: '1.0.0', + }), + commands: { + 'ext.toml': 'prompt = "Extension command"', + }, + }, + }); + + const mockConfig = { + getProjectRoot: vi.fn(() => process.cwd()), + getExtensions: vi.fn(() => [ + { + name: 'test-ext', + version: '1.0.0', + isActive: true, + path: extensionDir, + }, + ]), + getFolderTrust: vi.fn(() => false), + isTrustedFolder: vi.fn(() => false), + } as unknown as Config; + const loader = new FileCommandLoader(mockConfig); + const commands = await loader.loadCommands(signal); + + expect(commands).toHaveLength(3); + const commandNames = commands.map((cmd) => cmd.name); + expect(commandNames).toEqual(['user', 'project', 'ext']); + + const extCommand = commands.find((cmd) => cmd.name === 'ext'); + expect(extCommand?.extensionName).toBe('test-ext'); + expect(extCommand?.description).toMatch(/^\[test-ext\]/); + }); + + it('extension commands have extensionName metadata for conflict resolution', async () => { + const userCommandsDir = Storage.getUserCommandsDir(); + const projectCommandsDir = new Storage( + process.cwd(), + ).getProjectCommandsDir(); + const extensionDir = path.join( + process.cwd(), + GEMINI_DIR, + 'extensions', + 'test-ext', + ); + + mock({ + [extensionDir]: { + 'gemini-extension.json': JSON.stringify({ + name: 'test-ext', + version: '1.0.0', + }), + commands: { + 'deploy.toml': 'prompt = "Extension deploy command"', + }, + }, + [userCommandsDir]: { + 'deploy.toml': 'prompt = "User deploy command"', + }, + [projectCommandsDir]: { + 'deploy.toml': 'prompt = "Project deploy command"', + }, + }); + + const mockConfig = { + getProjectRoot: vi.fn(() => process.cwd()), + getExtensions: vi.fn(() => [ + { + name: 'test-ext', + version: '1.0.0', + isActive: true, + path: extensionDir, + }, + ]), + getFolderTrust: vi.fn(() => false), + isTrustedFolder: vi.fn(() => false), + } as unknown as Config; + const loader = new FileCommandLoader(mockConfig); + const commands = await loader.loadCommands(signal); + + // Return all commands, even duplicates + expect(commands).toHaveLength(3); + + expect(commands[0].name).toBe('deploy'); + expect(commands[0].extensionName).toBeUndefined(); + const result0 = await commands[0].action?.( + createMockCommandContext({ + invocation: { + raw: '/deploy', + name: 'deploy', + args: '', + }, + }), + '', + ); + expect(result0?.type).toBe('submit_prompt'); + if (result0?.type === 'submit_prompt') { + expect(result0.content).toEqual([{ text: 'User deploy command' }]); + } + + expect(commands[1].name).toBe('deploy'); + expect(commands[1].extensionName).toBeUndefined(); + const result1 = await commands[1].action?.( + createMockCommandContext({ + invocation: { + raw: '/deploy', + name: 'deploy', + args: '', + }, + }), + '', + ); + expect(result1?.type).toBe('submit_prompt'); + if (result1?.type === 'submit_prompt') { + expect(result1.content).toEqual([{ text: 'Project deploy command' }]); + } + + expect(commands[2].name).toBe('deploy'); + expect(commands[2].extensionName).toBe('test-ext'); + expect(commands[2].description).toMatch(/^\[test-ext\]/); + const result2 = await commands[2].action?.( + createMockCommandContext({ + invocation: { + raw: '/deploy', + name: 'deploy', + args: '', + }, + }), + '', + ); + expect(result2?.type).toBe('submit_prompt'); + if (result2?.type === 'submit_prompt') { + expect(result2.content).toEqual([{ text: 'Extension deploy command' }]); + } + }); + + it('only loads commands from active extensions', async () => { + const extensionDir1 = path.join( + process.cwd(), + GEMINI_DIR, + 'extensions', + 'active-ext', + ); + const extensionDir2 = path.join( + process.cwd(), + GEMINI_DIR, + 'extensions', + 'inactive-ext', + ); + + mock({ + [extensionDir1]: { + 'gemini-extension.json': JSON.stringify({ + name: 'active-ext', + version: '1.0.0', + }), + commands: { + 'active.toml': 'prompt = "Active extension command"', + }, + }, + [extensionDir2]: { + 'gemini-extension.json': JSON.stringify({ + name: 'inactive-ext', + version: '1.0.0', + }), + commands: { + 'inactive.toml': 'prompt = "Inactive extension command"', + }, + }, + }); + + const mockConfig = { + getProjectRoot: vi.fn(() => process.cwd()), + getExtensions: vi.fn(() => [ + { + name: 'active-ext', + version: '1.0.0', + isActive: true, + path: extensionDir1, + }, + { + name: 'inactive-ext', + version: '1.0.0', + isActive: false, + path: extensionDir2, + }, + ]), + getFolderTrust: vi.fn(() => false), + isTrustedFolder: vi.fn(() => false), + } as unknown as Config; + const loader = new FileCommandLoader(mockConfig); + const commands = await loader.loadCommands(signal); + + expect(commands).toHaveLength(1); + expect(commands[0].name).toBe('active'); + expect(commands[0].extensionName).toBe('active-ext'); + expect(commands[0].description).toMatch(/^\[active-ext\]/); + }); + + it('handles missing extension commands directory gracefully', async () => { + const extensionDir = path.join( + process.cwd(), + GEMINI_DIR, + 'extensions', + 'no-commands', + ); + + mock({ + [extensionDir]: { + 'gemini-extension.json': JSON.stringify({ + name: 'no-commands', + version: '1.0.0', + }), + // No commands directory + }, + }); + + const mockConfig = { + getProjectRoot: vi.fn(() => process.cwd()), + getExtensions: vi.fn(() => [ + { + name: 'no-commands', + version: '1.0.0', + isActive: true, + path: extensionDir, + }, + ]), + getFolderTrust: vi.fn(() => false), + isTrustedFolder: vi.fn(() => false), + } as unknown as Config; + const loader = new FileCommandLoader(mockConfig); + const commands = await loader.loadCommands(signal); + expect(commands).toHaveLength(0); + }); + + it('handles nested command structure in extensions', async () => { + const extensionDir = path.join( + process.cwd(), + GEMINI_DIR, + 'extensions', + 'a', + ); + + mock({ + [extensionDir]: { + 'gemini-extension.json': JSON.stringify({ + name: 'a', + version: '1.0.0', + }), + commands: { + b: { + 'c.toml': 'prompt = "Nested command from extension a"', + d: { + 'e.toml': 'prompt = "Deeply nested command"', + }, + }, + 'simple.toml': 'prompt = "Simple command"', + }, + }, + }); + + const mockConfig = { + getProjectRoot: vi.fn(() => process.cwd()), + getExtensions: vi.fn(() => [ + { name: 'a', version: '1.0.0', isActive: true, path: extensionDir }, + ]), + getFolderTrust: vi.fn(() => false), + isTrustedFolder: vi.fn(() => false), + } as unknown as Config; + const loader = new FileCommandLoader(mockConfig); + const commands = await loader.loadCommands(signal); + + expect(commands).toHaveLength(3); + + const commandNames = commands.map((cmd) => cmd.name).sort(); + expect(commandNames).toEqual(['b:c', 'b:d:e', 'simple']); + + const nestedCmd = commands.find((cmd) => cmd.name === 'b:c'); + expect(nestedCmd?.extensionName).toBe('a'); + expect(nestedCmd?.description).toMatch(/^\[a\]/); + expect(nestedCmd).toBeDefined(); + const result = await nestedCmd!.action?.( + createMockCommandContext({ + invocation: { + raw: '/b:c', + name: 'b:c', + args: '', + }, + }), + '', + ); + if (result?.type === 'submit_prompt') { + expect(result.content).toEqual([ + { text: 'Nested command from extension a' }, + ]); + } else { + assert.fail('Incorrect action type'); + } + }); + + it('correctly loads extensionId for extension commands', async () => { + const extensionId = 'my-test-ext-id-123'; + const extensionDir = path.join( + process.cwd(), + GEMINI_DIR, + 'extensions', + 'my-test-ext', + ); + + mock({ + [extensionDir]: { + 'gemini-extension.json': JSON.stringify({ + name: 'my-test-ext', + id: extensionId, + version: '1.0.0', + }), + commands: { + 'my-cmd.toml': 'prompt = "My test command"', + }, + }, + }); + + const mockConfig = { + getProjectRoot: vi.fn(() => process.cwd()), + getExtensions: vi.fn(() => [ + { + name: 'my-test-ext', + id: extensionId, + version: '1.0.0', + isActive: true, + path: extensionDir, + }, + ]), + getFolderTrust: vi.fn(() => false), + isTrustedFolder: vi.fn(() => false), + } as unknown as Config; + const loader = new FileCommandLoader(mockConfig); + const commands = await loader.loadCommands(signal); + + expect(commands).toHaveLength(1); + const command = commands[0]; + expect(command.name).toBe('my-cmd'); + expect(command.extensionName).toBe('my-test-ext'); + expect(command.extensionId).toBe(extensionId); + }); + }); + + describe('Argument Handling Integration (via ShellProcessor)', () => { + it('correctly processes a command with {{args}}', async () => { + const userCommandsDir = Storage.getUserCommandsDir(); + mock({ + [userCommandsDir]: { + 'shorthand.toml': + 'prompt = "The user wants to: {{args}}"\ndescription = "Shorthand test"', + }, + }); + + const loader = new FileCommandLoader(null as unknown as Config); + const commands = await loader.loadCommands(signal); + const command = commands.find((c) => c.name === 'shorthand'); + expect(command).toBeDefined(); + + const result = await command!.action?.( + createMockCommandContext({ + invocation: { + raw: '/shorthand do something cool', + name: 'shorthand', + args: 'do something cool', + }, + }), + 'do something cool', + ); + expect(result?.type).toBe('submit_prompt'); + if (result?.type === 'submit_prompt') { + expect(result.content).toEqual([ + { text: 'The user wants to: do something cool' }, + ]); + } + }); + }); + + describe('Default Argument Processor Integration', () => { + it('correctly processes a command without {{args}}', async () => { + const userCommandsDir = Storage.getUserCommandsDir(); + mock({ + [userCommandsDir]: { + 'model_led.toml': + 'prompt = "This is the instruction."\ndescription = "Default processor test"', + }, + }); + + const loader = new FileCommandLoader(null as unknown as Config); + const commands = await loader.loadCommands(signal); + const command = commands.find((c) => c.name === 'model_led'); + expect(command).toBeDefined(); + + const result = await command!.action?.( + createMockCommandContext({ + invocation: { + raw: '/model_led 1.2.0 added "a feature"', + name: 'model_led', + args: '1.2.0 added "a feature"', + }, + }), + '1.2.0 added "a feature"', + ); + expect(result?.type).toBe('submit_prompt'); + if (result?.type === 'submit_prompt') { + const expectedContent = + 'This is the instruction.\n\n/model_led 1.2.0 added "a feature"'; + expect(result.content).toEqual([{ text: expectedContent }]); + } + }); + }); + + describe('Shell Processor Integration', () => { + it('instantiates ShellProcessor if {{args}} is present (even without shell trigger)', async () => { + const userCommandsDir = Storage.getUserCommandsDir(); + mock({ + [userCommandsDir]: { + 'args_only.toml': `prompt = "Hello {{args}}"`, + }, + }); + + const loader = new FileCommandLoader(null as unknown as Config); + await loader.loadCommands(signal); + + expect(ShellProcessor).toHaveBeenCalledWith('args_only'); + }); + it('instantiates ShellProcessor if the trigger is present', async () => { + const userCommandsDir = Storage.getUserCommandsDir(); + mock({ + [userCommandsDir]: { + 'shell.toml': `prompt = "Run this: ${SHELL_INJECTION_TRIGGER}echo hello}"`, + }, + }); + + const loader = new FileCommandLoader(null as unknown as Config); + await loader.loadCommands(signal); + + expect(ShellProcessor).toHaveBeenCalledWith('shell'); + }); + + it('does not instantiate ShellProcessor if no triggers ({{args}} or !{}) are present', async () => { + const userCommandsDir = Storage.getUserCommandsDir(); + mock({ + [userCommandsDir]: { + 'regular.toml': `prompt = "Just a regular prompt"`, + }, + }); + + const loader = new FileCommandLoader(null as unknown as Config); + await loader.loadCommands(signal); + + expect(ShellProcessor).not.toHaveBeenCalled(); + }); + + it('returns a "submit_prompt" action if shell processing succeeds', async () => { + const userCommandsDir = Storage.getUserCommandsDir(); + mock({ + [userCommandsDir]: { + 'shell.toml': `prompt = "Run !{echo 'hello'}"`, + }, + }); + mockShellProcess.mockResolvedValue([{ text: 'Run hello' }]); + + const loader = new FileCommandLoader(null as unknown as Config); + const commands = await loader.loadCommands(signal); + const command = commands.find((c) => c.name === 'shell'); + expect(command).toBeDefined(); + + const result = await command!.action!( + createMockCommandContext({ + invocation: { raw: '/shell', name: 'shell', args: '' }, + }), + '', + ); + + expect(result?.type).toBe('submit_prompt'); + if (result?.type === 'submit_prompt') { + expect(result.content).toEqual([{ text: 'Run hello' }]); + } + }); + + it('returns a "confirm_shell_commands" action if shell processing requires it', async () => { + const userCommandsDir = Storage.getUserCommandsDir(); + const rawInvocation = '/shell rm -rf /'; + mock({ + [userCommandsDir]: { + 'shell.toml': `prompt = "Run !{rm -rf /}"`, + }, + }); + + // Mock the processor to throw the specific error + const error = new ConfirmationRequiredError('Confirmation needed', [ + 'rm -rf /', + ]); + mockShellProcess.mockRejectedValue(error); + + const loader = new FileCommandLoader(null as unknown as Config); + const commands = await loader.loadCommands(signal); + const command = commands.find((c) => c.name === 'shell'); + expect(command).toBeDefined(); + + const result = await command!.action!( + createMockCommandContext({ + invocation: { raw: rawInvocation, name: 'shell', args: 'rm -rf /' }, + }), + 'rm -rf /', + ); + + expect(result?.type).toBe('confirm_shell_commands'); + if (result?.type === 'confirm_shell_commands') { + expect(result.commandsToConfirm).toEqual(['rm -rf /']); + expect(result.originalInvocation.raw).toBe(rawInvocation); + } + }); + + it('re-throws other errors from the processor', async () => { + const userCommandsDir = Storage.getUserCommandsDir(); + mock({ + [userCommandsDir]: { + 'shell.toml': `prompt = "Run !{something}"`, + }, + }); + + const genericError = new Error('Something else went wrong'); + mockShellProcess.mockRejectedValue(genericError); + + const loader = new FileCommandLoader(null as unknown as Config); + const commands = await loader.loadCommands(signal); + const command = commands.find((c) => c.name === 'shell'); + expect(command).toBeDefined(); + + await expect( + command!.action!( + createMockCommandContext({ + invocation: { raw: '/shell', name: 'shell', args: '' }, + }), + '', + ), + ).rejects.toThrow('Something else went wrong'); + }); + it('assembles the processor pipeline in the correct order (AtFile -> Shell -> Default)', async () => { + const userCommandsDir = Storage.getUserCommandsDir(); + mock({ + [userCommandsDir]: { + // This prompt uses !{}, @{}, but NOT {{args}}, so all processors should be active. + 'pipeline.toml': ` + prompt = "Shell says: !{echo foo}. File says: @{./bar.txt}" + `, + }, + './bar.txt': 'bar content', + }); + + const defaultProcessMock = vi + .fn() + .mockImplementation((p: PromptPipelineContent) => + Promise.resolve([ + { text: `${(p[0] as { text: string }).text}-default-processed` }, + ]), + ); + + mockShellProcess.mockImplementation((p: PromptPipelineContent) => + Promise.resolve([ + { text: `${(p[0] as { text: string }).text}-shell-processed` }, + ]), + ); + + mockAtFileProcess.mockImplementation((p: PromptPipelineContent) => + Promise.resolve([ + { text: `${(p[0] as { text: string }).text}-at-file-processed` }, + ]), + ); + + vi.mocked(DefaultArgumentProcessor).mockImplementation( + () => + ({ + process: defaultProcessMock, + }) as unknown as DefaultArgumentProcessor, + ); + + const loader = new FileCommandLoader(null as unknown as Config); + const commands = await loader.loadCommands(signal); + const command = commands.find((c) => c.name === 'pipeline'); + expect(command).toBeDefined(); + + const result = await command!.action!( + createMockCommandContext({ + invocation: { + raw: '/pipeline baz', + name: 'pipeline', + args: 'baz', + }, + }), + 'baz', + ); + + expect(mockAtFileProcess.mock.invocationCallOrder[0]).toBeLessThan( + mockShellProcess.mock.invocationCallOrder[0], + ); + expect(mockShellProcess.mock.invocationCallOrder[0]).toBeLessThan( + defaultProcessMock.mock.invocationCallOrder[0], + ); + + // Verify the flow of the prompt through the processors + // 1. AtFile processor runs first + expect(mockAtFileProcess).toHaveBeenCalledWith( + [{ text: expect.stringContaining('@{./bar.txt}') }], + expect.any(Object), + ); + // 2. Shell processor runs second + expect(mockShellProcess).toHaveBeenCalledWith( + [{ text: expect.stringContaining('-at-file-processed') }], + expect.any(Object), + ); + // 3. Default processor runs third + expect(defaultProcessMock).toHaveBeenCalledWith( + [{ text: expect.stringContaining('-shell-processed') }], + expect.any(Object), + ); + + if (result?.type === 'submit_prompt') { + const contentAsArray = Array.isArray(result.content) + ? result.content + : [result.content]; + expect(contentAsArray.length).toBeGreaterThan(0); + const firstPart = contentAsArray[0]; + + if (typeof firstPart === 'object' && firstPart && 'text' in firstPart) { + expect(firstPart.text).toContain( + '-at-file-processed-shell-processed-default-processed', + ); + } else { + assert.fail( + 'First part of content is not a text part or is a string', + ); + } + } else { + assert.fail('Incorrect action type'); + } + }); + }); + + describe('@-file Processor Integration', () => { + it('correctly processes a command with @{file}', async () => { + const userCommandsDir = Storage.getUserCommandsDir(); + mock({ + [userCommandsDir]: { + 'at-file.toml': + 'prompt = "Context from file: @{./test.txt}"\ndescription = "@-file test"', + }, + './test.txt': 'file content', + }); + + mockAtFileProcess.mockImplementation( + async (prompt: PromptPipelineContent) => { + // A simplified mock of AtFileProcessor's behavior + const textContent = (prompt[0] as { text: string }).text; + if (textContent.includes('@{./test.txt}')) { + return [ + { + text: textContent.replace('@{./test.txt}', 'file content'), + }, + ]; + } + return prompt; + }, + ); + + // Prevent default processor from interfering + vi.mocked(DefaultArgumentProcessor).mockImplementation( + () => + ({ + process: (p: PromptPipelineContent) => Promise.resolve(p), + }) as unknown as DefaultArgumentProcessor, + ); + + const loader = new FileCommandLoader(null as unknown as Config); + const commands = await loader.loadCommands(signal); + const command = commands.find((c) => c.name === 'at-file'); + expect(command).toBeDefined(); + + const result = await command!.action?.( + createMockCommandContext({ + invocation: { + raw: '/at-file', + name: 'at-file', + args: '', + }, + }), + '', + ); + expect(result?.type).toBe('submit_prompt'); + if (result?.type === 'submit_prompt') { + expect(result.content).toEqual([ + { text: 'Context from file: file content' }, + ]); + } + }); + }); + + describe('with folder trust enabled', () => { + it('loads multiple commands', async () => { + const mockConfig = { + getProjectRoot: vi.fn(() => '/path/to/project'), + getExtensions: vi.fn(() => []), + getFolderTrust: vi.fn(() => true), + isTrustedFolder: vi.fn(() => true), + } as unknown as Config; + const userCommandsDir = Storage.getUserCommandsDir(); + mock({ + [userCommandsDir]: { + 'test1.toml': 'prompt = "Prompt 1"', + 'test2.toml': 'prompt = "Prompt 2"', + }, + }); + + const loader = new FileCommandLoader(mockConfig); + const commands = await loader.loadCommands(signal); + + expect(commands).toHaveLength(2); + }); + + it('allows global user commands but excludes project and extension commands when folder is not trusted', async () => { + const userCommandsDir = Storage.getUserCommandsDir(); + const projectCommandsDir = path.join( + '/path/to/project', + '.gemini', + 'commands', + ); + const mockConfig = { + getProjectRoot: vi.fn(() => '/path/to/project'), + getExtensions: vi.fn(() => [ + { + name: 'ext1', + id: 'ext1-id', + path: '/path/to/ext1', + isActive: true, + }, + ]), + getFolderTrust: vi.fn(() => true), + isTrustedFolder: vi.fn(() => false), + } as unknown as Config; + + mock({ + [userCommandsDir]: { + 'user1.toml': 'prompt = "User Prompt 1"', + 'user2.toml': 'prompt = "User Prompt 2"', + }, + [projectCommandsDir]: { + 'project1.toml': 'prompt = "Project Prompt 1"', + }, + '/path/to/ext1/commands': { + 'ext_cmd.toml': 'prompt = "Extension Prompt"', + }, + }); + + const loader = new FileCommandLoader(mockConfig); + const commands = await loader.loadCommands(signal); + + expect(commands).toHaveLength(2); + expect(commands.map((c) => c.name).sort()).toEqual(['user1', 'user2']); + }); + + it('allows global user commands in listAvailableFiles when folder is not trusted', async () => { + const userCommandsDir = Storage.getUserCommandsDir(); + const projectCommandsDir = path.join( + '/path/to/project', + '.gemini', + 'commands', + ); + mock({ + [userCommandsDir]: { + 'user_cmd.toml': 'prompt = "User prompt"', + }, + [projectCommandsDir]: { + 'project_cmd.toml': 'prompt = "Project prompt"', + }, + }); + + const mockConfig = { + getProjectRoot: vi.fn(() => '/path/to/project'), + getExtensions: vi.fn(() => []), + getFolderTrust: vi.fn(() => true), + isTrustedFolder: vi.fn(() => false), + } as unknown as Config; + + const loader = new FileCommandLoader(mockConfig); + const groups = await loader.listAvailableFiles(); + + expect(groups).toHaveLength(1); + expect(groups[0]?.displayName).toBe('User'); + expect(groups[0]?.files).toEqual(['user_cmd.toml']); + }); + }); + + describe('Aborted signal', () => { + it('does not log errors if the signal is aborted', async () => { + const controller = new AbortController(); + const abortSignal = controller.signal; + + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + const mockConfig = { + getProjectRoot: vi.fn(() => '/path/to/project'), + getExtensions: vi.fn(() => []), + getFolderTrust: vi.fn(() => false), + isTrustedFolder: vi.fn(() => false), + } as unknown as Config; + + // Set up mock-fs so that the loader attempts to read a directory. + const userCommandsDir = Storage.getUserCommandsDir(); + mock({ + [userCommandsDir]: { + 'test1.toml': 'prompt = "Prompt 1"', + }, + }); + + const loader = new FileCommandLoader(mockConfig); + + // Mock glob to throw an AbortError + const abortError = new DOMException('Aborted', 'AbortError'); + vi.mocked(glob.glob).mockImplementation(async () => { + controller.abort(); // Ensure the signal is aborted when the service checks + throw abortError; + }); + + await loader.loadCommands(abortSignal); + + expect(consoleErrorSpy).not.toHaveBeenCalled(); + + consoleErrorSpy.mockRestore(); + }); + }); + + describe('Sanitization', () => { + it('sanitizes command names from filenames containing control characters', async () => { + const userCommandsDir = Storage.getUserCommandsDir(); + mock({ + [userCommandsDir]: { + 'test\twith\nnewlines.toml': 'prompt = "Test prompt"', + }, + }); + + const loader = new FileCommandLoader(null); + const commands = await loader.loadCommands(signal); + expect(commands).toHaveLength(1); + // Non-alphanumeric characters (except - and .) become underscores + expect(commands[0].name).toBe('test_with_newlines'); + }); + + it('truncates excessively long filenames', async () => { + const userCommandsDir = Storage.getUserCommandsDir(); + const longName = 'a'.repeat(60) + '.toml'; + mock({ + [userCommandsDir]: { + [longName]: 'prompt = "Test prompt"', + }, + }); + + const loader = new FileCommandLoader(null); + const commands = await loader.loadCommands(signal); + expect(commands).toHaveLength(1); + expect(commands[0].name.length).toBe(50); + expect(commands[0].name).toBe('a'.repeat(47) + '...'); + }); + + it('sanitizes descriptions containing newlines and ANSI codes', async () => { + const userCommandsDir = Storage.getUserCommandsDir(); + mock({ + [userCommandsDir]: { + 'test.toml': + 'prompt = "Test"\ndescription = "Line 1\\nLine 2\\tTabbed\\r\\n\\u001B[31mRed text\\u001B[0m"', + }, + }); + + const loader = new FileCommandLoader(null); + const commands = await loader.loadCommands(signal); + expect(commands).toHaveLength(1); + // Newlines and tabs become spaces, ANSI is stripped + expect(commands[0].description).toBe('Line 1 Line 2 Tabbed Red text'); + }); + + it('truncates long descriptions', async () => { + const userCommandsDir = Storage.getUserCommandsDir(); + const longDesc = 'd'.repeat(150); + mock({ + [userCommandsDir]: { + 'test.toml': `prompt = "Test"\ndescription = "${longDesc}"`, + }, + }); + + const loader = new FileCommandLoader(null); + const commands = await loader.loadCommands(signal); + expect(commands).toHaveLength(1); + expect(commands[0].description.length).toBe(100); + expect(commands[0].description).toBe('d'.repeat(97) + '...'); + }); + }); +}); diff --git a/packages/cli/src/services/FileCommandLoader.ts b/packages/cli/src/services/FileCommandLoader.ts new file mode 100644 index 0000000000000000000000000000000000000000..dfa03c14c7bb2d838bf0c1cb76e43ea8786da852 --- /dev/null +++ b/packages/cli/src/services/FileCommandLoader.ts @@ -0,0 +1,413 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import toml from '@iarna/toml'; +import { glob } from 'glob'; +import { z } from 'zod'; +import { Storage, coreEvents, type Config } from '@google/gemini-cli-core'; +import type { ICommandLoader } from './types.js'; +import type { + CommandContext, + SlashCommand, + SlashCommandActionReturn, +} from '../ui/commands/types.js'; +import { CommandKind } from '../ui/commands/types.js'; +import { DefaultArgumentProcessor } from './prompt-processors/argumentProcessor.js'; +import type { + IPromptProcessor, + PromptPipelineContent, +} from './prompt-processors/types.js'; +import { + SHORTHAND_ARGS_PLACEHOLDER, + SHELL_INJECTION_TRIGGER, + AT_FILE_INJECTION_TRIGGER, +} from './prompt-processors/types.js'; +import { + ConfirmationRequiredError, + ShellProcessor, +} from './prompt-processors/shellProcessor.js'; +import { AtFileProcessor } from './prompt-processors/atFileProcessor.js'; +import { sanitizeForDisplay } from '../ui/utils/textUtils.js'; + +export interface CommandDirectory { + path: string; + kind: CommandKind; + extensionName?: string; + extensionId?: string; +} + +export interface CommandFileGroup { + displayName: string; + path: string; + files: string[]; + error?: string; +} + +/** + * Defines the Zod schema for a command definition file. This serves as the + * single source of truth for both validation and type inference. + */ +const TomlCommandDefSchema = z.object({ + prompt: z.string({ + required_error: "The 'prompt' field is required.", + invalid_type_error: "The 'prompt' field must be a string.", + }), + description: z.string().optional(), +}); + +/** + * Discovers and loads custom slash commands from .toml files in both the + * user's global config directory and the current project's directory. + * + * This loader is responsible for: + * - Recursively scanning command directories. + * - Parsing and validating TOML files. + * - Adapting valid definitions into executable SlashCommand objects. + * - Handling file system errors and malformed files gracefully. + */ +export class FileCommandLoader implements ICommandLoader { + private readonly projectRoot: string; + private readonly folderTrustEnabled: boolean; + private readonly isTrustedFolder: boolean; + + constructor(private readonly config: Config | null) { + this.folderTrustEnabled = !!config?.getFolderTrust(); + this.isTrustedFolder = !!config?.isTrustedFolder(); + this.projectRoot = config?.getProjectRoot() || process.cwd(); + } + + /** + * Loads all commands from user, project, and extension directories. + * Returns commands in order: user → project → extensions (alphabetically). + * + * Order is important for conflict resolution in CommandService: + * - User/project commands (without extensionName) use "last wins" strategy + * - Extension commands (with extensionName) get renamed if conflicts exist + * + * @param signal An AbortSignal to cancel the loading process. + * @returns A promise that resolves to an array of all loaded SlashCommands. + */ + async loadCommands(signal: AbortSignal): Promise { + const allCommands: SlashCommand[] = []; + const globOptions = { + nodir: true, + dot: true, + signal, + follow: true, + }; + + // Load commands from each directory + const commandDirs = this.getCommandDirectories(); + for (const dirInfo of commandDirs) { + try { + const files = await glob('**/*.toml', { + ...globOptions, + cwd: dirInfo.path, + }); + + const commandPromises = files.map((file) => + this.parseAndAdaptFile( + path.join(dirInfo.path, file), + dirInfo.path, + dirInfo.kind, + dirInfo.extensionName, + dirInfo.extensionId, + ), + ); + + const commands = (await Promise.all(commandPromises)).filter( + (cmd): cmd is SlashCommand => cmd !== null, + ); + + // Add all commands without deduplication + allCommands.push(...commands); + } catch (error) { + if ( + !signal.aborted && + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + (error as { code?: string })?.code !== 'ENOENT' + ) { + coreEvents.emitFeedback( + 'error', + `[FileCommandLoader] Error loading commands from ${dirInfo.path}:`, + error, + ); + } + } + } + + return allCommands; + } + + /** + * Lists available .toml command files from user, project, and extension directories. + */ + async listAvailableFiles(): Promise { + const directories = this.getCommandDirectories(); + const groups: CommandFileGroup[] = []; + + for (const dir of directories) { + const displayName = this.getDisplayName(dir); + + try { + const files = await glob('**/*.toml', { cwd: dir.path }); + if (files.length > 0) { + groups.push({ + displayName, + path: dir.path, + files: [...files].sort(), + }); + } + } catch (e) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + if ((e as { code?: string }).code === 'ENOENT') { + continue; + } + + groups.push({ + displayName, + path: dir.path, + files: [], + error: e instanceof Error ? e.message : String(e), + }); + } + } + + return groups; + } + + /** + * Returns a human-readable display name for the command directory source. + */ + private getDisplayName(dir: CommandDirectory): string { + switch (dir.kind) { + case CommandKind.USER_FILE: + return 'User'; + case CommandKind.WORKSPACE_FILE: + return 'Project'; + case CommandKind.EXTENSION_FILE: + return `Extension: ${dir.extensionName || 'Unknown'}`; + default: + return 'Custom'; + } + } + + /** + * Get all command directories in order for loading. + * User commands → Project commands → Extension commands + * This order ensures extension commands can detect all conflicts. + */ + private getCommandDirectories(): CommandDirectory[] { + const dirs: CommandDirectory[] = []; + + const storage = this.config?.storage ?? new Storage(this.projectRoot); + + // 1. User commands + const userCommandsDir = Storage.getUserCommandsDir(); + dirs.push({ + path: userCommandsDir, + kind: CommandKind.USER_FILE, + }); + + // 2. Project commands (skip if same directory as user commands, e.g. when + // cwd is the user's home directory, to avoid false conflict warnings) + if ( + !storage.isWorkspaceHomeDir() && + (!this.folderTrustEnabled || this.isTrustedFolder) + ) { + dirs.push({ + path: storage.getProjectCommandsDir(), + kind: CommandKind.WORKSPACE_FILE, + }); + } + + // 3. Extension commands (processed last to detect all conflicts) + if (this.config && (!this.folderTrustEnabled || this.isTrustedFolder)) { + const activeExtensions = this.config + .getExtensions() + .filter((ext) => ext.isActive) + .sort((a, b) => a.name.localeCompare(b.name)); // Sort alphabetically for deterministic loading + + const extensionCommandDirs = activeExtensions.map((ext) => ({ + path: path.join(ext.path, 'commands'), + kind: CommandKind.EXTENSION_FILE, + extensionName: ext.name, + extensionId: ext.id, + })); + + dirs.push(...extensionCommandDirs); + } + + return dirs; + } + + /** + * Parses a single .toml file and transforms it into a SlashCommand object. + * @param filePath The absolute path to the .toml file. + * @param baseDir The root command directory for name calculation. + * @param kind The CommandKind. + * @param extensionName Optional extension name to prefix commands with. + * @returns A promise resolving to a SlashCommand, or null if the file is invalid. + */ + private async parseAndAdaptFile( + filePath: string, + baseDir: string, + kind: CommandKind, + extensionName?: string, + extensionId?: string, + ): Promise { + let fileContent: string; + try { + fileContent = await fs.readFile(filePath, 'utf-8'); + } catch (error: unknown) { + coreEvents.emitFeedback( + 'error', + `[FileCommandLoader] Failed to read file ${filePath}:`, + error instanceof Error ? error.message : String(error), + ); + return null; + } + + let parsed: unknown; + try { + parsed = toml.parse(fileContent); + } catch (error: unknown) { + coreEvents.emitFeedback( + 'error', + `[FileCommandLoader] Failed to parse TOML file ${filePath}:`, + error instanceof Error ? error.message : String(error), + ); + return null; + } + + const validationResult = TomlCommandDefSchema.safeParse(parsed); + + if (!validationResult.success) { + coreEvents.emitFeedback( + 'error', + `[FileCommandLoader] Skipping invalid command file: ${filePath}. Validation errors:`, + validationResult.error.flatten(), + ); + return null; + } + + const validDef = validationResult.data; + + const relativePathWithExt = path.relative(baseDir, filePath); + const relativePath = relativePathWithExt.substring( + 0, + relativePathWithExt.length - 5, // length of '.toml' + ); + const baseCommandName = relativePath + .split(path.sep) + // Sanitize each path segment to prevent ambiguity, replacing non-allowlisted characters with underscores. + // Since ':' is our namespace separator, this ensures that colons do not cause naming conflicts. + .map((segment) => { + let sanitized = segment.replace(/[^a-zA-Z0-9_\-.]/g, '_'); + + // Truncate excessively long segments to prevent UI overflow + if (sanitized.length > 50) { + sanitized = sanitized.substring(0, 47) + '...'; + } + return sanitized; + }) + .join(':'); + + // Add extension name tag for extension commands + const defaultDescription = `Custom command from ${path.basename(filePath)}`; + let description = validDef.description || defaultDescription; + + description = sanitizeForDisplay(description, 100); + + if (extensionName) { + description = `[${extensionName}] ${description}`; + } + + const processors: IPromptProcessor[] = []; + const usesArgs = validDef.prompt.includes(SHORTHAND_ARGS_PLACEHOLDER); + const usesShellInjection = validDef.prompt.includes( + SHELL_INJECTION_TRIGGER, + ); + const usesAtFileInjection = validDef.prompt.includes( + AT_FILE_INJECTION_TRIGGER, + ); + + // 1. @-File Injection (Security First). + // This runs first to ensure we're not executing shell commands that + // could dynamically generate malicious @-paths. + if (usesAtFileInjection) { + processors.push(new AtFileProcessor(baseCommandName)); + } + + // 2. Argument and Shell Injection. + // This runs after file content has been safely injected. + if (usesShellInjection || usesArgs) { + processors.push(new ShellProcessor(baseCommandName)); + } + + // 3. Default Argument Handling. + // Appends the raw invocation if no explicit {{args}} are used. + if (!usesArgs) { + processors.push(new DefaultArgumentProcessor()); + } + + return { + name: baseCommandName, + description, + kind, + extensionName, + extensionId, + action: async ( + context: CommandContext, + _args: string, + ): Promise => { + if (!context.invocation) { + coreEvents.emitFeedback( + 'error', + `[FileCommandLoader] Critical error: Command '${baseCommandName}' was executed without invocation context.`, + ); + return { + type: 'submit_prompt', + content: [{ text: validDef.prompt }], // Fallback to unprocessed prompt + }; + } + + try { + let processedContent: PromptPipelineContent = [ + { text: validDef.prompt }, + ]; + for (const processor of processors) { + processedContent = await processor.process( + processedContent, + context, + ); + } + + return { + type: 'submit_prompt', + content: processedContent, + }; + } catch (e) { + // Check if it's our specific error type + if (e instanceof ConfirmationRequiredError) { + // Halt and request confirmation from the UI layer. + return { + type: 'confirm_shell_commands', + commandsToConfirm: e.commandsToConfirm, + originalInvocation: { + raw: context.invocation.raw, + }, + }; + } + // Re-throw other errors to be handled by the global error handler. + throw e; + } + }, + }; + } +} diff --git a/packages/cli/src/services/McpPromptLoader.test.ts b/packages/cli/src/services/McpPromptLoader.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..1db67e527d65b0d0b0297a0926a6b03feabba049 --- /dev/null +++ b/packages/cli/src/services/McpPromptLoader.test.ts @@ -0,0 +1,487 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { McpPromptLoader } from './McpPromptLoader.js'; +import type { Config } from '@google/gemini-cli-core'; +import type { PromptArgument } from '@modelcontextprotocol/sdk/types.js'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { CommandKind, type CommandContext } from '../ui/commands/types.js'; +import * as cliCore from '@google/gemini-cli-core'; + +// Define the mock prompt data at a higher scope +const mockPrompt = { + name: 'test-prompt', + description: 'A test prompt.', + serverName: 'test-server', + arguments: [ + { name: 'name', required: true, description: "The animal's name." }, + { name: 'age', required: true, description: "The animal's age." }, + { name: 'species', required: true, description: "The animal's species." }, + { + name: 'enclosure', + required: false, + description: "The animal's enclosure.", + }, + { name: 'trail', required: false, description: "The animal's trail." }, + ], + invoke: vi.fn().mockResolvedValue({ + messages: [{ content: { type: 'text', text: 'Hello, world!' } }], + }), +}; + +describe('McpPromptLoader', () => { + const mockConfig = {} as Config; + + // Use a beforeEach to set up and clean a spy for each test + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(cliCore, 'getMCPServerPrompts').mockReturnValue([mockPrompt]); + }); + + // --- `parseArgs` tests remain the same --- + + describe('parseArgs', () => { + it('should handle multi-word positional arguments', () => { + const loader = new McpPromptLoader(mockConfig); + const promptArgs: PromptArgument[] = [ + { name: 'arg1', required: true }, + { name: 'arg2', required: true }, + ]; + const userArgs = 'hello world'; + const result = loader.parseArgs(userArgs, promptArgs); + expect(result).toEqual({ arg1: 'hello', arg2: 'world' }); + }); + + it('should handle quoted multi-word positional arguments', () => { + const loader = new McpPromptLoader(mockConfig); + const promptArgs: PromptArgument[] = [ + { name: 'arg1', required: true }, + { name: 'arg2', required: true }, + ]; + const userArgs = '"hello world" foo'; + const result = loader.parseArgs(userArgs, promptArgs); + expect(result).toEqual({ arg1: 'hello world', arg2: 'foo' }); + }); + + it('should handle a single positional argument with multiple words', () => { + const loader = new McpPromptLoader(mockConfig); + const promptArgs: PromptArgument[] = [{ name: 'arg1', required: true }]; + const userArgs = 'hello world'; + const result = loader.parseArgs(userArgs, promptArgs); + expect(result).toEqual({ arg1: 'hello world' }); + }); + + it('should handle escaped quotes in positional arguments', () => { + const loader = new McpPromptLoader(mockConfig); + const promptArgs: PromptArgument[] = [{ name: 'arg1', required: true }]; + const userArgs = '"hello \\"world\\""'; + const result = loader.parseArgs(userArgs, promptArgs); + expect(result).toEqual({ arg1: 'hello "world"' }); + }); + + it('should handle escaped backslashes in positional arguments', () => { + const loader = new McpPromptLoader(mockConfig); + const promptArgs: PromptArgument[] = [{ name: 'arg1', required: true }]; + const userArgs = '"hello\\\\world"'; + const result = loader.parseArgs(userArgs, promptArgs); + expect(result).toEqual({ arg1: 'hello\\world' }); + }); + + it('should handle named args followed by positional args', () => { + const loader = new McpPromptLoader(mockConfig); + const promptArgs: PromptArgument[] = [ + { name: 'named', required: true }, + { name: 'pos', required: true }, + ]; + const userArgs = '--named="value" positional'; + const result = loader.parseArgs(userArgs, promptArgs); + expect(result).toEqual({ named: 'value', pos: 'positional' }); + }); + + it('should handle positional args followed by named args', () => { + const loader = new McpPromptLoader(mockConfig); + const promptArgs: PromptArgument[] = [ + { name: 'pos', required: true }, + { name: 'named', required: true }, + ]; + const userArgs = 'positional --named="value"'; + const result = loader.parseArgs(userArgs, promptArgs); + expect(result).toEqual({ pos: 'positional', named: 'value' }); + }); + + it('should handle positional args interspersed with named args', () => { + const loader = new McpPromptLoader(mockConfig); + const promptArgs: PromptArgument[] = [ + { name: 'pos1', required: true }, + { name: 'named', required: true }, + { name: 'pos2', required: true }, + ]; + const userArgs = 'p1 --named="value" p2'; + const result = loader.parseArgs(userArgs, promptArgs); + expect(result).toEqual({ pos1: 'p1', named: 'value', pos2: 'p2' }); + }); + + it('should treat an escaped quote at the start as a literal', () => { + const loader = new McpPromptLoader(mockConfig); + const promptArgs: PromptArgument[] = [ + { name: 'arg1', required: true }, + { name: 'arg2', required: true }, + ]; + const userArgs = '\\"hello world'; + const result = loader.parseArgs(userArgs, promptArgs); + expect(result).toEqual({ arg1: '"hello', arg2: 'world' }); + }); + + it('should handle a complex mix of args', () => { + const loader = new McpPromptLoader(mockConfig); + const promptArgs: PromptArgument[] = [ + { name: 'pos1', required: true }, + { name: 'named1', required: true }, + { name: 'pos2', required: true }, + { name: 'named2', required: true }, + { name: 'pos3', required: true }, + ]; + const userArgs = + 'p1 --named1="value 1" "p2 has spaces" --named2=value2 "p3 \\"with quotes\\""'; + const result = loader.parseArgs(userArgs, promptArgs); + expect(result).toEqual({ + pos1: 'p1', + named1: 'value 1', + pos2: 'p2 has spaces', + named2: 'value2', + pos3: 'p3 "with quotes"', + }); + }); + }); + + describe('loadCommands', () => { + const mockConfigWithPrompts = { + getMcpClientManager: () => ({ + getMcpServers: () => ({ + 'test-server': { httpUrl: 'https://test-server.com' }, + }), + }), + } as unknown as Config; + + it('should load prompts as slash commands', async () => { + const loader = new McpPromptLoader(mockConfigWithPrompts); + const commands = await loader.loadCommands(new AbortController().signal); + expect(commands).toHaveLength(1); + expect(commands[0].name).toBe('test-prompt'); + expect(commands[0].description).toBe('A test prompt.'); + expect(commands[0].kind).toBe(CommandKind.MCP_PROMPT); + }); + + it('should sanitize prompt names by replacing spaces with hyphens', async () => { + const mockPromptWithSpaces = { + ...mockPrompt, + name: 'Prompt Name', + }; + vi.spyOn(cliCore, 'getMCPServerPrompts').mockReturnValue([ + mockPromptWithSpaces, + ]); + + const loader = new McpPromptLoader(mockConfigWithPrompts); + const commands = await loader.loadCommands(new AbortController().signal); + + expect(commands).toHaveLength(1); + expect(commands[0].name).toBe('Prompt-Name'); + expect(commands[0].kind).toBe(CommandKind.MCP_PROMPT); + }); + + it('should trim whitespace from prompt names before sanitizing', async () => { + const mockPromptWithWhitespace = { + ...mockPrompt, + name: ' Prompt Name ', + }; + vi.spyOn(cliCore, 'getMCPServerPrompts').mockReturnValue([ + mockPromptWithWhitespace, + ]); + + const loader = new McpPromptLoader(mockConfigWithPrompts); + const commands = await loader.loadCommands(new AbortController().signal); + + expect(commands).toHaveLength(1); + expect(commands[0].name).toBe('Prompt-Name'); + expect(commands[0].kind).toBe(CommandKind.MCP_PROMPT); + }); + + it('should handle prompt invocation successfully', async () => { + const loader = new McpPromptLoader(mockConfigWithPrompts); + const commands = await loader.loadCommands(new AbortController().signal); + const action = commands[0].action!; + const context = {} as CommandContext; + const result = await action(context, 'test-name 123 tiger'); + expect(mockPrompt.invoke).toHaveBeenCalledWith({ + name: 'test-name', + age: '123', + species: 'tiger', + }); + expect(result).toEqual({ + type: 'submit_prompt', + content: JSON.stringify('Hello, world!'), + }); + }); + + it('should return an error for missing required arguments', async () => { + const loader = new McpPromptLoader(mockConfigWithPrompts); + const commands = await loader.loadCommands(new AbortController().signal); + const action = commands[0].action!; + const context = {} as CommandContext; + const result = await action(context, 'test-name'); + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Missing required argument(s): --age, --species', + }); + }); + + it('should return an error message if prompt invocation fails', async () => { + vi.spyOn(mockPrompt, 'invoke').mockRejectedValue( + new Error('Invocation failed!'), + ); + const loader = new McpPromptLoader(mockConfigWithPrompts); + const commands = await loader.loadCommands(new AbortController().signal); + const action = commands[0].action!; + const context = {} as CommandContext; + const result = await action(context, 'test-name 123 tiger'); + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Error: Invocation failed!', + }); + }); + + it('should return an empty array if config is not available', async () => { + const loader = new McpPromptLoader(null); + const commands = await loader.loadCommands(new AbortController().signal); + expect(commands).toEqual([]); + }); + + describe('autoExecute', () => { + it('should set autoExecute to true for prompts with no arguments (undefined)', async () => { + vi.spyOn(cliCore, 'getMCPServerPrompts').mockReturnValue([ + { ...mockPrompt, arguments: undefined }, + ]); + const loader = new McpPromptLoader(mockConfigWithPrompts); + const commands = await loader.loadCommands( + new AbortController().signal, + ); + expect(commands[0].autoExecute).toBe(true); + }); + + it('should set autoExecute to true for prompts with empty arguments array', async () => { + vi.spyOn(cliCore, 'getMCPServerPrompts').mockReturnValue([ + { ...mockPrompt, arguments: [] }, + ]); + const loader = new McpPromptLoader(mockConfigWithPrompts); + const commands = await loader.loadCommands( + new AbortController().signal, + ); + expect(commands[0].autoExecute).toBe(true); + }); + + it('should set autoExecute to false for prompts with only optional arguments', async () => { + vi.spyOn(cliCore, 'getMCPServerPrompts').mockReturnValue([ + { + ...mockPrompt, + arguments: [{ name: 'optional', required: false }], + }, + ]); + const loader = new McpPromptLoader(mockConfigWithPrompts); + const commands = await loader.loadCommands( + new AbortController().signal, + ); + expect(commands[0].autoExecute).toBe(false); + }); + + it('should set autoExecute to false for prompts with required arguments', async () => { + vi.spyOn(cliCore, 'getMCPServerPrompts').mockReturnValue([ + { + ...mockPrompt, + arguments: [{ name: 'required', required: true }], + }, + ]); + const loader = new McpPromptLoader(mockConfigWithPrompts); + const commands = await loader.loadCommands( + new AbortController().signal, + ); + expect(commands[0].autoExecute).toBe(false); + }); + }); + + describe('completion', () => { + it('should suggest no arguments when using positional arguments', async () => { + const loader = new McpPromptLoader(mockConfigWithPrompts); + const commands = await loader.loadCommands( + new AbortController().signal, + ); + const completion = commands[0].completion!; + const context = {} as CommandContext; + const suggestions = await completion(context, 'test-name 6 tiger'); + expect(suggestions).toEqual([]); + }); + + it('should suggest all arguments when none are present', async () => { + const loader = new McpPromptLoader(mockConfigWithPrompts); + const commands = await loader.loadCommands( + new AbortController().signal, + ); + const completion = commands[0].completion!; + const context = { + invocation: { + raw: '/find ', + name: 'find', + args: '', + }, + } as CommandContext; + const suggestions = await completion(context, ''); + expect(suggestions).toEqual([ + '--name="', + '--age="', + '--species="', + '--enclosure="', + '--trail="', + ]); + }); + + it('should suggest remaining arguments when some are present', async () => { + const loader = new McpPromptLoader(mockConfigWithPrompts); + const commands = await loader.loadCommands( + new AbortController().signal, + ); + const completion = commands[0].completion!; + const context = { + invocation: { + raw: '/find --name="test-name" --age="6" ', + name: 'find', + args: '--name="test-name" --age="6"', + }, + } as CommandContext; + const suggestions = await completion(context, ''); + expect(suggestions).toEqual([ + '--species="', + '--enclosure="', + '--trail="', + ]); + }); + + it('should suggest no arguments when all are present', async () => { + const loader = new McpPromptLoader(mockConfigWithPrompts); + const commands = await loader.loadCommands( + new AbortController().signal, + ); + const completion = commands[0].completion!; + const context = {} as CommandContext; + const suggestions = await completion( + context, + '--name="test-name" --age="6" --species="tiger" --enclosure="Tiger Den" --trail="Jungle"', + ); + expect(suggestions).toEqual([]); + }); + + it('should suggest nothing for prompts with no arguments', async () => { + // Temporarily override the mock to return a prompt with no args + vi.spyOn(cliCore, 'getMCPServerPrompts').mockReturnValue([ + { ...mockPrompt, arguments: [] }, + ]); + const loader = new McpPromptLoader(mockConfigWithPrompts); + const commands = await loader.loadCommands( + new AbortController().signal, + ); + const completion = commands[0].completion!; + const context = {} as CommandContext; + const suggestions = await completion(context, ''); + expect(suggestions).toEqual([]); + }); + + it('should suggest arguments matching a partial argument', async () => { + const loader = new McpPromptLoader(mockConfigWithPrompts); + const commands = await loader.loadCommands( + new AbortController().signal, + ); + const completion = commands[0].completion!; + const context = { + invocation: { + raw: '/find --s', + name: 'find', + args: '--s', + }, + } as CommandContext; + const suggestions = await completion(context, '--s'); + expect(suggestions).toEqual(['--species="']); + }); + + it('should suggest arguments even when a partial argument is parsed as a value', async () => { + const loader = new McpPromptLoader(mockConfigWithPrompts); + const commands = await loader.loadCommands( + new AbortController().signal, + ); + const completion = commands[0].completion!; + const context = { + invocation: { + raw: '/find --name="test" --a', + name: 'find', + args: '--name="test" --a', + }, + } as CommandContext; + const suggestions = await completion(context, '--a'); + expect(suggestions).toEqual(['--age="']); + }); + + it('should auto-close the quote for a named argument value', async () => { + const loader = new McpPromptLoader(mockConfigWithPrompts); + const commands = await loader.loadCommands( + new AbortController().signal, + ); + const completion = commands[0].completion!; + const context = { + invocation: { + raw: '/find --name="test', + name: 'find', + args: '--name="test', + }, + } as CommandContext; + const suggestions = await completion(context, '--name="test'); + expect(suggestions).toEqual(['--name="test"']); + }); + + it('should auto-close the quote for an empty named argument value', async () => { + const loader = new McpPromptLoader(mockConfigWithPrompts); + const commands = await loader.loadCommands( + new AbortController().signal, + ); + const completion = commands[0].completion!; + const context = { + invocation: { + raw: '/find --name="', + name: 'find', + args: '--name="', + }, + } as CommandContext; + const suggestions = await completion(context, '--name="'); + expect(suggestions).toEqual(['--name=""']); + }); + + it('should not add a quote if already present', async () => { + const loader = new McpPromptLoader(mockConfigWithPrompts); + const commands = await loader.loadCommands( + new AbortController().signal, + ); + const completion = commands[0].completion!; + const context = { + invocation: { + raw: '/find --name="test"', + name: 'find', + args: '--name="test"', + }, + } as CommandContext; + const suggestions = await completion(context, '--name="test"'); + expect(suggestions).toEqual([]); + }); + }); + }); +}); diff --git a/packages/cli/src/services/McpPromptLoader.ts b/packages/cli/src/services/McpPromptLoader.ts new file mode 100644 index 0000000000000000000000000000000000000000..0218e0fefd672cbb7cafe3d9657db80951407984 --- /dev/null +++ b/packages/cli/src/services/McpPromptLoader.ts @@ -0,0 +1,306 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + getErrorMessage, + getMCPServerPrompts, + type Config, +} from '@google/gemini-cli-core'; +import { + CommandKind, + type CommandContext, + type SlashCommand, + type SlashCommandActionReturn, +} from '../ui/commands/types.js'; +import type { ICommandLoader } from './types.js'; +import type { PromptArgument } from '@modelcontextprotocol/sdk/types.js'; + +/** + * Discovers and loads executable slash commands from prompts exposed by + * Model-Context-Protocol (MCP) servers. + */ +export class McpPromptLoader implements ICommandLoader { + constructor(private readonly config: Config | null) {} + + /** + * Loads all available prompts from all configured MCP servers and adapts + * them into executable SlashCommand objects. + * + * @param _signal An AbortSignal (unused for this synchronous loader). + * @returns A promise that resolves to an array of loaded SlashCommands. + */ + loadCommands(_signal: AbortSignal): Promise { + const promptCommands: SlashCommand[] = []; + if (!this.config) { + return Promise.resolve([]); + } + const mcpServers = this.config.getMcpClientManager()?.getMcpServers() || {}; + for (const serverName in mcpServers) { + const prompts = getMCPServerPrompts(this.config, serverName) || []; + for (const prompt of prompts) { + // Sanitize prompt names to ensure they are valid slash commands (e.g. "Prompt Name" -> "Prompt-Name") + const commandName = `${prompt.name}`.trim().replace(/\s+/g, '-'); + const newPromptCommand: SlashCommand = { + name: commandName, + description: prompt.description || `Invoke prompt ${prompt.name}`, + kind: CommandKind.MCP_PROMPT, + mcpServerName: serverName, + autoExecute: !prompt.arguments || prompt.arguments.length === 0, + subCommands: [ + { + name: 'help', + description: 'Show help for this prompt', + kind: CommandKind.MCP_PROMPT, + action: async (): Promise => { + if (!prompt.arguments || prompt.arguments.length === 0) { + return { + type: 'message', + messageType: 'info', + content: `Prompt "${prompt.name}" has no arguments.`, + }; + } + + let helpMessage = `Arguments for "${prompt.name}":\n\n`; + if (prompt.arguments && prompt.arguments.length > 0) { + helpMessage += `You can provide arguments by name (e.g., --argName="value") or by position.\n\n`; + helpMessage += `e.g., ${prompt.name} ${prompt.arguments?.map((_) => `"foo"`)} is equivalent to ${prompt.name} ${prompt.arguments?.map((arg) => `--${arg.name}="foo"`)}\n\n`; + } + for (const arg of prompt.arguments) { + helpMessage += ` --${arg.name}\n`; + if (arg.description) { + helpMessage += ` ${arg.description}\n`; + } + helpMessage += ` (required: ${ + arg.required ? 'yes' : 'no' + })\n\n`; + } + return { + type: 'message', + messageType: 'info', + content: helpMessage, + }; + }, + }, + ], + action: async ( + context: CommandContext, + args: string, + ): Promise => { + if (!this.config) { + return { + type: 'message', + messageType: 'error', + content: 'Config not loaded.', + }; + } + + const promptInputs = this.parseArgs(args, prompt.arguments); + if (promptInputs instanceof Error) { + return { + type: 'message', + messageType: 'error', + content: promptInputs.message, + }; + } + + try { + const mcpServers = + this.config.getMcpClientManager()?.getMcpServers() || {}; + const mcpServerConfig = mcpServers[serverName]; + if (!mcpServerConfig) { + return { + type: 'message', + messageType: 'error', + content: `MCP server config not found for '${serverName}'.`, + }; + } + const result = await prompt.invoke(promptInputs); + + if (result['error']) { + return { + type: 'message', + messageType: 'error', + content: `Error invoking prompt: ${result['error']}`, + }; + } + + const maybeContent = result.messages?.[0]?.content; + if (maybeContent.type !== 'text') { + return { + type: 'message', + messageType: 'error', + content: + 'Received an empty or invalid prompt response from the server.', + }; + } + + return { + type: 'submit_prompt', + content: JSON.stringify(maybeContent.text), + }; + } catch (error) { + return { + type: 'message', + messageType: 'error', + content: `Error: ${getErrorMessage(error)}`, + }; + } + }, + completion: async ( + commandContext: CommandContext, + partialArg: string, + ) => { + const invocation = commandContext.invocation; + if (!prompt || !prompt.arguments || !invocation) { + return []; + } + const indexOfFirstSpace = invocation.raw.indexOf(' ') + 1; + const parsedInputs = + indexOfFirstSpace === 0 + ? {} + : this.parseArgs( + invocation.raw.substring(indexOfFirstSpace), + prompt.arguments, + ); + const promptInputs = + parsedInputs instanceof Error ? {} : parsedInputs; + + const providedArgNames = Object.keys(promptInputs); + const unusedArguments = + prompt.arguments + .filter((arg) => { + // If this arguments is not in the prompt inputs + // add it to unusedArguments + if (!providedArgNames.includes(arg.name)) { + return true; + } + + // The parseArgs method assigns the value + // at the end of the prompt as a final value + // The argument should still be suggested + // Example /add --numberOne="34" --num + // numberTwo would be assigned a value of --num + // numberTwo should still be considered unused + const argValue = promptInputs[arg.name]; + return argValue === partialArg; + }) + .map((argument) => `--${argument.name}="`) || []; + + const exactlyMatchingArgumentAtTheEnd = prompt.arguments + .map((argument) => `--${argument.name}="`) + .filter((flagArgument) => { + const regex = new RegExp(`${flagArgument}[^"]*$`); + return regex.test(invocation.raw); + }); + + if (exactlyMatchingArgumentAtTheEnd.length === 1) { + if (exactlyMatchingArgumentAtTheEnd[0] === partialArg) { + return [`${partialArg}"`]; + } + if (partialArg.endsWith('"')) { + return [partialArg]; + } + return [`${partialArg}"`]; + } + + const matchingArguments = unusedArguments.filter((flagArgument) => + flagArgument.startsWith(partialArg), + ); + + return matchingArguments; + }, + }; + promptCommands.push(newPromptCommand); + } + } + return Promise.resolve(promptCommands); + } + + /** + * Parses the `userArgs` string representing the prompt arguments (all the text + * after the command) into a record matching the shape of the `promptArgs`. + * + * @param userArgs + * @param promptArgs + * @returns A record of the parsed arguments + * @visibleForTesting + */ + parseArgs( + userArgs: string, + promptArgs: PromptArgument[] | undefined, + ): Record | Error { + const argValues: { [key: string]: string } = {}; + const promptInputs: Record = {}; + + // arg parsing: --key="value" or --key=value + const namedArgRegex = /--([^=]+)=(?:"((?:\\.|[^"\\])*)"|([^ ]+))/g; + let match; + let lastIndex = 0; + const positionalParts: string[] = []; + + while ((match = namedArgRegex.exec(userArgs)) !== null) { + const key = match[1]; + // Extract the quoted or unquoted argument and remove escape chars. + const value = (match[2] ?? match[3]).replace(/\\(.)/g, '$1'); + argValues[key] = value; + // Capture text between matches as potential positional args + if (match.index > lastIndex) { + positionalParts.push(userArgs.substring(lastIndex, match.index)); + } + lastIndex = namedArgRegex.lastIndex; + } + + // Capture any remaining text after the last named arg + if (lastIndex < userArgs.length) { + positionalParts.push(userArgs.substring(lastIndex)); + } + + const positionalArgsString = positionalParts.join('').trim(); + // extracts either quoted strings or non-quoted sequences of non-space characters. + const positionalArgRegex = /(?:"((?:\\.|[^"\\])*)"|([^ ]+))/g; + const positionalArgs: string[] = []; + while ((match = positionalArgRegex.exec(positionalArgsString)) !== null) { + // Extract the quoted or unquoted argument and remove escape chars. + positionalArgs.push((match[1] ?? match[2]).replace(/\\(.)/g, '$1')); + } + + if (!promptArgs) { + return promptInputs; + } + for (const arg of promptArgs) { + if (argValues[arg.name]) { + promptInputs[arg.name] = argValues[arg.name]; + } + } + + const unfilledArgs = promptArgs.filter( + (arg) => arg.required && !promptInputs[arg.name], + ); + + if (unfilledArgs.length === 1) { + // If we have only one unfilled arg, we don't require quotes we just + // join all the given arguments together as if they were quoted. + promptInputs[unfilledArgs[0].name] = positionalArgs.join(' '); + } else { + const missingArgs: string[] = []; + for (let i = 0; i < unfilledArgs.length; i++) { + if (positionalArgs.length > i) { + promptInputs[unfilledArgs[i].name] = positionalArgs[i]; + } else { + missingArgs.push(unfilledArgs[i].name); + } + } + if (missingArgs.length > 0) { + const missingArgNames = missingArgs + .map((name) => `--${name}`) + .join(', '); + return new Error(`Missing required argument(s): ${missingArgNames}`); + } + } + + return promptInputs; + } +} diff --git a/packages/cli/src/services/SkillCommandLoader.test.ts b/packages/cli/src/services/SkillCommandLoader.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..6c7861ce6b6618cc1af6995116842ab713153e39 --- /dev/null +++ b/packages/cli/src/services/SkillCommandLoader.test.ts @@ -0,0 +1,137 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { SkillCommandLoader } from './SkillCommandLoader.js'; +import { CommandKind } from '../ui/commands/types.js'; +import { ACTIVATE_SKILL_TOOL_NAME } from '@google/gemini-cli-core'; + +describe('SkillCommandLoader', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let mockConfig: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let mockSkillManager: any; + + beforeEach(() => { + mockSkillManager = { + getDisplayableSkills: vi.fn(), + isAdminEnabled: vi.fn().mockReturnValue(true), + }; + + mockConfig = { + isSkillsSupportEnabled: vi.fn().mockReturnValue(true), + getSkillManager: vi.fn().mockReturnValue(mockSkillManager), + }; + }); + + it('should return an empty array if skills support is disabled', async () => { + mockConfig.isSkillsSupportEnabled.mockReturnValue(false); + const loader = new SkillCommandLoader(mockConfig); + const commands = await loader.loadCommands(new AbortController().signal); + expect(commands).toEqual([]); + }); + + it('should return an empty array if SkillManager is missing', async () => { + mockConfig.getSkillManager.mockReturnValue(null); + const loader = new SkillCommandLoader(mockConfig); + const commands = await loader.loadCommands(new AbortController().signal); + expect(commands).toEqual([]); + }); + + it('should return an empty array if skills are admin-disabled', async () => { + mockSkillManager.isAdminEnabled.mockReturnValue(false); + const loader = new SkillCommandLoader(mockConfig); + const commands = await loader.loadCommands(new AbortController().signal); + expect(commands).toEqual([]); + }); + + it('should load skills as slash commands', async () => { + const mockSkills = [ + { name: 'skill1', description: 'Description 1' }, + { name: 'skill2', description: '' }, + ]; + mockSkillManager.getDisplayableSkills.mockReturnValue(mockSkills); + + const loader = new SkillCommandLoader(mockConfig); + const commands = await loader.loadCommands(new AbortController().signal); + + expect(commands).toHaveLength(2); + + expect(commands[0]).toMatchObject({ + name: 'skill1', + description: 'Description 1', + kind: CommandKind.SKILL, + autoExecute: true, + }); + + expect(commands[1]).toMatchObject({ + name: 'skill2', + description: 'Activate the skill2 skill', + kind: CommandKind.SKILL, + autoExecute: true, + }); + }); + + it('should return a tool action when a skill command is executed', async () => { + const mockSkills = [{ name: 'test-skill', description: 'Test skill' }]; + mockSkillManager.getDisplayableSkills.mockReturnValue(mockSkills); + + const loader = new SkillCommandLoader(mockConfig); + const commands = await loader.loadCommands(new AbortController().signal); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const actionResult = await commands[0].action!({} as any, ''); + expect(actionResult).toEqual({ + type: 'tool', + toolName: ACTIVATE_SKILL_TOOL_NAME, + toolArgs: { name: 'test-skill' }, + postSubmitPrompt: 'Use the skill test-skill', + }); + }); + + it('should return a tool action with postSubmitPrompt when args are provided', async () => { + const mockSkills = [{ name: 'test-skill', description: 'Test skill' }]; + mockSkillManager.getDisplayableSkills.mockReturnValue(mockSkills); + + const loader = new SkillCommandLoader(mockConfig); + const commands = await loader.loadCommands(new AbortController().signal); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const actionResult = await commands[0].action!({} as any, 'hello world'); + expect(actionResult).toEqual({ + type: 'tool', + toolName: ACTIVATE_SKILL_TOOL_NAME, + toolArgs: { name: 'test-skill' }, + postSubmitPrompt: 'hello world', + }); + }); + + it('should sanitize skill names with spaces', async () => { + const mockSkills = [{ name: 'my awesome skill', description: 'Desc' }]; + mockSkillManager.getDisplayableSkills.mockReturnValue(mockSkills); + + const loader = new SkillCommandLoader(mockConfig); + const commands = await loader.loadCommands(new AbortController().signal); + + expect(commands[0].name).toBe('my-awesome-skill'); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const actionResult = (await commands[0].action!({} as any, '')) as any; + expect(actionResult.toolArgs).toEqual({ name: 'my awesome skill' }); + }); + + it('should propagate extensionName to the generated slash command', async () => { + const mockSkills = [ + { name: 'skill1', description: 'desc', extensionName: 'ext1' }, + ]; + mockSkillManager.getDisplayableSkills.mockReturnValue(mockSkills); + + const loader = new SkillCommandLoader(mockConfig); + const commands = await loader.loadCommands(new AbortController().signal); + + expect(commands[0].extensionName).toBe('ext1'); + }); +}); diff --git a/packages/cli/src/services/SkillCommandLoader.ts b/packages/cli/src/services/SkillCommandLoader.ts new file mode 100644 index 0000000000000000000000000000000000000000..ea51b377358ab68fd614776491182875759b6db2 --- /dev/null +++ b/packages/cli/src/services/SkillCommandLoader.ts @@ -0,0 +1,57 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { type Config, ACTIVATE_SKILL_TOOL_NAME } from '@google/gemini-cli-core'; +import { CommandKind, type SlashCommand } from '../ui/commands/types.js'; +import { type ICommandLoader } from './types.js'; + +/** + * Loads Agent Skills as slash commands. + */ +export class SkillCommandLoader implements ICommandLoader { + constructor(private config: Config | null) {} + + /** + * Discovers all available skills from the SkillManager and converts + * them into executable slash commands. + * + * @param _signal An AbortSignal (unused for this synchronous loader). + * @returns A promise that resolves to an array of `SlashCommand` objects. + */ + async loadCommands(_signal: AbortSignal): Promise { + if (!this.config || !this.config.isSkillsSupportEnabled()) { + return []; + } + + const skillManager = this.config.getSkillManager(); + if (!skillManager || !skillManager.isAdminEnabled()) { + return []; + } + + // Convert all displayable skills into slash commands. + const skills = skillManager.getDisplayableSkills(); + + return skills.map((skill) => { + const commandName = skill.name.trim().replace(/\s+/g, '-'); + return { + name: commandName, + description: skill.description || `Activate the ${skill.name} skill`, + kind: CommandKind.SKILL, + autoExecute: true, + extensionName: skill.extensionName, + action: async (_context, args) => ({ + type: 'tool', + toolName: ACTIVATE_SKILL_TOOL_NAME, + toolArgs: { name: skill.name }, + postSubmitPrompt: + args.trim().length > 0 + ? args.trim() + : `Use the skill ${skill.name}`, + }), + }; + }); + } +} diff --git a/packages/cli/src/services/SlashCommandConflictHandler.test.ts b/packages/cli/src/services/SlashCommandConflictHandler.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..5527188a0437e8bf24d8a405ac491b6b9825c85f --- /dev/null +++ b/packages/cli/src/services/SlashCommandConflictHandler.test.ts @@ -0,0 +1,194 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { SlashCommandConflictHandler } from './SlashCommandConflictHandler.js'; +import { + coreEvents, + CoreEvent, + type SlashCommandConflictsPayload, + type SlashCommandConflict, +} from '@google/gemini-cli-core'; +import { CommandKind } from '../ui/commands/types.js'; + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + coreEvents: { + on: vi.fn(), + off: vi.fn(), + emitFeedback: vi.fn(), + }, + }; +}); + +describe('SlashCommandConflictHandler', () => { + let handler: SlashCommandConflictHandler; + + /** + * Helper to find and invoke the registered conflict event listener. + */ + const simulateEvent = (conflicts: SlashCommandConflict[]) => { + const callback = vi + .mocked(coreEvents.on) + .mock.calls.find( + (call) => call[0] === CoreEvent.SlashCommandConflicts, + )![1] as (payload: SlashCommandConflictsPayload) => void; + callback({ conflicts }); + }; + + beforeEach(() => { + vi.useFakeTimers(); + handler = new SlashCommandConflictHandler(); + handler.start(); + }); + + afterEach(() => { + handler.stop(); + vi.clearAllMocks(); + vi.useRealTimers(); + }); + + it('should listen for conflict events on start', () => { + expect(coreEvents.on).toHaveBeenCalledWith( + CoreEvent.SlashCommandConflicts, + expect.any(Function), + ); + }); + + it('should display a descriptive message for a single extension conflict', () => { + simulateEvent([ + { + name: 'deploy', + renamedTo: 'firebase.deploy', + loserExtensionName: 'firebase', + loserKind: CommandKind.EXTENSION_FILE, + winnerKind: CommandKind.BUILT_IN, + }, + ]); + + vi.advanceTimersByTime(600); + + expect(coreEvents.emitFeedback).toHaveBeenCalledWith( + 'info', + "Extension 'firebase' command '/deploy' was renamed to '/firebase.deploy' because it conflicts with built-in command.", + ); + }); + + it('should display a descriptive message for a single MCP conflict', () => { + simulateEvent([ + { + name: 'pickle', + renamedTo: 'test-server.pickle', + loserMcpServerName: 'test-server', + loserKind: CommandKind.MCP_PROMPT, + winnerExtensionName: 'pickle-rick', + winnerKind: CommandKind.EXTENSION_FILE, + }, + ]); + + vi.advanceTimersByTime(600); + + expect(coreEvents.emitFeedback).toHaveBeenCalledWith( + 'info', + "MCP server 'test-server' command '/pickle' was renamed to '/test-server.pickle' because it conflicts with extension 'pickle-rick' command.", + ); + }); + + it('should group multiple conflicts for the same command name', () => { + simulateEvent([ + { + name: 'launch', + renamedTo: 'user.launch', + loserKind: CommandKind.USER_FILE, + winnerKind: CommandKind.WORKSPACE_FILE, + }, + { + name: 'launch', + renamedTo: 'workspace.launch', + loserKind: CommandKind.WORKSPACE_FILE, + winnerKind: CommandKind.USER_FILE, + }, + ]); + + vi.advanceTimersByTime(600); + + expect(coreEvents.emitFeedback).toHaveBeenCalledWith( + 'info', + `Conflicts detected for command '/launch': +- User command '/launch' was renamed to '/user.launch' +- Workspace command '/launch' was renamed to '/workspace.launch'`, + ); + }); + + it('should debounce multiple events within the flush window', () => { + simulateEvent([ + { + name: 'a', + renamedTo: 'user.a', + loserKind: CommandKind.USER_FILE, + winnerKind: CommandKind.BUILT_IN, + }, + ]); + + vi.advanceTimersByTime(200); + + simulateEvent([ + { + name: 'b', + renamedTo: 'user.b', + loserKind: CommandKind.USER_FILE, + winnerKind: CommandKind.BUILT_IN, + }, + ]); + + vi.advanceTimersByTime(600); + + // Should emit two feedbacks (one for each unique command name) + expect(coreEvents.emitFeedback).toHaveBeenCalledTimes(2); + }); + + it('should deduplicate already notified conflicts', () => { + const conflict = { + name: 'deploy', + renamedTo: 'firebase.deploy', + loserExtensionName: 'firebase', + loserKind: CommandKind.EXTENSION_FILE, + winnerKind: CommandKind.BUILT_IN, + }; + + simulateEvent([conflict]); + vi.advanceTimersByTime(600); + expect(coreEvents.emitFeedback).toHaveBeenCalledTimes(1); + + vi.mocked(coreEvents.emitFeedback).mockClear(); + + simulateEvent([conflict]); + vi.advanceTimersByTime(600); + expect(coreEvents.emitFeedback).not.toHaveBeenCalled(); + }); + + it('should display a descriptive message for a skill conflict', () => { + simulateEvent([ + { + name: 'chat', + renamedTo: 'google-workspace.chat', + loserExtensionName: 'google-workspace', + loserKind: CommandKind.SKILL, + winnerKind: CommandKind.BUILT_IN, + }, + ]); + + vi.advanceTimersByTime(600); + + expect(coreEvents.emitFeedback).toHaveBeenCalledWith( + 'info', + "Extension 'google-workspace' skill '/chat' was renamed to '/google-workspace.chat' because it conflicts with built-in command.", + ); + }); +}); diff --git a/packages/cli/src/services/SlashCommandConflictHandler.ts b/packages/cli/src/services/SlashCommandConflictHandler.ts new file mode 100644 index 0000000000000000000000000000000000000000..7da4e5384240742b9435b92456a6188ba4538023 --- /dev/null +++ b/packages/cli/src/services/SlashCommandConflictHandler.ts @@ -0,0 +1,175 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + coreEvents, + CoreEvent, + type SlashCommandConflictsPayload, + type SlashCommandConflict, +} from '@google/gemini-cli-core'; +import { CommandKind } from '../ui/commands/types.js'; + +/** + * Handles slash command conflict events and provides user feedback. + * + * This handler batches multiple conflict events into a single notification + * block per command name to avoid UI clutter during startup or incremental loading. + */ +export class SlashCommandConflictHandler { + private notifiedConflicts = new Set(); + private pendingConflicts: SlashCommandConflict[] = []; + private flushTimeout: ReturnType | null = null; + + constructor() { + this.handleConflicts = this.handleConflicts.bind(this); + } + + start() { + coreEvents.on(CoreEvent.SlashCommandConflicts, this.handleConflicts); + } + + stop() { + coreEvents.off(CoreEvent.SlashCommandConflicts, this.handleConflicts); + if (this.flushTimeout) { + clearTimeout(this.flushTimeout); + this.flushTimeout = null; + } + } + + private handleConflicts(payload: SlashCommandConflictsPayload) { + const newConflicts = payload.conflicts.filter((c) => { + // Use a unique key to prevent duplicate notifications for the same conflict + const sourceId = + c.loserExtensionName || c.loserMcpServerName || c.loserKind; + const key = `${c.name}:${sourceId}:${c.renamedTo}`; + if (this.notifiedConflicts.has(key)) { + return false; + } + this.notifiedConflicts.add(key); + return true; + }); + + if (newConflicts.length > 0) { + this.pendingConflicts.push(...newConflicts); + this.scheduleFlush(); + } + } + + private scheduleFlush() { + if (this.flushTimeout) { + clearTimeout(this.flushTimeout); + } + // Use a trailing debounce to capture staggered reloads during startup + this.flushTimeout = setTimeout(() => this.flush(), 500); + } + + private flush() { + this.flushTimeout = null; + const conflicts = [...this.pendingConflicts]; + this.pendingConflicts = []; + + if (conflicts.length === 0) { + return; + } + + // Group conflicts by their original command name + const grouped = new Map(); + for (const c of conflicts) { + const list = grouped.get(c.name) ?? []; + list.push(c); + grouped.set(c.name, list); + } + + for (const [name, commandConflicts] of grouped) { + if (commandConflicts.length > 1) { + this.emitGroupedFeedback(name, commandConflicts); + } else { + this.emitSingleFeedback(commandConflicts[0]); + } + } + } + + /** + * Emits a grouped notification for multiple conflicts sharing the same name. + */ + private emitGroupedFeedback( + name: string, + conflicts: SlashCommandConflict[], + ): void { + const messages = conflicts + .map((c) => { + const source = this.getSourceDescription( + c.loserExtensionName, + c.loserKind, + c.loserMcpServerName, + ); + return `- ${this.capitalize(source)} '/${c.name}' was renamed to '/${c.renamedTo}'`; + }) + .join('\n'); + + coreEvents.emitFeedback( + 'info', + `Conflicts detected for command '/${name}':\n${messages}`, + ); + } + + /** + * Emits a descriptive notification for a single command conflict. + */ + private emitSingleFeedback(c: SlashCommandConflict): void { + const loserSource = this.getSourceDescription( + c.loserExtensionName, + c.loserKind, + c.loserMcpServerName, + ); + const winnerSource = this.getSourceDescription( + c.winnerExtensionName, + c.winnerKind, + c.winnerMcpServerName, + ); + + coreEvents.emitFeedback( + 'info', + `${this.capitalize(loserSource)} '/${c.name}' was renamed to '/${c.renamedTo}' because it conflicts with ${winnerSource}.`, + ); + } + + private capitalize(s: string): string { + return s.charAt(0).toUpperCase() + s.slice(1); + } + + /** + * Returns a human-readable description of a command's source. + */ + private getSourceDescription( + extensionName?: string, + kind?: string, + mcpServerName?: string, + ): string { + switch (kind) { + case CommandKind.EXTENSION_FILE: + return extensionName + ? `extension '${extensionName}' command` + : 'extension command'; + case CommandKind.SKILL: + return extensionName + ? `extension '${extensionName}' skill` + : 'skill command'; + case CommandKind.MCP_PROMPT: + return mcpServerName + ? `MCP server '${mcpServerName}' command` + : 'MCP server command'; + case CommandKind.USER_FILE: + return 'user command'; + case CommandKind.WORKSPACE_FILE: + return 'workspace command'; + case CommandKind.BUILT_IN: + return 'built-in command'; + default: + return 'existing command'; + } + } +} diff --git a/packages/cli/src/services/SlashCommandResolver.test.ts b/packages/cli/src/services/SlashCommandResolver.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..40e3b6f1d5a2c6519055332adf3c98bf514fbd75 --- /dev/null +++ b/packages/cli/src/services/SlashCommandResolver.test.ts @@ -0,0 +1,232 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi } from 'vitest'; +import { SlashCommandResolver } from './SlashCommandResolver.js'; +import { CommandKind, type SlashCommand } from '../ui/commands/types.js'; + +const createMockCommand = (name: string, kind: CommandKind): SlashCommand => ({ + name, + description: `Description for ${name}`, + kind, + action: vi.fn(), +}); + +describe('SlashCommandResolver', () => { + describe('resolve', () => { + it('should return all commands when there are no conflicts', () => { + const cmdA = createMockCommand('a', CommandKind.BUILT_IN); + const cmdB = createMockCommand('b', CommandKind.USER_FILE); + + const { finalCommands, conflicts } = SlashCommandResolver.resolve([ + cmdA, + cmdB, + ]); + + expect(finalCommands).toHaveLength(2); + expect(conflicts).toHaveLength(0); + }); + + it('should rename extension commands when they conflict with built-in', () => { + const builtin = createMockCommand('deploy', CommandKind.BUILT_IN); + const extension = { + ...createMockCommand('deploy', CommandKind.EXTENSION_FILE), + extensionName: 'firebase', + }; + + const { finalCommands, conflicts } = SlashCommandResolver.resolve([ + builtin, + extension, + ]); + + expect(finalCommands.map((c) => c.name)).toContain('deploy'); + expect(finalCommands.map((c) => c.name)).toContain('firebase:deploy'); + expect(conflicts).toHaveLength(1); + }); + + it('should prefix both user and workspace commands when they conflict', () => { + const userCmd = createMockCommand('sync', CommandKind.USER_FILE); + const workspaceCmd = createMockCommand( + 'sync', + CommandKind.WORKSPACE_FILE, + ); + + const { finalCommands, conflicts } = SlashCommandResolver.resolve([ + userCmd, + workspaceCmd, + ]); + + const names = finalCommands.map((c) => c.name); + expect(names).not.toContain('sync'); + expect(names).toContain('user.sync'); + expect(names).toContain('workspace.sync'); + expect(conflicts).toHaveLength(1); + expect(conflicts[0].losers).toHaveLength(2); // Both are considered losers + }); + + it('should prefix file commands but keep built-in names during conflicts', () => { + const builtin = createMockCommand('help', CommandKind.BUILT_IN); + const user = createMockCommand('help', CommandKind.USER_FILE); + + const { finalCommands } = SlashCommandResolver.resolve([builtin, user]); + + const names = finalCommands.map((c) => c.name); + expect(names).toContain('help'); + expect(names).toContain('user.help'); + }); + + it('should prefix both commands when MCP and user file conflict', () => { + const mcp = { + ...createMockCommand('test', CommandKind.MCP_PROMPT), + mcpServerName: 'test-server', + }; + const user = createMockCommand('test', CommandKind.USER_FILE); + + const { finalCommands } = SlashCommandResolver.resolve([mcp, user]); + + const names = finalCommands.map((c) => c.name); + expect(names).not.toContain('test'); + expect(names).toContain('test-server.test'); + expect(names).toContain('user.test'); + }); + + it('should prefix MCP commands with server name when they conflict with built-in', () => { + const builtin = createMockCommand('help', CommandKind.BUILT_IN); + const mcp = { + ...createMockCommand('help', CommandKind.MCP_PROMPT), + mcpServerName: 'test-server', + }; + + const { finalCommands } = SlashCommandResolver.resolve([builtin, mcp]); + + const names = finalCommands.map((c) => c.name); + expect(names).toContain('help'); + expect(names).toContain('test-server.help'); + }); + + it('should prefix both MCP commands when they conflict with each other', () => { + const mcp1 = { + ...createMockCommand('test', CommandKind.MCP_PROMPT), + mcpServerName: 'server1', + }; + const mcp2 = { + ...createMockCommand('test', CommandKind.MCP_PROMPT), + mcpServerName: 'server2', + }; + + const { finalCommands } = SlashCommandResolver.resolve([mcp1, mcp2]); + + const names = finalCommands.map((c) => c.name); + expect(names).not.toContain('test'); + expect(names).toContain('server1.test'); + expect(names).toContain('server2.test'); + }); + + it('should favor the last built-in command silently during conflicts', () => { + const builtin1 = { + ...createMockCommand('help', CommandKind.BUILT_IN), + description: 'first', + }; + const builtin2 = { + ...createMockCommand('help', CommandKind.BUILT_IN), + description: 'second', + }; + + const { finalCommands } = SlashCommandResolver.resolve([ + builtin1, + builtin2, + ]); + + expect(finalCommands).toHaveLength(1); + expect(finalCommands[0].description).toBe('second'); + }); + + it('should fallback to numeric suffixes when both prefix and kind-based prefix are missing', () => { + const cmd1 = createMockCommand('test', CommandKind.BUILT_IN); + const cmd2 = { + ...createMockCommand('test', 'unknown' as CommandKind), + }; + + const { finalCommands } = SlashCommandResolver.resolve([cmd1, cmd2]); + + const names = finalCommands.map((c) => c.name); + expect(names).toContain('test'); + expect(names).toContain('test1'); + }); + + it('should apply numeric suffixes when renames also conflict', () => { + const user1 = createMockCommand('deploy', CommandKind.USER_FILE); + const user2 = createMockCommand('gcp:deploy', CommandKind.USER_FILE); + const extension = { + ...createMockCommand('deploy', CommandKind.EXTENSION_FILE), + extensionName: 'gcp', + }; + + const { finalCommands } = SlashCommandResolver.resolve([ + user1, + user2, + extension, + ]); + + expect(finalCommands.find((c) => c.name === 'gcp:deploy1')).toBeDefined(); + }); + + it('should prefix skills with extension name when they conflict with built-in', () => { + const builtin = createMockCommand('chat', CommandKind.BUILT_IN); + const skill = { + ...createMockCommand('chat', CommandKind.SKILL), + extensionName: 'google-workspace', + }; + + const { finalCommands } = SlashCommandResolver.resolve([builtin, skill]); + + const names = finalCommands.map((c) => c.name); + expect(names).toContain('chat'); + expect(names).toContain('google-workspace:chat'); + }); + + it('should ALWAYS prefix extension skills even if no conflict exists', () => { + const skill = { + ...createMockCommand('chat', CommandKind.SKILL), + extensionName: 'google-workspace', + }; + + const { finalCommands } = SlashCommandResolver.resolve([skill]); + + const names = finalCommands.map((c) => c.name); + expect(names).toContain('google-workspace:chat'); + expect(names).not.toContain('chat'); + }); + + it('should use numeric suffixes if prefixed skill names collide', () => { + const skill1 = { + ...createMockCommand('chat', CommandKind.SKILL), + extensionName: 'google-workspace', + }; + const skill2 = { + ...createMockCommand('chat', CommandKind.SKILL), + extensionName: 'google-workspace', + }; + + const { finalCommands } = SlashCommandResolver.resolve([skill1, skill2]); + + const names = finalCommands.map((c) => c.name); + expect(names).toContain('google-workspace:chat'); + expect(names).toContain('google-workspace:chat1'); + }); + + it('should NOT prefix skills with "skill" when extension name is missing', () => { + const builtin = createMockCommand('chat', CommandKind.BUILT_IN); + const skill = createMockCommand('chat', CommandKind.SKILL); + + const { finalCommands } = SlashCommandResolver.resolve([builtin, skill]); + + const names = finalCommands.map((c) => c.name); + expect(names).toContain('chat'); + expect(names).toContain('chat1'); + }); + }); +}); diff --git a/packages/cli/src/services/SlashCommandResolver.ts b/packages/cli/src/services/SlashCommandResolver.ts new file mode 100644 index 0000000000000000000000000000000000000000..e956d6f566513099965662686a2ca2e22c3ab440 --- /dev/null +++ b/packages/cli/src/services/SlashCommandResolver.ts @@ -0,0 +1,228 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CommandKind, type SlashCommand } from '../ui/commands/types.js'; +import type { CommandConflict } from './types.js'; + +/** + * Internal registry to track commands and conflicts during resolution. + */ +class CommandRegistry { + readonly commandMap = new Map(); + readonly conflictsMap = new Map(); + readonly firstEncounters = new Map(); + + get finalCommands(): SlashCommand[] { + return Array.from(this.commandMap.values()); + } + + get conflicts(): CommandConflict[] { + return Array.from(this.conflictsMap.values()); + } +} + +/** + * Resolves name conflicts among slash commands. + * + * Rules: + * 1. Built-in commands always keep the original name. + * 2. All other types are prefixed with their source name (e.g. user.name). + * 3. If multiple non-built-in commands conflict, all of them are renamed. + */ +export class SlashCommandResolver { + /** + * Orchestrates conflict resolution by applying renaming rules to ensures + * every command has a unique name. + */ + static resolve(allCommands: SlashCommand[]): { + finalCommands: SlashCommand[]; + conflicts: CommandConflict[]; + } { + const registry = new CommandRegistry(); + + for (const cmd of allCommands) { + const originalName = cmd.name; + let finalName = originalName; + + const shouldAlwaysPrefix = + cmd.kind === CommandKind.SKILL && !!cmd.extensionName; + + if (shouldAlwaysPrefix) { + finalName = this.getRenamedName( + originalName, + this.getPrefix(cmd), + registry.commandMap, + cmd.kind, + ); + } else if (registry.firstEncounters.has(originalName)) { + // We've already seen a command with this name, so resolve the conflict. + finalName = this.handleConflict(cmd, registry); + } else { + // Track the first claimant to report them as the conflict reason later. + registry.firstEncounters.set(originalName, cmd); + } + + // Store under final name, ensuring the command object reflects it. + registry.commandMap.set(finalName, { + ...cmd, + name: finalName, + }); + } + + return { + finalCommands: registry.finalCommands, + conflicts: registry.conflicts, + }; + } + + /** + * Resolves a name collision by deciding which command keeps the name and which is renamed. + * + * @param incoming The command currently being processed that has a name collision. + * @param registry The internal state of the resolution process. + * @returns The final name to be assigned to the `incoming` command. + */ + private static handleConflict( + incoming: SlashCommand, + registry: CommandRegistry, + ): string { + const collidingName = incoming.name; + const originalClaimant = registry.firstEncounters.get(collidingName)!; + + // Incoming built-in takes priority. Prefix any existing owner. + if (incoming.kind === CommandKind.BUILT_IN) { + this.prefixExistingCommand(collidingName, incoming, registry); + return collidingName; + } + + // Incoming non-built-in is renamed to its source-prefixed version. + const renamedName = this.getRenamedName( + incoming.name, + this.getPrefix(incoming), + registry.commandMap, + incoming.kind, + ); + this.trackConflict( + registry.conflictsMap, + collidingName, + originalClaimant, + incoming, + renamedName, + ); + + // Prefix current owner as well if it isn't a built-in. + this.prefixExistingCommand(collidingName, incoming, registry); + + return renamedName; + } + + /** + * Safely renames the command currently occupying a name in the registry. + * + * @param name The name of the command to prefix. + * @param reason The incoming command that is causing the prefixing. + * @param registry The internal state of the resolution process. + */ + private static prefixExistingCommand( + name: string, + reason: SlashCommand, + registry: CommandRegistry, + ): void { + const currentOwner = registry.commandMap.get(name); + + // Only non-built-in commands can be prefixed. + if (!currentOwner || currentOwner.kind === CommandKind.BUILT_IN) { + return; + } + + // Determine the new name for the owner using its source prefix. + const renamedName = this.getRenamedName( + currentOwner.name, + this.getPrefix(currentOwner), + registry.commandMap, + currentOwner.kind, + ); + + // Update the registry: remove the old name and add the owner under the new name. + registry.commandMap.delete(name); + const renamedOwner = { ...currentOwner, name: renamedName }; + registry.commandMap.set(renamedName, renamedOwner); + + // Record the conflict so the user can be notified of the prefixing. + this.trackConflict( + registry.conflictsMap, + name, + reason, + currentOwner, + renamedName, + ); + } + + /** + * Generates a unique name using numeric suffixes if needed. + */ + private static getRenamedName( + name: string, + prefix: string | undefined, + commandMap: Map, + kind?: CommandKind, + ): string { + const isExtensionPrefix = + kind === CommandKind.SKILL || kind === CommandKind.EXTENSION_FILE; + const separator = isExtensionPrefix ? ':' : '.'; + const base = prefix ? `${prefix}${separator}${name}` : name; + let renamedName = base; + let suffix = 1; + + while (commandMap.has(renamedName)) { + renamedName = `${base}${suffix}`; + suffix++; + } + return renamedName; + } + + /** + * Returns a suitable prefix for a conflicting command. + */ + private static getPrefix(cmd: SlashCommand): string | undefined { + switch (cmd.kind) { + case CommandKind.EXTENSION_FILE: + case CommandKind.SKILL: + return cmd.extensionName; + case CommandKind.MCP_PROMPT: + return cmd.mcpServerName; + case CommandKind.USER_FILE: + return 'user'; + case CommandKind.WORKSPACE_FILE: + return 'workspace'; + default: + return undefined; + } + } + /** + * Logs a conflict event. + */ + private static trackConflict( + conflictsMap: Map, + originalName: string, + reason: SlashCommand, + displacedCommand: SlashCommand, + renamedTo: string, + ) { + if (!conflictsMap.has(originalName)) { + conflictsMap.set(originalName, { + name: originalName, + losers: [], + }); + } + + conflictsMap.get(originalName)!.losers.push({ + command: displacedCommand, + renamedTo, + reason, + }); + } +} diff --git a/packages/cli/src/services/liteRtServerManager.test.ts b/packages/cli/src/services/liteRtServerManager.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..f1af5c800ae8b0fd30f0ac56a2d51d85ca12bb92 --- /dev/null +++ b/packages/cli/src/services/liteRtServerManager.test.ts @@ -0,0 +1,68 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { GemmaModelRouterSettings } from '@google/gemini-cli-core'; + +const mockGetBinaryPath = vi.hoisted(() => vi.fn()); +const mockIsServerRunning = vi.hoisted(() => vi.fn()); +const mockStartServer = vi.hoisted(() => vi.fn()); + +vi.mock('../commands/gemma/platform.js', () => ({ + getBinaryPath: mockGetBinaryPath, + isServerRunning: mockIsServerRunning, +})); + +vi.mock('../commands/gemma/start.js', () => ({ + startServer: mockStartServer, +})); + +import { LiteRtServerManager } from './liteRtServerManager.js'; + +describe('LiteRtServerManager', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(fs, 'existsSync').mockReturnValue(true); + mockIsServerRunning.mockResolvedValue(false); + mockStartServer.mockResolvedValue(true); + }); + + it('uses the configured custom binary path when auto-starting', async () => { + mockGetBinaryPath.mockReturnValue('/user/lit'); + + const settings: GemmaModelRouterSettings = { + enabled: true, + binaryPath: '/workspace/evil', + classifier: { + host: 'http://localhost:8123', + }, + }; + + await LiteRtServerManager.ensureRunning(settings); + + expect(mockGetBinaryPath).toHaveBeenCalledTimes(1); + expect(fs.existsSync).toHaveBeenCalledWith('/user/lit'); + expect(mockStartServer).toHaveBeenCalledWith('/user/lit', 8123); + }); + + it('falls back to the default binary path when no custom path is configured', async () => { + mockGetBinaryPath.mockReturnValue('/default/lit'); + + const settings: GemmaModelRouterSettings = { + enabled: true, + classifier: { + host: 'http://localhost:9379', + }, + }; + + await LiteRtServerManager.ensureRunning(settings); + + expect(mockGetBinaryPath).toHaveBeenCalledTimes(1); + expect(fs.existsSync).toHaveBeenCalledWith('/default/lit'); + expect(mockStartServer).toHaveBeenCalledWith('/default/lit', 9379); + }); +}); diff --git a/packages/cli/src/services/liteRtServerManager.ts b/packages/cli/src/services/liteRtServerManager.ts new file mode 100644 index 0000000000000000000000000000000000000000..e72d321f9d09a2e7efc2a3340c561d6792ceda00 --- /dev/null +++ b/packages/cli/src/services/liteRtServerManager.ts @@ -0,0 +1,59 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs'; +import { debugLogger } from '@google/gemini-cli-core'; +import type { GemmaModelRouterSettings } from '@google/gemini-cli-core'; +import { getBinaryPath, isServerRunning } from '../commands/gemma/platform.js'; +import { DEFAULT_PORT } from '../commands/gemma/constants.js'; + +export class LiteRtServerManager { + static async ensureRunning( + gemmaSettings: GemmaModelRouterSettings | undefined, + ): Promise { + if (!gemmaSettings?.enabled) return; + if (gemmaSettings.autoStartServer === false) return; + const binaryPath = getBinaryPath(); + if (!binaryPath || !fs.existsSync(binaryPath)) { + debugLogger.log( + '[LiteRtServerManager] Binary not installed, skipping auto-start. Run "gemini gemma setup".', + ); + return; + } + + const port = + parseInt( + gemmaSettings.classifier?.host?.match(/:(\d+)/)?.[1] ?? '', + 10, + ) || DEFAULT_PORT; + + const running = await isServerRunning(port); + if (running) { + debugLogger.log( + `[LiteRtServerManager] Server already running on port ${port}`, + ); + return; + } + + debugLogger.log( + `[LiteRtServerManager] Auto-starting LiteRT server on port ${port}...`, + ); + + try { + const { startServer } = await import('../commands/gemma/start.js'); + const started = await startServer(binaryPath, port); + if (started) { + debugLogger.log(`[LiteRtServerManager] Server started on port ${port}`); + } else { + debugLogger.warn( + `[LiteRtServerManager] Server may not have started correctly on port ${port}`, + ); + } + } catch (error) { + debugLogger.warn('[LiteRtServerManager] Auto-start failed:', error); + } + } +} diff --git a/packages/cli/src/services/prompt-processors/argumentProcessor.test.ts b/packages/cli/src/services/prompt-processors/argumentProcessor.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..80bde1287d1a0845fc79a3ff7a1d4db69d3b6c41 --- /dev/null +++ b/packages/cli/src/services/prompt-processors/argumentProcessor.test.ts @@ -0,0 +1,43 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { DefaultArgumentProcessor } from './argumentProcessor.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import { describe, it, expect } from 'vitest'; + +describe('Argument Processors', () => { + describe('DefaultArgumentProcessor', () => { + const processor = new DefaultArgumentProcessor(); + + it('should append the full command if args are provided', async () => { + const prompt = [{ text: 'Parse the command.' }]; + const context = createMockCommandContext({ + invocation: { + raw: '/mycommand arg1 "arg two"', + name: 'mycommand', + args: 'arg1 "arg two"', + }, + }); + const result = await processor.process(prompt, context); + expect(result).toEqual([ + { text: 'Parse the command.\n\n/mycommand arg1 "arg two"' }, + ]); + }); + + it('should NOT append the full command if no args are provided', async () => { + const prompt = [{ text: 'Parse the command.' }]; + const context = createMockCommandContext({ + invocation: { + raw: '/mycommand', + name: 'mycommand', + args: '', + }, + }); + const result = await processor.process(prompt, context); + expect(result).toEqual([{ text: 'Parse the command.' }]); + }); + }); +}); diff --git a/packages/cli/src/services/prompt-processors/argumentProcessor.ts b/packages/cli/src/services/prompt-processors/argumentProcessor.ts new file mode 100644 index 0000000000000000000000000000000000000000..b5e5b38a0c8bbf3e0094564e507591fe043f4369 --- /dev/null +++ b/packages/cli/src/services/prompt-processors/argumentProcessor.ts @@ -0,0 +1,27 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { appendToLastTextPart } from '@google/gemini-cli-core'; +import type { IPromptProcessor, PromptPipelineContent } from './types.js'; +import type { CommandContext } from '../../ui/commands/types.js'; + +/** + * Appends the user's full command invocation to the prompt if arguments are + * provided, allowing the model to perform its own argument parsing. + * + * This processor is only used if the prompt does NOT contain {{args}}. + */ +export class DefaultArgumentProcessor implements IPromptProcessor { + async process( + prompt: PromptPipelineContent, + context: CommandContext, + ): Promise { + if (context.invocation?.args) { + return appendToLastTextPart(prompt, context.invocation.raw); + } + return prompt; + } +} diff --git a/packages/cli/src/services/prompt-processors/atFileProcessor.test.ts b/packages/cli/src/services/prompt-processors/atFileProcessor.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..3b84baae6745931bbd6bb2d7891c30159291941b --- /dev/null +++ b/packages/cli/src/services/prompt-processors/atFileProcessor.test.ts @@ -0,0 +1,224 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import { type CommandContext } from '../../ui/commands/types.js'; +import { AtFileProcessor } from './atFileProcessor.js'; +import { MessageType } from '../../ui/types.js'; +import type { Config } from '@google/gemini-cli-core'; +import type { PartUnion } from '@google/genai'; + +// Mock the core dependency +const mockReadPathFromWorkspace = vi.hoisted(() => vi.fn()); +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + readPathFromWorkspace: mockReadPathFromWorkspace, + }; +}); + +describe('AtFileProcessor', () => { + let context: CommandContext; + let mockConfig: Config; + + beforeEach(() => { + vi.clearAllMocks(); + + mockConfig = { + // The processor only passes the config through, so we don't need a full mock. + get config() { + return this; + }, + } as unknown as Config; + + context = createMockCommandContext({ + services: { + agentContext: mockConfig, + }, + }); + + // Default mock success behavior: return content wrapped in a text part. + mockReadPathFromWorkspace.mockImplementation( + async (path: string): Promise => [ + { text: `content of ${path}` }, + ], + ); + }); + + it('should not change the prompt if no @{ trigger is present', async () => { + const processor = new AtFileProcessor(); + const prompt: PartUnion[] = [{ text: 'This is a simple prompt.' }]; + const result = await processor.process(prompt, context); + expect(result).toEqual(prompt); + expect(mockReadPathFromWorkspace).not.toHaveBeenCalled(); + }); + + it('should not change the prompt if config service is missing', async () => { + const processor = new AtFileProcessor(); + const prompt: PartUnion[] = [{ text: 'Analyze @{file.txt}' }]; + const contextWithoutConfig = createMockCommandContext({ + services: { + agentContext: null, + }, + }); + const result = await processor.process(prompt, contextWithoutConfig); + expect(result).toEqual(prompt); + expect(mockReadPathFromWorkspace).not.toHaveBeenCalled(); + }); + + describe('Parsing Logic', () => { + it('should replace a single valid @{path/to/file.txt} placeholder', async () => { + const processor = new AtFileProcessor(); + const prompt: PartUnion[] = [ + { text: 'Analyze this file: @{path/to/file.txt}' }, + ]; + const result = await processor.process(prompt, context); + expect(mockReadPathFromWorkspace).toHaveBeenCalledWith( + 'path/to/file.txt', + mockConfig, + ); + expect(result).toEqual([ + { text: 'Analyze this file: ' }, + { text: 'content of path/to/file.txt' }, + ]); + }); + + it('should replace multiple different @{...} placeholders', async () => { + const processor = new AtFileProcessor(); + const prompt: PartUnion[] = [ + { text: 'Compare @{file1.js} with @{file2.js}' }, + ]; + const result = await processor.process(prompt, context); + expect(mockReadPathFromWorkspace).toHaveBeenCalledTimes(2); + expect(mockReadPathFromWorkspace).toHaveBeenCalledWith( + 'file1.js', + mockConfig, + ); + expect(mockReadPathFromWorkspace).toHaveBeenCalledWith( + 'file2.js', + mockConfig, + ); + expect(result).toEqual([ + { text: 'Compare ' }, + { text: 'content of file1.js' }, + { text: ' with ' }, + { text: 'content of file2.js' }, + ]); + }); + + it('should handle placeholders at the beginning, middle, and end', async () => { + const processor = new AtFileProcessor(); + const prompt: PartUnion[] = [ + { text: '@{start.txt} in the @{middle.txt} and @{end.txt}' }, + ]; + const result = await processor.process(prompt, context); + expect(result).toEqual([ + { text: 'content of start.txt' }, + { text: ' in the ' }, + { text: 'content of middle.txt' }, + { text: ' and ' }, + { text: 'content of end.txt' }, + ]); + }); + + it('should correctly parse paths that contain balanced braces', async () => { + const processor = new AtFileProcessor(); + const prompt: PartUnion[] = [ + { text: 'Analyze @{path/with/{braces}/file.txt}' }, + ]; + const result = await processor.process(prompt, context); + expect(mockReadPathFromWorkspace).toHaveBeenCalledWith( + 'path/with/{braces}/file.txt', + mockConfig, + ); + expect(result).toEqual([ + { text: 'Analyze ' }, + { text: 'content of path/with/{braces}/file.txt' }, + ]); + }); + + it('should throw an error if the prompt contains an unclosed trigger', async () => { + const processor = new AtFileProcessor(); + const prompt: PartUnion[] = [{ text: 'Hello @{world' }]; + // The new parser throws an error for unclosed injections. + await expect(processor.process(prompt, context)).rejects.toThrow( + /Unclosed injection/, + ); + }); + }); + + describe('Integration and Error Handling', () => { + it('should leave the placeholder unmodified if readPathFromWorkspace throws', async () => { + const processor = new AtFileProcessor(); + const prompt: PartUnion[] = [ + { text: 'Analyze @{not-found.txt} and @{good-file.txt}' }, + ]; + mockReadPathFromWorkspace.mockImplementation(async (path: string) => { + if (path === 'not-found.txt') { + throw new Error('File not found'); + } + return [{ text: `content of ${path}` }]; + }); + + const result = await processor.process(prompt, context); + expect(result).toEqual([ + { text: 'Analyze ' }, + { text: '@{not-found.txt}' }, // Placeholder is preserved as a text part + { text: ' and ' }, + { text: 'content of good-file.txt' }, + ]); + }); + }); + + describe('UI Feedback', () => { + it('should call ui.addItem with an ERROR on failure', async () => { + const processor = new AtFileProcessor(); + const prompt: PartUnion[] = [{ text: 'Analyze @{bad-file.txt}' }]; + mockReadPathFromWorkspace.mockRejectedValue(new Error('Access denied')); + + await processor.process(prompt, context); + + expect(context.ui.addItem).toHaveBeenCalledTimes(1); + expect(context.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.ERROR, + text: "Failed to inject content for '@{bad-file.txt}': Access denied", + }, + expect.any(Number), + ); + }); + + it('should call ui.addItem with a WARNING if the file was ignored', async () => { + const processor = new AtFileProcessor(); + const prompt: PartUnion[] = [{ text: 'Analyze @{ignored.txt}' }]; + // Simulate an ignored file by returning an empty array. + mockReadPathFromWorkspace.mockResolvedValue([]); + + const result = await processor.process(prompt, context); + + // The placeholder should be removed, resulting in only the prefix. + expect(result).toEqual([{ text: 'Analyze ' }]); + + expect(context.ui.addItem).toHaveBeenCalledTimes(1); + expect(context.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: "File '@{ignored.txt}' was ignored by .gitignore or .geminiignore and was not included in the prompt.", + }, + expect.any(Number), + ); + }); + + it('should NOT call ui.addItem on success', async () => { + const processor = new AtFileProcessor(); + const prompt: PartUnion[] = [{ text: 'Analyze @{good-file.txt}' }]; + await processor.process(prompt, context); + expect(context.ui.addItem).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/cli/src/services/prompt-processors/atFileProcessor.ts b/packages/cli/src/services/prompt-processors/atFileProcessor.ts new file mode 100644 index 0000000000000000000000000000000000000000..8c1b168584f914c94bdf0bff87a8b73a6c8b2861 --- /dev/null +++ b/packages/cli/src/services/prompt-processors/atFileProcessor.ts @@ -0,0 +1,98 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + debugLogger, + flatMapTextParts, + readPathFromWorkspace, +} from '@google/gemini-cli-core'; +import type { CommandContext } from '../../ui/commands/types.js'; +import { MessageType } from '../../ui/types.js'; +import { + AT_FILE_INJECTION_TRIGGER, + type IPromptProcessor, + type PromptPipelineContent, +} from './types.js'; +import { extractInjections } from './injectionParser.js'; + +export class AtFileProcessor implements IPromptProcessor { + constructor(private readonly commandName?: string) {} + + async process( + input: PromptPipelineContent, + context: CommandContext, + ): Promise { + const config = context.services.agentContext?.config; + if (!config) { + return input; + } + + return flatMapTextParts(input, async (text) => { + if (!text.includes(AT_FILE_INJECTION_TRIGGER)) { + return [{ text }]; + } + + const injections = extractInjections( + text, + AT_FILE_INJECTION_TRIGGER, + this.commandName, + ); + if (injections.length === 0) { + return [{ text }]; + } + + const output: PromptPipelineContent = []; + let lastIndex = 0; + + for (const injection of injections) { + const prefix = text.substring(lastIndex, injection.startIndex); + if (prefix) { + output.push({ text: prefix }); + } + + const pathStr = injection.content; + try { + const fileContentParts = await readPathFromWorkspace(pathStr, config); + if (fileContentParts.length === 0) { + const uiMessage = `File '@{${pathStr}}' was ignored by .gitignore or .geminiignore and was not included in the prompt.`; + context.ui.addItem( + { type: MessageType.INFO, text: uiMessage }, + Date.now(), + ); + } + output.push(...fileContentParts); + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + const uiMessage = `Failed to inject content for '@{${pathStr}}': ${message}`; + + // `context.invocation` should always be present at this point. + debugLogger.error( + `Error while loading custom command (${context.invocation!.name}) ${uiMessage}. Leaving placeholder in prompt.`, + ); + context.ui.addItem( + { type: MessageType.ERROR, text: uiMessage }, + Date.now(), + ); + + const placeholder = text.substring( + injection.startIndex, + injection.endIndex, + ); + output.push({ text: placeholder }); + } + lastIndex = injection.endIndex; + } + + const suffix = text.substring(lastIndex); + if (suffix) { + output.push({ text: suffix }); + } + + return output; + }); + } +} diff --git a/packages/cli/src/services/prompt-processors/injectionParser.test.ts b/packages/cli/src/services/prompt-processors/injectionParser.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..5ce0f8f79f7e60014f2d483f6c13cd5127d1dc82 --- /dev/null +++ b/packages/cli/src/services/prompt-processors/injectionParser.test.ts @@ -0,0 +1,223 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { extractInjections } from './injectionParser.js'; + +describe('extractInjections', () => { + const SHELL_TRIGGER = '!{'; + const AT_FILE_TRIGGER = '@{'; + + describe('Basic Functionality', () => { + it('should return an empty array if no trigger is present', () => { + const prompt = 'This is a simple prompt without injections.'; + const result = extractInjections(prompt, SHELL_TRIGGER); + expect(result).toEqual([]); + }); + + it('should extract a single, simple injection', () => { + const prompt = 'Run this command: !{ls -la}'; + const result = extractInjections(prompt, SHELL_TRIGGER); + expect(result).toEqual([ + { + content: 'ls -la', + startIndex: 18, + endIndex: 27, + }, + ]); + }); + + it('should extract multiple injections', () => { + const prompt = 'First: !{cmd1}, Second: !{cmd2}'; + const result = extractInjections(prompt, SHELL_TRIGGER); + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + content: 'cmd1', + startIndex: 7, + endIndex: 14, + }); + expect(result[1]).toEqual({ + content: 'cmd2', + startIndex: 24, + endIndex: 31, + }); + }); + + it('should handle different triggers (e.g., @{)', () => { + const prompt = 'Read this file: @{path/to/file.txt}'; + const result = extractInjections(prompt, AT_FILE_TRIGGER); + expect(result).toEqual([ + { + content: 'path/to/file.txt', + startIndex: 16, + endIndex: 35, + }, + ]); + }); + }); + + describe('Positioning and Edge Cases', () => { + it('should handle injections at the start and end of the prompt', () => { + const prompt = '!{start} middle text !{end}'; + const result = extractInjections(prompt, SHELL_TRIGGER); + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + content: 'start', + startIndex: 0, + endIndex: 8, + }); + expect(result[1]).toEqual({ + content: 'end', + startIndex: 21, + endIndex: 27, + }); + }); + + it('should handle adjacent injections', () => { + const prompt = '!{A}!{B}'; + const result = extractInjections(prompt, SHELL_TRIGGER); + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ content: 'A', startIndex: 0, endIndex: 4 }); + expect(result[1]).toEqual({ content: 'B', startIndex: 4, endIndex: 8 }); + }); + + it('should handle empty injections', () => { + const prompt = 'Empty: !{}'; + const result = extractInjections(prompt, SHELL_TRIGGER); + expect(result).toEqual([ + { + content: '', + startIndex: 7, + endIndex: 10, + }, + ]); + }); + + it('should trim whitespace within the content', () => { + const prompt = '!{ \n command with space \t }'; + const result = extractInjections(prompt, SHELL_TRIGGER); + expect(result).toEqual([ + { + content: 'command with space', + startIndex: 0, + endIndex: 29, + }, + ]); + }); + + it('should ignore similar patterns that are not the exact trigger', () => { + const prompt = 'Not a trigger: !(cmd) or {cmd} or ! {cmd}'; + const result = extractInjections(prompt, SHELL_TRIGGER); + expect(result).toEqual([]); + }); + + it('should ignore extra closing braces before the trigger', () => { + const prompt = 'Ignore this } then !{run}'; + const result = extractInjections(prompt, SHELL_TRIGGER); + expect(result).toEqual([ + { + content: 'run', + startIndex: 19, + endIndex: 25, + }, + ]); + }); + + it('should stop parsing at the first balanced closing brace (non-greedy)', () => { + // This tests that the parser doesn't greedily consume extra closing braces + const prompt = 'Run !{ls -l}} extra braces'; + const result = extractInjections(prompt, SHELL_TRIGGER); + expect(result).toEqual([ + { + content: 'ls -l', + startIndex: 4, + endIndex: 12, + }, + ]); + }); + }); + + describe('Nested Braces (Balanced)', () => { + it('should correctly parse content with simple nested braces (e.g., JSON)', () => { + const prompt = `Send JSON: !{curl -d '{"key": "value"}'}`; + const result = extractInjections(prompt, SHELL_TRIGGER); + expect(result).toHaveLength(1); + expect(result[0].content).toBe(`curl -d '{"key": "value"}'`); + }); + + it('should correctly parse content with shell constructs (e.g., awk)', () => { + const prompt = `Process text: !{awk '{print $1}' file.txt}`; + const result = extractInjections(prompt, SHELL_TRIGGER); + expect(result).toHaveLength(1); + expect(result[0].content).toBe(`awk '{print $1}' file.txt`); + }); + + it('should correctly parse multiple levels of nesting', () => { + const prompt = `!{level1 {level2 {level3}} suffix}`; + const result = extractInjections(prompt, SHELL_TRIGGER); + expect(result).toHaveLength(1); + expect(result[0].content).toBe(`level1 {level2 {level3}} suffix`); + expect(result[0].endIndex).toBe(prompt.length); + }); + + it('should correctly parse paths containing balanced braces', () => { + const prompt = 'Analyze @{path/with/{braces}/file.txt}'; + const result = extractInjections(prompt, AT_FILE_TRIGGER); + expect(result).toHaveLength(1); + expect(result[0].content).toBe('path/with/{braces}/file.txt'); + }); + + it('should correctly handle an injection containing the trigger itself', () => { + // This works because the parser counts braces, it doesn't look for the trigger again until the current one is closed. + const prompt = '!{echo "The trigger is !{ confusing }"}'; + const expectedContent = 'echo "The trigger is !{ confusing }"'; + const result = extractInjections(prompt, SHELL_TRIGGER); + expect(result).toHaveLength(1); + expect(result[0].content).toBe(expectedContent); + }); + }); + + describe('Error Handling (Unbalanced/Unclosed)', () => { + it('should throw an error for a simple unclosed injection', () => { + const prompt = 'This prompt has !{an unclosed trigger'; + expect(() => extractInjections(prompt, SHELL_TRIGGER)).toThrow( + /Invalid syntax: Unclosed injection starting at index 16 \('!{'\)/, + ); + }); + + it('should throw an error if the prompt ends inside a nested block', () => { + const prompt = 'This fails: !{outer {inner'; + expect(() => extractInjections(prompt, SHELL_TRIGGER)).toThrow( + /Invalid syntax: Unclosed injection starting at index 12 \('!{'\)/, + ); + }); + + it('should include the context name in the error message if provided', () => { + const prompt = 'Failing !{command'; + const contextName = 'test-command'; + expect(() => + extractInjections(prompt, SHELL_TRIGGER, contextName), + ).toThrow( + /Invalid syntax in command 'test-command': Unclosed injection starting at index 8/, + ); + }); + + it('should throw if content contains unbalanced braces (e.g., missing closing)', () => { + // This is functionally the same as an unclosed injection from the parser's perspective. + const prompt = 'Analyze @{path/with/braces{example.txt}'; + expect(() => extractInjections(prompt, AT_FILE_TRIGGER)).toThrow( + /Invalid syntax: Unclosed injection starting at index 8 \('@{'\)/, + ); + }); + + it('should clearly state that unbalanced braces in content are not supported in the error', () => { + const prompt = 'Analyze @{path/with/braces{example.txt}'; + expect(() => extractInjections(prompt, AT_FILE_TRIGGER)).toThrow( + /Paths or commands with unbalanced braces are not supported directly/, + ); + }); + }); +}); diff --git a/packages/cli/src/services/prompt-processors/injectionParser.ts b/packages/cli/src/services/prompt-processors/injectionParser.ts new file mode 100644 index 0000000000000000000000000000000000000000..52d3226db8fd8dbb5351997c2ff27717bd1a6bec --- /dev/null +++ b/packages/cli/src/services/prompt-processors/injectionParser.ts @@ -0,0 +1,89 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Represents a single detected injection site in a prompt string. + */ +export interface Injection { + /** The content extracted from within the braces (e.g., the command or path), trimmed. */ + content: string; + /** The starting index of the injection (inclusive, points to the start of the trigger). */ + startIndex: number; + /** The ending index of the injection (exclusive, points after the closing '}'). */ + endIndex: number; +} + +/** + * Iteratively parses a prompt string to extract injections (e.g., !{...} or @{...}), + * correctly handling nested braces within the content. + * + * This parser relies on simple brace counting and does not support escaping. + * + * @param prompt The prompt string to parse. + * @param trigger The opening trigger sequence (e.g., '!{', '@{'). + * @param contextName Optional context name (e.g., command name) for error messages. + * @returns An array of extracted Injection objects. + * @throws Error if an unclosed injection is found. + */ +export function extractInjections( + prompt: string, + trigger: string, + contextName?: string, +): Injection[] { + const injections: Injection[] = []; + let index = 0; + + while (index < prompt.length) { + const startIndex = prompt.indexOf(trigger, index); + + if (startIndex === -1) { + break; + } + + let currentIndex = startIndex + trigger.length; + let braceCount = 1; + let foundEnd = false; + + while (currentIndex < prompt.length) { + const char = prompt[currentIndex]; + + if (char === '{') { + braceCount++; + } else if (char === '}') { + braceCount--; + if (braceCount === 0) { + const injectionContent = prompt.substring( + startIndex + trigger.length, + currentIndex, + ); + const endIndex = currentIndex + 1; + + injections.push({ + content: injectionContent.trim(), + startIndex, + endIndex, + }); + + index = endIndex; + foundEnd = true; + break; + } + } + currentIndex++; + } + + // Check if the inner loop finished without finding the closing brace. + if (!foundEnd) { + const contextInfo = contextName ? ` in command '${contextName}'` : ''; + // Enforce strict parsing (Comment 1) and clarify limitations (Comment 2). + throw new Error( + `Invalid syntax${contextInfo}: Unclosed injection starting at index ${startIndex} ('${trigger}'). Ensure braces are balanced. Paths or commands with unbalanced braces are not supported directly.`, + ); + } + } + + return injections; +} diff --git a/packages/cli/src/services/prompt-processors/shellProcessor.test.ts b/packages/cli/src/services/prompt-processors/shellProcessor.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..ebea1aa52860856fc780aab01f2314c46050c542 --- /dev/null +++ b/packages/cli/src/services/prompt-processors/shellProcessor.test.ts @@ -0,0 +1,761 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; +import { ConfirmationRequiredError, ShellProcessor } from './shellProcessor.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import type { CommandContext } from '../../ui/commands/types.js'; +import type { Config } from '@google/gemini-cli-core'; +import { + ApprovalMode, + getShellConfiguration, + PolicyDecision, + NoopSandboxManager, +} from '@google/gemini-cli-core'; +import { quote } from 'shell-quote'; +import { createPartFromText } from '@google/genai'; +import type { PromptPipelineContent } from './types.js'; + +// Helper function to determine the expected escaped string based on the current OS, +// mirroring the logic in the actual `escapeShellArg` implementation. +function getExpectedEscapedArgForPlatform(arg: string): string { + const { shell } = getShellConfiguration(); + + switch (shell) { + case 'powershell': + return `'${arg.replace(/'/g, "''")}'`; + case 'cmd': + return `"${arg.replace(/"/g, '""')}"`; + case 'bash': + default: + return quote([arg]); + } +} + +// Helper to create PromptPipelineContent +function createPromptPipelineContent(text: string): PromptPipelineContent { + return [createPartFromText(text)]; +} + +const mockCheckCommandPermissions = vi.hoisted(() => vi.fn()); +const mockShellExecute = vi.hoisted(() => vi.fn()); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + checkCommandPermissions: mockCheckCommandPermissions, + ShellExecutionService: { + execute: mockShellExecute, + }, + }; +}); + +const SUCCESS_RESULT = { + output: 'default shell output', + exitCode: 0, + error: null, + aborted: false, + signal: null, +}; + +describe('ShellProcessor', () => { + let context: CommandContext; + let mockConfig: Partial; + let mockPolicyEngineCheck: Mock; + + beforeEach(() => { + vi.clearAllMocks(); + + mockPolicyEngineCheck = vi.fn().mockResolvedValue({ + decision: PolicyDecision.ALLOW, + }); + + mockConfig = { + getTargetDir: vi.fn().mockReturnValue('/test/dir'), + getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT), + getEnableInteractiveShell: vi.fn().mockReturnValue(false), + getShellExecutionConfig: vi.fn().mockReturnValue({ + sandboxManager: new NoopSandboxManager(), + sanitizationConfig: { + allowedEnvironmentVariables: [], + blockedEnvironmentVariables: [], + enableEnvironmentVariableRedaction: false, + }, + }), + getPolicyEngine: vi.fn().mockReturnValue({ + check: mockPolicyEngineCheck, + }), + getExperimentalGemma: vi.fn().mockReturnValue(false), + get config() { + return this as unknown as Config; + }, + }; + + context = createMockCommandContext({ + invocation: { + raw: '/cmd default args', + name: 'cmd', + args: 'default args', + }, + services: { + agentContext: mockConfig as Config, + }, + session: { + sessionShellAllowlist: new Set(), + }, + }); + + mockShellExecute.mockReturnValue({ + result: Promise.resolve(SUCCESS_RESULT), + }); + + mockCheckCommandPermissions.mockReturnValue({ + allAllowed: true, + disallowedCommands: [], + }); + }); + + it('should throw an error if config is missing', async () => { + const processor = new ShellProcessor('test-command'); + const prompt: PromptPipelineContent = createPromptPipelineContent('!{ls}'); + const contextWithoutConfig = createMockCommandContext({ + services: { + agentContext: null, + }, + }); + + await expect( + processor.process(prompt, contextWithoutConfig), + ).rejects.toThrow(/Security configuration not loaded/); + }); + + it('should not change the prompt if no shell injections are present', async () => { + const processor = new ShellProcessor('test-command'); + const prompt: PromptPipelineContent = createPromptPipelineContent( + 'This is a simple prompt with no injections.', + ); + const result = await processor.process(prompt, context); + expect(result).toEqual(prompt); + expect(mockShellExecute).not.toHaveBeenCalled(); + }); + + it('should process a single valid shell injection if allowed', async () => { + const processor = new ShellProcessor('test-command'); + const prompt: PromptPipelineContent = createPromptPipelineContent( + 'The current status is: !{git status}', + ); + mockPolicyEngineCheck.mockResolvedValue({ + decision: PolicyDecision.ALLOW, + }); + mockShellExecute.mockReturnValue({ + result: Promise.resolve({ ...SUCCESS_RESULT, output: 'On branch main' }), + }); + + const result = await processor.process(prompt, context); + + expect(mockPolicyEngineCheck).toHaveBeenCalledWith( + { + name: 'run_shell_command', + args: { command: 'git status' }, + }, + undefined, + ); + expect(mockShellExecute).toHaveBeenCalledWith( + 'git status', + expect.any(String), + expect.any(Function), + expect.any(Object), + false, + expect.any(Object), + ); + expect(result).toEqual([{ text: 'The current status is: On branch main' }]); + }); + + it('should process multiple valid shell injections if all are allowed', async () => { + const processor = new ShellProcessor('test-command'); + const prompt: PromptPipelineContent = createPromptPipelineContent( + '!{git status} in !{pwd}', + ); + mockPolicyEngineCheck.mockResolvedValue({ + decision: PolicyDecision.ALLOW, + }); + + mockShellExecute + .mockReturnValueOnce({ + result: Promise.resolve({ + ...SUCCESS_RESULT, + output: 'On branch main', + }), + }) + .mockReturnValueOnce({ + result: Promise.resolve({ ...SUCCESS_RESULT, output: '/usr/home' }), + }); + + const result = await processor.process(prompt, context); + + expect(mockPolicyEngineCheck).toHaveBeenCalledTimes(2); + expect(mockShellExecute).toHaveBeenCalledTimes(2); + expect(result).toEqual([{ text: 'On branch main in /usr/home' }]); + }); + + it('should throw ConfirmationRequiredError if a command is not allowed in default mode', async () => { + const processor = new ShellProcessor('test-command'); + const prompt: PromptPipelineContent = createPromptPipelineContent( + 'Do something dangerous: !{rm -rf /}', + ); + mockPolicyEngineCheck.mockResolvedValue({ + decision: PolicyDecision.ASK_USER, + }); + + await expect(processor.process(prompt, context)).rejects.toThrow( + ConfirmationRequiredError, + ); + }); + + it('should NOT throw ConfirmationRequiredError if a command is not allowed but approval mode is YOLO', async () => { + const processor = new ShellProcessor('test-command'); + const prompt: PromptPipelineContent = createPromptPipelineContent( + 'Do something dangerous: !{rm -rf /}', + ); + // In YOLO mode, PolicyEngine returns ALLOW + mockPolicyEngineCheck.mockResolvedValue({ + decision: PolicyDecision.ALLOW, + }); + // Override the approval mode for this test (though PolicyEngine mock handles the decision) + (mockConfig.getApprovalMode as Mock).mockReturnValue(ApprovalMode.YOLO); + mockShellExecute.mockReturnValue({ + result: Promise.resolve({ ...SUCCESS_RESULT, output: 'deleted' }), + }); + + const result = await processor.process(prompt, context); + + // It should proceed with execution + expect(mockShellExecute).toHaveBeenCalledWith( + 'rm -rf /', + expect.any(String), + expect.any(Function), + expect.any(Object), + false, + expect.any(Object), + ); + expect(result).toEqual([{ text: 'Do something dangerous: deleted' }]); + }); + + it('should still throw an error for a hard-denied command even in YOLO mode', async () => { + const processor = new ShellProcessor('test-command'); + const prompt: PromptPipelineContent = createPromptPipelineContent( + 'Do something forbidden: !{reboot}', + ); + mockPolicyEngineCheck.mockResolvedValue({ + decision: PolicyDecision.DENY, + }); + // Set approval mode to YOLO + (mockConfig.getApprovalMode as Mock).mockReturnValue(ApprovalMode.YOLO); + + await expect(processor.process(prompt, context)).rejects.toThrow( + /Blocked command: "reboot". Reason: Blocked by policy/, + ); + + // Ensure it never tried to execute + expect(mockShellExecute).not.toHaveBeenCalled(); + }); + + it('should throw ConfirmationRequiredError with the correct command', async () => { + const processor = new ShellProcessor('test-command'); + const prompt: PromptPipelineContent = createPromptPipelineContent( + 'Do something dangerous: !{rm -rf /}', + ); + mockPolicyEngineCheck.mockResolvedValue({ + decision: PolicyDecision.ASK_USER, + }); + + try { + await processor.process(prompt, context); + // Fail if it doesn't throw + expect(true).toBe(false); + } catch (e) { + expect(e).toBeInstanceOf(ConfirmationRequiredError); + if (e instanceof ConfirmationRequiredError) { + expect(e.commandsToConfirm).toEqual(['rm -rf /']); + } + } + + expect(mockShellExecute).not.toHaveBeenCalled(); + }); + + it('should throw ConfirmationRequiredError with multiple commands if multiple are disallowed', async () => { + const processor = new ShellProcessor('test-command'); + const prompt: PromptPipelineContent = createPromptPipelineContent( + '!{cmd1} and !{cmd2}', + ); + mockPolicyEngineCheck.mockImplementation(async (toolCall) => { + const cmd = toolCall.args.command; + if (cmd === 'cmd1' || cmd === 'cmd2') { + return { decision: PolicyDecision.ASK_USER }; + } + return { decision: PolicyDecision.ALLOW }; + }); + + try { + await processor.process(prompt, context); + // Fail if it doesn't throw + expect(true).toBe(false); + } catch (e) { + expect(e).toBeInstanceOf(ConfirmationRequiredError); + if (e instanceof ConfirmationRequiredError) { + expect(e.commandsToConfirm).toEqual(['cmd1', 'cmd2']); + } + } + }); + + it('should not execute any commands if at least one requires confirmation', async () => { + const processor = new ShellProcessor('test-command'); + const prompt: PromptPipelineContent = createPromptPipelineContent( + 'First: !{echo "hello"}, Second: !{rm -rf /}', + ); + + mockPolicyEngineCheck.mockImplementation(async (toolCall) => { + const cmd = toolCall.args.command; + if (cmd.includes('rm')) { + return { decision: PolicyDecision.ASK_USER }; + } + return { decision: PolicyDecision.ALLOW }; + }); + + await expect(processor.process(prompt, context)).rejects.toThrow( + ConfirmationRequiredError, + ); + + // Ensure no commands were executed because the pipeline was halted. + expect(mockShellExecute).not.toHaveBeenCalled(); + }); + + it('should only request confirmation for disallowed commands in a mixed prompt', async () => { + const processor = new ShellProcessor('test-command'); + const prompt: PromptPipelineContent = createPromptPipelineContent( + 'Allowed: !{ls -l}, Disallowed: !{rm -rf /}', + ); + + mockPolicyEngineCheck.mockImplementation(async (toolCall) => { + const cmd = toolCall.args.command; + if (cmd.includes('rm')) { + return { decision: PolicyDecision.ASK_USER }; + } + return { decision: PolicyDecision.ALLOW }; + }); + + try { + await processor.process(prompt, context); + expect.fail('Should have thrown ConfirmationRequiredError'); + } catch (e) { + expect(e).toBeInstanceOf(ConfirmationRequiredError); + if (e instanceof ConfirmationRequiredError) { + expect(e.commandsToConfirm).toEqual(['rm -rf /']); + } + } + }); + + it('should execute all commands if they are on the session allowlist', async () => { + const processor = new ShellProcessor('test-command'); + const prompt: PromptPipelineContent = createPromptPipelineContent( + 'Run !{cmd1} and !{cmd2}', + ); + + // Add commands to the session allowlist (conceptually, in this test we just mock the engine allowing them) + context.session.sessionShellAllowlist = new Set(['cmd1', 'cmd2']); + + // checkCommandPermissions should now pass for these + mockPolicyEngineCheck.mockResolvedValue({ + decision: PolicyDecision.ALLOW, + }); + + mockShellExecute + .mockReturnValueOnce({ + result: Promise.resolve({ ...SUCCESS_RESULT, output: 'output1' }), + }) + .mockReturnValueOnce({ + result: Promise.resolve({ ...SUCCESS_RESULT, output: 'output2' }), + }); + + const result = await processor.process(prompt, context); + + expect(mockPolicyEngineCheck).not.toHaveBeenCalled(); + expect(mockShellExecute).toHaveBeenCalledTimes(2); + expect(result).toEqual([{ text: 'Run output1 and output2' }]); + }); + + it('should support the full confirmation flow (Ask -> Approve -> Retry)', async () => { + // 1. Initial State: Command NOT allowed + const processor = new ShellProcessor('test-command'); + const prompt: PromptPipelineContent = + createPromptPipelineContent('!{echo "once"}'); + + // Policy Engine says ASK_USER + mockPolicyEngineCheck.mockResolvedValue({ + decision: PolicyDecision.ASK_USER, + }); + + // 2. First Attempt: processing should fail with ConfirmationRequiredError + try { + await processor.process(prompt, context); + expect.fail('Should have thrown ConfirmationRequiredError'); + } catch (e) { + expect(e).toBeInstanceOf(ConfirmationRequiredError); + expect(mockPolicyEngineCheck).toHaveBeenCalledTimes(1); + } + + // 3. User Approves: Add to session allowlist (simulating UI action) + context.session.sessionShellAllowlist.add('echo "once"'); + + // 4. Retry: calling process() again with the same context + // Reset mocks to ensure we track new calls cleanly + mockPolicyEngineCheck.mockClear(); + + // Mock successful execution + mockShellExecute.mockReturnValue({ + result: Promise.resolve({ ...SUCCESS_RESULT, output: 'once' }), + }); + + const result = await processor.process(prompt, context); + + // 5. Verify Success AND Policy Engine Bypass + expect(mockPolicyEngineCheck).not.toHaveBeenCalled(); + expect(mockShellExecute).toHaveBeenCalledWith( + 'echo "once"', + expect.any(String), + expect.any(Function), + expect.any(Object), + false, + expect.any(Object), + ); + expect(result).toEqual([{ text: 'once' }]); + }); + + it('should trim whitespace from the command inside the injection before interpolation', async () => { + const processor = new ShellProcessor('test-command'); + const prompt: PromptPipelineContent = createPromptPipelineContent( + 'Files: !{ ls {{args}} -l }', + ); + + const rawArgs = context.invocation!.args; + + const expectedEscapedArgs = getExpectedEscapedArgForPlatform(rawArgs); + + const expectedCommand = `ls ${expectedEscapedArgs} -l`; + + mockPolicyEngineCheck.mockResolvedValue({ + decision: PolicyDecision.ALLOW, + }); + mockShellExecute.mockReturnValue({ + result: Promise.resolve({ ...SUCCESS_RESULT, output: 'total 0' }), + }); + + await processor.process(prompt, context); + + expect(mockPolicyEngineCheck).toHaveBeenCalledWith( + { name: 'run_shell_command', args: { command: expectedCommand } }, + undefined, + ); + expect(mockShellExecute).toHaveBeenCalledWith( + expectedCommand, + expect.any(String), + expect.any(Function), + expect.any(Object), + false, + expect.any(Object), + ); + }); + + it('should handle an empty command inside the injection gracefully (skips execution)', async () => { + const processor = new ShellProcessor('test-command'); + const prompt: PromptPipelineContent = + createPromptPipelineContent('This is weird: !{}'); + + const result = await processor.process(prompt, context); + + expect(mockPolicyEngineCheck).not.toHaveBeenCalled(); + expect(mockShellExecute).not.toHaveBeenCalled(); + + // It replaces !{} with an empty string. + expect(result).toEqual([{ text: 'This is weird: ' }]); + }); + + describe('Error Reporting', () => { + it('should append exit code and command name on failure', async () => { + const processor = new ShellProcessor('test-command'); + const prompt: PromptPipelineContent = + createPromptPipelineContent('!{cmd}'); + mockShellExecute.mockReturnValue({ + result: Promise.resolve({ + ...SUCCESS_RESULT, + output: 'some error output', + stderr: '', + exitCode: 1, + }), + }); + + const result = await processor.process(prompt, context); + + expect(result).toEqual([ + { + text: "some error output\n[Shell command 'cmd' exited with code 1]", + }, + ]); + }); + + it('should append signal info and command name if terminated by signal', async () => { + const processor = new ShellProcessor('test-command'); + const prompt: PromptPipelineContent = + createPromptPipelineContent('!{cmd}'); + mockShellExecute.mockReturnValue({ + result: Promise.resolve({ + ...SUCCESS_RESULT, + output: 'output', + stderr: '', + exitCode: null, + signal: 'SIGTERM', + }), + }); + + const result = await processor.process(prompt, context); + + expect(result).toEqual([ + { + text: "output\n[Shell command 'cmd' terminated by signal SIGTERM]", + }, + ]); + }); + + it('should throw a detailed error if the shell fails to spawn', async () => { + const processor = new ShellProcessor('test-command'); + const prompt: PromptPipelineContent = + createPromptPipelineContent('!{bad-command}'); + const spawnError = new Error('spawn EACCES'); + mockShellExecute.mockReturnValue({ + result: Promise.resolve({ + ...SUCCESS_RESULT, + stdout: '', + stderr: '', + exitCode: null, + error: spawnError, + aborted: false, + }), + }); + + await expect(processor.process(prompt, context)).rejects.toThrow( + "Failed to start shell command in 'test-command': spawn EACCES. Command: bad-command", + ); + }); + + it('should report abort status with command name if aborted', async () => { + const processor = new ShellProcessor('test-command'); + const prompt: PromptPipelineContent = createPromptPipelineContent( + '!{long-running-command}', + ); + const spawnError = new Error('Aborted'); + mockShellExecute.mockReturnValue({ + result: Promise.resolve({ + ...SUCCESS_RESULT, + output: 'partial output', + stderr: '', + exitCode: null, + error: spawnError, + aborted: true, // Key difference + }), + }); + + const result = await processor.process(prompt, context); + expect(result).toEqual([ + { + text: "partial output\n[Shell command 'long-running-command' aborted]", + }, + ]); + }); + }); + + describe('Context-Aware Argument Interpolation ({{args}})', () => { + const rawArgs = 'user input'; + + beforeEach(() => { + // Update context for these tests to use specific arguments + context.invocation!.args = rawArgs; + }); + + it('should perform raw replacement if no shell injections are present (optimization path)', async () => { + const processor = new ShellProcessor('test-command'); + const prompt: PromptPipelineContent = createPromptPipelineContent( + 'The user said: {{args}}', + ); + + const result = await processor.process(prompt, context); + + expect(result).toEqual([{ text: `The user said: ${rawArgs}` }]); + expect(mockShellExecute).not.toHaveBeenCalled(); + }); + + it('should perform raw replacement outside !{} blocks', async () => { + const processor = new ShellProcessor('test-command'); + const prompt: PromptPipelineContent = createPromptPipelineContent( + 'Outside: {{args}}. Inside: !{echo "hello"}', + ); + mockShellExecute.mockReturnValue({ + result: Promise.resolve({ ...SUCCESS_RESULT, output: 'hello' }), + }); + + const result = await processor.process(prompt, context); + + expect(result).toEqual([{ text: `Outside: ${rawArgs}. Inside: hello` }]); + }); + + it('should perform escaped replacement inside !{} blocks', async () => { + const processor = new ShellProcessor('test-command'); + const prompt: PromptPipelineContent = createPromptPipelineContent( + 'Command: !{grep {{args}} file.txt}', + ); + mockShellExecute.mockReturnValue({ + result: Promise.resolve({ ...SUCCESS_RESULT, output: 'match found' }), + }); + + const result = await processor.process(prompt, context); + + const expectedEscapedArgs = getExpectedEscapedArgForPlatform(rawArgs); + const expectedCommand = `grep ${expectedEscapedArgs} file.txt`; + + expect(mockShellExecute).toHaveBeenCalledWith( + expectedCommand, + expect.any(String), + expect.any(Function), + expect.any(Object), + false, + expect.any(Object), + ); + + expect(result).toEqual([{ text: 'Command: match found' }]); + }); + + it('should handle both raw (outside) and escaped (inside) injection simultaneously', async () => { + const processor = new ShellProcessor('test-command'); + const prompt: PromptPipelineContent = createPromptPipelineContent( + 'User "({{args}})" requested search: !{search {{args}}}', + ); + mockShellExecute.mockReturnValue({ + result: Promise.resolve({ ...SUCCESS_RESULT, output: 'results' }), + }); + + const result = await processor.process(prompt, context); + + const expectedEscapedArgs = getExpectedEscapedArgForPlatform(rawArgs); + const expectedCommand = `search ${expectedEscapedArgs}`; + expect(mockShellExecute).toHaveBeenCalledWith( + expectedCommand, + expect.any(String), + expect.any(Function), + expect.any(Object), + false, + expect.any(Object), + ); + + expect(result).toEqual([ + { text: `User "(${rawArgs})" requested search: results` }, + ]); + }); + + it('should perform security checks on the final, resolved (escaped) command', async () => { + const processor = new ShellProcessor('test-command'); + const prompt: PromptPipelineContent = + createPromptPipelineContent('!{rm {{args}}}'); + + const expectedEscapedArgs = getExpectedEscapedArgForPlatform(rawArgs); + const expectedResolvedCommand = `rm ${expectedEscapedArgs}`; + mockPolicyEngineCheck.mockResolvedValue({ + decision: PolicyDecision.ASK_USER, + }); + + await expect(processor.process(prompt, context)).rejects.toThrow( + ConfirmationRequiredError, + ); + + expect(mockPolicyEngineCheck).toHaveBeenCalledWith( + { + name: 'run_shell_command', + args: { command: expectedResolvedCommand }, + }, + undefined, + ); + }); + + it('should report the resolved command if a hard denial occurs', async () => { + const processor = new ShellProcessor('test-command'); + const prompt: PromptPipelineContent = + createPromptPipelineContent('!{rm {{args}}}'); + const expectedEscapedArgs = getExpectedEscapedArgForPlatform(rawArgs); + const expectedResolvedCommand = `rm ${expectedEscapedArgs}`; + mockPolicyEngineCheck.mockResolvedValue({ + decision: PolicyDecision.DENY, + }); + + await expect(processor.process(prompt, context)).rejects.toThrow( + `Blocked command: "${expectedResolvedCommand}". Reason: Blocked by policy.`, + ); + }); + }); + describe('Real-World Escaping Scenarios', () => { + it('should correctly handle multiline arguments', async () => { + const processor = new ShellProcessor('test-command'); + const multilineArgs = 'first line\nsecond line'; + context.invocation!.args = multilineArgs; + const prompt: PromptPipelineContent = createPromptPipelineContent( + 'Commit message: !{git commit -m {{args}}}', + ); + + const expectedEscapedArgs = + getExpectedEscapedArgForPlatform(multilineArgs); + const expectedCommand = `git commit -m ${expectedEscapedArgs}`; + + await processor.process(prompt, context); + + expect(mockShellExecute).toHaveBeenCalledWith( + expectedCommand, + expect.any(String), + expect.any(Function), + expect.any(Object), + false, + expect.any(Object), + ); + }); + + it.each([ + { name: 'spaces', input: 'file with spaces.txt' }, + { name: 'double quotes', input: 'a "quoted" string' }, + { name: 'single quotes', input: "it's a string" }, + { name: 'command substitution (backticks)', input: '`reboot`' }, + { name: 'command substitution (dollar)', input: '$(reboot)' }, + { name: 'variable expansion', input: '$HOME' }, + { name: 'command chaining (semicolon)', input: 'a; reboot' }, + { name: 'command chaining (ampersand)', input: 'a && reboot' }, + ])('should safely escape args containing $name', async ({ input }) => { + const processor = new ShellProcessor('test-command'); + context.invocation!.args = input; + const prompt: PromptPipelineContent = + createPromptPipelineContent('!{echo {{args}}}'); + + const expectedEscapedArgs = getExpectedEscapedArgForPlatform(input); + const expectedCommand = `echo ${expectedEscapedArgs}`; + + await processor.process(prompt, context); + + expect(mockShellExecute).toHaveBeenCalledWith( + expectedCommand, + expect.any(String), + expect.any(Function), + expect.any(Object), + false, + expect.any(Object), + ); + }); + }); +}); diff --git a/packages/cli/src/services/prompt-processors/shellProcessor.ts b/packages/cli/src/services/prompt-processors/shellProcessor.ts new file mode 100644 index 0000000000000000000000000000000000000000..0042dc4f49ea757897243aa7959999a6a7db50bd --- /dev/null +++ b/packages/cli/src/services/prompt-processors/shellProcessor.ts @@ -0,0 +1,217 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + escapeShellArg, + getShellConfiguration, + ShellExecutionService, + flatMapTextParts, + PolicyDecision, +} from '@google/gemini-cli-core'; + +import type { CommandContext } from '../../ui/commands/types.js'; +import type { IPromptProcessor, PromptPipelineContent } from './types.js'; +import { + SHELL_INJECTION_TRIGGER, + SHORTHAND_ARGS_PLACEHOLDER, +} from './types.js'; +import { extractInjections, type Injection } from './injectionParser.js'; +import { themeManager } from '../../ui/themes/theme-manager.js'; + +export class ConfirmationRequiredError extends Error { + constructor( + message: string, + public commandsToConfirm: string[], + ) { + super(message); + this.name = 'ConfirmationRequiredError'; + } +} + +/** + * Represents a single detected shell injection site in the prompt, + * after resolution of arguments. Extends the base Injection interface. + */ +interface ResolvedShellInjection extends Injection { + /** The command after {{args}} has been escaped and substituted. */ + resolvedCommand?: string; +} + +/** + * Handles prompt interpolation, including shell command execution (`!{...}`) + * and context-aware argument injection (`{{args}}`). + * + * This processor ensures that: + * 1. `{{args}}` outside `!{...}` are replaced with raw input. + * 2. `{{args}}` inside `!{...}` are replaced with shell-escaped input. + * 3. Shell commands are executed securely after argument substitution. + * 4. Parsing correctly handles nested braces. + */ +export class ShellProcessor implements IPromptProcessor { + constructor(private readonly commandName: string) {} + + async process( + prompt: PromptPipelineContent, + context: CommandContext, + ): Promise { + return flatMapTextParts(prompt, (text) => + this.processString(text, context), + ); + } + + private async processString( + prompt: string, + context: CommandContext, + ): Promise { + const userArgsRaw = context.invocation?.args || ''; + + if (!prompt.includes(SHELL_INJECTION_TRIGGER)) { + return [ + { text: prompt.replaceAll(SHORTHAND_ARGS_PLACEHOLDER, userArgsRaw) }, + ]; + } + + const config = context.services.agentContext?.config; + if (!config) { + throw new Error( + `Security configuration not loaded. Cannot verify shell command permissions for '${this.commandName}'. Aborting.`, + ); + } + + const injections = extractInjections( + prompt, + SHELL_INJECTION_TRIGGER, + this.commandName, + ); + + // If extractInjections found no closed blocks (and didn't throw), treat as raw. + if (injections.length === 0) { + return [ + { text: prompt.replaceAll(SHORTHAND_ARGS_PLACEHOLDER, userArgsRaw) }, + ]; + } + + const { shell } = getShellConfiguration(); + const userArgsEscaped = escapeShellArg(userArgsRaw, shell); + + const resolvedInjections: ResolvedShellInjection[] = injections.map( + (injection) => { + const command = injection.content; + + if (command === '') { + return { ...injection, resolvedCommand: undefined }; + } + + const resolvedCommand = command.replaceAll( + SHORTHAND_ARGS_PLACEHOLDER, + userArgsEscaped, + ); + return { ...injection, resolvedCommand }; + }, + ); + + const commandsToConfirm = new Set(); + for (const injection of resolvedInjections) { + const command = injection.resolvedCommand; + + if (!command) continue; + + if (context.session.sessionShellAllowlist?.has(command)) { + continue; + } + + // Security check on the final, escaped command string. + const { decision } = await config.getPolicyEngine().check( + { + name: 'run_shell_command', + args: { command }, + }, + undefined, + ); + + if (decision === PolicyDecision.DENY) { + throw new Error( + `${this.commandName} cannot be run. Blocked command: "${command}". Reason: Blocked by policy.`, + ); + } else if (decision === PolicyDecision.ASK_USER) { + commandsToConfirm.add(command); + } + } + + // Handle confirmation requirements. + if (commandsToConfirm.size > 0) { + throw new ConfirmationRequiredError( + 'Shell command confirmation required', + Array.from(commandsToConfirm), + ); + } + + let processedPrompt = ''; + let lastIndex = 0; + + for (const injection of resolvedInjections) { + // Append the text segment BEFORE the injection, substituting {{args}} with RAW input. + const segment = prompt.substring(lastIndex, injection.startIndex); + processedPrompt += segment.replaceAll( + SHORTHAND_ARGS_PLACEHOLDER, + userArgsRaw, + ); + + // Execute the resolved command (which already has ESCAPED input). + if (injection.resolvedCommand) { + const activeTheme = themeManager.getActiveTheme(); + const shellExecutionConfig = { + ...config.getShellExecutionConfig(), + defaultFg: activeTheme.colors.Foreground, + defaultBg: activeTheme.colors.Background, + }; + const { result } = await ShellExecutionService.execute( + injection.resolvedCommand, + config.getTargetDir(), + () => {}, + new AbortController().signal, + config.getEnableInteractiveShell(), + shellExecutionConfig, + ); + + const executionResult = await result; + + // Handle Spawn Errors + if (executionResult.error && !executionResult.aborted) { + throw new Error( + `Failed to start shell command in '${this.commandName}': ${executionResult.error.message}. Command: ${injection.resolvedCommand}`, + ); + } + + // Append the output, making stderr explicit for the model. + processedPrompt += executionResult.output; + + // Append a status message if the command did not succeed. + if (executionResult.aborted) { + processedPrompt += `\n[Shell command '${injection.resolvedCommand}' aborted]`; + } else if ( + executionResult.exitCode !== 0 && + executionResult.exitCode !== null + ) { + processedPrompt += `\n[Shell command '${injection.resolvedCommand}' exited with code ${executionResult.exitCode}]`; + } else if (executionResult.signal !== null) { + processedPrompt += `\n[Shell command '${injection.resolvedCommand}' terminated by signal ${executionResult.signal}]`; + } + } + + lastIndex = injection.endIndex; + } + + // Append the remaining text AFTER the last injection, substituting {{args}} with RAW input. + const finalSegment = prompt.substring(lastIndex); + processedPrompt += finalSegment.replaceAll( + SHORTHAND_ARGS_PLACEHOLDER, + userArgsRaw, + ); + + return [{ text: processedPrompt }]; + } +} diff --git a/packages/cli/src/services/prompt-processors/types.ts b/packages/cli/src/services/prompt-processors/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..c687657443fe038e648fc9bf791b655b7c196e3a --- /dev/null +++ b/packages/cli/src/services/prompt-processors/types.ts @@ -0,0 +1,54 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandContext } from '../../ui/commands/types.js'; +import type { PartUnion } from '@google/genai'; + +/** + * Defines the input/output type for prompt processors. + */ +export type PromptPipelineContent = PartUnion[]; + +/** + * Defines the interface for a prompt processor, a module that can transform + * a prompt string before it is sent to the model. Processors are chained + * together to create a processing pipeline. + */ +export interface IPromptProcessor { + /** + * Processes a prompt input (which may contain text and multi-modal parts), + * applying a specific transformation as part of a pipeline. + * + * @param prompt The current state of the prompt string. This may have been + * modified by previous processors in the pipeline. + * @param context The full command context, providing access to invocation + * details (like `context.invocation.raw` and `context.invocation.args`), + * application services, and UI handlers. + * @returns A promise that resolves to the transformed prompt string, which + * will be passed to the next processor or, if it's the last one, sent to the model. + */ + process( + prompt: PromptPipelineContent, + context: CommandContext, + ): Promise; +} + +/** + * The placeholder string for shorthand argument injection in custom commands. + * When used outside of !{...}, arguments are injected raw. + * When used inside !{...}, arguments are shell-escaped. + */ +export const SHORTHAND_ARGS_PLACEHOLDER = '{{args}}'; + +/** + * The trigger string for shell command injection in custom commands. + */ +export const SHELL_INJECTION_TRIGGER = '!{'; + +/** + * The trigger string for at file injection in custom commands. + */ +export const AT_FILE_INJECTION_TRIGGER = '@{'; diff --git a/packages/cli/src/services/types.ts b/packages/cli/src/services/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..b583e56e3974b713adf78b7b95bb860773a4dd26 --- /dev/null +++ b/packages/cli/src/services/types.ts @@ -0,0 +1,33 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { SlashCommand } from '../ui/commands/types.js'; + +/** + * Defines the contract for any class that can load and provide slash commands. + * This allows the CommandService to be extended with new command sources + * (e.g., file-based, remote APIs) without modification. + * + * Loaders should receive any necessary dependencies (like Config) via their + * constructor. + */ +export interface ICommandLoader { + /** + * Discovers and returns a list of slash commands from the loader's source. + * @param signal An AbortSignal to allow cancellation. + * @returns A promise that resolves to an array of SlashCommand objects. + */ + loadCommands(signal: AbortSignal): Promise; +} + +export interface CommandConflict { + name: string; + losers: Array<{ + command: SlashCommand; + renamedTo: string; + reason: SlashCommand; + }>; +} diff --git a/packages/cli/src/test-utils/AppRig.test.tsx b/packages/cli/src/test-utils/AppRig.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6d94342937ac32c818ba631f962df4248b7f50a8 --- /dev/null +++ b/packages/cli/src/test-utils/AppRig.test.tsx @@ -0,0 +1,82 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, afterEach, expect } from 'vitest'; +import { AppRig } from './AppRig.js'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { debugLogger } from '@google/gemini-cli-core'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +describe('AppRig', () => { + let rig: AppRig | undefined; + + afterEach(async () => { + await rig?.unmount(); + }); + + it('should handle deterministic tool turns with breakpoints', async () => { + const fakeResponsesPath = path.join( + __dirname, + 'fixtures', + 'steering.responses', + ); + rig = new AppRig({ + fakeResponsesPath, + configOverrides: { modelSteering: true }, + }); + await rig.initialize(); + await rig.render(); + await rig.waitForIdle(); + + // Set breakpoints on the canonical tool names + rig.setBreakpoint('list_directory'); + rig.setBreakpoint('read_file'); + + // Start a task + debugLogger.log('[Test] Sending message: Start long task'); + await rig.sendMessage('Start long task'); + + // Wait for the first breakpoint (list_directory) + const pending1 = await rig.waitForPendingConfirmation('list_directory'); + expect(pending1.toolName).toBe('list_directory'); + + // Injected a hint + await rig.addUserHint('focus on .txt'); + + // Resolve and wait for the NEXT breakpoint (read_file) + // resolveTool will automatically remove the breakpoint policy for list_directory + await rig.resolveTool('list_directory'); + + const pending2 = await rig.waitForPendingConfirmation('read_file'); + expect(pending2.toolName).toBe('read_file'); + + // Resolve and finish. Also removes read_file breakpoint. + await rig.resolveTool('read_file'); + await rig.waitForOutput('Task complete.', 100000); + }); + + it('should render the app and handle a simple message', async () => { + const fakeResponsesPath = path.join( + __dirname, + 'fixtures', + 'simple.responses', + ); + rig = new AppRig({ fakeResponsesPath }); + await rig.initialize(); + await rig.render(); + // Wait for initial render + await rig.waitForIdle(); + + // Type a message + await rig.type('Hello'); + await rig.pressEnter(); + + // Wait for model response + await rig.waitForOutput('Hello! How can I help you today?'); + }); +}); diff --git a/packages/cli/src/test-utils/AppRig.tsx b/packages/cli/src/test-utils/AppRig.tsx new file mode 100644 index 0000000000000000000000000000000000000000..548372a139a58f481425a2972e5484e28da641d6 --- /dev/null +++ b/packages/cli/src/test-utils/AppRig.tsx @@ -0,0 +1,782 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi } from 'vitest'; +import { act } from 'react'; +import stripAnsi from 'strip-ansi'; +import os from 'node:os'; +import path from 'node:path'; +import fs from 'node:fs'; +import { AppContainer } from '../ui/AppContainer.js'; +import { + renderWithProviders, + type RenderInstance, + persistentStateMock, +} from './render.js'; +import { + makeFakeConfig, + type Config, + type ConfigParameters, + ExtensionLoader, + AuthType, + ApprovalMode, + createPolicyEngineConfig, + PolicyDecision, + ToolConfirmationOutcome, + MessageBusType, + type ToolCallsUpdateMessage, + coreEvents, + ideContextStore, + createContentGenerator, + IdeClient, + debugLogger, + CoreToolCallStatus, + IntegrityDataStatus, +} from '@google/gemini-cli-core'; +import { + type MockShellCommand, + MockShellExecutionService, +} from './MockShellExecutionService.js'; +import { createMockSettings } from './settings.js'; +import { + type LoadedSettings, + resetSettingsCacheForTesting, +} from '../config/settings.js'; +import { AuthState, StreamingState } from '../ui/types.js'; +import { randomUUID } from 'node:crypto'; +import type { + TrackedCancelledToolCall, + TrackedCompletedToolCall, + TrackedToolCall, +} from '../ui/hooks/useToolScheduler.js'; + +// Global state observer for React-based signals +const sessionStateMap = new Map(); +const activeRigs = new Map(); + +// Mock StreamingContext to report state changes back to the observer +vi.mock('../ui/contexts/StreamingContext.js', async (importOriginal) => { + const original = + await importOriginal(); + const { useConfig } = await import('../ui/contexts/ConfigContext.js'); + const React = await import('react'); + + return { + ...original, + useStreamingContext: () => { + const state = original.useStreamingContext(); + const config = useConfig(); + const sessionId = config.getSessionId(); + + React.useEffect(() => { + sessionStateMap.set(sessionId, state); + // If we see activity, we are no longer "awaiting" the start of a response + if (state !== StreamingState.Idle) { + const rig = activeRigs.get(sessionId); + if (rig) { + rig.awaitingResponse = false; + } + } + }, [sessionId, state]); + + return state; + }, + }; +}); + +// Mock core functions globally for tests using AppRig. +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const original = + await importOriginal(); + const { MockShellExecutionService: MockService } = await import( + './MockShellExecutionService.js' + ); + // Register the real execution logic so MockShellExecutionService can fall back to it + MockService.setOriginalImplementation(original.ShellExecutionService.execute); + + return { + ...original, + ShellExecutionService: MockService, + }; +}); + +// Mock useAuthCommand to bypass authentication flows in tests +vi.mock('../ui/auth/useAuth.js', () => ({ + useAuthCommand: () => ({ + authState: AuthState.Authenticated, + setAuthState: vi.fn(), + authError: null, + onAuthError: vi.fn(), + apiKeyDefaultValue: 'test-api-key', + reloadApiKey: vi.fn().mockResolvedValue('test-api-key'), + accountSuspensionInfo: null, + setAccountSuspensionInfo: vi.fn(), + }), + validateAuthMethodWithSettings: () => null, +})); + +// A minimal mock ExtensionManager to satisfy AppContainer's forceful cast +class MockExtensionManager extends ExtensionLoader { + getExtensions = vi.fn().mockReturnValue([]); + setRequestConsent = vi.fn(); + setRequestSetting = vi.fn(); + integrityManager = { + verifyExtensionIntegrity: vi + .fn() + .mockResolvedValue(IntegrityDataStatus.VERIFIED), + storeExtensionIntegrity: vi.fn().mockResolvedValue(undefined), + }; +} + +// Mock GeminiRespondingSpinner to disable animations (avoiding 'act()' warnings) without triggering screen reader mode. +vi.mock('../ui/components/GeminiRespondingSpinner.js', async () => { + const React = await import('react'); + const { Text } = await import('ink'); + return { + GeminiSpinner: () => React.createElement(Text, null, '...'), + GeminiRespondingSpinner: ({ + nonRespondingDisplay, + }: { + nonRespondingDisplay: string; + }) => React.createElement(Text, null, nonRespondingDisplay || '...'), + }; +}); + +export interface AppRigOptions { + fakeResponsesPath?: string; + terminalWidth?: number; + terminalHeight?: number; + configOverrides?: Partial; +} + +export interface PendingConfirmation { + toolName: string; + toolDisplayName?: string; + correlationId: string; +} + +export class AppRig { + private renderResult: RenderInstance | undefined; + private config: Config | undefined; + private settings: LoadedSettings | undefined; + private testDir: string; + private sessionId: string; + + private pendingConfirmations = new Map(); + private breakpointTools = new Set(); + private lastAwaitedConfirmation: PendingConfirmation | undefined; + + /** + * True if a message was just sent but React hasn't yet reported a non-idle state. + */ + awaitingResponse = false; + + constructor(private options: AppRigOptions = {}) { + const uniqueId = randomUUID(); + this.testDir = fs.mkdtempSync( + path.join(os.tmpdir(), `gemini-app-rig-${uniqueId.slice(0, 8)}-`), + ); + this.sessionId = `test-session-${uniqueId}`; + activeRigs.set(this.sessionId, this); + + // Pre-create the persistent state file to bypass the terminal setup prompt + const geminiDir = path.join(this.testDir, '.gemini'); + if (!fs.existsSync(geminiDir)) { + fs.mkdirSync(geminiDir, { recursive: true }); + } + fs.writeFileSync( + path.join(geminiDir, 'state.json'), + JSON.stringify({ terminalSetupPromptShown: true }), + ); + } + + async initialize() { + persistentStateMock.setData({ + terminalSetupPromptShown: true, + tipsShown: 10, + }); + + this.setupEnvironment(); + resetSettingsCacheForTesting(); + this.settings = this.createRigSettings(); + + const approvalMode = + this.options.configOverrides?.approvalMode ?? ApprovalMode.DEFAULT; + const policyEngineConfig = await createPolicyEngineConfig( + this.settings.merged, + approvalMode, + ); + + const configParams: ConfigParameters = { + sessionId: this.sessionId, + targetDir: this.testDir, + cwd: this.testDir, + debugMode: false, + model: 'test-model', + fakeResponses: this.options.fakeResponsesPath, + interactive: true, + approvalMode, + policyEngineConfig, + enableEventDrivenScheduler: true, + extensionLoader: new MockExtensionManager(), + excludeTools: this.options.configOverrides?.excludeTools, + useAlternateBuffer: false, + ...this.options.configOverrides, + }; + this.config = makeFakeConfig(configParams); + + if (this.options.fakeResponsesPath) { + this.stubRefreshAuth(); + } + + this.setupMessageBusListeners(); + + await act(async () => { + await this.config!.initialize(); + // Since we mocked useAuthCommand, we must manually trigger the first + // refreshAuth to ensure contentGenerator is initialized. + await this.config!.refreshAuth(AuthType.USE_GEMINI); + }); + } + + private setupEnvironment() { + // Stub environment variables to avoid interference from developer's machine + vi.stubEnv('GEMINI_CLI_HOME', this.testDir); + vi.stubEnv('TERM_PROGRAM', 'other'); + vi.stubEnv('VSCODE_GIT_IPC_HANDLE', ''); + if (this.options.fakeResponsesPath) { + vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); + MockShellExecutionService.setPassthrough(false); + } else { + if (!process.env['GEMINI_API_KEY']) { + throw new Error( + 'GEMINI_API_KEY must be set in the environment for live model tests.', + ); + } + // For live tests, we allow falling through to the real shell service if no mock matches + MockShellExecutionService.setPassthrough(true); + } + vi.stubEnv('GEMINI_DEFAULT_AUTH_TYPE', AuthType.USE_GEMINI); + } + + private createRigSettings(): LoadedSettings { + return createMockSettings({ + user: { + path: path.join(this.testDir, '.gemini', 'user_settings.json'), + settings: { + security: { + auth: { + selectedType: AuthType.USE_GEMINI, + useExternal: true, + }, + folderTrust: { + enabled: true, + }, + }, + ide: { + enabled: false, + hasSeenNudge: true, + }, + }, + originalSettings: {}, + }, + merged: { + security: { + auth: { + selectedType: AuthType.USE_GEMINI, + useExternal: true, + }, + folderTrust: { + enabled: true, + }, + }, + ide: { + enabled: false, + hasSeenNudge: true, + }, + ui: { + useAlternateBuffer: false, + }, + }, + }); + } + + private stubRefreshAuth() { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const gcConfig = this.config as any; + gcConfig.refreshAuth = async (authMethod: AuthType) => { + gcConfig.modelAvailabilityService.reset(); + + const newContentGeneratorConfig = { + authType: authMethod, + proxy: gcConfig.getProxy(), + apiKey: process.env['GEMINI_API_KEY'] || 'test-api-key', + }; + + gcConfig.contentGenerator = await createContentGenerator( + newContentGeneratorConfig, + this.config!, + gcConfig.getSessionId(), + ); + gcConfig.contentGeneratorConfig = newContentGeneratorConfig; + + // Initialize BaseLlmClient now that the ContentGenerator is available + const { BaseLlmClient } = await import('@google/gemini-cli-core'); + gcConfig.baseLlmClient = new BaseLlmClient( + gcConfig.contentGenerator, + this.config!, + ); + }; + } + + private toolCalls: TrackedToolCall[] = []; + + private setupMessageBusListeners() { + if (!this.config) return; + const messageBus = this.config.getMessageBus(); + + messageBus.subscribe( + MessageBusType.TOOL_CALLS_UPDATE, + (message: ToolCallsUpdateMessage) => { + this.toolCalls = message.toolCalls; + for (const call of message.toolCalls) { + if (call.status === 'awaiting_approval' && call.correlationId) { + const details = call.confirmationDetails; + const title = 'title' in details ? details.title : ''; + const toolDisplayName = + call.tool?.displayName || title.replace(/^Confirm:\s*/, ''); + if (!this.pendingConfirmations.has(call.correlationId)) { + this.pendingConfirmations.set(call.correlationId, { + toolName: call.request.name, + toolDisplayName, + correlationId: call.correlationId, + }); + } + } else if (call.status !== 'awaiting_approval') { + for (const [ + correlationId, + pending, + ] of this.pendingConfirmations.entries()) { + if (pending.toolName === call.request.name) { + this.pendingConfirmations.delete(correlationId); + break; + } + } + } + } + }, + ); + } + + /** + * Returns true if the agent is currently busy (responding or executing tools). + */ + isBusy(): boolean { + if (this.awaitingResponse) { + return true; + } + + const reactState = sessionStateMap.get(this.sessionId); + // If we have a React-based state, use it as the definitive signal. + // 'responding' and 'waiting-for-confirmation' both count as busy for the overall task. + if (reactState !== undefined) { + return reactState !== StreamingState.Idle; + } + + // Fallback to tool tracking if React hasn't reported yet + const isAnyToolActive = this.toolCalls.some((tc) => { + if ( + tc.status === CoreToolCallStatus.Executing || + tc.status === CoreToolCallStatus.Scheduled || + tc.status === CoreToolCallStatus.Validating + ) { + return true; + } + if ( + tc.status === CoreToolCallStatus.Success || + tc.status === CoreToolCallStatus.Error || + tc.status === CoreToolCallStatus.Cancelled + ) { + return !(tc as TrackedCompletedToolCall | TrackedCancelledToolCall) + .responseSubmittedToGemini; + } + return false; + }); + + const isAwaitingConfirmation = this.toolCalls.some( + (tc) => tc.status === CoreToolCallStatus.AwaitingApproval, + ); + + return isAnyToolActive || isAwaitingConfirmation; + } + + async render() { + if (!this.config || !this.settings) + throw new Error('AppRig not initialized'); + + await act(async () => { + this.renderResult = await renderWithProviders( + , + { + config: this.config!, + settings: this.settings!, + width: this.options.terminalWidth ?? 120, + uiState: { + terminalHeight: this.options.terminalHeight ?? 40, + }, + }, + ); + }); + } + + setMockCommands(commands: MockShellCommand[]) { + MockShellExecutionService.setMockCommands(commands); + } + + setToolPolicy(toolName: string, decision: PolicyDecision, priority = 10) { + if (!this.config) throw new Error('AppRig not initialized'); + this.config.getPolicyEngine().addRule({ + toolName, + decision, + priority, + source: 'AppRig Override', + }); + } + + setBreakpoint(toolName: string | string[]) { + if (Array.isArray(toolName)) { + for (const name of toolName) { + this.setBreakpoint(name); + } + } else { + this.setToolPolicy(toolName, PolicyDecision.ASK_USER, 100); + this.breakpointTools.add(toolName); + } + } + + removeToolPolicy(toolName: string, source = 'AppRig Override') { + if (!this.config) throw new Error('AppRig not initialized'); + this.config.getPolicyEngine().removeRulesForTool(toolName, source); + this.breakpointTools.delete(toolName); + } + + getTestDir(): string { + return this.testDir; + } + + getPendingConfirmations() { + return Array.from(this.pendingConfirmations.values()); + } + + private async waitUntil( + predicate: () => boolean | Promise, + options: { timeout?: number; interval?: number; message?: string } = {}, + ) { + const { + timeout = 30000, + interval = 100, + message = 'Condition timed out', + } = options; + const start = Date.now(); + + while (true) { + if (await predicate()) return; + + if (Date.now() - start > timeout) { + throw new Error(message); + } + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, interval)); + }); + } + } + + async waitForPendingConfirmation( + toolNameOrDisplayName?: string | RegExp | string[], + timeout = 30000, + ): Promise { + const matches = (p: PendingConfirmation) => { + if (!toolNameOrDisplayName) return true; + if (typeof toolNameOrDisplayName === 'string') { + return ( + p.toolName === toolNameOrDisplayName || + p.toolDisplayName === toolNameOrDisplayName + ); + } + if (Array.isArray(toolNameOrDisplayName)) { + return ( + toolNameOrDisplayName.includes(p.toolName) || + toolNameOrDisplayName.includes(p.toolDisplayName || '') + ); + } + return ( + toolNameOrDisplayName.test(p.toolName) || + toolNameOrDisplayName.test(p.toolDisplayName || '') + ); + }; + + let matched: PendingConfirmation | undefined; + await this.waitUntil( + () => { + matched = this.getPendingConfirmations().find(matches); + return !!matched; + }, + { + timeout, + message: `Timed out waiting for pending confirmation: ${toolNameOrDisplayName || 'any'}. Current pending: ${this.getPendingConfirmations() + .map((p) => p.toolName) + .join(', ')}`, + }, + ); + + this.lastAwaitedConfirmation = matched; + return matched!; + } + + /** + * Waits for either a tool confirmation request OR for the agent to go idle. + */ + async waitForNextEvent( + timeout = 60000, + ): Promise< + | { type: 'confirmation'; confirmation: PendingConfirmation } + | { type: 'idle' } + > { + let confirmation: PendingConfirmation | undefined; + let isIdle = false; + + await this.waitUntil( + async () => { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + confirmation = this.getPendingConfirmations()[0]; + // Now that we have a code-powered signal, this should be perfectly deterministic. + isIdle = !this.isBusy(); + return !!confirmation || isIdle; + }, + { + timeout, + message: 'Timed out waiting for next event (confirmation or idle).', + }, + ); + + if (confirmation) { + this.lastAwaitedConfirmation = confirmation; + return { type: 'confirmation', confirmation }; + } + + // Ensure all renders are flushed before returning 'idle' + await this.renderResult?.waitUntilReady(); + return { type: 'idle' }; + } + + async resolveTool( + toolNameOrDisplayName: string | RegExp | PendingConfirmation, + outcome: ToolConfirmationOutcome = ToolConfirmationOutcome.ProceedOnce, + ): Promise { + if (!this.config) throw new Error('AppRig not initialized'); + const messageBus = this.config.getMessageBus(); + + let pending: PendingConfirmation; + if ( + typeof toolNameOrDisplayName === 'object' && + 'correlationId' in toolNameOrDisplayName + ) { + pending = toolNameOrDisplayName; + } else { + pending = await this.waitForPendingConfirmation(toolNameOrDisplayName); + } + + await act(async () => { + this.pendingConfirmations.delete(pending.correlationId); + + if (this.breakpointTools.has(pending.toolName)) { + this.removeToolPolicy(pending.toolName); + } + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + messageBus.publish({ + type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, + correlationId: pending.correlationId, + confirmed: outcome !== ToolConfirmationOutcome.Cancel, + outcome, + }); + }); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + } + + async resolveAwaitedTool( + outcome: ToolConfirmationOutcome = ToolConfirmationOutcome.ProceedOnce, + ): Promise { + if (!this.lastAwaitedConfirmation) { + throw new Error('No tool has been awaited yet'); + } + await this.resolveTool(this.lastAwaitedConfirmation, outcome); + this.lastAwaitedConfirmation = undefined; + } + + async addUserHint(hint: string) { + if (!this.config) throw new Error('AppRig not initialized'); + await act(async () => { + this.config!.injectionService.addInjection(hint, 'user_steering'); + }); + } + + /** + * Drains all pending tool calls that hit a breakpoint until the agent is idle. + * Useful for negative tests to ensure no unwanted tools (like generalist) are called. + * + * @param onConfirmation Optional callback to inspect each confirmation before resolving. + * Return true to skip the default resolveTool call (e.g. if you handled it). + */ + async drainBreakpointsUntilIdle( + onConfirmation?: (confirmation: PendingConfirmation) => void | boolean, + timeout = 60000, + ) { + while (true) { + const event = await this.waitForNextEvent(timeout); + if (event.type === 'idle') { + break; + } + + const confirmation = event.confirmation; + const handled = onConfirmation?.(confirmation); + + if (!handled) { + await this.resolveTool(confirmation); + } + } + } + + getConfig(): Config { + if (!this.config) throw new Error('AppRig not initialized'); + return this.config; + } + + async type(text: string) { + if (!this.renderResult) throw new Error('AppRig not initialized'); + await act(async () => { + this.renderResult!.stdin.write(text); + }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + } + + async pressEnter() { + await this.type('\r'); + } + + async pressKey(key: string) { + if (!this.renderResult) throw new Error('AppRig not initialized'); + await act(async () => { + this.renderResult!.stdin.write(key); + }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + } + + get lastFrame() { + if (!this.renderResult) return ''; + return stripAnsi(this.renderResult.lastFrame({ allowEmpty: true }) || ''); + } + + getStaticOutput() { + if (!this.renderResult) return ''; + return stripAnsi(this.renderResult.stdout.lastFrame() || ''); + } + + async waitForOutput(pattern: string | RegExp, timeout = 30000) { + await this.waitUntil( + () => { + const frame = this.lastFrame; + return typeof pattern === 'string' + ? frame.includes(pattern) + : pattern.test(frame); + }, + { + timeout, + message: `Timed out waiting for output: ${pattern}\nLast frame:\n${this.lastFrame}`, + }, + ); + } + + async waitForIdle(timeout = 20000) { + await this.waitForOutput('Type your message', timeout); + } + + async sendMessage(text: string) { + this.awaitingResponse = true; + await this.type(text); + await this.pressEnter(); + } + + async unmount() { + // Clean up global state for this session + sessionStateMap.delete(this.sessionId); + activeRigs.delete(this.sessionId); + + // Poison the chat recording service to prevent late writes to the test directory + if (this.config) { + const recordingService = this.config + .getGeminiClient() + ?.getChatRecordingService(); + if (recordingService) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (recordingService as any).conversationFile = null; + } + } + + if (this.renderResult) { + this.renderResult.unmount(); + } + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 500)); + }); + + vi.unstubAllEnvs(); + + coreEvents.removeAllListeners(); + coreEvents.drainBacklogs(); + MockShellExecutionService.reset(); + ideContextStore.clear(); + // Forcefully clear IdeClient singleton promise + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (IdeClient as any).instancePromise = null; + vi.clearAllMocks(); + + this.config = undefined; + this.renderResult = undefined; + + if (this.testDir && fs.existsSync(this.testDir)) { + try { + fs.rmSync(this.testDir, { recursive: true, force: true }); + } catch (e) { + debugLogger.warn( + `Failed to cleanup test directory ${this.testDir}:`, + e, + ); + } + } + } +} diff --git a/packages/cli/src/test-utils/MockShellExecutionService.ts b/packages/cli/src/test-utils/MockShellExecutionService.ts new file mode 100644 index 0000000000000000000000000000000000000000..ce9e28c594d30cb000270758c2c802b3f60ea119 --- /dev/null +++ b/packages/cli/src/test-utils/MockShellExecutionService.ts @@ -0,0 +1,140 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi } from 'vitest'; +import type { + ShellExecutionHandle, + ShellExecutionResult, + ShellOutputEvent, + ShellExecutionConfig, +} from '@google/gemini-cli-core'; + +export interface MockShellCommand { + command: string | RegExp; + result: Partial; + events?: ShellOutputEvent[]; +} + +type ShellExecutionServiceExecute = ( + commandToExecute: string, + cwd: string, + onOutputEvent: (event: ShellOutputEvent) => void, + abortSignal: AbortSignal, + shouldUseNodePty: boolean, + shellExecutionConfig: ShellExecutionConfig, +) => Promise; + +export class MockShellExecutionService { + private static mockCommands: MockShellCommand[] = []; + private static originalExecute: ShellExecutionServiceExecute | undefined; + private static passthroughEnabled = false; + + /** + * Registers the original implementation to allow falling back to real shell execution. + */ + static setOriginalImplementation( + implementation: ShellExecutionServiceExecute, + ) { + this.originalExecute = implementation; + } + + /** + * Enables or disables passthrough to the real implementation when no mock matches. + */ + static setPassthrough(enabled: boolean) { + this.passthroughEnabled = enabled; + } + + static setMockCommands(commands: MockShellCommand[]) { + this.mockCommands = commands; + } + + static reset() { + this.mockCommands = []; + this.passthroughEnabled = false; + this.writeToPty.mockClear(); + this.kill.mockClear(); + this.background.mockClear(); + this.resizePty.mockClear(); + this.scrollPty.mockClear(); + } + + static async execute( + commandToExecute: string, + cwd: string, + onOutputEvent: (event: ShellOutputEvent) => void, + abortSignal: AbortSignal, + shouldUseNodePty: boolean, + shellExecutionConfig: ShellExecutionConfig, + ): Promise { + const mock = this.mockCommands.find((m) => + typeof m.command === 'string' + ? m.command === commandToExecute + : m.command.test(commandToExecute), + ); + + const pid = Math.floor(Math.random() * 10000); + + if (mock) { + if (mock.events) { + for (const event of mock.events) { + onOutputEvent(event); + } + } + + const result: ShellExecutionResult = { + rawOutput: Buffer.from(mock.result.output || ''), + output: mock.result.output || '', + exitCode: mock.result.exitCode ?? 0, + signal: mock.result.signal ?? null, + error: mock.result.error ?? null, + aborted: false, + pid, + executionMethod: 'none', + ...mock.result, + }; + + return { + pid, + result: Promise.resolve(result), + }; + } + + if (this.passthroughEnabled && this.originalExecute) { + return this.originalExecute( + commandToExecute, + cwd, + onOutputEvent, + abortSignal, + shouldUseNodePty, + shellExecutionConfig, + ); + } + + return { + pid, + result: Promise.resolve({ + rawOutput: Buffer.from(''), + output: `Command not found: ${commandToExecute}`, + exitCode: 127, + signal: null, + error: null, + aborted: false, + pid, + executionMethod: 'none', + }), + }; + } + + static writeToPty = vi.fn(); + static isPtyActive = vi.fn(() => false); + static onExit = vi.fn(() => () => {}); + static kill = vi.fn(); + static background = vi.fn(); + static subscribe = vi.fn(() => () => {}); + static resizePty = vi.fn(); + static scrollPty = vi.fn(); +} diff --git a/packages/cli/src/test-utils/async.ts b/packages/cli/src/test-utils/async.ts new file mode 100644 index 0000000000000000000000000000000000000000..3069c3f41acd16366e795ceaf7a36714b8e1224a --- /dev/null +++ b/packages/cli/src/test-utils/async.ts @@ -0,0 +1,39 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { act } from 'react'; +import { vi } from 'vitest'; + +// The waitFor from vitest doesn't properly wrap in act(), so we have to +// implement our own like the one in @testing-library/react +// or @testing-library/react-native +// The version of waitFor from vitest is still fine to use if you aren't waiting +// for React state updates. +export async function waitFor( + assertion: () => void | Promise, + { timeout = 2000, interval = 50 } = {}, +): Promise { + const startTime = Date.now(); + + while (true) { + try { + await assertion(); + return; + } catch (error) { + if (Date.now() - startTime > timeout) { + throw error; + } + + await act(async () => { + if (vi.isFakeTimers()) { + await vi.advanceTimersByTimeAsync(interval); + } else { + await new Promise((resolve) => setTimeout(resolve, interval)); + } + }); + } + } +} diff --git a/packages/cli/src/test-utils/createExtension.ts b/packages/cli/src/test-utils/createExtension.ts new file mode 100644 index 0000000000000000000000000000000000000000..27d12a9665d144430c792f5ad1d9750cba7b0ea8 --- /dev/null +++ b/packages/cli/src/test-utils/createExtension.ts @@ -0,0 +1,73 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { + type MCPServerConfig, + type ExtensionInstallMetadata, + type ExtensionSetting, + type CustomTheme, +} from '@google/gemini-cli-core'; +import { + EXTENSIONS_CONFIG_FILENAME, + INSTALL_METADATA_FILENAME, +} from '../config/extensions/variables.js'; + +export function createExtension({ + extensionsDir = 'extensions-dir', + name = 'my-extension', + version = '1.0.0', + addContextFile = false, + contextFileName = undefined as string | string[] | undefined, + mcpServers = {} as Record, + installMetadata = undefined as ExtensionInstallMetadata | undefined, + settings = undefined as ExtensionSetting[] | undefined, + themes = undefined as CustomTheme[] | undefined, +} = {}): string { + const extDir = path.join(extensionsDir, name); + fs.mkdirSync(extDir, { recursive: true }); + fs.writeFileSync( + path.join(extDir, EXTENSIONS_CONFIG_FILENAME), + JSON.stringify({ + name, + version, + contextFileName, + mcpServers, + settings, + themes, + }), + ); + + if (addContextFile) { + fs.writeFileSync(path.join(extDir, 'GEMINI.md'), 'context'); + } + + if (contextFileName) { + const files = Array.isArray(contextFileName) + ? contextFileName + : [contextFileName]; + for (const f of files) { + if (typeof f === 'string') { + try { + const filePath = path.join(extDir, f); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, 'context'); + } catch { + // Ignore errors for invalid paths in tests + } + } + } + } + + if (installMetadata) { + fs.writeFileSync( + path.join(extDir, INSTALL_METADATA_FILENAME), + JSON.stringify(installMetadata), + ); + } + return extDir; +} diff --git a/packages/cli/src/test-utils/customMatchers.ts b/packages/cli/src/test-utils/customMatchers.ts new file mode 100644 index 0000000000000000000000000000000000000000..d34576cf3f6627ddb07d9fb1aa4fa3e0ae250a3c --- /dev/null +++ b/packages/cli/src/test-utils/customMatchers.ts @@ -0,0 +1,131 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/// + +import { expect, type Assertion } from 'vitest'; +import path from 'node:path'; +import stripAnsi from 'strip-ansi'; +import type { TextBuffer } from '../ui/components/shared/text-buffer.js'; + +// RegExp to detect invalid characters: backspace, and ANSI escape codes +// eslint-disable-next-line no-control-regex +const invalidCharsRegex = /[\b\x1b]/; + +const callCountByTest = new Map(); + +export async function toMatchSvgSnapshot( + this: Assertion, + renderInstance: { + lastFrameRaw?: (options?: { allowEmpty?: boolean }) => string; + lastFrame?: (options?: { allowEmpty?: boolean }) => string; + generateSvg: () => string; + }, + options?: { allowEmpty?: boolean; name?: string }, +) { + const currentTestName = expect.getState().currentTestName; + if (!currentTestName) { + throw new Error('toMatchSvgSnapshot must be called within a test'); + } + const testPath = expect.getState().testPath; + if (!testPath) { + throw new Error('toMatchSvgSnapshot requires testPath'); + } + + let textContent: string; + if (renderInstance.lastFrameRaw) { + textContent = renderInstance.lastFrameRaw({ + allowEmpty: options?.allowEmpty, + }); + } else if (renderInstance.lastFrame) { + textContent = renderInstance.lastFrame({ allowEmpty: options?.allowEmpty }); + } else { + throw new Error( + 'toMatchSvgSnapshot requires a renderInstance with either lastFrameRaw or lastFrame', + ); + } + const svgContent = renderInstance.generateSvg(); + + const sanitize = (name: string) => + name.replace(/[^a-zA-Z0-9_-]/g, '-').replace(/-+/g, '-'); + + const testId = testPath + ':' + currentTestName; + let count = callCountByTest.get(testId) ?? 0; + count++; + callCountByTest.set(testId, count); + + const snapshotName = + options?.name ?? + (count > 1 ? `${currentTestName}-${count}` : currentTestName); + + const svgFileName = + sanitize(path.basename(testPath).replace(/\.test\.tsx?$/, '')) + + '-' + + sanitize(snapshotName) + + '.snap.svg'; + const svgDir = path.join(path.dirname(testPath), '__snapshots__'); + const svgFilePath = path.join(svgDir, svgFileName); + + // Assert the text matches standard snapshot, stripping ANSI for stability + expect(stripAnsi(textContent)).toMatchSnapshot(); + + // Assert the SVG matches the file snapshot + await expect(svgContent).toMatchFileSnapshot(svgFilePath); + + return { pass: true, message: () => '' }; +} + +function toHaveOnlyValidCharacters(this: Assertion, buffer: TextBuffer) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { isNot } = this as any; + let pass = true; + const invalidLines: Array<{ line: number; content: string }> = []; + + for (let i = 0; i < buffer.lines.length; i++) { + const line = buffer.lines[i]; + if (line.includes('\n')) { + pass = false; + invalidLines.push({ line: i, content: line }); + break; // Fail fast on newlines + } + if (invalidCharsRegex.test(line)) { + pass = false; + invalidLines.push({ line: i, content: line }); + } + } + + return { + pass, + message: () => + `Expected buffer ${isNot ? 'not ' : ''}to have only valid characters, but found invalid characters in lines:\n${invalidLines + .map((l) => ` [${l.line}]: "${l.content}"`) /* This line was changed */ + .join('\n')}`, + actual: buffer.lines, + expected: 'Lines with no line breaks, backspaces, or escape codes.', + }; +} + +expect.extend({ + toHaveOnlyValidCharacters, + toMatchSvgSnapshot, + // eslint-disable-next-line @typescript-eslint/no-explicit-any +} as any); + +// Extend Vitest's `expect` interface with the custom matcher's type definition. +declare module 'vitest' { + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-empty-object-type + interface Assertion extends CustomMatchers {} + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + interface AsymmetricMatchersContaining extends CustomMatchers {} + + interface CustomMatchers { + toHaveOnlyValidCharacters(): T; + toMatchSvgSnapshot(options?: { + allowEmpty?: boolean; + name?: string; + }): Promise; + } +} diff --git a/packages/cli/src/test-utils/mockCommandContext.test.ts b/packages/cli/src/test-utils/mockCommandContext.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..605718e0276d2af85b91c715a85273ca832ce267 --- /dev/null +++ b/packages/cli/src/test-utils/mockCommandContext.test.ts @@ -0,0 +1,66 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect } from 'vitest'; +import { createMockCommandContext } from './mockCommandContext.js'; + +describe('createMockCommandContext', () => { + it('should return a valid CommandContext object with default mocks', () => { + const context = createMockCommandContext(); + + // Just a few spot checks to ensure the structure is correct + // and functions are mocks. + expect(context).toBeDefined(); + expect(context.ui.addItem).toBeInstanceOf(Function); + expect(vi.isMockFunction(context.ui.addItem)).toBe(true); + }); + + it('should apply top-level overrides correctly', () => { + const mockClear = vi.fn(); + const overrides = { + ui: { + clear: mockClear, + }, + }; + + const context = createMockCommandContext(overrides); + + // Call the function to see if the override was used + context.ui.clear(); + + // Assert that our specific mock was called, not the default + expect(mockClear).toHaveBeenCalled(); + // And that other defaults are still in place + expect(vi.isMockFunction(context.ui.addItem)).toBe(true); + }); + + it('should apply deeply nested overrides correctly', () => { + // This is the most important test for factory's logic. + const mockConfig = { + getProjectRoot: () => '/test/project', + getModel: () => 'gemini-pro', + }; + + const overrides = { + services: { + agentContext: { config: mockConfig }, + }, + }; + + const context = createMockCommandContext(overrides); + + expect(context.services.agentContext).toBeDefined(); + expect(context.services.agentContext?.config?.getModel()).toBe( + 'gemini-pro', + ); + expect(context.services.agentContext?.config?.getProjectRoot()).toBe( + '/test/project', + ); + + // Verify a default property on the same nested object is still there + expect(context.services.logger).toBeDefined(); + }); +}); diff --git a/packages/cli/src/test-utils/mockCommandContext.ts b/packages/cli/src/test-utils/mockCommandContext.ts new file mode 100644 index 0000000000000000000000000000000000000000..e3c5179ed59bf6f55f41f33d9d66ad8fcc1cb527 --- /dev/null +++ b/packages/cli/src/test-utils/mockCommandContext.ts @@ -0,0 +1,122 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi } from 'vitest'; +import type { CommandContext } from '../ui/commands/types.js'; +import { mergeSettings, type LoadedSettings } from '../config/settings.js'; +import type { GitService } from '@google/gemini-cli-core'; +import type { SessionStatsState } from '../ui/contexts/SessionContext.js'; + +// A utility type to make all properties of an object, and its nested objects, partial. +type DeepPartial = T extends object + ? { + [P in keyof T]?: DeepPartial; + } + : T; + +/** + * Creates a deep, fully-typed mock of the CommandContext for use in tests. + * All functions are pre-mocked with `vi.fn()`. + * + * @param overrides - A deep partial object to override any default mock values. + * @returns A complete, mocked CommandContext object. + */ +export const createMockCommandContext = ( + overrides: DeepPartial = {}, +): CommandContext => { + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); + + const defaultMocks: CommandContext = { + invocation: { + raw: '', + name: '', + args: '', + }, + services: { + agentContext: null, + settings: { + merged: defaultMergedSettings, + setValue: vi.fn(), + forScope: vi.fn().mockReturnValue({ settings: {} }), + } as unknown as LoadedSettings, + git: undefined as GitService | undefined, + logger: { + log: vi.fn(), + logMessage: vi.fn(), + saveCheckpoint: vi.fn(), + loadCheckpoint: vi.fn().mockResolvedValue([]), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, // Cast because Logger is a class. + }, + ui: { + addItem: vi.fn(), + clear: vi.fn(), + setDebugMessage: vi.fn(), + pendingItem: null, + setPendingItem: vi.fn(), + loadHistory: vi.fn(), + toggleCorgiMode: vi.fn(), + toggleShortcutsHelp: vi.fn(), + toggleVimEnabled: vi.fn(), + reloadCommands: vi.fn(), + openAgentConfigDialog: vi.fn(), + closeAgentConfigDialog: vi.fn(), + extensionsUpdateState: new Map(), + setExtensionsUpdateState: vi.fn(), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + session: { + sessionShellAllowlist: new Set(), + stats: { + sessionStartTime: new Date(), + lastPromptTokenCount: 0, + metrics: { + models: {}, + tools: { + totalCalls: 0, + totalSuccess: 0, + totalFail: 0, + totalDurationMs: 0, + totalDecisions: { accept: 0, reject: 0, modify: 0 }, + byName: {}, + }, + }, + } as SessionStatsState, + }, + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const merge = (target: any, source: any): any => { + const output = { ...target }; + + for (const key in source) { + if (Object.prototype.hasOwnProperty.call(source, key)) { + const sourceValue = source[key]; + const targetValue = output[key]; + + if ( + // We only want to recursively merge plain objects + Object.prototype.toString.call(sourceValue) === '[object Object]' && + Object.prototype.toString.call(targetValue) === '[object Object]' + ) { + output[key] = merge(targetValue, sourceValue); + } else { + // If not, we do a direct assignment. This preserves Date objects and others. + output[key] = sourceValue; + } + } + } + return output; + }; + + const merged: unknown = merge(defaultMocks, overrides); + const isCommandContext = (val: unknown): val is CommandContext => + typeof val === 'object' && val !== null; + if (isCommandContext(merged)) { + return merged; + } + throw new Error('Unreachable'); +}; diff --git a/packages/cli/src/test-utils/mockConfig.ts b/packages/cli/src/test-utils/mockConfig.ts new file mode 100644 index 0000000000000000000000000000000000000000..94f15d8ac4ef0e52c7b8710c3b5e84888d53183f --- /dev/null +++ b/packages/cli/src/test-utils/mockConfig.ts @@ -0,0 +1,216 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi } from 'vitest'; +import { NoopSandboxManager } from '@google/gemini-cli-core'; +import type { Config } from '@google/gemini-cli-core'; +import { + createTestMergedSettings, + type LoadedSettings, + type Settings, +} from '../config/settings.js'; + +/** + * Creates a mocked Config object with default values and allows overrides. + */ +export const createMockConfig = (overrides: Partial = {}): Config => + ({ + getSandbox: vi.fn(() => undefined), + getQuestion: vi.fn(() => ''), + isInteractive: vi.fn(() => false), + isInitialized: vi.fn(() => true), + setTerminalBackground: vi.fn(), + storage: { + getProjectTempDir: vi.fn().mockReturnValue('/tmp/gemini-test'), + initialize: vi.fn().mockResolvedValue(undefined), + }, + getDebugMode: vi.fn(() => false), + getProjectRoot: vi.fn(() => '/'), + refreshAuth: vi.fn().mockResolvedValue(undefined), + getRemoteAdminSettings: vi.fn(() => undefined), + initialize: vi.fn().mockResolvedValue(undefined), + getPolicyEngine: vi.fn(() => ({})), + getMessageBus: vi.fn(() => ({ subscribe: vi.fn() })), + getHookSystem: vi.fn(() => ({ + fireSessionEndEvent: vi.fn().mockResolvedValue(undefined), + fireSessionStartEvent: vi.fn().mockResolvedValue(undefined), + })), + isAutoMemoryEnabled: vi.fn(() => false), + getListExtensions: vi.fn(() => false), + getExtensions: vi.fn(() => []), + getListSessions: vi.fn(() => false), + getDeleteSession: vi.fn(() => undefined), + setSessionId: vi.fn(), + resetNewSessionState: vi.fn(), + getSessionId: vi.fn().mockReturnValue('mock-session-id'), + getWorktreeSettings: vi.fn(() => undefined), + getContentGeneratorConfig: vi.fn(() => ({ authType: 'google' })), + getAcpMode: vi.fn(() => false), + isBrowserLaunchSuppressed: vi.fn(() => false), + setRemoteAdminSettings: vi.fn(), + isYoloModeDisabled: vi.fn(() => false), + isPlanEnabled: vi.fn(() => false), + getPlanModeRoutingEnabled: vi.fn().mockResolvedValue(true), + getApprovedPlanPath: vi.fn(() => undefined), + getCoreTools: vi.fn(() => []), + getAllowedTools: vi.fn(() => []), + getApprovalMode: vi.fn(() => 'default'), + getFileFilteringRespectGitIgnore: vi.fn(() => true), + getOutputFormat: vi.fn(() => 'text'), + getUsageStatisticsEnabled: vi.fn(() => true), + getScreenReader: vi.fn(() => false), + getGeminiMdFileCount: vi.fn(() => 0), + getDeferredCommand: vi.fn(() => undefined), + getFileSystemService: vi.fn(() => ({})), + clientVersion: '1.0.0', + getModel: vi.fn().mockReturnValue('gemini-pro'), + getWorkingDir: vi.fn().mockReturnValue('/mock/cwd'), + getToolRegistry: vi.fn().mockReturnValue({ + getTools: vi.fn().mockReturnValue([]), + getAllTools: vi.fn().mockReturnValue([]), + }), + getAgentRegistry: vi.fn().mockReturnValue({}), + getPromptRegistry: vi.fn().mockReturnValue({}), + getResourceRegistry: vi.fn().mockReturnValue({}), + getSkillManager: vi.fn().mockReturnValue({ + isAdminEnabled: vi.fn().mockReturnValue(false), + }), + getFileService: vi.fn().mockReturnValue({}), + getGitService: vi.fn().mockResolvedValue({}), + getUserMemory: vi.fn().mockReturnValue(''), + getSystemInstructionMemory: vi.fn().mockReturnValue(''), + getSessionMemory: vi.fn().mockReturnValue(''), + getGeminiMdFilePaths: vi.fn().mockReturnValue([]), + getShowMemoryUsage: vi.fn().mockReturnValue(false), + getAccessibility: vi.fn().mockReturnValue({}), + getTelemetryEnabled: vi.fn().mockReturnValue(false), + getTelemetryLogPromptsEnabled: vi.fn().mockReturnValue(false), + getTelemetryTracesEnabled: vi.fn().mockReturnValue(false), + getTelemetryOtlpEndpoint: vi.fn().mockReturnValue(''), + getTelemetryOtlpProtocol: vi.fn().mockReturnValue('grpc'), + getTelemetryTarget: vi.fn().mockReturnValue(''), + getTelemetryOutfile: vi.fn().mockReturnValue(undefined), + getTelemetryUseCollector: vi.fn().mockReturnValue(false), + getTelemetryUseCliAuth: vi.fn().mockReturnValue(false), + getGeminiClient: vi.fn().mockReturnValue({ + isInitialized: vi.fn().mockReturnValue(true), + }), + updateSystemInstructionIfInitialized: vi.fn().mockResolvedValue(undefined), + getModelRouterService: vi.fn().mockReturnValue({}), + getModelAvailabilityService: vi.fn().mockReturnValue({}), + getEnableRecursiveFileSearch: vi.fn().mockReturnValue(true), + getFileFilteringEnableFuzzySearch: vi.fn().mockReturnValue(true), + getFileFilteringRespectGeminiIgnore: vi.fn().mockReturnValue(true), + getFileFilteringOptions: vi.fn().mockReturnValue({}), + getCustomExcludes: vi.fn().mockReturnValue([]), + getCheckpointingEnabled: vi.fn().mockReturnValue(false), + getProxy: vi.fn().mockReturnValue(undefined), + getBugCommand: vi.fn().mockReturnValue(undefined), + getExtensionManagement: vi.fn().mockReturnValue(true), + getExtensionLoader: vi.fn().mockReturnValue({}), + getEnabledExtensions: vi.fn().mockReturnValue([]), + getEnableExtensionReloading: vi.fn().mockReturnValue(false), + getDisableLLMCorrection: vi.fn().mockReturnValue(false), + getNoBrowser: vi.fn().mockReturnValue(false), + getAgentsSettings: vi.fn().mockReturnValue({}), + getSummarizeToolOutputConfig: vi.fn().mockReturnValue(undefined), + getIdeMode: vi.fn().mockReturnValue(false), + getFolderTrust: vi.fn().mockReturnValue(true), + isTrustedFolder: vi.fn().mockReturnValue(true), + getCompressionThreshold: vi.fn().mockResolvedValue(undefined), + getUserCaching: vi.fn().mockResolvedValue(false), + getNumericalRoutingEnabled: vi.fn().mockResolvedValue(false), + getClassifierThreshold: vi.fn().mockResolvedValue(undefined), + getBannerTextNoCapacityIssues: vi.fn().mockResolvedValue(''), + getBannerTextCapacityIssues: vi.fn().mockResolvedValue(''), + isInteractiveShellEnabled: vi.fn().mockReturnValue(false), + getDisableAlwaysAllow: vi.fn().mockReturnValue(false), + isSkillsSupportEnabled: vi.fn().mockReturnValue(false), + reloadSkills: vi.fn().mockResolvedValue(undefined), + reloadAgents: vi.fn().mockResolvedValue(undefined), + getUseRipgrep: vi.fn().mockReturnValue(false), + getEnableInteractiveShell: vi.fn().mockReturnValue(false), + getSkipNextSpeakerCheck: vi.fn().mockReturnValue(false), + getRetryFetchErrors: vi.fn().mockReturnValue(true), + getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true), + getShellToolInactivityTimeout: vi.fn().mockReturnValue(300000), + getRequestTimeoutMs: vi.fn().mockReturnValue(undefined), + getShellExecutionConfig: vi.fn().mockReturnValue({ + sandboxManager: new NoopSandboxManager(), + sanitizationConfig: { + allowedEnvironmentVariables: [], + blockedEnvironmentVariables: [], + enableEnvironmentVariableRedaction: false, + }, + }), + setShellExecutionConfig: vi.fn(), + getEnableToolOutputTruncation: vi.fn().mockReturnValue(true), + getTruncateToolOutputThreshold: vi.fn().mockReturnValue(1000), + getTruncateToolOutputLines: vi.fn().mockReturnValue(100), + getNextCompressionTruncationId: vi.fn().mockReturnValue(1), + getUseWriteTodos: vi.fn().mockReturnValue(false), + getFileExclusions: vi.fn().mockReturnValue({}), + getEnableHooks: vi.fn().mockReturnValue(true), + getEnableHooksUI: vi.fn().mockReturnValue(true), + getMcpClientManager: vi.fn().mockReturnValue({ + getMcpInstructions: vi.fn().mockReturnValue(''), + getMcpServers: vi.fn().mockReturnValue({}), + getLastError: vi.fn().mockReturnValue(undefined), + }), + setUserInteractedWithMcp: vi.fn(), + emitMcpDiagnostic: vi.fn(), + getEnableEventDrivenScheduler: vi.fn().mockReturnValue(false), + getAdminSkillsEnabled: vi.fn().mockReturnValue(false), + getDisabledSkills: vi.fn().mockReturnValue([]), + getExperimentalGemma: vi.fn().mockReturnValue(false), + getMemoryBoundaryMarkers: vi.fn().mockReturnValue(['.git']), + getTerminalBackground: vi.fn().mockReturnValue(undefined), + getEmbeddingModel: vi.fn().mockReturnValue('embedding-model'), + getQuotaErrorOccurred: vi.fn().mockReturnValue(false), + getMaxSessionTurns: vi.fn().mockReturnValue(100), + getExcludeTools: vi.fn().mockReturnValue(new Set()), + getAllowedMcpServers: vi.fn().mockReturnValue([]), + getBlockedMcpServers: vi.fn().mockReturnValue([]), + getExperiments: vi.fn().mockReturnValue(undefined), + getHasAccessToPreviewModel: vi.fn().mockReturnValue(false), + validatePathAccess: vi.fn().mockReturnValue(null), + getUseAlternateBuffer: vi.fn().mockReturnValue(false), + getUseTerminalBuffer: vi.fn().mockReturnValue(false), + getUseRenderProcess: vi.fn().mockReturnValue(false), + ...overrides, + }) as unknown as Config; + +/** + * Creates a mocked LoadedSettings object for tests. + */ +export function createMockSettings( + overrides: Record = {}, +): LoadedSettings { + const merged = createTestMergedSettings( + (overrides['merged'] as Partial) || {}, + ); + + return { + system: { settings: {} }, + systemDefaults: { settings: {} }, + user: { settings: {} }, + workspace: { settings: {} }, + errors: [], + subscribe: vi.fn().mockReturnValue(() => {}), + getSnapshot: vi.fn().mockReturnValue({ + system: { settings: {} }, + systemDefaults: { settings: {} }, + user: { settings: {} }, + workspace: { settings: {} }, + isTrusted: true, + errors: [], + merged, + }), + setValue: vi.fn(), + ...overrides, + merged, + } as unknown as LoadedSettings; +} diff --git a/packages/cli/src/test-utils/mockDebugLogger.ts b/packages/cli/src/test-utils/mockDebugLogger.ts new file mode 100644 index 0000000000000000000000000000000000000000..bc0cde9010ef0fea20969d1c9088f63a210c06e1 --- /dev/null +++ b/packages/cli/src/test-utils/mockDebugLogger.ts @@ -0,0 +1,77 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi } from 'vitest'; +import stripAnsi from 'strip-ansi'; +import { format } from 'node:util'; + +export function createMockDebugLogger(options: { stripAnsi?: boolean } = {}) { + const emitConsoleLog = vi.fn(); + const debugLogger = { + log: vi.fn((message: unknown, ...args: unknown[]) => { + let formatted = + typeof message === 'string' ? format(message, ...args) : message; + if (options.stripAnsi && typeof formatted === 'string') { + formatted = stripAnsi(formatted); + } + emitConsoleLog('log', formatted); + }), + error: vi.fn((message: unknown, ...args: unknown[]) => { + let formatted = + typeof message === 'string' ? format(message, ...args) : message; + if (options.stripAnsi && typeof formatted === 'string') { + formatted = stripAnsi(formatted); + } + emitConsoleLog('error', formatted); + }), + warn: vi.fn((message: unknown, ...args: unknown[]) => { + let formatted = + typeof message === 'string' ? format(message, ...args) : message; + if (options.stripAnsi && typeof formatted === 'string') { + formatted = stripAnsi(formatted); + } + emitConsoleLog('warn', formatted); + }), + debug: vi.fn(), + info: vi.fn(), + }; + + return { emitConsoleLog, debugLogger }; +} + +/** + * A helper specifically designed for `vi.mock('@google/gemini-cli-core', ...)` to easily + * mock both `debugLogger` and `coreEvents.emitConsoleLog`. + * + * Example: + * ```typescript + * vi.mock('@google/gemini-cli-core', async (importOriginal) => { + * const { mockCoreDebugLogger } = await import('../../test-utils/mockDebugLogger.js'); + * return mockCoreDebugLogger( + * await importOriginal(), + * { stripAnsi: true } + * ); + * }); + * ``` + */ +export function mockCoreDebugLogger>( + actual: T, + options?: { stripAnsi?: boolean }, +): T { + const { emitConsoleLog, debugLogger } = createMockDebugLogger(options); + return { + ...actual, + coreEvents: { + // eslint-disable-next-line no-restricted-syntax + ...(typeof actual['coreEvents'] === 'object' && + actual['coreEvents'] !== null + ? actual['coreEvents'] + : {}), + emitConsoleLog, + }, + debugLogger, + } as T; +} diff --git a/packages/cli/src/test-utils/mockSpinner.tsx b/packages/cli/src/test-utils/mockSpinner.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c27c7a8707633809cb02b6fd6fb70a8b8728005e --- /dev/null +++ b/packages/cli/src/test-utils/mockSpinner.tsx @@ -0,0 +1,23 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi } from 'vitest'; +import type { SpinnerName } from 'cli-spinners'; + +export function mockInkSpinner() { + vi.mock('ink-spinner', async () => { + const { Text } = await import('ink'); + const cliSpinners = (await import('cli-spinners')).default; + + return { + default: function MockSpinner({ type = 'dots' }: { type?: SpinnerName }) { + const spinner = cliSpinners[type]; + const frame = spinner ? spinner.frames[0] : '⠋'; + return {frame}; + }, + }; + }); +} diff --git a/packages/cli/src/test-utils/persistentStateFake.ts b/packages/cli/src/test-utils/persistentStateFake.ts new file mode 100644 index 0000000000000000000000000000000000000000..512b25b95bec86f0440fdc72f57ff006609daf23 --- /dev/null +++ b/packages/cli/src/test-utils/persistentStateFake.ts @@ -0,0 +1,45 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi } from 'vitest'; + +/** + * A fake implementation of PersistentState for testing. + * It keeps state in memory and provides spies for get and set. + */ +export class FakePersistentState { + private data: Record = {}; + + get = vi.fn().mockImplementation((key: string) => this.data[key]); + + set = vi.fn().mockImplementation((key: string, value: unknown) => { + this.data[key] = value; + }); + + /** + * Helper to reset the fake state between tests. + */ + reset() { + this.data = {}; + this.get.mockClear(); + this.set.mockClear(); + } + + /** + * Helper to clear mock call history without wiping data. + */ + mockClear() { + this.get.mockClear(); + this.set.mockClear(); + } + + /** + * Helper to set initial data for the fake. + */ + setData(data: Record) { + this.data = { ...data }; + } +} diff --git a/packages/cli/src/test-utils/render.test.tsx b/packages/cli/src/test-utils/render.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3c3f4102a44760d6bd908c95c9d1f29aae7bfa7d --- /dev/null +++ b/packages/cli/src/test-utils/render.test.tsx @@ -0,0 +1,108 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi } from 'vitest'; +import { useState, useEffect, act } from 'react'; +import { Text } from 'ink'; +import { renderHook, render } from './render.js'; +import { waitFor } from './async.js'; + +describe('render', () => { + it('should render a component', async () => { + const { lastFrame, unmount } = await render(Hello World); + expect(lastFrame()).toBe('Hello World\n'); + unmount(); + }); + + it('should support rerender', async () => { + const { lastFrame, rerender, waitUntilReady, unmount } = await render( + Hello, + ); + expect(lastFrame()).toBe('Hello\n'); + + await act(async () => rerender(World)); + await waitUntilReady(); + expect(lastFrame()).toBe('World\n'); + unmount(); + }); + + it('should support unmount', async () => { + const cleanupMock = vi.fn(); + function TestComponent() { + useEffect(() => cleanupMock, []); + return Hello; + } + + const { unmount } = await render(); + unmount(); + expect(cleanupMock).toHaveBeenCalled(); + }); +}); + +describe('renderHook', () => { + it('should rerender with previous props when called without arguments', async () => { + const useTestHook = ({ value }: { value: number }) => { + const [count, setCount] = useState(0); + useEffect(() => setCount((c) => c + 1), [value]); + return { count, value }; + }; + + const { result, rerender, waitUntilReady, unmount } = await renderHook( + useTestHook, + { initialProps: { value: 1 } }, + ); + + expect(result.current.value).toBe(1); + await waitFor(() => expect(result.current.count).toBe(1)); + + // Rerender with new props + await act(async () => rerender({ value: 2 })); + await waitUntilReady(); + expect(result.current.value).toBe(2); + await waitFor(() => expect(result.current.count).toBe(2)); + + // Rerender without arguments should use previous props (value: 2) + // This would previously crash or pass undefined if not fixed + await act(async () => rerender()); + await waitUntilReady(); + expect(result.current.value).toBe(2); + // Count should not increase because value didn't change + await waitFor(() => expect(result.current.count).toBe(2)); + unmount(); + }); + + it('should handle initial render without props', async () => { + const useTestHook = () => { + const [count, setCount] = useState(0); + return { count, increment: () => setCount((c) => c + 1) }; + }; + + const { result, rerender, waitUntilReady, unmount } = + await renderHook(useTestHook); + + expect(result.current.count).toBe(0); + + await act(async () => rerender()); + await waitUntilReady(); + expect(result.current.count).toBe(0); + unmount(); + }); + + it('should update props if undefined is passed explicitly', async () => { + const useTestHook = (val: string | undefined) => val; + const { result, rerender, waitUntilReady, unmount } = await renderHook( + useTestHook, + { initialProps: 'initial' }, + ); + + expect(result.current).toBe('initial'); + + await act(async () => rerender(undefined)); + await waitUntilReady(); + expect(result.current).toBeUndefined(); + unmount(); + }); +}); diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx new file mode 100644 index 0000000000000000000000000000000000000000..99ba65229286c085a25107d74a866a39461b3f6c --- /dev/null +++ b/packages/cli/src/test-utils/render.tsx @@ -0,0 +1,961 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + render as inkRenderDirect, + type Instance as InkInstance, + type RenderOptions, +} from 'ink'; +import { EventEmitter } from 'node:events'; +import { Box } from 'ink'; +import { Terminal } from '@xterm/headless'; +import { vi } from 'vitest'; +import stripAnsi from 'strip-ansi'; +import type React from 'react'; +import { act, useState } from 'react'; +import type { LoadedSettings } from '../config/settings.js'; +import { KeypressProvider } from '../ui/contexts/KeypressContext.js'; +import { SettingsContext } from '../ui/contexts/SettingsContext.js'; +import { ShellFocusContext } from '../ui/contexts/ShellFocusContext.js'; +import { UIStateContext, type UIState } from '../ui/contexts/UIStateContext.js'; +import { ConfigContext } from '../ui/contexts/ConfigContext.js'; +import { VimModeProvider } from '../ui/contexts/VimModeContext.js'; +import { MouseProvider } from '../ui/contexts/MouseContext.js'; +import { ScrollProvider } from '../ui/contexts/ScrollProvider.js'; +import { StreamingContext } from '../ui/contexts/StreamingContext.js'; +import { + type UIActions, + UIActionsContext, +} from '../ui/contexts/UIActionsContext.js'; +import { type HistoryItemToolGroup, StreamingState } from '../ui/types.js'; +import { ToolActionsProvider } from '../ui/contexts/ToolActionsContext.js'; +import { AskUserActionsProvider } from '../ui/contexts/AskUserActionsContext.js'; +import { TerminalProvider } from '../ui/contexts/TerminalContext.js'; +import { + OverflowProvider, + useOverflowActions, + useOverflowState, + type OverflowActions, + type OverflowState, +} from '../ui/contexts/OverflowContext.js'; + +import { makeFakeConfig } from '@google/gemini-cli-core'; +import { type Config } from '@google/gemini-cli-core'; +import { FakePersistentState } from './persistentStateFake.js'; +import { AppContext, type AppState } from '../ui/contexts/AppContext.js'; +import { createMockSettings } from './settings.js'; +import { SessionStatsProvider } from '../ui/contexts/SessionContext.js'; +import { themeManager, DEFAULT_THEME } from '../ui/themes/theme-manager.js'; +import { DefaultLight } from '../ui/themes/builtin/light/default-light.js'; +import { pickDefaultThemeName } from '../ui/themes/theme.js'; +import { generateSvgForTerminal } from './svg.js'; + +export const persistentStateMock = new FakePersistentState(); + +if (process.env['NODE_ENV'] === 'test') { + // We mock NODE_ENV to development during tests that use render.tsx + // so that animations (which check process.env.NODE_ENV !== 'test') + // are actually tested. We mutate process.env directly here because + // vi.stubEnv() is cleared by vi.unstubAllEnvs() in test-setup.ts + // after each test. + process.env['NODE_ENV'] = 'development'; +} + +vi.mock('../utils/persistentState.js', () => ({ + get persistentState() { + return persistentStateMock; + }, +})); + +vi.mock('../ui/utils/terminalUtils.js', () => ({ + isLowColorDepth: vi.fn(() => false), + getColorDepth: vi.fn(() => 24), + isITerm2: vi.fn(() => false), +})); + +type TerminalState = { + terminal: Terminal; + cols: number; + rows: number; +}; + +type RenderMetrics = Parameters>[0]; + +interface InkRenderMetrics extends RenderMetrics { + output: string; + staticOutput?: string; +} + +function isInkRenderMetrics( + metrics: RenderMetrics, +): metrics is InkRenderMetrics { + const m = metrics as Record; + return ( + typeof m === 'object' && + m !== null && + 'output' in m && + // eslint-disable-next-line no-restricted-syntax + typeof m['output'] === 'string' + ); +} + +class XtermStdout extends EventEmitter { + private state: TerminalState; + private pendingWrites = 0; + private renderCount = 0; + private queue: { promise: Promise }; + isTTY = true; + + getColorDepth(): number { + return 24; + } + + private lastRenderOutput: string | undefined = undefined; + private lastRenderStaticContent: string | undefined = undefined; + + constructor(state: TerminalState, queue: { promise: Promise }) { + super(); + this.state = state; + this.queue = queue; + } + + get columns() { + return this.state.terminal.cols; + } + + get rows() { + return this.state.terminal.rows; + } + + get frames(): string[] { + return []; + } + + write = (data: string) => { + this.pendingWrites++; + this.queue.promise = this.queue.promise.then(async () => { + await new Promise((resolve) => + this.state.terminal.write(data, resolve), + ); + this.pendingWrites--; + }); + }; + + clear = () => { + this.state.terminal.reset(); + this.lastRenderOutput = undefined; + this.lastRenderStaticContent = undefined; + }; + + dispose = () => { + this.state.terminal.dispose(); + }; + + onRender = (staticContent: string, output: string) => { + this.renderCount++; + this.lastRenderStaticContent = staticContent; + this.lastRenderOutput = output; + this.emit('render'); + }; + + private normalizeFrame = (text: string): string => + text.replace(/\r\n/g, '\n'); + + generateSvg = (): string => generateSvgForTerminal(this.state.terminal); + + lastFrameRaw = (options: { allowEmpty?: boolean } = {}) => { + const result = + (this.lastRenderStaticContent ?? '') + (this.lastRenderOutput ?? ''); + + const normalized = this.normalizeFrame(result); + + if (normalized === '' && !options.allowEmpty) { + throw new Error( + 'lastFrameRaw() returned an empty string. If this is intentional, use lastFrameRaw({ allowEmpty: true }). ' + + 'Otherwise, ensure you are calling await waitUntilReady() and that the component is rendering correctly.', + ); + } + + return normalized; + }; + + lastFrame = (options: { allowEmpty?: boolean } = {}) => { + const buffer = this.state.terminal.buffer.active; + const allLines: string[] = []; + for (let i = 0; i < buffer.length; i++) { + allLines.push(buffer.getLine(i)?.translateToString(true) ?? ''); + } + + const trimmed = [...allLines]; + while (trimmed.length > 0 && trimmed[trimmed.length - 1] === '') { + trimmed.pop(); + } + const result = trimmed.join('\n'); + + const normalized = this.normalizeFrame(result); + + if (normalized === '' && !options.allowEmpty) { + throw new Error( + 'lastFrame() returned an empty string. If this is intentional, use lastFrame({ allowEmpty: true }). ' + + 'Otherwise, ensure you are calling await waitUntilReady() and that the component is rendering correctly.', + ); + } + return normalized === '' ? normalized : normalized + '\n'; + }; + + async waitUntilReady() { + const startRenderCount = this.renderCount; + if (!vi.isFakeTimers()) { + // Give Ink a chance to start its rendering loop + await new Promise((resolve) => setImmediate(resolve)); + } + await act(async () => { + if (vi.isFakeTimers()) { + await vi.advanceTimersByTimeAsync(50); + } else { + // Wait for at least one render to be called if we haven't rendered yet or since start of this call, + // but don't wait forever as some renders might be synchronous or skipped. + if (this.renderCount === startRenderCount) { + const renderPromise = new Promise((resolve) => + this.once('render', resolve), + ); + const timeoutPromise = new Promise((resolve) => + setTimeout(resolve, 1000), + ); + await Promise.race([renderPromise, timeoutPromise]); + } + } + }); + + let attempts = 0; + const maxAttempts = 50; + + let lastCurrent = ''; + let lastExpected = ''; + + while (attempts < maxAttempts) { + // Ensure all pending writes to the terminal are processed. + await this.queue.promise; + + const currentFrame = stripAnsi( + this.lastFrame({ allowEmpty: true }), + ).trim(); + const expectedFrame = this.normalizeFrame( + stripAnsi( + (this.lastRenderStaticContent ?? '') + (this.lastRenderOutput ?? ''), + ), + ).trim(); + + lastCurrent = currentFrame; + lastExpected = expectedFrame; + + const isMatch = () => { + if (expectedFrame === '...') { + // '...' is our fallback when output isn't in metrics, meaning Ink rendered *something* + // but we don't know what it is. If terminal has content, we consider it a match. + // However, if the component rendered null, both would be empty, but our fallback + // made expectedFrame '...'. In that case, we can't easily know if it's ready, + // but we can assume if there are no pending writes, it's ready. + return currentFrame !== '' || this.pendingWrites === 0; + } + + // If Ink expects nothing (no new static content and no dynamic output), + // we consider it a match because the terminal buffer will just hold the historical static content. + if (expectedFrame === '') { + return true; + } + + if (this.lastRenderOutput === undefined) { + return false; + } + + // If the terminal is empty but Ink expects something, it's not a match. + if (currentFrame === '') { + return false; + } + + // Check if the current frame contains the expected content. + // We use includes because xterm might have some formatting or + // extra whitespace that Ink doesn't account for in its raw output metrics. + return currentFrame.includes(expectedFrame); + }; + + if (this.pendingWrites === 0 && isMatch()) { + return; + } + + attempts++; + await act(async () => { + if (vi.isFakeTimers()) { + await vi.advanceTimersByTimeAsync(10); + } else { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + }); + } + + throw new Error( + `waitUntilReady() timed out after ${maxAttempts} attempts.\n` + + `Expected content (stripped ANSI):\n"${lastExpected}"\n` + + `Actual content (stripped ANSI):\n"${lastCurrent}"\n` + + `Pending writes: ${this.pendingWrites}\n` + + `Render count: ${this.renderCount}`, + ); + } +} + +class XtermStderr extends EventEmitter { + private state: TerminalState; + private pendingWrites = 0; + private queue: { promise: Promise }; + isTTY = true; + + constructor(state: TerminalState, queue: { promise: Promise }) { + super(); + this.state = state; + this.queue = queue; + } + + write = (data: string) => { + this.pendingWrites++; + this.queue.promise = this.queue.promise.then(async () => { + await new Promise((resolve) => + this.state.terminal.write(data, resolve), + ); + this.pendingWrites--; + }); + }; + + dispose = () => { + this.state.terminal.dispose(); + }; + + lastFrame = () => ''; +} + +class XtermStdin extends EventEmitter { + isTTY = true; + data: string | null = null; + constructor(options: { isTTY?: boolean } = {}) { + super(); + this.isTTY = options.isTTY ?? true; + } + + write = (data: string) => { + this.data = data; + this.emit('readable'); + this.emit('data', data); + }; + + setEncoding() {} + setRawMode() {} + resume() {} + pause() {} + ref() {} + unref() {} + + read = () => { + const { data } = this; + this.data = null; + return data; + }; +} + +export type RenderInstance = { + rerender: (tree: React.ReactElement) => void; + unmount: () => void; + cleanup: () => void; + stdout: XtermStdout; + stderr: XtermStderr; + stdin: XtermStdin; + frames: string[]; + lastFrame: (options?: { allowEmpty?: boolean }) => string; + lastFrameRaw: (options?: { allowEmpty?: boolean }) => string; + generateSvg: () => string; + terminal: Terminal; + waitUntilReady: () => Promise; + capturedOverflowState: OverflowState | undefined; + capturedOverflowActions: OverflowActions | undefined; +}; + +export type RenderWithProvidersInstance = RenderInstance & { + simulateClick: ( + col: number, + row: number, + button?: 0 | 1 | 2, + ) => Promise; +}; + +const instances: InkInstance[] = []; + +export const render = async ( + tree: React.ReactElement, + terminalWidth?: number, +): Promise< + Omit +> => { + const cols = terminalWidth ?? 100; + // We use 1000 rows to avoid windows with incorrect snapshots if a correct + // value was used (e.g. 40 rows). The alternatives to make things worse are + // windows unfortunately with odd duplicate content in the backbuffer + // which does not match actual behavior in xterm.js on windows. + const rows = 1000; + const terminal = new Terminal({ + cols, + rows, + allowProposedApi: true, + convertEol: true, + }); + + const state: TerminalState = { + terminal, + cols, + rows, + }; + const writeQueue = { promise: Promise.resolve() }; + const stdout = new XtermStdout(state, writeQueue); + const stderr = new XtermStderr(state, writeQueue); + const stdin = new XtermStdin(); + + let instance!: InkInstance; + stdout.clear(); + act(() => { + instance = inkRenderDirect(tree, { + stdout: stdout as unknown as NodeJS.WriteStream, + + stderr: stderr as unknown as NodeJS.WriteStream, + + stdin: stdin as unknown as NodeJS.ReadStream, + debug: false, + exitOnCtrlC: false, + patchConsole: false, + onRender: (metrics: RenderMetrics) => { + const output = isInkRenderMetrics(metrics) ? metrics.output : '...'; + const staticOutput = isInkRenderMetrics(metrics) + ? (metrics.staticOutput ?? '') + : ''; + stdout.onRender(staticOutput, output); + }, + }); + }); + + instances.push(instance); + + await stdout.waitUntilReady(); + + return { + rerender: (newTree: React.ReactElement) => { + act(() => { + stdout.clear(); + instance.rerender(newTree); + }); + }, + unmount: () => { + act(() => { + instance.unmount(); + }); + stdout.dispose(); + stderr.dispose(); + }, + cleanup: instance.cleanup, + stdout, + stderr, + stdin, + frames: stdout.frames, + lastFrame: stdout.lastFrame, + lastFrameRaw: stdout.lastFrameRaw, + generateSvg: stdout.generateSvg, + terminal: state.terminal, + waitUntilReady: () => stdout.waitUntilReady(), + }; +}; + +export const cleanup = () => { + for (const instance of instances) { + act(() => { + instance.unmount(); + }); + instance.cleanup(); + } + instances.length = 0; +}; + +export const simulateClick = async ( + stdin: XtermStdin, + col: number, + row: number, + button: 0 | 1 | 2 = 0, // 0 for left, 1 for middle, 2 for right +) => { + // Terminal mouse events are 1-based, so convert if necessary. + const mouseEventString = `\x1b[<${button};${col};${row}M`; + await act(async () => { + stdin.write(mouseEventString); + }); +}; + +export const mockSettings = createMockSettings(); + +// A minimal mock UIState to satisfy the context provider. +// Tests that need specific UIState values should provide their own. +const baseMockUiState = { + history: [], + renderMarkdown: true, + streamingState: StreamingState.Idle, + isConfigInitialized: true, + isAuthenticating: false, + terminalWidth: 100, + terminalHeight: 40, + currentModel: 'gemini-pro', + terminalBackgroundColor: 'black' as const, + cleanUiDetailsVisible: false, + allowPlanMode: true, + activePtyId: undefined, + backgroundTasks: new Map(), + backgroundTaskHeight: 0, + quota: { + userTier: undefined, + stats: undefined, + proQuotaRequest: null, + validationRequest: null, + }, + hintMode: false, + hintBuffer: '', + bannerData: { + defaultText: '', + warningText: '', + }, + bannerVisible: false, + nightly: false, + updateInfo: null, + pendingHistoryItems: [], + mainControlsRef: () => {}, + rootUiRef: { current: null }, +}; + +export const mockAppState: AppState = { + version: '1.2.3', + startupWarnings: [], +}; + +const mockUIActions: UIActions = { + handleThemeSelect: vi.fn(), + closeThemeDialog: vi.fn(), + handleThemeHighlight: vi.fn(), + handleAuthSelect: vi.fn(), + setAuthState: vi.fn(), + onAuthError: vi.fn(), + handleEditorSelect: vi.fn(), + exitEditorDialog: vi.fn(), + exitPrivacyNotice: vi.fn(), + closeSettingsDialog: vi.fn(), + closeModelDialog: vi.fn(), + openVoiceModelDialog: vi.fn(), + closeVoiceModelDialog: vi.fn(), + openAgentConfigDialog: vi.fn(), + closeAgentConfigDialog: vi.fn(), + openPermissionsDialog: vi.fn(), + openSessionBrowser: vi.fn(), + closeSessionBrowser: vi.fn(), + handleResumeSession: vi.fn(), + handleDeleteSession: vi.fn(), + closePermissionsDialog: vi.fn(), + setShellModeActive: vi.fn(), + vimHandleInput: vi.fn(), + handleIdePromptComplete: vi.fn(), + handleFolderTrustSelect: vi.fn(), + setIsPolicyUpdateDialogOpen: vi.fn(), + setConstrainHeight: vi.fn(), + onEscapePromptChange: vi.fn(), + refreshStatic: vi.fn(), + handleFinalSubmit: vi.fn(), + handleClearScreen: vi.fn(), + handleProQuotaChoice: vi.fn(), + handleValidationChoice: vi.fn(), + handleOverageMenuChoice: vi.fn(), + handleEmptyWalletChoice: vi.fn(), + setQueueErrorMessage: vi.fn(), + addMessage: vi.fn(), + popAllMessages: vi.fn(), + handleApiKeySubmit: vi.fn(), + handleApiKeyCancel: vi.fn(), + setBannerVisible: vi.fn(), + setShortcutsHelpVisible: vi.fn(), + setCleanUiDetailsVisible: vi.fn(), + toggleCleanUiDetailsVisible: vi.fn(), + revealCleanUiDetailsTemporarily: vi.fn(), + handleWarning: vi.fn(), + setEmbeddedShellFocused: vi.fn(), + dismissBackgroundTask: vi.fn(), + setActiveBackgroundTaskPid: vi.fn(), + setIsBackgroundTaskListOpen: vi.fn(), + setAuthContext: vi.fn(), + dismissLoginRestart: vi.fn(), + onHintInput: vi.fn(), + onHintBackspace: vi.fn(), + onHintClear: vi.fn(), + onHintSubmit: vi.fn(), + handleRestart: vi.fn(), + handleNewAgentsSelect: vi.fn(), + getPreferredEditor: vi.fn(), + clearAccountSuspension: vi.fn(), + setVoiceModeEnabled: vi.fn(), +}; + +import { type TextBuffer } from '../ui/components/shared/text-buffer.js'; +import { InputContext, type InputState } from '../ui/contexts/InputContext.js'; +import { QuotaContext, type QuotaState } from '../ui/contexts/QuotaContext.js'; + +let capturedOverflowState: OverflowState | undefined; +let capturedOverflowActions: OverflowActions | undefined; +const ContextCapture: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + capturedOverflowState = useOverflowState(); + capturedOverflowActions = useOverflowActions(); + return <>{children}; +}; + +export const renderWithProviders = async ( + component: React.ReactElement, + { + shellFocus = true, + settings = mockSettings, + uiState: providedUiState, + quotaState: providedQuotaState, + inputState: providedInputState, + width, + mouseEventsEnabled = false, + config, + uiActions, + toolActions, + persistentState, + appState = mockAppState, + }: { + shellFocus?: boolean; + settings?: LoadedSettings; + uiState?: Partial; + quotaState?: Partial; + inputState?: Partial; + width?: number; + mouseEventsEnabled?: boolean; + config?: Config; + uiActions?: Partial; + toolActions?: Partial<{ + isExpanded: (callId: string) => boolean; + toggleExpansion: (callId: string) => void; + toggleAllExpansion: (callIds: string[]) => void; + }>; + persistentState?: { + get?: typeof persistentStateMock.get; + set?: typeof persistentStateMock.set; + }; + appState?: AppState; + } = {}, +): Promise => { + const baseState: UIState = new Proxy( + { ...baseMockUiState, ...providedUiState }, + { + get(target, prop) { + if (prop in target) { + return target[prop as keyof typeof target]; + } + // For properties not in the base mock or provided state, + // we'll check the original proxy to see if it's a defined but + // unprovided property, and if not, throw. + if (prop in baseMockUiState) { + return baseMockUiState[prop as keyof typeof baseMockUiState]; + } + throw new Error(`mockUiState does not have property ${String(prop)}`); + }, + }, + ) as UIState; + + const quotaState: QuotaState = { + userTier: undefined, + stats: undefined, + proQuotaRequest: null, + validationRequest: null, + overageMenuRequest: null, + emptyWalletRequest: null, + ...providedQuotaState, + }; + + const inputState = { + buffer: { text: '' } as unknown as TextBuffer, + userMessages: [], + shellModeActive: false, + showEscapePrompt: false, + copyModeEnabled: false, + inputWidth: 80, + suggestionsWidth: 40, + ...(providedUiState as unknown as Partial), + ...providedInputState, + }; + + if (persistentState?.get) { + persistentStateMock.get.mockImplementation(persistentState.get); + } + if (persistentState?.set) { + persistentStateMock.set.mockImplementation(persistentState.set); + } + + persistentStateMock.mockClear(); + + const terminalWidth = width ?? baseState.terminalWidth; + + const finalConfig = + config || + makeFakeConfig({ + useAlternateBuffer: settings.merged.ui?.useAlternateBuffer, + showMemoryUsage: settings.merged.ui?.showMemoryUsage, + accessibility: settings.merged.ui?.accessibility, + }); + + const mainAreaWidth = providedUiState?.mainAreaWidth ?? terminalWidth; + + const finalUiState = { + ...baseState, + terminalWidth, + mainAreaWidth, + }; + + themeManager.setTerminalBackground(baseState.terminalBackgroundColor); + const themeName = pickDefaultThemeName( + baseState.terminalBackgroundColor, + themeManager.getAllThemes(), + DEFAULT_THEME.name, + DefaultLight.name, + ); + themeManager.setActiveTheme(themeName); + + const finalUIActions = { ...mockUIActions, ...uiActions }; + + const allToolCalls = (finalUiState.pendingHistoryItems || []) + .filter((item): item is HistoryItemToolGroup => item.type === 'tool_group') + .flatMap((item) => item.tools); + + capturedOverflowState = undefined; + capturedOverflowActions = undefined; + + const wrapWithProviders = (comp: React.ReactElement) => ( + + + + + + + + + + + + + + + + + + + + + {comp} + + + + + + + + + + + + + + + + + + + + + ); + + const renderResult = await render( + wrapWithProviders(component), + terminalWidth, + ); + + return { + ...renderResult, + rerender: (newComponent: React.ReactElement) => { + renderResult.rerender(wrapWithProviders(newComponent)); + }, + capturedOverflowState, + capturedOverflowActions, + simulateClick: (col: number, row: number, button?: 0 | 1 | 2) => + simulateClick(renderResult.stdin, col, row, button), + }; +}; + +export async function renderHook( + renderCallback: (props: Props) => Result, + options?: { + initialProps?: Props; + wrapper?: React.ComponentType<{ children: React.ReactNode }>; + }, +): Promise<{ + result: { current: Result }; + rerender: (props?: Props) => void; + unmount: () => void; + waitUntilReady: () => Promise; + generateSvg: () => string; +}> { + const result = { current: undefined as unknown as Result }; + let currentProps = options?.initialProps as Props; + + function TestComponent({ + renderCallback, + props, + }: { + renderCallback: (props: Props) => Result; + props: Props; + }) { + result.current = renderCallback(props); + return null; + } + + const Wrapper = options?.wrapper || (({ children }) => <>{children}); + + let inkRerender: (tree: React.ReactElement) => void = () => {}; + let unmount: () => void = () => {}; + let waitUntilReady: () => Promise = async () => {}; + let generateSvg: () => string = () => ''; + + const renderResult = await render( + + + , + ); + inkRerender = renderResult.rerender; + unmount = renderResult.unmount; + waitUntilReady = renderResult.waitUntilReady; + generateSvg = renderResult.generateSvg; + + function rerender(props?: Props) { + if (arguments.length > 0) { + currentProps = props as Props; + } + act(() => { + inkRerender( + + + , + ); + }); + } + + return { result, rerender, unmount, waitUntilReady, generateSvg }; +} + +export async function renderHookWithProviders( + renderCallback: (props: Props) => Result, + options: { + initialProps?: Props; + wrapper?: React.ComponentType<{ children: React.ReactNode }>; + // Options for renderWithProviders + shellFocus?: boolean; + settings?: LoadedSettings; + uiState?: Partial; + width?: number; + mouseEventsEnabled?: boolean; + config?: Config; + } = {}, +): Promise<{ + result: { current: Result }; + rerender: (props?: Props) => void; + unmount: () => void; + waitUntilReady: () => Promise; + generateSvg: () => string; +}> { + const result = { current: undefined as unknown as Result }; + + let setPropsFn: ((props: Props) => void) | undefined; + let forceUpdateFn: (() => void) | undefined; + + function TestComponent({ initialProps }: { initialProps: Props }) { + const [props, setProps] = useState(initialProps); + const [, forceUpdate] = useState(0); + setPropsFn = setProps; + forceUpdateFn = () => forceUpdate((n) => n + 1); + result.current = renderCallback(props); + return null; + } + + const Wrapper = options.wrapper || (({ children }) => <>{children}); + + let renderResult: RenderWithProvidersInstance; + + await act(async () => { + renderResult = await renderWithProviders( + + {} + + , + options, + ); + }); + + function rerender(newProps?: Props) { + act(() => { + if (arguments.length > 0 && setPropsFn) { + setPropsFn(newProps as Props); + } else if (forceUpdateFn) { + forceUpdateFn(); + } + }); + } + + return { + result, + rerender, + unmount: () => { + act(() => { + renderResult.unmount(); + }); + }, + waitUntilReady: () => renderResult.waitUntilReady(), + generateSvg: () => renderResult.generateSvg(), + }; +} diff --git a/packages/cli/src/test-utils/settings.ts b/packages/cli/src/test-utils/settings.ts new file mode 100644 index 0000000000000000000000000000000000000000..9c97e987b10035c74e7ebf6f070df44cfff603c9 --- /dev/null +++ b/packages/cli/src/test-utils/settings.ts @@ -0,0 +1,82 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { + LoadedSettings, + createTestMergedSettings, + type SettingsError, +} from '../config/settings.js'; + +export interface MockSettingsFile { + settings: any; + originalSettings: any; + path: string; + readOnly?: boolean; +} + +interface CreateMockSettingsOptions { + system?: MockSettingsFile; + systemDefaults?: MockSettingsFile; + user?: MockSettingsFile; + workspace?: MockSettingsFile; + isTrusted?: boolean; + errors?: SettingsError[]; + merged?: any; + [key: string]: any; +} + +/** + * Creates a mock LoadedSettings object for testing. + * + * @param overrides - Partial settings or LoadedSettings properties to override. + * If 'merged' is provided, it overrides the computed merged settings. + * Any functions in overrides are assigned directly to the LoadedSettings instance. + */ +export const createMockSettings = ( + overrides: CreateMockSettingsOptions = {}, +): LoadedSettings => { + const { + system, + systemDefaults, + user, + workspace, + isTrusted, + errors, + merged: mergedOverride, + ...settingsOverrides + } = overrides; + + const loaded = new LoadedSettings( + (system as any) || { path: '', settings: {}, originalSettings: {} }, + + (systemDefaults as any) || { path: '', settings: {}, originalSettings: {} }, + + (user as any) || { + path: '', + settings: settingsOverrides, + originalSettings: settingsOverrides, + }, + (workspace as any) || { path: '', settings: {}, originalSettings: {} }, + isTrusted ?? true, + errors || [], + ); + + if (mergedOverride) { + // @ts-expect-error - overriding private field for testing + loaded._merged = createTestMergedSettings(mergedOverride); + } + + // Assign any function overrides (e.g., vi.fn() for methods) + for (const key in overrides) { + if (typeof overrides[key] === 'function') { + (loaded as any)[key] = overrides[key]; + } + } + + return loaded; +}; diff --git a/packages/cli/src/test-utils/svg.ts b/packages/cli/src/test-utils/svg.ts new file mode 100644 index 0000000000000000000000000000000000000000..92d3f53c2f969d9347519cdf7e348480c805b7fb --- /dev/null +++ b/packages/cli/src/test-utils/svg.ts @@ -0,0 +1,213 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Terminal } from '@xterm/headless'; + +export const generateSvgForTerminal = (terminal: Terminal): string => { + const activeBuffer = terminal.buffer.active; + + const getHexColor = ( + isRGB: boolean, + isPalette: boolean, + isDefault: boolean, + colorCode: number, + ): string | null => { + if (isDefault) return null; + if (isRGB) { + return `#${colorCode.toString(16).padStart(6, '0')}`; + } + if (isPalette) { + if (colorCode >= 0 && colorCode <= 15) { + return ( + [ + '#000000', + '#cd0000', + '#00cd00', + '#cdcd00', + '#0000ee', + '#cd00cd', + '#00cdcd', + '#e5e5e5', + '#7f7f7f', + '#ff0000', + '#00ff00', + '#ffff00', + '#5c5cff', + '#ff00ff', + '#00ffff', + '#ffffff', + ][colorCode] || null + ); + } else if (colorCode >= 16 && colorCode <= 231) { + const v = [0, 95, 135, 175, 215, 255]; + const c = colorCode - 16; + const b = v[c % 6]; + const g = v[Math.floor(c / 6) % 6]; + const r = v[Math.floor(c / 36) % 6]; + return `#${[r, g, b].map((x) => x?.toString(16).padStart(2, '0')).join('')}`; + } else if (colorCode >= 232 && colorCode <= 255) { + const gray = 8 + (colorCode - 232) * 10; + const hex = gray.toString(16).padStart(2, '0'); + return `#${hex}${hex}${hex}`; + } + } + return null; + }; + + const escapeXml = (unsafe: string): string => + // eslint-disable-next-line no-control-regex + unsafe.replace(/[<>&'"\x00-\x08\x0B-\x0C\x0E-\x1F]/g, (c) => { + switch (c) { + case '<': + return '<'; + case '>': + return '>'; + case '&': + return '&'; + case "'": + return '''; + case '"': + return '"'; + default: + return ''; + } + }); + + const charWidth = 9; + const charHeight = 17; + const padding = 10; + + // Find the actual number of rows with content to avoid rendering trailing blank space. + let contentRows = terminal.rows; + for (let y = terminal.rows - 1; y >= 0; y--) { + const line = activeBuffer.getLine(y); + if (line && line.translateToString(true).trim().length > 0) { + contentRows = y + 1; + break; + } + } + + if (contentRows === 0) contentRows = 1; // Minimum 1 row + + const width = terminal.cols * charWidth + padding * 2; + const height = contentRows * charHeight + padding * 2; + + let svg = ` +`; + svg += ` +`; + svg += ` +`; // Terminal background + svg += ` +`; + + for (let y = 0; y < contentRows; y++) { + const line = activeBuffer.getLine(y); + if (!line) continue; + + let currentFgHex: string | null = null; + let currentBgHex: string | null = null; + let currentIsBold = false; + let currentIsItalic = false; + let currentIsUnderline = false; + let currentBlockStartCol = -1; + let currentBlockText = ''; + let currentBlockNumCells = 0; + + const finalizeBlock = (_endCol: number) => { + if (currentBlockStartCol !== -1) { + if (currentBlockText.length > 0) { + const xPos = currentBlockStartCol * charWidth; + const yPos = y * charHeight; + + if (currentBgHex) { + const rectWidth = currentBlockNumCells * charWidth; + svg += ` +`; + } + if (currentBlockText.trim().length > 0 || currentIsUnderline) { + const fill = currentFgHex || '#ffffff'; // Default text color + const textWidth = currentBlockNumCells * charWidth; + + let extraAttrs = ''; + if (currentIsBold) extraAttrs += ' font-weight="bold"'; + if (currentIsItalic) extraAttrs += ' font-style="italic"'; + if (currentIsUnderline) + extraAttrs += ' text-decoration="underline"'; + + // Use textLength to ensure the block fits exactly into its designated cells + const textElement = `${escapeXml(currentBlockText)}`; + + svg += ` ${textElement}\n`; + } + } + } + }; + + for (let x = 0; x < line.length; x++) { + const cell = line.getCell(x); + if (!cell) continue; + const cellWidth = cell.getWidth(); + if (cellWidth === 0) continue; // Skip continuation cells of wide characters + + let fgHex = getHexColor( + cell.isFgRGB(), + cell.isFgPalette(), + cell.isFgDefault(), + cell.getFgColor(), + ); + let bgHex = getHexColor( + cell.isBgRGB(), + cell.isBgPalette(), + cell.isBgDefault(), + cell.getBgColor(), + ); + + if (cell.isInverse()) { + const tempFgHex = fgHex; + fgHex = bgHex || '#000000'; + bgHex = tempFgHex || '#ffffff'; + } + + const isBold = !!cell.isBold(); + const isItalic = !!cell.isItalic(); + const isUnderline = !!cell.isUnderline(); + + let chars = cell.getChars(); + if (chars === '') chars = ' '.repeat(cellWidth); + + if ( + fgHex !== currentFgHex || + bgHex !== currentBgHex || + isBold !== currentIsBold || + isItalic !== currentIsItalic || + isUnderline !== currentIsUnderline || + currentBlockStartCol === -1 + ) { + finalizeBlock(x); + currentFgHex = fgHex; + currentBgHex = bgHex; + currentIsBold = isBold; + currentIsItalic = isItalic; + currentIsUnderline = isUnderline; + currentBlockStartCol = x; + currentBlockText = chars; + currentBlockNumCells = cellWidth; + } else { + currentBlockText += chars; + currentBlockNumCells += cellWidth; + } + } + finalizeBlock(line.length); + } + + svg += ` \n`; + return svg; +}; diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3505e6345295f694edecc7413864c76c749dbafb --- /dev/null +++ b/packages/cli/src/ui/App.test.tsx @@ -0,0 +1,295 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, type Mock, beforeEach } from 'vitest'; +import type React from 'react'; +import { renderWithProviders } from '../test-utils/render.js'; +import { createMockSettings } from '../test-utils/settings.js'; +import { Text, useIsScreenReaderEnabled, type DOMElement } from 'ink'; +import { App } from './App.js'; +import { type UIState } from './contexts/UIStateContext.js'; +import { StreamingState } from './types.js'; +import { makeFakeConfig, CoreToolCallStatus } from '@google/gemini-cli-core'; + +vi.mock('ink', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + useIsScreenReaderEnabled: vi.fn(), + }; +}); + +vi.mock('./components/DialogManager.js', () => ({ + DialogManager: () => DialogManager, +})); + +vi.mock('./components/Composer.js', () => ({ + Composer: () => Composer, +})); + +vi.mock('./components/Notifications.js', async () => { + const { Text, Box } = await import('ink'); + return { + Notifications: () => ( + + Notifications + + ), + }; +}); + +vi.mock('./components/QuittingDisplay.js', () => ({ + QuittingDisplay: () => Quitting..., +})); + +vi.mock('./components/HistoryItemDisplay.js', () => ({ + HistoryItemDisplay: () => HistoryItemDisplay, +})); + +vi.mock('./components/Footer.js', async () => { + const { Text, Box } = await import('ink'); + return { + Footer: () => ( + + Footer + + ), + }; +}); + +describe('App', () => { + beforeEach(() => { + (useIsScreenReaderEnabled as Mock).mockReturnValue(false); + }); + + const mockUIState: Partial = { + streamingState: StreamingState.Idle, + cleanUiDetailsVisible: true, + quittingMessages: null, + dialogsVisible: false, + mainControlsRef: vi.fn(), + rootUiRef: { + current: null, + } as unknown as React.MutableRefObject, + historyManager: { + addItem: vi.fn(), + history: [], + updateItem: vi.fn(), + clearItems: vi.fn(), + loadHistory: vi.fn(), + }, + history: [], + pendingHistoryItems: [], + pendingGeminiHistoryItems: [], + bannerData: { + defaultText: 'Mock Banner Text', + warningText: '', + }, + backgroundTasks: new Map(), + }; + + it('should render main content and composer when not quitting', async () => { + const { lastFrame, unmount } = await renderWithProviders(, { + uiState: mockUIState, + settings: createMockSettings({ ui: { useAlternateBuffer: false } }), + }); + + expect(lastFrame()).toContain('Tips for getting started'); + expect(lastFrame()).toContain('Notifications'); + expect(lastFrame()).toContain('Composer'); + unmount(); + }); + + it('should render quitting display when quittingMessages is set', async () => { + const quittingUIState = { + ...mockUIState, + quittingMessages: [{ id: 1, type: 'user', text: 'test' }], + } as UIState; + + const { lastFrame, unmount } = await renderWithProviders(, { + uiState: quittingUIState, + settings: createMockSettings({ ui: { useAlternateBuffer: false } }), + }); + + expect(lastFrame()).toContain('Quitting...'); + unmount(); + }); + + it('should render full history in alternate buffer mode when quittingMessages is set', async () => { + const quittingUIState = { + ...mockUIState, + quittingMessages: [{ id: 1, type: 'user', text: 'test' }], + history: [{ id: 1, type: 'user', text: 'history item' }], + pendingHistoryItems: [{ type: 'user', text: 'pending item' }], + } as UIState; + + const { lastFrame, unmount } = await renderWithProviders(, { + uiState: quittingUIState, + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), + }); + + expect(lastFrame()).toContain('HistoryItemDisplay'); + expect(lastFrame()).toContain('Quitting...'); + unmount(); + }); + + it('should render dialog manager when dialogs are visible', async () => { + const dialogUIState = { + ...mockUIState, + dialogsVisible: true, + } as UIState; + + const { lastFrame, unmount } = await renderWithProviders(, { + uiState: dialogUIState, + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), + }); + + expect(lastFrame()).toContain('Tips for getting started'); + expect(lastFrame()).toContain('Notifications'); + expect(lastFrame()).toContain('DialogManager'); + unmount(); + }); + + it.each([ + { key: 'C', stateKey: 'ctrlCPressedOnce' }, + { key: 'D', stateKey: 'ctrlDPressedOnce' }, + ])( + 'should show Ctrl+$key exit prompt when dialogs are visible and $stateKey is true', + async ({ key, stateKey }) => { + const uiState = { + ...mockUIState, + dialogsVisible: true, + [stateKey]: true, + } as UIState; + + const { lastFrame, unmount } = await renderWithProviders(, { + uiState, + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), + }); + + expect(lastFrame()).toContain(`Press Ctrl+${key} again to exit.`); + unmount(); + }, + ); + + it('should render ScreenReaderAppLayout when screen reader is enabled', async () => { + (useIsScreenReaderEnabled as Mock).mockReturnValue(true); + + const { lastFrame, unmount } = await renderWithProviders(, { + uiState: mockUIState, + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), + }); + + expect(lastFrame()).toContain('Notifications'); + expect(lastFrame()).toContain('Footer'); + expect(lastFrame()).toContain('Tips for getting started'); + expect(lastFrame()).toContain('Composer'); + unmount(); + }); + + it('should render DefaultAppLayout when screen reader is not enabled', async () => { + (useIsScreenReaderEnabled as Mock).mockReturnValue(false); + + const { lastFrame, unmount } = await renderWithProviders(, { + uiState: mockUIState, + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), + }); + + expect(lastFrame()).toContain('Tips for getting started'); + expect(lastFrame()).toContain('Notifications'); + expect(lastFrame()).toContain('Composer'); + unmount(); + }); + + it('should render ToolConfirmationQueue along with Composer when tool is confirming and experiment is on', async () => { + (useIsScreenReaderEnabled as Mock).mockReturnValue(false); + + const toolCalls = [ + { + callId: 'call-1', + name: 'ls', + description: 'list directory', + status: CoreToolCallStatus.AwaitingApproval, + resultDisplay: '', + confirmationDetails: { + type: 'exec' as const, + title: 'Confirm execution', + command: 'ls', + rootCommand: 'ls', + rootCommands: ['ls'], + }, + }, + ]; + + const stateWithConfirmingTool = { + ...mockUIState, + pendingHistoryItems: [ + { + type: 'tool_group', + tools: toolCalls, + }, + ], + pendingGeminiHistoryItems: [ + { + type: 'tool_group', + tools: toolCalls, + }, + ], + } as UIState; + + const configWithExperiment = makeFakeConfig({ useAlternateBuffer: true }); + vi.spyOn(configWithExperiment, 'isTrustedFolder').mockReturnValue(true); + vi.spyOn(configWithExperiment, 'getIdeMode').mockReturnValue(false); + + const { lastFrame, unmount } = await renderWithProviders(, { + uiState: stateWithConfirmingTool, + config: configWithExperiment, + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), + }); + + expect(lastFrame()).toContain('Tips for getting started'); + expect(lastFrame()).toContain('Notifications'); + expect(lastFrame()).toContain('Action Required'); // From ToolConfirmationQueue + expect(lastFrame()).toContain('Composer'); + expect(lastFrame()).toMatchSnapshot(); + unmount(); + }); + + describe('Snapshots', () => { + it('renders default layout correctly', async () => { + (useIsScreenReaderEnabled as Mock).mockReturnValue(false); + const { lastFrame, unmount } = await renderWithProviders(, { + uiState: mockUIState, + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), + }); + expect(lastFrame()).toMatchSnapshot(); + unmount(); + }); + + it('renders screen reader layout correctly', async () => { + (useIsScreenReaderEnabled as Mock).mockReturnValue(true); + const { lastFrame, unmount } = await renderWithProviders(, { + uiState: mockUIState, + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), + }); + expect(lastFrame()).toMatchSnapshot(); + unmount(); + }); + + it('renders with dialogs visible', async () => { + const dialogUIState = { + ...mockUIState, + dialogsVisible: true, + } as UIState; + const { lastFrame, unmount } = await renderWithProviders(, { + uiState: dialogUIState, + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), + }); + expect(lastFrame()).toMatchSnapshot(); + unmount(); + }); + }); +}); diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2c3e424ae4ca91801f6fdda477d251d88020e695 --- /dev/null +++ b/packages/cli/src/ui/App.tsx @@ -0,0 +1,38 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useIsScreenReaderEnabled } from 'ink'; +import { useUIState } from './contexts/UIStateContext.js'; +import { StreamingContext } from './contexts/StreamingContext.js'; +import { QuittingDisplay } from './components/QuittingDisplay.js'; +import { ScreenReaderAppLayout } from './layouts/ScreenReaderAppLayout.js'; +import { DefaultAppLayout } from './layouts/DefaultAppLayout.js'; +import { AlternateBufferQuittingDisplay } from './components/AlternateBufferQuittingDisplay.js'; +import { useAlternateBuffer } from './hooks/useAlternateBuffer.js'; + +export const App = () => { + const uiState = useUIState(); + const isAlternateBuffer = useAlternateBuffer(); + const isScreenReaderEnabled = useIsScreenReaderEnabled(); + + if (uiState.quittingMessages) { + if (isAlternateBuffer) { + return ( + + + + ); + } else { + return ; + } + } + + return ( + + {isScreenReaderEnabled ? : } + + ); +}; diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..86de99c556eeff0014c4b8f23d8110ad980dad73 --- /dev/null +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -0,0 +1,3741 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, + type Mock, + type MockedObject, +} from 'vitest'; +import { render, cleanup, persistentStateMock } from '../test-utils/render.js'; +import { waitFor } from '../test-utils/async.js'; +import { act, useContext } from 'react'; +import { AppContainer } from './AppContainer.js'; +import { SettingsContext } from './contexts/SettingsContext.js'; +import { type TrackedToolCall } from './hooks/useToolScheduler.js'; +import { + type Config, + makeFakeConfig, + CoreEvent, + type UserFeedbackPayload, + type ResumedSessionData, + type StartupWarning, + WarningPriority, + AuthType, + type AgentDefinition, + CoreToolCallStatus, +} from '@google/gemini-cli-core'; + +// Mock coreEvents +const mockCoreEvents = vi.hoisted(() => ({ + on: vi.fn(), + off: vi.fn(), + drainBacklogs: vi.fn(), + emit: vi.fn(), +})); + +// Mock IdeClient +const mockIdeClient = vi.hoisted(() => ({ + getInstance: vi.fn().mockReturnValue(new Promise(() => {})), +})); + +// Mock stdout and ink app +const mocks = vi.hoisted(() => ({ + mockStdout: { write: vi.fn() }, + mockRerender: vi.fn(), +})); +const terminalNotificationsMocks = vi.hoisted(() => ({ + notifyViaTerminal: vi.fn().mockResolvedValue(true), + isNotificationsEnabled: vi.fn(() => true), + getNotificationMethod: vi.fn(() => 'auto'), + buildRunEventNotificationContent: vi.fn((event) => ({ + title: 'Mock Notification', + subtitle: 'Mock Subtitle', + body: JSON.stringify(event), + })), +})); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + coreEvents: mockCoreEvents, + IdeClient: mockIdeClient, + writeToStdout: vi.fn((...args) => + process.stdout.write( + ...(args as Parameters), + ), + ), + writeToStderr: vi.fn((...args) => + process.stderr.write( + ...(args as Parameters), + ), + ), + patchStdio: vi.fn(() => () => {}), + createWorkingStdio: vi.fn(() => ({ + stdout: process.stdout, + stderr: process.stderr, + })), + enableMouseEvents: vi.fn(), + disableMouseEvents: vi.fn(), + FileDiscoveryService: vi.fn().mockImplementation(() => ({ + initialize: vi.fn(), + })), + startupProfiler: { + flush: vi.fn(), + start: vi.fn(), + end: vi.fn(), + }, + }; +}); +import ansiEscapes from 'ansi-escapes'; +import { type LoadedSettings } from '../config/settings.js'; +import { createMockSettings } from '../test-utils/settings.js'; +import type { InitializationResult } from '../core/initializer.js'; +import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js'; +import { StreamingState, MessageType } from './types.js'; +import { UIStateContext, type UIState } from './contexts/UIStateContext.js'; +import { + UIActionsContext, + type UIActions, +} from './contexts/UIActionsContext.js'; +import { KeypressProvider } from './contexts/KeypressContext.js'; +import { OverflowProvider } from './contexts/OverflowContext.js'; +import { + useOverflowActions, + type OverflowActions, +} from './contexts/OverflowContext.js'; + +// Mock useStdout and useApp to capture terminal title writes and spy on re-renders +vi.mock('ink', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useStdout: () => ({ stdout: mocks.mockStdout }), + useApp: () => ({ + rerender: mocks.mockRerender, + }), + measureElement: vi.fn(), + }; +}); + +import { InputContext, type InputState } from './contexts/InputContext.js'; +import { QuotaContext, type QuotaState } from './contexts/QuotaContext.js'; + +// Helper component will read the context values provided by AppContainer +// so we can assert against them in our tests. +let capturedUIState: UIState; +let capturedInputState: InputState; +let capturedQuotaState: QuotaState; +let capturedUIActions: UIActions; +let capturedOverflowActions: OverflowActions; +function TestContextConsumer() { + capturedUIState = useContext(UIStateContext)!; + capturedInputState = useContext(InputContext)!; + capturedQuotaState = useContext(QuotaContext)!; + capturedUIActions = useContext(UIActionsContext)!; + capturedOverflowActions = useOverflowActions()!; + return null; +} + +vi.mock('./App.js', () => ({ + App: TestContextConsumer, +})); + +vi.mock('./hooks/useQuotaAndFallback.js'); +vi.mock('./hooks/useHistoryManager.js'); +vi.mock('./hooks/useThemeCommand.js'); +vi.mock('./auth/useAuth.js'); +vi.mock('../config/auth.js', () => ({ + validateAuthMethod: vi.fn().mockResolvedValue(null), +})); +vi.mock('./hooks/useEditorSettings.js'); +vi.mock('./hooks/useSettingsCommand.js'); +vi.mock('./hooks/useModelCommand.js'); +vi.mock('./hooks/slashCommandProcessor.js'); +vi.mock('./hooks/useConsoleMessages.js'); +vi.mock('./hooks/useTerminalSize.js', () => ({ + useTerminalSize: vi.fn(() => ({ columns: 80, rows: 24 })), +})); +vi.mock('./hooks/useGeminiStream.js'); +vi.mock('./hooks/vim.js'); +vi.mock('./hooks/useFocus.js'); +vi.mock('./hooks/useBracketedPaste.js'); +vi.mock('./hooks/useLoadingIndicator.js'); +vi.mock('./hooks/useSuspend.js'); +vi.mock('./hooks/useFolderTrust.js'); +vi.mock('./hooks/useIdeTrustListener.js'); +vi.mock('./hooks/useMessageQueue.js'); +vi.mock('./hooks/useApprovalModeIndicator.js'); +vi.mock('./hooks/useGitBranchName.js'); +vi.mock('./hooks/useExtensionUpdates.js'); +vi.mock('./contexts/VimModeContext.js'); +vi.mock('./contexts/SessionContext.js'); +vi.mock('./components/shared/text-buffer.js'); +vi.mock('./hooks/useLogger.js'); +vi.mock('./hooks/useInputHistoryStore.js'); +vi.mock('./hooks/atCommandProcessor.js'); +vi.mock('./hooks/useHookDisplayState.js'); +vi.mock('./hooks/useBanner.js', () => ({ + useBanner: vi.fn((bannerData) => ({ + bannerText: ( + bannerData.warningText || + bannerData.defaultText || + '' + ).replace(/\\n/g, '\n'), + })), +})); +vi.mock('./hooks/useShellInactivityStatus.js', () => ({ + useShellInactivityStatus: vi.fn(() => ({ + shouldShowFocusHint: false, + inactivityStatus: 'none', + })), +})); +vi.mock('../utils/terminalNotifications.js', () => ({ + notifyViaTerminal: terminalNotificationsMocks.notifyViaTerminal, + isNotificationsEnabled: terminalNotificationsMocks.isNotificationsEnabled, + getNotificationMethod: terminalNotificationsMocks.getNotificationMethod, + buildRunEventNotificationContent: + terminalNotificationsMocks.buildRunEventNotificationContent, +})); +vi.mock('./hooks/useTerminalTheme.js', () => ({ + useTerminalTheme: vi.fn(), +})); + +import { useHookDisplayState } from './hooks/useHookDisplayState.js'; +import { useTerminalTheme } from './hooks/useTerminalTheme.js'; +import { useShellInactivityStatus } from './hooks/useShellInactivityStatus.js'; +import { useFocus } from './hooks/useFocus.js'; + +// Mock external utilities +vi.mock('../utils/events.js'); +vi.mock('../utils/handleAutoUpdate.js'); +vi.mock('./utils/ConsolePatcher.js'); +vi.mock('../utils/cleanup.js'); + +import { useHistory } from './hooks/useHistoryManager.js'; +import { useThemeCommand } from './hooks/useThemeCommand.js'; +import { useAuthCommand } from './auth/useAuth.js'; +import { validateAuthMethod } from '../config/auth.js'; +import { useEditorSettings } from './hooks/useEditorSettings.js'; +import { useSettingsCommand } from './hooks/useSettingsCommand.js'; +import { useModelCommand } from './hooks/useModelCommand.js'; +import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js'; +import { useErrorCount } from './hooks/useConsoleMessages.js'; +import { useGeminiStream } from './hooks/useGeminiStream.js'; +import { useVim } from './hooks/vim.js'; +import { useFolderTrust } from './hooks/useFolderTrust.js'; +import { useIdeTrustListener } from './hooks/useIdeTrustListener.js'; +import { useMessageQueue } from './hooks/useMessageQueue.js'; +import { useApprovalModeIndicator } from './hooks/useApprovalModeIndicator.js'; +import { useGitBranchName } from './hooks/useGitBranchName.js'; +import { + useConfirmUpdateRequests, + useExtensionUpdates, +} from './hooks/useExtensionUpdates.js'; +import { useVimMode } from './contexts/VimModeContext.js'; +import { useSessionStats } from './contexts/SessionContext.js'; +import { useTextBuffer } from './components/shared/text-buffer.js'; +import { useLogger } from './hooks/useLogger.js'; +import { useLoadingIndicator } from './hooks/useLoadingIndicator.js'; +import { useInputHistoryStore } from './hooks/useInputHistoryStore.js'; +import { useKeypress, type Key } from './hooks/useKeypress.js'; +import * as useKeypressModule from './hooks/useKeypress.js'; +import { useSuspend } from './hooks/useSuspend.js'; +import { + writeToStdout, + enableMouseEvents, + disableMouseEvents, +} from '@google/gemini-cli-core'; +import { type ExtensionManager } from '../config/extension-manager.js'; +import { + WARNING_PROMPT_DURATION_MS, + EXPAND_HINT_DURATION_MS, +} from './constants.js'; + +describe('AppContainer State Management', () => { + let mockConfig: Config; + let mockSettings: LoadedSettings; + let mockInitResult: InitializationResult; + let mockExtensionManager: MockedObject; + + type AppContainerProps = { + settings?: LoadedSettings; + config?: Config; + version?: string; + initResult?: InitializationResult; + startupWarnings?: StartupWarning[]; + resumedSessionData?: ResumedSessionData; + }; + + // Helper to generate the AppContainer JSX for render and rerender + const getAppContainer = ({ + settings = mockSettings, + config = mockConfig, + version = '1.0.0', + initResult = mockInitResult, + startupWarnings, + resumedSessionData, + }: AppContainerProps = {}) => ( + + + + + + + + ); + + // Helper to render the AppContainer + const renderAppContainer = async (props?: AppContainerProps) => + render(getAppContainer(props)); + + // Create typed mocks for all hooks + const mockedUseQuotaAndFallback = useQuotaAndFallback as Mock; + const mockedUseHistory = useHistory as Mock; + const mockedUseThemeCommand = useThemeCommand as Mock; + const mockedUseAuthCommand = useAuthCommand as Mock; + const mockedUseEditorSettings = useEditorSettings as Mock; + const mockedUseSettingsCommand = useSettingsCommand as Mock; + const mockedUseModelCommand = useModelCommand as Mock; + const mockedUseSlashCommandProcessor = useSlashCommandProcessor as Mock; + const mockedUseConsoleMessages = useErrorCount as Mock; + const mockedUseGeminiStream = useGeminiStream as Mock; + const mockedUseVim = useVim as Mock; + const mockedUseFolderTrust = useFolderTrust as Mock; + const mockedUseIdeTrustListener = useIdeTrustListener as Mock; + const mockedUseMessageQueue = useMessageQueue as Mock; + const mockedUseApprovalModeIndicator = useApprovalModeIndicator as Mock; + const mockedUseGitBranchName = useGitBranchName as Mock; + const mockedUseConfirmUpdateRequests = useConfirmUpdateRequests as Mock; + const mockedUseExtensionUpdates = useExtensionUpdates as Mock; + const mockedUseVimMode = useVimMode as Mock; + const mockedUseSessionStats = useSessionStats as Mock; + const mockedUseTextBuffer = useTextBuffer as Mock; + const mockedUseLogger = useLogger as Mock; + const mockedUseLoadingIndicator = useLoadingIndicator as Mock; + const mockedUseSuspend = useSuspend as Mock; + const mockedUseInputHistoryStore = useInputHistoryStore as Mock; + const mockedUseHookDisplayState = useHookDisplayState as Mock; + const mockedUseTerminalTheme = useTerminalTheme as Mock; + const mockedUseShellInactivityStatus = useShellInactivityStatus as Mock; + const mockedUseFocusState = useFocus as Mock; + + const DEFAULT_GEMINI_STREAM_MOCK = { + streamingState: 'idle', + submitQuery: vi.fn(), + initError: null, + pendingHistoryItems: [], + thought: null, + cancelOngoingRequest: vi.fn(), + handleApprovalModeChange: vi.fn(), + activePtyId: null, + loopDetectionConfirmationRequest: null, + backgroundTaskCount: 0, + isBackgroundTaskVisible: false, + toggleBackgroundTasks: vi.fn(), + backgroundCurrentExecution: vi.fn(), + backgroundTasks: new Map(), + registerBackgroundTask: vi.fn(), + dismissBackgroundTask: vi.fn(), + }; + + beforeEach(() => { + persistentStateMock.reset(); + vi.clearAllMocks(); + + mockIdeClient.getInstance.mockReturnValue(new Promise(() => {})); + + // Initialize mock stdout for terminal title tests + + mocks.mockStdout.write.mockClear(); + (disableMouseEvents as import('vitest').Mock).mockClear(); + + capturedUIState = null!; + + // **Provide a default return value for EVERY mocked hook.** + mockedUseQuotaAndFallback.mockReturnValue({ + proQuotaRequest: null, + handleProQuotaChoice: vi.fn(), + }); + mockedUseHistory.mockReturnValue({ + history: [], + addItem: vi.fn(), + updateItem: vi.fn(), + clearItems: vi.fn(), + loadHistory: vi.fn(), + }); + mockedUseThemeCommand.mockReturnValue({ + isThemeDialogOpen: false, + openThemeDialog: vi.fn(), + handleThemeSelect: vi.fn(), + handleThemeHighlight: vi.fn(), + }); + mockedUseAuthCommand.mockReturnValue({ + authState: 'authenticated', + setAuthState: vi.fn(), + authError: null, + onAuthError: vi.fn(), + }); + mockedUseEditorSettings.mockReturnValue({ + isEditorDialogOpen: false, + openEditorDialog: vi.fn(), + handleEditorSelect: vi.fn(), + exitEditorDialog: vi.fn(), + }); + mockedUseSettingsCommand.mockReturnValue({ + isSettingsDialogOpen: false, + openSettingsDialog: vi.fn(), + closeSettingsDialog: vi.fn(), + }); + mockedUseModelCommand.mockReturnValue({ + isModelDialogOpen: false, + openModelDialog: vi.fn(), + closeModelDialog: vi.fn(), + }); + mockedUseSlashCommandProcessor.mockReturnValue({ + handleSlashCommand: vi.fn(), + slashCommands: [], + pendingHistoryItems: [], + commandContext: {}, + shellConfirmationRequest: null, + confirmationRequest: null, + }); + mockedUseConsoleMessages.mockReturnValue({ + errorCount: 0, + handleNewMessage: vi.fn(), + clearErrorCount: vi.fn(), + }); + mockedUseGeminiStream.mockReturnValue(DEFAULT_GEMINI_STREAM_MOCK); + mockedUseVim.mockReturnValue({ handleInput: vi.fn() }); + mockedUseFolderTrust.mockReturnValue({ + isFolderTrustDialogOpen: false, + handleFolderTrustSelect: vi.fn(), + isRestarting: false, + }); + mockedUseIdeTrustListener.mockReturnValue({ + needsRestart: false, + restartReason: 'NONE', + }); + mockedUseMessageQueue.mockReturnValue({ + messageQueue: [], + addMessage: vi.fn(), + clearQueue: vi.fn(), + getQueuedMessagesText: vi.fn().mockReturnValue(''), + }); + mockedUseApprovalModeIndicator.mockReturnValue(false); + mockedUseGitBranchName.mockReturnValue('main'); + mockedUseVimMode.mockReturnValue({ + isVimEnabled: false, + toggleVimEnabled: vi.fn(), + }); + mockedUseSessionStats.mockReturnValue({ stats: {} }); + mockedUseTextBuffer.mockReturnValue({ + text: '', + setText: vi.fn(), + lines: [''], + cursor: [0, 0], + handleInput: vi.fn().mockReturnValue(false), + }); + mockedUseLogger.mockReturnValue({ + getPreviousUserMessages: vi.fn().mockResolvedValue([]), + }); + mockedUseInputHistoryStore.mockReturnValue({ + inputHistory: [], + addInput: vi.fn(), + initializeFromLogger: vi.fn(), + }); + mockedUseLoadingIndicator.mockReturnValue({ + elapsedTime: '0.0s', + currentLoadingPhrase: '', + }); + mockedUseSuspend.mockReturnValue({ + handleSuspend: vi.fn(), + }); + mockedUseHookDisplayState.mockReturnValue([]); + mockedUseTerminalTheme.mockReturnValue(undefined); + mockedUseShellInactivityStatus.mockReturnValue({ + shouldShowFocusHint: false, + inactivityStatus: 'none', + }); + mockedUseFocusState.mockReturnValue({ + isFocused: true, + hasReceivedFocusEvent: true, + }); + mockedUseConfirmUpdateRequests.mockReturnValue({ + addConfirmUpdateExtensionRequest: vi.fn(), + confirmUpdateExtensionRequests: [], + }); + mockedUseExtensionUpdates.mockReturnValue({ + extensionsUpdateState: new Map(), + extensionsUpdateStateInternal: new Map(), + dispatchExtensionStateUpdate: vi.fn(), + }); + + // Mock Config + mockConfig = makeFakeConfig(); + vi.spyOn(mockConfig, 'getUseRenderProcess').mockReturnValue(false); + + // Mock config's getTargetDir to return consistent workspace directory + vi.spyOn(mockConfig, 'getTargetDir').mockReturnValue('/test/workspace'); + vi.spyOn(mockConfig, 'initialize').mockResolvedValue(undefined); + vi.spyOn(mockConfig, 'getDebugMode').mockReturnValue(false); + + mockExtensionManager = { + getExtensions: vi.fn().mockReturnValue([]), + setRequestConsent: vi.fn(), + setRequestSetting: vi.fn(), + start: vi.fn(), + } as unknown as MockedObject; + vi.spyOn(mockConfig, 'getExtensionLoader').mockReturnValue( + mockExtensionManager, + ); + + // Mock LoadedSettings + mockSettings = createMockSettings({ + hideBanner: false, + hideTips: false, + hideFooter: false, + showMemoryUsage: false, + theme: 'default', + ui: { + showStatusInTitle: false, + hideWindowTitle: false, + useAlternateBuffer: false, + }, + }); + + // Mock InitializationResult + mockInitResult = { + themeError: null, + authError: null, + shouldOpenAuthDialog: false, + geminiMdFileCount: 0, + } as InitializationResult; + }); + + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + }); + + describe('Basic Rendering', () => { + it('renders without crashing with minimal props', async () => { + const { unmount } = await act(async () => renderAppContainer()); + expect(capturedUIState).toBeTruthy(); + unmount(); + }); + + it('renders with startup warnings', async () => { + const startupWarnings: StartupWarning[] = [ + { + id: 'w1', + message: 'Warning 1', + priority: WarningPriority.High, + }, + { + id: 'w2', + message: 'Warning 2', + priority: WarningPriority.High, + }, + ]; + + const { unmount } = await act(async () => + renderAppContainer({ startupWarnings }), + ); + expect(capturedUIState).toBeTruthy(); + unmount(); + }); + + it('shows full UI details by default', async () => { + const { unmount } = await act(async () => renderAppContainer()); + + expect(capturedUIState.cleanUiDetailsVisible).toBe(true); + unmount(); + }); + + it('starts in minimal UI mode when Focus UI preference is persisted', async () => { + persistentStateMock.get.mockReturnValueOnce(true); + + const { unmount } = await act(async () => + renderAppContainer({ + settings: mockSettings, + }), + ); + + expect(capturedUIState.cleanUiDetailsVisible).toBe(false); + expect(persistentStateMock.get).toHaveBeenCalledWith('focusUiEnabled'); + unmount(); + }); + }); + + describe('State Initialization', () => { + it('calls validateAuthMethod and onAuthError if validation fails', async () => { + const mockOnAuthError = vi.fn(); + mockedUseAuthCommand.mockReturnValue({ + authState: 'authenticated', + setAuthState: vi.fn(), + authError: null, + onAuthError: mockOnAuthError, + }); + vi.mocked(validateAuthMethod).mockResolvedValueOnce('Validation Failed'); + + const { unmount } = await act(async () => + renderAppContainer({ + settings: createMockSettings({ + merged: { + security: { + auth: { selectedType: 'oauth-personal', useExternal: false }, + }, + }, + }), + }), + ); + + await waitFor(() => { + expect(validateAuthMethod).toHaveBeenCalledWith('oauth-personal'); + expect(mockOnAuthError).toHaveBeenCalledWith('Validation Failed'); + }); + + unmount(); + }); + + it('sends a macOS notification when confirmation is pending and terminal is unfocused', async () => { + mockedUseFocusState.mockReturnValue({ + isFocused: false, + hasReceivedFocusEvent: true, + }); + mockedUseGeminiStream.mockReturnValue({ + ...DEFAULT_GEMINI_STREAM_MOCK, + pendingHistoryItems: [ + { + type: 'tool_group', + tools: [ + { + callId: 'call-1', + name: 'run_shell_command', + description: 'Run command', + resultDisplay: undefined, + status: CoreToolCallStatus.AwaitingApproval, + confirmationDetails: { + type: 'exec', + title: 'Run shell command', + command: 'ls', + rootCommand: 'ls', + rootCommands: ['ls'], + }, + }, + ], + }, + ], + }); + + const { unmount } = await act(async () => renderAppContainer()); + + expect(terminalNotificationsMocks.notifyViaTerminal).toHaveBeenCalled(); + expect( + terminalNotificationsMocks.buildRunEventNotificationContent, + ).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'attention', + }), + ); + + unmount(); + }); + + it('does not send attention notification when terminal is focused', async () => { + mockedUseFocusState.mockReturnValue({ + isFocused: true, + hasReceivedFocusEvent: true, + }); + mockedUseGeminiStream.mockReturnValue({ + ...DEFAULT_GEMINI_STREAM_MOCK, + pendingHistoryItems: [ + { + type: 'tool_group', + tools: [ + { + callId: 'call-2', + name: 'run_shell_command', + description: 'Run command', + resultDisplay: undefined, + status: CoreToolCallStatus.AwaitingApproval, + confirmationDetails: { + type: 'exec', + title: 'Run shell command', + command: 'ls', + rootCommand: 'ls', + rootCommands: ['ls'], + }, + }, + ], + }, + ], + }); + + const { unmount } = await act(async () => renderAppContainer()); + + expect( + terminalNotificationsMocks.notifyViaTerminal, + ).not.toHaveBeenCalled(); + + unmount(); + }); + + it('sends attention notification when focus reporting is unavailable', async () => { + mockedUseFocusState.mockReturnValue({ + isFocused: true, + hasReceivedFocusEvent: false, + }); + mockedUseGeminiStream.mockReturnValue({ + ...DEFAULT_GEMINI_STREAM_MOCK, + pendingHistoryItems: [ + { + type: 'tool_group', + tools: [ + { + callId: 'call-focus-unknown', + name: 'run_shell_command', + description: 'Run command', + resultDisplay: undefined, + status: CoreToolCallStatus.AwaitingApproval, + confirmationDetails: { + type: 'exec', + title: 'Run shell command', + command: 'ls', + rootCommand: 'ls', + rootCommands: ['ls'], + }, + }, + ], + }, + ], + }); + + const { unmount } = await act(async () => renderAppContainer()); + + expect(terminalNotificationsMocks.notifyViaTerminal).toHaveBeenCalled(); + + unmount(); + }); + + it('sends a macOS notification when a response completes while unfocused', async () => { + mockedUseFocusState.mockReturnValue({ + isFocused: false, + hasReceivedFocusEvent: true, + }); + let currentStreamingState: 'idle' | 'responding' = 'responding'; + mockedUseGeminiStream.mockImplementation(() => ({ + ...DEFAULT_GEMINI_STREAM_MOCK, + streamingState: currentStreamingState, + })); + + const { unmount, rerender } = await act(async () => renderAppContainer()); + + currentStreamingState = 'idle'; + await act(async () => { + rerender(getAppContainer()); + }); + + expect( + terminalNotificationsMocks.buildRunEventNotificationContent, + ).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'session_complete', + detail: 'Gemini CLI finished responding.', + }), + ); + expect(terminalNotificationsMocks.notifyViaTerminal).toHaveBeenCalled(); + + unmount(); + }); + + it('sends completion notification when focus reporting is unavailable', async () => { + mockedUseFocusState.mockReturnValue({ + isFocused: true, + hasReceivedFocusEvent: false, + }); + let currentStreamingState: 'idle' | 'responding' = 'responding'; + mockedUseGeminiStream.mockImplementation(() => ({ + ...DEFAULT_GEMINI_STREAM_MOCK, + streamingState: currentStreamingState, + })); + + const { unmount, rerender } = await act(async () => renderAppContainer()); + + currentStreamingState = 'idle'; + await act(async () => { + rerender(getAppContainer()); + }); + + expect( + terminalNotificationsMocks.buildRunEventNotificationContent, + ).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'session_complete', + detail: 'Gemini CLI finished responding.', + }), + ); + + unmount(); + }); + + it('does not send completion notification when another action-required dialog is pending', async () => { + mockedUseFocusState.mockReturnValue({ + isFocused: false, + hasReceivedFocusEvent: true, + }); + mockedUseQuotaAndFallback.mockReturnValue({ + proQuotaRequest: { kind: 'upgrade' }, + handleProQuotaChoice: vi.fn(), + }); + let currentStreamingState: 'idle' | 'responding' = 'responding'; + mockedUseGeminiStream.mockImplementation(() => ({ + ...DEFAULT_GEMINI_STREAM_MOCK, + streamingState: currentStreamingState, + })); + + const { unmount, rerender } = await act(async () => renderAppContainer()); + + currentStreamingState = 'idle'; + await act(async () => { + rerender(getAppContainer()); + }); + + expect( + terminalNotificationsMocks.notifyViaTerminal, + ).not.toHaveBeenCalled(); + + unmount(); + }); + + it('can send repeated attention notifications for the same key after pending state clears', async () => { + mockedUseFocusState.mockReturnValue({ + isFocused: false, + hasReceivedFocusEvent: true, + }); + + let pendingHistoryItems = [ + { + type: 'tool_group', + tools: [ + { + callId: 'repeat-key-call', + name: 'run_shell_command', + description: 'Run command', + resultDisplay: undefined, + status: CoreToolCallStatus.AwaitingApproval, + confirmationDetails: { + type: 'exec', + title: 'Run shell command', + command: 'ls', + rootCommand: 'ls', + rootCommands: ['ls'], + }, + }, + ], + }, + ]; + + mockedUseGeminiStream.mockImplementation(() => ({ + ...DEFAULT_GEMINI_STREAM_MOCK, + pendingHistoryItems, + })); + + const { unmount, rerender } = await act(async () => renderAppContainer()); + + expect( + terminalNotificationsMocks.notifyViaTerminal, + ).toHaveBeenCalledTimes(1); + + pendingHistoryItems = []; + await act(async () => { + rerender(getAppContainer()); + }); + + pendingHistoryItems = [ + { + type: 'tool_group', + tools: [ + { + callId: 'repeat-key-call', + name: 'run_shell_command', + description: 'Run command', + resultDisplay: undefined, + status: CoreToolCallStatus.AwaitingApproval, + confirmationDetails: { + type: 'exec', + title: 'Run shell command', + command: 'ls', + rootCommand: 'ls', + rootCommands: ['ls'], + }, + }, + ], + }, + ]; + await act(async () => { + rerender(getAppContainer()); + }); + + expect( + terminalNotificationsMocks.notifyViaTerminal, + ).toHaveBeenCalledTimes(2); + + unmount(); + }); + + it('initializes with theme error from initialization result', async () => { + const initResultWithError = { + ...mockInitResult, + themeError: 'Failed to load theme', + }; + + const { unmount } = await act(async () => + renderAppContainer({ + initResult: initResultWithError, + }), + ); + expect(capturedUIState).toBeTruthy(); + unmount(); + }); + + it('handles debug mode state', async () => { + const debugConfig = makeFakeConfig(); + vi.spyOn(debugConfig, 'getDebugMode').mockReturnValue(true); + + const { unmount } = await act(async () => + renderAppContainer({ config: debugConfig }), + ); + unmount(); + }); + }); + + describe('Context Providers', () => { + it('provides AppContext with correct values', async () => { + const { unmount } = await act(async () => + renderAppContainer({ version: '2.0.0' }), + ); + expect(capturedUIState).toBeTruthy(); + + // Should render and unmount cleanly + unmount(); + }); + + it('provides UIStateContext with state management', async () => { + const { unmount } = await act(async () => renderAppContainer()); + expect(capturedUIState).toBeTruthy(); + unmount(); + }); + + it('provides UIActionsContext with action handlers', async () => { + const { unmount } = await act(async () => renderAppContainer()); + expect(capturedUIState).toBeTruthy(); + unmount(); + }); + + it('provides ConfigContext with config object', async () => { + const { unmount } = await act(async () => renderAppContainer()); + expect(capturedUIState).toBeTruthy(); + unmount(); + }); + }); + + describe('Settings Integration', () => { + it('handles settings with all display options disabled', async () => { + const settingsAllHidden = createMockSettings({ + hideBanner: true, + hideTips: true, + hideFooter: true, + showMemoryUsage: false, + }); + + const { unmount } = await act(async () => + renderAppContainer({ settings: settingsAllHidden }), + ); + expect(capturedUIState).toBeTruthy(); + unmount(); + }); + + it('handles settings with memory usage enabled', async () => { + const settingsWithMemory = createMockSettings({ + showMemoryUsage: true, + }); + + const { unmount } = await act(async () => + renderAppContainer({ settings: settingsWithMemory }), + ); + expect(capturedUIState).toBeTruthy(); + unmount(); + }); + }); + + describe('Version Handling', () => { + it.each(['1.0.0', '2.1.3-beta', '3.0.0-nightly'])( + 'handles version format: %s', + async (version) => { + const { unmount } = await act(async () => + renderAppContainer({ version }), + ); + expect(capturedUIState).toBeTruthy(); + unmount(); + }, + ); + }); + + describe('Error Handling', () => { + it('handles config methods that might throw', async () => { + const errorConfig = makeFakeConfig(); + vi.spyOn(errorConfig, 'getModel').mockImplementation(() => { + throw new Error('Config error'); + }); + + // Should still render without crashing - errors should be handled internally + const { unmount } = await act(async () => + renderAppContainer({ config: errorConfig }), + ); + unmount(); + }); + + it('handles undefined settings gracefully', async () => { + const undefinedSettings = createMockSettings(); + + const { unmount } = await act(async () => + renderAppContainer({ settings: undefinedSettings }), + ); + expect(capturedUIState).toBeTruthy(); + unmount(); + }); + }); + + describe('Provider Hierarchy', () => { + it('establishes correct provider nesting order', async () => { + // This tests that all the context providers are properly nested + // and that the component tree can be built without circular dependencies + const { unmount } = await act(async () => renderAppContainer()); + + unmount(); + }); + }); + + describe('Session Resumption', () => { + it('handles resumed session data correctly', async () => { + const mockResumedSessionData = { + conversation: { + sessionId: 'test-session-123', + projectHash: 'test-project-hash', + startTime: '2024-01-01T00:00:00Z', + lastUpdated: '2024-01-01T00:00:01Z', + messages: [ + { + id: 'msg-1', + type: 'user' as const, + content: 'Hello', + timestamp: '2024-01-01T00:00:00Z', + }, + { + id: 'msg-2', + type: 'gemini' as const, + content: 'Hi there!', + role: 'model' as const, + parts: [{ text: 'Hi there!' }], + timestamp: '2024-01-01T00:00:01Z', + }, + ], + }, + filePath: '/tmp/test-session.json', + }; + + const { unmount } = await act(async () => + renderAppContainer({ + config: mockConfig, + settings: mockSettings, + version: '1.0.0', + initResult: mockInitResult, + resumedSessionData: mockResumedSessionData, + }), + ); + unmount(); + }); + + it('renders without resumed session data', async () => { + const { unmount } = await act(async () => + renderAppContainer({ + config: mockConfig, + settings: mockSettings, + version: '1.0.0', + initResult: mockInitResult, + resumedSessionData: undefined, + }), + ); + unmount(); + }); + + it('initializes chat recording service when config has it', async () => { + const mockChatRecordingService = { + initialize: vi.fn(), + recordMessage: vi.fn(), + recordMessageTokens: vi.fn(), + recordToolCalls: vi.fn(), + }; + + const mockGeminiClient = { + isInitialized: vi.fn(() => true), + resumeChat: vi.fn(), + getUserTier: vi.fn(), + getChatRecordingService: vi.fn(() => mockChatRecordingService), + }; + + const configWithRecording = makeFakeConfig(); + vi.spyOn(configWithRecording, 'getGeminiClient').mockReturnValue( + mockGeminiClient as unknown as ReturnType, + ); + + const { unmount } = await act(async () => + renderAppContainer({ + config: configWithRecording, + settings: mockSettings, + version: '1.0.0', + initResult: mockInitResult, + }), + ); + unmount(); + }); + }); + describe('Session Recording Integration', () => { + it('provides chat recording service configuration', async () => { + const mockChatRecordingService = { + initialize: vi.fn(), + recordMessage: vi.fn(), + recordMessageTokens: vi.fn(), + recordToolCalls: vi.fn(), + getSessionId: vi.fn(() => 'test-session-123'), + getCurrentConversation: vi.fn(), + }; + + const mockGeminiClient = { + isInitialized: vi.fn(() => true), + resumeChat: vi.fn(), + getUserTier: vi.fn(), + getChatRecordingService: vi.fn(() => mockChatRecordingService), + setHistory: vi.fn(), + }; + + const configWithRecording = makeFakeConfig(); + vi.spyOn(configWithRecording, 'getGeminiClient').mockReturnValue( + mockGeminiClient as unknown as ReturnType, + ); + vi.spyOn(configWithRecording, 'getSessionId').mockReturnValue( + 'test-session-123', + ); + + const { unmount } = await act(async () => + renderAppContainer({ + config: configWithRecording, + settings: mockSettings, + version: '1.0.0', + initResult: mockInitResult, + }), + ); + + // Verify the recording service structure is correct + expect(configWithRecording.getGeminiClient).toBeDefined(); + expect(mockGeminiClient.getChatRecordingService).toBeDefined(); + expect(mockChatRecordingService.initialize).toBeDefined(); + expect(mockChatRecordingService.recordMessage).toBeDefined(); + unmount(); + }); + + it('handles session recording when messages are added', async () => { + const mockRecordMessage = vi.fn(); + const mockRecordMessageTokens = vi.fn(); + + const mockChatRecordingService = { + initialize: vi.fn(), + recordMessage: mockRecordMessage, + recordMessageTokens: mockRecordMessageTokens, + recordToolCalls: vi.fn(), + getSessionId: vi.fn(() => 'test-session-123'), + }; + + const mockGeminiClient = { + isInitialized: vi.fn(() => true), + getChatRecordingService: vi.fn(() => mockChatRecordingService), + getUserTier: vi.fn(), + }; + + const configWithRecording = makeFakeConfig(); + vi.spyOn(configWithRecording, 'getGeminiClient').mockReturnValue( + mockGeminiClient as unknown as ReturnType, + ); + + const { unmount } = await act(async () => + renderAppContainer({ + config: configWithRecording, + settings: mockSettings, + version: '1.0.0', + initResult: mockInitResult, + }), + ); + + // The actual recording happens through the useHistory hook + // which would be triggered by user interactions + expect(mockChatRecordingService.initialize).toBeDefined(); + expect(mockChatRecordingService.recordMessage).toBeDefined(); + unmount(); + }); + }); + + describe('Session Resume Flow', () => { + it('accepts resumed session data', async () => { + const mockResumeChat = vi.fn(); + const mockGeminiClient = { + isInitialized: vi.fn(() => true), + resumeChat: mockResumeChat, + getUserTier: vi.fn(), + getChatRecordingService: vi.fn(() => ({ + initialize: vi.fn(), + recordMessage: vi.fn(), + recordMessageTokens: vi.fn(), + recordToolCalls: vi.fn(), + })), + }; + + const configWithClient = makeFakeConfig(); + vi.spyOn(configWithClient, 'getGeminiClient').mockReturnValue( + mockGeminiClient as unknown as ReturnType, + ); + + const resumedData = { + conversation: { + sessionId: 'resumed-session-456', + projectHash: 'project-hash', + startTime: '2024-01-01T00:00:00Z', + lastUpdated: '2024-01-01T00:01:00Z', + messages: [ + { + id: 'msg-1', + type: 'user' as const, + content: 'Previous question', + timestamp: '2024-01-01T00:00:00Z', + }, + { + id: 'msg-2', + type: 'gemini' as const, + content: 'Previous answer', + role: 'model' as const, + parts: [{ text: 'Previous answer' }], + timestamp: '2024-01-01T00:00:30Z', + tokenCount: { input: 10, output: 20 }, + }, + ], + }, + filePath: '/tmp/resumed-session.json', + }; + + const { unmount } = await act(async () => + renderAppContainer({ + config: configWithClient, + settings: mockSettings, + version: '1.0.0', + initResult: mockInitResult, + resumedSessionData: resumedData, + }), + ); + + // Verify the resume functionality structure is in place + expect(mockGeminiClient.resumeChat).toBeDefined(); + expect(resumedData.conversation.messages).toHaveLength(2); + unmount(); + }); + + it('does not attempt resume when client is not initialized', async () => { + const mockResumeChat = vi.fn(); + const mockGeminiClient = { + isInitialized: vi.fn(() => false), // Not initialized + resumeChat: mockResumeChat, + getUserTier: vi.fn(), + getChatRecordingService: vi.fn(), + }; + + const configWithClient = makeFakeConfig(); + vi.spyOn(configWithClient, 'getGeminiClient').mockReturnValue( + mockGeminiClient as unknown as ReturnType, + ); + + const resumedData = { + conversation: { + sessionId: 'test-session', + projectHash: 'project-hash', + startTime: '2024-01-01T00:00:00Z', + lastUpdated: '2024-01-01T00:01:00Z', + messages: [], + }, + filePath: '/tmp/session.json', + }; + + const { unmount } = await act(async () => + renderAppContainer({ + config: configWithClient, + settings: mockSettings, + version: '1.0.0', + initResult: mockInitResult, + resumedSessionData: resumedData, + }), + ); + + // Should not call resumeChat when client is not initialized + expect(mockResumeChat).not.toHaveBeenCalled(); + unmount(); + }); + }); + + describe('SessionStart Hook Rendering', () => { + it('does not render systemMessage directly (avoids duplicate with HookSystemMessage event)', async () => { + const mockAddItem = vi.fn(); + mockedUseHistory.mockReturnValue({ + history: [], + addItem: mockAddItem, + updateItem: vi.fn(), + clearItems: vi.fn(), + loadHistory: vi.fn(), + }); + + const fireSessionStartEvent = vi.fn().mockResolvedValue({ + systemMessage: 'Hello from SessionStart hook', + getAdditionalContext: vi.fn(() => undefined), + }); + vi.spyOn(mockConfig, 'getHookSystem').mockReturnValue({ + fireSessionEndEvent: vi.fn().mockResolvedValue(undefined), + fireSessionStartEvent, + } as unknown as ReturnType); + + const { unmount } = await act(async () => renderAppContainer()); + await waitFor(() => expect(fireSessionStartEvent).toHaveBeenCalled()); + + // The direct-render path (the bug) would call addItem with the + // systemMessage text and no `source` field. The HookSystemMessage + // event-listener path (the correct one) always sets `source`. + const directRenderCall = mockAddItem.mock.calls.find( + ([item]) => + item?.text === 'Hello from SessionStart hook' && !item?.source, + ); + expect(directRenderCall).toBeUndefined(); + + unmount(); + }); + }); + + describe('Token Counting from Session Stats', () => { + it('tracks token counts from session messages', async () => { + // Session stats are provided through the SessionStatsProvider context + // in the real app, not through the config directly + const mockChatRecordingService = { + initialize: vi.fn(), + recordMessage: vi.fn(), + recordMessageTokens: vi.fn(), + recordToolCalls: vi.fn(), + getSessionId: vi.fn(() => 'test-session-123'), + getCurrentConversation: vi.fn(() => ({ + sessionId: 'test-session-123', + messages: [], + totalInputTokens: 150, + totalOutputTokens: 350, + })), + }; + + const mockGeminiClient = { + isInitialized: vi.fn(() => true), + getChatRecordingService: vi.fn(() => mockChatRecordingService), + getUserTier: vi.fn(), + }; + + const configWithRecording = makeFakeConfig(); + vi.spyOn(configWithRecording, 'getGeminiClient').mockReturnValue( + mockGeminiClient as unknown as ReturnType, + ); + + const { unmount } = await act(async () => + renderAppContainer({ + config: configWithRecording, + settings: mockSettings, + version: '1.0.0', + initResult: mockInitResult, + }), + ); + + // In the actual app, these stats would be displayed in components + // and updated as messages are processed through the recording service + expect(mockChatRecordingService.recordMessageTokens).toBeDefined(); + expect(mockChatRecordingService.getCurrentConversation).toBeDefined(); + unmount(); + }); + }); + + describe('Quota and Fallback Integration', () => { + it('passes a null proQuotaRequest to QuotaContext by default', async () => { + // The default mock from beforeEach already sets proQuotaRequest to null + const { unmount } = await act(async () => renderAppContainer()); + // Assert that the context value is as expected + expect(capturedQuotaState.proQuotaRequest).toBeNull(); + unmount(); + }); + + it('passes a valid proQuotaRequest to QuotaContext when provided by the hook', async () => { + // Arrange: Create a mock request object that a UI dialog would receive + const mockRequest = { + failedModel: 'gemini-pro', + fallbackModel: 'gemini-flash', + resolve: vi.fn(), + }; + mockedUseQuotaAndFallback.mockReturnValue({ + proQuotaRequest: mockRequest, + handleProQuotaChoice: vi.fn(), + }); + + // Act: Render the container + const { unmount } = await act(async () => renderAppContainer()); + // Assert: The mock request is correctly passed through the context + expect(capturedQuotaState.proQuotaRequest).toEqual(mockRequest); + unmount(); + }); + + it('passes the handleProQuotaChoice function to UIActionsContext', async () => { + // Arrange: Create a mock handler function + const mockHandler = vi.fn(); + mockedUseQuotaAndFallback.mockReturnValue({ + proQuotaRequest: null, + handleProQuotaChoice: mockHandler, + }); + + // Act: Render the container + const { unmount } = await act(async () => renderAppContainer()); + // Assert: The action in the context is the mock handler we provided + expect(capturedUIActions.handleProQuotaChoice).toBe(mockHandler); + + // You can even verify that the plumbed function is callable + act(() => { + capturedUIActions.handleProQuotaChoice('retry_later'); + }); + expect(mockHandler).toHaveBeenCalledWith('retry_later'); + unmount(); + }); + }); + + describe('Terminal Title Update Feature', () => { + beforeEach(() => { + // Reset mock stdout for each test + mocks.mockStdout.write.mockClear(); + (disableMouseEvents as import('vitest').Mock).mockClear(); + }); + + it('verifies useStdout is mocked', async () => { + const { useStdout } = await import('ink'); + const { stdout } = useStdout(); + expect(stdout).toBe(mocks.mockStdout); + }); + + it('should update terminal title with Working… when showStatusInTitle is false', async () => { + // Arrange: Set up mock settings with showStatusInTitle disabled + const mockSettingsWithShowStatusFalse = createMockSettings({ + ui: { + showStatusInTitle: false, + hideWindowTitle: false, + }, + }); + + // Mock the streaming state as Active + mockedUseGeminiStream.mockReturnValue({ + ...DEFAULT_GEMINI_STREAM_MOCK, + streamingState: 'responding', + thought: { subject: 'Some thought' }, + }); + + // Act: Render the container + const { unmount } = await act(async () => + renderAppContainer({ + settings: mockSettingsWithShowStatusFalse, + }), + ); + + // Assert: Check that title was updated with "Working…" + const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => + call[0].includes('\x1b]0;'), + ); + + expect(titleWrites).toHaveLength(1); + expect(titleWrites[0][0]).toBe( + `\x1b]0;${'✦ Working… (workspace)'.padEnd(80, ' ')}\x07`, + ); + unmount(); + }); + + it('should use legacy terminal title when dynamicWindowTitle is false', async () => { + // Arrange: Set up mock settings with dynamicWindowTitle disabled + const mockSettingsWithDynamicTitleFalse = createMockSettings({ + ui: { + dynamicWindowTitle: false, + hideWindowTitle: false, + }, + }); + + // Mock the streaming state + mockedUseGeminiStream.mockReturnValue({ + ...DEFAULT_GEMINI_STREAM_MOCK, + streamingState: 'responding', + thought: { subject: 'Some thought' }, + }); + + // Act: Render the container + const { unmount } = await act(async () => + renderAppContainer({ + settings: mockSettingsWithDynamicTitleFalse, + }), + ); + + // Assert: Check that legacy title was used + const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => + call[0].includes('\x1b]0;'), + ); + + expect(titleWrites).toHaveLength(1); + expect(titleWrites[0][0]).toBe( + `\x1b]0;${'Gemini CLI (workspace)'.padEnd(80, ' ')}\x07`, + ); + unmount(); + }); + + it('should not update terminal title when hideWindowTitle is true', async () => { + // Arrange: Set up mock settings with hideWindowTitle enabled + const mockSettingsWithHideTitleTrue = createMockSettings({ + ui: { + showStatusInTitle: true, + hideWindowTitle: true, + }, + }); + + // Act: Render the container + const { unmount } = await act(async () => + renderAppContainer({ + settings: mockSettingsWithHideTitleTrue, + }), + ); + + // Assert: Check that no title-related writes occurred + const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => + call[0].includes('\x1b]0;'), + ); + + expect(titleWrites).toHaveLength(0); + unmount(); + }); + + it('should update terminal title with thought subject when in active state', async () => { + // Arrange: Set up mock settings with showStatusInTitle enabled + const mockSettingsWithTitleEnabled = createMockSettings({ + ui: { + showStatusInTitle: true, + hideWindowTitle: false, + }, + }); + + // Mock the streaming state and thought + const thoughtSubject = 'Processing request'; + mockedUseGeminiStream.mockReturnValue({ + ...DEFAULT_GEMINI_STREAM_MOCK, + streamingState: 'responding', + thought: { subject: thoughtSubject }, + }); + + // Act: Render the container + const { unmount } = await act(async () => + renderAppContainer({ + settings: mockSettingsWithTitleEnabled, + }), + ); + + // Assert: Check that title was updated with thought subject and suffix + const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => + call[0].includes('\x1b]0;'), + ); + + expect(titleWrites).toHaveLength(1); + expect(titleWrites[0][0]).toBe( + `\x1b]0;${`✦ ${thoughtSubject} (workspace)`.padEnd(80, ' ')}\x07`, + ); + unmount(); + }); + + it('should update terminal title with default text when in Idle state and no thought subject', async () => { + // Arrange: Set up mock settings with showStatusInTitle enabled + const mockSettingsWithTitleEnabled = createMockSettings({ + ui: { + showStatusInTitle: true, + hideWindowTitle: false, + }, + }); + + // Mock the streaming state as Idle with no thought + mockedUseGeminiStream.mockReturnValue(DEFAULT_GEMINI_STREAM_MOCK); + + // Act: Render the container + const { unmount } = await act(async () => + renderAppContainer({ + settings: mockSettingsWithTitleEnabled, + }), + ); + + // Assert: Check that title was updated with default Idle text + const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => + call[0].includes('\x1b]0;'), + ); + + expect(titleWrites).toHaveLength(1); + expect(titleWrites[0][0]).toBe( + `\x1b]0;${'◇ Ready (workspace)'.padEnd(80, ' ')}\x07`, + ); + unmount(); + }); + + it('should update terminal title when in WaitingForConfirmation state with thought subject', async () => { + // Arrange: Set up mock settings with showStatusInTitle enabled + const mockSettingsWithTitleEnabled = createMockSettings({ + ui: { + showStatusInTitle: true, + hideWindowTitle: false, + }, + }); + + // Mock the streaming state and thought + const thoughtSubject = 'Confirm tool execution'; + mockedUseGeminiStream.mockReturnValue({ + ...DEFAULT_GEMINI_STREAM_MOCK, + streamingState: 'waiting_for_confirmation', + thought: { subject: thoughtSubject }, + }); + + // Act: Render the container + const { unmount } = await act(async () => + renderAppContainer({ + settings: mockSettingsWithTitleEnabled, + }), + ); + + // Assert: Check that title was updated with confirmation text + const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => + call[0].includes('\x1b]0;'), + ); + + expect(titleWrites).toHaveLength(1); + expect(titleWrites[0][0]).toBe( + `\x1b]0;${'✋ Action Required (workspace)'.padEnd(80, ' ')}\x07`, + ); + unmount(); + }); + + describe('Shell Focus Action Required', () => { + beforeEach(async () => { + vi.useFakeTimers(); + // Use real implementation for these tests to verify title updates + const actual = await vi.importActual< + typeof import('./hooks/useShellInactivityStatus.js') + >('./hooks/useShellInactivityStatus.js'); + mockedUseShellInactivityStatus.mockImplementation( + actual.useShellInactivityStatus, + ); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should show Action Required in title after a delay when shell is awaiting focus', async () => { + const startTime = 1000000; + vi.setSystemTime(startTime); + + // Arrange: Set up mock settings with showStatusInTitle enabled + const mockSettingsWithTitleEnabled = createMockSettings({ + ui: { + showStatusInTitle: true, + hideWindowTitle: false, + }, + }); + + // Mock an active shell pty but not focused + mockedUseGeminiStream.mockReturnValue({ + ...DEFAULT_GEMINI_STREAM_MOCK, + streamingState: 'responding', + thought: { subject: 'Executing shell command' }, + pendingToolCalls: [], + activePtyId: 'pty-1', + lastOutputTime: startTime + 100, // Trigger aggressive delay + retryStatus: null, + }); + + vi.spyOn(mockConfig, 'isInteractive').mockReturnValue(true); + vi.spyOn(mockConfig, 'isInteractiveShellEnabled').mockReturnValue(true); + + // Act: Render the container (embeddedShellFocused is false by default in state) + const { unmount } = await act(async () => + renderAppContainer({ + settings: mockSettingsWithTitleEnabled, + }), + ); + + // Initially it should show the working status + const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => + call[0].includes('\x1b]0;'), + ); + expect(titleWrites[titleWrites.length - 1][0]).toContain( + '✦ Executing shell command', + ); + + // Fast-forward time by 40 seconds + await act(async () => { + await vi.advanceTimersByTimeAsync(40000); + }); + + // Now it should show Action Required + const titleWritesDelayed = mocks.mockStdout.write.mock.calls.filter( + (call) => call[0].includes('\x1b]0;'), + ); + const lastTitle = titleWritesDelayed[titleWritesDelayed.length - 1][0]; + expect(lastTitle).toContain('✋ Action Required'); + + unmount(); + }); + + it('should show Working… in title for redirected commands after 2 mins', async () => { + const startTime = 1000000; + vi.setSystemTime(startTime); + + // Arrange: Set up mock settings with showStatusInTitle enabled + const mockSettingsWithTitleEnabled = createMockSettings({ + ui: { + showStatusInTitle: true, + hideWindowTitle: false, + }, + }); + + // Mock an active shell pty with redirection active + mockedUseGeminiStream.mockReturnValue({ + ...DEFAULT_GEMINI_STREAM_MOCK, + streamingState: 'responding', + thought: { subject: 'Executing shell command' }, + pendingToolCalls: [ + { + request: { + name: 'run_shell_command', + args: { command: 'ls > out' }, + }, + status: CoreToolCallStatus.Executing, + } as unknown as TrackedToolCall, + ], + activePtyId: 'pty-1', + lastOutputTime: startTime, + retryStatus: null, + }); + + vi.spyOn(mockConfig, 'isInteractive').mockReturnValue(true); + vi.spyOn(mockConfig, 'isInteractiveShellEnabled').mockReturnValue(true); + + const { unmount } = await act(async () => + renderAppContainer({ + settings: mockSettingsWithTitleEnabled, + }), + ); + + // Fast-forward time by 65 seconds - should still NOT be Action Required + await act(async () => { + await vi.advanceTimersByTimeAsync(65000); + }); + + const titleWritesMid = mocks.mockStdout.write.mock.calls.filter( + (call) => call[0].includes('\x1b]0;'), + ); + expect(titleWritesMid[titleWritesMid.length - 1][0]).not.toContain( + '✋ Action Required', + ); + + // Fast-forward to 2 minutes (120000ms) + await act(async () => { + await vi.advanceTimersByTimeAsync(60000); + }); + + const titleWritesEnd = mocks.mockStdout.write.mock.calls.filter( + (call) => call[0].includes('\x1b]0;'), + ); + expect(titleWritesEnd[titleWritesEnd.length - 1][0]).toContain( + '⏲ Working…', + ); + + unmount(); + }); + + it('should show Working… in title for silent non-redirected commands after 1 min', async () => { + const startTime = 1000000; + vi.setSystemTime(startTime); + + // Arrange: Set up mock settings with showStatusInTitle enabled + const mockSettingsWithTitleEnabled = createMockSettings({ + ui: { + showStatusInTitle: true, + hideWindowTitle: false, + }, + }); + + // Mock an active shell pty with NO output since operation started (silent) + mockedUseGeminiStream.mockReturnValue({ + ...DEFAULT_GEMINI_STREAM_MOCK, + streamingState: 'responding', + thought: { subject: 'Executing shell command' }, + pendingToolCalls: [], + activePtyId: 'pty-1', + lastOutputTime: startTime, // lastOutputTime <= operationStartTime + retryStatus: null, + }); + + vi.spyOn(mockConfig, 'isInteractive').mockReturnValue(true); + vi.spyOn(mockConfig, 'isInteractiveShellEnabled').mockReturnValue(true); + + const { unmount } = await act(async () => + renderAppContainer({ + settings: mockSettingsWithTitleEnabled, + }), + ); + + // Fast-forward time by 65 seconds + await act(async () => { + await vi.advanceTimersByTimeAsync(65000); + }); + + const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => + call[0].includes('\x1b]0;'), + ); + const lastTitle = titleWrites[titleWrites.length - 1][0]; + // Should show Working… (⏲) instead of Action Required (✋) + expect(lastTitle).toContain('⏲ Working…'); + + unmount(); + }); + + it('should NOT show Action Required in title if shell is streaming output', async () => { + const startTime = 1000000; + vi.setSystemTime(startTime); + + // Arrange: Set up mock settings with showStatusInTitle enabled + const mockSettingsWithTitleEnabled = createMockSettings({ + ui: { + showStatusInTitle: true, + hideWindowTitle: false, + }, + }); + + // Mock an active shell pty but not focused + let lastOutputTime = startTime + 1000; + mockedUseGeminiStream.mockImplementation(() => ({ + ...DEFAULT_GEMINI_STREAM_MOCK, + streamingState: 'responding', + thought: { subject: 'Executing shell command' }, + activePtyId: 'pty-1', + lastOutputTime, + })); + + vi.spyOn(mockConfig, 'isInteractive').mockReturnValue(true); + vi.spyOn(mockConfig, 'isInteractiveShellEnabled').mockReturnValue(true); + + // Act: Render the container + const { unmount, rerender } = await act(async () => + renderAppContainer({ + settings: mockSettingsWithTitleEnabled, + }), + ); + + // Fast-forward time by 20 seconds + await act(async () => { + await vi.advanceTimersByTimeAsync(20000); + }); + + // Update lastOutputTime to simulate new output + lastOutputTime = startTime + 21000; + mockedUseGeminiStream.mockImplementation(() => ({ + ...DEFAULT_GEMINI_STREAM_MOCK, + streamingState: 'responding', + thought: { subject: 'Executing shell command' }, + activePtyId: 'pty-1', + lastOutputTime, + })); + + // Rerender to propagate the new lastOutputTime + await act(async () => { + rerender(getAppContainer({ settings: mockSettingsWithTitleEnabled })); + }); + + // Fast-forward time by another 20 seconds + // Total time elapsed: 40s. + // Time since last output: 20s. + // It should NOT show Action Required yet. + await act(async () => { + await vi.advanceTimersByTimeAsync(20000); + }); + + const titleWritesAfterOutput = mocks.mockStdout.write.mock.calls.filter( + (call) => call[0].includes('\x1b]0;'), + ); + const lastTitle = + titleWritesAfterOutput[titleWritesAfterOutput.length - 1][0]; + expect(lastTitle).not.toContain('✋ Action Required'); + expect(lastTitle).toContain('✦ Executing shell command'); + + // Fast-forward another 40 seconds (Total 60s since last output) + await act(async () => { + await vi.advanceTimersByTimeAsync(40000); + }); + + // Now it SHOULD show Action Required + const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => + call[0].includes('\x1b]0;'), + ); + const lastTitleFinal = titleWrites[titleWrites.length - 1][0]; + expect(lastTitleFinal).toContain('✋ Action Required'); + + unmount(); + }); + }); + + it('should pad title to exactly 80 characters', async () => { + // Arrange: Set up mock settings with showStatusInTitle enabled + const mockSettingsWithTitleEnabled = createMockSettings({ + ui: { + showStatusInTitle: true, + hideWindowTitle: false, + }, + }); + + // Mock the streaming state and thought with a short subject + const shortTitle = 'Short'; + mockedUseGeminiStream.mockReturnValue({ + ...DEFAULT_GEMINI_STREAM_MOCK, + streamingState: 'responding', + thought: { subject: shortTitle }, + }); + + // Act: Render the container + const { unmount } = await act(async () => + renderAppContainer({ + settings: mockSettingsWithTitleEnabled, + }), + ); + + // Assert: Check that title is padded to exactly 80 characters + const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => + call[0].includes('\x1b]0;'), + ); + + expect(titleWrites).toHaveLength(1); + const calledWith = titleWrites[0][0]; + const expectedTitle = `✦ ${shortTitle} (workspace)`.padEnd(80, ' '); + const expectedEscapeSequence = `\x1b]0;${expectedTitle}\x07`; + expect(calledWith).toBe(expectedEscapeSequence); + unmount(); + }); + + it('should use correct ANSI escape code format', async () => { + // Arrange: Set up mock settings with showStatusInTitle enabled + const mockSettingsWithTitleEnabled = createMockSettings({ + ui: { + showStatusInTitle: true, + hideWindowTitle: false, + }, + }); + + // Mock the streaming state and thought + const title = 'Test Title'; + mockedUseGeminiStream.mockReturnValue({ + ...DEFAULT_GEMINI_STREAM_MOCK, + streamingState: 'responding', + thought: { subject: title }, + }); + + // Act: Render the container + const { unmount } = await act(async () => + renderAppContainer({ + settings: mockSettingsWithTitleEnabled, + }), + ); + + // Assert: Check that the correct ANSI escape sequence is used + const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => + call[0].includes('\x1b]0;'), + ); + + expect(titleWrites).toHaveLength(1); + const expectedEscapeSequence = `\x1b]0;${`✦ ${title} (workspace)`.padEnd(80, ' ')}\x07`; + expect(titleWrites[0][0]).toBe(expectedEscapeSequence); + unmount(); + }); + + it('should use CLI_TITLE environment variable when set', async () => { + // Arrange: Set up mock settings with showStatusInTitle disabled (so it shows suffix) + const mockSettingsWithTitleDisabled = createMockSettings({ + ui: { + showStatusInTitle: false, + hideWindowTitle: false, + }, + }); + + // Mock CLI_TITLE environment variable + vi.stubEnv('CLI_TITLE', 'Custom Gemini Title'); + + // Mock the streaming state + mockedUseGeminiStream.mockReturnValue({ + ...DEFAULT_GEMINI_STREAM_MOCK, + streamingState: 'responding', + }); + + // Act: Render the container + const { unmount } = await act(async () => + renderAppContainer({ + settings: mockSettingsWithTitleDisabled, + }), + ); + + // Assert: Check that title was updated with CLI_TITLE value + const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => + call[0].includes('\x1b]0;'), + ); + + expect(titleWrites).toHaveLength(1); + expect(titleWrites[0][0]).toBe( + `\x1b]0;${'✦ Working… (Custom Gemini Title)'.padEnd(80, ' ')}\x07`, + ); + unmount(); + }); + }); + + describe('Queue Error Message', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('should set and clear the queue error message after a timeout', async () => { + const { rerender, unmount } = await act(async () => renderAppContainer()); + await act(async () => { + vi.advanceTimersByTime(0); + }); + + expect(capturedUIState.queueErrorMessage).toBeNull(); + + act(() => { + capturedUIActions.setQueueErrorMessage('Test error'); + }); + rerender(getAppContainer()); + expect(capturedUIState.queueErrorMessage).toBe('Test error'); + + act(() => { + vi.advanceTimersByTime(3000); + }); + rerender(getAppContainer()); + expect(capturedUIState.queueErrorMessage).toBeNull(); + unmount(); + }); + + it('should reset the timer if a new error message is set', async () => { + const { rerender, unmount } = await act(async () => renderAppContainer()); + await act(async () => { + vi.advanceTimersByTime(0); + }); + + act(() => { + capturedUIActions.setQueueErrorMessage('First error'); + }); + rerender(getAppContainer()); + expect(capturedUIState.queueErrorMessage).toBe('First error'); + + act(() => { + vi.advanceTimersByTime(1500); + }); + + act(() => { + capturedUIActions.setQueueErrorMessage('Second error'); + }); + rerender(getAppContainer()); + expect(capturedUIState.queueErrorMessage).toBe('Second error'); + + act(() => { + vi.advanceTimersByTime(2000); + }); + rerender(getAppContainer()); + expect(capturedUIState.queueErrorMessage).toBe('Second error'); + + // 5. Advance time past the 3 second timeout from the second message + act(() => { + vi.advanceTimersByTime(1000); + }); + rerender(getAppContainer()); + expect(capturedUIState.queueErrorMessage).toBeNull(); + unmount(); + }); + }); + + describe('Keyboard Input Handling (CTRL+C / CTRL+D)', () => { + let mockHandleSlashCommand: Mock; + let mockCancelOngoingRequest: Mock; + let rerender: () => void; + let unmount: () => void; + let stdin: Awaited>['stdin']; + + // Helper function to reduce boilerplate in tests + const setupKeypressTest = async () => { + const renderResult = await act(async () => renderAppContainer()); + stdin = renderResult.stdin; + await act(async () => { + vi.advanceTimersByTime(0); + }); + + rerender = () => { + renderResult.rerender(getAppContainer()); + }; + unmount = renderResult.unmount; + }; + + const pressKey = (sequence: string, times = 1) => { + for (let i = 0; i < times; i++) { + act(() => { + stdin.write(sequence); + }); + rerender(); + } + }; + + beforeEach(() => { + // Mock slash command handler + mockHandleSlashCommand = vi.fn(); + mockedUseSlashCommandProcessor.mockReturnValue({ + handleSlashCommand: mockHandleSlashCommand, + slashCommands: [], + pendingHistoryItems: [], + commandContext: {}, + shellConfirmationRequest: null, + confirmationRequest: null, + }); + + // Mock request cancellation + mockCancelOngoingRequest = vi.fn(); + mockedUseGeminiStream.mockReturnValue({ + ...DEFAULT_GEMINI_STREAM_MOCK, + cancelOngoingRequest: mockCancelOngoingRequest, + }); + + // Default empty text buffer + mockedUseTextBuffer.mockReturnValue({ + text: '', + setText: vi.fn(), + lines: [''], + cursor: [0, 0], + handleInput: vi.fn().mockReturnValue(false), + }); + + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + describe('CTRL+C', () => { + it('should cancel ongoing request on first press', async () => { + mockedUseGeminiStream.mockReturnValue({ + ...DEFAULT_GEMINI_STREAM_MOCK, + streamingState: 'responding', + cancelOngoingRequest: mockCancelOngoingRequest, + }); + await setupKeypressTest(); + + pressKey('\x03'); // Ctrl+C + + expect(mockCancelOngoingRequest).toHaveBeenCalledTimes(1); + expect(mockHandleSlashCommand).not.toHaveBeenCalled(); + unmount(); + }); + + it('should quit on second press', async () => { + await setupKeypressTest(); + + pressKey('\x03', 2); // Ctrl+C + + expect(mockCancelOngoingRequest).toHaveBeenCalledTimes(2); + expect(mockHandleSlashCommand).toHaveBeenCalledWith( + '/quit', + undefined, + undefined, + false, + ); + unmount(); + }); + + it('should reset press count after a timeout', async () => { + await setupKeypressTest(); + + pressKey('\x03'); // Ctrl+C + expect(mockHandleSlashCommand).not.toHaveBeenCalled(); + + // Advance timer past the reset threshold + act(() => { + vi.advanceTimersByTime(WARNING_PROMPT_DURATION_MS + 1); + }); + + pressKey('\x03'); // Ctrl+C + expect(mockHandleSlashCommand).not.toHaveBeenCalled(); + unmount(); + }); + }); + + describe('CTRL+D', () => { + it('should quit on second press if buffer is empty', async () => { + await setupKeypressTest(); + + pressKey('\x04', 2); // Ctrl+D + + expect(mockHandleSlashCommand).toHaveBeenCalledWith( + '/quit', + undefined, + undefined, + false, + ); + unmount(); + }); + + it('should NOT quit if buffer is not empty', async () => { + mockedUseTextBuffer.mockReturnValue({ + text: 'some text', + setText: vi.fn(), + lines: ['some text'], + cursor: [0, 9], // At the end + handleInput: vi.fn().mockReturnValue(false), + }); + await setupKeypressTest(); + + pressKey('\x04'); // Ctrl+D + + // Should only be called once, so count is 1, not quitting yet. + expect(mockHandleSlashCommand).not.toHaveBeenCalled(); + + pressKey('\x04'); // Ctrl+D + // It should still not quit because buffer is non-empty. + expect(mockHandleSlashCommand).not.toHaveBeenCalled(); + unmount(); + }); + + it('should reset press count after a timeout', async () => { + await setupKeypressTest(); + + pressKey('\x04'); // Ctrl+D + expect(mockHandleSlashCommand).not.toHaveBeenCalled(); + + // Advance timer past the reset threshold + act(() => { + vi.advanceTimersByTime(WARNING_PROMPT_DURATION_MS + 1); + }); + + pressKey('\x04'); // Ctrl+D + expect(mockHandleSlashCommand).not.toHaveBeenCalled(); + unmount(); + }); + }); + + describe('CTRL+Z', () => { + it('should call handleSuspend', async () => { + const handleSuspend = vi.fn(); + mockedUseSuspend.mockReturnValue({ handleSuspend }); + await setupKeypressTest(); + + pressKey('\x1A'); // Ctrl+Z + + expect(handleSuspend).toHaveBeenCalledTimes(1); + unmount(); + }); + }); + + describe('Focus Handling (Tab / Shift+Tab)', () => { + beforeEach(() => { + // Mock activePtyId to enable focus + mockedUseGeminiStream.mockReturnValue({ + ...DEFAULT_GEMINI_STREAM_MOCK, + activePtyId: 1, + }); + }); + + it('should focus shell input on Tab', async () => { + await setupKeypressTest(); + + pressKey('\t'); + + expect(capturedUIState.embeddedShellFocused).toBe(true); + unmount(); + }); + + it('should unfocus shell input on Shift+Tab', async () => { + await setupKeypressTest(); + + // Focus first + pressKey('\t'); + expect(capturedUIState.embeddedShellFocused).toBe(true); + + // Unfocus via Shift+Tab + pressKey('\x1b[Z'); + expect(capturedUIState.embeddedShellFocused).toBe(false); + unmount(); + }); + + it('should auto-unfocus when activePtyId becomes null', async () => { + // Start with active pty and focused + mockedUseGeminiStream.mockReturnValue({ + ...DEFAULT_GEMINI_STREAM_MOCK, + activePtyId: 1, + }); + + const renderResult = await act(async () => render(getAppContainer())); + await act(async () => { + vi.advanceTimersByTime(0); + }); + + // Focus it + act(() => { + renderResult.stdin.write('\t'); + }); + expect(capturedUIState.embeddedShellFocused).toBe(true); + + // Now mock activePtyId becoming null + mockedUseGeminiStream.mockReturnValue({ + ...DEFAULT_GEMINI_STREAM_MOCK, + activePtyId: null, + }); + + // Rerender to trigger useEffect + await act(async () => { + renderResult.rerender(getAppContainer()); + }); + + expect(capturedUIState.embeddedShellFocused).toBe(false); + renderResult.unmount(); + }); + + it('should focus background shell on Tab when already visible (not toggle it off)', async () => { + const mockToggleBackgroundTask = vi.fn(); + mockedUseGeminiStream.mockReturnValue({ + ...DEFAULT_GEMINI_STREAM_MOCK, + activePtyId: null, + isBackgroundTaskVisible: true, + backgroundTasks: new Map([[123, { pid: 123, status: 'running' }]]), + toggleBackgroundTasks: mockToggleBackgroundTask, + }); + + await setupKeypressTest(); + + // Initially not focused + expect(capturedUIState.embeddedShellFocused).toBe(false); + + // Press Tab + pressKey('\t'); + + // Should be focused + expect(capturedUIState.embeddedShellFocused).toBe(true); + // Should NOT have toggled (closed) the shell + expect(mockToggleBackgroundTask).not.toHaveBeenCalled(); + + unmount(); + }); + }); + + describe('Background Shell Toggling (CTRL+B)', () => { + it('should toggle background shell on Ctrl+B even if visible but not focused', async () => { + const mockToggleBackgroundTask = vi.fn(); + mockedUseGeminiStream.mockReturnValue({ + ...DEFAULT_GEMINI_STREAM_MOCK, + activePtyId: null, + isBackgroundTaskVisible: true, + backgroundTasks: new Map([[123, { pid: 123, status: 'running' }]]), + toggleBackgroundTasks: mockToggleBackgroundTask, + }); + + await setupKeypressTest(); + + // Initially not focused, but visible + expect(capturedUIState.embeddedShellFocused).toBe(false); + + // Press Ctrl+B + pressKey('\x02'); + + // Should have toggled (closed) the shell + expect(mockToggleBackgroundTask).toHaveBeenCalled(); + // Should be unfocused + expect(capturedUIState.embeddedShellFocused).toBe(false); + + unmount(); + }); + + it('should show and focus background shell on Ctrl+B if hidden', async () => { + const mockToggleBackgroundTask = vi.fn(); + const geminiStreamMock = { + ...DEFAULT_GEMINI_STREAM_MOCK, + activePtyId: null, + isBackgroundTaskVisible: false, + backgroundTasks: new Map([[123, { pid: 123, status: 'running' }]]), + toggleBackgroundTasks: mockToggleBackgroundTask, + }; + mockedUseGeminiStream.mockReturnValue(geminiStreamMock); + + await setupKeypressTest(); + + // Update the mock state when toggled to simulate real behavior + mockToggleBackgroundTask.mockImplementation(() => { + geminiStreamMock.isBackgroundTaskVisible = true; + }); + + // Press Ctrl+B + pressKey('\x02'); + + // Should have toggled (shown) the shell + expect(mockToggleBackgroundTask).toHaveBeenCalled(); + // Should be focused + expect(capturedUIState.embeddedShellFocused).toBe(true); + + unmount(); + }); + }); + }); + + describe('Shortcuts Help Visibility', () => { + let handleGlobalKeypress: (key: Key) => boolean; + let mockedUseKeypress: Mock; + let rerender: () => void; + let unmount: () => void; + + const setupShortcutsVisibilityTest = async () => { + const renderResult = await act(async () => renderAppContainer()); + await act(async () => { + vi.advanceTimersByTime(0); + }); + rerender = () => renderResult.rerender(getAppContainer()); + unmount = renderResult.unmount; + }; + + const pressKey = (key: Partial) => { + act(() => { + handleGlobalKeypress({ + name: 'r', + shift: false, + alt: false, + ctrl: false, + cmd: false, + insertable: false, + sequence: '', + ...key, + } as Key); + }); + rerender(); + }; + + beforeEach(() => { + mockedUseKeypress = vi.spyOn(useKeypressModule, 'useKeypress') as Mock; + mockedUseKeypress.mockImplementation( + (callback: (key: Key) => boolean, options: { isActive: boolean }) => { + // AppContainer registers multiple keypress handlers; capture only + // active handlers so inactive copy-mode handler doesn't override. + if (options?.isActive) { + handleGlobalKeypress = callback; + } + }, + ); + vi.useFakeTimers(); + }); + + afterEach(() => { + mockedUseKeypress.mockRestore(); + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('dismisses shortcuts help when a registered hotkey is pressed', async () => { + await setupShortcutsVisibilityTest(); + + act(() => { + capturedUIActions.setShortcutsHelpVisible(true); + }); + rerender(); + expect(capturedUIState.shortcutsHelpVisible).toBe(true); + + pressKey({ name: 'r', ctrl: true, sequence: '\x12' }); // Ctrl+R + expect(capturedUIState.shortcutsHelpVisible).toBe(false); + + unmount(); + }); + + it('dismisses shortcuts help when streaming starts', async () => { + await setupShortcutsVisibilityTest(); + + act(() => { + capturedUIActions.setShortcutsHelpVisible(true); + }); + rerender(); + expect(capturedUIState.shortcutsHelpVisible).toBe(true); + + mockedUseGeminiStream.mockReturnValue({ + ...DEFAULT_GEMINI_STREAM_MOCK, + streamingState: 'responding', + }); + + await act(async () => { + rerender(); + }); + expect(capturedUIState.shortcutsHelpVisible).toBe(false); + + unmount(); + }); + + it('dismisses shortcuts help when action-required confirmation appears', async () => { + await setupShortcutsVisibilityTest(); + + act(() => { + capturedUIActions.setShortcutsHelpVisible(true); + }); + rerender(); + expect(capturedUIState.shortcutsHelpVisible).toBe(true); + + mockedUseSlashCommandProcessor.mockReturnValue({ + handleSlashCommand: vi.fn(), + slashCommands: [], + pendingHistoryItems: [], + commandContext: {}, + shellConfirmationRequest: null, + confirmationRequest: { + prompt: 'Confirm this action?', + onConfirm: vi.fn(), + }, + }); + + await act(async () => { + rerender(); + }); + expect(capturedUIState.shortcutsHelpVisible).toBe(false); + + unmount(); + }); + }); + + describe('Copy Mode (F9)', () => { + let rerender: () => void; + let unmount: () => void; + let stdin: Awaited>['stdin']; + + const setupCopyModeTest = async ( + isAlternateMode = false, + childHandler?: Mock, + ) => { + vi.spyOn(mockConfig, 'getUseTerminalBuffer').mockReturnValue(false); + + vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue( + isAlternateMode, + ); + + // Update settings for this test run + const testSettings = createMockSettings({ + ui: { useAlternateBuffer: isAlternateMode }, + }); + + function TestChild() { + useKeypress(childHandler || (() => {}), { + isActive: !!childHandler, + priority: true, + }); + return null; + } + + const getTree = (settings: LoadedSettings) => ( + + + + + + + + + ); + + const renderResult = await act(async () => render(getTree(testSettings))); + stdin = renderResult.stdin; + await act(async () => { + vi.advanceTimersByTime(0); + }); + + rerender = () => renderResult.rerender(getTree(testSettings)); + unmount = renderResult.unmount; + }; + + beforeEach(() => { + mocks.mockStdout.write.mockClear(); + (disableMouseEvents as import('vitest').Mock).mockClear(); + + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + describe.each([ + { + isAlternateMode: false, + shouldEnable: false, + modeName: 'Normal Mode', + }, + { + isAlternateMode: true, + shouldEnable: true, + modeName: 'Alternate Buffer Mode', + }, + ])('$modeName', ({ isAlternateMode, shouldEnable }) => { + it(`should ${shouldEnable ? 'toggle' : 'NOT toggle'} mouse off when F9 is pressed`, async () => { + await setupCopyModeTest(isAlternateMode); + mocks.mockStdout.write.mockClear(); // Clear initial enable call + (disableMouseEvents as import('vitest').Mock).mockClear(); + + act(() => { + stdin.write('\x1b[20~'); // F9 + }); + rerender(); + + if (shouldEnable) { + expect(disableMouseEvents).toHaveBeenCalled(); + } else { + expect(disableMouseEvents).not.toHaveBeenCalled(); + } + unmount(); + }); + + if (shouldEnable) { + it('should toggle mouse back on when F9 is pressed again', async () => { + await setupCopyModeTest(isAlternateMode); + (writeToStdout as Mock).mockClear(); + + // Turn it on (disable mouse) + act(() => { + stdin.write('\x1b[20~'); // F9 + }); + rerender(); + expect(disableMouseEvents).toHaveBeenCalled(); + + // Turn it off (enable mouse) + act(() => { + stdin.write('a'); // Any key should exit copy mode + }); + rerender(); + + expect(enableMouseEvents).toHaveBeenCalled(); + unmount(); + }); + + it('should exit copy mode on non-scroll key press', async () => { + await setupCopyModeTest(isAlternateMode); + + // Enter copy mode + act(() => { + stdin.write('\x1b[20~'); // F9 + }); + rerender(); + + (writeToStdout as Mock).mockClear(); + + // Press any other key + act(() => { + stdin.write('a'); + }); + rerender(); + + // Should have re-enabled mouse + expect(enableMouseEvents).toHaveBeenCalled(); + unmount(); + }); + + it('should not exit copy mode on PageDown and should pass it through', async () => { + const childHandler = vi.fn().mockReturnValue(false); + await setupCopyModeTest(true, childHandler); + + // Enter copy mode + act(() => { + stdin.write('\x13'); // Ctrl+S + }); + rerender(); + expect(disableMouseEvents).toHaveBeenCalled(); + + childHandler.mockClear(); + (enableMouseEvents as Mock).mockClear(); + + // PageDown should be passed through to lower-priority handlers. + act(() => { + stdin.write('\x1b[6~'); + }); + rerender(); + + expect(enableMouseEvents).not.toHaveBeenCalled(); + expect(childHandler).toHaveBeenCalled(); + expect(childHandler).toHaveBeenCalledWith( + expect.objectContaining({ name: 'pagedown' }), + ); + unmount(); + }); + + it('should not exit copy mode on Shift+Down and should pass it through', async () => { + const childHandler = vi.fn().mockReturnValue(false); + await setupCopyModeTest(true, childHandler); + + // Enter copy mode + act(() => { + stdin.write('\x13'); // Ctrl+S + }); + rerender(); + expect(disableMouseEvents).toHaveBeenCalled(); + + childHandler.mockClear(); + (enableMouseEvents as Mock).mockClear(); + + act(() => { + stdin.write('\x1b[1;2B'); // Shift+Down + }); + rerender(); + + expect(enableMouseEvents).not.toHaveBeenCalled(); + expect(childHandler).toHaveBeenCalled(); + expect(childHandler).toHaveBeenCalledWith( + expect.objectContaining({ name: 'down', shift: true }), + ); + unmount(); + }); + + it('should have higher priority than other priority listeners when enabled', async () => { + // 1. Initial state with a child component's priority listener (already subscribed) + // It should NOT handle Ctrl+S so we can enter copy mode. + const childHandler = vi.fn().mockReturnValue(false); + await setupCopyModeTest(true, childHandler); + + // 2. Enter copy mode + act(() => { + stdin.write('\x1b[20~'); // F9 + }); + rerender(); + + // 3. Verify we are in copy mode + expect(disableMouseEvents).toHaveBeenCalled(); + + // 4. Press any key + childHandler.mockClear(); + // Now childHandler should return true for other keys, simulating a greedy listener + childHandler.mockReturnValue(true); + + act(() => { + stdin.write('a'); + }); + rerender(); + + // 5. Verify that the exit handler took priority and childHandler was NOT called + expect(childHandler).not.toHaveBeenCalled(); + expect(enableMouseEvents).toHaveBeenCalled(); + unmount(); + }); + } + }); + }); + + describe('Model Dialog Integration', () => { + it('should provide isModelDialogOpen in the UIStateContext', async () => { + mockedUseModelCommand.mockReturnValue({ + isModelDialogOpen: true, + openModelDialog: vi.fn(), + closeModelDialog: vi.fn(), + }); + + const { unmount } = await act(async () => renderAppContainer()); + + expect(capturedUIState.isModelDialogOpen).toBe(true); + unmount(); + }); + + it('should provide model dialog actions in the UIActionsContext', async () => { + const mockCloseModelDialog = vi.fn(); + + mockedUseModelCommand.mockReturnValue({ + isModelDialogOpen: false, + openModelDialog: vi.fn(), + closeModelDialog: mockCloseModelDialog, + }); + + const { unmount } = await act(async () => renderAppContainer()); + + // Verify that the actions are correctly passed through context + act(() => { + capturedUIActions.closeModelDialog(); + }); + expect(mockCloseModelDialog).toHaveBeenCalled(); + unmount(); + }); + }); + + describe('Agent Configuration Dialog Integration', () => { + it('should initialize with dialog closed and no agent selected', async () => { + const { unmount } = await act(async () => renderAppContainer()); + expect(capturedUIState.isAgentConfigDialogOpen).toBe(false); + expect(capturedUIState.selectedAgentName).toBeUndefined(); + expect(capturedUIState.selectedAgentDisplayName).toBeUndefined(); + expect(capturedUIState.selectedAgentDefinition).toBeUndefined(); + unmount(); + }); + + it('should update state when openAgentConfigDialog is called', async () => { + const { unmount } = await act(async () => renderAppContainer()); + + const agentDefinition = { name: 'test-agent' }; + act(() => { + capturedUIActions.openAgentConfigDialog( + 'test-agent', + 'Test Agent', + agentDefinition as unknown as AgentDefinition, + ); + }); + + expect(capturedUIState.isAgentConfigDialogOpen).toBe(true); + expect(capturedUIState.selectedAgentName).toBe('test-agent'); + expect(capturedUIState.selectedAgentDisplayName).toBe('Test Agent'); + expect(capturedUIState.selectedAgentDefinition).toEqual(agentDefinition); + unmount(); + }); + + it('should clear state when closeAgentConfigDialog is called', async () => { + const { unmount } = await act(async () => renderAppContainer()); + + const agentDefinition = { name: 'test-agent' }; + act(() => { + capturedUIActions.openAgentConfigDialog( + 'test-agent', + 'Test Agent', + agentDefinition as unknown as AgentDefinition, + ); + }); + + expect(capturedUIState.isAgentConfigDialogOpen).toBe(true); + + act(() => { + capturedUIActions.closeAgentConfigDialog(); + }); + + expect(capturedUIState.isAgentConfigDialogOpen).toBe(false); + expect(capturedUIState.selectedAgentName).toBeUndefined(); + expect(capturedUIState.selectedAgentDisplayName).toBeUndefined(); + expect(capturedUIState.selectedAgentDefinition).toBeUndefined(); + unmount(); + }); + }); + + describe('CoreEvents Integration', () => { + it('subscribes to UserFeedback and drains backlog on mount', async () => { + const { unmount } = await act(async () => renderAppContainer()); + + expect(mockCoreEvents.on).toHaveBeenCalledWith( + CoreEvent.UserFeedback, + expect.any(Function), + ); + expect(mockCoreEvents.drainBacklogs).toHaveBeenCalledTimes(1); + unmount(); + }); + + it('unsubscribes from UserFeedback on unmount', async () => { + let unmount: () => void; + await act(async () => { + const result = await renderAppContainer(); + unmount = result.unmount; + }); + await waitFor(() => expect(capturedUIState).toBeTruthy()); + + unmount!(); + + expect(mockCoreEvents.off).toHaveBeenCalledWith( + CoreEvent.UserFeedback, + expect.any(Function), + ); + }); + + it('adds history item when UserFeedback event is received', async () => { + let unmount: () => void; + await act(async () => { + const result = await renderAppContainer(); + unmount = result.unmount; + }); + await waitFor(() => expect(capturedUIState).toBeTruthy()); + + // Get the registered handler + const handler = mockCoreEvents.on.mock.calls.find( + (call: unknown[]) => call[0] === CoreEvent.UserFeedback, + )?.[1]; + expect(handler).toBeDefined(); + + // Simulate an event + const payload: UserFeedbackPayload = { + severity: 'error', + message: 'Test error message', + }; + act(() => { + handler(payload); + }); + + expect(mockedUseHistory().addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + text: 'Test error message', + }), + expect.any(Number), + ); + unmount!(); + }); + + it('calls app.rerender() when ExternalEditorClosed event is received in terminalBuffer mode', async () => { + vi.spyOn(mockConfig, 'getUseTerminalBuffer').mockReturnValue(true); + vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue(false); + vi.spyOn(mockConfig, 'getScreenReader').mockReturnValue(false); + + mocks.mockRerender.mockClear(); + + let unmount: () => void; + await act(async () => { + const result = await renderAppContainer(); + unmount = result.unmount; + }); + await waitFor(() => expect(capturedUIState).toBeTruthy()); + + const handler = mockCoreEvents.on.mock.calls.find( + (call: unknown[]) => call[0] === CoreEvent.ExternalEditorClosed, + )?.[1]; + expect(handler).toBeDefined(); + + act(() => { + handler(); + }); + + expect(mocks.mockRerender).toHaveBeenCalledTimes(1); + unmount!(); + }); + + it('updates currentModel when ModelChanged event is received', async () => { + // Arrange: Mock initial model + vi.spyOn(mockConfig, 'getModel').mockReturnValue('initial-model'); + + let unmount: () => void; + await act(async () => { + const result = await renderAppContainer(); + unmount = result.unmount; + }); + await waitFor(() => { + expect(capturedUIState?.currentModel).toBe('initial-model'); + }); + + // Get the registered handler for ModelChanged + const handler = mockCoreEvents.on.mock.calls.find( + (call: unknown[]) => call[0] === CoreEvent.ModelChanged, + )?.[1]; + expect(handler).toBeDefined(); + + // Act: Simulate ModelChanged event + // Update config mock to return new model since the handler reads from config + vi.spyOn(mockConfig, 'getModel').mockReturnValue('new-model'); + act(() => { + handler({ model: 'new-model' }); + }); + + // Assert: Verify model is updated + await waitFor(() => { + expect(capturedUIState.currentModel).toBe('new-model'); + }); + unmount!(); + }); + + it('provides activeHooks from useHookDisplayState', async () => { + const mockHooks = [{ name: 'hook1', eventName: 'event1' }]; + mockedUseHookDisplayState.mockReturnValue(mockHooks); + + let unmount: () => void; + await act(async () => { + const result = await renderAppContainer(); + unmount = result.unmount; + }); + await waitFor(() => expect(capturedUIState).toBeTruthy()); + + expect(capturedUIState.activeHooks).toEqual(mockHooks); + unmount!(); + }); + + it('handles consent request events', async () => { + let unmount: () => void; + await act(async () => { + const result = await renderAppContainer(); + unmount = result.unmount; + }); + await waitFor(() => expect(capturedUIState).toBeTruthy()); + + const handler = mockCoreEvents.on.mock.calls.find( + (call: unknown[]) => call[0] === CoreEvent.ConsentRequest, + )?.[1]; + expect(handler).toBeDefined(); + + const onConfirm = vi.fn(); + const payload = { + prompt: 'Do you consent?', + onConfirm, + }; + + act(() => { + handler(payload); + }); + + expect(capturedUIState.authConsentRequest).toBeDefined(); + expect(capturedUIState.authConsentRequest?.prompt).toBe( + 'Do you consent?', + ); + + act(() => { + capturedUIState.authConsentRequest?.onConfirm(true); + }); + + expect(onConfirm).toHaveBeenCalledWith(true); + expect(capturedUIState.authConsentRequest).toBeNull(); + unmount!(); + }); + + it('unsubscribes from ConsentRequest on unmount', async () => { + let unmount: () => void; + await act(async () => { + const result = await renderAppContainer(); + unmount = result.unmount; + }); + await waitFor(() => expect(capturedUIState).toBeTruthy()); + + unmount!(); + + expect(mockCoreEvents.off).toHaveBeenCalledWith( + CoreEvent.ConsentRequest, + expect.any(Function), + ); + }); + }); + + describe('Banner Text', () => { + it('should render placeholder banner text for USE_GEMINI auth type', async () => { + const config = makeFakeConfig(); + vi.spyOn(config, 'getContentGeneratorConfig').mockReturnValue({ + authType: AuthType.USE_GEMINI, + apiKey: 'fake-key', + }); + let unmount: () => void; + await act(async () => { + const result = await renderAppContainer(); + unmount = result.unmount; + }); + await waitFor(() => { + expect(capturedUIState.bannerData.defaultText).toBeDefined(); + unmount!(); + }); + }); + }); + + describe('onCancelSubmit Behavior', () => { + let mockSetText: Mock; + + // Helper to extract arguments from the useGeminiStream hook call + // This isolates the positional argument dependency to a single location + const extractUseGeminiStreamArgs = (args: unknown[]) => ({ + onCancelSubmit: args[13] as (shouldRestorePrompt?: boolean) => void, + }); + + beforeEach(() => { + mockSetText = vi.fn(); + mockedUseTextBuffer.mockReturnValue({ + text: '', + setText: mockSetText, + }); + }); + + it('preserves buffer when cancelling, even if empty (user is in control)', async () => { + const { unmount } = await act(async () => renderAppContainer()); + + const { onCancelSubmit } = extractUseGeminiStreamArgs( + mockedUseGeminiStream.mock.lastCall!, + ); + + act(() => { + onCancelSubmit(false); + }); + + // Should NOT modify buffer when cancelling - user is in control + expect(mockSetText).not.toHaveBeenCalled(); + + unmount(); + }); + + it('preserves prompt text when cancelling streaming, even if same as last message (regression test for issue #13387)', async () => { + // Mock buffer with text that user typed while streaming (same as last message) + const promptText = 'What is Python?'; + mockedUseTextBuffer.mockReturnValue({ + text: promptText, + setText: mockSetText, + }); + + // Mock input history with same message + mockedUseInputHistoryStore.mockReturnValue({ + inputHistory: [promptText], + addInput: vi.fn(), + initializeFromLogger: vi.fn(), + }); + + const { unmount } = await act(async () => renderAppContainer()); + + const { onCancelSubmit } = extractUseGeminiStreamArgs( + mockedUseGeminiStream.mock.lastCall!, + ); + + act(() => { + // Simulate Escape key cancelling streaming (shouldRestorePrompt=false) + onCancelSubmit(false); + }); + + // Should NOT call setText - prompt should be preserved regardless of content + expect(mockSetText).not.toHaveBeenCalled(); + + unmount(); + }); + + it('restores the prompt when onCancelSubmit is called with shouldRestorePrompt=true (or undefined)', async () => { + // Mock useInputHistoryStore to provide input history + mockedUseInputHistoryStore.mockReturnValue({ + inputHistory: ['previous message'], + addInput: vi.fn(), + initializeFromLogger: vi.fn(), + }); + + const { unmount } = await act(async () => renderAppContainer()); + expect(capturedInputState.userMessages).toContain('previous message'); + + const { onCancelSubmit } = extractUseGeminiStreamArgs( + mockedUseGeminiStream.mock.lastCall!, + ); + + await act(async () => { + onCancelSubmit(true); + }); + + expect(mockSetText).toHaveBeenCalledWith('previous message'); + + unmount(); + }); + + it('input history is independent from conversation history (survives /clear)', async () => { + // This test verifies that input history (used for up-arrow navigation) is maintained + // separately from conversation history and survives /clear operations. + const mockAddInput = vi.fn(); + mockedUseInputHistoryStore.mockReturnValue({ + inputHistory: ['first prompt', 'second prompt'], + addInput: mockAddInput, + initializeFromLogger: vi.fn(), + }); + + const { rerender, unmount } = await act(async () => renderAppContainer()); + + // Verify userMessages is populated from inputHistory + expect(capturedInputState.userMessages).toContain('first prompt'); + expect(capturedInputState.userMessages).toContain('second prompt'); + + // Clear the conversation history (simulating /clear command) + const mockClearItems = vi.fn(); + mockedUseHistory.mockReturnValue({ + history: [], + addItem: vi.fn(), + updateItem: vi.fn(), + clearItems: mockClearItems, + loadHistory: vi.fn(), + }); + + await act(async () => { + // Rerender to apply the new mock. + rerender(getAppContainer()); + }); + + // Verify that userMessages still contains the input history + // (it should not be affected by clearing conversation history) + expect(capturedInputState.userMessages).toContain('first prompt'); + expect(capturedInputState.userMessages).toContain('second prompt'); + + unmount(); + }); + }); + + describe('Regression Tests', () => { + it('does not refresh static on startup if banner text is empty', async () => { + // Mock banner text to be empty strings + vi.spyOn(mockConfig, 'getBannerTextNoCapacityIssues').mockResolvedValue( + '', + ); + vi.spyOn(mockConfig, 'getBannerTextCapacityIssues').mockResolvedValue(''); + + // Clear previous calls + mocks.mockStdout.write.mockClear(); + (disableMouseEvents as import('vitest').Mock).mockClear(); + + const { unmount } = await act(async () => renderAppContainer()); + + // Allow async effects to run + expect(capturedUIState).toBeTruthy(); + + // Wait for fetchBannerTexts to complete + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + + // Check that clearTerminal was NOT written to stdout + const clearTerminalCalls = mocks.mockStdout.write.mock.calls.filter( + (call: unknown[]) => call[0] === ansiEscapes.clearTerminal, + ); + + expect(clearTerminalCalls).toHaveLength(0); + unmount(); + }); + }); + + describe('Submission Handling', () => { + it('resets expansion state on submission when not in alternate buffer', async () => { + const { checkPermissions } = await import( + './hooks/atCommandProcessor.js' + ); + vi.mocked(checkPermissions).mockResolvedValue([]); + + const { unmount } = await act(async () => + renderAppContainer({ + settings: createMockSettings({ ui: { useAlternateBuffer: false } }), + }), + ); + + expect(capturedUIActions).toBeTruthy(); + + // Expand first + act(() => capturedUIActions.setConstrainHeight(false)); + expect(capturedUIState.constrainHeight).toBe(false); + + // Reset mock stdout to clear any initial writes + mocks.mockStdout.write.mockClear(); + (disableMouseEvents as import('vitest').Mock).mockClear(); + + // Submit + await act(async () => capturedUIActions.handleFinalSubmit('test prompt')); + + // Should be reset + expect(capturedUIState.constrainHeight).toBe(true); + unmount(); + }); + + it('resets expansion state on submission when in alternate buffer without clearing terminal', async () => { + const { checkPermissions } = await import( + './hooks/atCommandProcessor.js' + ); + vi.mocked(checkPermissions).mockResolvedValue([]); + + vi.spyOn(mockConfig, 'getUseTerminalBuffer').mockReturnValue(false); + + vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue(true); + + const { unmount } = await act(async () => + renderAppContainer({ + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), + }), + ); + + expect(capturedUIActions).toBeTruthy(); + + // Expand first + act(() => capturedUIActions.setConstrainHeight(false)); + expect(capturedUIState.constrainHeight).toBe(false); + + // Reset mock stdout + mocks.mockStdout.write.mockClear(); + (disableMouseEvents as import('vitest').Mock).mockClear(); + + // Submit + await act(async () => capturedUIActions.handleFinalSubmit('test prompt')); + + // Should be reset + expect(capturedUIState.constrainHeight).toBe(true); + // Should NOT refresh static's clearTerminal in alternate buffer + expect(mocks.mockStdout.write).not.toHaveBeenCalledWith( + ansiEscapes.clearTerminal, + ); + unmount(); + }); + }); + + describe('Overflow Hint Handling', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should set showIsExpandableHint when overflow occurs in Standard Mode and hides after 10s', async () => { + const { unmount } = await act(async () => renderAppContainer()); + await waitFor(() => expect(capturedOverflowActions).toBeTruthy()); + + // Trigger overflow + act(() => { + capturedOverflowActions.addOverflowingId('test-id'); + }); + + await waitFor(() => { + // Should show hint because we are in Standard Mode (default settings) and have overflow + expect(capturedUIState.showIsExpandableHint).toBe(true); + }); + + // Advance just before the timeout + act(() => { + vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS - 100); + }); + expect(capturedUIState.showIsExpandableHint).toBe(true); + + // Advance to hit the timeout mark + act(() => { + vi.advanceTimersByTime(100); + }); + await waitFor(() => { + expect(capturedUIState.showIsExpandableHint).toBe(false); + }); + + unmount(); + }); + + it('resets the hint timer when a new component overflows (overflowingIdsSize increases)', async () => { + const { unmount } = await act(async () => renderAppContainer()); + await waitFor(() => expect(capturedOverflowActions).toBeTruthy()); + + // 1. Trigger first overflow + act(() => { + capturedOverflowActions.addOverflowingId('test-id-1'); + }); + + await waitFor(() => { + expect(capturedUIState.showIsExpandableHint).toBe(true); + }); + + // 2. Advance half the duration + act(() => { + vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2); + }); + expect(capturedUIState.showIsExpandableHint).toBe(true); + + // 3. Trigger second overflow (this should reset the timer) + act(() => { + capturedOverflowActions.addOverflowingId('test-id-2'); + }); + + // Advance by 1ms to allow the OverflowProvider's 0ms batching timeout to fire + // and flush the state update to AppContainer, triggering the reset. + act(() => { + vi.advanceTimersByTime(1); + }); + + await waitFor(() => { + expect(capturedUIState.showIsExpandableHint).toBe(true); + }); + + // 4. Advance enough that the ORIGINAL timer would have expired + // Subtracting 1ms since we advanced it above to flush the state. + act(() => { + vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2 + 100 - 1); + }); + // The hint should STILL be visible because the timer reset at step 3 + expect(capturedUIState.showIsExpandableHint).toBe(true); + + // 5. Advance to the end of the NEW timer + act(() => { + vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2 - 100); + }); + await waitFor(() => { + expect(capturedUIState.showIsExpandableHint).toBe(false); + }); + + unmount(); + }); + + it('toggles expansion state and resets the hint timer when Ctrl+O is pressed in Standard Mode', async () => { + const { stdin, unmount } = await act(async () => renderAppContainer()); + await waitFor(() => expect(capturedOverflowActions).toBeTruthy()); + + // Initial state is constrainHeight = true + expect(capturedUIState.constrainHeight).toBe(true); + + // Trigger overflow so the hint starts showing + act(() => { + capturedOverflowActions.addOverflowingId('test-id'); + }); + + await waitFor(() => { + expect(capturedUIState.showIsExpandableHint).toBe(true); + }); + + // Advance half the duration + act(() => { + vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2); + }); + expect(capturedUIState.showIsExpandableHint).toBe(true); + + // Simulate Ctrl+O + act(() => { + stdin.write('\x0f'); // \x0f is Ctrl+O + }); + + // constrainHeight should toggle + expect(capturedUIState.constrainHeight).toBe(false); + + // Advance enough that the original timer would have expired if it hadn't reset + act(() => { + vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2 + 1000); + }); + + // We expect it to still be true because Ctrl+O should have reset the timer + expect(capturedUIState.showIsExpandableHint).toBe(true); + + // Advance remaining time to reach the new timeout + act(() => { + vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2 - 1000); + }); + + await waitFor(() => { + expect(capturedUIState.showIsExpandableHint).toBe(false); + }); + + unmount(); + }); + + it('toggles Ctrl+O multiple times and verifies the hint disappears exactly after the last toggle', async () => { + const { stdin, unmount } = await act(async () => renderAppContainer()); + await waitFor(() => expect(capturedOverflowActions).toBeTruthy()); + + // Initial state is constrainHeight = true + expect(capturedUIState.constrainHeight).toBe(true); + + // Trigger overflow so the hint starts showing + act(() => { + capturedOverflowActions.addOverflowingId('test-id'); + }); + + await waitFor(() => { + expect(capturedUIState.showIsExpandableHint).toBe(true); + }); + + // Advance half the duration + act(() => { + vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2); + }); + expect(capturedUIState.showIsExpandableHint).toBe(true); + + // First toggle 'on' (expanded) + act(() => { + stdin.write('\x0f'); // Ctrl+O + }); + expect(capturedUIState.constrainHeight).toBe(false); + + // Wait 1 second + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(capturedUIState.showIsExpandableHint).toBe(true); + + // Second toggle 'off' (collapsed) + act(() => { + stdin.write('\x0f'); // Ctrl+O + }); + expect(capturedUIState.constrainHeight).toBe(true); + + // Wait 1 second + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(capturedUIState.showIsExpandableHint).toBe(true); + + // Third toggle 'on' (expanded) + act(() => { + stdin.write('\x0f'); // Ctrl+O + }); + expect(capturedUIState.constrainHeight).toBe(false); + + // Now we wait just before the timeout from the LAST toggle. + // It should still be true. + act(() => { + vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS - 100); + }); + expect(capturedUIState.showIsExpandableHint).toBe(true); + + // Wait 0.1s more to hit exactly the timeout since the last toggle. + // It should hide now. + act(() => { + vi.advanceTimersByTime(100); + }); + await waitFor(() => { + expect(capturedUIState.showIsExpandableHint).toBe(false); + }); + + unmount(); + }); + + it('DOES set showIsExpandableHint when overflow occurs in Alternate Buffer Mode', async () => { + const settingsWithAlternateBuffer = createMockSettings({ + ui: { useAlternateBuffer: true }, + }); + + vi.spyOn(mockConfig, 'getUseTerminalBuffer').mockReturnValue(false); + + vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue(true); + + const { unmount } = await act(async () => + renderAppContainer({ + settings: settingsWithAlternateBuffer, + }), + ); + await waitFor(() => expect(capturedOverflowActions).toBeTruthy()); + + // Trigger overflow + act(() => { + capturedOverflowActions.addOverflowingId('test-id'); + }); + + // Should NOW show hint because we are in Alternate Buffer Mode + await waitFor(() => { + expect(capturedUIState.showIsExpandableHint).toBe(true); + }); + + unmount(); + }); + }); + + describe('Permission Handling', () => { + it('shows permission dialog when checkPermissions returns paths', async () => { + const { checkPermissions } = await import( + './hooks/atCommandProcessor.js' + ); + vi.mocked(checkPermissions).mockResolvedValue(['/test/file.txt']); + + const { unmount } = await act(async () => renderAppContainer()); + + expect(capturedUIActions).toBeTruthy(); + + await act(async () => + capturedUIActions.handleFinalSubmit('read @file.txt'), + ); + + expect(capturedUIState.permissionConfirmationRequest).not.toBeNull(); + expect(capturedUIState.permissionConfirmationRequest?.files).toEqual([ + '/test/file.txt', + ]); + unmount(); + }); + + it.each([true, false])( + 'handles permissions when allowed is %s', + async (allowed) => { + const { checkPermissions } = await import( + './hooks/atCommandProcessor.js' + ); + vi.mocked(checkPermissions).mockResolvedValue(['/test/file.txt']); + const addReadOnlyPathSpy = vi.spyOn( + mockConfig.getWorkspaceContext(), + 'addReadOnlyPath', + ); + const { submitQuery } = mockedUseGeminiStream(); + + const { unmount } = await act(async () => renderAppContainer()); + + expect(capturedUIActions).toBeTruthy(); + + await act(async () => + capturedUIActions.handleFinalSubmit('read @file.txt'), + ); + + await act(async () => + capturedUIState.permissionConfirmationRequest?.onComplete({ + allowed, + }), + ); + + if (allowed) { + expect(addReadOnlyPathSpy).toHaveBeenCalledWith('/test/file.txt'); + } else { + expect(addReadOnlyPathSpy).not.toHaveBeenCalled(); + } + expect(submitQuery).toHaveBeenCalledWith('read @file.txt'); + expect(capturedUIState.permissionConfirmationRequest).toBeNull(); + unmount(); + }, + ); + }); + + describe('Plan Mode Availability', () => { + it('should allow plan mode when enabled and idle', async () => { + vi.spyOn(mockConfig, 'isPlanEnabled').mockReturnValue(true); + mockedUseGeminiStream.mockReturnValue({ + ...DEFAULT_GEMINI_STREAM_MOCK, + pendingHistoryItems: [], + }); + + const { unmount } = await act(async () => renderAppContainer()); + + expect(capturedUIState).toBeTruthy(); + expect(capturedUIState.allowPlanMode).toBe(true); + unmount(); + }); + + it('should NOT allow plan mode when disabled in config', async () => { + vi.spyOn(mockConfig, 'isPlanEnabled').mockReturnValue(false); + mockedUseGeminiStream.mockReturnValue({ + ...DEFAULT_GEMINI_STREAM_MOCK, + pendingHistoryItems: [], + }); + + const { unmount } = await act(async () => renderAppContainer()); + + expect(capturedUIState).toBeTruthy(); + expect(capturedUIState.allowPlanMode).toBe(false); + unmount(); + }); + + it('should NOT allow plan mode when streaming', async () => { + vi.spyOn(mockConfig, 'isPlanEnabled').mockReturnValue(true); + mockedUseGeminiStream.mockReturnValue({ + ...DEFAULT_GEMINI_STREAM_MOCK, + streamingState: StreamingState.Responding, + pendingHistoryItems: [], + }); + + const { unmount } = await act(async () => renderAppContainer()); + + expect(capturedUIState).toBeTruthy(); + expect(capturedUIState.allowPlanMode).toBe(false); + unmount(); + }); + + it('should NOT allow plan mode when a tool is awaiting confirmation', async () => { + vi.spyOn(mockConfig, 'isPlanEnabled').mockReturnValue(true); + mockedUseGeminiStream.mockReturnValue({ + ...DEFAULT_GEMINI_STREAM_MOCK, + streamingState: StreamingState.Idle, + pendingHistoryItems: [ + { + type: 'tool_group', + tools: [ + { + name: 'test_tool', + status: CoreToolCallStatus.AwaitingApproval, + }, + ], + }, + ], + }); + + const { unmount } = await act(async () => renderAppContainer()); + + expect(capturedUIState).toBeTruthy(); + expect(capturedUIState.allowPlanMode).toBe(false); + unmount(); + }); + }); + + describe('Compression Queuing', () => { + beforeEach(async () => { + const { checkPermissions } = await import( + './hooks/atCommandProcessor.js' + ); + vi.mocked(checkPermissions).mockResolvedValue([]); + + vi.spyOn(mockConfig, 'isModelSteeringEnabled').mockReturnValue(true); + + const actual = await vi.importActual('./hooks/useMessageQueue.js'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { useMessageQueue: realUseMessageQueue } = actual as any; + mockedUseMessageQueue.mockImplementation(realUseMessageQueue); + + // Start compression by mocking pendingHistoryItems to include a pending compression + mockedUseGeminiStream.mockImplementation(() => ({ + ...DEFAULT_GEMINI_STREAM_MOCK, + pendingHistoryItems: [ + { + type: MessageType.COMPRESSION, + compression: { + isPending: true, + originalTokenCount: null, + newTokenCount: null, + compressionStatus: null, + }, + }, + ], + })); + }); + + it('queues messages during compression instead of handling as steering hints', async () => { + const { unmount } = await act(async () => renderAppContainer()); + + // Verify state isolation + expect(capturedUIState.streamingState).toBe(StreamingState.Idle); + + // Submit a message + await act(async () => + capturedUIActions.handleFinalSubmit('follow up message'), + ); + + // Verify it was queued, not submitted as steering hint + expect(capturedUIState.messageQueue).toContain('follow up message'); + + unmount(); + }); + + it('executes slash commands immediately during compression', async () => { + const { unmount } = await act(async () => renderAppContainer()); + + // Submit a slash command + await act(async () => capturedUIActions.handleFinalSubmit('/help')); + + // Verify it was NOT queued + expect(capturedUIState.messageQueue).not.toContain('/help'); + + unmount(); + }); + }); +}); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx new file mode 100644 index 0000000000000000000000000000000000000000..801e230c8aaf4a348cb533fd0255c9ef23b05a32 --- /dev/null +++ b/packages/cli/src/ui/AppContainer.tsx @@ -0,0 +1,2869 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + useMemo, + useState, + useCallback, + useEffect, + useRef, + useLayoutEffect, + useContext, +} from 'react'; +import { + type DOMElement, + ResizeObserver, + useApp, + useStdout, + useStdin, + type AppProps, + AppContext as InkAppContext, +} from 'ink'; +import { App } from './App.js'; +import { AppContext } from './contexts/AppContext.js'; +import { UIStateContext, type UIState } from './contexts/UIStateContext.js'; +import { QuotaContext } from './contexts/QuotaContext.js'; +import { + UIActionsContext, + type UIActions, +} from './contexts/UIActionsContext.js'; +import { ConfigContext } from './contexts/ConfigContext.js'; +import { + type HistoryItem, + AuthState, + type ConfirmationRequest, + type PermissionConfirmationRequest, + type QuotaStats, + MessageType, + StreamingState, + type HistoryItemInfo, +} from './types.js'; +import { checkPermissions } from './hooks/atCommandProcessor.js'; +import { ToolActionsProvider } from './contexts/ToolActionsContext.js'; +import { MouseProvider } from './contexts/MouseContext.js'; +import { ScrollProvider } from './contexts/ScrollProvider.js'; +import { + type StartupWarning, + type Config, + type IdeInfo, + type IdeContext, + type UserTierId, + type GeminiUserTier, + type UserFeedbackPayload, + type HookSystemMessagePayload, + type AgentDefinition, + type ApprovalMode, + IdeClient, + ideContextStore, + getErrorMessage, + getAllGeminiMdFilenames, + AuthType, + clearCachedCredentialFile, + type ResumedSessionData, + recordExitFail, + ShellExecutionService, + saveApiKey, + debugLogger, + isValidEditorType, + coreEvents, + CoreEvent, + flattenMemory, + type MemoryChangedPayload, + writeToStdout, + disableMouseEvents, + enterAlternateScreen, + enableMouseEvents, + disableLineWrapping, + shouldEnterAlternateScreen, + startupProfiler, + SessionStartSource, + SessionEndReason, + generateSummary, + type ConsentRequestPayload, + type AgentsDiscoveredPayload, + ChangeAuthRequestedError, + ProjectIdRequiredError, + buildUserSteeringHintPrompt, + logBillingEvent, + ApiKeyUpdatedEvent, + LegacyAgentProtocol, + type InjectionSource, +} from '@google/gemini-cli-core'; +import { validateAuthMethod } from '../config/auth.js'; +import process from 'node:process'; +import { useHistory } from './hooks/useHistoryManager.js'; +import { useMemoryMonitor } from './hooks/useMemoryMonitor.js'; +import { useThemeCommand } from './hooks/useThemeCommand.js'; +import { useAuthCommand } from './auth/useAuth.js'; +import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js'; +import { useEditorSettings } from './hooks/useEditorSettings.js'; +import { useSettingsCommand } from './hooks/useSettingsCommand.js'; +import { useModelCommand } from './hooks/useModelCommand.js'; +import { useVoiceModelCommand } from './hooks/useVoiceModelCommand.js'; +import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js'; +import { useVimMode } from './contexts/VimModeContext.js'; +import { + useOverflowActions, + useOverflowState, +} from './contexts/OverflowContext.js'; +import { useErrorCount } from './hooks/useConsoleMessages.js'; +import { useTerminalSize } from './hooks/useTerminalSize.js'; +import { calculatePromptWidths } from './components/InputPrompt.js'; +import { calculateMainAreaWidth } from './utils/ui-sizing.js'; +import ansiEscapes from 'ansi-escapes'; +import { basename } from 'node:path'; +import { computeTerminalTitle } from '../utils/windowTitle.js'; +import { useTextBuffer } from './components/shared/text-buffer.js'; +import { useLogger } from './hooks/useLogger.js'; +import { useGeminiStream } from './hooks/useGeminiStream.js'; +import { useAgentStream } from './hooks/useAgentStream.js'; +import { type BackgroundTask } from './hooks/useExecutionLifecycle.js'; +import { useVim } from './hooks/vim.js'; +import { type LoadableSettingScope, SettingScope } from '../config/settings.js'; +import { type InitializationResult } from '../core/initializer.js'; +import { startAutoMemoryIfEnabled } from '../utils/autoMemory.js'; +import { useFocus } from './hooks/useFocus.js'; +import { useKeypress, type Key } from './hooks/useKeypress.js'; +import { KeypressPriority } from './contexts/KeypressContext.js'; +import { Command } from './key/keyMatchers.js'; +import { useLoadingIndicator } from './hooks/useLoadingIndicator.js'; +import { useShellInactivityStatus } from './hooks/useShellInactivityStatus.js'; +import { useFolderTrust } from './hooks/useFolderTrust.js'; +import { useIdeTrustListener } from './hooks/useIdeTrustListener.js'; +import { type IdeIntegrationNudgeResult } from './IdeIntegrationNudge.js'; +import { appEvents, AppEvent, TransientMessageType } from '../utils/events.js'; +import { type UpdateObject } from './utils/updateCheck.js'; +import { setUpdateHandler } from '../utils/handleAutoUpdate.js'; +import { + registerCleanup, + removeCleanup, + runExitCleanup, +} from '../utils/cleanup.js'; +import { relaunchApp } from '../utils/processUtils.js'; +import type { SessionInfo } from '../utils/sessionUtils.js'; +import { useMessageQueue } from './hooks/useMessageQueue.js'; +import { useMcpStatus } from './hooks/useMcpStatus.js'; +import { useApprovalModeIndicator } from './hooks/useApprovalModeIndicator.js'; +import { useSessionStats } from './contexts/SessionContext.js'; +import { useGitBranchName } from './hooks/useGitBranchName.js'; +import { + useConfirmUpdateRequests, + useExtensionUpdates, +} from './hooks/useExtensionUpdates.js'; +import { ShellFocusContext } from './contexts/ShellFocusContext.js'; +import { type ExtensionManager } from '../config/extension-manager.js'; +import { requestConsentInteractive } from '../config/extensions/consent.js'; +import { useSessionBrowser } from './hooks/useSessionBrowser.js'; +import { useSessionResume } from './hooks/useSessionResume.js'; +import { useIncludeDirsTrust } from './hooks/useIncludeDirsTrust.js'; +import { isWorkspaceTrusted } from '../config/trustedFolders.js'; +import { useSettings } from './contexts/SettingsContext.js'; +import { terminalCapabilityManager } from './utils/terminalCapabilityManager.js'; +import { useInputHistoryStore } from './hooks/useInputHistoryStore.js'; +import { useBanner } from './hooks/useBanner.js'; +import { useTerminalSetupPrompt } from './utils/terminalSetup.js'; +import { useHookDisplayState } from './hooks/useHookDisplayState.js'; +import { useBackgroundTaskManager } from './hooks/useBackgroundTaskManager.js'; +import { + WARNING_PROMPT_DURATION_MS, + QUEUE_ERROR_DISPLAY_DURATION_MS, + EXPAND_HINT_DURATION_MS, +} from './constants.js'; +import { NewAgentsChoice } from './components/NewAgentsNotification.js'; +import { isSlashCommand } from './utils/commandUtils.js'; +import { parseSlashCommand } from '../utils/commands.js'; +import { useTerminalTheme } from './hooks/useTerminalTheme.js'; +import { useTimedMessage } from './hooks/useTimedMessage.js'; +import { useIsHelpDismissKey } from './utils/shortcutsHelp.js'; +import { useSuspend } from './hooks/useSuspend.js'; +import { useRunEventNotifications } from './hooks/useRunEventNotifications.js'; +import { + isNotificationsEnabled, + getNotificationMethod, +} from '../utils/terminalNotifications.js'; +import { + getLastTurnToolCallIds, + isToolExecuting, + isToolAwaitingConfirmation, + getAllToolCalls, +} from './utils/historyUtils.js'; + +interface AppContainerProps { + config: Config; + startupWarnings?: StartupWarning[]; + version: string; + initializationResult: InitializationResult; + resumedSessionData?: ResumedSessionData; +} + +import { useRepeatedKeyPress } from './hooks/useRepeatedKeyPress.js'; +import { + useVisibilityToggle, + APPROVAL_MODE_REVEAL_DURATION_MS, +} from './hooks/useVisibilityToggle.js'; +import { useKeyMatchers } from './hooks/useKeyMatchers.js'; + +import { InputContext } from './contexts/InputContext.js'; + +/** + * The fraction of the terminal width to allocate to the shell. + * This provides horizontal padding. + */ +const SHELL_WIDTH_FRACTION = 0.89; + +/** + * The number of lines to subtract from the available terminal height + * for the shell. This provides vertical padding and space for other UI elements. + */ +const SHELL_HEIGHT_PADDING = 10; + +export const AppContainer = (props: AppContainerProps) => { + const isHelpDismissKey = useIsHelpDismissKey(); + const keyMatchers = useKeyMatchers(); + const { config, initializationResult, resumedSessionData } = props; + const settings = useSettings(); + const { reset } = useOverflowActions()!; + const notificationsEnabled = isNotificationsEnabled(settings); + const notificationMethod = getNotificationMethod(settings); + + const { setOptions, dumpCurrentFrame, startRecording, stopRecording } = + useContext(InkAppContext); + const recordingFilenameRef = useRef(null); + const historyManager = useHistory({ + chatRecordingService: config.getGeminiClient()?.getChatRecordingService(), + }); + + useMemoryMonitor(historyManager); + const isAlternateBuffer = config.getUseAlternateBuffer(); + const [mouseMode, setMouseMode] = useState(() => + config.getUseAlternateBuffer(), + ); + + useEffect(() => { + setOptions({ + stickyHeadersInBackbuffer: mouseMode, + }); + if (mouseMode) { + enableMouseEvents(); + } else { + disableMouseEvents(); + } + }, [mouseMode, setOptions]); + + const [corgiMode, setCorgiMode] = useState(false); + const [debugMessage, setDebugMessage] = useState(''); + const [quittingMessages, setQuittingMessages] = useState< + HistoryItem[] | null + >(null); + const [showPrivacyNotice, setShowPrivacyNotice] = useState(false); + const [themeError, setThemeError] = useState( + initializationResult.themeError, + ); + const [isProcessing, setIsProcessing] = useState(false); + const [embeddedShellFocused, setEmbeddedShellFocused] = useState(false); + const [showDebugProfiler, setShowDebugProfiler] = useState(false); + const [customDialog, setCustomDialog] = useState( + null, + ); + const [copyModeEnabled, setCopyModeEnabled] = useState(false); + const [pendingRestorePrompt, setPendingRestorePrompt] = useState(false); + const toggleBackgroundTasksRef = useRef<() => void>(() => {}); + const isBackgroundTaskVisibleRef = useRef(false); + const backgroundTasksRef = useRef>(new Map()); + + const [adminSettingsChanged, setAdminSettingsChanged] = useState(false); + + const [expandedTools, setExpandedTools] = useState>(new Set()); + + const toggleExpansion = useCallback((callId: string) => { + setExpandedTools((prev) => { + const next = new Set(prev); + if (next.has(callId)) { + next.delete(callId); + } else { + next.add(callId); + } + return next; + }); + }, []); + + const toggleAllExpansion = useCallback((callIds: string[]) => { + setExpandedTools((prev) => { + const next = new Set(prev); + const anyCollapsed = callIds.some((id) => !next.has(id)); + + if (anyCollapsed) { + callIds.forEach((id) => next.add(id)); + } else { + callIds.forEach((id) => next.delete(id)); + } + return next; + }); + }, []); + + const isExpanded = useCallback( + (callId: string) => expandedTools.has(callId), + [expandedTools], + ); + + const [shellModeActive, setShellModeActive] = useState(false); + const [isVoiceModeEnabled, setVoiceModeEnabled] = useState(false); + const [modelSwitchedFromQuotaError, setModelSwitchedFromQuotaError] = + useState(false); + const [historyRemountKey, setHistoryRemountKey] = useState(0); + const [settingsNonce, setSettingsNonce] = useState(0); + const activeHooks = useHookDisplayState(); + const [updateInfo, setUpdateInfo] = useState(null); + const [isTrustedFolder, setIsTrustedFolder] = useState( + () => isWorkspaceTrusted(settings.merged).isTrusted, + ); + + const [queueErrorMessage, setQueueErrorMessage] = useTimedMessage( + QUEUE_ERROR_DISPLAY_DURATION_MS, + ); + + const [newAgents, setNewAgents] = useState(null); + const [constrainHeight, setConstrainHeight] = useState(true); + const [expandHintTrigger, triggerExpandHint] = useTimedMessage( + EXPAND_HINT_DURATION_MS, + ); + const showIsExpandableHint = Boolean(expandHintTrigger); + const overflowState = useOverflowState(); + const overflowingIdsSize = overflowState?.overflowingIds.size ?? 0; + const hasOverflowState = overflowingIdsSize > 0 || !constrainHeight; + + /** + * Manages the visibility and x-second timer for the expansion hint. + * + * This effect triggers the timer countdown whenever an overflow is detected + * or the user manually toggles the expansion state with Ctrl+O. + * By depending on overflowingIdsSize, the timer resets when *new* views + * overflow, but avoids infinitely resetting during single-view streaming. + * + * In alternate buffer mode, we don't trigger the hint automatically on overflow + * to avoid noise, but the user can still trigger it manually with Ctrl+O. + */ + useEffect(() => { + if (hasOverflowState) { + triggerExpandHint(true); + } + }, [hasOverflowState, overflowingIdsSize, triggerExpandHint]); + + const [defaultBannerText, setDefaultBannerText] = useState(''); + const [warningBannerText, setWarningBannerText] = useState(''); + const [bannerVisible, setBannerVisible] = useState(true); + + const bannerData = useMemo( + () => ({ + defaultText: defaultBannerText, + warningText: warningBannerText, + }), + [defaultBannerText, warningBannerText], + ); + + const { bannerText } = useBanner(bannerData); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const extensionManager = config.getExtensionLoader() as ExtensionManager; + // We are in the interactive CLI, update how we request consent and settings. + extensionManager.setRequestConsent((description) => + requestConsentInteractive(description, addConfirmUpdateExtensionRequest), + ); + extensionManager.setRequestSetting(); + + const { addConfirmUpdateExtensionRequest, confirmUpdateExtensionRequests } = + useConfirmUpdateRequests(); + const { + extensionsUpdateState, + extensionsUpdateStateInternal, + dispatchExtensionStateUpdate, + } = useExtensionUpdates( + extensionManager, + historyManager.addItem, + config.getEnableExtensionReloading(), + ); + + const [isPermissionsDialogOpen, setPermissionsDialogOpen] = useState(false); + const [permissionsDialogProps, setPermissionsDialogProps] = useState<{ + targetDirectory?: string; + } | null>(null); + const openPermissionsDialog = useCallback( + (props?: { targetDirectory?: string }) => { + setPermissionsDialogOpen(true); + setPermissionsDialogProps(props ?? null); + }, + [], + ); + const closePermissionsDialog = useCallback(() => { + setPermissionsDialogOpen(false); + setPermissionsDialogProps(null); + }, []); + + const [isAgentConfigDialogOpen, setIsAgentConfigDialogOpen] = useState(false); + const [selectedAgentName, setSelectedAgentName] = useState< + string | undefined + >(); + const [selectedAgentDisplayName, setSelectedAgentDisplayName] = useState< + string | undefined + >(); + const [selectedAgentDefinition, setSelectedAgentDefinition] = useState< + AgentDefinition | undefined + >(); + + const openAgentConfigDialog = useCallback( + (name: string, displayName: string, definition: AgentDefinition) => { + setSelectedAgentName(name); + setSelectedAgentDisplayName(displayName); + setSelectedAgentDefinition(definition); + setIsAgentConfigDialogOpen(true); + }, + [], + ); + + const closeAgentConfigDialog = useCallback(() => { + setIsAgentConfigDialogOpen(false); + setSelectedAgentName(undefined); + setSelectedAgentDisplayName(undefined); + setSelectedAgentDefinition(undefined); + }, []); + + const toggleDebugProfiler = useCallback( + () => setShowDebugProfiler((prev) => !prev), + [], + ); + + const [currentModel, setCurrentModel] = useState(config.getModel()); + + const [userTier, setUserTier] = useState(undefined); + const [quotaStats, setQuotaStats] = useState(() => { + const remaining = config.getQuotaRemaining(); + const limit = config.getQuotaLimit(); + const resetTime = config.getQuotaResetTime(); + return remaining !== undefined || + limit !== undefined || + resetTime !== undefined + ? { remaining, limit, resetTime } + : undefined; + }); + const [paidTier, setPaidTier] = useState( + undefined, + ); + + const [isConfigInitialized, setConfigInitialized] = useState(false); + + const logger = useLogger(config); + const { inputHistory, addInput, initializeFromLogger } = + useInputHistoryStore(); + + // Terminal and layout hooks + const { columns: terminalWidth, rows: terminalHeight } = useTerminalSize(); + const { stdin, setRawMode } = useStdin(); + const { stdout } = useStdout(); + const app: AppProps = useApp(); + + // Additional hooks moved from App.tsx + const { stats: sessionStats } = useSessionStats(); + const branchName = useGitBranchName(config.getTargetDir()); + + // Layout measurements + // For performance profiling only + const rootUiRef = useRef(null); + const lastTitleRef = useRef(null); + const staticExtraHeight = 3; + + useEffect(() => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + (async () => { + // Note: the program will not work if this fails so let errors be + // handled by the global catch. + if (!config.isInitialized()) { + await config.initialize(); + } + setConfigInitialized(true); + startupProfiler.flush(config); + + startAutoMemoryIfEnabled(config); + + const sessionStartSource = resumedSessionData + ? SessionStartSource.Resume + : SessionStartSource.Startup; + const result = await config + .getHookSystem() + ?.fireSessionStartEvent(sessionStartSource); + + if (result) { + const additionalContext = result.getAdditionalContext(); + const geminiClient = config.getGeminiClient(); + if (additionalContext && geminiClient) { + await geminiClient.addHistory({ + role: 'user', + parts: [ + { text: `${additionalContext}` }, + ], + }); + } + } + + // Fire-and-forget: generate summary for previous session in background + generateSummary(config).catch((e) => { + debugLogger.warn('Background summary generation failed:', e); + }); + })(); + const cleanupFn = async () => { + // Turn off mouse scroll. + disableMouseEvents(); + + // Kill all background shells + await Promise.all( + Array.from(backgroundTasksRef.current.keys()).map((pid) => + ShellExecutionService.kill(pid), + ), + ); + + const ideClient = await IdeClient.getInstance(); + await ideClient.disconnect(); + + // Fire SessionEnd hook on cleanup (only if hooks are enabled) + await config?.getHookSystem()?.fireSessionEndEvent(SessionEndReason.Exit); + }; + registerCleanup(cleanupFn); + + return () => { + removeCleanup(cleanupFn); + cleanupFn().catch((e: unknown) => + debugLogger.error('Error during cleanup:', e), + ); + }; + }, [config, resumedSessionData]); + + useEffect( + () => setUpdateHandler(historyManager.addItem, setUpdateInfo), + [historyManager.addItem], + ); + + // Subscribe to fallback mode and model changes from core + useEffect(() => { + const handleModelChanged = () => { + setCurrentModel(config.getModel()); + }; + + const handleQuotaChanged = (payload: { + remaining: number | undefined; + limit: number | undefined; + resetTime?: string; + }) => { + setQuotaStats({ + remaining: payload.remaining, + limit: payload.limit, + resetTime: payload.resetTime, + }); + }; + + coreEvents.on(CoreEvent.ModelChanged, handleModelChanged); + coreEvents.on(CoreEvent.QuotaChanged, handleQuotaChanged); + return () => { + coreEvents.off(CoreEvent.ModelChanged, handleModelChanged); + coreEvents.off(CoreEvent.QuotaChanged, handleQuotaChanged); + }; + }, [config]); + + useEffect(() => { + const handleSettingsChanged = () => { + setSettingsNonce((prev) => prev + 1); + }; + + const handleAdminSettingsChanged = () => { + setAdminSettingsChanged(true); + }; + + const handleAgentsDiscovered = (payload: AgentsDiscoveredPayload) => { + setNewAgents(payload.agents); + }; + + coreEvents.on(CoreEvent.SettingsChanged, handleSettingsChanged); + coreEvents.on(CoreEvent.AdminSettingsChanged, handleAdminSettingsChanged); + coreEvents.on(CoreEvent.AgentsDiscovered, handleAgentsDiscovered); + return () => { + coreEvents.off(CoreEvent.SettingsChanged, handleSettingsChanged); + coreEvents.off( + CoreEvent.AdminSettingsChanged, + handleAdminSettingsChanged, + ); + coreEvents.off(CoreEvent.AgentsDiscovered, handleAgentsDiscovered); + }; + }, [settings]); + + const { errorCount, clearErrorCount } = useErrorCount(); + + const mainAreaWidth = calculateMainAreaWidth(terminalWidth, config); + // Derive widths for InputPrompt using shared helper + const { inputWidth, suggestionsWidth } = useMemo(() => { + const { inputWidth, suggestionsWidth } = + calculatePromptWidths(mainAreaWidth); + return { inputWidth, suggestionsWidth }; + }, [mainAreaWidth]); + + const staticAreaMaxItemHeight = Math.max(terminalHeight * 4, 100); + + const getPreferredEditor = useCallback(() => { + const val = settings.merged.general.preferredEditor; + return isValidEditorType(val) ? val : undefined; + }, [settings.merged.general.preferredEditor]); + + const buffer = useTextBuffer({ + initialText: '', + viewport: { height: 10, width: inputWidth }, + stdin, + setRawMode, + escapePastedPaths: true, + shellModeActive, + getPreferredEditor, + }); + const bufferRef = useRef(buffer); + useEffect(() => { + bufferRef.current = buffer; + }, [buffer]); + + const stableSetText = useCallback((text: string) => { + bufferRef.current.setText(text); + }, []); + + // Initialize input history from logger (past sessions) + useEffect(() => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + initializeFromLogger(logger); + }, [logger, initializeFromLogger]); + + // One-time prompt to suggest running /terminal-setup when it would help. + useTerminalSetupPrompt({ + addConfirmUpdateExtensionRequest, + addItem: historyManager.addItem, + }); + + const refreshStatic = useCallback(() => { + if (!isAlternateBuffer && !config.getUseTerminalBuffer()) { + stdout.write(ansiEscapes.clearTerminal); + setHistoryRemountKey((prev) => prev + 1); + } + }, [setHistoryRemountKey, isAlternateBuffer, stdout, config]); + + const shouldUseAlternateScreen = shouldEnterAlternateScreen( + isAlternateBuffer, + config.getScreenReader(), + ); + + const handleEditorClose = useCallback(() => { + if (shouldUseAlternateScreen) { + // The editor may have exited alternate buffer mode so we need to + // enter it again to be safe. + enterAlternateScreen(); + enableMouseEvents(); + disableLineWrapping(); + app.rerender(); + } else if (config.getUseTerminalBuffer()) { + app.rerender(); + } + terminalCapabilityManager.enableSupportedModes(); + refreshStatic(); + }, [refreshStatic, shouldUseAlternateScreen, app, config]); + + const [editorError, setEditorError] = useState(null); + const { + isEditorDialogOpen, + openEditorDialog, + handleEditorSelect, + exitEditorDialog, + } = useEditorSettings(settings, setEditorError, historyManager.addItem); + + useEffect(() => { + coreEvents.on(CoreEvent.ExternalEditorClosed, handleEditorClose); + coreEvents.on(CoreEvent.RequestEditorSelection, openEditorDialog); + return () => { + coreEvents.off(CoreEvent.ExternalEditorClosed, handleEditorClose); + coreEvents.off(CoreEvent.RequestEditorSelection, openEditorDialog); + }; + }, [handleEditorClose, openEditorDialog]); + + useEffect(() => { + if ( + !(settings.merged.ui.hideBanner || config.getScreenReader()) && + bannerVisible && + bannerText + ) { + // The header should show a banner but the Header is rendered in static + // so we must trigger a static refresh for it to be visible. + refreshStatic(); + } + }, [bannerVisible, bannerText, settings, config, refreshStatic]); + + const { isSettingsDialogOpen, openSettingsDialog, closeSettingsDialog } = + useSettingsCommand(); + + const { + isThemeDialogOpen, + openThemeDialog, + closeThemeDialog, + handleThemeSelect, + handleThemeHighlight, + } = useThemeCommand( + settings, + setThemeError, + historyManager.addItem, + initializationResult.themeError, + refreshStatic, + ); + // Poll for terminal background color changes to auto-switch theme + useTerminalTheme(handleThemeSelect, config, refreshStatic); + const { + authState, + setAuthState, + authError, + onAuthError, + apiKeyDefaultValue, + reloadApiKey, + accountSuspensionInfo, + setAccountSuspensionInfo, + } = useAuthCommand( + settings, + config, + initializationResult.authError, + initializationResult.accountSuspensionInfo, + ); + const [authContext, setAuthContext] = useState<{ requiresRestart?: boolean }>( + {}, + ); + + useEffect(() => { + if (authState === AuthState.Authenticated && authContext.requiresRestart) { + setAuthState(AuthState.AwaitingLoginRestart); + setAuthContext({}); + } + }, [authState, authContext, setAuthState]); + + const { + proQuotaRequest, + handleProQuotaChoice, + validationRequest, + handleValidationChoice, + // G1 AI Credits + overageMenuRequest, + handleOverageMenuChoice, + emptyWalletRequest, + handleEmptyWalletChoice, + } = useQuotaAndFallback({ + config, + historyManager, + userTier, + paidTier, + settings, + setModelSwitchedFromQuotaError, + onShowAuthSelection: () => setAuthState(AuthState.Updating), + errorVerbosity: settings.merged.ui.errorVerbosity, + }); + + // Derive auth state variables for backward compatibility with UIStateContext + const isAuthDialogOpen = authState === AuthState.Updating; + // TODO: Consider handling other auth types that should also skip the blocking screen + const isAuthenticating = + authState === AuthState.Unauthenticated && + settings.merged.security.auth.selectedType !== AuthType.USE_GEMINI; + + // Session browser and resume functionality + const isGeminiClientInitialized = config.getGeminiClient()?.isInitialized(); + + const { loadHistoryForResume, isResuming } = useSessionResume({ + config, + historyManager, + refreshStatic, + isGeminiClientInitialized, + setQuittingMessages, + resumedSessionData, + isAuthenticating, + }); + const { + isSessionBrowserOpen, + openSessionBrowser, + closeSessionBrowser, + handleResumeSession, + handleDeleteSession: handleDeleteSessionSync, + } = useSessionBrowser(config, loadHistoryForResume); + // Wrap handleDeleteSession to return a Promise for UIActions interface + const handleDeleteSession = useCallback( + async (session: SessionInfo): Promise => { + await handleDeleteSessionSync(session); + }, + [handleDeleteSessionSync], + ); + + // Create handleAuthSelect wrapper for backward compatibility + const handleAuthSelect = useCallback( + async (authType: AuthType | undefined, scope: LoadableSettingScope) => { + if (authType) { + const previousAuthType = + config.getContentGeneratorConfig()?.authType ?? 'unknown'; + if (authType === AuthType.LOGIN_WITH_GOOGLE) { + setAuthContext({ requiresRestart: true }); + } else { + setAuthContext({}); + } + await clearCachedCredentialFile(); + settings.setValue(scope, 'security.auth.selectedType', authType); + + try { + config.setRemoteAdminSettings(undefined); + await config.refreshAuth(authType); + setAuthState(AuthState.Authenticated); + logBillingEvent( + config, + new ApiKeyUpdatedEvent(previousAuthType, authType), + ); + } catch (e) { + if (e instanceof ChangeAuthRequestedError) { + return; + } + if (e instanceof ProjectIdRequiredError) { + // OAuth succeeded but account setup requires project ID + // Show the error message directly without "Failed to authenticate" prefix + onAuthError(getErrorMessage(e)); + return; + } + onAuthError( + `Failed to authenticate: ${e instanceof Error ? e.message : String(e)}`, + ); + return; + } + + if ( + authType === AuthType.LOGIN_WITH_GOOGLE && + config.isBrowserLaunchSuppressed() + ) { + writeToStdout(` +---------------------------------------------------------------- +Logging in with Google... Restarting Gemini CLI to continue. +---------------------------------------------------------------- + `); + await relaunchApp(); + } + } + setAuthState(AuthState.Authenticated); + }, + [settings, config, setAuthState, onAuthError, setAuthContext], + ); + + const handleApiKeySubmit = useCallback( + async (apiKey: string) => { + try { + onAuthError(null); + if (!apiKey.trim()) { + onAuthError('API key cannot be empty or whitespace only.'); + return; + } + + await saveApiKey(apiKey); + await reloadApiKey(); + await config.refreshAuth(AuthType.USE_GEMINI); + setAuthState(AuthState.Authenticated); + } catch (e) { + onAuthError( + `Failed to save API key: ${e instanceof Error ? e.message : String(e)}`, + ); + } + }, + [setAuthState, onAuthError, reloadApiKey, config], + ); + + const handleApiKeyCancel = useCallback(() => { + // Go back to auth method selection + setAuthState(AuthState.Updating); + }, [setAuthState]); + + // Sync user tier from config when authentication changes + useEffect(() => { + // Only sync when not currently authenticating + if (authState === AuthState.Authenticated) { + setUserTier(config.getUserTier()); + setPaidTier(config.getUserPaidTier()); + } + }, [config, authState]); + + // Check for enforced auth type mismatch + useEffect(() => { + if ( + settings.merged.security.auth.enforcedType && + settings.merged.security.auth.selectedType && + settings.merged.security.auth.enforcedType !== + settings.merged.security.auth.selectedType + ) { + onAuthError( + `Authentication is enforced to be ${settings.merged.security.auth.enforcedType}, but you are currently using ${settings.merged.security.auth.selectedType}.`, + ); + } else if ( + settings.merged.security.auth.selectedType && + !settings.merged.security.auth.useExternal + ) { + // We skip validation for Gemini API key here because it might be stored + // in the keychain, which we can't check synchronously. + // The useAuth hook handles validation for this case. + if (settings.merged.security.auth.selectedType === AuthType.USE_GEMINI) { + return; + } + + const authMethod = settings.merged.security.auth.selectedType; + void (async () => { + try { + const error = await validateAuthMethod(authMethod); + if ( + error && + authMethod === settings.merged.security.auth.selectedType + ) { + onAuthError(error); + } + } catch (e) { + if (authMethod === settings.merged.security.auth.selectedType) { + onAuthError(getErrorMessage(e)); + } + } + })(); + } + }, [ + settings.merged.security.auth.selectedType, + settings.merged.security.auth.enforcedType, + settings.merged.security.auth.useExternal, + onAuthError, + ]); + + const { isModelDialogOpen, openModelDialog, closeModelDialog } = + useModelCommand(); + + const { + isVoiceModelDialogOpen, + openVoiceModelDialog, + closeVoiceModelDialog, + } = useVoiceModelCommand(); + + const { toggleVimEnabled } = useVimMode(); + + const setIsBackgroundTaskListOpenRef = useRef<(open: boolean) => void>( + () => {}, + ); + const [shortcutsHelpVisible, setShortcutsHelpVisible] = useState(false); + + const { + cleanUiDetailsVisible, + setCleanUiDetailsVisible, + toggleCleanUiDetailsVisible, + revealCleanUiDetailsTemporarily, + } = useVisibilityToggle(); + + const slashCommandActions = useMemo( + () => ({ + openAuthDialog: () => setAuthState(AuthState.Updating), + openThemeDialog, + openEditorDialog, + openPrivacyNotice: () => setShowPrivacyNotice(true), + openSettingsDialog, + openSessionBrowser, + openModelDialog, + openVoiceModelDialog, + openAgentConfigDialog, + openPermissionsDialog, + quit: (messages: HistoryItem[]) => { + closeThemeDialog(); + setQuittingMessages(messages); + setTimeout(async () => { + await runExitCleanup(); + process.exit(0); + }, 100); + }, + setDebugMessage, + toggleCorgiMode: () => setCorgiMode((prev) => !prev), + toggleVoiceMode: () => setVoiceModeEnabled((prev) => !prev), + toggleDebugProfiler, + dispatchExtensionStateUpdate, + addConfirmUpdateExtensionRequest, + toggleBackgroundTasks: () => { + toggleBackgroundTasksRef.current(); + if (!isBackgroundTaskVisibleRef.current) { + setEmbeddedShellFocused(true); + if (backgroundTasksRef.current.size > 1) { + setIsBackgroundTaskListOpenRef.current(true); + } else { + setIsBackgroundTaskListOpenRef.current(false); + } + } + }, + toggleShortcutsHelp: () => setShortcutsHelpVisible((visible) => !visible), + setText: stableSetText, + }), + [ + setAuthState, + openThemeDialog, + closeThemeDialog, + openEditorDialog, + openSettingsDialog, + openSessionBrowser, + openModelDialog, + openVoiceModelDialog, + openAgentConfigDialog, + setQuittingMessages, + setDebugMessage, + setShowPrivacyNotice, + setCorgiMode, + dispatchExtensionStateUpdate, + openPermissionsDialog, + addConfirmUpdateExtensionRequest, + toggleDebugProfiler, + setShortcutsHelpVisible, + stableSetText, + ], + ); + + const { + handleSlashCommand, + slashCommands, + pendingHistoryItems: pendingSlashCommandHistoryItems, + commandContext, + confirmationRequest: commandConfirmationRequest, + } = useSlashCommandProcessor( + config, + settings, + historyManager.addItem, + historyManager.clearItems, + historyManager.loadHistory, + refreshStatic, + toggleVimEnabled, + setIsProcessing, + slashCommandActions, + extensionsUpdateStateInternal, + isConfigInitialized, + setBannerVisible, + setCustomDialog, + ); + + const [authConsentRequest, setAuthConsentRequest] = + useState(null); + const [permissionConfirmationRequest, setPermissionConfirmationRequest] = + useState(null); + + useEffect(() => { + const handleConsentRequest = (payload: ConsentRequestPayload) => { + setAuthConsentRequest({ + prompt: payload.prompt, + onConfirm: (confirmed: boolean) => { + setAuthConsentRequest(null); + payload.onConfirm(confirmed); + }, + }); + }; + + coreEvents.on(CoreEvent.ConsentRequest, handleConsentRequest); + return () => { + coreEvents.off(CoreEvent.ConsentRequest, handleConsentRequest); + }; + }, []); + + const performMemoryRefresh = useCallback(async () => { + historyManager.addItem( + { + type: MessageType.INFO, + text: 'Refreshing hierarchical memory (GEMINI.md or other context files)...', + }, + Date.now(), + ); + try { + await config.getMemoryContextManager()?.refresh(); + config.updateSystemInstructionIfInitialized(); + const flattenedMemory = flattenMemory(config.getUserMemory()); + const fileCount = config.getGeminiMdFileCount(); + + historyManager.addItem( + { + type: MessageType.INFO, + text: `Memory reloaded successfully. ${ + flattenedMemory.length > 0 + ? `Loaded ${flattenedMemory.length} characters from ${fileCount} file(s)` + : 'No memory content found' + }`, + }, + Date.now(), + ); + if (config.getDebugMode()) { + debugLogger.log( + `[DEBUG] Refreshed memory content in config: ${flattenedMemory.substring( + 0, + 200, + )}...`, + ); + } + } catch (error) { + const errorMessage = getErrorMessage(error); + historyManager.addItem( + { + type: MessageType.ERROR, + text: `Error refreshing memory: ${errorMessage}`, + }, + Date.now(), + ); + debugLogger.warn('Error refreshing memory:', error); + } + }, [config, historyManager]); + + const cancelHandlerRef = useRef< + (shouldRestorePrompt?: boolean, clearBuffer?: boolean) => void + >(() => {}); + + const onCancelSubmit = useCallback( + (shouldRestorePrompt?: boolean, clearBuffer: boolean = false) => { + if (shouldRestorePrompt) { + setPendingRestorePrompt(true); + } else { + setPendingRestorePrompt(false); + cancelHandlerRef.current(false, clearBuffer); + } + }, + [], + ); + + useEffect(() => { + if (pendingRestorePrompt) { + const lastHistoryUserMsg = historyManager.history.findLast( + (h) => h.type === 'user', + ); + const lastUserMsg = inputHistory.at(-1); + + if ( + !lastHistoryUserMsg || + (typeof lastHistoryUserMsg.text === 'string' && + lastHistoryUserMsg.text === lastUserMsg) + ) { + cancelHandlerRef.current(true); + setPendingRestorePrompt(false); + } + } + }, [pendingRestorePrompt, inputHistory, historyManager.history]); + + const pendingHintsRef = useRef([]); + const [pendingHintCount, setPendingHintCount] = useState(0); + + const consumePendingHints = useCallback(() => { + if (pendingHintsRef.current.length === 0) { + return null; + } + const hint = pendingHintsRef.current.join('\n'); + pendingHintsRef.current = []; + setPendingHintCount(0); + return hint; + }, []); + + useEffect(() => { + const hintListener = (text: string, source: InjectionSource) => { + if (source !== 'user_steering' && source !== 'background_completion') { + return; + } + pendingHintsRef.current.push(text); + setPendingHintCount((prev) => prev + 1); + }; + config.injectionService.onInjection(hintListener); + return () => { + config.injectionService.offInjection(hintListener); + }; + }, [config]); + + const streamAgent = useMemo( + () => + config?.getAgentSessionInteractiveEnabled() + ? new LegacyAgentProtocol({ config, getPreferredEditor }) + : undefined, + [config, getPreferredEditor], + ); + + const activeStream = streamAgent + ? // eslint-disable-next-line react-hooks/rules-of-hooks + useAgentStream({ + agent: streamAgent, + addItem: historyManager.addItem, + onCancelSubmit, + isShellFocused: embeddedShellFocused, + logger, + }) + : // eslint-disable-next-line react-hooks/rules-of-hooks + useGeminiStream( + config.getGeminiClient(), + historyManager.history, + historyManager.addItem, + config, + settings, + setDebugMessage, + handleSlashCommand, + shellModeActive, + getPreferredEditor, + onAuthError, + performMemoryRefresh, + modelSwitchedFromQuotaError, + setModelSwitchedFromQuotaError, + onCancelSubmit, + setEmbeddedShellFocused, + terminalWidth, + terminalHeight, + embeddedShellFocused, + consumePendingHints, + ); + + const { + streamingState, + submitQuery, + initError, + pendingHistoryItems: pendingGeminiHistoryItems, + thought, + cancelOngoingRequest, + pendingToolCalls, + handleApprovalModeChange, + activePtyId, + loopDetectionConfirmationRequest, + lastOutputTime, + backgroundTaskCount, + isBackgroundTaskVisible, + toggleBackgroundTasks, + backgroundCurrentExecution, + backgroundTasks, + dismissBackgroundTask, + retryStatus, + } = activeStream; + + const pendingHistoryItems = useMemo( + () => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems], + [pendingSlashCommandHistoryItems, pendingGeminiHistoryItems], + ); + + toggleBackgroundTasksRef.current = toggleBackgroundTasks; + isBackgroundTaskVisibleRef.current = isBackgroundTaskVisible; + backgroundTasksRef.current = backgroundTasks; + + const { + activeBackgroundTaskPid, + setIsBackgroundTaskListOpen, + isBackgroundTaskListOpen, + setActiveBackgroundTaskPid, + backgroundTaskHeight, + } = useBackgroundTaskManager({ + backgroundTasks, + backgroundTaskCount, + isBackgroundTaskVisible, + activePtyId, + embeddedShellFocused, + setEmbeddedShellFocused, + terminalHeight, + }); + + setIsBackgroundTaskListOpenRef.current = setIsBackgroundTaskListOpen; + + const lastOutputTimeRef = useRef(0); + + useEffect(() => { + lastOutputTimeRef.current = lastOutputTime; + }, [lastOutputTime]); + + const { shouldShowFocusHint, inactivityStatus } = useShellInactivityStatus({ + activePtyId, + lastOutputTime, + streamingState, + pendingToolCalls, + embeddedShellFocused, + isInteractiveShellEnabled: config.isInteractiveShellEnabled(), + }); + + const shouldShowActionRequiredTitle = inactivityStatus === 'action_required'; + const shouldShowSilentWorkingTitle = inactivityStatus === 'silent_working'; + + const handleApprovalModeChangeWithUiReveal = useCallback( + (mode: ApprovalMode) => { + void handleApprovalModeChange(mode); + if (!cleanUiDetailsVisible) { + revealCleanUiDetailsTemporarily(APPROVAL_MODE_REVEAL_DURATION_MS); + } + }, + [ + handleApprovalModeChange, + cleanUiDetailsVisible, + revealCleanUiDetailsTemporarily, + ], + ); + + const { isMcpReady } = useMcpStatus(config); + + const isCompressing = useMemo( + () => + pendingHistoryItems.some( + (item) => + item.type === MessageType.COMPRESSION && item.compression.isPending, + ), + [pendingHistoryItems], + ); + + const { + messageQueue, + addMessage, + clearQueue, + getQueuedMessagesText, + popAllMessages, + } = useMessageQueue({ + isConfigInitialized, + streamingState, + submitQuery, + isMcpReady, + isCompressing, + }); + + cancelHandlerRef.current = useCallback( + (shouldRestorePrompt: boolean = true, clearBuffer: boolean = false) => { + if (!clearBuffer && isToolAwaitingConfirmation(pendingHistoryItems)) { + return; // Don't clear - user may be composing a follow-up message + } + + // If cancelling (shouldRestorePrompt=false): + if (!shouldRestorePrompt) { + // Clear the buffer if explicitly requested (e.g., Ctrl+C) + if (clearBuffer) { + buffer.setText(''); + } + // Otherwise (e.g., Escape), user is in control - preserve whatever text they typed + return; + } + + // Restore the last message when shouldRestorePrompt=true + const lastUserMessage = inputHistory.at(-1); + let textToSet = lastUserMessage || ''; + + const queuedText = getQueuedMessagesText(); + if (queuedText) { + textToSet = textToSet ? `${textToSet}\n\n${queuedText}` : queuedText; + clearQueue(); + } + + if (textToSet) { + buffer.setText(textToSet); + } + }, + [ + buffer, + inputHistory, + getQueuedMessagesText, + clearQueue, + pendingHistoryItems, + ], + ); + + const handleHintSubmit = useCallback( + (hint: string) => { + const trimmed = hint.trim(); + if (!trimmed) { + return; + } + config.injectionService.addInjection(trimmed, 'user_steering'); + // Render hints with a distinct style. + historyManager.addItem({ + type: 'hint', + text: trimmed, + }); + }, + [config, historyManager], + ); + + const handleFinalSubmit = useCallback( + async (submittedValue: string) => { + reset(); + // Explicitly hide the expansion hint and clear its x-second timer when a new turn begins. + triggerExpandHint(null); + if (!constrainHeight) { + setConstrainHeight(true); + if (!isAlternateBuffer) { + refreshStatic(); + } + } + + const isSlash = isSlashCommand(submittedValue.trim()); + const isIdle = streamingState === StreamingState.Idle; + const isAgentRunning = + streamingState === StreamingState.Responding || + isToolExecuting(pendingHistoryItems); + + if (isSlash && isAgentRunning) { + const { commandToExecute } = parseSlashCommand( + submittedValue, + slashCommands ?? [], + ); + if (commandToExecute?.isSafeConcurrent) { + void handleSlashCommand(submittedValue); + addInput(submittedValue); + return; + } + } + + if (config.isModelSteeringEnabled() && isAgentRunning && !isSlash) { + handleHintSubmit(submittedValue); + addInput(submittedValue); + return; + } + + const isMcpOrConfigReady = isConfigInitialized && isMcpReady; + if ( + (isSlash && isConfigInitialized) || + (!isCompressing && isIdle && isMcpOrConfigReady) + ) { + if (!isSlash) { + const permissions = await checkPermissions(submittedValue, config); + if (permissions.length > 0) { + setPermissionConfirmationRequest({ + files: permissions, + onComplete: (result) => { + setPermissionConfirmationRequest(null); + if (result.allowed) { + permissions.forEach((p) => + config.getWorkspaceContext().addReadOnlyPath(p), + ); + } + void submitQuery(submittedValue); + }, + }); + addInput(submittedValue); + return; + } + } + void submitQuery(submittedValue); + } else { + // Check messageQueue.length === 0 to only notify on the first queued item + if ( + isIdle && + !isCompressing && + !isMcpOrConfigReady && + messageQueue.length === 0 + ) { + coreEvents.emitFeedback( + 'info', + !isConfigInitialized + ? 'Initializing... Prompts will be queued.' + : 'Waiting for MCP servers to initialize... Slash commands are still available and prompts will be queued.', + ); + } + addMessage(submittedValue); + } + addInput(submittedValue); // Track input for up-arrow history + }, + [ + addMessage, + addInput, + submitQuery, + handleSlashCommand, + slashCommands, + isMcpReady, + streamingState, + isCompressing, + messageQueue.length, + pendingHistoryItems, + config, + constrainHeight, + setConstrainHeight, + isAlternateBuffer, + refreshStatic, + reset, + handleHintSubmit, + isConfigInitialized, + triggerExpandHint, + ], + ); + + const handleClearScreen = useCallback(() => { + reset(); + // Explicitly hide the expansion hint and clear its x-second timer when clearing the screen. + triggerExpandHint(null); + historyManager.clearItems(); + clearErrorCount(); + refreshStatic(); + }, [ + historyManager, + clearErrorCount, + refreshStatic, + reset, + triggerExpandHint, + ]); + + const { handleInput: vimHandleInput } = useVim(buffer, handleFinalSubmit); + + /** + * Determines if the input prompt should be active and accept user input. + * Input is disabled during: + * - Initialization errors + * - Slash command processing + * - Tool confirmations (WaitingForConfirmation state) + * - Any future streaming states not explicitly allowed + */ + const isInputActive = + !initError && + !isProcessing && + !isResuming && + (streamingState === StreamingState.Idle || + streamingState === StreamingState.Responding || + streamingState === StreamingState.WaitingForConfirmation) && + !proQuotaRequest; + + const observerRef = useRef(null); + + useEffect( + () => () => { + observerRef.current?.disconnect(); + }, + [], + ); + + const [controlsHeight, setControlsHeight] = useState(0); + const [lastNonCopyControlsHeight, setLastNonCopyControlsHeight] = useState(0); + + useLayoutEffect(() => { + if (!copyModeEnabled && controlsHeight > 0) { + setLastNonCopyControlsHeight(controlsHeight); + } + }, [copyModeEnabled, controlsHeight]); + + const stableControlsHeight = + copyModeEnabled && lastNonCopyControlsHeight > 0 + ? lastNonCopyControlsHeight + : controlsHeight; + + const mainControlsRef = useCallback((node: DOMElement | null) => { + if (observerRef.current) { + observerRef.current.disconnect(); + observerRef.current = null; + } + + if (node) { + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + if (entry) { + const roundedHeight = Math.round(entry.contentRect.height); + setControlsHeight((prev) => + roundedHeight !== prev ? roundedHeight : prev, + ); + } + }); + observer.observe(node); + observerRef.current = observer; + } + }, []); + + // Compute available terminal height based on stable controls measurement + const availableTerminalHeight = Math.max( + 0, + terminalHeight - stableControlsHeight - backgroundTaskHeight - 1, + ); + + config.setShellExecutionConfig({ + terminalWidth: Math.floor(terminalWidth * SHELL_WIDTH_FRACTION), + terminalHeight: Math.max( + Math.floor(availableTerminalHeight - SHELL_HEIGHT_PADDING), + 1, + ), + pager: settings.merged.tools.shell.pager, + showColor: settings.merged.tools.shell.showColor, + sanitizationConfig: config.sanitizationConfig, + sandboxManager: config.sandboxManager, + }); + + const { isFocused, hasReceivedFocusEvent } = useFocus(); + + // Context file names computation + const contextFileNames = useMemo(() => { + const fromSettings = settings.merged.context.fileName; + return fromSettings + ? Array.isArray(fromSettings) + ? fromSettings + : [fromSettings] + : getAllGeminiMdFilenames(); + }, [settings.merged.context.fileName]); + // Initial prompt handling + const initialPrompt = useMemo(() => config.getQuestion(), [config]); + const initialPromptSubmitted = useRef(false); + const geminiClient = config.getGeminiClient(); + + useEffect(() => { + if ( + initialPrompt && + isConfigInitialized && + !initialPromptSubmitted.current && + !isAuthenticating && + !isAuthDialogOpen && + !isThemeDialogOpen && + !isEditorDialogOpen && + !showPrivacyNotice && + geminiClient?.isInitialized?.() + ) { + void handleFinalSubmit(initialPrompt); + initialPromptSubmitted.current = true; + } + }, [ + initialPrompt, + isConfigInitialized, + handleFinalSubmit, + isAuthenticating, + isAuthDialogOpen, + isThemeDialogOpen, + isEditorDialogOpen, + showPrivacyNotice, + geminiClient, + ]); + + const [idePromptAnswered, setIdePromptAnswered] = useState(false); + const [currentIDE, setCurrentIDE] = useState(null); + + useEffect(() => { + const getIde = async () => { + const ideClient = await IdeClient.getInstance(); + const currentIde = ideClient.getCurrentIde(); + setCurrentIDE(currentIde || null); + }; + // eslint-disable-next-line @typescript-eslint/no-floating-promises + getIde(); + }, []); + const shouldShowIdePrompt = Boolean( + currentIDE && + !config.getIdeMode() && + !settings.merged.ide.hasSeenNudge && + !idePromptAnswered, + ); + + const [showErrorDetails, setShowErrorDetails] = useState(false); + const [showFullTodos, setShowFullTodos] = useState(false); + const [renderMarkdown, setRenderMarkdown] = useState(true); + + const handleExitRepeat = useCallback( + (count: number) => { + if (count > 2) { + recordExitFail(config); + } + if (count > 1) { + void handleSlashCommand('/quit', undefined, undefined, false); + } + }, + [config, handleSlashCommand], + ); + + const { pressCount: ctrlCPressCount, handlePress: handleCtrlCPress } = + useRepeatedKeyPress({ + windowMs: WARNING_PROMPT_DURATION_MS, + onRepeat: handleExitRepeat, + }); + + const { pressCount: ctrlDPressCount, handlePress: handleCtrlDPress } = + useRepeatedKeyPress({ + windowMs: WARNING_PROMPT_DURATION_MS, + onRepeat: handleExitRepeat, + }); + + const [ideContextState, setIdeContextState] = useState< + IdeContext | undefined + >(); + const [showEscapePrompt, setShowEscapePrompt] = useState(false); + const [showIdeRestartPrompt, setShowIdeRestartPrompt] = useState(false); + + const [transientMessage, showTransientMessage] = useTimedMessage<{ + text: string; + type: TransientMessageType; + }>(WARNING_PROMPT_DURATION_MS); + + const { + isFolderTrustDialogOpen, + discoveryResults: folderDiscoveryResults, + handleFolderTrustSelect, + isRestarting, + } = useFolderTrust(settings, setIsTrustedFolder, historyManager.addItem); + + const policyUpdateConfirmationRequest = + config.getPolicyUpdateConfirmationRequest(); + const [isPolicyUpdateDialogOpen, setIsPolicyUpdateDialogOpen] = useState( + !!policyUpdateConfirmationRequest, + ); + const { + needsRestart: ideNeedsRestart, + restartReason: ideTrustRestartReason, + } = useIdeTrustListener(); + useIncludeDirsTrust(config, isTrustedFolder, historyManager, setCustomDialog); + + const tabFocusTimeoutRef = useRef(null); + + useEffect(() => { + const handleTransientMessage = (payload: { + message: string; + type: TransientMessageType; + }) => { + showTransientMessage({ text: payload.message, type: payload.type }); + }; + + const handleSelectionWarning = () => { + showTransientMessage({ + text: 'Press Ctrl-S to enter selection mode to copy text.', + type: TransientMessageType.Warning, + }); + }; + const handlePasteTimeout = () => { + showTransientMessage({ + text: 'Paste Timed out. Possibly due to slow connection.', + type: TransientMessageType.Warning, + }); + }; + + appEvents.on(AppEvent.TransientMessage, handleTransientMessage); + appEvents.on(AppEvent.SelectionWarning, handleSelectionWarning); + appEvents.on(AppEvent.PasteTimeout, handlePasteTimeout); + + return () => { + appEvents.off(AppEvent.TransientMessage, handleTransientMessage); + appEvents.off(AppEvent.SelectionWarning, handleSelectionWarning); + appEvents.off(AppEvent.PasteTimeout, handlePasteTimeout); + if (tabFocusTimeoutRef.current) { + clearTimeout(tabFocusTimeoutRef.current); + } + }; + }, [showTransientMessage]); + + const handleWarning = useCallback( + (message: string) => { + showTransientMessage({ + text: message, + type: TransientMessageType.Warning, + }); + }, + [showTransientMessage], + ); + + const { handleSuspend } = useSuspend({ + handleWarning, + setRawMode, + shouldUseAlternateScreen, + }); + + useEffect(() => { + if (ideNeedsRestart) { + // IDE trust changed, force a restart. + setShowIdeRestartPrompt(true); + } + }, [ideNeedsRestart]); + + useEffect(() => { + const unsubscribe = ideContextStore.subscribe(setIdeContextState); + setIdeContextState(ideContextStore.get()); + return unsubscribe; + }, []); + + useEffect(() => { + const openDebugConsole = () => { + setShowErrorDetails(true); + setConstrainHeight(false); + }; + appEvents.on(AppEvent.OpenDebugConsole, openDebugConsole); + + return () => { + appEvents.off(AppEvent.OpenDebugConsole, openDebugConsole); + }; + }, [config]); + + const handleEscapePromptChange = useCallback((showPrompt: boolean) => { + setShowEscapePrompt(showPrompt); + }, []); + + const handleIdePromptComplete = useCallback( + (result: IdeIntegrationNudgeResult) => { + if (result.userSelection === 'yes') { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + handleSlashCommand('/ide install'); + settings.setValue(SettingScope.User, 'ide.hasSeenNudge', true); + } else if (result.userSelection === 'dismiss') { + settings.setValue(SettingScope.User, 'ide.hasSeenNudge', true); + } + setIdePromptAnswered(true); + }, + [handleSlashCommand, settings], + ); + + const handleGlobalKeypress = useCallback( + (key: Key): boolean => { + // Debug log keystrokes if enabled + if (settings.merged.general.debugKeystrokeLogging) { + debugLogger.log('[DEBUG] Keystroke:', JSON.stringify(key)); + } + + if (shortcutsHelpVisible && isHelpDismissKey(key)) { + setShortcutsHelpVisible(false); + } + + if (keyMatchers[Command.TOGGLE_MOUSE_MODE](key)) { + setMouseMode((prev) => !prev); + if (mouseMode && !isAlternateBuffer) { + appEvents.emit(AppEvent.ScrollToBottom); + } + return true; + } + + if (isAlternateBuffer && keyMatchers[Command.TOGGLE_COPY_MODE](key)) { + setCopyModeEnabled(true); + disableMouseEvents(); + return true; + } + + if (keyMatchers[Command.QUIT](key)) { + // If the user presses Ctrl+C, we want to cancel any ongoing requests. + // This should happen regardless of the count. + void cancelOngoingRequest?.(); + + handleCtrlCPress(); + return true; + } else if (keyMatchers[Command.EXIT](key)) { + // If the input field is non-empty, do not exit. + if (bufferRef.current.text.length > 0) { + return false; + } + handleCtrlDPress(); + return true; + } else if (keyMatchers[Command.SUSPEND_APP](key)) { + handleSuspend(); + } else if (keyMatchers[Command.DUMP_FRAME](key)) { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const filename = `snapshot-${timestamp}.json`; + if (dumpCurrentFrame) { + dumpCurrentFrame(filename); + debugLogger.log(`Dumped frame to: ${filename}`); + } + return true; + } else if (keyMatchers[Command.START_RECORDING](key)) { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const filename = `recording-${timestamp}.json`; + if (startRecording) { + startRecording(filename); + recordingFilenameRef.current = filename; + debugLogger.log(`Started recording to: ${filename}`); + } + return true; + } else if (keyMatchers[Command.STOP_RECORDING](key)) { + if (stopRecording) { + stopRecording(); + debugLogger.log( + `Stopped recording, saved to: ${recordingFilenameRef.current ?? 'unknown'}`, + ); + recordingFilenameRef.current = null; + } + return true; + } else if ( + keyMatchers[Command.TOGGLE_COPY_MODE](key) && + !isAlternateBuffer + ) { + showTransientMessage({ + text: 'Use Ctrl+O to expand and collapse blocks of content.', + type: TransientMessageType.Warning, + }); + return true; + } + + const toggleLastTurnTools = () => { + triggerExpandHint(true); + + const targetToolCallIds = getLastTurnToolCallIds( + historyManager.history, + pendingHistoryItems, + ); + + if (targetToolCallIds.length > 0) { + toggleAllExpansion(targetToolCallIds); + } + }; + + let enteringConstrainHeightMode = false; + if (!constrainHeight) { + enteringConstrainHeightMode = true; + setConstrainHeight(true); + if (keyMatchers[Command.SHOW_MORE_LINES](key)) { + toggleLastTurnTools(); + } + if (!isAlternateBuffer) { + refreshStatic(); + } + } + + if (keyMatchers[Command.SHOW_ERROR_DETAILS](key)) { + if (settings.merged.general.devtools) { + void (async () => { + const { toggleDevToolsPanel } = await import( + '../utils/devtoolsService.js' + ); + await toggleDevToolsPanel( + config, + showErrorDetails, + () => setShowErrorDetails((prev) => !prev), + () => setShowErrorDetails(true), + ); + })(); + } else { + setShowErrorDetails((prev) => !prev); + } + return true; + } else if (keyMatchers[Command.SHOW_FULL_TODOS](key)) { + setShowFullTodos((prev) => !prev); + return true; + } else if (keyMatchers[Command.TOGGLE_MARKDOWN](key)) { + setRenderMarkdown((prev) => { + const newValue = !prev; + // Force re-render of static content + refreshStatic(); + return newValue; + }); + return true; + } else if ( + keyMatchers[Command.SHOW_IDE_CONTEXT_DETAIL](key) && + config.getIdeMode() && + ideContextState + ) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + handleSlashCommand('/ide status'); + return true; + } else if ( + keyMatchers[Command.SHOW_MORE_LINES](key) && + !enteringConstrainHeightMode + ) { + setConstrainHeight(false); + toggleLastTurnTools(); + refreshStatic(); + return true; + } else if ( + (keyMatchers[Command.FOCUS_SHELL_INPUT](key) || + keyMatchers[Command.UNFOCUS_BACKGROUND_SHELL_LIST](key)) && + (activePtyId || (isBackgroundTaskVisible && backgroundTasks.size > 0)) + ) { + if (embeddedShellFocused) { + const capturedTime = lastOutputTimeRef.current; + if (tabFocusTimeoutRef.current) + clearTimeout(tabFocusTimeoutRef.current); + tabFocusTimeoutRef.current = setTimeout(() => { + if (lastOutputTimeRef.current === capturedTime) { + setEmbeddedShellFocused(false); + } else { + showTransientMessage({ + text: 'Use Shift+Tab to unfocus', + type: TransientMessageType.Warning, + }); + } + }, 150); + return false; + } + + const isIdle = Date.now() - lastOutputTimeRef.current >= 100; + + if (isIdle && !activePtyId && !isBackgroundTaskVisible) { + if (tabFocusTimeoutRef.current) + clearTimeout(tabFocusTimeoutRef.current); + toggleBackgroundTasks(); + setEmbeddedShellFocused(true); + if (backgroundTasks.size > 1) setIsBackgroundTaskListOpen(true); + return true; + } + + setEmbeddedShellFocused(true); + return true; + } else if ( + keyMatchers[Command.UNFOCUS_SHELL_INPUT](key) || + keyMatchers[Command.UNFOCUS_BACKGROUND_SHELL](key) + ) { + if (embeddedShellFocused) { + setEmbeddedShellFocused(false); + return true; + } + return false; + } else if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) { + if (activePtyId) { + backgroundCurrentExecution(); + // After backgrounding, we explicitly do NOT show or focus the background UI. + } else { + toggleBackgroundTasks(); + // Toggle focus based on intent: if we were hiding, unfocus; if showing, focus. + if (!isBackgroundTaskVisible && backgroundTasks.size > 0) { + setEmbeddedShellFocused(true); + if (backgroundTasks.size > 1) { + setIsBackgroundTaskListOpen(true); + } + } else { + setEmbeddedShellFocused(false); + } + } + return true; + } else if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL_LIST](key)) { + if (backgroundTasks.size > 0 && isBackgroundTaskVisible) { + if (!embeddedShellFocused) { + setEmbeddedShellFocused(true); + } + setIsBackgroundTaskListOpen(true); + } + return true; + } + return false; + }, + [ + constrainHeight, + setConstrainHeight, + setShowErrorDetails, + config, + ideContextState, + handleCtrlCPress, + handleCtrlDPress, + handleSlashCommand, + cancelOngoingRequest, + activePtyId, + handleSuspend, + embeddedShellFocused, + settings.merged.general.debugKeystrokeLogging, + refreshStatic, + setCopyModeEnabled, + tabFocusTimeoutRef, + isAlternateBuffer, + shortcutsHelpVisible, + backgroundCurrentExecution, + toggleBackgroundTasks, + backgroundTasks, + isBackgroundTaskVisible, + setIsBackgroundTaskListOpen, + lastOutputTimeRef, + showTransientMessage, + settings.merged.general.devtools, + showErrorDetails, + triggerExpandHint, + keyMatchers, + isHelpDismissKey, + historyManager.history, + pendingHistoryItems, + toggleAllExpansion, + dumpCurrentFrame, + startRecording, + stopRecording, + mouseMode, + ], + ); + + useKeypress(handleGlobalKeypress, { isActive: true, priority: true }); + + useKeypress( + (key: Key) => { + if ( + keyMatchers[Command.SCROLL_UP](key) || + keyMatchers[Command.SCROLL_DOWN](key) || + keyMatchers[Command.PAGE_UP](key) || + keyMatchers[Command.PAGE_DOWN](key) || + keyMatchers[Command.SCROLL_HOME](key) || + keyMatchers[Command.SCROLL_END](key) + ) { + return false; + } + + setCopyModeEnabled(false); + if (mouseMode) { + enableMouseEvents(); + } + return true; + }, + { + isActive: copyModeEnabled, + // We need to receive keypresses first so they do not bubble to other + // handlers. + priority: KeypressPriority.Critical, + }, + ); + + useEffect(() => { + // Respect hideWindowTitle settings + if (settings.merged.ui.hideWindowTitle) return; + + const paddedTitle = computeTerminalTitle({ + streamingState, + thoughtSubject: thought?.subject, + isConfirming: + !!commandConfirmationRequest || shouldShowActionRequiredTitle, + isSilentWorking: shouldShowSilentWorkingTitle, + folderName: basename(config.getTargetDir()), + showThoughts: !!settings.merged.ui.showStatusInTitle, + useDynamicTitle: settings.merged.ui.dynamicWindowTitle, + }); + + // Only update the title if it's different from the last value we set + if (lastTitleRef.current !== paddedTitle) { + lastTitleRef.current = paddedTitle; + stdout.write(`\x1b]0;${paddedTitle}\x07`); + } + // Note: We don't need to reset the window title on exit because Gemini CLI is already doing that elsewhere + }, [ + streamingState, + thought, + commandConfirmationRequest, + shouldShowActionRequiredTitle, + shouldShowSilentWorkingTitle, + settings.merged.ui.showStatusInTitle, + settings.merged.ui.dynamicWindowTitle, + settings.merged.ui.hideWindowTitle, + config, + stdout, + ]); + + useEffect(() => { + const handleUserFeedback = (payload: UserFeedbackPayload) => { + let type: MessageType; + switch (payload.severity) { + case 'error': + type = MessageType.ERROR; + break; + case 'warning': + type = MessageType.WARNING; + break; + case 'info': + type = MessageType.INFO; + break; + default: + throw new Error( + `Unexpected severity for user feedback: ${payload.severity}`, + ); + } + + historyManager.addItem( + { + type, + text: payload.message, + }, + Date.now(), + ); + + // If there is an attached error object, log it to the debug drawer. + if (payload.error) { + debugLogger.warn( + `[Feedback Details for "${payload.message}"]`, + payload.error, + ); + } + }; + + const handleHookSystemMessage = (payload: HookSystemMessagePayload) => { + historyManager.addItem( + { + type: MessageType.INFO, + text: payload.message, + source: payload.hookName, + } as HistoryItemInfo, + Date.now(), + ); + }; + + coreEvents.on(CoreEvent.UserFeedback, handleUserFeedback); + coreEvents.on(CoreEvent.HookSystemMessage, handleHookSystemMessage); + + // Flush any messages that happened during startup before this component + // mounted. + coreEvents.drainBacklogs(); + + return () => { + coreEvents.off(CoreEvent.UserFeedback, handleUserFeedback); + coreEvents.off(CoreEvent.HookSystemMessage, handleHookSystemMessage); + }; + }, [historyManager]); + + const nightly = props.version.includes('nightly'); + + const isAwaitingLoginRestart = authState === AuthState.AwaitingLoginRestart; + const loginRestartMessage = + settings.merged.security.auth.selectedType === AuthType.USE_VERTEX_AI + ? 'Authenticating to Vertex AI in Cloud Shell requires a restart to apply project settings.' + : undefined; + + const dialogsVisible = + shouldShowIdePrompt || + isFolderTrustDialogOpen || + isPolicyUpdateDialogOpen || + adminSettingsChanged || + !!commandConfirmationRequest || + !!authConsentRequest || + !!permissionConfirmationRequest || + !!customDialog || + confirmUpdateExtensionRequests.length > 0 || + !!loopDetectionConfirmationRequest || + isThemeDialogOpen || + isSettingsDialogOpen || + isModelDialogOpen || + isVoiceModelDialogOpen || + isAgentConfigDialogOpen || + isPermissionsDialogOpen || + isAuthenticating || + isAuthDialogOpen || + isEditorDialogOpen || + showPrivacyNotice || + showIdeRestartPrompt || + !!proQuotaRequest || + !!validationRequest || + !!overageMenuRequest || + !!emptyWalletRequest || + isSessionBrowserOpen || + authState === AuthState.AwaitingApiKeyInput || + isAwaitingLoginRestart || + !!newAgents; + + const hasPendingToolConfirmation = useMemo( + () => isToolAwaitingConfirmation(pendingHistoryItems), + [pendingHistoryItems], + ); + + const hasConfirmUpdateExtensionRequests = + confirmUpdateExtensionRequests.length > 0; + const hasLoopDetectionConfirmationRequest = + !!loopDetectionConfirmationRequest; + + const hasPendingActionRequired = + hasPendingToolConfirmation || + !!commandConfirmationRequest || + !!authConsentRequest || + hasConfirmUpdateExtensionRequests || + hasLoopDetectionConfirmationRequest || + !!proQuotaRequest || + !!validationRequest || + !!overageMenuRequest || + !!emptyWalletRequest || + !!customDialog; + + const loadingPhrases = settings.merged.ui.loadingPhrases; + const showStatusTips = loadingPhrases === 'tips' || loadingPhrases === 'all'; + const showStatusWit = loadingPhrases === 'witty' || loadingPhrases === 'all'; + + const showLoadingIndicator = + (!embeddedShellFocused || isBackgroundTaskVisible) && + streamingState === StreamingState.Responding && + !hasPendingActionRequired; + + let estimatedStatusLength = 0; + if (activeHooks.length > 0 && settings.merged.hooksConfig.notifications) { + const hookLabel = + activeHooks.length > 1 ? 'Executing Hooks' : 'Executing Hook'; + const hookNames = activeHooks + .map( + (h) => + h.name + + (h.index && h.total && h.total > 1 ? ` (${h.index}/${h.total})` : ''), + ) + .join(', '); + estimatedStatusLength = hookLabel.length + hookNames.length + 10; + } else if (showLoadingIndicator) { + const thoughtText = thought?.subject || 'Waiting for model...'; + estimatedStatusLength = thoughtText.length + 25; + } else if (hasPendingActionRequired) { + estimatedStatusLength = 35; + } + + const maxLength = terminalWidth - estimatedStatusLength - 5; + + const { elapsedTime, currentLoadingPhrase, currentTip, currentWittyPhrase } = + useLoadingIndicator({ + streamingState, + shouldShowFocusHint, + retryStatus, + showTips: showStatusTips, + showWit: showStatusWit, + customWittyPhrases: settings.merged.ui.customWittyPhrases, + errorVerbosity: settings.merged.ui.errorVerbosity, + maxLength, + }); + + const allowPlanMode = + config.isPlanEnabled() && + streamingState === StreamingState.Idle && + !hasPendingActionRequired; + + const showApprovalModeIndicator = useApprovalModeIndicator({ + config, + addItem: historyManager.addItem, + onApprovalModeChange: handleApprovalModeChangeWithUiReveal, + isActive: !embeddedShellFocused, + allowPlanMode, + }); + + useRunEventNotifications({ + notificationsEnabled, + notificationMethod, + isFocused, + hasReceivedFocusEvent, + streamingState, + hasPendingActionRequired, + pendingHistoryItems, + commandConfirmationRequest, + authConsentRequest, + permissionConfirmationRequest, + hasConfirmUpdateExtensionRequests, + hasLoopDetectionConfirmationRequest, + }); + + const isPassiveShortcutsHelpState = + isInputActive && + streamingState === StreamingState.Idle && + !hasPendingActionRequired; + + useEffect(() => { + if (shortcutsHelpVisible && !isPassiveShortcutsHelpState) { + setShortcutsHelpVisible(false); + } + }, [ + shortcutsHelpVisible, + isPassiveShortcutsHelpState, + setShortcutsHelpVisible, + ]); + + useEffect(() => { + if ( + !isConfigInitialized || + !config.isModelSteeringEnabled() || + streamingState !== StreamingState.Idle || + !isMcpReady || + isToolAwaitingConfirmation(pendingHistoryItems) + ) { + return; + } + + const pendingHint = consumePendingHints(); + if (!pendingHint) { + return; + } + + void submitQuery([{ text: buildUserSteeringHintPrompt(pendingHint) }]); + }, [ + config, + historyManager, + isConfigInitialized, + isMcpReady, + streamingState, + submitQuery, + consumePendingHints, + pendingHistoryItems, + pendingHintCount, + ]); + + const allToolCalls = useMemo( + () => getAllToolCalls(pendingHistoryItems), + [pendingHistoryItems], + ); + + const [geminiMdFileCount, setGeminiMdFileCount] = useState( + config.getGeminiMdFileCount(), + ); + useEffect(() => { + const handleMemoryChanged = (result: MemoryChangedPayload) => { + setGeminiMdFileCount(result.fileCount); + }; + coreEvents.on(CoreEvent.MemoryChanged, handleMemoryChanged); + return () => { + coreEvents.off(CoreEvent.MemoryChanged, handleMemoryChanged); + }; + }, []); + + useEffect(() => { + let isMounted = true; + + const fetchBannerTexts = async () => { + const [defaultBanner, warningBanner] = await Promise.all([ + config.getBannerTextNoCapacityIssues(), + config.getBannerTextCapacityIssues(), + ]); + + if (isMounted) { + setDefaultBannerText(defaultBanner); + setWarningBannerText(warningBanner); + setBannerVisible(true); + } + }; + // eslint-disable-next-line @typescript-eslint/no-floating-promises + fetchBannerTexts(); + + return () => { + isMounted = false; + }; + }, [config, refreshStatic]); + + const inputState = useMemo( + () => ({ + buffer, + userMessages: inputHistory, + shellModeActive, + showEscapePrompt, + copyModeEnabled, + inputWidth, + suggestionsWidth, + }), + [ + buffer, + inputHistory, + shellModeActive, + showEscapePrompt, + copyModeEnabled, + inputWidth, + suggestionsWidth, + ], + ); + + const quotaState = useMemo( + () => ({ + userTier, + stats: quotaStats, + proQuotaRequest, + validationRequest, + // G1 AI Credits dialog state + overageMenuRequest, + emptyWalletRequest, + }), + [ + userTier, + quotaStats, + proQuotaRequest, + validationRequest, + overageMenuRequest, + emptyWalletRequest, + ], + ); + + const uiState: UIState = useMemo( + () => ({ + history: historyManager.history, + historyManager, + isThemeDialogOpen, + + themeError, + isAuthenticating, + isConfigInitialized, + authError, + accountSuspensionInfo, + isAuthDialogOpen, + isAwaitingApiKeyInput: authState === AuthState.AwaitingApiKeyInput, + isAwaitingLoginRestart, + loginRestartMessage, + apiKeyDefaultValue, + editorError, + isEditorDialogOpen, + showPrivacyNotice, + mouseMode, + corgiMode, + debugMessage, + quittingMessages, + isSettingsDialogOpen, + isSessionBrowserOpen, + isModelDialogOpen, + isVoiceModelDialogOpen, + isAgentConfigDialogOpen, + selectedAgentName, + selectedAgentDisplayName, + selectedAgentDefinition, + isPermissionsDialogOpen, + permissionsDialogProps, + slashCommands, + pendingSlashCommandHistoryItems, + commandContext, + commandConfirmationRequest, + authConsentRequest, + confirmUpdateExtensionRequests, + loopDetectionConfirmationRequest, + permissionConfirmationRequest, + geminiMdFileCount, + streamingState, + initError, + pendingGeminiHistoryItems, + thought, + isInputActive, + isVoiceModeEnabled, + isResuming, + shouldShowIdePrompt, + isFolderTrustDialogOpen: isFolderTrustDialogOpen ?? false, + folderDiscoveryResults, + isPolicyUpdateDialogOpen, + policyUpdateConfirmationRequest, + isTrustedFolder, + constrainHeight, + showErrorDetails, + showFullTodos, + ideContextState, + renderMarkdown, + ctrlCPressedOnce: ctrlCPressCount >= 1, + ctrlDPressedOnce: ctrlDPressCount >= 1, + shortcutsHelpVisible, + cleanUiDetailsVisible, + isFocused, + elapsedTime, + currentLoadingPhrase, + currentTip, + currentWittyPhrase, + historyRemountKey, + activeHooks, + messageQueue, + queueErrorMessage, + showApprovalModeIndicator, + allowPlanMode, + currentModel, + contextFileNames, + errorCount, + availableTerminalHeight, + stableControlsHeight, + mainAreaWidth, + staticAreaMaxItemHeight, + staticExtraHeight, + dialogsVisible, + pendingHistoryItems, + nightly, + branchName, + sessionStats, + terminalWidth, + terminalHeight, + mainControlsRef, + rootUiRef, + currentIDE, + updateInfo, + showIdeRestartPrompt, + ideTrustRestartReason, + isRestarting, + extensionsUpdateState, + activePtyId, + backgroundTaskCount, + isBackgroundTaskVisible, + embeddedShellFocused, + showDebugProfiler, + customDialog, + transientMessage, + bannerData, + bannerVisible, + terminalBackgroundColor: config.getTerminalBackground(), + settingsNonce, + backgroundTasks, + activeBackgroundTaskPid, + backgroundTaskHeight, + isBackgroundTaskListOpen, + adminSettingsChanged, + newAgents, + showIsExpandableHint, + hintMode: + config.isModelSteeringEnabled() && isToolExecuting(pendingHistoryItems), + hintBuffer: '', + }), + [ + isThemeDialogOpen, + + themeError, + isAuthenticating, + isConfigInitialized, + authError, + accountSuspensionInfo, + isAuthDialogOpen, + editorError, + isEditorDialogOpen, + showPrivacyNotice, + mouseMode, + corgiMode, + debugMessage, + quittingMessages, + isSettingsDialogOpen, + isSessionBrowserOpen, + isModelDialogOpen, + isVoiceModelDialogOpen, + isAgentConfigDialogOpen, + selectedAgentName, + selectedAgentDisplayName, + selectedAgentDefinition, + isPermissionsDialogOpen, + permissionsDialogProps, + slashCommands, + pendingSlashCommandHistoryItems, + commandContext, + commandConfirmationRequest, + authConsentRequest, + confirmUpdateExtensionRequests, + loopDetectionConfirmationRequest, + permissionConfirmationRequest, + geminiMdFileCount, + streamingState, + initError, + pendingGeminiHistoryItems, + thought, + isInputActive, + isVoiceModeEnabled, + isResuming, + shouldShowIdePrompt, + isFolderTrustDialogOpen, + folderDiscoveryResults, + isPolicyUpdateDialogOpen, + policyUpdateConfirmationRequest, + isTrustedFolder, + constrainHeight, + showErrorDetails, + showFullTodos, + ideContextState, + renderMarkdown, + ctrlCPressCount, + ctrlDPressCount, + shortcutsHelpVisible, + cleanUiDetailsVisible, + isFocused, + elapsedTime, + currentLoadingPhrase, + currentTip, + currentWittyPhrase, + historyRemountKey, + activeHooks, + messageQueue, + queueErrorMessage, + showApprovalModeIndicator, + allowPlanMode, + contextFileNames, + errorCount, + availableTerminalHeight, + stableControlsHeight, + mainAreaWidth, + staticAreaMaxItemHeight, + staticExtraHeight, + dialogsVisible, + pendingHistoryItems, + nightly, + branchName, + sessionStats, + terminalWidth, + terminalHeight, + mainControlsRef, + rootUiRef, + currentIDE, + updateInfo, + showIdeRestartPrompt, + ideTrustRestartReason, + isRestarting, + currentModel, + extensionsUpdateState, + activePtyId, + backgroundTaskCount, + isBackgroundTaskVisible, + historyManager, + embeddedShellFocused, + showDebugProfiler, + customDialog, + apiKeyDefaultValue, + authState, + isAwaitingLoginRestart, + loginRestartMessage, + transientMessage, + bannerData, + bannerVisible, + config, + settingsNonce, + backgroundTaskHeight, + isBackgroundTaskListOpen, + activeBackgroundTaskPid, + backgroundTasks, + adminSettingsChanged, + newAgents, + showIsExpandableHint, + ], + ); + + const exitPrivacyNotice = useCallback( + () => setShowPrivacyNotice(false), + [setShowPrivacyNotice], + ); + + const uiActions: UIActions = useMemo( + () => ({ + handleThemeSelect, + closeThemeDialog, + handleThemeHighlight, + handleAuthSelect, + setAuthState, + onAuthError, + handleEditorSelect, + exitEditorDialog, + exitPrivacyNotice, + closeSettingsDialog, + closeModelDialog, + openVoiceModelDialog, + closeVoiceModelDialog, + openAgentConfigDialog, + closeAgentConfigDialog, + openPermissionsDialog, + closePermissionsDialog, + setShellModeActive, + vimHandleInput, + handleIdePromptComplete, + handleFolderTrustSelect, + setIsPolicyUpdateDialogOpen, + setConstrainHeight, + onEscapePromptChange: handleEscapePromptChange, + refreshStatic, + handleFinalSubmit, + handleClearScreen, + handleProQuotaChoice, + handleValidationChoice, + // G1 AI Credits handlers + handleOverageMenuChoice, + handleEmptyWalletChoice, + openSessionBrowser, + closeSessionBrowser, + handleResumeSession, + handleDeleteSession, + setQueueErrorMessage, + addMessage, + popAllMessages, + handleApiKeySubmit, + handleApiKeyCancel, + setBannerVisible, + setShortcutsHelpVisible, + setCleanUiDetailsVisible, + toggleCleanUiDetailsVisible, + revealCleanUiDetailsTemporarily, + handleWarning, + setEmbeddedShellFocused, + dismissBackgroundTask, + setActiveBackgroundTaskPid, + setIsBackgroundTaskListOpen, + setAuthContext, + dismissLoginRestart: () => { + setAuthContext({}); + setAuthState(AuthState.Updating); + }, + onHintInput: () => {}, + onHintBackspace: () => {}, + onHintClear: () => {}, + onHintSubmit: () => {}, + handleRestart: async () => { + if (process.send) { + const remoteSettings = config.getRemoteAdminSettings(); + if (remoteSettings) { + process.send({ + type: 'admin-settings-update', + settings: remoteSettings, + }); + } + } + await relaunchApp(); + }, + handleNewAgentsSelect: async (choice: NewAgentsChoice) => { + if (newAgents && choice === NewAgentsChoice.ACKNOWLEDGE) { + const registry = config.getAgentRegistry(); + try { + await Promise.all( + newAgents.map((agent) => registry.acknowledgeAgent(agent)), + ); + } catch (error) { + debugLogger.error('Failed to acknowledge agents:', error); + historyManager.addItem( + { + type: MessageType.ERROR, + text: `Failed to acknowledge agents: ${getErrorMessage(error)}`, + }, + Date.now(), + ); + } + } + setNewAgents(null); + }, + getPreferredEditor, + clearAccountSuspension: () => { + setAccountSuspensionInfo(null); + setAuthState(AuthState.Updating); + }, + setVoiceModeEnabled: (value: boolean) => { + setVoiceModeEnabled(value); + }, + }), + [ + handleThemeSelect, + closeThemeDialog, + handleThemeHighlight, + handleAuthSelect, + setAuthState, + onAuthError, + handleEditorSelect, + exitEditorDialog, + exitPrivacyNotice, + closeSettingsDialog, + closeModelDialog, + openVoiceModelDialog, + closeVoiceModelDialog, + openAgentConfigDialog, + closeAgentConfigDialog, + openPermissionsDialog, + closePermissionsDialog, + setShellModeActive, + vimHandleInput, + handleIdePromptComplete, + handleFolderTrustSelect, + setIsPolicyUpdateDialogOpen, + setConstrainHeight, + handleEscapePromptChange, + refreshStatic, + handleFinalSubmit, + handleClearScreen, + handleProQuotaChoice, + handleValidationChoice, + handleOverageMenuChoice, + handleEmptyWalletChoice, + openSessionBrowser, + closeSessionBrowser, + handleResumeSession, + handleDeleteSession, + setQueueErrorMessage, + addMessage, + popAllMessages, + handleApiKeySubmit, + handleApiKeyCancel, + setBannerVisible, + setShortcutsHelpVisible, + setCleanUiDetailsVisible, + toggleCleanUiDetailsVisible, + revealCleanUiDetailsTemporarily, + handleWarning, + setEmbeddedShellFocused, + dismissBackgroundTask, + setActiveBackgroundTaskPid, + setIsBackgroundTaskListOpen, + setAuthContext, + setAccountSuspensionInfo, + newAgents, + config, + historyManager, + getPreferredEditor, + setVoiceModeEnabled, + ], + ); + + return ( + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/packages/cli/src/ui/IdeIntegrationNudge.test.tsx b/packages/cli/src/ui/IdeIntegrationNudge.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d05a17dad8133a8009d19b17293a099b8dec95a2 --- /dev/null +++ b/packages/cli/src/ui/IdeIntegrationNudge.test.tsx @@ -0,0 +1,192 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { renderWithProviders } from '../test-utils/render.js'; +import { act } from 'react'; +import { IdeIntegrationNudge } from './IdeIntegrationNudge.js'; +import { debugLogger } from '@google/gemini-cli-core'; + +// Mock debugLogger +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + debugLogger: { + log: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, + }; +}); + +describe('IdeIntegrationNudge', () => { + const defaultProps = { + ide: { + name: 'vscode', + displayName: 'VS Code', + }, + onComplete: vi.fn(), + }; + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + beforeEach(() => { + vi.mocked(debugLogger.warn).mockImplementation((...args) => { + if ( + // eslint-disable-next-line no-restricted-syntax + typeof args[0] === 'string' && + /was not wrapped in act/.test(args[0]) + ) { + return; + } + }); + vi.stubEnv('GEMINI_CLI_IDE_SERVER_PORT', ''); + vi.stubEnv('GEMINI_CLI_IDE_WORKSPACE_PATH', ''); + }); + + it('renders correctly with default options', async () => { + const { lastFrame, unmount } = await renderWithProviders( + , + ); + const frame = lastFrame(); + + expect(frame).toContain('Do you want to connect VS Code to Gemini CLI?'); + expect(frame).toContain('Yes'); + expect(frame).toContain('No (esc)'); + expect(frame).toContain("No, don't ask again"); + unmount(); + }); + + it('handles "Yes" selection', async () => { + const onComplete = vi.fn(); + const { stdin, waitUntilReady, unmount } = await renderWithProviders( + , + ); + + // "Yes" is the first option and selected by default usually. + await act(async () => { + stdin.write('\r'); + }); + await waitUntilReady(); + + expect(onComplete).toHaveBeenCalledWith({ + userSelection: 'yes', + isExtensionPreInstalled: false, + }); + unmount(); + }); + + it('handles "No" selection', async () => { + const onComplete = vi.fn(); + const { stdin, waitUntilReady, unmount } = await renderWithProviders( + , + ); + + // Navigate down to "No (esc)" + await act(async () => { + stdin.write('\u001B[B'); // Down arrow + }); + await waitUntilReady(); + + await act(async () => { + stdin.write('\r'); // Enter + }); + await waitUntilReady(); + + expect(onComplete).toHaveBeenCalledWith({ + userSelection: 'no', + isExtensionPreInstalled: false, + }); + unmount(); + }); + + it('handles "Dismiss" selection', async () => { + const onComplete = vi.fn(); + const { stdin, waitUntilReady, unmount } = await renderWithProviders( + , + ); + + // Navigate down to "No, don't ask again" + await act(async () => { + stdin.write('\u001B[B'); // Down arrow + }); + await waitUntilReady(); + + await act(async () => { + stdin.write('\u001B[B'); // Down arrow + }); + await waitUntilReady(); + + await act(async () => { + stdin.write('\r'); // Enter + }); + await waitUntilReady(); + + expect(onComplete).toHaveBeenCalledWith({ + userSelection: 'dismiss', + isExtensionPreInstalled: false, + }); + unmount(); + }); + + it('handles Escape key press', async () => { + const onComplete = vi.fn(); + const { stdin, waitUntilReady, unmount } = await renderWithProviders( + , + ); + + // Press Escape + await act(async () => { + stdin.write('\u001B'); + }); + // Escape key has a timeout in KeypressContext, so we need to wrap waitUntilReady in act + await act(async () => { + await waitUntilReady(); + }); + + expect(onComplete).toHaveBeenCalledWith({ + userSelection: 'no', + isExtensionPreInstalled: false, + }); + unmount(); + }); + + it('displays correct text and handles selection when extension is pre-installed', async () => { + vi.stubEnv('GEMINI_CLI_IDE_SERVER_PORT', '1234'); + vi.stubEnv('GEMINI_CLI_IDE_WORKSPACE_PATH', '/tmp'); + + const onComplete = vi.fn(); + const { lastFrame, stdin, waitUntilReady, unmount } = + await renderWithProviders( + , + ); + + const frame = lastFrame(); + + expect(frame).toContain( + 'If you select Yes, the CLI will have access to your open files', + ); + expect(frame).not.toContain("we'll install an extension"); + + // Select "Yes" + await act(async () => { + stdin.write('\r'); + }); + await waitUntilReady(); + + expect(onComplete).toHaveBeenCalledWith({ + userSelection: 'yes', + isExtensionPreInstalled: true, + }); + unmount(); + }); +}); diff --git a/packages/cli/src/ui/IdeIntegrationNudge.tsx b/packages/cli/src/ui/IdeIntegrationNudge.tsx new file mode 100644 index 0000000000000000000000000000000000000000..37823cf8a8d03962933735885293077ca50bf290 --- /dev/null +++ b/packages/cli/src/ui/IdeIntegrationNudge.tsx @@ -0,0 +1,104 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { IdeInfo } from '@google/gemini-cli-core'; +import { Box, Text } from 'ink'; +import { + RadioButtonSelect, + type RadioSelectItem, +} from './components/shared/RadioButtonSelect.js'; +import { useKeypress } from './hooks/useKeypress.js'; +import { theme } from './semantic-colors.js'; + +export type IdeIntegrationNudgeResult = { + userSelection: 'yes' | 'no' | 'dismiss'; + isExtensionPreInstalled: boolean; +}; + +interface IdeIntegrationNudgeProps { + ide: IdeInfo; + onComplete: (result: IdeIntegrationNudgeResult) => void; +} + +export function IdeIntegrationNudge({ + ide, + onComplete, +}: IdeIntegrationNudgeProps) { + useKeypress( + (key) => { + if (key.name === 'escape') { + onComplete({ + userSelection: 'no', + isExtensionPreInstalled: false, + }); + return true; + } + return false; + }, + { isActive: true }, + ); + + const { displayName: ideName } = ide; + // Assume extension is already installed if the env variables are set. + const isExtensionPreInstalled = + !!process.env['GEMINI_CLI_IDE_SERVER_PORT'] && + !!process.env['GEMINI_CLI_IDE_WORKSPACE_PATH']; + + const OPTIONS: Array> = [ + { + label: 'Yes', + value: { + userSelection: 'yes', + isExtensionPreInstalled, + }, + key: 'Yes', + }, + { + label: 'No (esc)', + value: { + userSelection: 'no', + isExtensionPreInstalled, + }, + key: 'No (esc)', + }, + { + label: "No, don't ask again", + value: { + userSelection: 'dismiss', + isExtensionPreInstalled, + }, + key: "No, don't ask again", + }, + ]; + + const installText = isExtensionPreInstalled + ? `If you select Yes, the CLI will have access to your open files and display diffs directly in ${ + ideName ?? 'your editor' + }.` + : `If you select Yes, we'll install an extension that allows the CLI to access your open files and display diffs directly in ${ + ideName ?? 'your editor' + }.`; + + return ( + + + + {'> '} + {`Do you want to connect ${ideName ?? 'your editor'} to Gemini CLI?`} + + {installText} + + + + ); +} diff --git a/packages/cli/src/ui/ToolConfirmationFullFrame.test.tsx b/packages/cli/src/ui/ToolConfirmationFullFrame.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5fde51c429da76f77638604b4b6fbfbb6514ada8 --- /dev/null +++ b/packages/cli/src/ui/ToolConfirmationFullFrame.test.tsx @@ -0,0 +1,177 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { cleanup, renderWithProviders } from '../test-utils/render.js'; +import { createMockSettings } from '../test-utils/settings.js'; +import { App } from './App.js'; +import { + CoreToolCallStatus, + ApprovalMode, + makeFakeConfig, + type SerializableConfirmationDetails, +} from '@google/gemini-cli-core'; +import { type UIState } from './contexts/UIStateContext.js'; +import { act } from 'react'; +import { StreamingState } from './types.js'; + +vi.mock('ink', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + useIsScreenReaderEnabled: vi.fn(() => false), + }; +}); + +vi.mock('./components/GeminiSpinner.js', () => ({ + GeminiSpinner: () => null, +})); + +vi.mock('./components/CliSpinner.js', () => ({ + CliSpinner: () => null, +})); + +// Mock hooks to align with codebase style, even if App uses UIState directly +vi.mock('./hooks/useGeminiStream.js'); +vi.mock('./hooks/useHistoryManager.js'); +vi.mock('./hooks/useQuotaAndFallback.js'); +vi.mock('./hooks/useThemeCommand.js'); +vi.mock('./auth/useAuth.js'); +vi.mock('./hooks/useEditorSettings.js'); +vi.mock('./hooks/useSettingsCommand.js'); +vi.mock('./hooks/useModelCommand.js'); +vi.mock('./hooks/slashCommandProcessor.js'); +vi.mock('./hooks/useConsoleMessages.js'); +vi.mock('./hooks/useTerminalSize.js', () => ({ + useTerminalSize: vi.fn(() => ({ columns: 100, rows: 30 })), +})); + +describe('Full Terminal Tool Confirmation Snapshot', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + }); + + it('renders tool confirmation box in the frame of the entire terminal', async () => { + // Generate a large diff to warrant truncation + let largeDiff = + '--- a/packages/cli/src/ui/components/InputPrompt.tsx\n+++ b/packages/cli/src/ui/components/InputPrompt.tsx\n@@ -1,100 +1,105 @@\n'; + for (let i = 1; i <= 60; i++) { + largeDiff += ` const line${i} = true;\n`; + } + largeDiff += '- return kittyProtocolSupporte...;\n'; + largeDiff += '+ return kittyProtocolSupporte...;\n'; + largeDiff += ' buffer: TextBuffer;\n'; + largeDiff += ' onSubmit: (value: string) => void;'; + + const confirmationDetails: SerializableConfirmationDetails = { + type: 'edit', + title: 'Edit packages/.../InputPrompt.tsx', + fileName: 'InputPrompt.tsx', + filePath: 'packages/.../InputPrompt.tsx', + fileDiff: largeDiff, + originalContent: 'old', + newContent: 'new', + isModifying: false, + }; + + const toolCalls = [ + { + callId: 'call-1-modify-selected', + name: 'Edit', + description: + 'packages/.../InputPrompt.tsx: return kittyProtocolSupporte... => return kittyProtocolSupporte...', + status: CoreToolCallStatus.AwaitingApproval, + resultDisplay: '', + confirmationDetails, + }, + ]; + + const mockUIState = { + history: [ + { + id: 1, + type: 'user', + text: 'Can you edit InputPrompt.tsx for me?', + }, + ], + mainAreaWidth: 99, + availableTerminalHeight: 36, + streamingState: StreamingState.WaitingForConfirmation, + constrainHeight: true, + isConfigInitialized: true, + cleanUiDetailsVisible: true, + pendingHistoryItems: [ + { + id: 2, + type: 'tool_group', + tools: toolCalls, + }, + ], + showApprovalModeIndicator: ApprovalMode.DEFAULT, + sessionStats: { + lastPromptTokenCount: 175400, + contextPercentage: 3, + }, + buffer: { text: '' }, + messageQueue: [], + activeHooks: [], + contextFileNames: [], + rootUiRef: { current: null }, + } as unknown as UIState; + + const mockConfig = makeFakeConfig(); + mockConfig.getUseAlternateBuffer = () => true; + mockConfig.isTrustedFolder = () => true; + mockConfig.getDisableAlwaysAllow = () => false; + mockConfig.getIdeMode = () => false; + mockConfig.getTargetDir = () => '/directory'; + + const { waitUntilReady, lastFrame, generateSvg, unmount } = + await renderWithProviders(, { + uiState: mockUIState, + quotaState: { + userTier: 'PRO', + stats: { + remaining: 100, + limit: 1000, + }, + }, + config: mockConfig, + settings: createMockSettings({ + merged: { + ui: { + useAlternateBuffer: true, + theme: 'default', + showUserIdentity: false, + showShortcutsHint: false, + footer: { + hideContextPercentage: false, + hideTokens: false, + hideModel: false, + }, + }, + security: { + enablePermanentToolApproval: true, + }, + }, + }), + }); + + await waitUntilReady(); + + // Give it a moment to render + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 500)); + }); + + await expect({ lastFrame, generateSvg }).toMatchSvgSnapshot(); + unmount(); + }); +}); diff --git a/packages/cli/src/ui/colors.ts b/packages/cli/src/ui/colors.ts new file mode 100644 index 0000000000000000000000000000000000000000..c602f587fbd5aae9fe3db892a549df1645c7d504 --- /dev/null +++ b/packages/cli/src/ui/colors.ts @@ -0,0 +1,65 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { themeManager } from './themes/theme-manager.js'; +import type { ColorsTheme } from './themes/theme.js'; + +export const Colors: ColorsTheme = { + get type() { + return themeManager.getActiveTheme().colors.type; + }, + get Foreground() { + return themeManager.getActiveTheme().colors.Foreground; + }, + get Background() { + return themeManager.getColors().Background; + }, + get LightBlue() { + return themeManager.getActiveTheme().colors.LightBlue; + }, + get AccentBlue() { + return themeManager.getActiveTheme().colors.AccentBlue; + }, + get AccentPurple() { + return themeManager.getActiveTheme().colors.AccentPurple; + }, + get AccentCyan() { + return themeManager.getActiveTheme().colors.AccentCyan; + }, + get AccentGreen() { + return themeManager.getActiveTheme().colors.AccentGreen; + }, + get AccentYellow() { + return themeManager.getActiveTheme().colors.AccentYellow; + }, + get AccentRed() { + return themeManager.getActiveTheme().colors.AccentRed; + }, + get DiffAdded() { + return themeManager.getActiveTheme().colors.DiffAdded; + }, + get DiffRemoved() { + return themeManager.getActiveTheme().colors.DiffRemoved; + }, + get Comment() { + return themeManager.getActiveTheme().colors.Comment; + }, + get Gray() { + return themeManager.getActiveTheme().colors.Gray; + }, + get DarkGray() { + return themeManager.getColors().DarkGray; + }, + get InputBackground() { + return themeManager.getColors().InputBackground; + }, + get MessageBackground() { + return themeManager.getColors().MessageBackground; + }, + get GradientColors() { + return themeManager.getActiveTheme().colors.GradientColors; + }, +}; diff --git a/packages/cli/src/ui/constants.ts b/packages/cli/src/ui/constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..ebed09763708734e1e886419b3154c8943a1434b --- /dev/null +++ b/packages/cli/src/ui/constants.ts @@ -0,0 +1,80 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export const SHELL_COMMAND_NAME = 'Shell Command'; + +export const SHELL_NAME = 'Shell'; + +// Limit Gemini messages to a very high number of lines to mitigate performance +// issues in the worst case if we somehow get an enormous response from Gemini. +// This threshold is arbitrary but should be high enough to never impact normal +// usage. +export const MAX_GEMINI_MESSAGE_LINES = 65536; + +export const SHELL_FOCUS_HINT_DELAY_MS = 5000; + +// Tool status symbols used in ToolMessage component +export const TOOL_STATUS = { + SUCCESS: '✓', + PENDING: 'o', + EXECUTING: '⊷', + CONFIRMING: '?', + CANCELED: '-', + ERROR: 'x', +} as const; + +// Maximum number of MCP resources to display per server before truncating +export const MAX_MCP_RESOURCES_TO_SHOW = 10; + +export const WARNING_PROMPT_DURATION_MS = 3000; +export const QUEUE_ERROR_DISPLAY_DURATION_MS = 3000; +export const SHELL_ACTION_REQUIRED_TITLE_DELAY_MS = 30000; +export const SHELL_SILENT_WORKING_TITLE_DELAY_MS = 120000; +export const EXPAND_HINT_DURATION_MS = 5000; + +export const DEFAULT_BACKGROUND_OPACITY = 0.16; +export const DEFAULT_INPUT_BACKGROUND_OPACITY = 0.24; +export const DEFAULT_SELECTION_OPACITY = 0.2; +export const DEFAULT_BORDER_OPACITY = 0.4; + +export const KEYBOARD_SHORTCUTS_URL = + 'https://geminicli.com/docs/cli/keyboard-shortcuts/'; +export const LRU_BUFFER_PERF_CACHE_LIMIT = 20000; + +// Max lines to show for active shell output when not focused +export const ACTIVE_SHELL_MAX_LINES = 15; + +// Max lines to preserve in history for completed shell commands +export const COMPLETED_SHELL_MAX_LINES = 15; + +// Max lines to show for subagent results before collapsing +export const SUBAGENT_MAX_LINES = 15; + +/** Minimum terminal width required to show the full context used label */ +export const MIN_TERMINAL_WIDTH_FOR_FULL_LABEL = 100; + +/** Default context usage fraction at which to trigger compression */ +export const DEFAULT_COMPRESSION_THRESHOLD = 0.5; + +/** Documentation URL for skills setup and configuration */ +export const SKILLS_DOCS_URL = + 'https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/skills.md'; + +/** Max lines to show for a compact tool subview (e.g. diff) */ +export const COMPACT_TOOL_SUBVIEW_MAX_LINES = 15; + +// Maximum number of UTF-16 code units to retain in a background task's output +// buffer. Beyond this, the oldest output is dropped to keep memory bounded. +// 10 MB is large enough for ~200,000 lines of terminal output and stays well +// below the V8 string length limit (~1 GB) even with multiple concurrent tasks. +export const MAX_SHELL_OUTPUT_SIZE = 10_000_000; // 10 MB + +// Truncation is triggered only once the output exceeds +// MAX_SHELL_OUTPUT_SIZE + SHELL_OUTPUT_TRUNCATION_BUFFER, then sliced back to +// MAX_SHELL_OUTPUT_SIZE. This avoids an O(n) string copy on every appended +// chunk, amortizing the cost to once per SHELL_OUTPUT_TRUNCATION_BUFFER bytes +// of new input (i.e. once per ~1 MB on a busy shell). +export const SHELL_OUTPUT_TRUNCATION_BUFFER = 1_000_000; // 1 MB diff --git a/packages/cli/src/ui/debug.ts b/packages/cli/src/ui/debug.ts new file mode 100644 index 0000000000000000000000000000000000000000..833dcc8b810c75ae3a5f6f06022e75e4f7e6359a --- /dev/null +++ b/packages/cli/src/ui/debug.ts @@ -0,0 +1,11 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// A top-level field to track the total number of active animated components. +// This is used for testing to ensure we wait for animations to finish. +export const debugState = { + debugNumAnimatedComponents: 0, +}; diff --git a/packages/cli/src/ui/semantic-colors.ts b/packages/cli/src/ui/semantic-colors.ts new file mode 100644 index 0000000000000000000000000000000000000000..88e75833f8427c86f5f43c0b93cf52616280af09 --- /dev/null +++ b/packages/cli/src/ui/semantic-colors.ts @@ -0,0 +1,26 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { themeManager } from './themes/theme-manager.js'; +import type { SemanticColors } from './themes/semantic-tokens.js'; + +export const theme: SemanticColors = { + get text() { + return themeManager.getSemanticColors().text; + }, + get background() { + return themeManager.getSemanticColors().background; + }, + get border() { + return themeManager.getSemanticColors().border; + }, + get ui() { + return themeManager.getSemanticColors().ui; + }, + get status() { + return themeManager.getSemanticColors().status; + }, +}; diff --git a/packages/cli/src/ui/textConstants.ts b/packages/cli/src/ui/textConstants.ts new file mode 100644 index 0000000000000000000000000000000000000000..eaef8bf0ff5981c52e037af6c382b68674cd45f4 --- /dev/null +++ b/packages/cli/src/ui/textConstants.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export const SCREEN_READER_USER_PREFIX = 'User: '; + +export const SCREEN_READER_MODEL_PREFIX = 'Model: '; + +export const SCREEN_READER_LOADING = 'loading'; + +export const SCREEN_READER_RESPONDING = 'responding'; + +export const REDIRECTION_WARNING_NOTE_LABEL = 'Note: '; +export const REDIRECTION_WARNING_NOTE_TEXT = + 'Command contains redirection which can be undesirable.'; +export const REDIRECTION_WARNING_TIP_LABEL = 'Tip: '; // Padded to align with "Note: " +export const getRedirectionWarningTipText = (shiftTabHint: string) => + `Toggle auto-edit (${shiftTabHint}) to allow redirection in the future.`; + +export const GENERIC_WORKING_LABEL = 'Working...'; diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..5ed6e05723cc3a0fc1de4036b03f8a3d3ef75ee6 --- /dev/null +++ b/packages/cli/src/ui/types.ts @@ -0,0 +1,582 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + type CompressionStatus, + type GeminiCLIExtension, + type MCPServerConfig, + type ThoughtSummary, + type SerializableConfirmationDetails, + type ToolResultDisplay, + type ToolDisplay, + type RetrieveUserQuotaResponse, + type SkillDefinition, + type AgentDefinition, + type ApprovalMode, + type Kind, + type AnsiOutput, + CoreToolCallStatus, + checkExhaustive, + type SubagentActivityItem, +} from '@google/gemini-cli-core'; +import type { PartListUnion } from '@google/genai'; +import { type ReactNode } from 'react'; + +export { CoreToolCallStatus }; +export type { + ThoughtSummary, + SkillDefinition, + SerializableConfirmationDetails, + ToolResultDisplay, +}; + +export enum AuthState { + // Attempting to authenticate or re-authenticate + Unauthenticated = 'unauthenticated', + // Auth dialog is open for user to select auth method + Updating = 'updating', + // Waiting for user to input API key + AwaitingApiKeyInput = 'awaiting_api_key_input', + // Successfully authenticated + Authenticated = 'authenticated', + // Waiting for the user to restart after a login + AwaitingLoginRestart = 'awaiting_login_restart', +} + +// Only defining the state enum needed by the UI +export enum StreamingState { + Idle = 'idle', + Responding = 'responding', + WaitingForConfirmation = 'waiting_for_confirmation', +} + +// Copied from server/src/core/turn.ts for CLI usage +export enum GeminiEventType { + Content = 'content', + ToolCallRequest = 'tool_call_request', + // Add other event types if the UI hook needs to handle them +} + +export enum ToolCallStatus { + Pending = 'Pending', + Canceled = 'Canceled', + Confirming = 'Confirming', + Executing = 'Executing', + Success = 'Success', + Error = 'Error', +} + +/** + * Maps core tool call status to a simplified UI status. + */ +export function mapCoreStatusToDisplayStatus( + coreStatus: CoreToolCallStatus, +): ToolCallStatus { + switch (coreStatus) { + case CoreToolCallStatus.Validating: + return ToolCallStatus.Pending; + case CoreToolCallStatus.AwaitingApproval: + return ToolCallStatus.Confirming; + case CoreToolCallStatus.Executing: + return ToolCallStatus.Executing; + case CoreToolCallStatus.Success: + return ToolCallStatus.Success; + case CoreToolCallStatus.Cancelled: + return ToolCallStatus.Canceled; + case CoreToolCallStatus.Error: + return ToolCallStatus.Error; + case CoreToolCallStatus.Scheduled: + return ToolCallStatus.Pending; + default: + return checkExhaustive(coreStatus); + } +} + +/** + * --- TYPE GUARDS --- + */ + +export const isTodoList = (res: unknown): res is { todos: unknown[] } => + typeof res === 'object' && res !== null && 'todos' in res; + +export const isAnsiOutput = (res: unknown): res is AnsiOutput => + Array.isArray(res) && (res.length === 0 || Array.isArray(res[0])); + +export interface ToolCallEvent { + type: 'tool_call'; + status: CoreToolCallStatus; + callId: string; + name: string; + args: Record; + resultDisplay: ToolResultDisplay | undefined; + confirmationDetails: SerializableConfirmationDetails | undefined; + correlationId?: string; +} + +export interface IndividualToolCallDisplay { + callId: string; + parentCallId?: string; + name: string; + args?: Record; + description: string; + display?: ToolDisplay; + resultDisplay: ToolResultDisplay | undefined; + status: CoreToolCallStatus; + // True when the tool was initiated directly by the user (slash/@/shell flows). + isClientInitiated?: boolean; + kind?: Kind; + confirmationDetails: SerializableConfirmationDetails | undefined; + renderOutputAsMarkdown?: boolean; + ptyId?: number; + outputFile?: string; + correlationId?: string; + approvalMode?: ApprovalMode; + progressMessage?: string; + originalRequestName?: string; + progress?: number; + progressTotal?: number; + subagentHistory?: SubagentActivityItem[]; +} + +export interface CompressionProps { + isPending: boolean; + originalTokenCount: number | null; + newTokenCount: number | null; + compressionStatus: CompressionStatus | null; +} + +export interface ExportSessionProps { + isPending: boolean; + targetPath?: string; +} + +/** + * For use when you want no icon. + */ +export const emptyIcon = ' '; + +export interface HistoryItemBase { + text?: string; // Text content for user/gemini/info/error messages +} + +export type HistoryItemUser = HistoryItemBase & { + type: 'user'; + text: string; +}; + +export type HistoryItemGemini = HistoryItemBase & { + type: 'gemini'; + text: string; +}; + +export type HistoryItemGeminiContent = HistoryItemBase & { + type: 'gemini_content'; + text: string; +}; + +export type HistoryItemInfo = HistoryItemBase & { + type: 'info'; + text: string; + secondaryText?: string; + source?: string; + icon?: string; + color?: string; + marginBottom?: number; +}; + +export type HistoryItemError = HistoryItemBase & { + type: 'error'; + text: string; +}; + +export type HistoryItemWarning = HistoryItemBase & { + type: 'warning'; + text: string; +}; + +export type HistoryItemAbout = HistoryItemBase & { + type: 'about'; + cliVersion: string; + osVersion: string; + sandboxEnv: string; + modelVersion: string; + selectedAuthType: string; + gcpProject: string; + ideClient: string; + userEmail?: string; + tier?: string; +}; + +export type HistoryItemHelp = HistoryItemBase & { + type: 'help'; + timestamp: Date; +}; + +export interface HistoryItemQuotaBase extends HistoryItemBase { + selectedAuthType?: string; + userEmail?: string; + tier?: string; + currentModel?: string; + pooledRemaining?: number; + pooledLimit?: number; + pooledResetTime?: string; +} + +export interface QuotaStats { + remaining: number | undefined; + limit: number | undefined; + resetTime?: string; +} + +export type HistoryItemStats = HistoryItemQuotaBase & { + type: 'stats'; + duration: string; + quotas?: RetrieveUserQuotaResponse; + creditBalance?: number; +}; + +export type HistoryItemModelStats = HistoryItemQuotaBase & { + type: 'model_stats'; +}; + +export type HistoryItemToolStats = HistoryItemBase & { + type: 'tool_stats'; +}; + +export type HistoryItemModel = HistoryItemBase & { + type: 'model'; + model: string; +}; + +export type HistoryItemQuit = HistoryItemBase & { + type: 'quit'; + duration: string; +}; + +export type HistoryItemToolGroup = HistoryItemBase & { + type: 'tool_group'; + tools: IndividualToolCallDisplay[]; + borderTop?: boolean; + borderBottom?: boolean; + borderColor?: string; + borderDimColor?: boolean; +}; + +export type ToolDisplayItem = ToolDisplay & { + status: CoreToolCallStatus; + originalRequestName?: string; +}; + +export type HistoryItemToolDisplayGroup = HistoryItemBase & { + type: 'tool_display_group'; + tools: ToolDisplayItem[]; + borderTop?: boolean; + borderBottom?: boolean; + borderColor?: string; + borderDimColor?: boolean; +}; + +export type HistoryItemUserShell = HistoryItemBase & { + type: 'user_shell'; + text: string; +}; + +export type HistoryItemCompression = HistoryItemBase & { + type: 'compression'; + compression: CompressionProps; +}; + +export type HistoryItemExportSession = HistoryItemBase & { + type: 'export_session'; + exportSession: ExportSessionProps; +}; + +export type HistoryItemExtensionsList = HistoryItemBase & { + type: 'extensions_list'; + extensions: GeminiCLIExtension[]; +}; + +export interface ChatDetail { + name: string; + mtime: string; +} + +export type HistoryItemThinking = HistoryItemBase & { + type: 'thinking'; + thought: ThoughtSummary; +}; + +export type HistoryItemHint = HistoryItemBase & { + type: 'hint'; + text: string; +}; + +export type HistoryItemChatList = HistoryItemBase & { + type: 'chat_list'; + chats: ChatDetail[]; +}; + +export type HistoryItemSubagent = HistoryItemBase & { + type: 'subagent'; + agentName: string; + history: SubagentActivityItem[]; +}; + +export interface ToolDefinition { + name: string; + displayName: string; + description?: string; +} + +export type HistoryItemToolsList = HistoryItemBase & { + type: 'tools_list'; + tools: ToolDefinition[]; + showDescriptions: boolean; +}; + +export type HistoryItemSkillsList = HistoryItemBase & { + type: 'skills_list'; + skills: SkillDefinition[]; + showDescriptions: boolean; +}; + +export type AgentDefinitionJson = Pick< + AgentDefinition, + 'name' | 'displayName' | 'description' | 'kind' +>; + +export type HistoryItemAgentsList = HistoryItemBase & { + type: 'agents_list'; + agents: AgentDefinitionJson[]; +}; + +// JSON-friendly types for using as a simple data model showing info about an +// MCP Server. +export interface JsonMcpTool { + serverName: string; + name: string; + description?: string; + schema?: { + parametersJsonSchema?: unknown; + parameters?: unknown; + }; +} + +export interface JsonMcpPrompt { + serverName: string; + name: string; + description?: string; +} + +export interface JsonMcpResource { + serverName: string; + name?: string; + uri?: string; + mimeType?: string; + description?: string; +} + +export type HistoryItemGemmaStatus = HistoryItemBase & { + type: 'gemma_status'; + binaryInstalled: boolean; + binaryPath: string | null; + modelName: string; + modelDownloaded: boolean; + serverRunning: boolean; + serverPid: number | null; + serverPort: number; + settingsEnabled: boolean; + allPassing: boolean; +}; + +export type HistoryItemMcpStatus = HistoryItemBase & { + type: 'mcp_status'; + servers: Record; + tools: JsonMcpTool[]; + prompts: JsonMcpPrompt[]; + resources: JsonMcpResource[]; + authStatus: Record< + string, + 'authenticated' | 'expired' | 'unauthenticated' | 'not-configured' + >; + enablementState: Record< + string, + { + enabled: boolean; + isSessionDisabled: boolean; + isPersistentDisabled: boolean; + } + >; + errors: Record; + blockedServers: Array<{ name: string; extensionName: string }>; + discoveryInProgress: boolean; + connectingServers: string[]; + showDescriptions: boolean; + showSchema: boolean; +}; + +// Individually exported types extending HistoryItemBase +export type HistoryItemWithoutId = + | HistoryItemUser + | HistoryItemUserShell + | HistoryItemGemini + | HistoryItemGeminiContent + | HistoryItemInfo + | HistoryItemError + | HistoryItemWarning + | HistoryItemAbout + | HistoryItemHelp + | HistoryItemToolGroup + | HistoryItemToolDisplayGroup + | HistoryItemStats + | HistoryItemModelStats + | HistoryItemToolStats + | HistoryItemModel + | HistoryItemQuit + | HistoryItemCompression + | HistoryItemExportSession + | HistoryItemExtensionsList + | HistoryItemToolsList + | HistoryItemSkillsList + | HistoryItemAgentsList + | HistoryItemMcpStatus + | HistoryItemGemmaStatus + | HistoryItemChatList + | HistoryItemThinking + | HistoryItemHint + | HistoryItemSubagent; + +export type HistoryItem = HistoryItemWithoutId & { id: number }; + +// Message types used by internal command feedback (subset of HistoryItem types) +export enum MessageType { + INFO = 'info', + ERROR = 'error', + WARNING = 'warning', + USER = 'user', + ABOUT = 'about', + HELP = 'help', + STATS = 'stats', + MODEL_STATS = 'model_stats', + TOOL_STATS = 'tool_stats', + QUIT = 'quit', + GEMINI = 'gemini', + COMPRESSION = 'compression', + EXPORT_SESSION = 'export_session', + EXTENSIONS_LIST = 'extensions_list', + TOOLS_LIST = 'tools_list', + SKILLS_LIST = 'skills_list', + AGENTS_LIST = 'agents_list', + MCP_STATUS = 'mcp_status', + GEMMA_STATUS = 'gemma_status', + CHAT_LIST = 'chat_list', + HINT = 'hint', +} + +// Simplified message structure for internal feedback +export type Message = + | { + type: MessageType.INFO | MessageType.ERROR | MessageType.USER; + content: string; // Renamed from text for clarity in this context + timestamp: Date; + } + | { + type: MessageType.ABOUT; + timestamp: Date; + cliVersion: string; + osVersion: string; + sandboxEnv: string; + modelVersion: string; + selectedAuthType: string; + gcpProject: string; + ideClient: string; + userEmail?: string; + content?: string; // Optional content, not really used for ABOUT + } + | { + type: MessageType.HELP; + timestamp: Date; + content?: string; // Optional content, not really used for HELP + } + | { + type: MessageType.STATS; + timestamp: Date; + duration: string; + content?: string; + } + | { + type: MessageType.MODEL_STATS; + timestamp: Date; + content?: string; + } + | { + type: MessageType.TOOL_STATS; + timestamp: Date; + content?: string; + } + | { + type: MessageType.QUIT; + timestamp: Date; + duration: string; + content?: string; + } + | { + type: MessageType.COMPRESSION; + compression: CompressionProps; + timestamp: Date; + }; + +export interface ConsoleMessageItem { + type: 'log' | 'warn' | 'error' | 'debug' | 'info'; + content: string; + count: number; +} + +/** + * Result type for a slash command that should immediately result in a prompt + * being submitted to the Gemini model. + */ +export interface SubmitPromptResult { + type: 'submit_prompt'; + content: PartListUnion; +} + +/** + * Defines the result of the slash command processor for its consumer (useGeminiStream). + */ +export type SlashCommandProcessorResult = + | { + type: 'schedule_tool'; + toolName: string; + toolArgs: Record; + postSubmitPrompt?: PartListUnion; + } + | { + type: 'handled'; // Indicates the command was processed and no further action is needed. + } + | SubmitPromptResult; + +export interface ConfirmationRequest { + prompt: ReactNode; + onConfirm: (confirm: boolean) => void; +} + +export interface LoopDetectionConfirmationRequest { + onComplete: (result: { userSelection: 'disable' | 'keep' }) => void; +} + +export interface PermissionConfirmationRequest { + files: string[]; + onComplete: (result: { allowed: boolean }) => void; +} + +export interface ActiveHook { + name: string; + eventName: string; + source?: string; + index?: number; + total?: number; +} diff --git a/packages/cli/src/utils/activityLogger.ts b/packages/cli/src/utils/activityLogger.ts new file mode 100644 index 0000000000000000000000000000000000000000..8f47b398086fe2b3193cc0fc2b0a6b83b430d82c --- /dev/null +++ b/packages/cli/src/utils/activityLogger.ts @@ -0,0 +1,1032 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import http from 'node:http'; +import https from 'node:https'; +import zlib from 'node:zlib'; +import fs from 'node:fs'; +import path from 'node:path'; +import { EventEmitter } from 'node:events'; +import { + CoreEvent, + coreEvents, + debugLogger, + type ConsoleLogPayload, + type Config, +} from '@google/gemini-cli-core'; +import WebSocket from 'ws'; + +const ACTIVITY_ID_HEADER = 'x-activity-request-id'; +const MAX_BUFFER_SIZE = 100; + +function isHeaderRecord( + h: http.OutgoingHttpHeaders | readonly string[], +): h is http.OutgoingHttpHeaders { + return !Array.isArray(h); +} + +function isRequestOptions(value: unknown): value is http.RequestOptions { + return ( + typeof value === 'object' && + value !== null && + !(value instanceof URL) && + !Array.isArray(value) + ); +} + +function isIncomingMessageCallback( + value: unknown, +): value is (res: http.IncomingMessage) => void { + return typeof value === 'function'; +} + +type HttpRequestArgs = + | [] + | [ + url: string | URL | http.RequestOptions, + options?: http.RequestOptions | ((res: http.IncomingMessage) => void), + callback?: (res: http.IncomingMessage) => void, + ]; + +function callHttpRequest( + originalFn: typeof http.request, + args: HttpRequestArgs, +): http.ClientRequest { + if (args.length === 0) { + return originalFn({}); + } + if (args.length === 1) { + const first = args[0]; + if (typeof first === 'string' || first instanceof URL) { + return originalFn(first); + } + if (isRequestOptions(first)) { + return originalFn(first); + } + return originalFn({}); + } + if (args.length === 2) { + const first = args[0]; + const second = args[1]; + if (typeof first === 'string' || first instanceof URL) { + if (isIncomingMessageCallback(second)) { + return originalFn(first, second); + } + if (isRequestOptions(second)) { + return originalFn(first, second); + } + } + if (isRequestOptions(first) && isIncomingMessageCallback(second)) { + return originalFn(first, second); + } + } + if (args.length === 3) { + const first = args[0]; + const second = args[1]; + const third = args[2]; + if ( + (typeof first === 'string' || first instanceof URL) && + isRequestOptions(second) && + isIncomingMessageCallback(third) + ) { + return originalFn(first, second, third); + } + } + return originalFn({}); +} + +export interface NetworkLog { + id: string; + timestamp: number; + method: string; + url: string; + headers: Record; + body?: string; + pending?: boolean; + chunk?: { + index: number; + data: string; + timestamp: number; + }; + response?: { + status: number; + headers: Record; + body?: string; + durationMs: number; + }; + error?: string; +} + +/** Partial update to an existing network log. */ +export type PartialNetworkLog = { id: string } & Partial; + +/** + * Capture utility for session activities (network and console). + * Provides a stream of events that can be persisted for analysis or inspection. + */ +export class ActivityLogger extends EventEmitter { + private static instance: ActivityLogger; + private isInterceptionEnabled = false; + private requestStartTimes = new Map(); + private networkLoggingEnabled = false; + + private networkBufferMap = new Map< + string, + Array + >(); + private networkBufferIds: string[] = []; + private consoleBuffer: Array = []; + private readonly bufferLimit = 10; + + static getInstance(): ActivityLogger { + if (!ActivityLogger.instance) { + ActivityLogger.instance = new ActivityLogger(); + } + return ActivityLogger.instance; + } + + enableNetworkLogging() { + this.networkLoggingEnabled = true; + this.emit('network-logging-enabled'); + } + + disableNetworkLogging() { + this.networkLoggingEnabled = false; + } + + isNetworkLoggingEnabled(): boolean { + return this.networkLoggingEnabled; + } + + /** + * Atomically returns and clears all buffered logs. + * Prevents data loss from events emitted between get and clear. + */ + drainBufferedLogs(): { + network: Array; + console: Array; + } { + const network: Array = []; + for (const id of this.networkBufferIds) { + const events = this.networkBufferMap.get(id); + if (events) network.push(...events); + } + const console = [...this.consoleBuffer]; + this.networkBufferMap.clear(); + this.networkBufferIds = []; + this.consoleBuffer = []; + return { network, console }; + } + + getBufferedLogs(): { + network: Array; + console: Array; + } { + const network: Array = []; + for (const id of this.networkBufferIds) { + const events = this.networkBufferMap.get(id); + if (events) network.push(...events); + } + return { + network, + console: [...this.consoleBuffer], + }; + } + + clearBufferedLogs(): void { + this.networkBufferMap.clear(); + this.networkBufferIds = []; + this.consoleBuffer = []; + } + + private stringifyHeaders(headers: unknown): Record { + const result: Record = {}; + if (!headers) return result; + + if (headers instanceof Headers) { + headers.forEach((v, k) => { + result[k.toLowerCase()] = v; + }); + } else if (typeof headers === 'object' && headers !== null) { + for (const [key, val] of Object.entries(headers)) { + result[key.toLowerCase()] = Array.isArray(val) + ? val.join(', ') + : String(val); + } + } + return result; + } + + private sanitizeNetworkLog( + log: NetworkLog | PartialNetworkLog, + ): NetworkLog | PartialNetworkLog { + if (!log || typeof log !== 'object') return log; + + const sanitized = { ...log }; + + // Sanitize request headers + if ('headers' in sanitized && sanitized.headers) { + const headers = { ...sanitized.headers }; + for (const key of Object.keys(headers)) { + if ( + ['authorization', 'cookie', 'x-goog-api-key'].includes( + key.toLowerCase(), + ) + ) { + headers[key] = '[REDACTED]'; + } + } + sanitized.headers = headers; + } + + // Sanitize response headers + if ('response' in sanitized && sanitized.response?.headers) { + const resHeaders = { ...sanitized.response.headers }; + for (const key of Object.keys(resHeaders)) { + if (['set-cookie'].includes(key.toLowerCase())) { + resHeaders[key] = '[REDACTED]'; + } + } + sanitized.response = { ...sanitized.response, headers: resHeaders }; + } + + return sanitized; + } + + /** @internal Emit a network event — public for testing only. */ + emitNetworkEvent(payload: NetworkLog | PartialNetworkLog) { + this.safeEmitNetwork(payload); + } + + private safeEmitNetwork(payload: NetworkLog | PartialNetworkLog) { + const sanitized = this.sanitizeNetworkLog(payload); + const id = sanitized.id; + + if (!this.networkBufferMap.has(id)) { + this.networkBufferIds.push(id); + this.networkBufferMap.set(id, []); + // Evict oldest request group if over limit + if (this.networkBufferIds.length > this.bufferLimit) { + const evictId = this.networkBufferIds.shift()!; + this.networkBufferMap.delete(evictId); + } + } + this.networkBufferMap.get(id)!.push(sanitized); + + this.emit('network', sanitized); + } + + enable() { + if (this.isInterceptionEnabled) return; + this.isInterceptionEnabled = true; + + this.patchGlobalFetch(); + this.patchNodeHttp(); + } + + private patchGlobalFetch() { + if (!global.fetch) return; + const originalFetch = global.fetch; + + global.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const url = + typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : input.url; + if (url.includes('127.0.0.1') || url.includes('localhost')) + return originalFetch(input, init); + + const id = Math.random().toString(36).substring(7); + + const inputMethod = + typeof input === 'object' && 'method' in input + ? input.method + : undefined; + const inputHeaders = + typeof input === 'object' && 'headers' in input + ? input.headers + : undefined; + + const method = (init?.method ?? inputMethod ?? 'GET').toUpperCase(); + const headers = new Headers(init?.headers ?? inputHeaders ?? {}); + headers.set(ACTIVITY_ID_HEADER, id); + + const newInit = { + ...init, + method, + headers, + }; + + let reqBody = ''; + const body = newInit.body; + if (body) { + if (typeof body === 'string') reqBody = body; + else if (body instanceof URLSearchParams) reqBody = body.toString(); + } + + this.requestStartTimes.set(id, Date.now()); + this.safeEmitNetwork({ + id, + timestamp: Date.now(), + method, + url, + headers: this.stringifyHeaders(newInit.headers), + body: reqBody, + pending: true, + }); + + try { + const response = await originalFetch(input, newInit); + const clonedRes = response.clone(); + + // Stream chunks if body is available + if (clonedRes.body) { + const reader = clonedRes.body.getReader(); + const decoder = new TextDecoder(); + const chunks: string[] = []; + let chunkIndex = 0; + + const readStream = async () => { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const chunkData = decoder.decode(value, { stream: true }); + chunks.push(chunkData); + + // Emit chunk update + this.safeEmitNetwork({ + id, + pending: true, + chunk: { + index: chunkIndex++, + data: chunkData, + timestamp: Date.now(), + }, + }); + } + + // Final update with complete response + const startTime = this.requestStartTimes.get(id); + const durationMs = startTime ? Date.now() - startTime : 0; + this.requestStartTimes.delete(id); + + this.safeEmitNetwork({ + id, + pending: false, + response: { + status: response.status, + headers: this.stringifyHeaders(response.headers), + body: chunks.join(''), + durationMs, + }, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.safeEmitNetwork({ + id, + pending: false, + error: `Failed to read response body: ${message}`, + }); + } + }; + + void readStream(); + } else { + // Fallback for responses without body stream + clonedRes + .text() + .then((text) => { + const startTime = this.requestStartTimes.get(id); + const durationMs = startTime ? Date.now() - startTime : 0; + this.requestStartTimes.delete(id); + + this.safeEmitNetwork({ + id, + pending: false, + response: { + status: response.status, + headers: this.stringifyHeaders(response.headers), + body: text, + durationMs, + }, + }); + }) + .catch((err) => { + const message = err instanceof Error ? err.message : String(err); + this.safeEmitNetwork({ + id, + pending: false, + error: `Failed to read response body: ${message}`, + }); + }); + } + + return response; + } catch (err: unknown) { + this.requestStartTimes.delete(id); + const message = err instanceof Error ? err.message : String(err); + this.safeEmitNetwork({ id, pending: false, error: message }); + throw err; + } + }; + } + + private patchNodeHttp() { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const self = this; + const originalRequest = http.request; + const originalHttpsRequest = https.request; + + const wrapRequest = ( + originalFn: typeof http.request, + args: HttpRequestArgs, + protocol: string, + ) => { + const firstArg = args[0]; + let options: http.RequestOptions | string | URL; + if (typeof firstArg === 'string') { + options = firstArg; + } else if (firstArg instanceof URL) { + options = firstArg; + } else if (firstArg && typeof firstArg === 'object') { + options = isRequestOptions(firstArg) ? firstArg : {}; + } else { + options = {}; + } + + let url = ''; + if (typeof options === 'string') { + url = options; + } else if (options instanceof URL) { + url = options.href; + } else { + // Some callers pass URL-like objects that include href + const href = + 'href' in options && typeof options.href === 'string' + ? options.href + : ''; + url = + href || + `${protocol}//${options.hostname || options.host || 'localhost'}${options.path || '/'}`; + } + + if (url.includes('127.0.0.1') || url.includes('localhost')) { + return callHttpRequest(originalFn, args); + } + + const rawHeaders = + typeof options === 'object' && + options !== null && + !(options instanceof URL) + ? options.headers + : undefined; + let headers: http.OutgoingHttpHeaders = {}; + if (rawHeaders && isHeaderRecord(rawHeaders)) { + headers = rawHeaders; + } + + if (headers[ACTIVITY_ID_HEADER]) { + delete headers[ACTIVITY_ID_HEADER]; + return callHttpRequest(originalFn, args); + } + + const id = Math.random().toString(36).substring(7); + this.requestStartTimes.set(id, Date.now()); + const req = callHttpRequest(originalFn, args); + const requestChunks: Buffer[] = []; + + const oldWrite = req.write; + const oldEnd = req.end; + + req.write = function (chunk: string | Uint8Array, ...etc: unknown[]) { + if (chunk) { + const arg0 = etc[0]; + const encoding = + typeof arg0 === 'string' && Buffer.isEncoding(arg0) + ? arg0 + : undefined; + requestChunks.push( + Buffer.isBuffer(chunk) + ? chunk + : typeof chunk === 'string' + ? Buffer.from(chunk, encoding) + : Buffer.from( + chunk instanceof Uint8Array ? chunk : String(chunk), + ), + ); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-return + return (oldWrite as any).apply(this, [chunk, ...etc]); + }; + + req.end = function ( + this: http.ClientRequest, + chunkOrCb?: string | Uint8Array | (() => void), + ...etc: unknown[] + ) { + const chunk = typeof chunkOrCb === 'function' ? undefined : chunkOrCb; + if (chunk) { + const arg0 = etc[0]; + const encoding = + typeof arg0 === 'string' && Buffer.isEncoding(arg0) + ? arg0 + : undefined; + requestChunks.push( + Buffer.isBuffer(chunk) + ? chunk + : typeof chunk === 'string' + ? Buffer.from(chunk, encoding) + : Buffer.from( + chunk instanceof Uint8Array ? chunk : String(chunk), + ), + ); + } + const body = Buffer.concat(requestChunks).toString('utf8'); + + self.safeEmitNetwork({ + id, + timestamp: Date.now(), + method: req.method || 'GET', + url, + headers: self.stringifyHeaders(req.getHeaders()), + body, + pending: true, + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-return + return (oldEnd as any).apply(this, [chunkOrCb, ...etc]); + }; + + req.on('response', (res: http.IncomingMessage) => { + const responseChunks: Buffer[] = []; + let chunkIndex = 0; + + res.on('data', (chunk: Buffer) => { + const chunkBuffer = Buffer.from(chunk); + responseChunks.push(chunkBuffer); + + // Emit chunk update for streaming + self.safeEmitNetwork({ + id, + pending: true, + chunk: { + index: chunkIndex++, + data: chunkBuffer.toString('utf8'), + timestamp: Date.now(), + }, + }); + }); + + res.on('end', () => { + const buffer = Buffer.concat(responseChunks); + const encoding = res.headers['content-encoding']; + + const processBuffer = (finalBuffer: Buffer) => { + const resBody = finalBuffer.toString('utf8'); + const startTime = self.requestStartTimes.get(id); + const durationMs = startTime ? Date.now() - startTime : 0; + self.requestStartTimes.delete(id); + + self.safeEmitNetwork({ + id, + pending: false, + response: { + status: res.statusCode || 0, + headers: self.stringifyHeaders(res.headers), + body: resBody, + durationMs, + }, + }); + }; + + if (encoding === 'gzip') { + zlib.gunzip(buffer, (err, decompressed) => { + processBuffer(err ? buffer : decompressed); + }); + } else if (encoding === 'deflate') { + zlib.inflate(buffer, (err, decompressed) => { + processBuffer(err ? buffer : decompressed); + }); + } else { + processBuffer(buffer); + } + }); + }); + + req.on('error', (err: Error) => { + self.requestStartTimes.delete(id); + const message = err.message; + self.safeEmitNetwork({ + id, + pending: false, + error: message, + }); + }); + + return req; + }; + + Object.defineProperty(http, 'request', { + value: ( + url: string | URL | http.RequestOptions, + options?: http.RequestOptions | ((res: http.IncomingMessage) => void), + callback?: (res: http.IncomingMessage) => void, + ): http.ClientRequest => { + const args: HttpRequestArgs = + callback !== undefined + ? [url, options, callback] + : options !== undefined + ? [url, options] + : [url]; + return wrapRequest(originalRequest, args, 'http:'); + }, + writable: true, + configurable: true, + }); + Object.defineProperty(https, 'request', { + value: ( + url: string | URL | http.RequestOptions, + options?: http.RequestOptions | ((res: http.IncomingMessage) => void), + callback?: (res: http.IncomingMessage) => void, + ): http.ClientRequest => { + const args: HttpRequestArgs = + callback !== undefined + ? [url, options, callback] + : options !== undefined + ? [url, options] + : [url]; + return wrapRequest( + originalHttpsRequest as typeof http.request, + args, + 'https:', + ); + }, + writable: true, + configurable: true, + }); + } + + logConsole(payload: ConsoleLogPayload) { + const enriched = { ...payload, timestamp: Date.now() }; + this.consoleBuffer.push(enriched); + if (this.consoleBuffer.length > this.bufferLimit) { + this.consoleBuffer.shift(); + } + this.emit('console', enriched); + } +} + +/** + * Setup file-based logging to JSONL + */ +function setupFileLogging( + capture: ActivityLogger, + config: Config, + customPath?: string, +) { + const logFile = + customPath || + (config.storage + ? path.join( + config.storage.getProjectTempLogsDir(), + `session-${config.getSessionId()}.jsonl`, + ) + : null); + + if (!logFile) return; + + const logsDir = path.dirname(logFile); + if (!fs.existsSync(logsDir)) { + fs.mkdirSync(logsDir, { recursive: true }); + } + + const writeToLog = (type: 'console' | 'network', payload: unknown) => { + try { + const entry = + JSON.stringify({ + type, + payload, + sessionId: config.getSessionId(), + timestamp: Date.now(), + }) + '\n'; + + fs.promises.appendFile(logFile, entry).catch((err) => { + debugLogger.error('Failed to write to activity log:', err); + }); + } catch (err) { + debugLogger.error('Failed to prepare activity log entry:', err); + } + }; + + capture.on('console', (payload) => writeToLog('console', payload)); + capture.on('network', (payload) => writeToLog('network', payload)); +} + +/** + * Setup network-based logging via WebSocket + */ +function setupNetworkLogging( + capture: ActivityLogger, + host: string, + port: number, + config: Config, + onReconnectFailed?: () => void, +) { + const transportBuffer: object[] = []; + let ws: WebSocket | null = null; + let reconnectTimer: NodeJS.Timeout | null = null; + let sessionId: string | null = null; + let pingInterval: NodeJS.Timeout | null = null; + let reconnectAttempts = 0; + const MAX_RECONNECT_ATTEMPTS = 2; + + const connect = () => { + try { + ws = new WebSocket(`ws://${host}:${port}/ws`); + + ws.on('open', () => { + debugLogger.debug(`WebSocket connected to ${host}:${port}`); + reconnectAttempts = 0; + // Register with CLI's session ID + sendMessage({ + type: 'register', + sessionId: config.getSessionId(), + timestamp: Date.now(), + }); + }); + + ws.on('message', (data: Buffer) => { + try { + const parsed: unknown = JSON.parse(data.toString()); + if ( + typeof parsed === 'object' && + parsed !== null && + 'type' in parsed && + typeof parsed.type === 'string' + ) { + handleServerMessage({ + type: parsed.type, + sessionId: + 'sessionId' in parsed && typeof parsed.sessionId === 'string' + ? parsed.sessionId + : undefined, + }); + } + } catch (err) { + debugLogger.debug('Invalid WebSocket message:', err); + } + }); + + ws.on('close', () => { + debugLogger.debug(`WebSocket disconnected from ${host}:${port}`); + cleanup(); + scheduleReconnect(); + }); + + ws.on('error', (err) => { + debugLogger.debug(`WebSocket error:`, err); + }); + } catch (err) { + debugLogger.debug(`Failed to connect WebSocket:`, err); + scheduleReconnect(); + } + }; + + const handleServerMessage = (message: { + type: string; + sessionId?: string; + }) => { + switch (message.type) { + case 'registered': + sessionId = message.sessionId || null; + debugLogger.debug(`WebSocket session registered: ${sessionId}`); + + // Start ping interval + if (pingInterval) clearInterval(pingInterval); + pingInterval = setInterval(() => { + sendMessage({ type: 'pong', timestamp: Date.now() }); + }, 15000); + + // Flush buffered logs + flushBuffer(); + break; + case 'trigger-debugger': { + import('node:inspector') + .then((inspector) => { + inspector.open(); + debugLogger.log( + 'Node debugger attached. Open chrome://inspect in Chrome to start debugging.', + ); + return import('./events.js'); + }) + .then(({ appEvents, AppEvent, TransientMessageType }) => { + appEvents.emit(AppEvent.TransientMessage, { + message: 'Debugger attached from DevTools.', + type: TransientMessageType.Hint, + }); + }) + .catch((err) => + debugLogger.debug('Failed to trigger debugger:', err), + ); + break; + } + case 'ping': + sendMessage({ type: 'pong', timestamp: Date.now() }); + break; + + default: + // Ignore unknown message types + break; + } + }; + + const sendMessage = (message: object) => { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(message)); + } + }; + + const sendToNetwork = (type: 'console' | 'network', payload: object) => { + const message = { + type, + payload, + sessionId: sessionId || config.getSessionId(), + timestamp: Date.now(), + }; + + // If not connected or network logging not enabled, buffer + if ( + !ws || + ws.readyState !== WebSocket.OPEN || + !capture.isNetworkLoggingEnabled() + ) { + transportBuffer.push(message); + if (transportBuffer.length > MAX_BUFFER_SIZE) transportBuffer.shift(); + return; + } + + sendMessage(message); + }; + + const flushBuffer = () => { + if ( + !ws || + ws.readyState !== WebSocket.OPEN || + !capture.isNetworkLoggingEnabled() + ) { + return; + } + + const { network, console: consoleLogs } = capture.drainBufferedLogs(); + const allInitialLogs: Array<{ + type: 'network' | 'console'; + payload: object; + timestamp: number; + }> = [ + ...network.map((l) => ({ + type: 'network' as const, + payload: l, + timestamp: 'timestamp' in l && l.timestamp ? l.timestamp : Date.now(), + })), + ...consoleLogs.map((l) => ({ + type: 'console' as const, + payload: l, + timestamp: l.timestamp, + })), + ].sort((a, b) => a.timestamp - b.timestamp); + + debugLogger.debug( + `Flushing ${allInitialLogs.length} initial buffered logs and ${transportBuffer.length} transport buffered logs...`, + ); + + for (const log of allInitialLogs) { + sendMessage({ + type: log.type, + payload: log.payload, + sessionId: sessionId || config.getSessionId(), + timestamp: Date.now(), + }); + } + + while (transportBuffer.length > 0) { + const message = transportBuffer.shift()!; + sendMessage(message); + } + }; + + const cleanup = () => { + if (pingInterval) { + clearInterval(pingInterval); + pingInterval = null; + } + ws = null; + }; + + const scheduleReconnect = () => { + if (reconnectTimer) return; + + reconnectAttempts++; + if (reconnectAttempts > MAX_RECONNECT_ATTEMPTS && onReconnectFailed) { + debugLogger.debug( + `WebSocket reconnect failed after ${MAX_RECONNECT_ATTEMPTS} attempts, promoting to server...`, + ); + onReconnectFailed(); + return; + } + + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + debugLogger.debug('Reconnecting WebSocket...'); + connect(); + }, 1000); + }; + + // Initial connection + connect(); + + capture.on('console', (payload) => sendToNetwork('console', payload)); + capture.on('network', (payload) => sendToNetwork('network', payload)); + + capture.on('network-logging-enabled', () => { + debugLogger.debug('Network logging enabled, flushing buffer...'); + flushBuffer(); + }); + + // Cleanup on process exit + process.on('exit', () => { + if (reconnectTimer) clearTimeout(reconnectTimer); + if (ws) ws.close(); + cleanup(); + }); +} + +let bridgeAttached = false; + +/** + * Bridge coreEvents to the ActivityLogger singleton (guarded — only once). + */ +function bridgeCoreEvents(capture: ActivityLogger) { + if (bridgeAttached) return; + bridgeAttached = true; + coreEvents.on(CoreEvent.ConsoleLog, (payload) => { + capture.logConsole(payload); + }); +} + +/** + * Initialize the activity logger with a specific transport mode. + * + * @param config CLI configuration + * @param options Transport configuration: network (WebSocket) or file (JSONL) + */ +export function initActivityLogger( + config: Config, + options: + | { + mode: 'network'; + host: string; + port: number; + onReconnectFailed?: () => void; + } + | { mode: 'file'; filePath?: string } + | { mode: 'buffer' }, +): void { + const capture = ActivityLogger.getInstance(); + capture.enable(); + + if (options.mode === 'network') { + setupNetworkLogging( + capture, + options.host, + options.port, + config, + options.onReconnectFailed, + ); + capture.enableNetworkLogging(); + } else if (options.mode === 'file') { + setupFileLogging(capture, config, options.filePath); + } + // buffer mode: no transport, just intercept + bridge + + bridgeCoreEvents(capture); +} + +/** + * Add a network (WebSocket) transport to the existing ActivityLogger singleton. + * Used for promotion re-entry without re-bridging coreEvents. + */ +export function addNetworkTransport( + config: Config, + host: string, + port: number, + onReconnectFailed?: () => void, +): void { + const capture = ActivityLogger.getInstance(); + setupNetworkLogging(capture, host, port, config, onReconnectFailed); +} diff --git a/packages/cli/src/utils/agentSettings.test.ts b/packages/cli/src/utils/agentSettings.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..ffc113ea73e681e083da08d30b7d14010d4285f9 --- /dev/null +++ b/packages/cli/src/utils/agentSettings.test.ts @@ -0,0 +1,147 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi } from 'vitest'; +import { + SettingScope, + type LoadedSettings, + type LoadableSettingScope, +} from '../config/settings.js'; +import { enableAgent, disableAgent } from './agentSettings.js'; + +function createMockLoadedSettings(opts: { + userSettings?: Record; + workspaceSettings?: Record; + userPath?: string; + workspacePath?: string; +}): LoadedSettings { + const scopes: Record< + string, + { + settings: Record; + originalSettings: Record; + path: string; + } + > = { + [SettingScope.User]: { + settings: opts.userSettings ?? {}, + originalSettings: opts.userSettings ?? {}, + path: opts.userPath ?? '/home/user/.gemini/settings.json', + }, + [SettingScope.Workspace]: { + settings: opts.workspaceSettings ?? {}, + originalSettings: opts.workspaceSettings ?? {}, + path: opts.workspacePath ?? '/project/.gemini/settings.json', + }, + }; + + return { + forScope: vi.fn((scope: LoadableSettingScope) => scopes[scope]), + setValue: vi.fn(), + } as unknown as LoadedSettings; +} + +describe('agentSettings', () => { + describe('agentStrategy (via enableAgent / disableAgent)', () => { + describe('enableAgent', () => { + it('should return no-op when the agent is already enabled in both scopes', () => { + const settings = createMockLoadedSettings({ + userSettings: { + agents: { overrides: { 'my-agent': { enabled: true } } }, + }, + workspaceSettings: { + agents: { overrides: { 'my-agent': { enabled: true } } }, + }, + }); + + const result = enableAgent(settings, 'my-agent'); + + expect(result.status).toBe('no-op'); + expect(result.action).toBe('enable'); + expect(result.agentName).toBe('my-agent'); + expect(result.modifiedScopes).toHaveLength(0); + expect(settings.setValue).not.toHaveBeenCalled(); + }); + + it('should enable the agent when not present in any scope', () => { + const settings = createMockLoadedSettings({ + userSettings: {}, + workspaceSettings: {}, + }); + + const result = enableAgent(settings, 'my-agent'); + + expect(result.status).toBe('success'); + expect(result.action).toBe('enable'); + expect(result.agentName).toBe('my-agent'); + expect(result.modifiedScopes).toHaveLength(2); + expect(settings.setValue).toHaveBeenCalledTimes(2); + }); + + it('should enable the agent only in the scope where it is not enabled', () => { + const settings = createMockLoadedSettings({ + userSettings: { + agents: { overrides: { 'my-agent': { enabled: true } } }, + }, + workspaceSettings: { + agents: { overrides: { 'my-agent': { enabled: false } } }, + }, + }); + + const result = enableAgent(settings, 'my-agent'); + + expect(result.status).toBe('success'); + expect(result.modifiedScopes).toHaveLength(1); + expect(result.modifiedScopes[0].scope).toBe(SettingScope.Workspace); + expect(result.alreadyInStateScopes).toHaveLength(1); + expect(result.alreadyInStateScopes[0].scope).toBe(SettingScope.User); + expect(settings.setValue).toHaveBeenCalledTimes(1); + }); + }); + + describe('disableAgent', () => { + it('should return no-op when agent is already explicitly disabled', () => { + const settings = createMockLoadedSettings({ + userSettings: { + agents: { overrides: { 'my-agent': { enabled: false } } }, + }, + }); + + const result = disableAgent(settings, 'my-agent', SettingScope.User); + + expect(result.status).toBe('no-op'); + expect(result.action).toBe('disable'); + expect(result.agentName).toBe('my-agent'); + expect(settings.setValue).not.toHaveBeenCalled(); + }); + + it('should disable the agent when it is currently enabled', () => { + const settings = createMockLoadedSettings({ + userSettings: { + agents: { overrides: { 'my-agent': { enabled: true } } }, + }, + }); + + const result = disableAgent(settings, 'my-agent', SettingScope.User); + + expect(result.status).toBe('success'); + expect(result.action).toBe('disable'); + expect(result.modifiedScopes).toHaveLength(1); + expect(result.modifiedScopes[0].scope).toBe(SettingScope.User); + expect(settings.setValue).toHaveBeenCalledTimes(1); + }); + + it('should return error for an invalid scope', () => { + const settings = createMockLoadedSettings({}); + + const result = disableAgent(settings, 'my-agent', SettingScope.Session); + + expect(result.status).toBe('error'); + expect(result.error).toContain('Invalid settings scope'); + }); + }); + }); +}); diff --git a/packages/cli/src/utils/agentSettings.ts b/packages/cli/src/utils/agentSettings.ts new file mode 100644 index 0000000000000000000000000000000000000000..1ea9054c9c85fc7ffd60cda21e5f3a60c9cd3c77 --- /dev/null +++ b/packages/cli/src/utils/agentSettings.ts @@ -0,0 +1,80 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { SettingScope, LoadedSettings } from '../config/settings.js'; +import { + type FeatureActionResult, + type FeatureToggleStrategy, + enableFeature, + disableFeature, +} from './featureToggleUtils.js'; + +export type AgentActionStatus = 'success' | 'no-op' | 'error'; + +/** + * Metadata representing the result of an agent settings operation. + */ +export interface AgentActionResult + extends Omit { + agentName: string; +} + +const agentStrategy: FeatureToggleStrategy = { + needsEnabling: (settings, scope, agentName) => { + const agentOverrides = settings.forScope(scope).settings.agents?.overrides; + return agentOverrides?.[agentName]?.enabled !== true; + }, + enable: (settings, scope, agentName) => { + settings.setValue(scope, `agents.overrides.${agentName}.enabled`, true); + }, + isExplicitlyDisabled: (settings, scope, agentName) => { + const agentOverrides = settings.forScope(scope).settings.agents?.overrides; + return agentOverrides?.[agentName]?.enabled === false; + }, + disable: (settings, scope, agentName) => { + settings.setValue(scope, `agents.overrides.${agentName}.enabled`, false); + }, +}; + +/** + * Enables an agent by setting `agents.overrides..enabled` to `true` + * in available writable scopes (User and Workspace). + */ +export function enableAgent( + settings: LoadedSettings, + agentName: string, +): AgentActionResult { + const { featureName, ...rest } = enableFeature( + settings, + agentName, + agentStrategy, + ); + return { + ...rest, + agentName: featureName, + }; +} + +/** + * Disables an agent by setting `agents.overrides..enabled` to `false` + * in the specified scope. + */ +export function disableAgent( + settings: LoadedSettings, + agentName: string, + scope: SettingScope, +): AgentActionResult { + const { featureName, ...rest } = disableFeature( + settings, + agentName, + scope, + agentStrategy, + ); + return { + ...rest, + agentName: featureName, + }; +} diff --git a/packages/cli/src/utils/cleanup.test.ts b/packages/cli/src/utils/cleanup.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..0e2454cb825505fb10b2bc13d2152f1428e7ffdf --- /dev/null +++ b/packages/cli/src/utils/cleanup.test.ts @@ -0,0 +1,326 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { promises as fs } from 'node:fs'; +import * as path from 'node:path'; + +vi.mock('@google/gemini-cli-core', () => ({ + Storage: vi.fn().mockImplementation(() => ({ + getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'), + initialize: vi.fn().mockResolvedValue(undefined), + })), + shutdownTelemetry: vi.fn(), + isTelemetrySdkInitialized: vi.fn().mockReturnValue(false), + ExitCodes: { SUCCESS: 0 }, +})); + +vi.mock('node:fs', () => ({ + promises: { + rm: vi.fn(), + }, +})); + +import { + registerCleanup, + runExitCleanup, + registerSyncCleanup, + runSyncCleanup, + cleanupCheckpoints, + resetCleanupForTesting, + setupSignalHandlers, + setupTtyCheck, +} from './cleanup.js'; + +describe('cleanup', () => { + beforeEach(async () => { + vi.clearAllMocks(); + resetCleanupForTesting(); + }); + + it('should run a registered synchronous function', async () => { + const cleanupFn = vi.fn(); + registerCleanup(cleanupFn); + + await runExitCleanup(); + + expect(cleanupFn).toHaveBeenCalledTimes(1); + }); + + it('should run a registered asynchronous function', async () => { + const cleanupFn = vi.fn().mockResolvedValue(undefined); + registerCleanup(cleanupFn); + + await runExitCleanup(); + + expect(cleanupFn).toHaveBeenCalledTimes(1); + }); + + it('should run multiple registered functions', async () => { + const syncFn = vi.fn(); + const asyncFn = vi.fn().mockResolvedValue(undefined); + + registerCleanup(syncFn); + registerCleanup(asyncFn); + + await runExitCleanup(); + + expect(syncFn).toHaveBeenCalledTimes(1); + expect(asyncFn).toHaveBeenCalledTimes(1); + }); + + it('should run cleanupFunctions BEFORE draining stdin and BEFORE runSyncCleanup', async () => { + const callOrder: string[] = []; + + // Cleanup function + registerCleanup(() => { + callOrder.push('cleanup'); + }); + + // Sync cleanup function (e.g. setRawMode(false)) + registerSyncCleanup(() => { + callOrder.push('sync'); + }); + + // Mock stdin.resume to track drainStdin + const originalResume = process.stdin.resume; + process.stdin.resume = vi.fn().mockImplementation(() => { + callOrder.push('drain'); + return process.stdin; + }); + + // Mock stdin properties for drainStdin + const originalIsTTY = process.stdin.isTTY; + Object.defineProperty(process.stdin, 'isTTY', { + value: true, + configurable: true, + }); + + try { + await runExitCleanup(); + } finally { + process.stdin.resume = originalResume; + Object.defineProperty(process.stdin, 'isTTY', { + value: originalIsTTY, + configurable: true, + }); + } + + expect(callOrder).toEqual(['drain', 'drain', 'sync', 'cleanup']); + }); + + it('should continue running cleanup functions even if one throws an error', async () => { + const errorFn = vi.fn().mockImplementation(() => { + throw new Error('test error'); + }); + const successFn = vi.fn(); + registerCleanup(errorFn); + registerCleanup(successFn); + + await expect(runExitCleanup()).resolves.not.toThrow(); + + expect(errorFn).toHaveBeenCalledTimes(1); + expect(successFn).toHaveBeenCalledTimes(1); + }); + + describe('sync cleanup', () => { + it('should run registered sync functions', async () => { + const syncFn = vi.fn(); + registerSyncCleanup(syncFn); + runSyncCleanup(); + expect(syncFn).toHaveBeenCalledTimes(1); + }); + + it('should continue running sync cleanup functions even if one throws', async () => { + const errorFn = vi.fn().mockImplementation(() => { + throw new Error('test error'); + }); + const successFn = vi.fn(); + registerSyncCleanup(errorFn); + registerSyncCleanup(successFn); + + expect(() => runSyncCleanup()).not.toThrow(); + expect(errorFn).toHaveBeenCalledTimes(1); + expect(successFn).toHaveBeenCalledTimes(1); + }); + }); + + describe('cleanupCheckpoints', () => { + it('should remove checkpoints directory', async () => { + await cleanupCheckpoints(); + expect(fs.rm).toHaveBeenCalledWith( + path.join('/tmp/project', 'checkpoints'), + { + recursive: true, + force: true, + }, + ); + }); + + it('should ignore errors during checkpoint removal', async () => { + vi.mocked(fs.rm).mockRejectedValue(new Error('Failed to remove')); + await expect(cleanupCheckpoints()).resolves.not.toThrow(); + }); + }); +}); + +describe('signal and TTY handling', () => { + let processOnHandlers: Map< + string, + Array<(...args: unknown[]) => void | Promise> + >; + + beforeEach(() => { + processOnHandlers = new Map(); + resetCleanupForTesting(); + + vi.spyOn(process, 'on').mockImplementation( + (event: string | symbol, handler: (...args: unknown[]) => void) => { + if (typeof event === 'string') { + const handlers = processOnHandlers.get(event) || []; + handlers.push(handler); + processOnHandlers.set(event, handlers); + } + return process; + }, + ); + + vi.spyOn(process, 'exit').mockImplementation((() => { + // Don't actually exit + }) as typeof process.exit); + }); + + afterEach(() => { + vi.restoreAllMocks(); + processOnHandlers.clear(); + }); + + describe('setupSignalHandlers', () => { + it('should register handlers for SIGHUP, SIGTERM, and SIGINT', () => { + setupSignalHandlers(); + + expect(processOnHandlers.has('SIGHUP')).toBe(true); + expect(processOnHandlers.has('SIGTERM')).toBe(true); + expect(processOnHandlers.has('SIGINT')).toBe(true); + }); + + it('should gracefully shutdown when SIGHUP is received', async () => { + setupSignalHandlers(); + + const sighupHandlers = processOnHandlers.get('SIGHUP') || []; + expect(sighupHandlers.length).toBeGreaterThan(0); + + await sighupHandlers[0]?.(); + + expect(process.exit).toHaveBeenCalledWith(0); + }); + + it('should register SIGTERM handler that can trigger shutdown', () => { + setupSignalHandlers(); + + const sigtermHandlers = processOnHandlers.get('SIGTERM') || []; + expect(sigtermHandlers.length).toBeGreaterThan(0); + // eslint-disable-next-line no-restricted-syntax + expect(typeof sigtermHandlers[0]).toBe('function'); + }); + }); + + describe('setupTtyCheck', () => { + let originalStdinIsTTY: boolean | undefined; + let originalStdoutIsTTY: boolean | undefined; + + beforeEach(() => { + originalStdinIsTTY = process.stdin.isTTY; + originalStdoutIsTTY = process.stdout.isTTY; + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + Object.defineProperty(process.stdin, 'isTTY', { + value: originalStdinIsTTY, + writable: true, + configurable: true, + }); + Object.defineProperty(process.stdout, 'isTTY', { + value: originalStdoutIsTTY, + writable: true, + configurable: true, + }); + }); + + it('should return a cleanup function', () => { + const cleanup = setupTtyCheck(); + expect(typeof cleanup).toBe('function'); + cleanup(); + }); + + it('should not exit when both stdin and stdout are TTY', async () => { + Object.defineProperty(process.stdin, 'isTTY', { + value: true, + writable: true, + configurable: true, + }); + Object.defineProperty(process.stdout, 'isTTY', { + value: true, + writable: true, + configurable: true, + }); + + const cleanup = setupTtyCheck(); + await vi.advanceTimersByTimeAsync(5000); + expect(process.exit).not.toHaveBeenCalled(); + cleanup(); + }); + + it('should exit when both stdin and stdout are not TTY', async () => { + Object.defineProperty(process.stdin, 'isTTY', { + value: false, + writable: true, + configurable: true, + }); + Object.defineProperty(process.stdout, 'isTTY', { + value: false, + writable: true, + configurable: true, + }); + + const cleanup = setupTtyCheck(); + await vi.advanceTimersByTimeAsync(5000); + expect(process.exit).toHaveBeenCalledWith(0); + cleanup(); + }); + + it('should not check when SANDBOX env is set', async () => { + const originalSandbox = process.env['SANDBOX']; + process.env['SANDBOX'] = 'true'; + + Object.defineProperty(process.stdin, 'isTTY', { + value: false, + writable: true, + configurable: true, + }); + Object.defineProperty(process.stdout, 'isTTY', { + value: false, + writable: true, + configurable: true, + }); + + const cleanup = setupTtyCheck(); + await vi.advanceTimersByTimeAsync(5000); + expect(process.exit).not.toHaveBeenCalled(); + cleanup(); + process.env['SANDBOX'] = originalSandbox; + }); + + it('cleanup function should stop the interval', () => { + const cleanup = setupTtyCheck(); + cleanup(); + vi.advanceTimersByTime(10000); + expect(process.exit).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/cli/src/utils/commands.ts b/packages/cli/src/utils/commands.ts new file mode 100644 index 0000000000000000000000000000000000000000..30f3215d3586d5917e6264320d26d3f29c7f89d9 --- /dev/null +++ b/packages/cli/src/utils/commands.ts @@ -0,0 +1,89 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { type SlashCommand } from '../ui/commands/types.js'; + +export type ParsedSlashCommand = { + commandToExecute: SlashCommand | undefined; + args: string; + canonicalPath: string[]; +}; + +/** + * Parses a raw slash command string into its command, arguments, and canonical path. + * If no valid command is found, the `commandToExecute` property will be `undefined`. + * + * @param query The raw input string, e.g., "/memory show" or "/help". + * @param commands The list of available top-level slash commands. + * @returns An object containing the resolved command, its arguments, and its canonical path. + */ +export const parseSlashCommand = ( + query: string, + commands: readonly SlashCommand[], +): ParsedSlashCommand => { + const trimmed = query.trim(); + + const parts = trimmed.substring(1).trim().split(/\s+/); + const commandPath = parts.filter((p) => p); // The parts of the command, e.g., ['memory', 'add'] + + let currentCommands = commands; + let commandToExecute: SlashCommand | undefined; + let pathIndex = 0; + const canonicalPath: string[] = []; + let parentCommand: SlashCommand | undefined; + + for (const part of commandPath) { + // TODO: For better performance and architectural clarity, this two-pass + // search could be replaced. A more optimal approach would be to + // pre-compute a single lookup map in `CommandService.ts` that resolves + // all name and alias conflicts during the initial loading phase. The + // processor would then perform a single, fast lookup on that map. + + // First pass: check for an exact match on the primary command name. + let foundCommand = currentCommands.find((cmd) => cmd.name === part); + + // Second pass: if no primary name matches, check for an alias. + if (!foundCommand) { + foundCommand = currentCommands.find((cmd) => + cmd.altNames?.includes(part), + ); + } + + if (foundCommand) { + parentCommand = commandToExecute; + commandToExecute = foundCommand; + canonicalPath.push(foundCommand.name); + pathIndex++; + if (foundCommand.subCommands) { + currentCommands = foundCommand.subCommands; + } else { + break; + } + } else { + break; + } + } + + const args = parts.slice(pathIndex).join(' '); + + // Backtrack if the matched (sub)command doesn't take arguments but some were provided, + // AND the parent command is capable of handling them. + if ( + commandToExecute && + commandToExecute.takesArgs === false && + args.length > 0 && + parentCommand && + parentCommand.action + ) { + return { + commandToExecute: parentCommand, + args: parts.slice(pathIndex - 1).join(' '), + canonicalPath: canonicalPath.slice(0, -1), + }; + } + + return { commandToExecute, args, canonicalPath }; +}; diff --git a/packages/cli/src/utils/deepMerge.test.ts b/packages/cli/src/utils/deepMerge.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..3310924795cc93ad84a6b406777b16cd82c44c7d --- /dev/null +++ b/packages/cli/src/utils/deepMerge.test.ts @@ -0,0 +1,240 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { customDeepMerge } from './deepMerge.js'; +import { MergeStrategy } from '../config/settingsSchema.js'; + +describe('customDeepMerge', () => { + it('should merge simple objects', () => { + const target = { a: 1, b: 2 }; + const source = { b: 3, c: 4 }; + const getMergeStrategy = () => undefined; + const result = customDeepMerge(getMergeStrategy, target, source); + expect(result).toEqual({ a: 1, b: 3, c: 4 }); + }); + + it('should merge nested objects', () => { + const target = { a: { x: 1 }, b: 2 }; + const source = { a: { y: 2 }, c: 3 }; + const getMergeStrategy = () => undefined; + const result = customDeepMerge(getMergeStrategy, target, source); + expect(result).toEqual({ a: { x: 1, y: 2 }, b: 2, c: 3 }); + }); + + it('should replace arrays by default', () => { + const target = { a: [1, 2] }; + const source = { a: [3, 4] }; + const getMergeStrategy = () => undefined; + const result = customDeepMerge(getMergeStrategy, target, source); + expect(result).toEqual({ a: [3, 4] }); + }); + + it('should concatenate arrays with CONCAT strategy', () => { + const target = { a: [1, 2] }; + const source = { a: [3, 4] }; + const getMergeStrategy = (path: string[]) => + path.join('.') === 'a' ? MergeStrategy.CONCAT : undefined; + const result = customDeepMerge(getMergeStrategy, target, source); + expect(result).toEqual({ a: [1, 2, 3, 4] }); + }); + + it('should union arrays with UNION strategy', () => { + const target = { a: [1, 2, 3] }; + const source = { a: [3, 4, 5] }; + const getMergeStrategy = (path: string[]) => + path.join('.') === 'a' ? MergeStrategy.UNION : undefined; + const result = customDeepMerge(getMergeStrategy, target, source); + expect(result).toEqual({ a: [1, 2, 3, 4, 5] }); + }); + + it('should shallow merge objects with SHALLOW_MERGE strategy', () => { + const target = { a: { x: 1, y: 1 } }; + const source = { a: { y: 2, z: 2 } }; + const getMergeStrategy = (path: string[]) => + path.join('.') === 'a' ? MergeStrategy.SHALLOW_MERGE : undefined; + const result = customDeepMerge(getMergeStrategy, target, source); + // This is still a deep merge, but the properties of the object are merged. + expect(result).toEqual({ a: { x: 1, y: 2, z: 2 } }); + }); + + it('should handle multiple source objects', () => { + const target = { a: 1 }; + const source1 = { b: 2 }; + const source2 = { c: 3 }; + const getMergeStrategy = () => undefined; + const result = customDeepMerge(getMergeStrategy, target, source1, source2); + expect(result).toEqual({ a: 1, b: 2, c: 3 }); + }); + + it('should return an empty object if no sources are provided', () => { + const getMergeStrategy = () => undefined; + const result = customDeepMerge(getMergeStrategy); + expect(result).toEqual({}); + }); + + it('should return a deep copy of the first source if only one is provided', () => { + const target = { a: { b: 1 } }; + const getMergeStrategy = () => undefined; + const result = customDeepMerge(getMergeStrategy, target); + expect(result).toEqual(target); + expect(result).not.toBe(target); + }); + + it('should not mutate the original source objects', () => { + const target = { a: { x: 1 }, b: [1, 2] }; + const source = { a: { y: 2 }, b: [3, 4] }; + const originalTarget = JSON.parse(JSON.stringify(target)); + const originalSource = JSON.parse(JSON.stringify(source)); + const getMergeStrategy = () => undefined; + + customDeepMerge(getMergeStrategy, target, source); + + expect(target).toEqual(originalTarget); + expect(source).toEqual(originalSource); + }); + + it('should not mutate sources when merging multiple levels deep', () => { + const s1 = { data: { common: { val: 'from s1' }, s1_only: true } }; + const s2 = { data: { common: { val: 'from s2' }, s2_only: true } }; + const s1_original = JSON.parse(JSON.stringify(s1)); + const s2_original = JSON.parse(JSON.stringify(s2)); + + const getMergeStrategy = () => undefined; + const result = customDeepMerge(getMergeStrategy, s1, s2); + + expect(s1).toEqual(s1_original); + expect(s2).toEqual(s2_original); + expect(result).toEqual({ + data: { + common: { val: 'from s2' }, + s1_only: true, + s2_only: true, + }, + }); + }); + + it('should handle complex nested strategies', () => { + const target = { + level1: { + arr1: [1, 2], + arr2: [1, 2], + obj1: { a: 1 }, + }, + }; + const source = { + level1: { + arr1: [3, 4], + arr2: [2, 3], + obj1: { b: 2 }, + }, + }; + const getMergeStrategy = (path: string[]) => { + const p = path.join('.'); + if (p === 'level1.arr1') return MergeStrategy.CONCAT; + if (p === 'level1.arr2') return MergeStrategy.UNION; + if (p === 'level1.obj1') return MergeStrategy.SHALLOW_MERGE; + return undefined; + }; + + const result = customDeepMerge(getMergeStrategy, target, source); + + expect(result).toEqual({ + level1: { + arr1: [1, 2, 3, 4], + arr2: [1, 2, 3], + obj1: { a: 1, b: 2 }, + }, + }); + }); + + it('should not pollute the prototype', () => { + const maliciousSource = JSON.parse('{"__proto__": {"polluted1": "true"}}'); + const getMergeStrategy = () => undefined; + let result = customDeepMerge(getMergeStrategy, {}, maliciousSource); + + expect(result).toEqual({}); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(({} as any).polluted1).toBeUndefined(); + + const maliciousSource2 = JSON.parse( + '{"constructor": {"prototype": {"polluted2": "true"}}}', + ); + result = customDeepMerge(getMergeStrategy, {}, maliciousSource2); + expect(result).toEqual({}); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(({} as any).polluted2).toBeUndefined(); + + const maliciousSource3 = JSON.parse('{"prototype": {"polluted3": "true"}}'); + result = customDeepMerge(getMergeStrategy, {}, maliciousSource3); + expect(result).toEqual({}); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(({} as any).polluted3).toBeUndefined(); + }); + + it('should use additionalProperties merge strategy for dynamic properties', () => { + // Simulates how hooks work: hooks.disabled uses UNION, but hooks.BeforeTool (dynamic) uses CONCAT + const target = { + hooks: { + BeforeTool: [{ command: 'user-hook-1' }, { command: 'user-hook-2' }], + disabled: ['hook-a'], + }, + }; + const source = { + hooks: { + BeforeTool: [{ command: 'workspace-hook-1' }], + disabled: ['hook-b'], + }, + }; + + // Mock the getMergeStrategyForPath behavior for hooks + const getMergeStrategy = (path: string[]) => { + const p = path.join('.'); + // hooks.disabled uses UNION strategy (explicitly defined in schema) + if (p === 'hooks.disabled') return MergeStrategy.UNION; + // hooks.BeforeTool uses CONCAT strategy (via additionalProperties) + if (p === 'hooks.BeforeTool') return MergeStrategy.CONCAT; + return undefined; + }; + + const result = customDeepMerge(getMergeStrategy, target, source); + + // BeforeTool should concatenate + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((result as any)['hooks']['BeforeTool']).toEqual([ + { command: 'user-hook-1' }, + { command: 'user-hook-2' }, + { command: 'workspace-hook-1' }, + ]); + // disabled should union (deduplicate) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((result as any)['hooks']['disabled']).toEqual(['hook-a', 'hook-b']); + }); + + it('should overwrite primitive with object', () => { + const target = { a: 1 }; + const source = { a: { b: 2 } }; + const getMergeStrategy = () => undefined; + const result = customDeepMerge(getMergeStrategy, target, source); + expect(result).toEqual({ a: { b: 2 } }); + }); + + it('should overwrite object with primitive', () => { + const target = { a: { b: 2 } }; + const source = { a: 1 }; + const getMergeStrategy = () => undefined; + const result = customDeepMerge(getMergeStrategy, target, source); + expect(result).toEqual({ a: 1 }); + }); + + it('should not overwrite with undefined', () => { + const target = { a: 1 }; + const source = { a: undefined }; + const getMergeStrategy = () => undefined; + const result = customDeepMerge(getMergeStrategy, target, source); + expect(result).toEqual({ a: 1 }); + }); +}); diff --git a/packages/cli/src/utils/devtoolsService.test.ts b/packages/cli/src/utils/devtoolsService.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..5280b7e3549994f8c372fb6bdab1e6d61537be02 --- /dev/null +++ b/packages/cli/src/utils/devtoolsService.test.ts @@ -0,0 +1,485 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import type { Config } from '@google/gemini-cli-core'; + +// --- Mocks (hoisted) --- + +const mockInitActivityLogger = vi.hoisted(() => vi.fn()); +const mockAddNetworkTransport = vi.hoisted(() => vi.fn()); + +type Listener = (...args: unknown[]) => void; + +const { MockWebSocket } = vi.hoisted(() => { + class MockWebSocket { + close = vi.fn(); + url: string; + static instances: MockWebSocket[] = []; + private listeners = new Map(); + + constructor(url: string) { + this.url = url; + MockWebSocket.instances.push(this); + } + + on(event: string, fn: Listener) { + const fns = this.listeners.get(event) || []; + fns.push(fn); + this.listeners.set(event, fns); + return this; + } + + emit(event: string, ...args: unknown[]) { + for (const fn of this.listeners.get(event) || []) { + fn(...args); + } + } + + simulateOpen() { + this.emit('open'); + } + + simulateError() { + this.emit('error', new Error('ECONNREFUSED')); + } + } + return { MockWebSocket }; +}); + +const mockDevToolsInstance = vi.hoisted(() => ({ + start: vi.fn(), + stop: vi.fn(), + getPort: vi.fn(), +})); + +const mockActivityLoggerInstance = vi.hoisted(() => ({ + disableNetworkLogging: vi.fn(), + enableNetworkLogging: vi.fn(), + drainBufferedLogs: vi.fn().mockReturnValue({ network: [], console: [] }), +})); + +vi.mock('./activityLogger.js', () => ({ + initActivityLogger: mockInitActivityLogger, + addNetworkTransport: mockAddNetworkTransport, + ActivityLogger: { + getInstance: () => mockActivityLoggerInstance, + }, +})); + +const mockShouldLaunchBrowser = vi.hoisted(() => vi.fn(() => true)); +const mockOpenBrowserSecurely = vi.hoisted(() => + vi.fn(() => Promise.resolve()), +); + +vi.mock('@google/gemini-cli-core', () => ({ + debugLogger: { + log: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + }, + shouldLaunchBrowser: mockShouldLaunchBrowser, + openBrowserSecurely: mockOpenBrowserSecurely, +})); + +vi.mock('ws', () => ({ + default: MockWebSocket, +})); + +vi.mock('@google/gemini-cli-devtools', () => ({ + DevTools: { + getInstance: () => mockDevToolsInstance, + }, +})); + +// --- Import under test (after mocks) --- +import { + setupInitialActivityLogger, + startDevToolsServer, + toggleDevToolsPanel, + resetForTesting, +} from './devtoolsService.js'; + +function createMockConfig(overrides: Record = {}) { + return { + isInteractive: vi.fn().mockReturnValue(true), + getSessionId: vi.fn().mockReturnValue('test-session'), + getDebugMode: vi.fn().mockReturnValue(false), + storage: { getProjectTempLogsDir: vi.fn().mockReturnValue('/tmp/logs') }, + ...overrides, + } as unknown as Config; +} + +describe('devtoolsService', () => { + beforeEach(() => { + vi.clearAllMocks(); + MockWebSocket.instances = []; + resetForTesting(); + delete process.env['GEMINI_CLI_ACTIVITY_LOG_TARGET']; + }); + + describe('setupInitialActivityLogger', () => { + it('stays in buffer mode (no probe attempted)', () => { + const config = createMockConfig(); + setupInitialActivityLogger(config); + + expect(mockInitActivityLogger).toHaveBeenCalledWith(config, { + mode: 'buffer', + }); + expect(mockAddNetworkTransport).not.toHaveBeenCalled(); + // No WebSocket probe on startup + expect(MockWebSocket.instances.length).toBe(0); + }); + + it('initializes in file mode when target env var is set', () => { + process.env['GEMINI_CLI_ACTIVITY_LOG_TARGET'] = '/tmp/test.jsonl'; + const config = createMockConfig(); + setupInitialActivityLogger(config); + + expect(mockInitActivityLogger).toHaveBeenCalledWith(config, { + mode: 'file', + filePath: '/tmp/test.jsonl', + }); + // No probe attempted + expect(MockWebSocket.instances.length).toBe(0); + }); + + it('does nothing in file mode when config.storage is missing', () => { + process.env['GEMINI_CLI_ACTIVITY_LOG_TARGET'] = '/tmp/test.jsonl'; + const config = createMockConfig({ storage: undefined }); + setupInitialActivityLogger(config); + + expect(mockInitActivityLogger).not.toHaveBeenCalled(); + expect(MockWebSocket.instances.length).toBe(0); + }); + }); + + describe('startDevToolsServer', () => { + it('starts new server when none exists and enables logging', async () => { + const config = createMockConfig(); + mockDevToolsInstance.start.mockResolvedValue('http://127.0.0.1:25417'); + mockDevToolsInstance.getPort.mockReturnValue(25417); + + const promise = startDevToolsServer(config); + + await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1)); + MockWebSocket.instances[0].simulateError(); + + const url = await promise; + + expect(url).toBe('http://localhost:25417'); + expect(mockAddNetworkTransport).toHaveBeenCalledWith( + config, + '127.0.0.1', + 25417, + expect.any(Function), + ); + expect( + mockActivityLoggerInstance.enableNetworkLogging, + ).toHaveBeenCalled(); + }); + + it('connects to existing server if one is found', async () => { + const config = createMockConfig(); + + const promise = startDevToolsServer(config); + + await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1)); + MockWebSocket.instances[0].simulateOpen(); + + const url = await promise; + + expect(url).toBe('http://localhost:25417'); + expect(mockAddNetworkTransport).toHaveBeenCalled(); + expect( + mockActivityLoggerInstance.enableNetworkLogging, + ).toHaveBeenCalled(); + }); + + it('deduplicates concurrent calls (returns same promise)', async () => { + const config = createMockConfig(); + mockDevToolsInstance.start.mockResolvedValue('http://127.0.0.1:25417'); + mockDevToolsInstance.getPort.mockReturnValue(25417); + + const promise1 = startDevToolsServer(config); + const promise2 = startDevToolsServer(config); + + expect(promise1).toBe(promise2); + + await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1)); + MockWebSocket.instances[0].simulateError(); + + const [url1, url2] = await Promise.all([promise1, promise2]); + expect(url1).toBe('http://localhost:25417'); + expect(url2).toBe('http://localhost:25417'); + // Only one probe + one server start + expect(mockAddNetworkTransport).toHaveBeenCalledTimes(1); + }); + + it('throws when DevTools server fails to start', async () => { + const config = createMockConfig(); + mockDevToolsInstance.start.mockRejectedValue( + new Error('MODULE_NOT_FOUND'), + ); + + const promise = startDevToolsServer(config); + + // Probe fails first + await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1)); + MockWebSocket.instances[0].simulateError(); + + await expect(promise).rejects.toThrow('MODULE_NOT_FOUND'); + expect(mockAddNetworkTransport).not.toHaveBeenCalled(); + }); + + it('allows retry after server start failure', async () => { + const config = createMockConfig(); + mockDevToolsInstance.start.mockRejectedValueOnce( + new Error('MODULE_NOT_FOUND'), + ); + + const promise1 = startDevToolsServer(config); + + // Probe fails + await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1)); + MockWebSocket.instances[0].simulateError(); + + await expect(promise1).rejects.toThrow('MODULE_NOT_FOUND'); + + // Second attempt should work (not return the cached rejected promise) + mockDevToolsInstance.start.mockResolvedValue('http://127.0.0.1:25417'); + mockDevToolsInstance.getPort.mockReturnValue(25417); + + const promise2 = startDevToolsServer(config); + + await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(2)); + MockWebSocket.instances[1].simulateError(); + + const url = await promise2; + expect(url).toBe('http://localhost:25417'); + expect(mockAddNetworkTransport).toHaveBeenCalled(); + }); + + it('short-circuits on second F12 after successful start', async () => { + const config = createMockConfig(); + mockDevToolsInstance.start.mockResolvedValue('http://127.0.0.1:25417'); + mockDevToolsInstance.getPort.mockReturnValue(25417); + + const promise1 = startDevToolsServer(config); + + await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1)); + MockWebSocket.instances[0].simulateError(); + + const url1 = await promise1; + expect(url1).toBe('http://localhost:25417'); + + mockAddNetworkTransport.mockClear(); + mockDevToolsInstance.start.mockClear(); + + // Second call should short-circuit via connectedUrl + const url2 = await startDevToolsServer(config); + expect(url2).toBe('http://localhost:25417'); + expect(mockAddNetworkTransport).not.toHaveBeenCalled(); + expect(mockDevToolsInstance.start).not.toHaveBeenCalled(); + }); + + it('stops own server and connects to existing when losing port race', async () => { + const config = createMockConfig(); + + // Server starts on a different port (lost the race) + mockDevToolsInstance.start.mockResolvedValue('http://127.0.0.1:25418'); + mockDevToolsInstance.getPort.mockReturnValue(25418); + + const promise = startDevToolsServer(config); + + // First: probe for existing server (fails) + await vi.waitFor(() => { + expect(MockWebSocket.instances.length).toBe(1); + }); + MockWebSocket.instances[0].simulateError(); + + // Second: after starting, probes the default port winner + await vi.waitFor(() => { + expect(MockWebSocket.instances.length).toBe(2); + }); + // Winner is alive + MockWebSocket.instances[1].simulateOpen(); + + const url = await promise; + + expect(mockDevToolsInstance.stop).toHaveBeenCalled(); + expect(url).toBe('http://localhost:25417'); + expect(mockAddNetworkTransport).toHaveBeenCalledWith( + config, + '127.0.0.1', + 25417, + expect.any(Function), + ); + }); + + it('keeps own server when winner is not responding', async () => { + const config = createMockConfig(); + + mockDevToolsInstance.start.mockResolvedValue('http://127.0.0.1:25418'); + mockDevToolsInstance.getPort.mockReturnValue(25418); + + const promise = startDevToolsServer(config); + + // Probe for existing (fails) + await vi.waitFor(() => { + expect(MockWebSocket.instances.length).toBe(1); + }); + MockWebSocket.instances[0].simulateError(); + + // Probe the winner (also fails) + await vi.waitFor(() => { + expect(MockWebSocket.instances.length).toBe(2); + }); + MockWebSocket.instances[1].simulateError(); + + const url = await promise; + + expect(mockDevToolsInstance.stop).not.toHaveBeenCalled(); + expect(url).toBe('http://localhost:25418'); + expect(mockAddNetworkTransport).toHaveBeenCalledWith( + config, + '127.0.0.1', + 25418, + expect.any(Function), + ); + }); + }); + + describe('handlePromotion (via startDevToolsServer)', () => { + it('caps promotion attempts at MAX_PROMOTION_ATTEMPTS', async () => { + const config = createMockConfig(); + mockDevToolsInstance.start.mockResolvedValue('http://127.0.0.1:25417'); + mockDevToolsInstance.getPort.mockReturnValue(25417); + + // First: set up the logger so we can grab onReconnectFailed + const promise = startDevToolsServer(config); + + await vi.waitFor(() => { + expect(MockWebSocket.instances.length).toBe(1); + }); + MockWebSocket.instances[0].simulateError(); + + await promise; + + // Extract onReconnectFailed callback + const initCall = mockAddNetworkTransport.mock.calls[0]; + const onReconnectFailed = initCall[3]; + expect(onReconnectFailed).toBeDefined(); + + // Trigger promotion MAX_PROMOTION_ATTEMPTS + 1 times + // Each call should succeed (addNetworkTransport called) until cap is hit + mockAddNetworkTransport.mockClear(); + + await onReconnectFailed(); // attempt 1 + await onReconnectFailed(); // attempt 2 + await onReconnectFailed(); // attempt 3 + await onReconnectFailed(); // attempt 4 — should be capped + + // Only 3 calls to addNetworkTransport (capped at MAX_PROMOTION_ATTEMPTS) + expect(mockAddNetworkTransport).toHaveBeenCalledTimes(3); + }); + }); + + describe('toggleDevToolsPanel', () => { + it('calls toggle (to close) when already open', async () => { + const config = createMockConfig(); + const toggle = vi.fn(); + const setOpen = vi.fn(); + + const promise = toggleDevToolsPanel(config, true, toggle, setOpen); + await promise; + + expect(toggle).toHaveBeenCalledTimes(1); + expect(setOpen).not.toHaveBeenCalled(); + }); + + it('does NOT call toggle or setOpen when browser opens successfully', async () => { + const config = createMockConfig(); + const toggle = vi.fn(); + const setOpen = vi.fn(); + + mockShouldLaunchBrowser.mockReturnValue(true); + mockOpenBrowserSecurely.mockResolvedValue(undefined); + mockDevToolsInstance.start.mockResolvedValue('http://127.0.0.1:25417'); + mockDevToolsInstance.getPort.mockReturnValue(25417); + + const promise = toggleDevToolsPanel(config, false, toggle, setOpen); + + await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1)); + MockWebSocket.instances[0].simulateError(); + + await promise; + + expect(toggle).not.toHaveBeenCalled(); + expect(setOpen).not.toHaveBeenCalled(); + }); + + it('calls setOpen when browser fails to open', async () => { + const config = createMockConfig(); + const toggle = vi.fn(); + const setOpen = vi.fn(); + + mockShouldLaunchBrowser.mockReturnValue(true); + mockOpenBrowserSecurely.mockRejectedValue(new Error('no browser')); + mockDevToolsInstance.start.mockResolvedValue('http://127.0.0.1:25417'); + mockDevToolsInstance.getPort.mockReturnValue(25417); + + const promise = toggleDevToolsPanel(config, false, toggle, setOpen); + + await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1)); + MockWebSocket.instances[0].simulateError(); + + await promise; + + expect(toggle).not.toHaveBeenCalled(); + expect(setOpen).toHaveBeenCalledTimes(1); + }); + + it('calls setOpen when shouldLaunchBrowser returns false', async () => { + const config = createMockConfig(); + const toggle = vi.fn(); + const setOpen = vi.fn(); + + mockShouldLaunchBrowser.mockReturnValue(false); + mockDevToolsInstance.start.mockResolvedValue('http://127.0.0.1:25417'); + mockDevToolsInstance.getPort.mockReturnValue(25417); + + const promise = toggleDevToolsPanel(config, false, toggle, setOpen); + + await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1)); + MockWebSocket.instances[0].simulateError(); + + await promise; + + expect(toggle).not.toHaveBeenCalled(); + expect(setOpen).toHaveBeenCalledTimes(1); + }); + + it('calls setOpen when DevTools server fails to start', async () => { + const config = createMockConfig(); + const toggle = vi.fn(); + const setOpen = vi.fn(); + + mockDevToolsInstance.start.mockRejectedValue(new Error('fail')); + + const promise = toggleDevToolsPanel(config, false, toggle, setOpen); + + await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1)); + MockWebSocket.instances[0].simulateError(); + + await promise; + + expect(toggle).not.toHaveBeenCalled(); + expect(setOpen).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/cli/src/utils/devtoolsService.ts b/packages/cli/src/utils/devtoolsService.ts new file mode 100644 index 0000000000000000000000000000000000000000..4f54738875707cab2b43998b4ad2fd65e3b12513 --- /dev/null +++ b/packages/cli/src/utils/devtoolsService.ts @@ -0,0 +1,236 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { debugLogger, type Config } from '@google/gemini-cli-core'; +import WebSocket from 'ws'; +import { + initActivityLogger, + addNetworkTransport, + ActivityLogger, +} from './activityLogger.js'; + +interface IDevTools { + start(): Promise; + stop(): Promise; + getPort(): number; +} + +const DEFAULT_DEVTOOLS_PORT = 25417; +const DEFAULT_DEVTOOLS_HOST = '127.0.0.1'; +const MAX_PROMOTION_ATTEMPTS = 3; +let promotionAttempts = 0; +let serverStartPromise: Promise | null = null; +let connectedUrl: string | null = null; + +/** + * Probe whether a DevTools server is already listening on the given host:port. + * Returns true if a WebSocket handshake succeeds within a short timeout. + */ +function probeDevTools(host: string, port: number): Promise { + return new Promise((resolve) => { + const ws = new WebSocket(`ws://${host}:${port}/ws`); + const timer = setTimeout(() => { + ws.close(); + resolve(false); + }, 500); + + ws.on('open', () => { + clearTimeout(timer); + ws.close(); + resolve(true); + }); + + ws.on('error', () => { + clearTimeout(timer); + ws.close(); + resolve(false); + }); + }); +} + +/** + * Start a DevTools server, then check if we won the default port. + * If another instance grabbed it first (race), stop ours and connect as client. + * Returns { host, port } of the DevTools to connect to. + */ +async function startOrJoinDevTools( + defaultHost: string, + defaultPort: number, +): Promise<{ host: string; port: number }> { + const mod = await import('@google/gemini-cli-devtools'); + const devtools: IDevTools = mod.DevTools.getInstance(); + const url = await devtools.start(); + const actualPort = devtools.getPort(); + + if (actualPort === defaultPort) { + // We won the port — we are the server + debugLogger.log(`DevTools available at: ${url}`); + return { host: defaultHost, port: actualPort }; + } + + // Lost the race — someone else has the default port. + // Verify the winner is actually alive, then stop ours and connect to theirs. + const winnerAlive = await probeDevTools(defaultHost, defaultPort); + if (winnerAlive) { + await devtools.stop(); + debugLogger.log( + `DevTools (existing) at: http://${defaultHost}:${defaultPort}`, + ); + return { host: defaultHost, port: defaultPort }; + } + + // Winner isn't responding (maybe also racing and failed) — keep ours + debugLogger.log(`DevTools available at: ${url}`); + return { host: defaultHost, port: actualPort }; +} + +/** + * Handle promotion: when reconnect fails, start or join a DevTools server + * and add a new network transport for the logger. + */ +async function handlePromotion(config: Config) { + promotionAttempts++; + if (promotionAttempts > MAX_PROMOTION_ATTEMPTS) { + debugLogger.debug( + `Giving up on DevTools promotion after ${MAX_PROMOTION_ATTEMPTS} attempts`, + ); + return; + } + + try { + const result = await startOrJoinDevTools( + DEFAULT_DEVTOOLS_HOST, + DEFAULT_DEVTOOLS_PORT, + ); + addNetworkTransport(config, result.host, result.port, () => + handlePromotion(config), + ); + } catch (err) { + debugLogger.debug('Failed to promote to DevTools server:', err); + } +} + +/** + * Initializes the activity logger. + * Interception starts immediately in buffering mode. + * Transport is only attached when the user presses F12. + */ +export function setupInitialActivityLogger(config: Config) { + const target = process.env['GEMINI_CLI_ACTIVITY_LOG_TARGET']; + + if (target) { + if (!config.storage) return; + initActivityLogger(config, { mode: 'file', filePath: target }); + } else { + // Start in buffering mode — transport attached later on F12 + initActivityLogger(config, { mode: 'buffer' }); + } +} + +/** + * Starts the DevTools server and opens the UI in the browser. + * Returns the URL to the DevTools UI. + * Deduplicates concurrent calls — returns the same promise if already in flight. + */ +export function startDevToolsServer(config: Config): Promise { + if (connectedUrl) return Promise.resolve(connectedUrl); + if (serverStartPromise) return serverStartPromise; + serverStartPromise = startDevToolsServerImpl(config).catch((err) => { + serverStartPromise = null; + throw err; + }); + return serverStartPromise; +} + +async function startDevToolsServerImpl(config: Config): Promise { + const onReconnectFailed = () => handlePromotion(config); + + // Probe for an existing DevTools server + const existing = await probeDevTools( + DEFAULT_DEVTOOLS_HOST, + DEFAULT_DEVTOOLS_PORT, + ); + + let host = DEFAULT_DEVTOOLS_HOST; + let port = DEFAULT_DEVTOOLS_PORT; + + if (existing) { + debugLogger.log( + `DevTools (existing) at: http://${DEFAULT_DEVTOOLS_HOST}:${DEFAULT_DEVTOOLS_PORT}`, + ); + } else { + // No existing server — start (or join if we lose the race) + try { + const result = await startOrJoinDevTools( + DEFAULT_DEVTOOLS_HOST, + DEFAULT_DEVTOOLS_PORT, + ); + host = result.host; + port = result.port; + } catch (err) { + debugLogger.debug('Failed to start DevTools:', err); + throw err; + } + } + + // Promote the activity logger to use the network transport + addNetworkTransport(config, host, port, onReconnectFailed); + const capture = ActivityLogger.getInstance(); + capture.enableNetworkLogging(); + + const url = `http://localhost:${port}`; + connectedUrl = url; + return url; +} + +/** + * Handles the F12 key toggle for the DevTools panel. + * Starts the DevTools server, attempts to open the browser. + * If the panel is already open, it closes it. + * If the panel is closed: + * - Attempts to open the browser. + * - If browser opening is successful, the panel remains closed. + * - If browser opening fails or is not possible, the panel is opened. + */ +export async function toggleDevToolsPanel( + config: Config, + isOpen: boolean, + toggle: () => void, + setOpen: () => void, +): Promise { + if (isOpen) { + toggle(); + return; + } + + try { + const { openBrowserSecurely, shouldLaunchBrowser } = await import( + '@google/gemini-cli-core' + ); + const url = await startDevToolsServer(config); + if (shouldLaunchBrowser()) { + try { + await openBrowserSecurely(url); + // Browser opened successfully, don't open drawer. + return; + } catch (e) { + debugLogger.warn('Failed to open browser securely:', e); + } + } + // If we can't launch browser or it failed, open drawer. + setOpen(); + } catch (e) { + setOpen(); + debugLogger.error('Failed to start DevTools server:', e); + } +} + +/** Reset module-level state — test only. */ +export function resetForTesting() { + promotionAttempts = 0; + serverStartPromise = null; + connectedUrl = null; +} diff --git a/packages/cli/src/utils/dialogScopeUtils.test.ts b/packages/cli/src/utils/dialogScopeUtils.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..373db6c52d0b68a9c15cb4b04fa10832fc57dd75 --- /dev/null +++ b/packages/cli/src/utils/dialogScopeUtils.test.ts @@ -0,0 +1,107 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { SettingScope, type LoadedSettings } from '../config/settings.js'; +import { + getScopeItems, + getScopeMessageForSetting, +} from './dialogScopeUtils.js'; +import { isInSettingsScope } from './settingsUtils.js'; + +vi.mock('../config/settings', () => ({ + SettingScope: { + User: 'user', + Workspace: 'workspace', + System: 'system', + }, + isLoadableSettingScope: (scope: string) => + ['user', 'workspace', 'system'].includes(scope), +})); + +vi.mock('./settingsUtils', () => ({ + isInSettingsScope: vi.fn(), +})); + +describe('dialogScopeUtils', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + describe('getScopeItems', () => { + it('should return scope items with correct labels and values', () => { + const items = getScopeItems(); + expect(items).toEqual([ + { label: 'User Settings', value: SettingScope.User }, + { label: 'Workspace Settings', value: SettingScope.Workspace }, + { label: 'System Settings', value: SettingScope.System }, + ]); + }); + }); + + describe('getScopeMessageForSetting', () => { + let mockSettings: { forScope: ReturnType }; + + beforeEach(() => { + mockSettings = { + forScope: vi.fn().mockReturnValue({ settings: {} }), + }; + }); + + it('should return empty string if not modified in other scopes', () => { + vi.mocked(isInSettingsScope).mockReturnValue(false); + const message = getScopeMessageForSetting( + 'key', + SettingScope.User, + mockSettings as unknown as LoadedSettings, + ); + expect(message).toBe(''); + }); + + it('should return message indicating modification in other scopes', () => { + vi.mocked(isInSettingsScope).mockReturnValue(true); + + const message = getScopeMessageForSetting( + 'key', + SettingScope.User, + mockSettings as unknown as LoadedSettings, + ); + expect(message).toMatch(/Also modified in/); + expect(message).toMatch(/workspace/); + expect(message).toMatch(/system/); + }); + + it('should return message indicating modification in other scopes but not current', () => { + const workspaceSettings = { scope: 'workspace' }; + const systemSettings = { scope: 'system' }; + const userSettings = { scope: 'user' }; + + mockSettings.forScope.mockImplementation((scope: string) => { + if (scope === SettingScope.Workspace) + return { settings: workspaceSettings }; + if (scope === SettingScope.System) return { settings: systemSettings }; + if (scope === SettingScope.User) return { settings: userSettings }; + return { settings: {} }; + }); + + vi.mocked(isInSettingsScope).mockImplementation( + (_key, settings: unknown) => { + if (settings === workspaceSettings) return true; + if (settings === systemSettings) return false; + if (settings === userSettings) return false; + return false; + }, + ); + + const message = getScopeMessageForSetting( + 'key', + SettingScope.User, + mockSettings as unknown as LoadedSettings, + ); + expect(message).toBe('(Modified in workspace)'); + }); + }); +}); diff --git a/packages/cli/src/utils/dialogScopeUtils.ts b/packages/cli/src/utils/dialogScopeUtils.ts new file mode 100644 index 0000000000000000000000000000000000000000..e40c60e70d067bf127ea275e3f84168fe2bf185f --- /dev/null +++ b/packages/cli/src/utils/dialogScopeUtils.ts @@ -0,0 +1,74 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + isLoadableSettingScope, + SettingScope, + type LoadableSettingScope, + type Settings, +} from '../config/settings.js'; +import { isInSettingsScope } from './settingsUtils.js'; + +/** + * Shared scope labels for dialog components that need to display setting scopes + */ +export const SCOPE_LABELS = { + [SettingScope.User]: 'User Settings', + [SettingScope.Workspace]: 'Workspace Settings', + [SettingScope.System]: 'System Settings', +} as const; + +/** + * Helper function to get scope items for radio button selects + */ +export function getScopeItems(): Array<{ + label: string; + value: LoadableSettingScope; +}> { + return [ + { label: SCOPE_LABELS[SettingScope.User], value: SettingScope.User }, + { + label: SCOPE_LABELS[SettingScope.Workspace], + value: SettingScope.Workspace, + }, + { label: SCOPE_LABELS[SettingScope.System], value: SettingScope.System }, + ]; +} + +/** + * Generate scope message for a specific setting + */ +export function getScopeMessageForSetting( + settingKey: string, + selectedScope: LoadableSettingScope, + settings: { + forScope: (scope: LoadableSettingScope) => { settings: Settings }; + }, +): string { + const otherScopes = Object.values(SettingScope) + .filter(isLoadableSettingScope) + .filter((scope) => scope !== selectedScope); + + const modifiedInOtherScopes = otherScopes.filter((scope) => { + const scopeSettings = settings.forScope(scope).settings; + return isInSettingsScope(settingKey, scopeSettings); + }); + + if (modifiedInOtherScopes.length === 0) { + return ''; + } + + const modifiedScopesStr = modifiedInOtherScopes.join(', '); + const currentScopeSettings = settings.forScope(selectedScope).settings; + const existsInCurrentScope = isInSettingsScope( + settingKey, + currentScopeSettings, + ); + + return existsInCurrentScope + ? `(Also modified in ${modifiedScopesStr})` + : `(Modified in ${modifiedScopesStr})`; +} diff --git a/packages/cli/src/utils/envVarResolver.test.ts b/packages/cli/src/utils/envVarResolver.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..f4cffa1fc26bdb105382afdc4242a66d3f29dcf1 --- /dev/null +++ b/packages/cli/src/utils/envVarResolver.test.ts @@ -0,0 +1,322 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + resolveEnvVarsInString, + resolveEnvVarsInObject, +} from './envVarResolver.js'; + +describe('resolveEnvVarsInString', () => { + beforeEach(() => { + vi.stubEnv('TEST_VAR', ''); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('should resolve $VAR_NAME format', () => { + vi.stubEnv('TEST_VAR', 'test-value'); + + const result = resolveEnvVarsInString('Value is $TEST_VAR'); + + expect(result).toBe('Value is test-value'); + }); + + it('should resolve ${VAR_NAME} format', () => { + vi.stubEnv('TEST_VAR', 'test-value'); + + const result = resolveEnvVarsInString('Value is ${TEST_VAR}'); + + expect(result).toBe('Value is test-value'); + }); + + it('should resolve multiple variables', () => { + vi.stubEnv('HOST', 'localhost'); + vi.stubEnv('PORT', '8080'); + + const result = resolveEnvVarsInString('URL: http://$HOST:${PORT}/api'); + + expect(result).toBe('URL: http://localhost:8080/api'); + }); + + it('should support environment variables with dots', () => { + vi.stubEnv('FOO.BAR', 'baz'); + const result = resolveEnvVarsInString('Value: ${FOO.BAR}'); + expect(result).toBe('Value: baz'); + }); + + it('should leave undefined variables unchanged', () => { + const result = resolveEnvVarsInString('Value is $UNDEFINED_VAR'); + + expect(result).toBe('Value is $UNDEFINED_VAR'); + }); + + it('should leave undefined variables with braces unchanged', () => { + const result = resolveEnvVarsInString('Value is ${UNDEFINED_VAR}'); + + expect(result).toBe('Value is ${UNDEFINED_VAR}'); + }); + + it('should handle empty string', () => { + const result = resolveEnvVarsInString(''); + + expect(result).toBe(''); + }); + + it('should handle string without variables', () => { + const result = resolveEnvVarsInString('No variables here'); + + expect(result).toBe('No variables here'); + }); + + it('should handle mixed defined and undefined variables', () => { + vi.stubEnv('DEFINED', 'value'); + + const result = resolveEnvVarsInString('$DEFINED and $UNDEFINED mixed'); + + expect(result).toBe('value and $UNDEFINED mixed'); + }); + + it('should use default value when environment variable is missing', () => { + const result = resolveEnvVarsInString( + 'URL: ${MISSING_VAR:-https://default.example.com}/api', + ); + expect(result).toBe('URL: https://default.example.com/api'); + }); + + it('should ignore default value when environment variable is present', () => { + vi.stubEnv('PRESENT_VAR', 'https://actual.example.com'); + const result = resolveEnvVarsInString( + 'URL: ${PRESENT_VAR:-https://default.example.com}/api', + ); + expect(result).toBe('URL: https://actual.example.com/api'); + }); + + it('should support empty default value', () => { + const result = resolveEnvVarsInString('Value: ${MISSING_VAR:-}'); + expect(result).toBe('Value: '); + }); + + it('should correctly handle default values that contain colons or dashes', () => { + const result = resolveEnvVarsInString( + 'Value: ${MISSING_VAR:-val:-123-abc}', + ); + expect(result).toBe('Value: val:-123-abc'); + }); +}); + +describe('resolveEnvVarsInObject', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('should resolve variables in nested objects', () => { + vi.stubEnv('API_KEY', 'secret-123'); + vi.stubEnv('DB_URL', 'postgresql://localhost/test'); + + const config = { + server: { + auth: { + key: '$API_KEY', + }, + database: '${DB_URL}', + }, + port: 3000, + }; + + const result = resolveEnvVarsInObject(config); + + expect(result).toEqual({ + server: { + auth: { + key: 'secret-123', + }, + database: 'postgresql://localhost/test', + }, + port: 3000, + }); + }); + + it('should resolve variables in arrays', () => { + vi.stubEnv('ENV', 'production'); + vi.stubEnv('VERSION', '1.0.0'); + + const config = { + tags: ['$ENV', 'app', '${VERSION}'], + metadata: { + env: '$ENV', + }, + }; + + const result = resolveEnvVarsInObject(config); + + expect(result).toEqual({ + tags: ['production', 'app', '1.0.0'], + metadata: { + env: 'production', + }, + }); + }); + + it('should preserve non-string types', () => { + const config = { + enabled: true, + count: 42, + value: null, + data: undefined, + tags: ['item1', 'item2'], + }; + + const result = resolveEnvVarsInObject(config); + + expect(result).toEqual(config); + }); + + it('should handle MCP server config structure', () => { + vi.stubEnv('API_TOKEN', 'token-123'); + vi.stubEnv('SERVER_PORT', '8080'); + + const extensionConfig = { + name: 'test-extension', + version: '1.0.0', + mcpServers: { + 'test-server': { + command: 'node', + args: ['server.js', '--port', '${SERVER_PORT}'], + env: { + API_KEY: '$API_TOKEN', + STATIC_VALUE: 'unchanged', + }, + timeout: 5000, + }, + }, + }; + + const result = resolveEnvVarsInObject(extensionConfig); + + expect(result).toEqual({ + name: 'test-extension', + version: '1.0.0', + mcpServers: { + 'test-server': { + command: 'node', + args: ['server.js', '--port', '8080'], + env: { + API_KEY: 'token-123', + STATIC_VALUE: 'unchanged', + }, + timeout: 5000, + }, + }, + }); + }); + + it('should handle empty and null values', () => { + const config = { + empty: '', + nullValue: null, + undefinedValue: undefined, + zero: 0, + false: false, + }; + + const result = resolveEnvVarsInObject(config); + + expect(result).toEqual(config); + }); + + it('should handle circular references in objects without infinite recursion', () => { + vi.stubEnv('TEST_VAR', 'resolved-value'); + + type ConfigWithCircularRef = { + name: string; + value: number; + self?: ConfigWithCircularRef; + }; + + const config: ConfigWithCircularRef = { + name: '$TEST_VAR', + value: 42, + }; + // Create circular reference + config.self = config; + + const result = resolveEnvVarsInObject(config); + + expect(result.name).toBe('resolved-value'); + expect(result.value).toBe(42); + expect(result.self).toBeDefined(); + expect(result.self?.name).toBe('$TEST_VAR'); // Circular reference should be shallow copied + expect(result.self?.value).toBe(42); + // Verify it doesn't create infinite recursion by checking it's not the same object + expect(result.self).not.toBe(result); + }); + + it('should handle circular references in arrays without infinite recursion', () => { + vi.stubEnv('ARRAY_VAR', 'array-value'); + + type ArrayWithCircularRef = Array; + const arr: ArrayWithCircularRef = ['$ARRAY_VAR', 123]; + // Create circular reference + arr.push(arr); + + const result = resolveEnvVarsInObject(arr); + + expect(result[0]).toBe('array-value'); + expect(result[1]).toBe(123); + expect(Array.isArray(result[2])).toBe(true); + const subArray = result[2] as ArrayWithCircularRef; + expect(subArray[0]).toBe('$ARRAY_VAR'); // Circular reference should be shallow copied + expect(subArray[1]).toBe(123); + // Verify it doesn't create infinite recursion + expect(result[2]).not.toBe(result); + }); + + it('should handle complex nested circular references', () => { + vi.stubEnv('NESTED_VAR', 'nested-resolved'); + + type ObjWithRef = { + name: string; + id: number; + ref?: ObjWithRef; + }; + + const obj1: ObjWithRef = { name: '$NESTED_VAR', id: 1 }; + const obj2: ObjWithRef = { name: 'static', id: 2 }; + + // Create cross-references + obj1.ref = obj2; + obj2.ref = obj1; + + const config = { + primary: obj1, + secondary: obj2, + value: '$NESTED_VAR', + }; + + const result = resolveEnvVarsInObject(config); + + expect(result.value).toBe('nested-resolved'); + expect(result.primary.name).toBe('nested-resolved'); + expect(result.primary.id).toBe(1); + expect(result.secondary.name).toBe('static'); + expect(result.secondary.id).toBe(2); + + // Check that circular references are handled (shallow copied) + expect(result.primary.ref).toBeDefined(); + expect(result.secondary.ref).toBeDefined(); + expect(result.primary.ref?.name).toBe('static'); // Should be shallow copy + expect(result.secondary.ref?.name).toBe('nested-resolved'); // The shallow copy still gets processed + + // Most importantly: verify no infinite recursion by checking objects are different + expect(result.primary.ref).not.toBe(result.secondary); + expect(result.secondary.ref).not.toBe(result.primary); + expect(result.primary).not.toBe(obj1); // New object created + expect(result.secondary).not.toBe(obj2); // New object created + }); +}); diff --git a/packages/cli/src/utils/errors.test.ts b/packages/cli/src/utils/errors.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a1737117244e82e30e322f9cfef690593771f24e --- /dev/null +++ b/packages/cli/src/utils/errors.test.ts @@ -0,0 +1,651 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + vi, + type MockInstance, + describe, + it, + expect, + beforeEach, + afterEach, +} from 'vitest'; +import type { Config } from '@google/gemini-cli-core'; +import { + OutputFormat, + FatalInputError, + debugLogger, + coreEvents, +} from '@google/gemini-cli-core'; +import { + handleError, + handleToolError, + handleCancellationError, + handleMaxTurnsExceededError, +} from './errors.js'; +import { runSyncCleanup } from './cleanup.js'; + +// Mock the cleanup module +vi.mock('./cleanup.js', () => ({ + runSyncCleanup: vi.fn(), +})); + +// Mock the core modules +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const original = + await importOriginal(); + + return { + ...original, + parseAndFormatApiError: vi.fn((error: unknown) => { + if (error instanceof Error) { + return `API Error: ${error.message}`; + } + return `API Error: ${String(error)}`; + }), + JsonFormatter: vi.fn().mockImplementation(() => ({ + formatError: vi.fn( + (error: Error, code?: string | number, sessionId?: string) => + JSON.stringify( + { + ...(sessionId && { session_id: sessionId }), + error: { + type: error.constructor.name, + message: error.message, + ...(code && { code }), + }, + }, + null, + 2, + ), + ), + })), + StreamJsonFormatter: vi.fn().mockImplementation(() => ({ + emitEvent: vi.fn(), + convertToStreamStats: vi.fn().mockReturnValue({ + total_tokens: 0, + input_tokens: 0, + output_tokens: 0, + cached: 0, + input: 0, + duration_ms: 0, + tool_calls: 0, + models: {}, + }), + })), + uiTelemetryService: { + getMetrics: vi.fn().mockReturnValue({}), + }, + JsonStreamEventType: { + RESULT: 'result', + }, + coreEvents: { + emitFeedback: vi.fn(), + }, + FatalToolExecutionError: class extends Error { + constructor(message: string) { + super(message); + this.name = 'FatalToolExecutionError'; + this.exitCode = 54; + } + exitCode: number; + }, + FatalCancellationError: class extends Error { + constructor(message: string) { + super(message); + this.name = 'FatalCancellationError'; + this.exitCode = 130; + } + exitCode: number; + }, + }; +}); + +describe('errors', () => { + let mockConfig: Config; + let processExitSpy: MockInstance; + let debugLoggerErrorSpy: MockInstance; + let debugLoggerWarnSpy: MockInstance; + let coreEventsEmitFeedbackSpy: MockInstance; + let runSyncCleanupSpy: MockInstance; + + const TEST_SESSION_ID = 'test-session-123'; + + beforeEach(() => { + // Reset mocks + vi.clearAllMocks(); + + // Mock debugLogger + debugLoggerErrorSpy = vi + .spyOn(debugLogger, 'error') + .mockImplementation(() => {}); + debugLoggerWarnSpy = vi + .spyOn(debugLogger, 'warn') + .mockImplementation(() => {}); + + // Mock coreEvents + coreEventsEmitFeedbackSpy = vi.mocked(coreEvents.emitFeedback); + + // Mock runSyncCleanup + runSyncCleanupSpy = vi.mocked(runSyncCleanup); + + // Mock process.exit to throw instead of actually exiting + processExitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => { + throw new Error(`process.exit called with code: ${code}`); + }); + + // Create mock config + mockConfig = { + getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT), + getContentGeneratorConfig: vi.fn().mockReturnValue({ authType: 'test' }), + getSessionId: vi.fn().mockReturnValue(TEST_SESSION_ID), + } as unknown as Config; + }); + + afterEach(() => { + debugLoggerErrorSpy.mockRestore(); + debugLoggerWarnSpy.mockRestore(); + processExitSpy.mockRestore(); + }); + + describe('handleError', () => { + describe('in text mode', () => { + beforeEach(() => { + ( + mockConfig.getOutputFormat as ReturnType + ).mockReturnValue(OutputFormat.TEXT); + }); + + it('should re-throw without logging to debugLogger', () => { + const testError = new Error('Test error'); + + expect(() => { + handleError(testError, mockConfig); + }).toThrow(testError); + + expect(debugLoggerErrorSpy).not.toHaveBeenCalled(); + }); + + it('should handle non-Error objects', () => { + const testError = 'String error'; + + expect(() => { + handleError(testError, mockConfig); + }).toThrow(testError); + }); + }); + + describe('in JSON mode', () => { + beforeEach(() => { + ( + mockConfig.getOutputFormat as ReturnType + ).mockReturnValue(OutputFormat.JSON); + }); + + it('should format error as JSON, emit feedback exactly once, and exit with default code', () => { + const testError = new Error('Test error'); + + expect(() => { + handleError(testError, mockConfig); + }).toThrow('process.exit called with code: 1'); + + expect(coreEventsEmitFeedbackSpy).toHaveBeenCalledTimes(1); + expect(coreEventsEmitFeedbackSpy).toHaveBeenCalledWith( + 'error', + JSON.stringify( + { + session_id: TEST_SESSION_ID, + error: { + type: 'Error', + message: 'Test error', + code: 1, + }, + }, + null, + 2, + ), + ); + expect(debugLoggerErrorSpy).not.toHaveBeenCalled(); + expect(runSyncCleanupSpy).toHaveBeenCalled(); + }); + + it('should use custom error code when provided and only surface once', () => { + const testError = new Error('Test error'); + + expect(() => { + handleError(testError, mockConfig, 42); + }).toThrow('process.exit called with code: 42'); + + expect(coreEventsEmitFeedbackSpy).toHaveBeenCalledTimes(1); + expect(coreEventsEmitFeedbackSpy).toHaveBeenCalledWith( + 'error', + JSON.stringify( + { + session_id: TEST_SESSION_ID, + error: { + type: 'Error', + message: 'Test error', + code: 42, + }, + }, + null, + 2, + ), + ); + expect(debugLoggerErrorSpy).not.toHaveBeenCalled(); + }); + + it('should extract exitCode from FatalError instances and only surface once', () => { + const fatalError = new FatalInputError('Fatal error'); + + expect(() => { + handleError(fatalError, mockConfig); + }).toThrow('process.exit called with code: 42'); + + expect(coreEventsEmitFeedbackSpy).toHaveBeenCalledTimes(1); + expect(coreEventsEmitFeedbackSpy).toHaveBeenCalledWith( + 'error', + JSON.stringify( + { + session_id: TEST_SESSION_ID, + error: { + type: 'FatalInputError', + message: 'Fatal error', + code: 42, + }, + }, + null, + 2, + ), + ); + expect(debugLoggerErrorSpy).not.toHaveBeenCalled(); + }); + + it('should handle error with code property', () => { + const errorWithCode = new Error('Error with code') as Error & { + code: number; + }; + errorWithCode.code = 404; + + expect(() => { + handleError(errorWithCode, mockConfig); + }).toThrow('process.exit called with code: 404'); + }); + + it('should handle error with status property', () => { + const errorWithStatus = new Error('Error with status') as Error & { + status: string; + }; + errorWithStatus.status = 'TIMEOUT'; + + expect(() => { + handleError(errorWithStatus, mockConfig); + }).toThrow('process.exit called with code: 1'); // string codes become 1 + + expect(coreEventsEmitFeedbackSpy).toHaveBeenCalledWith( + 'error', + JSON.stringify( + { + session_id: TEST_SESSION_ID, + error: { + type: 'Error', + message: 'Error with status', + code: 'TIMEOUT', + }, + }, + null, + 2, + ), + ); + }); + }); + + describe('in STREAM_JSON mode', () => { + beforeEach(() => { + ( + mockConfig.getOutputFormat as ReturnType + ).mockReturnValue(OutputFormat.STREAM_JSON); + }); + + it('should emit result event, run cleanup, and exit', () => { + const testError = new Error('Test error'); + + expect(() => { + handleError(testError, mockConfig); + }).toThrow('process.exit called with code: 1'); + + expect(runSyncCleanupSpy).toHaveBeenCalled(); + }); + + it('should extract exitCode from FatalError instances', () => { + const fatalError = new FatalInputError('Fatal error'); + + expect(() => { + handleError(fatalError, mockConfig); + }).toThrow('process.exit called with code: 42'); + }); + }); + }); + + describe('handleToolError', () => { + const toolName = 'test-tool'; + const toolError = new Error('Tool failed'); + + describe('in text mode', () => { + beforeEach(() => { + ( + mockConfig.getOutputFormat as ReturnType + ).mockReturnValue(OutputFormat.TEXT); + }); + + it('should log error message to stderr (via debugLogger) for non-fatal', () => { + handleToolError(toolName, toolError, mockConfig); + + expect(debugLoggerWarnSpy).toHaveBeenCalledWith( + 'Error executing tool test-tool: Tool failed', + ); + }); + + it('should use resultDisplay when provided', () => { + handleToolError( + toolName, + toolError, + mockConfig, + 'CUSTOM_ERROR', + 'Custom display message', + ); + + expect(debugLoggerWarnSpy).toHaveBeenCalledWith( + 'Error executing tool test-tool: Custom display message', + ); + }); + + it('should emit feedback exactly once for fatal errors and not use debugLogger', () => { + expect(() => { + handleToolError(toolName, toolError, mockConfig, 'no_space_left'); + }).toThrow('process.exit called with code: 54'); + + expect(coreEventsEmitFeedbackSpy).toHaveBeenCalledTimes(1); + expect(coreEventsEmitFeedbackSpy).toHaveBeenCalledWith( + 'error', + 'Error executing tool test-tool: Tool failed', + ); + expect(debugLoggerErrorSpy).not.toHaveBeenCalled(); + expect(runSyncCleanupSpy).toHaveBeenCalled(); + }); + }); + + describe('in JSON mode', () => { + beforeEach(() => { + ( + mockConfig.getOutputFormat as ReturnType + ).mockReturnValue(OutputFormat.JSON); + }); + + describe('non-fatal errors', () => { + it('should log error message to stderr without exiting for recoverable errors', () => { + handleToolError( + toolName, + toolError, + mockConfig, + 'invalid_tool_params', + ); + + expect(debugLoggerWarnSpy).toHaveBeenCalledWith( + 'Error executing tool test-tool: Tool failed', + ); + // Should not exit for non-fatal errors + expect(processExitSpy).not.toHaveBeenCalled(); + expect(coreEventsEmitFeedbackSpy).not.toHaveBeenCalled(); + }); + + it('should not exit for file not found errors', () => { + handleToolError(toolName, toolError, mockConfig, 'file_not_found'); + + expect(debugLoggerWarnSpy).toHaveBeenCalledWith( + 'Error executing tool test-tool: Tool failed', + ); + expect(processExitSpy).not.toHaveBeenCalled(); + expect(coreEventsEmitFeedbackSpy).not.toHaveBeenCalled(); + }); + + it('should not exit for permission denied errors', () => { + handleToolError(toolName, toolError, mockConfig, 'permission_denied'); + + expect(debugLoggerWarnSpy).toHaveBeenCalledWith( + 'Error executing tool test-tool: Tool failed', + ); + expect(processExitSpy).not.toHaveBeenCalled(); + expect(coreEventsEmitFeedbackSpy).not.toHaveBeenCalled(); + }); + + it('should not exit for path not in workspace errors', () => { + handleToolError( + toolName, + toolError, + mockConfig, + 'path_not_in_workspace', + ); + + expect(debugLoggerWarnSpy).toHaveBeenCalledWith( + 'Error executing tool test-tool: Tool failed', + ); + expect(processExitSpy).not.toHaveBeenCalled(); + expect(coreEventsEmitFeedbackSpy).not.toHaveBeenCalled(); + }); + + it('should prefer resultDisplay over error message', () => { + handleToolError( + toolName, + toolError, + mockConfig, + 'invalid_tool_params', + 'Display message', + ); + + expect(debugLoggerWarnSpy).toHaveBeenCalledWith( + 'Error executing tool test-tool: Display message', + ); + expect(processExitSpy).not.toHaveBeenCalled(); + }); + }); + + describe('fatal errors', () => { + it('should exit immediately for NO_SPACE_LEFT errors and only surface once', () => { + expect(() => { + handleToolError(toolName, toolError, mockConfig, 'no_space_left'); + }).toThrow('process.exit called with code: 54'); + + expect(coreEventsEmitFeedbackSpy).toHaveBeenCalledTimes(1); + expect(coreEventsEmitFeedbackSpy).toHaveBeenCalledWith( + 'error', + JSON.stringify( + { + session_id: TEST_SESSION_ID, + error: { + type: 'FatalToolExecutionError', + message: 'Error executing tool test-tool: Tool failed', + code: 'no_space_left', + }, + }, + null, + 2, + ), + ); + expect(debugLoggerErrorSpy).not.toHaveBeenCalled(); + expect(runSyncCleanupSpy).toHaveBeenCalled(); + }); + }); + }); + + describe('in STREAM_JSON mode', () => { + beforeEach(() => { + ( + mockConfig.getOutputFormat as ReturnType + ).mockReturnValue(OutputFormat.STREAM_JSON); + }); + + it('should emit result event, run cleanup, and exit for fatal errors', () => { + expect(() => { + handleToolError(toolName, toolError, mockConfig, 'no_space_left'); + }).toThrow('process.exit called with code: 54'); + expect(runSyncCleanupSpy).toHaveBeenCalled(); + expect(coreEventsEmitFeedbackSpy).not.toHaveBeenCalled(); // Stream mode uses emitEvent + }); + + it('should log to stderr and not exit for non-fatal errors', () => { + handleToolError(toolName, toolError, mockConfig, 'invalid_tool_params'); + expect(debugLoggerWarnSpy).toHaveBeenCalledWith( + 'Error executing tool test-tool: Tool failed', + ); + expect(processExitSpy).not.toHaveBeenCalled(); + }); + }); + }); + + describe('handleCancellationError', () => { + describe('in text mode', () => { + beforeEach(() => { + ( + mockConfig.getOutputFormat as ReturnType + ).mockReturnValue(OutputFormat.TEXT); + }); + + it('should emit feedback exactly once, run cleanup, and exit with 130', () => { + expect(() => { + handleCancellationError(mockConfig); + }).toThrow('process.exit called with code: 130'); + + expect(coreEventsEmitFeedbackSpy).toHaveBeenCalledTimes(1); + expect(coreEventsEmitFeedbackSpy).toHaveBeenCalledWith( + 'error', + 'Operation cancelled.', + ); + expect(debugLoggerErrorSpy).not.toHaveBeenCalled(); + expect(runSyncCleanupSpy).toHaveBeenCalled(); + }); + }); + + describe('in JSON mode', () => { + beforeEach(() => { + ( + mockConfig.getOutputFormat as ReturnType + ).mockReturnValue(OutputFormat.JSON); + }); + + it('should format cancellation as JSON, emit feedback once, and exit with 130', () => { + expect(() => { + handleCancellationError(mockConfig); + }).toThrow('process.exit called with code: 130'); + + expect(coreEventsEmitFeedbackSpy).toHaveBeenCalledTimes(1); + expect(coreEventsEmitFeedbackSpy).toHaveBeenCalledWith( + 'error', + JSON.stringify( + { + session_id: TEST_SESSION_ID, + error: { + type: 'FatalCancellationError', + message: 'Operation cancelled.', + code: 130, + }, + }, + null, + 2, + ), + ); + expect(debugLoggerErrorSpy).not.toHaveBeenCalled(); + }); + }); + + describe('in STREAM_JSON mode', () => { + beforeEach(() => { + ( + mockConfig.getOutputFormat as ReturnType + ).mockReturnValue(OutputFormat.STREAM_JSON); + }); + + it('should emit result event and exit with 130', () => { + expect(() => { + handleCancellationError(mockConfig); + }).toThrow('process.exit called with code: 130'); + expect(coreEventsEmitFeedbackSpy).not.toHaveBeenCalled(); + }); + }); + }); + + describe('handleMaxTurnsExceededError', () => { + describe('in text mode', () => { + beforeEach(() => { + ( + mockConfig.getOutputFormat as ReturnType + ).mockReturnValue(OutputFormat.TEXT); + }); + + it('should emit feedback exactly once, run cleanup, and exit with 53', () => { + expect(() => { + handleMaxTurnsExceededError(mockConfig); + }).toThrow('process.exit called with code: 53'); + + expect(coreEventsEmitFeedbackSpy).toHaveBeenCalledTimes(1); + expect(coreEventsEmitFeedbackSpy).toHaveBeenCalledWith( + 'error', + 'Reached max session turns for this session. Increase the number of turns by specifying maxSessionTurns in settings.json.', + ); + expect(debugLoggerErrorSpy).not.toHaveBeenCalled(); + expect(runSyncCleanupSpy).toHaveBeenCalled(); + }); + }); + + describe('in JSON mode', () => { + beforeEach(() => { + ( + mockConfig.getOutputFormat as ReturnType + ).mockReturnValue(OutputFormat.JSON); + }); + + it('should format max turns error as JSON, emit feedback once, and exit with 53', () => { + expect(() => { + handleMaxTurnsExceededError(mockConfig); + }).toThrow('process.exit called with code: 53'); + + expect(coreEventsEmitFeedbackSpy).toHaveBeenCalledTimes(1); + expect(coreEventsEmitFeedbackSpy).toHaveBeenCalledWith( + 'error', + JSON.stringify( + { + session_id: TEST_SESSION_ID, + error: { + type: 'FatalTurnLimitedError', + message: + 'Reached max session turns for this session. Increase the number of turns by specifying maxSessionTurns in settings.json.', + code: 53, + }, + }, + null, + 2, + ), + ); + expect(debugLoggerErrorSpy).not.toHaveBeenCalled(); + }); + }); + + describe('in STREAM_JSON mode', () => { + beforeEach(() => { + ( + mockConfig.getOutputFormat as ReturnType + ).mockReturnValue(OutputFormat.STREAM_JSON); + }); + + it('should emit result event and exit with 53', () => { + expect(() => { + handleMaxTurnsExceededError(mockConfig); + }).toThrow('process.exit called with code: 53'); + expect(coreEventsEmitFeedbackSpy).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/packages/cli/src/utils/gitUtils.test.ts b/packages/cli/src/utils/gitUtils.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..8b3876fcc5120d99e33d08c760f9b43b808cda4d --- /dev/null +++ b/packages/cli/src/utils/gitUtils.test.ts @@ -0,0 +1,248 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, expect, it, afterEach, beforeEach } from 'vitest'; +import * as child_process from 'node:child_process'; +import { + isGitHubRepository, + getGitRepoRoot, + getLatestGitHubRelease, + getGitHubRepoInfo, +} from './gitUtils.js'; + +vi.mock('child_process'); + +describe('isGitHubRepository', async () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns false if the git command fails', async () => { + vi.mocked(child_process.execSync).mockImplementation((): string => { + throw new Error('oops'); + }); + expect(isGitHubRepository()).toBe(false); + }); + + it('returns false if the remote is not github.com', async () => { + vi.mocked(child_process.execSync).mockReturnValueOnce('https://gitlab.com'); + expect(isGitHubRepository()).toBe(false); + }); + + it('returns true if the remote is github.com', async () => { + vi.mocked(child_process.execSync).mockReturnValueOnce(` + origin https://github.com/sethvargo/gemini-cli (fetch) + origin https://github.com/sethvargo/gemini-cli (push) + `); + expect(isGitHubRepository()).toBe(true); + }); +}); + +describe('getGitHubRepoInfo', async () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('throws an error if github repo info cannot be determined', async () => { + vi.mocked(child_process.execSync).mockImplementation((): string => { + throw new Error('oops'); + }); + expect(() => { + getGitHubRepoInfo(); + }).toThrowError(/oops/); + }); + + it('throws an error if owner/repo could not be determined', async () => { + vi.mocked(child_process.execSync).mockReturnValueOnce(''); + expect(() => { + getGitHubRepoInfo(); + }).toThrowError(/Owner & repo could not be extracted from remote URL/); + }); + + it('returns the owner and repo', async () => { + vi.mocked(child_process.execSync).mockReturnValueOnce( + 'https://github.com/owner/repo.git ', + ); + expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' }); + }); + + // --- Tests for credential formats --- + + it('returns the owner and repo for URL with classic PAT token (ghp_)', async () => { + vi.mocked(child_process.execSync).mockReturnValueOnce( + 'https://ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx@github.com/owner/repo.git', + ); + expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' }); + }); + + it('returns the owner and repo for URL with fine-grained PAT token (github_pat_)', async () => { + vi.mocked(child_process.execSync).mockReturnValueOnce( + 'https://github_pat_xxxxxxxxxxxxxxxxxxxxxx_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx@github.com/owner/repo.git', + ); + expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' }); + }); + + it('returns the owner and repo for URL with username:password format', async () => { + vi.mocked(child_process.execSync).mockReturnValueOnce( + 'https://username:password@github.com/owner/repo.git', + ); + expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' }); + }); + + it('returns the owner and repo for URL with OAuth token (oauth2:token)', async () => { + vi.mocked(child_process.execSync).mockReturnValueOnce( + 'https://oauth2:gho_xxxxxxxxxxxx@github.com/owner/repo.git', + ); + expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' }); + }); + + it('returns the owner and repo for URL with GitHub Actions token (x-access-token)', async () => { + vi.mocked(child_process.execSync).mockReturnValueOnce( + 'https://x-access-token:ghs_xxxxxxxxxxxx@github.com/owner/repo.git', + ); + expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' }); + }); + + // --- Tests for case insensitivity --- + + it('returns the owner and repo for URL with uppercase GITHUB.COM', async () => { + vi.mocked(child_process.execSync).mockReturnValueOnce( + 'https://GITHUB.COM/owner/repo.git', + ); + expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' }); + }); + + it('returns the owner and repo for URL with mixed case GitHub.Com', async () => { + vi.mocked(child_process.execSync).mockReturnValueOnce( + 'https://GitHub.Com/owner/repo.git', + ); + expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' }); + }); + + // --- Tests for SSH format --- + + it('returns the owner and repo for SSH URL', async () => { + vi.mocked(child_process.execSync).mockReturnValueOnce( + 'git@github.com:owner/repo.git', + ); + expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' }); + }); + + it('throws for non-GitHub SSH URL', async () => { + vi.mocked(child_process.execSync).mockReturnValueOnce( + 'git@gitlab.com:owner/repo.git', + ); + expect(() => { + getGitHubRepoInfo(); + }).toThrowError(/Owner & repo could not be extracted from remote URL/); + }); + + // --- Tests for edge cases --- + + it('returns the owner and repo for URL without .git suffix', async () => { + vi.mocked(child_process.execSync).mockReturnValueOnce( + 'https://github.com/owner/repo', + ); + expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' }); + }); + + it('throws for non-GitHub HTTPS URL', async () => { + vi.mocked(child_process.execSync).mockReturnValueOnce( + 'https://gitlab.com/owner/repo.git', + ); + expect(() => { + getGitHubRepoInfo(); + }).toThrowError(/Owner & repo could not be extracted from remote URL/); + }); + + it('handles repo names containing .git substring', async () => { + vi.mocked(child_process.execSync).mockReturnValueOnce( + 'https://github.com/owner/my.git.repo.git', + ); + expect(getGitHubRepoInfo()).toEqual({ + owner: 'owner', + repo: 'my.git.repo', + }); + }); +}); + +describe('getGitRepoRoot', async () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('throws an error if git root cannot be determined', async () => { + vi.mocked(child_process.execSync).mockImplementation((): string => { + throw new Error('oops'); + }); + expect(() => { + getGitRepoRoot(); + }).toThrowError(/oops/); + }); + + it('throws an error if git root is empty', async () => { + vi.mocked(child_process.execSync).mockReturnValueOnce(''); + expect(() => { + getGitRepoRoot(); + }).toThrowError(/Git repo returned empty value/); + }); + + it('returns the root', async () => { + vi.mocked(child_process.execSync).mockReturnValueOnce('/path/to/git/repo'); + expect(getGitRepoRoot()).toBe('/path/to/git/repo'); + }); +}); + +describe('getLatestRelease', async () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('throws an error if the fetch fails', async () => { + global.fetch = vi.fn(() => Promise.reject('nope')); + await expect(getLatestGitHubRelease()).rejects.toThrowError( + /Unable to determine the latest/, + ); + }); + + it('throws an error if the fetch does not return a json body', async () => { + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ foo: 'bar' }), + } as Response), + ); + await expect(getLatestGitHubRelease()).rejects.toThrowError( + /Unable to determine the latest/, + ); + }); + + it('returns the release version', async () => { + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ tag_name: 'v1.2.3' }), + } as Response), + ); + await expect(getLatestGitHubRelease()).resolves.toBe('v1.2.3'); + }); +}); diff --git a/packages/cli/src/utils/handleAutoUpdate.test.ts b/packages/cli/src/utils/handleAutoUpdate.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..803a40141c1f764b7cf571e4d93d4d79ef6e8309 --- /dev/null +++ b/packages/cli/src/utils/handleAutoUpdate.test.ts @@ -0,0 +1,534 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, + type Mock, +} from 'vitest'; +import { getInstallationInfo, PackageManager } from './installationInfo.js'; +import { updateEventEmitter } from './updateEventEmitter.js'; +import type { UpdateObject } from '../ui/utils/updateCheck.js'; +import type { LoadedSettings } from '../config/settings.js'; +import EventEmitter from 'node:events'; +import type { ChildProcess } from 'node:child_process'; +import { + handleAutoUpdate, + setUpdateHandler, + isUpdateInProgress, + waitForUpdateCompletion, + _setUpdateStateForTesting, +} from './handleAutoUpdate.js'; +import { MessageType } from '../ui/types.js'; + +vi.mock('./installationInfo.js', async () => { + const actual = await vi.importActual('./installationInfo.js'); + return { + ...actual, + getInstallationInfo: vi.fn(), + }; +}); + +vi.mock('./updateEventEmitter.js', async (importOriginal) => + importOriginal(), +); + +const mockGetInstallationInfo = vi.mocked(getInstallationInfo); + +describe('handleAutoUpdate', () => { + let mockSpawn: Mock; + let mockUpdateInfo: UpdateObject; + let mockSettings: LoadedSettings; + let mockChildProcess: ChildProcess; + + beforeEach(() => { + vi.stubEnv('GEMINI_SANDBOX', ''); + vi.stubEnv('SANDBOX', ''); + mockSpawn = vi.fn(); + vi.clearAllMocks(); + vi.spyOn(updateEventEmitter, 'emit'); + mockUpdateInfo = { + update: { + latest: '2.0.0', + current: '1.0.0', + type: 'major', + name: '@google/gemini-cli', + }, + message: 'An update is available!', + }; + + mockSettings = { + merged: { + general: { + enableAutoUpdate: true, + enableAutoUpdateNotification: true, + }, + tools: { + sandbox: false, + }, + }, + } as LoadedSettings; + + mockChildProcess = Object.assign(new EventEmitter(), { + stdin: Object.assign(new EventEmitter(), { + write: vi.fn(), + end: vi.fn(), + }), + unref: vi.fn(), + }) as unknown as ChildProcess; + + mockSpawn.mockReturnValue( + mockChildProcess as unknown as ReturnType, + ); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.clearAllMocks(); + _setUpdateStateForTesting(false); + }); + + it('should do nothing if update info is null', () => { + handleAutoUpdate(null, mockSettings, '/root', false, mockSpawn); + expect(mockGetInstallationInfo).not.toHaveBeenCalled(); + expect(updateEventEmitter.emit).not.toHaveBeenCalled(); + expect(mockSpawn).not.toHaveBeenCalled(); + }); + + it('should track update progress state', async () => { + mockGetInstallationInfo.mockReturnValue({ + updateCommand: 'npm i -g @google/gemini-cli@latest', + updateMessage: 'This is an additional message.', + isGlobal: false, + packageManager: PackageManager.NPM, + }); + + expect(isUpdateInProgress()).toBe(false); + + handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', false, mockSpawn); + + expect(isUpdateInProgress()).toBe(true); + + mockChildProcess.emit('close', 0); + + expect(isUpdateInProgress()).toBe(false); + }); + + it('should track update progress state on error', async () => { + mockGetInstallationInfo.mockReturnValue({ + updateCommand: 'npm i -g @google/gemini-cli@latest', + updateMessage: 'This is an additional message.', + isGlobal: false, + packageManager: PackageManager.NPM, + }); + + handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', false, mockSpawn); + + expect(isUpdateInProgress()).toBe(true); + + mockChildProcess.emit('error', new Error('fail')); + + expect(isUpdateInProgress()).toBe(false); + }); + + it('should resolve waitForUpdateCompletion when update succeeds', async () => { + _setUpdateStateForTesting(true); + + const waitPromise = waitForUpdateCompletion(); + updateEventEmitter.emit('update-success', {}); + + await expect(waitPromise).resolves.toBeUndefined(); + }); + + it('should resolve waitForUpdateCompletion when update fails', async () => { + _setUpdateStateForTesting(true); + + const waitPromise = waitForUpdateCompletion(); + updateEventEmitter.emit('update-failed', {}); + + await expect(waitPromise).resolves.toBeUndefined(); + }); + + it('should resolve waitForUpdateCompletion immediately if not in progress', async () => { + _setUpdateStateForTesting(false); + + const waitPromise = waitForUpdateCompletion(); + + await expect(waitPromise).resolves.toBeUndefined(); + }); + + it('should timeout waitForUpdateCompletion', async () => { + vi.useFakeTimers(); + _setUpdateStateForTesting(true); + + const waitPromise = waitForUpdateCompletion(1000); + + vi.advanceTimersByTime(1001); + + await expect(waitPromise).resolves.toBeUndefined(); + vi.useRealTimers(); + }); + + it('should do nothing if update prompts are disabled', () => { + mockSettings.merged.general.enableAutoUpdateNotification = false; + handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', false, mockSpawn); + expect(mockGetInstallationInfo).not.toHaveBeenCalled(); + expect(updateEventEmitter.emit).not.toHaveBeenCalled(); + expect(mockSpawn).not.toHaveBeenCalled(); + }); + + it('should emit "update-received" but not update if auto-updates are disabled', () => { + mockSettings.merged.general.enableAutoUpdate = false; + mockGetInstallationInfo.mockReturnValue({ + updateCommand: 'npm i -g @google/gemini-cli@latest', + updateMessage: 'Please update manually.', + isGlobal: true, + packageManager: PackageManager.NPM, + }); + + handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', false, mockSpawn); + + expect(updateEventEmitter.emit).toHaveBeenCalledTimes(1); + expect(updateEventEmitter.emit).toHaveBeenCalledWith('update-received', { + ...mockUpdateInfo, + message: 'An update is available!\nPlease update manually.', + isUpdating: false, + }); + expect(mockSpawn).not.toHaveBeenCalled(); + }); + + it.each([ + PackageManager.NPX, + PackageManager.PNPX, + PackageManager.BUNX, + PackageManager.BINARY, + ])( + 'should suppress update notifications when running via %s', + (packageManager) => { + mockGetInstallationInfo.mockReturnValue({ + updateCommand: undefined, + updateMessage: `Running via ${packageManager}, update not applicable.`, + isGlobal: false, + packageManager, + }); + + handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', false, mockSpawn); + + expect(updateEventEmitter.emit).not.toHaveBeenCalled(); + expect(mockSpawn).not.toHaveBeenCalled(); + }, + ); + + it('should emit "update-received" but not update if no update command is found', () => { + mockGetInstallationInfo.mockReturnValue({ + updateCommand: undefined, + updateMessage: 'Cannot determine update command.', + isGlobal: false, + packageManager: PackageManager.NPM, + }); + + handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', false, mockSpawn); + + expect(updateEventEmitter.emit).toHaveBeenCalledTimes(1); + expect(updateEventEmitter.emit).toHaveBeenCalledWith('update-received', { + ...mockUpdateInfo, + message: 'An update is available!\nCannot determine update command.', + isUpdating: false, + }); + expect(mockSpawn).not.toHaveBeenCalled(); + }); + + it('should combine update messages correctly', () => { + mockGetInstallationInfo.mockReturnValue({ + updateCommand: undefined, // No command to prevent spawn + updateMessage: 'This is an additional message.', + isGlobal: false, + packageManager: PackageManager.NPM, + }); + + handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', false, mockSpawn); + + expect(updateEventEmitter.emit).toHaveBeenCalledTimes(1); + expect(updateEventEmitter.emit).toHaveBeenCalledWith('update-received', { + ...mockUpdateInfo, + message: 'An update is available!\nThis is an additional message.', + isUpdating: false, + }); + }); + + it('should attempt to perform an update when conditions are met', async () => { + mockGetInstallationInfo.mockReturnValue({ + updateCommand: 'npm i -g @google/gemini-cli@latest', + updateMessage: 'This is an additional message.', + isGlobal: false, + packageManager: PackageManager.NPM, + }); + + // Simulate successful execution + setTimeout(() => { + mockChildProcess.emit('close', 0); + }, 0); + + handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', false, mockSpawn); + + expect(mockSpawn).toHaveBeenCalledOnce(); + }); + + it('should emit "update-failed" when the update process fails', async () => { + await new Promise((resolve) => { + mockGetInstallationInfo.mockReturnValue({ + updateCommand: 'npm i -g @google/gemini-cli@latest', + updateMessage: 'This is an additional message.', + isGlobal: false, + packageManager: PackageManager.NPM, + }); + + // Simulate failed execution + setTimeout(() => { + mockChildProcess.emit('close', 1); + resolve(); + }, 0); + + handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', false, mockSpawn); + }); + + expect(updateEventEmitter.emit).toHaveBeenCalledWith('update-failed', { + message: + 'Automatic update failed. Please try updating manually:\n\nnpm i -g @google/gemini-cli@2.0.0', + }); + }); + + it('should emit "update-failed" when the spawn function throws an error', async () => { + await new Promise((resolve) => { + mockGetInstallationInfo.mockReturnValue({ + updateCommand: 'npm i -g @google/gemini-cli@latest', + updateMessage: 'This is an additional message.', + isGlobal: false, + packageManager: PackageManager.NPM, + }); + + // Simulate an error event + setTimeout(() => { + mockChildProcess.emit('error', new Error('Spawn error')); + resolve(); + }, 0); + + handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', false, mockSpawn); + }); + + expect(updateEventEmitter.emit).toHaveBeenCalledWith('update-failed', { + message: + 'Automatic update failed. Please try updating manually. (error: Spawn error)\n\nnpm i -g @google/gemini-cli@2.0.0', + }); + }); + + it('should use the "@nightly" tag for nightly updates', async () => { + mockUpdateInfo = { + ...mockUpdateInfo, + update: { + ...mockUpdateInfo.update, + current: '1.0.0-nightly.0', + latest: '2.0.0-nightly.1', + }, + }; + mockGetInstallationInfo.mockReturnValue({ + updateCommand: 'npm i -g @google/gemini-cli@latest', + updateMessage: 'This is an additional message.', + isGlobal: false, + packageManager: PackageManager.NPM, + }); + + handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', false, mockSpawn); + + expect(mockSpawn).toHaveBeenCalledWith( + 'npm i -g @google/gemini-cli@nightly', + { + shell: true, + stdio: 'ignore', + detached: true, + }, + ); + }); + + it('should NOT update if target is less stable than current (defense-in-depth)', async () => { + mockUpdateInfo = { + ...mockUpdateInfo, + update: { + ...mockUpdateInfo.update, + current: '1.0.0', + latest: '1.1.0-nightly.1', + }, + }; + mockGetInstallationInfo.mockReturnValue({ + updateCommand: 'npm i -g @google/gemini-cli@latest', + isGlobal: false, + packageManager: PackageManager.NPM, + }); + + handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', false, mockSpawn); + + expect(mockSpawn).not.toHaveBeenCalled(); + }); + + it('should emit "update-success" when the update process succeeds', async () => { + await new Promise((resolve) => { + mockGetInstallationInfo.mockReturnValue({ + updateCommand: 'npm i -g @google/gemini-cli@latest', + updateMessage: 'This is an additional message.', + isGlobal: false, + packageManager: PackageManager.NPM, + }); + + // Simulate successful execution + setTimeout(() => { + mockChildProcess.emit('close', 0); + resolve(); + }, 0); + + handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', false, mockSpawn); + }); + + expect(updateEventEmitter.emit).toHaveBeenCalledWith('update-success', { + message: + 'Update successful! The new version will be used on your next run.', + }); + }); + + it('should suppress update if isSandboxEnabled is true', () => { + handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', true, mockSpawn); + + expect(updateEventEmitter.emit).toHaveBeenCalledWith('update-info', { + message: `${mockUpdateInfo.message}\nAutomatic update is not available in sandbox mode.`, + }); + expect(mockSpawn).not.toHaveBeenCalled(); + }); +}); + +describe('setUpdateHandler', () => { + let addItem: ReturnType; + let setUpdateInfo: ReturnType; + let unregister: () => void; + + beforeEach(() => { + addItem = vi.fn(); + setUpdateInfo = vi.fn(); + vi.useFakeTimers(); + unregister = setUpdateHandler(addItem, setUpdateInfo); + }); + + afterEach(() => { + unregister(); + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it('should register event listeners', () => { + // We can't easily check if listeners are registered on the real EventEmitter + // without mocking it more deeply, but we can check if they respond to events. + expect(unregister).toBeInstanceOf(Function); + }); + + it('should handle update-received event', () => { + const updateInfo: UpdateObject = { + update: { + latest: '2.0.0', + current: '1.0.0', + type: 'major', + name: '@google/gemini-cli', + }, + message: 'Update available', + }; + + // Access the actual emitter to emit events + updateEventEmitter.emit('update-received', updateInfo); + + expect(setUpdateInfo).toHaveBeenCalledWith(updateInfo); + + // Advance timers to trigger timeout + vi.advanceTimersByTime(60000); + + expect(addItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: 'Update available', + }, + expect.any(Number), + ); + expect(setUpdateInfo).toHaveBeenCalledWith(null); + }); + + it('should handle update-failed event', () => { + updateEventEmitter.emit('update-failed', { + message: 'Failed message with command', + }); + + expect(setUpdateInfo).toHaveBeenCalledWith(null); + expect(addItem).toHaveBeenCalledWith( + { + type: MessageType.ERROR, + text: 'Failed message with command', + }, + expect.any(Number), + ); + }); + + it('should handle update-success event', () => { + updateEventEmitter.emit('update-success', { message: 'Success' }); + + expect(setUpdateInfo).toHaveBeenCalledWith(null); + expect(addItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: 'Update successful! The new version will be used on your next run.', + }, + expect.any(Number), + ); + }); + + it('should not show update-received message if update-success was called', () => { + const updateInfo: UpdateObject = { + update: { + latest: '2.0.0', + current: '1.0.0', + type: 'major', + name: '@google/gemini-cli', + }, + message: 'Update available', + }; + + updateEventEmitter.emit('update-received', updateInfo); + updateEventEmitter.emit('update-success', { message: 'Success' }); + + // Advance timers + vi.advanceTimersByTime(60000); + + // Should only have called addItem for success, not for received (after timeout) + expect(addItem).toHaveBeenCalledTimes(1); + expect(addItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: 'Update successful! The new version will be used on your next run.', + }, + expect.any(Number), + ); + }); + + it('should handle update-info event', () => { + updateEventEmitter.emit('update-info', { message: 'Info message' }); + + expect(addItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: 'Info message', + }, + expect.any(Number), + ); + }); +}); diff --git a/packages/cli/src/utils/handleAutoUpdate.ts b/packages/cli/src/utils/handleAutoUpdate.ts new file mode 100644 index 0000000000000000000000000000000000000000..0071ed7a2f774e2c084108e3ea7e481de4535a53 --- /dev/null +++ b/packages/cli/src/utils/handleAutoUpdate.ts @@ -0,0 +1,257 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { UpdateObject } from '../ui/utils/updateCheck.js'; +import type { LoadedSettings } from '../config/settings.js'; +import { getInstallationInfo, PackageManager } from './installationInfo.js'; +import { updateEventEmitter } from './updateEventEmitter.js'; +import { MessageType, type HistoryItem } from '../ui/types.js'; +import { spawnWrapper } from './spawnWrapper.js'; +import type { spawn } from 'node:child_process'; +import { + debugLogger, + getChannelFromVersion, + RELEASE_CHANNEL_STABILITY, +} from '@google/gemini-cli-core'; + +let _updateInProgress = false; + +/** @internal */ +export function _setUpdateStateForTesting(value: boolean) { + _updateInProgress = value; +} + +export function isUpdateInProgress() { + return _updateInProgress; +} + +/** + * Returns a promise that resolves when the update process completes or times out. + */ +export async function waitForUpdateCompletion( + timeoutMs = 30000, +): Promise { + if (!_updateInProgress) { + return; + } + + debugLogger.log( + '\nGemini CLI is waiting for a background update to complete before restarting...', + ); + + return new Promise((resolve) => { + // Re-check the condition inside the promise executor to avoid a race condition. + // If the update finished between the initial check and now, resolve immediately. + if (!_updateInProgress) { + resolve(); + return; + } + + const timer = setTimeout(cleanup, timeoutMs); + + function cleanup() { + clearTimeout(timer); + updateEventEmitter.off('update-success', cleanup); + updateEventEmitter.off('update-failed', cleanup); + resolve(); + } + + updateEventEmitter.once('update-success', cleanup); + updateEventEmitter.once('update-failed', cleanup); + }); +} + +export function handleAutoUpdate( + info: UpdateObject | null, + settings: LoadedSettings, + projectRoot: string, + isSandboxEnabled: boolean, + spawnFn: typeof spawn = spawnWrapper, +) { + if (!info) { + return; + } + + if (isSandboxEnabled) { + updateEventEmitter.emit('update-info', { + message: `${info.message}\nAutomatic update is not available in sandbox mode.`, + }); + return; + } + + if (!settings.merged.general.enableAutoUpdateNotification) { + return; + } + + const installationInfo = getInstallationInfo( + projectRoot, + settings.merged.general.enableAutoUpdate, + ); + + if ( + [ + PackageManager.NPX, + PackageManager.PNPX, + PackageManager.BUNX, + PackageManager.BINARY, + ].includes(installationInfo.packageManager) + ) { + return; + } + + let combinedMessage = info.message; + if (installationInfo.updateMessage) { + combinedMessage += `\n${installationInfo.updateMessage}`; + } + + if ( + !installationInfo.updateCommand || + !settings.merged.general.enableAutoUpdate + ) { + updateEventEmitter.emit('update-received', { + ...info, + message: combinedMessage, + isUpdating: false, + }); + return; + } + updateEventEmitter.emit('update-received', { + ...info, + message: combinedMessage, + isUpdating: true, + }); + if (_updateInProgress) { + return; + } + + const currentVersion = info.update.current; + if (!currentVersion) { + debugLogger.warn( + 'Update check: current version is missing. Skipping automatic update for safety.', + ); + return; + } + + const currentChannel = getChannelFromVersion(currentVersion); + const targetChannel = getChannelFromVersion(info.update.latest); + + // Defense-in-depth: prevent updates to a less stable channel + if ( + RELEASE_CHANNEL_STABILITY[targetChannel] < + RELEASE_CHANNEL_STABILITY[currentChannel] + ) { + return; + } + + const isNightly = info.update.latest.includes('nightly'); + + const updateCommand = installationInfo.updateCommand.replace( + '@latest', + isNightly ? '@nightly' : `@${info.update.latest}`, + ); + const updateProcess = spawnFn(updateCommand, { + stdio: 'ignore', + shell: true, + detached: true, + }); + + _updateInProgress = true; + + // Un-reference the child process to allow the parent to exit independently. + updateProcess.unref(); + + updateProcess.on('close', (code) => { + _updateInProgress = false; + if (code === 0) { + updateEventEmitter.emit('update-success', { + message: + 'Update successful! The new version will be used on your next run.', + }); + } else { + updateEventEmitter.emit('update-failed', { + message: `Automatic update failed. Please try updating manually:\n\n${updateCommand}`, + }); + } + }); + + updateProcess.on('error', (err) => { + _updateInProgress = false; + updateEventEmitter.emit('update-failed', { + message: `Automatic update failed. Please try updating manually. (error: ${err.message})\n\n${updateCommand}`, + }); + }); + return updateProcess; +} + +export function setUpdateHandler( + addItem: (item: Omit, timestamp: number) => void, + setUpdateInfo: (info: UpdateObject | null) => void, +) { + let successfullyInstalled = false; + const handleUpdateReceived = (info: UpdateObject) => { + setUpdateInfo(info); + const savedMessage = info.message; + setTimeout(() => { + if (!successfullyInstalled) { + addItem( + { + type: MessageType.INFO, + text: savedMessage, + }, + Date.now(), + ); + } + setUpdateInfo(null); + }, 60000); + }; + + const handleUpdateFailed = (data?: { message: string }) => { + setUpdateInfo(null); + addItem( + { + type: MessageType.ERROR, + text: + data?.message || + `Automatic update failed. Please try updating manually`, + }, + Date.now(), + ); + }; + + const handleUpdateSuccess = () => { + successfullyInstalled = true; + setUpdateInfo(null); + addItem( + { + type: MessageType.INFO, + text: `Update successful! The new version will be used on your next run.`, + }, + Date.now(), + ); + }; + + const handleUpdateInfo = (data: { message: string }) => { + addItem( + { + type: MessageType.INFO, + text: data.message, + }, + Date.now(), + ); + }; + + updateEventEmitter.on('update-received', handleUpdateReceived); + updateEventEmitter.on('update-failed', handleUpdateFailed); + updateEventEmitter.on('update-success', handleUpdateSuccess); + updateEventEmitter.on('update-info', handleUpdateInfo); + + return () => { + updateEventEmitter.off('update-received', handleUpdateReceived); + updateEventEmitter.off('update-failed', handleUpdateFailed); + updateEventEmitter.off('update-success', handleUpdateSuccess); + updateEventEmitter.off('update-info', handleUpdateInfo); + }; +} diff --git a/packages/cli/src/utils/hookSettings.test.ts b/packages/cli/src/utils/hookSettings.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..9de8adb051860847daf70385cbf88e8b3fe6ba0c --- /dev/null +++ b/packages/cli/src/utils/hookSettings.test.ts @@ -0,0 +1,182 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { enableHook, disableHook } from './hookSettings.js'; +import { SettingScope, type LoadedSettings } from '../config/settings.js'; + +describe('hookSettings', () => { + let mockSettings: LoadedSettings; + let mockUser: { + path: string; + settings: { hooksConfig: { disabled: string[] } }; + }; + let mockWorkspace: { + path: string; + settings: { hooksConfig: { disabled: string[] } }; + }; + let mockSetValue: ReturnType; + + beforeEach(() => { + mockUser = { + path: '/mock/user.json', + settings: { hooksConfig: { disabled: [] } }, + }; + mockWorkspace = { + path: '/mock/workspace.json', + settings: { hooksConfig: { disabled: [] } }, + }; + mockSetValue = vi.fn(); + + mockSettings = { + forScope: (scope: SettingScope) => { + if (scope === SettingScope.User) return mockUser; + if (scope === SettingScope.Workspace) return mockWorkspace; + return mockUser; // Default/Fallback + }, + setValue: mockSetValue, + } as unknown as LoadedSettings; + }); + + describe('enableHook', () => { + it('should return no-op if hook is not disabled in any scope', () => { + const result = enableHook(mockSettings, 'test-hook'); + + expect(result.status).toBe('no-op'); + expect(result.action).toBe('enable'); + expect(result.modifiedScopes).toHaveLength(0); + expect(result.alreadyInStateScopes).toHaveLength(2); // User + Workspace + expect(mockSetValue).not.toHaveBeenCalled(); + }); + + it('should enable hook in User scope if disabled there', () => { + mockUser.settings.hooksConfig.disabled = ['test-hook']; + + const result = enableHook(mockSettings, 'test-hook'); + + expect(result.status).toBe('success'); + expect(result.modifiedScopes).toEqual([ + { scope: SettingScope.User, path: '/mock/user.json' }, + ]); + expect(mockSetValue).toHaveBeenCalledWith( + SettingScope.User, + 'hooksConfig.disabled', + [], + ); + }); + + it('should enable hook in Workspace scope if disabled there', () => { + mockWorkspace.settings.hooksConfig.disabled = ['test-hook']; + + const result = enableHook(mockSettings, 'test-hook'); + + expect(result.status).toBe('success'); + expect(result.modifiedScopes).toEqual([ + { scope: SettingScope.Workspace, path: '/mock/workspace.json' }, + ]); + expect(mockSetValue).toHaveBeenCalledWith( + SettingScope.Workspace, + 'hooksConfig.disabled', + [], + ); + }); + + it('should enable hook in BOTH scopes if disabled in both', () => { + mockUser.settings.hooksConfig.disabled = ['test-hook', 'other']; + mockWorkspace.settings.hooksConfig.disabled = ['test-hook']; + + const result = enableHook(mockSettings, 'test-hook'); + + expect(result.status).toBe('success'); + expect(result.modifiedScopes).toHaveLength(2); + expect(result.modifiedScopes).toContainEqual({ + scope: SettingScope.User, + path: '/mock/user.json', + }); + expect(result.modifiedScopes).toContainEqual({ + scope: SettingScope.Workspace, + path: '/mock/workspace.json', + }); + + expect(mockSetValue).toHaveBeenCalledWith( + SettingScope.Workspace, + 'hooksConfig.disabled', + [], + ); + expect(mockSetValue).toHaveBeenCalledWith( + SettingScope.User, + 'hooksConfig.disabled', + ['other'], + ); + }); + }); + + describe('disableHook', () => { + it('should disable hook in the requested scope', () => { + const result = disableHook( + mockSettings, + 'test-hook', + SettingScope.Workspace, + ); + + expect(result.status).toBe('success'); + expect(result.modifiedScopes).toEqual([ + { scope: SettingScope.Workspace, path: '/mock/workspace.json' }, + ]); + expect(mockSetValue).toHaveBeenCalledWith( + SettingScope.Workspace, + 'hooksConfig.disabled', + ['test-hook'], + ); + }); + + it('should return no-op if already disabled in requested scope', () => { + mockWorkspace.settings.hooksConfig.disabled = ['test-hook']; + + const result = disableHook( + mockSettings, + 'test-hook', + SettingScope.Workspace, + ); + + expect(result.status).toBe('no-op'); + expect(mockSetValue).not.toHaveBeenCalled(); + }); + + it('should disable in requested scope and report if already disabled in other scope', () => { + // User has it disabled + mockUser.settings.hooksConfig.disabled = ['test-hook']; + + // We request disable in Workspace + const result = disableHook( + mockSettings, + 'test-hook', + SettingScope.Workspace, + ); + + expect(result.status).toBe('success'); + expect(result.modifiedScopes).toEqual([ + { scope: SettingScope.Workspace, path: '/mock/workspace.json' }, + ]); + expect(result.alreadyInStateScopes).toEqual([ + { scope: SettingScope.User, path: '/mock/user.json' }, + ]); + expect(mockSetValue).toHaveBeenCalledWith( + SettingScope.Workspace, + 'hooksConfig.disabled', + ['test-hook'], + ); + }); + + it('should return error if invalid scope provided', () => { + // @ts-expect-error - Testing runtime check + const result = disableHook(mockSettings, 'test-hook', 'InvalidScope'); + + expect(result.status).toBe('error'); + expect(result.error).toContain('Invalid settings scope'); + }); + }); +}); diff --git a/packages/cli/src/utils/installationInfo.test.ts b/packages/cli/src/utils/installationInfo.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..ad55efda32d68c7f27ffc90e967b52aabc16bc86 --- /dev/null +++ b/packages/cli/src/utils/installationInfo.test.ts @@ -0,0 +1,413 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { getInstallationInfo, PackageManager } from './installationInfo.js'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as childProcess from 'node:child_process'; +import { isGitRepository, debugLogger } from '@google/gemini-cli-core'; + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + isGitRepository: vi.fn(), + }; +}); + +vi.mock('fs', async (importOriginal) => { + const actualFs = await importOriginal(); + return { + ...actualFs, + realpathSync: vi.fn(), + existsSync: vi.fn(), + }; +}); + +vi.mock('child_process', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + execSync: vi.fn(), + }; +}); + +const mockedIsGitRepository = vi.mocked(isGitRepository); +const mockedRealPathSync = vi.mocked(fs.realpathSync); +const mockedExistsSync = vi.mocked(fs.existsSync); +const mockedExecSync = vi.mocked(childProcess.execSync); + +describe('getInstallationInfo', () => { + const projectRoot = '/path/to/project'; + let originalArgv: string[]; + + beforeEach(() => { + vi.resetAllMocks(); + originalArgv = [...process.argv]; + // Mock process.cwd() for isGitRepository + vi.spyOn(process, 'cwd').mockReturnValue(projectRoot); + vi.spyOn(debugLogger, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + process.argv = originalArgv; + }); + + it('should detect running as a standalone binary', () => { + vi.stubEnv('IS_BINARY', 'true'); + process.argv[1] = '/path/to/binary'; + const info = getInstallationInfo(projectRoot, true); + expect(info.packageManager).toBe(PackageManager.BINARY); + expect(info.isGlobal).toBe(true); + expect(info.updateMessage).toBe( + 'Running as a standalone binary. Please update by downloading the latest version from GitHub.', + ); + expect(info.updateCommand).toBeUndefined(); + vi.unstubAllEnvs(); + }); + + it('should return UNKNOWN when cliPath is not available', () => { + process.argv[1] = ''; + const info = getInstallationInfo(projectRoot, true); + expect(info.packageManager).toBe(PackageManager.UNKNOWN); + }); + + it('should return UNKNOWN and log error if realpathSync fails', () => { + process.argv[1] = '/path/to/cli'; + const error = new Error('realpath failed'); + mockedRealPathSync.mockImplementation(() => { + throw error; + }); + + const info = getInstallationInfo(projectRoot, true); + + expect(info.packageManager).toBe(PackageManager.UNKNOWN); + expect(debugLogger.log).toHaveBeenCalledWith(error); + }); + + it('should detect running from a local git clone', () => { + process.argv[1] = `${projectRoot}/packages/cli/dist/index.js`; + mockedRealPathSync.mockReturnValue( + `${projectRoot}/packages/cli/dist/index.js`, + ); + mockedIsGitRepository.mockReturnValue(true); + + const info = getInstallationInfo(projectRoot, true); + + expect(info.packageManager).toBe(PackageManager.UNKNOWN); + expect(info.isGlobal).toBe(false); + expect(info.updateMessage).toBe( + 'Running from a local git clone. Please update with "git pull".', + ); + }); + + it('should detect running via npx', () => { + const npxPath = `/Users/test/.npm/_npx/12345/bin/gemini`; + process.argv[1] = npxPath; + mockedRealPathSync.mockReturnValue(npxPath); + + const info = getInstallationInfo(projectRoot, true); + + expect(info.packageManager).toBe(PackageManager.NPX); + expect(info.isGlobal).toBe(false); + expect(info.updateMessage).toBe('Running via npx, update not applicable.'); + }); + + it('should detect running via pnpx', () => { + const pnpxPath = `/Users/test/.pnpm/_pnpx/12345/bin/gemini`; + process.argv[1] = pnpxPath; + mockedRealPathSync.mockReturnValue(pnpxPath); + + const info = getInstallationInfo(projectRoot, true); + + expect(info.packageManager).toBe(PackageManager.PNPX); + expect(info.isGlobal).toBe(false); + expect(info.updateMessage).toBe('Running via pnpx, update not applicable.'); + }); + + it('should detect running via bunx', () => { + const bunxPath = `/Users/test/.bun/install/cache/12345/bin/gemini`; + process.argv[1] = bunxPath; + mockedRealPathSync.mockReturnValue(bunxPath); + mockedExecSync.mockImplementation(() => { + throw new Error('Command failed'); + }); + + const info = getInstallationInfo(projectRoot, true); + + expect(info.packageManager).toBe(PackageManager.BUNX); + expect(info.isGlobal).toBe(false); + expect(info.updateMessage).toBe('Running via bunx, update not applicable.'); + }); + + it('should detect Homebrew installation via execSync', () => { + Object.defineProperty(process, 'platform', { + value: 'darwin', + }); + // Use a path that matches what brew would resolve to + const cliPath = '/opt/homebrew/Cellar/gemini-cli/1.0.0/bin/gemini'; + process.argv[1] = cliPath; + + mockedExecSync.mockImplementation((cmd) => { + if (typeof cmd === 'string' && cmd.includes('brew --prefix gemini-cli')) { + return '/opt/homebrew/opt/gemini-cli'; + } + throw new Error(`Command failed: ${cmd}`); + }); + + mockedRealPathSync.mockImplementation((p) => { + if (p === cliPath) return cliPath; + if (p === '/opt/homebrew/opt/gemini-cli') { + return '/opt/homebrew/Cellar/gemini-cli/1.0.0'; + } + return String(p); + }); + + const info = getInstallationInfo(projectRoot, true); + + expect(mockedExecSync).toHaveBeenCalledWith( + expect.stringContaining('brew --prefix gemini-cli'), + expect.anything(), + ); + expect(info.packageManager).toBe(PackageManager.HOMEBREW); + expect(info.isGlobal).toBe(true); + expect(info.updateMessage).toBe( + 'Installed via Homebrew. Please update with "brew upgrade gemini-cli".', + ); + }); + + it('should fall through if brew command fails', () => { + Object.defineProperty(process, 'platform', { + value: 'darwin', + }); + const cliPath = '/usr/local/bin/gemini'; + process.argv[1] = cliPath; + mockedRealPathSync.mockReturnValue(cliPath); + mockedExecSync.mockImplementation(() => { + throw new Error('Command failed'); + }); + + const info = getInstallationInfo(projectRoot, true); + + expect(mockedExecSync).toHaveBeenCalledWith( + expect.stringContaining('brew --prefix gemini-cli'), + expect.anything(), + ); + // Should fall back to default global npm + expect(info.packageManager).toBe(PackageManager.NPM); + expect(info.isGlobal).toBe(true); + }); + + it('should detect global pnpm installation', () => { + const pnpmPath = `/Users/test/.pnpm/global/5/node_modules/.pnpm/some-hash/node_modules/@google/gemini-cli/dist/index.js`; + process.argv[1] = pnpmPath; + mockedRealPathSync.mockReturnValue(pnpmPath); + mockedExecSync.mockImplementation(() => { + throw new Error('Command failed'); + }); + + // isAutoUpdateEnabled = true -> "Attempting to automatically update" + const info = getInstallationInfo(projectRoot, true); + expect(info.packageManager).toBe(PackageManager.PNPM); + expect(info.isGlobal).toBe(true); + expect(info.updateCommand).toBe('pnpm add -g @google/gemini-cli@latest'); + expect(info.updateMessage).toContain('Attempting to automatically update'); + + // isAutoUpdateEnabled = false -> "Please run..." + const infoDisabled = getInstallationInfo(projectRoot, false); + expect(infoDisabled.updateMessage).toContain('Please run pnpm add'); + }); + + it('should detect global yarn installation', () => { + const yarnPath = `/Users/test/.yarn/global/node_modules/@google/gemini-cli/dist/index.js`; + process.argv[1] = yarnPath; + mockedRealPathSync.mockReturnValue(yarnPath); + mockedExecSync.mockImplementation(() => { + throw new Error('Command failed'); + }); + + // isAutoUpdateEnabled = true -> "Attempting to automatically update" + const info = getInstallationInfo(projectRoot, true); + expect(info.packageManager).toBe(PackageManager.YARN); + expect(info.isGlobal).toBe(true); + expect(info.updateCommand).toBe( + 'yarn global add @google/gemini-cli@latest', + ); + expect(info.updateMessage).toContain('Attempting to automatically update'); + + // isAutoUpdateEnabled = false -> "Please run..." + const infoDisabled = getInstallationInfo(projectRoot, false); + expect(infoDisabled.updateMessage).toContain('Please run yarn global add'); + }); + + it('should detect global bun installation', () => { + const bunPath = `/Users/test/.bun/install/global/node_modules/@google/gemini-cli/dist/index.js`; + process.argv[1] = bunPath; + mockedRealPathSync.mockReturnValue(bunPath); + mockedExecSync.mockImplementation(() => { + throw new Error('Command failed'); + }); + + // isAutoUpdateEnabled = true -> "Attempting to automatically update" + const info = getInstallationInfo(projectRoot, true); + expect(info.packageManager).toBe(PackageManager.BUN); + expect(info.isGlobal).toBe(true); + expect(info.updateCommand).toBe('bun add -g @google/gemini-cli@latest'); + expect(info.updateMessage).toContain('Attempting to automatically update'); + + // isAutoUpdateEnabled = false -> "Please run..." + const infoDisabled = getInstallationInfo(projectRoot, false); + expect(infoDisabled.updateMessage).toContain('Please run bun add'); + }); + + it('should detect local installation and identify yarn from lockfile', () => { + const localPath = `${projectRoot}/node_modules/.bin/gemini`; + process.argv[1] = localPath; + mockedRealPathSync.mockReturnValue(localPath); + mockedExecSync.mockImplementation(() => { + throw new Error('Command failed'); + }); + mockedExistsSync.mockImplementation( + (p) => p === path.join(projectRoot, 'yarn.lock'), + ); + + const info = getInstallationInfo(projectRoot, true); + + expect(info.packageManager).toBe(PackageManager.YARN); + expect(info.isGlobal).toBe(false); + expect(info.updateMessage).toContain('Locally installed'); + }); + + it('should detect local installation and identify pnpm from lockfile', () => { + const localPath = `${projectRoot}/node_modules/.bin/gemini`; + process.argv[1] = localPath; + mockedRealPathSync.mockReturnValue(localPath); + mockedExecSync.mockImplementation(() => { + throw new Error('Command failed'); + }); + mockedExistsSync.mockImplementation( + (p) => p === path.join(projectRoot, 'pnpm-lock.yaml'), + ); + + const info = getInstallationInfo(projectRoot, true); + + expect(info.packageManager).toBe(PackageManager.PNPM); + expect(info.isGlobal).toBe(false); + }); + + it('should detect local installation and identify bun from lockfile', () => { + const localPath = `${projectRoot}/node_modules/.bin/gemini`; + process.argv[1] = localPath; + mockedRealPathSync.mockReturnValue(localPath); + mockedExecSync.mockImplementation(() => { + throw new Error('Command failed'); + }); + mockedExistsSync.mockImplementation( + (p) => p === path.join(projectRoot, 'bun.lockb'), + ); + + const info = getInstallationInfo(projectRoot, true); + + expect(info.packageManager).toBe(PackageManager.BUN); + expect(info.isGlobal).toBe(false); + }); + + it('should default to local npm installation if no lockfile is found', () => { + const localPath = `${projectRoot}/node_modules/.bin/gemini`; + process.argv[1] = localPath; + mockedRealPathSync.mockReturnValue(localPath); + mockedExecSync.mockImplementation(() => { + throw new Error('Command failed'); + }); + mockedExistsSync.mockReturnValue(false); // No lockfiles + + const info = getInstallationInfo(projectRoot, true); + + expect(info.packageManager).toBe(PackageManager.NPM); + expect(info.isGlobal).toBe(false); + }); + + it('should default to global npm installation for unrecognized paths', () => { + const globalPath = `/usr/local/bin/gemini`; + process.argv[1] = globalPath; + mockedRealPathSync.mockReturnValue(globalPath); + mockedExecSync.mockImplementation(() => { + throw new Error('Command failed'); + }); + + // isAutoUpdateEnabled = true -> "Attempting to automatically update" + const info = getInstallationInfo(projectRoot, true); + expect(info.packageManager).toBe(PackageManager.NPM); + expect(info.isGlobal).toBe(true); + expect(info.updateCommand).toBe('npm install -g @google/gemini-cli@latest'); + expect(info.updateMessage).toContain('Attempting to automatically update'); + + // isAutoUpdateEnabled = false -> "Please run..." + const infoDisabled = getInstallationInfo(projectRoot, false); + expect(infoDisabled.updateMessage).toContain('Please run npm install'); + }); + + it('should detect Volta installation (Unix-style)', () => { + const voltaPath = + '/Users/test/.volta/tools/image/node/20.0.0/lib/node_modules/@google/gemini-cli/dist/index.js'; + process.argv[1] = voltaPath; + mockedRealPathSync.mockReturnValue(voltaPath); + + const info = getInstallationInfo(projectRoot, true); + + expect(info.packageManager).toBe(PackageManager.VOLTA); + expect(info.updateCommand).toBe('volta install @google/gemini-cli@latest'); + }); + + it('should detect Volta installation (Windows-style)', () => { + const voltaPath = + 'C:\\Users\\test\\AppData\\Local\\Volta\\tools\\image\\node\\20.0.0\\node_modules\\@google/gemini-cli\\dist\\index.js'; + process.argv[1] = voltaPath; + mockedRealPathSync.mockReturnValue(voltaPath); + + const info = getInstallationInfo(projectRoot, true); + + expect(info.packageManager).toBe(PackageManager.VOLTA); + expect(info.updateCommand).toBe('volta install @google/gemini-cli@latest'); + }); + + it('should NOT detect Homebrew if gemini-cli is installed in brew but running from npm location', () => { + Object.defineProperty(process, 'platform', { + value: 'darwin', + }); + // Path looks like standard global NPM + const cliPath = + '/usr/local/lib/node_modules/@google/gemini-cli/dist/index.js'; + process.argv[1] = cliPath; + + // Setup mocks + mockedExecSync.mockImplementation((cmd) => { + if (typeof cmd === 'string' && cmd.includes('brew list')) { + return Buffer.from('gemini-cli\n'); + } + // Future proofing for the fix: + if (typeof cmd === 'string' && cmd.includes('brew --prefix gemini-cli')) { + return '/opt/homebrew/opt/gemini-cli'; + } + throw new Error(`Command failed: ${cmd}`); + }); + + mockedRealPathSync.mockImplementation((p) => { + if (p === cliPath) return cliPath; + // Future proofing for the fix: + if (p === '/opt/homebrew/opt/gemini-cli') + return '/opt/homebrew/Cellar/gemini-cli/1.0.0'; + return String(p); + }); + + const info = getInstallationInfo(projectRoot, false); + + expect(info.packageManager).not.toBe(PackageManager.HOMEBREW); + expect(info.packageManager).toBe(PackageManager.NPM); + }); +}); diff --git a/packages/cli/src/utils/jsonoutput.test.ts b/packages/cli/src/utils/jsonoutput.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..c80f1097ff15130aa5a475f8d8559c0a078cce1b --- /dev/null +++ b/packages/cli/src/utils/jsonoutput.test.ts @@ -0,0 +1,100 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { checkInput, tryParseJSON } from './jsonoutput.js'; + +describe('check tools output', () => { + it('accepts object-like JSON strings', () => { + const testJSON = '{"a":1, "b": 2}'; + expect(checkInput(testJSON)).toBeTruthy(); + }); + + it('accepts array JSON strings', () => { + expect(checkInput('[1,2,3]')).toBeTruthy(); + }); + + it('rejects primitive strings/plaintext strings', () => { + expect(checkInput('test text')).toBeFalsy(); + }); + + it('rejects empty strings', () => { + expect(checkInput('')).toBeFalsy(); + }); + + it('rejects null and undefined', () => { + expect(checkInput(null)).toBeFalsy(); + expect(checkInput(undefined)).toBeFalsy(); + }); + + it('rejects malformed JSON-like strings', () => { + const malformedJSON = '"a":1,}'; + + expect(checkInput(malformedJSON)).toBeFalsy(); + }); + + it('rejects mixed text and JSON text strings', () => { + const testJSON = 'text {"a":1, "b": 2}'; + expect(checkInput(testJSON)).toBeFalsy(); + }); + + it('rejects ANSI-tainted input', () => { + const text = '\u001B[32m{"a":1}\u001B[0m'; + + expect(checkInput(text)).toBeFalsy(); + }); +}); + +describe('check parsing json', () => { + it('returns parsed object for valid JSON', () => { + const testJSON = '{"a":1, "b": 2}'; + const parsedTestJSON = JSON.parse(testJSON); + + const output = tryParseJSON(testJSON); + + expect(output).toEqual(parsedTestJSON); + }); + + it('returns parsed array for non-empty arrays', () => { + const testJSON = '[1,2,3]'; + const parsedTestJSON = JSON.parse(testJSON); + + const output = tryParseJSON(testJSON); + + expect(output).toEqual(parsedTestJSON); + }); + + it('returns null for Malformed JSON', () => { + const text = '{"a":1,}'; + + expect(tryParseJSON(text)).toBeFalsy(); + }); + + it('returns null for empty arrays', () => { + const testArr = '[]'; + + expect(tryParseJSON(testArr)).toBeFalsy(); + }); + + it('returns null for empty objects', () => { + const testObj = '{}'; + + expect(tryParseJSON(testObj)).toBeFalsy(); + }); + + it('trims whitespace and parse valid json', () => { + const text = '\n { "a": 1 } \n'; + expect(tryParseJSON(text)).toBeTruthy(); + }); + + it('returns null for plaintext', () => { + const testText = 'test plaintext'; + + const output = tryParseJSON(testText); + + expect(output).toBeFalsy(); + }); +}); diff --git a/packages/cli/src/utils/jsonoutput.ts b/packages/cli/src/utils/jsonoutput.ts new file mode 100644 index 0000000000000000000000000000000000000000..46fa0479dae1c9f56881097e738605107e29eded --- /dev/null +++ b/packages/cli/src/utils/jsonoutput.ts @@ -0,0 +1,46 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import stripAnsi from 'strip-ansi'; + +export function checkInput(input: string | null | undefined): boolean { + if (input === null || input === undefined) { + return false; + } + + const trimmed = input.trim(); + if (!trimmed) { + return false; + } + + if (!/^(?:\[|\{)/.test(trimmed)) { + return false; + } + + if (stripAnsi(trimmed) !== trimmed) return false; + + return true; +} + +export function tryParseJSON(input: string): object | null { + if (!checkInput(input)) return null; + const trimmed = input.trim(); + try { + const parsed: unknown = JSON.parse(trimmed); + if (parsed === null || typeof parsed !== 'object') { + return null; + } + if (Array.isArray(parsed) && parsed.length === 0) { + return null; + } + + if (!Array.isArray(parsed) && Object.keys(parsed).length === 0) return null; + + return parsed; + } catch { + return null; + } +} diff --git a/packages/cli/src/utils/logCleanup.test.ts b/packages/cli/src/utils/logCleanup.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d3318377011c0de7c750a82016aeec79f1fe7984 --- /dev/null +++ b/packages/cli/src/utils/logCleanup.test.ts @@ -0,0 +1,116 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { + promises as fs, + type PathLike, + type Dirent, + type Stats, +} from 'node:fs'; +import * as path from 'node:path'; +import { cleanupBackgroundLogs } from './logCleanup.js'; + +vi.mock('@google/gemini-cli-core', () => ({ + ShellExecutionService: { + getLogDir: vi.fn().mockReturnValue('/tmp/gemini/tmp/background-processes'), + }, + debugLogger: { + debug: vi.fn(), + warn: vi.fn(), + }, +})); + +vi.mock('node:fs', () => ({ + promises: { + access: vi.fn(), + readdir: vi.fn(), + stat: vi.fn(), + unlink: vi.fn(), + }, +})); + +describe('logCleanup', () => { + const logDir = '/tmp/gemini/tmp/background-processes'; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should skip cleanup if the directory does not exist', async () => { + vi.mocked(fs.access).mockRejectedValue(new Error('ENOENT')); + + await cleanupBackgroundLogs(); + + expect(fs.access).toHaveBeenCalledWith(logDir); + expect(fs.readdir).not.toHaveBeenCalled(); + }); + + it('should skip cleanup if the directory is empty', async () => { + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readdir).mockResolvedValue([]); + + await cleanupBackgroundLogs(); + + expect(fs.readdir).toHaveBeenCalledWith(logDir, { withFileTypes: true }); + expect(fs.unlink).not.toHaveBeenCalled(); + }); + + it('should delete log files older than 7 days', async () => { + const now = Date.now(); + const oldTime = now - 8 * 24 * 60 * 60 * 1000; // 8 days ago + const newTime = now - 1 * 24 * 60 * 60 * 1000; // 1 day ago + + const entries = [ + { name: 'old.log', isFile: () => true }, + { name: 'new.log', isFile: () => true }, + { name: 'not-a-log.txt', isFile: () => true }, + { name: 'some-dir', isFile: () => false }, + ] as Dirent[]; + + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked( + fs.readdir as ( + path: PathLike, + options: { withFileTypes: true }, + ) => Promise, + ).mockResolvedValue(entries); + vi.mocked(fs.stat).mockImplementation((filePath: PathLike) => { + const pathStr = filePath.toString(); + if (pathStr.endsWith('old.log')) { + return Promise.resolve({ mtime: new Date(oldTime) } as Stats); + } + if (pathStr.endsWith('new.log')) { + return Promise.resolve({ mtime: new Date(newTime) } as Stats); + } + return Promise.resolve({ mtime: new Date(now) } as Stats); + }); + vi.mocked(fs.unlink).mockResolvedValue(undefined); + + await cleanupBackgroundLogs(); + + expect(fs.unlink).toHaveBeenCalledTimes(1); + expect(fs.unlink).toHaveBeenCalledWith(path.join(logDir, 'old.log')); + expect(fs.unlink).not.toHaveBeenCalledWith(path.join(logDir, 'new.log')); + }); + + it('should handle errors during file deletion gracefully', async () => { + const now = Date.now(); + const oldTime = now - 8 * 24 * 60 * 60 * 1000; + + const entries = [{ name: 'old.log', isFile: () => true }]; + + vi.mocked(fs.access).mockResolvedValue(undefined); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.mocked(fs.readdir).mockResolvedValue(entries as any); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.mocked(fs.stat).mockResolvedValue({ mtime: new Date(oldTime) } as any); + vi.mocked(fs.unlink).mockRejectedValue(new Error('Permission denied')); + + await expect(cleanupBackgroundLogs()).resolves.not.toThrow(); + expect(fs.unlink).toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/utils/logCleanup.ts b/packages/cli/src/utils/logCleanup.ts new file mode 100644 index 0000000000000000000000000000000000000000..14d1e13f45eceba5726fc141c30e729de9e3aab4 --- /dev/null +++ b/packages/cli/src/utils/logCleanup.ts @@ -0,0 +1,66 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { promises as fs } from 'node:fs'; +import * as path from 'node:path'; +import { ShellExecutionService, debugLogger } from '@google/gemini-cli-core'; + +const RETENTION_PERIOD_MS = 7 * 24 * 60 * 60 * 1000; // 7 days + +/** + * Cleans up background process log files older than 7 days. + * Scans ~/.gemini/tmp/background-processes/ for .log files. + * + * @param debugMode Whether to log detailed debug information. + */ +export async function cleanupBackgroundLogs( + debugMode: boolean = false, +): Promise { + try { + const logDir = ShellExecutionService.getLogDir(); + + // Check if the directory exists + try { + await fs.access(logDir); + } catch { + // Directory doesn't exist, nothing to clean up + return; + } + + const entries = await fs.readdir(logDir, { withFileTypes: true }); + const now = Date.now(); + let deletedCount = 0; + + for (const entry of entries) { + if (entry.isFile() && entry.name.endsWith('.log')) { + const filePath = path.join(logDir, entry.name); + try { + const stats = await fs.stat(filePath); + if (now - stats.mtime.getTime() > RETENTION_PERIOD_MS) { + await fs.unlink(filePath); + deletedCount++; + } + } catch (error) { + if (debugMode) { + debugLogger.debug( + `Failed to process log file ${entry.name}:`, + error, + ); + } + } + } + } + + if (deletedCount > 0 && debugMode) { + debugLogger.debug(`Cleaned up ${deletedCount} expired background logs.`); + } + } catch (error) { + // Best-effort cleanup, don't let it crash the CLI + if (debugMode) { + debugLogger.warn('Background log cleanup failed:', error); + } + } +} diff --git a/packages/cli/src/utils/math.test.ts b/packages/cli/src/utils/math.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..b94b2cf96e6ca731a41fc4b8ebfaa1283a4b9552 --- /dev/null +++ b/packages/cli/src/utils/math.test.ts @@ -0,0 +1,24 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { lerp } from './math.js'; + +describe('math', () => { + describe('lerp', () => { + it.each([ + [0, 10, 0, 0], + [0, 10, 1, 10], + [0, 10, 0.5, 5], + [10, 20, 0.5, 15], + [-10, 10, 0.5, 0], + [0, 10, 2, 20], + [0, 10, -1, -10], + ])('lerp(%d, %d, %d) should return %d', (start, end, t, expected) => { + expect(lerp(start, end, t)).toBe(expected); + }); + }); +}); diff --git a/packages/cli/src/utils/processUtils.test.ts b/packages/cli/src/utils/processUtils.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..29b0eef66c202c63680fbc10ea2ea0aff438ba0c --- /dev/null +++ b/packages/cli/src/utils/processUtils.test.ts @@ -0,0 +1,196 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi } from 'vitest'; +import { + RELAUNCH_EXIT_CODE, + relaunchApp, + _resetRelaunchStateForTesting, + isStandardSea, + getScriptArgs, + isSeaEnvironment, + getSpawnConfig, + type ProcessWithSea, +} from './processUtils.js'; +import * as cleanup from './cleanup.js'; +import * as handleAutoUpdate from './handleAutoUpdate.js'; + +vi.mock('./handleAutoUpdate.js', () => ({ + waitForUpdateCompletion: vi.fn().mockResolvedValue(undefined), +})); + +describe('processUtils', () => { + const processExit = vi + .spyOn(process, 'exit') + .mockReturnValue(undefined as never); + const runExitCleanup = vi.spyOn(cleanup, 'runExitCleanup'); + + beforeEach(() => { + _resetRelaunchStateForTesting(); + }); + + afterEach(() => vi.clearAllMocks()); + + it('should wait for updates, run cleanup, and exit with the relaunch code', async () => { + await relaunchApp(); + expect(handleAutoUpdate.waitForUpdateCompletion).toHaveBeenCalledTimes(1); + expect(runExitCleanup).toHaveBeenCalledTimes(1); + expect(processExit).toHaveBeenCalledWith(RELAUNCH_EXIT_CODE); + }); +}); + +describe('SEA handling utilities', () => { + const originalArgv = process.argv; + const originalExecArgv = process.execArgv; + const originalExecPath = process.execPath; + const originalIsSea = (process as ProcessWithSea).isSea; + + beforeEach(() => { + vi.unstubAllEnvs(); + vi.stubEnv('NODE_OPTIONS', ''); + process.argv = [...originalArgv]; + process.execArgv = [...originalExecArgv]; + process.execPath = '/fake/exec/path'; + delete (process as ProcessWithSea).isSea; + }); + + afterEach(() => { + vi.unstubAllEnvs(); + process.argv = originalArgv; + process.execArgv = originalExecArgv; + process.execPath = originalExecPath; + if (originalIsSea) { + (process as ProcessWithSea).isSea = originalIsSea; + } else { + delete (process as ProcessWithSea).isSea; + } + }); + + describe('isStandardSea', () => { + it('returns false if argv[0] === argv[1]', () => { + process.argv = ['/bin/gemini', '/bin/gemini', 'my-command']; + vi.stubEnv('IS_BINARY', 'true'); + expect(isStandardSea()).toBe(false); + }); + + it('returns true if IS_BINARY is true and argv[0] !== argv[1]', () => { + process.argv = ['/bin/gemini', 'my-command']; + vi.stubEnv('IS_BINARY', 'true'); + expect(isStandardSea()).toBe(true); + }); + + it('returns true if process.isSea() is true and argv[0] !== argv[1]', () => { + process.argv = ['/bin/gemini', 'my-command']; + (process as ProcessWithSea).isSea = () => true; + expect(isStandardSea()).toBe(true); + }); + + it('returns false in standard node environment', () => { + process.argv = ['/bin/node', '/path/to/script.js', 'my-command']; + expect(isStandardSea()).toBe(false); + }); + }); + + describe('getScriptArgs', () => { + it('slices from index 1 if isStandardSea is true', () => { + process.argv = ['/bin/gemini', 'my-command', '--flag']; + vi.stubEnv('IS_BINARY', 'true'); + expect(getScriptArgs()).toEqual(['my-command', '--flag']); + }); + + it('slices from index 2 if isStandardSea is false (relaunch SEA or standard node)', () => { + // Relaunch SEA + process.argv = ['/bin/gemini', '/bin/gemini', 'my-command', '--flag']; + vi.stubEnv('IS_BINARY', 'true'); + expect(getScriptArgs()).toEqual(['my-command', '--flag']); + + // Standard node + process.argv = ['/bin/node', '/path/to/script.js', 'my-command']; + vi.stubEnv('IS_BINARY', ''); + expect(getScriptArgs()).toEqual(['my-command']); + }); + }); + + describe('isSeaEnvironment', () => { + it('returns true if IS_BINARY is true', () => { + vi.stubEnv('IS_BINARY', 'true'); + expect(isSeaEnvironment()).toBe(true); + }); + + it('returns true if process.isSea() is true', () => { + (process as ProcessWithSea).isSea = () => true; + expect(isSeaEnvironment()).toBe(true); + }); + + it('returns true if argv[0] === argv[1]', () => { + process.argv = ['/bin/gemini', '/bin/gemini']; + expect(isSeaEnvironment()).toBe(true); + }); + + it('returns false otherwise', () => { + process.argv = ['/bin/node', '/path/to/script.js']; + expect(isSeaEnvironment()).toBe(false); + }); + }); + + describe('getSpawnConfig', () => { + it('handles standard node mode', () => { + process.argv = ['/bin/node', '/path/to/script.js', 'my-command']; + process.execArgv = ['--inspect']; + process.execPath = '/bin/node'; + + const config = getSpawnConfig( + ['--max-old-space-size=8192'], + ['my-command'], + ); + + expect(config.spawnArgs).toEqual([ + '--inspect', + '--max-old-space-size=8192', + '/path/to/script.js', + 'my-command', + ]); + expect(config.env['GEMINI_CLI_NO_RELAUNCH']).toBe('true'); + expect(config.env['NODE_OPTIONS']).toBeFalsy(); + }); + + it('handles SEA binary mode with new nodeArgs', () => { + vi.stubEnv('IS_BINARY', 'true'); + vi.stubEnv('NODE_OPTIONS', '--existing-flag'); + process.argv = ['/bin/gemini', 'my-command']; + process.execArgv = ['--inspect']; // Should not be duplicated in NODE_OPTIONS + process.execPath = '/bin/gemini'; + + const config = getSpawnConfig( + ['--max-old-space-size=8192'], + ['my-command'], + ); + + expect(config.spawnArgs).toEqual([ + '/bin/gemini', // explicitly uses execPath as placeholder + 'my-command', + ]); + expect(config.env['NODE_OPTIONS']).toBe( + '--existing-flag --max-old-space-size=8192', + ); + expect(config.env['GEMINI_CLI_NO_RELAUNCH']).toBe('true'); + }); + + it('throws error for complex nodeArgs in SEA mode', () => { + vi.stubEnv('IS_BINARY', 'true'); + + expect(() => { + getSpawnConfig(['--title "My App"'], []); + }).toThrow( + 'Unsupported node argument for SEA relaunch: --title "My App". Complex escaping is not supported.', + ); + + expect(() => { + getSpawnConfig(['--title=A\\B'], []); + }).toThrow(); + }); + }); +}); diff --git a/packages/cli/src/utils/processUtils.ts b/packages/cli/src/utils/processUtils.ts new file mode 100644 index 0000000000000000000000000000000000000000..5848056da748db9ce15423c2b997f3cb0994beab --- /dev/null +++ b/packages/cli/src/utils/processUtils.ts @@ -0,0 +1,129 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { runExitCleanup } from './cleanup.js'; +import { waitForUpdateCompletion } from './handleAutoUpdate.js'; + +/** + * Exit code used to signal that the CLI should be relaunched. + */ +export const RELAUNCH_EXIT_CODE = 199; + +/** + * Exits the process with a special code to signal that the parent process should relaunch it. + */ +let isRelaunching = false; + +/** @internal only for testing */ +export function _resetRelaunchStateForTesting(): void { + isRelaunching = false; +} + +export async function relaunchApp(): Promise { + if (isRelaunching) return; + isRelaunching = true; + await waitForUpdateCompletion(); + await runExitCleanup(); + process.exit(RELAUNCH_EXIT_CODE); +} + +export interface ProcessWithSea extends NodeJS.Process { + isSea?: () => boolean; +} + +/** + * Determines whether the current process is a "standard" SEA (Single Executable Application) + * where the user arguments start at index 1 instead of index 2. + * A relaunched SEA child will have process.argv[0] === process.argv[1] (because we inject execPath), + * so it will return false here and correctly slice from index 2. + */ +export function isStandardSea(): boolean { + return ( + process.argv[0] !== process.argv[1] && + (process.env['IS_BINARY'] === 'true' || + (process as ProcessWithSea).isSea?.() === true) + ); +} + +/** + * Extracts the user-provided script arguments from process.argv, + * accounting for the differences in SEA execution modes. + */ +export function getScriptArgs(): string[] { + return process.argv.slice(isStandardSea() ? 1 : 2); +} + +/** + * Determines if the current process is running in any SEA environment + * (either the initial launch or a relaunched child). + */ +export function isSeaEnvironment(): boolean { + return ( + process.env['IS_BINARY'] === 'true' || + (process as ProcessWithSea).isSea?.() === true || + process.argv[0] === process.argv[1] + ); +} + +/** + * Constructs the arguments and environment for spawning a child process during relaunch. + * Handles differences between standard Node and SEA binary modes. + */ +export function getSpawnConfig( + nodeArgs: string[], + scriptArgs: string[], +): { + spawnArgs: string[]; + env: NodeJS.ProcessEnv; +} { + const isBinary = isSeaEnvironment(); + const newEnv: NodeJS.ProcessEnv = { + ...process.env, + GEMINI_CLI_NO_RELAUNCH: 'true', + }; + + const finalSpawnArgs: string[] = []; + + if (isBinary) { + // In SEA mode, Node flags must be passed via NODE_OPTIONS, as the binary + // passes all CLI arguments directly to the application. + // We only need to append the *new* nodeArgs (e.g., memory flags). + // Existing execArgv are inherited via the environment or baked into the binary. + if (nodeArgs.length > 0) { + for (const arg of nodeArgs) { + if (/[\s"'\\]/.test(arg)) { + throw new Error( + `Unsupported node argument for SEA relaunch: ${arg}. Complex escaping is not supported.`, + ); + } + } + const existingNodeOptions = process.env['NODE_OPTIONS'] || ''; + // nodeArgs in our codebase are simple flags like --max-old-space-size=X + // that do not contain spaces and do not require complex escaping. + newEnv['NODE_OPTIONS'] = + `${existingNodeOptions} ${nodeArgs.join(' ')}`.trim(); + } + // Binary is its own entry point. To maintain the [node, script, ...args] + // structure expected by the application (which uses slice(2)), + // we must provide a placeholder for the script path. + // We explicitly use process.execPath to break the cycle and prevent + // compounding argument duplication on subsequent relaunches. + finalSpawnArgs.push(process.execPath, ...scriptArgs); + } else { + // Standard Node mode: pass all flags via command line. + finalSpawnArgs.push( + ...process.execArgv, + ...nodeArgs, + process.argv[1], + ...scriptArgs, + ); + } + + return { + spawnArgs: finalSpawnArgs, + env: newEnv, + }; +} diff --git a/packages/cli/src/utils/readStdin.ts b/packages/cli/src/utils/readStdin.ts new file mode 100644 index 0000000000000000000000000000000000000000..f828bca88c2334b54261d5ce5b0da9901c442e82 --- /dev/null +++ b/packages/cli/src/utils/readStdin.ts @@ -0,0 +1,99 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { debugLogger } from '@google/gemini-cli-core'; + +/** + * Truncates a string to fit within a UTF-8 byte limit without splitting + * multi-byte characters. Walks back from the cut point to find the last + * complete character boundary. + */ +function truncateUtf8Bytes(str: string, maxBytes: number): string { + const buf = Buffer.from(str, 'utf8'); + if (buf.length <= maxBytes) return str; + let end = maxBytes; + // Walk backward past any UTF-8 continuation bytes (10xxxxxx) + while (end > 0 && (buf[end] & 0xc0) === 0x80) { + end--; + } + // end now points to the lead byte of an incomplete sequence — exclude it + return buf.subarray(0, end).toString('utf8'); +} + +export async function readStdin(): Promise { + const MAX_STDIN_SIZE = 8 * 1024 * 1024; // 8MB + return new Promise((resolve, reject) => { + let data = ''; + let totalSize = 0; + process.stdin.setEncoding('utf8'); + + const pipedInputShouldBeAvailableInMs = 500; + let pipedInputTimerId: null | NodeJS.Timeout = setTimeout(() => { + // stop reading if input is not available yet, this is needed + // in terminals where stdin is never TTY and nothing's piped + // which causes the program to get stuck expecting data from stdin + onEnd(); + }, pipedInputShouldBeAvailableInMs); + + const onReadable = () => { + let chunk; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + while ((chunk = process.stdin.read()) !== null) { + if (pipedInputTimerId) { + clearTimeout(pipedInputTimerId); + pipedInputTimerId = null; + } + + const chunkByteLength = Buffer.byteLength(chunk, 'utf8'); + if (totalSize + chunkByteLength > MAX_STDIN_SIZE) { + const remainingBytes = MAX_STDIN_SIZE - totalSize; + data += truncateUtf8Bytes(chunk, remainingBytes); + debugLogger.warn( + `Warning: stdin input truncated to ${MAX_STDIN_SIZE} bytes.`, + ); + process.stdin.destroy(); // Stop reading further + onEnd(); + break; + } + data += chunk; + totalSize += chunkByteLength; + } + }; + + const onEnd = () => { + cleanup(); + resolve(data); + }; + + const onError = (err: Error) => { + cleanup(); + reject(err); + }; + + const cleanup = () => { + if (pipedInputTimerId) { + clearTimeout(pipedInputTimerId); + pipedInputTimerId = null; + } + process.stdin.removeListener('readable', onReadable); + process.stdin.removeListener('end', onEnd); + process.stdin.removeListener('error', onError); + + // Add a no-op error listener if no other error listeners are present to prevent + // unhandled 'error' events (like EIO) from crashing the process after we stop reading. + // This is especially important for background execution where TTY might cause EIO. + if (process.stdin.listenerCount('error') === 0) { + process.stdin.on('error', noopErrorHandler); + } + }; + + process.stdin.on('readable', onReadable); + process.stdin.on('end', onEnd); + process.stdin.on('error', onError); + }); +} + +function noopErrorHandler() {} diff --git a/packages/cli/src/utils/relaunch.test.ts b/packages/cli/src/utils/relaunch.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..3a6de661229dd141aea357378bcec31245e80a91 --- /dev/null +++ b/packages/cli/src/utils/relaunch.test.ts @@ -0,0 +1,387 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + vi, + describe, + it, + expect, + beforeEach, + afterEach, + type MockInstance, +} from 'vitest'; +import { EventEmitter } from 'node:events'; +import { RELAUNCH_EXIT_CODE } from './processUtils.js'; +import { spawn, type ChildProcess } from 'node:child_process'; + +const mocks = vi.hoisted(() => ({ + writeToStderr: vi.fn(), +})); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + writeToStderr: mocks.writeToStderr, + }; +}); + +vi.mock('node:child_process', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + spawn: vi.fn(), + }; +}); + +const mockedSpawn = vi.mocked(spawn); + +// Import the functions initially +import { relaunchAppInChildProcess, relaunchOnExitCode } from './relaunch.js'; + +describe('relaunchOnExitCode', () => { + let processExitSpy: MockInstance; + let stdinResumeSpy: MockInstance; + const originalPlatform = process.platform; + + const setPlatform = (platform: NodeJS.Platform) => { + Object.defineProperty(process, 'platform', { + value: platform, + configurable: true, + }); + }; + + beforeEach(() => { + processExitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('PROCESS_EXIT_CALLED'); + }); + stdinResumeSpy = vi + .spyOn(process.stdin, 'resume') + .mockImplementation(() => process.stdin); + vi.clearAllMocks(); + mocks.writeToStderr.mockClear(); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + setPlatform(originalPlatform); + processExitSpy.mockRestore(); + stdinResumeSpy.mockRestore(); + }); + + it('should exit with non-RELAUNCH_EXIT_CODE', async () => { + const runner = vi.fn().mockResolvedValue(0); + + await expect(relaunchOnExitCode(runner)).rejects.toThrow( + 'PROCESS_EXIT_CALLED', + ); + + expect(runner).toHaveBeenCalledTimes(1); + expect(processExitSpy).toHaveBeenCalledWith(0); + }); + + it('should continue running when RELAUNCH_EXIT_CODE is returned', async () => { + let callCount = 0; + const runner = vi.fn().mockImplementation(async () => { + callCount++; + if (callCount === 1) return RELAUNCH_EXIT_CODE; + if (callCount === 2) return RELAUNCH_EXIT_CODE; + return 0; // Exit on third call + }); + + await expect(relaunchOnExitCode(runner)).rejects.toThrow( + 'PROCESS_EXIT_CALLED', + ); + + expect(runner).toHaveBeenCalledTimes(3); + expect(processExitSpy).toHaveBeenCalledWith(0); + }); + + it('should not relaunch on Android when RELAUNCH_EXIT_CODE is returned', async () => { + setPlatform('android'); + const runner = vi.fn().mockResolvedValue(RELAUNCH_EXIT_CODE); + + await expect(relaunchOnExitCode(runner)).rejects.toThrow( + 'PROCESS_EXIT_CALLED', + ); + + expect(runner).toHaveBeenCalledTimes(1); + expect(processExitSpy).toHaveBeenCalledWith(RELAUNCH_EXIT_CODE); + }); + + it('should handle runner errors', async () => { + const error = new Error('Runner failed'); + const runner = vi.fn().mockRejectedValue(error); + + await expect(relaunchOnExitCode(runner)).rejects.toThrow( + 'PROCESS_EXIT_CALLED', + ); + + expect(runner).toHaveBeenCalledTimes(1); + expect(mocks.writeToStderr).toHaveBeenCalledWith( + expect.stringContaining( + 'Fatal error: Failed to relaunch the CLI process.', + ), + ); + expect(stdinResumeSpy).toHaveBeenCalled(); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); +}); + +describe('relaunchAppInChildProcess', () => { + let processExitSpy: MockInstance; + let stdinPauseSpy: MockInstance; + let stdinResumeSpy: MockInstance; + + // Store original values to restore later + const originalExecArgv = [...process.execArgv]; + const originalArgv = [...process.argv]; + const originalExecPath = process.execPath; + + beforeEach(() => { + vi.clearAllMocks(); + mocks.writeToStderr.mockClear(); + + vi.stubEnv('GEMINI_CLI_NO_RELAUNCH', ''); + vi.stubEnv('IS_BINARY', ''); + vi.stubEnv('NODE_OPTIONS', ''); + + process.execArgv = [...originalExecArgv]; + process.argv = [...originalArgv]; + process.execPath = '/usr/bin/node'; + + processExitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('PROCESS_EXIT_CALLED'); + }); + stdinPauseSpy = vi + .spyOn(process.stdin, 'pause') + .mockImplementation(() => process.stdin); + stdinResumeSpy = vi + .spyOn(process.stdin, 'resume') + .mockImplementation(() => process.stdin); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + process.execArgv = [...originalExecArgv]; + process.argv = [...originalArgv]; + process.execPath = originalExecPath; + + processExitSpy.mockRestore(); + stdinPauseSpy.mockRestore(); + stdinResumeSpy.mockRestore(); + }); + + describe('when GEMINI_CLI_NO_RELAUNCH is set', () => { + it('should return early without spawning a child process', async () => { + vi.stubEnv('GEMINI_CLI_NO_RELAUNCH', 'true'); + + await relaunchAppInChildProcess(['--test'], ['--verbose']); + + expect(mockedSpawn).not.toHaveBeenCalled(); + expect(processExitSpy).not.toHaveBeenCalled(); + }); + }); + + describe('when GEMINI_CLI_NO_RELAUNCH is not set', () => { + beforeEach(() => { + vi.stubEnv('GEMINI_CLI_NO_RELAUNCH', ''); + }); + + it('should construct correct spawn arguments and use command line for node arguments in standard Node mode', async () => { + process.execArgv = ['--inspect=9229', '--trace-warnings']; + process.argv = [ + '/usr/bin/node', + '/path/to/cli.js', + 'command', + '--flag=value', + '--verbose', + ]; + + const additionalNodeArgs = [ + '--max-old-space-size=4096', + '--experimental-modules', + ]; + const additionalScriptArgs = ['--model', 'gemini-1.5-pro', '--debug']; + + const mockChild = createMockChildProcess(0, true); + mockedSpawn.mockReturnValue(mockChild); + + await expect( + relaunchAppInChildProcess(additionalNodeArgs, additionalScriptArgs), + ).rejects.toThrow('PROCESS_EXIT_CALLED'); + + expect(mockedSpawn).toHaveBeenCalledWith( + process.execPath, + [ + '--inspect=9229', + '--trace-warnings', + '--max-old-space-size=4096', + '--experimental-modules', + '/path/to/cli.js', + '--model', + 'gemini-1.5-pro', + '--debug', + 'command', + '--flag=value', + '--verbose', + ], + expect.objectContaining({ + env: expect.objectContaining({ + GEMINI_CLI_NO_RELAUNCH: 'true', + }), + }), + ); + + const lastCall = mockedSpawn.mock.calls[0] as unknown as [ + string, + string[], + { env: NodeJS.ProcessEnv }, + ]; + const env = lastCall[2].env; + expect(env['NODE_OPTIONS']).toBeFalsy(); + }); + + it('should handle SEA binary mode (IS_BINARY=true) correctly using NODE_OPTIONS', async () => { + vi.stubEnv('IS_BINARY', 'true'); + // execArgv should be inherited, not duplicated in NODE_OPTIONS + process.execArgv = ['--inspect=9229']; + process.argv = [ + '/usr/bin/gemini', + '/usr/bin/gemini', + 'command', + '--verbose', + ]; + + const additionalNodeArgs = ['--max-old-space-size=8192']; + const additionalScriptArgs: string[] = []; + + const mockChild = createMockChildProcess(0, true); + mockedSpawn.mockReturnValue(mockChild); + + await expect( + relaunchAppInChildProcess(additionalNodeArgs, additionalScriptArgs), + ).rejects.toThrow('PROCESS_EXIT_CALLED'); + + expect(mockedSpawn).toHaveBeenCalledWith( + process.execPath, + ['/usr/bin/node', 'command', '--verbose'], + expect.objectContaining({ + env: expect.objectContaining({ + GEMINI_CLI_NO_RELAUNCH: 'true', + NODE_OPTIONS: '--max-old-space-size=8192', + }), + }), + ); + }); + + it('should append new nodeArgs to NODE_OPTIONS in SEA mode without escaping', async () => { + vi.stubEnv('IS_BINARY', 'true'); + vi.stubEnv('NODE_OPTIONS', '--existing-flag'); + process.execArgv = ['--inspect']; // inherited from env/binary, should not be duplicated + process.argv = ['/usr/bin/gemini', '/usr/bin/gemini', 'command']; + + // In our use case, these are simple flags like --max-old-space-size=X + const additionalNodeArgs = ['--max-old-space-size=8192']; + const additionalScriptArgs: string[] = []; + + const mockChild = createMockChildProcess(0, true); + mockedSpawn.mockReturnValue(mockChild); + + await expect( + relaunchAppInChildProcess(additionalNodeArgs, additionalScriptArgs), + ).rejects.toThrow('PROCESS_EXIT_CALLED'); + + expect(mockedSpawn).toHaveBeenCalledWith( + process.execPath, + ['/usr/bin/node', 'command'], + expect.objectContaining({ + env: expect.objectContaining({ + NODE_OPTIONS: '--existing-flag --max-old-space-size=8192', + }), + }), + ); + }); + + it('should handle empty additional arguments correctly in Node mode', async () => { + process.execArgv = ['--trace-warnings']; + process.argv = ['/usr/bin/node', '/app/cli.js', 'start']; + + const mockChild = createMockChildProcess(0, true); + mockedSpawn.mockReturnValue(mockChild); + + await expect(relaunchAppInChildProcess([], [])).rejects.toThrow( + 'PROCESS_EXIT_CALLED', + ); + + expect(mockedSpawn).toHaveBeenCalledWith( + process.execPath, + ['--trace-warnings', '/app/cli.js', 'start'], + expect.anything(), + ); + }); + + it('should handle null exit code from child process', async () => { + process.argv = ['/usr/bin/node', '/app/cli.js']; + + const mockChild = createMockChildProcess(0, false); // Don't auto-close + mockedSpawn.mockImplementation(() => { + // Emit close with null code immediately + setImmediate(() => { + mockChild.emit('close', null); + }); + return mockChild; + }); + + // Start the relaunch process + const promise = relaunchAppInChildProcess([], []); + + await expect(promise).rejects.toThrow('PROCESS_EXIT_CALLED'); + + // Should default to exit code 1 + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + }); +}); + +/** + * Creates a mock child process that emits events asynchronously + */ +function createMockChildProcess( + exitCode: number = 0, + autoClose: boolean = false, +): ChildProcess { + const mockChild = new EventEmitter() as ChildProcess; + + Object.assign(mockChild, { + stdin: null, + stdout: null, + stderr: null, + stdio: [null, null, null], + pid: 12345, + killed: false, + exitCode: null, + signalCode: null, + spawnargs: [], + spawnfile: '', + kill: vi.fn(), + send: vi.fn(), + disconnect: vi.fn(), + unref: vi.fn(), + ref: vi.fn(), + on: mockChild.on.bind(mockChild), + emit: mockChild.emit.bind(mockChild), + }); + + if (autoClose) { + setImmediate(() => { + mockChild.emit('close', exitCode); + }); + } + + return mockChild; +} diff --git a/packages/cli/src/utils/relaunch.ts b/packages/cli/src/utils/relaunch.ts new file mode 100644 index 0000000000000000000000000000000000000000..752918539bc2342b38037445ea13d7173fdba43c --- /dev/null +++ b/packages/cli/src/utils/relaunch.ts @@ -0,0 +1,85 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { spawn } from 'node:child_process'; +import { + RELAUNCH_EXIT_CODE, + getSpawnConfig, + getScriptArgs, +} from './processUtils.js'; +import { + writeToStderr, + type AdminControlsSettings, +} from '@google/gemini-cli-core'; + +export async function relaunchOnExitCode(runner: () => Promise) { + while (true) { + try { + const exitCode = await runner(); + + if (process.platform === 'android' || exitCode !== RELAUNCH_EXIT_CODE) { + process.exit(exitCode); + } + } catch (error) { + process.stdin.resume(); + const errorMessage = + error instanceof Error ? (error.stack ?? error.message) : String(error); + writeToStderr( + `Fatal error: Failed to relaunch the CLI process.\n${errorMessage}\n`, + ); + process.exit(1); + } + } +} + +export async function relaunchAppInChildProcess( + additionalNodeArgs: string[], + additionalScriptArgs: string[], + remoteAdminSettings?: AdminControlsSettings, +) { + if (process.env['GEMINI_CLI_NO_RELAUNCH']) { + return; + } + + let latestAdminSettings = remoteAdminSettings; + + const runner = () => { + const scriptArgs = getScriptArgs(); + const { spawnArgs, env: newEnv } = getSpawnConfig(additionalNodeArgs, [ + ...additionalScriptArgs, + ...scriptArgs, + ]); + + // The parent process should not be reading from stdin while the child is running. + process.stdin.pause(); + + const child = spawn(process.execPath, spawnArgs, { + stdio: ['inherit', 'inherit', 'inherit', 'ipc'], + env: newEnv, + }); + + if (latestAdminSettings) { + child.send({ type: 'admin-settings', settings: latestAdminSettings }); + } + + child.on('message', (msg: { type?: string; settings?: unknown }) => { + if (msg.type === 'admin-settings-update' && msg.settings) { + latestAdminSettings = msg.settings as AdminControlsSettings; + } + }); + + return new Promise((resolve, reject) => { + child.on('error', reject); + child.on('close', (code) => { + // Resume stdin before the parent process exits. + process.stdin.resume(); + resolve(code ?? 1); + }); + }); + }; + + await relaunchOnExitCode(runner); +} diff --git a/packages/cli/src/utils/sandbox-macos-permissive-open.sb b/packages/cli/src/utils/sandbox-macos-permissive-open.sb new file mode 100644 index 0000000000000000000000000000000000000000..ad926053743df32ac8cc62f313c6b63c57826cb4 --- /dev/null +++ b/packages/cli/src/utils/sandbox-macos-permissive-open.sb @@ -0,0 +1,220 @@ +(version 1) + +;; permissive-open: uses (deny default) and explicitly allows the operations the +;; CLI needs, matching the restrictive-* / strict-* profiles. Keep the allow-list +;; minimal and reviewed; do not switch to (allow default). +;; +;; Keep the non-network rules in sync with sandbox-macos-permissive-proxied.sb: +;; the two profiles are intentionally identical except for their network rules +;; ("open" allows broad outbound; "proxied" routes outbound through the proxy). +(deny default) + +;; allow reading files from anywhere on host +(allow file-read*) + +;; allow exec/fork (children inherit this policy, so they stay sandboxed) +(allow process-exec) +(allow process-fork) + +;; allow signals to self, e.g. SIGPIPE on write to closed pipe +(allow signal (target self)) + +;; allow read access to specific information about system +;; from https://source.chromium.org/chromium/chromium/src/+/main:sandbox/policy/mac/common.sb;l=273-319;drc=7b3962fe2e5fc9e2ee58000dc8fbf3429d84d3bd +(allow sysctl-read + (sysctl-name "hw.activecpu") + (sysctl-name "hw.busfrequency_compat") + (sysctl-name "hw.byteorder") + (sysctl-name "hw.cacheconfig") + (sysctl-name "hw.cachelinesize_compat") + (sysctl-name "hw.cpufamily") + (sysctl-name "hw.cpufrequency_compat") + (sysctl-name "hw.cputype") + (sysctl-name "hw.l1dcachesize_compat") + (sysctl-name "hw.l1icachesize_compat") + (sysctl-name "hw.l2cachesize_compat") + (sysctl-name "hw.l3cachesize_compat") + (sysctl-name "hw.logicalcpu_max") + (sysctl-name "hw.machine") + (sysctl-name "hw.ncpu") + (sysctl-name "hw.nperflevels") + (sysctl-name "hw.optional.arm.FEAT_BF16") + (sysctl-name "hw.optional.arm.FEAT_DotProd") + (sysctl-name "hw.optional.arm.FEAT_FCMA") + (sysctl-name "hw.optional.arm.FEAT_FHM") + (sysctl-name "hw.optional.arm.FEAT_FP16") + (sysctl-name "hw.optional.arm.FEAT_I8MM") + (sysctl-name "hw.optional.arm.FEAT_JSCVT") + (sysctl-name "hw.optional.arm.FEAT_LSE") + (sysctl-name "hw.optional.arm.FEAT_RDM") + (sysctl-name "hw.optional.arm.FEAT_SHA512") + (sysctl-name "hw.optional.armv8_2_sha512") + (sysctl-name "hw.packages") + (sysctl-name "hw.pagesize_compat") + (sysctl-name "hw.physicalcpu_max") + (sysctl-name "hw.tbfrequency_compat") + (sysctl-name "hw.vectorunit") + (sysctl-name "kern.hostname") + (sysctl-name "kern.maxfilesperproc") + (sysctl-name "kern.osproductversion") + (sysctl-name "kern.osrelease") + (sysctl-name "kern.ostype") + (sysctl-name "kern.osvariant_status") + (sysctl-name "kern.osversion") + (sysctl-name "kern.secure_kernel") + (sysctl-name "kern.usrstack64") + (sysctl-name "kern.version") + (sysctl-name "sysctl.proc_cputype") + (sysctl-name-prefix "hw.perflevel") +) + +;; allow writes only to specific paths (deny default already blocks the rest) +(allow file-write* + (subpath (param "TARGET_DIR")) + (subpath (param "TMP_DIR")) + (subpath (param "CACHE_DIR")) + (subpath (string-append (param "HOME_DIR") "/.npm")) + (subpath (string-append (param "HOME_DIR") "/.cache")) + ;; Allow writes to included directories from --include-directories + (subpath (param "INCLUDE_DIR_0")) + (subpath (param "INCLUDE_DIR_1")) + (subpath (param "INCLUDE_DIR_2")) + (subpath (param "INCLUDE_DIR_3")) + (subpath (param "INCLUDE_DIR_4")) + (literal "/dev/stdout") + (literal "/dev/stderr") + (literal "/dev/null") + (literal "/dev/ptmx") + (regex #"^/dev/ttys[0-9]*$") +) + +;; allow the mach services normal workflows need under deny-default: sysmond for +;; process listing (pgrep), plus DNS resolution (mDNSResponder), directory +;; services (opendirectoryd), and certificate validation (trustd/ocspd). +;; This set mirrors the deny-default profile in +;; packages/core/src/sandbox/macos/baseProfile.ts (its NETWORK_SEATBELT_PROFILE), +;; which restrictive-open reaches only implicitly via (allow network-outbound). +;; Keep this allow-list minimal and reviewed. +(allow mach-lookup + (global-name "com.apple.sysmond") + (global-name "com.apple.system.opendirectoryd.libinfo") + (global-name "com.apple.system.opendirectoryd.membership") + (global-name "com.apple.bsd.dirhelper") + (global-name "com.apple.SecurityServer") + (global-name "com.apple.networkd") + (global-name "com.apple.ocspd") + (global-name "com.apple.trustd") + (global-name "com.apple.trustd.agent") + (global-name "com.apple.mDNSResponder") + (global-name "com.apple.mDNSResponderHelper") + (global-name "com.apple.SystemConfiguration.DNSConfiguration") + (global-name "com.apple.SystemConfiguration.configd") +) + +;; AF_SYSTEM socket used by the network stack (from baseProfile.ts) +(allow system-socket + (require-all + (socket-domain AF_SYSTEM) + (socket-protocol 2) + ) +) + +;; enable terminal access required by ink +;; fixes setRawMode EPERM failure (at node:tty:81:24) +(allow file-ioctl (regex #"^/dev/tty.*")) + +;; allow inbound network traffic (local dev/test servers, the debugger on :9229, +;; OAuth localhost callbacks) +(allow network-inbound (local ip "*:*")) + +;; allow binding local ports (dev/test servers, OAuth localhost listeners) +(allow network-bind (local ip "*:*")) + +;; allow all outbound network traffic +(allow network-outbound) + +;; Block Docker & container daemon UNIX domain sockets (read & write) +(deny file-read* file-write* + (literal "/var/run/docker.sock") + (literal "/var/run/docker.sock.raw") + (literal "/private/var/run/docker.sock") + (literal "/private/var/run/docker.sock.raw") + (subpath "/var/run/docker") + (subpath "/private/var/run/docker") + (subpath "/Users/Shared/.docker") + (subpath (string-append (param "HOME_DIR") "/.docker/run")) + (subpath (string-append (param "HOME_DIR") "/.docker/desktop")) + (subpath (string-append (param "HOME_DIR") "/.colima")) + (subpath (string-append (param "HOME_DIR") "/.orbstack/run")) + (subpath (string-append (param "HOME_DIR") "/.rd")) +) + +;; Block container CLI and daemon binaries from execution +(deny process-exec + (literal "/usr/local/bin/docker") + (literal "/usr/local/bin/dockerd") + (literal "/usr/local/bin/docker-compose") + (literal "/usr/bin/docker") + (literal "/opt/homebrew/bin/docker") + (literal "/opt/homebrew/bin/dockerd") + (literal "/opt/homebrew/bin/docker-compose") + (literal "/opt/homebrew/bin/podman") + (literal "/usr/local/bin/podman") + (literal "/opt/homebrew/bin/colima") + (literal "/usr/local/bin/colima") + (literal "/opt/homebrew/bin/orb") + (literal "/Applications/Docker.app/Contents/Resources/bin/docker") + (subpath "/Applications/Docker.app/Contents/MacOS/") + (subpath "/Applications/OrbStack.app/Contents/MacOS/") + (subpath "/Applications/Rancher Desktop.app/Contents/MacOS/") +) + +;; Block com.docker.* and container manager Mach lookup / XPC services +(deny mach-lookup + (xpc-service-name-prefix "com.docker.") + (global-name-prefix "com.docker.") + (global-name-prefix "dev.kdrag0n.OrbStack") +) + +;; Block Docker POSIX Shared Memory +(deny ipc-posix-shm* + (ipc-posix-name-prefix "docker") + (ipc-posix-name-prefix "com.docker.") +) + +;; Deny writing to user's Gemini CLI configuration directory and sensitive files +(deny file-write* + (subpath (string-append (param "HOME_DIR") "/.gemini")) + (regex #"/oauth_creds\.json$") + (regex #"/gemini-credentials\.json$") + (regex #"/mcp-oauth-tokens\.json$") + (regex #"/a2a-oauth-tokens\.json$") + (regex #"/google_accounts\.json$") + (regex #"/trusted_hooks\.json$") + (regex #"/trustedFolders\.json$") + (regex #"/policy_integrity\.json$") + (regex #"/\.env($|\..*)") +) + +;; Block reading sensitive credentials and environment files +(deny file-read* + (literal (string-append (param "HOME_DIR") "/.gemini/oauth_creds.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/gemini-credentials.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/mcp-oauth-tokens.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/a2a-oauth-tokens.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/google_accounts.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/trusted_hooks.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/trustedFolders.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/policy_integrity.json")) + (regex #"/oauth_creds\.json$") + (regex #"/gemini-credentials\.json$") + (regex #"/mcp-oauth-tokens\.json$") + (regex #"/a2a-oauth-tokens\.json$") + (regex #"/google_accounts\.json$") + (regex #"/trusted_hooks\.json$") + (regex #"/trustedFolders\.json$") + (regex #"/policy_integrity\.json$") + (regex #"/\.env($|\..*)") +) + + diff --git a/packages/cli/src/utils/sandbox-macos-permissive-proxied.sb b/packages/cli/src/utils/sandbox-macos-permissive-proxied.sb new file mode 100644 index 0000000000000000000000000000000000000000..a18686c56f63b638fcce064c1c9ad2735427eab7 --- /dev/null +++ b/packages/cli/src/utils/sandbox-macos-permissive-proxied.sb @@ -0,0 +1,223 @@ +(version 1) + +;; permissive-proxied: uses (deny default) and explicitly allows the operations +;; the CLI needs, matching the restrictive-* / strict-* profiles. Keep the +;; allow-list minimal and reviewed; do not switch to (allow default). +;; +;; Keep the non-network rules in sync with sandbox-macos-permissive-open.sb: +;; the two profiles are intentionally identical except for their network rules +;; ("open" allows broad outbound; "proxied" routes outbound through the proxy). +(deny default) + +;; allow reading files from anywhere on host +(allow file-read*) + +;; allow exec/fork (children inherit this policy, so they stay sandboxed) +(allow process-exec) +(allow process-fork) + +;; allow signals to self, e.g. SIGPIPE on write to closed pipe +(allow signal (target self)) + +;; allow read access to specific information about system +;; from https://source.chromium.org/chromium/chromium/src/+/main:sandbox/policy/mac/common.sb;l=273-319;drc=7b3962fe2e5fc9e2ee58000dc8fbf3429d84d3bd +(allow sysctl-read + (sysctl-name "hw.activecpu") + (sysctl-name "hw.busfrequency_compat") + (sysctl-name "hw.byteorder") + (sysctl-name "hw.cacheconfig") + (sysctl-name "hw.cachelinesize_compat") + (sysctl-name "hw.cpufamily") + (sysctl-name "hw.cpufrequency_compat") + (sysctl-name "hw.cputype") + (sysctl-name "hw.l1dcachesize_compat") + (sysctl-name "hw.l1icachesize_compat") + (sysctl-name "hw.l2cachesize_compat") + (sysctl-name "hw.l3cachesize_compat") + (sysctl-name "hw.logicalcpu_max") + (sysctl-name "hw.machine") + (sysctl-name "hw.ncpu") + (sysctl-name "hw.nperflevels") + (sysctl-name "hw.optional.arm.FEAT_BF16") + (sysctl-name "hw.optional.arm.FEAT_DotProd") + (sysctl-name "hw.optional.arm.FEAT_FCMA") + (sysctl-name "hw.optional.arm.FEAT_FHM") + (sysctl-name "hw.optional.arm.FEAT_FP16") + (sysctl-name "hw.optional.arm.FEAT_I8MM") + (sysctl-name "hw.optional.arm.FEAT_JSCVT") + (sysctl-name "hw.optional.arm.FEAT_LSE") + (sysctl-name "hw.optional.arm.FEAT_RDM") + (sysctl-name "hw.optional.arm.FEAT_SHA512") + (sysctl-name "hw.optional.armv8_2_sha512") + (sysctl-name "hw.packages") + (sysctl-name "hw.pagesize_compat") + (sysctl-name "hw.physicalcpu_max") + (sysctl-name "hw.tbfrequency_compat") + (sysctl-name "hw.vectorunit") + (sysctl-name "kern.hostname") + (sysctl-name "kern.maxfilesperproc") + (sysctl-name "kern.osproductversion") + (sysctl-name "kern.osrelease") + (sysctl-name "kern.ostype") + (sysctl-name "kern.osvariant_status") + (sysctl-name "kern.osversion") + (sysctl-name "kern.secure_kernel") + (sysctl-name "kern.usrstack64") + (sysctl-name "kern.version") + (sysctl-name "sysctl.proc_cputype") + (sysctl-name-prefix "hw.perflevel") +) + +;; allow writes only to specific paths (deny default already blocks the rest). +;; Mirrors permissive-open, including /dev/ptmx and the /dev/ttys regex needed +;; for PTY support under deny default. +(allow file-write* + (subpath (param "TARGET_DIR")) + (subpath (param "TMP_DIR")) + (subpath (param "CACHE_DIR")) + (subpath (string-append (param "HOME_DIR") "/.npm")) + (subpath (string-append (param "HOME_DIR") "/.cache")) + ;; Allow writes to included directories from --include-directories + (subpath (param "INCLUDE_DIR_0")) + (subpath (param "INCLUDE_DIR_1")) + (subpath (param "INCLUDE_DIR_2")) + (subpath (param "INCLUDE_DIR_3")) + (subpath (param "INCLUDE_DIR_4")) + (literal "/dev/stdout") + (literal "/dev/stderr") + (literal "/dev/null") + (literal "/dev/ptmx") + (regex #"^/dev/ttys[0-9]*$") +) + +;; allow the mach services normal workflows need under deny-default: sysmond for +;; process listing (pgrep), plus DNS resolution (mDNSResponder), directory +;; services (opendirectoryd), and certificate validation (trustd/ocspd). +;; This set mirrors the deny-default profile in +;; packages/core/src/sandbox/macos/baseProfile.ts (its NETWORK_SEATBELT_PROFILE), +;; which restrictive-proxied reaches only implicitly via (allow network-outbound). +;; Keep this allow-list minimal and reviewed. +(allow mach-lookup + (global-name "com.apple.sysmond") + (global-name "com.apple.system.opendirectoryd.libinfo") + (global-name "com.apple.system.opendirectoryd.membership") + (global-name "com.apple.bsd.dirhelper") + (global-name "com.apple.SecurityServer") + (global-name "com.apple.networkd") + (global-name "com.apple.ocspd") + (global-name "com.apple.trustd") + (global-name "com.apple.trustd.agent") + (global-name "com.apple.mDNSResponder") + (global-name "com.apple.mDNSResponderHelper") + (global-name "com.apple.SystemConfiguration.DNSConfiguration") + (global-name "com.apple.SystemConfiguration.configd") +) + +;; AF_SYSTEM socket used by the network stack (from baseProfile.ts) +(allow system-socket + (require-all + (socket-domain AF_SYSTEM) + (socket-protocol 2) + ) +) + +;; enable terminal access required by ink +;; fixes setRawMode EPERM failure (at node:tty:81:24) +(allow file-ioctl (regex #"^/dev/tty.*")) + +;; allow inbound network traffic on debugger port +(allow network-inbound (local ip "localhost:9229")) + +;; allow binding local ports (dev/test servers, OAuth localhost listeners) +(allow network-bind (local ip "*:*")) + +;; deny all outbound network traffic EXCEPT through proxy on localhost:8877 +;; set `GEMINI_SANDBOX_PROXY_COMMAND=` to run proxy alongside sandbox +;; proxy must listen on :::8877 (see docs/examples/proxy-script.md) +(allow network-outbound (remote tcp "localhost:8877")) + +;; Block Docker & container daemon UNIX domain sockets (read & write) +(deny file-read* file-write* + (literal "/var/run/docker.sock") + (literal "/var/run/docker.sock.raw") + (literal "/private/var/run/docker.sock") + (literal "/private/var/run/docker.sock.raw") + (subpath "/var/run/docker") + (subpath "/private/var/run/docker") + (subpath "/Users/Shared/.docker") + (subpath (string-append (param "HOME_DIR") "/.docker/run")) + (subpath (string-append (param "HOME_DIR") "/.docker/desktop")) + (subpath (string-append (param "HOME_DIR") "/.colima")) + (subpath (string-append (param "HOME_DIR") "/.orbstack/run")) + (subpath (string-append (param "HOME_DIR") "/.rd")) +) + +;; Block container CLI and daemon binaries from execution +(deny process-exec + (literal "/usr/local/bin/docker") + (literal "/usr/local/bin/dockerd") + (literal "/usr/local/bin/docker-compose") + (literal "/usr/bin/docker") + (literal "/opt/homebrew/bin/docker") + (literal "/opt/homebrew/bin/dockerd") + (literal "/opt/homebrew/bin/docker-compose") + (literal "/opt/homebrew/bin/podman") + (literal "/usr/local/bin/podman") + (literal "/opt/homebrew/bin/colima") + (literal "/usr/local/bin/colima") + (literal "/opt/homebrew/bin/orb") + (literal "/Applications/Docker.app/Contents/Resources/bin/docker") + (subpath "/Applications/Docker.app/Contents/MacOS/") + (subpath "/Applications/OrbStack.app/Contents/MacOS/") + (subpath "/Applications/Rancher Desktop.app/Contents/MacOS/") +) + +;; Block com.docker.* and container manager Mach lookup / XPC services +(deny mach-lookup + (xpc-service-name-prefix "com.docker.") + (global-name-prefix "com.docker.") + (global-name-prefix "dev.kdrag0n.OrbStack") +) + +;; Block Docker POSIX Shared Memory +(deny ipc-posix-shm* + (ipc-posix-name-prefix "docker") + (ipc-posix-name-prefix "com.docker.") +) + +;; Deny writing to user's Gemini CLI configuration directory and sensitive files +(deny file-write* + (subpath (string-append (param "HOME_DIR") "/.gemini")) + (regex #"/oauth_creds\.json$") + (regex #"/gemini-credentials\.json$") + (regex #"/mcp-oauth-tokens\.json$") + (regex #"/a2a-oauth-tokens\.json$") + (regex #"/google_accounts\.json$") + (regex #"/trusted_hooks\.json$") + (regex #"/trustedFolders\.json$") + (regex #"/policy_integrity\.json$") + (regex #"/\.env($|\..*)") +) + +;; Block reading sensitive credentials and environment files +(deny file-read* + (literal (string-append (param "HOME_DIR") "/.gemini/oauth_creds.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/gemini-credentials.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/mcp-oauth-tokens.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/a2a-oauth-tokens.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/google_accounts.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/trusted_hooks.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/trustedFolders.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/policy_integrity.json")) + (regex #"/oauth_creds\.json$") + (regex #"/gemini-credentials\.json$") + (regex #"/mcp-oauth-tokens\.json$") + (regex #"/a2a-oauth-tokens\.json$") + (regex #"/google_accounts\.json$") + (regex #"/trusted_hooks\.json$") + (regex #"/trustedFolders\.json$") + (regex #"/policy_integrity\.json$") + (regex #"/\.env($|\..*)") +) + + diff --git a/packages/cli/src/utils/sandbox-macos-restrictive-open.sb b/packages/cli/src/utils/sandbox-macos-restrictive-open.sb new file mode 100644 index 0000000000000000000000000000000000000000..b18c4d9e8217e558776c53e979cd28cd56a07f1a --- /dev/null +++ b/packages/cli/src/utils/sandbox-macos-restrictive-open.sb @@ -0,0 +1,182 @@ +(version 1) + +;; deny everything by default +(deny default) + +;; allow reading files from anywhere on host +(allow file-read*) +(allow file-read* + (literal (string-append (param "HOME_DIR") "/.gemini/settings.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/keybindings.json")) +) + +;; allow exec/fork (children inherit policy) +(allow process-exec) +(allow process-fork) + +;; allow signals to self, e.g. SIGPIPE on write to closed pipe +(allow signal (target self)) + +;; allow read access to specific information about system +;; from https://source.chromium.org/chromium/chromium/src/+/main:sandbox/policy/mac/common.sb;l=273-319;drc=7b3962fe2e5fc9e2ee58000dc8fbf3429d84d3bd +(allow sysctl-read + (sysctl-name "hw.activecpu") + (sysctl-name "hw.busfrequency_compat") + (sysctl-name "hw.byteorder") + (sysctl-name "hw.cacheconfig") + (sysctl-name "hw.cachelinesize_compat") + (sysctl-name "hw.cpufamily") + (sysctl-name "hw.cpufrequency_compat") + (sysctl-name "hw.cputype") + (sysctl-name "hw.l1dcachesize_compat") + (sysctl-name "hw.l1icachesize_compat") + (sysctl-name "hw.l2cachesize_compat") + (sysctl-name "hw.l3cachesize_compat") + (sysctl-name "hw.logicalcpu_max") + (sysctl-name "hw.machine") + (sysctl-name "hw.ncpu") + (sysctl-name "hw.nperflevels") + (sysctl-name "hw.optional.arm.FEAT_BF16") + (sysctl-name "hw.optional.arm.FEAT_DotProd") + (sysctl-name "hw.optional.arm.FEAT_FCMA") + (sysctl-name "hw.optional.arm.FEAT_FHM") + (sysctl-name "hw.optional.arm.FEAT_FP16") + (sysctl-name "hw.optional.arm.FEAT_I8MM") + (sysctl-name "hw.optional.arm.FEAT_JSCVT") + (sysctl-name "hw.optional.arm.FEAT_LSE") + (sysctl-name "hw.optional.arm.FEAT_RDM") + (sysctl-name "hw.optional.arm.FEAT_SHA512") + (sysctl-name "hw.optional.armv8_2_sha512") + (sysctl-name "hw.packages") + (sysctl-name "hw.pagesize_compat") + (sysctl-name "hw.physicalcpu_max") + (sysctl-name "hw.tbfrequency_compat") + (sysctl-name "hw.vectorunit") + (sysctl-name "kern.hostname") + (sysctl-name "kern.maxfilesperproc") + (sysctl-name "kern.osproductversion") + (sysctl-name "kern.osrelease") + (sysctl-name "kern.ostype") + (sysctl-name "kern.osvariant_status") + (sysctl-name "kern.osversion") + (sysctl-name "kern.secure_kernel") + (sysctl-name "kern.usrstack64") + (sysctl-name "kern.version") + (sysctl-name "sysctl.proc_cputype") + (sysctl-name-prefix "hw.perflevel") +) + +;; allow writes to specific paths +(allow file-write* + (subpath (param "TARGET_DIR")) + (subpath (param "TMP_DIR")) + (subpath (param "CACHE_DIR")) + (subpath (string-append (param "HOME_DIR") "/.npm")) + (subpath (string-append (param "HOME_DIR") "/.cache")) + ;; Allow writes to included directories from --include-directories + (subpath (param "INCLUDE_DIR_0")) + (subpath (param "INCLUDE_DIR_1")) + (subpath (param "INCLUDE_DIR_2")) + (subpath (param "INCLUDE_DIR_3")) + (subpath (param "INCLUDE_DIR_4")) + (literal "/dev/stdout") + (literal "/dev/stderr") + (literal "/dev/null") +) + +;; allow communication with sysmond for process listing (e.g. for pgrep) +(allow mach-lookup (global-name "com.apple.sysmond")) + +;; enable terminal access required by ink +;; fixes setRawMode EPERM failure (at node:tty:81:24) +(allow file-ioctl (regex #"^/dev/tty.*")) + +;; allow inbound network traffic on debugger port +(allow network-inbound (local ip "localhost:9229")) + +;; allow all outbound network traffic +(allow network-outbound) + +;; Block Docker & container daemon UNIX domain sockets (read & write) +(deny file-read* file-write* + (literal "/var/run/docker.sock") + (literal "/var/run/docker.sock.raw") + (literal "/private/var/run/docker.sock") + (literal "/private/var/run/docker.sock.raw") + (subpath "/var/run/docker") + (subpath "/private/var/run/docker") + (subpath "/Users/Shared/.docker") + (subpath (string-append (param "HOME_DIR") "/.docker/run")) + (subpath (string-append (param "HOME_DIR") "/.docker/desktop")) + (subpath (string-append (param "HOME_DIR") "/.colima")) + (subpath (string-append (param "HOME_DIR") "/.orbstack/run")) + (subpath (string-append (param "HOME_DIR") "/.rd")) +) + +;; Block container CLI and daemon binaries from execution +(deny process-exec + (literal "/usr/local/bin/docker") + (literal "/usr/local/bin/dockerd") + (literal "/usr/local/bin/docker-compose") + (literal "/usr/bin/docker") + (literal "/opt/homebrew/bin/docker") + (literal "/opt/homebrew/bin/dockerd") + (literal "/opt/homebrew/bin/docker-compose") + (literal "/opt/homebrew/bin/podman") + (literal "/usr/local/bin/podman") + (literal "/opt/homebrew/bin/colima") + (literal "/usr/local/bin/colima") + (literal "/opt/homebrew/bin/orb") + (literal "/Applications/Docker.app/Contents/Resources/bin/docker") + (subpath "/Applications/Docker.app/Contents/MacOS/") + (subpath "/Applications/OrbStack.app/Contents/MacOS/") + (subpath "/Applications/Rancher Desktop.app/Contents/MacOS/") +) + +;; Block com.docker.* and container manager Mach lookup / XPC services +(deny mach-lookup + (xpc-service-name-prefix "com.docker.") + (global-name-prefix "com.docker.") + (global-name-prefix "dev.kdrag0n.OrbStack") +) + +;; Block Docker POSIX Shared Memory +(deny ipc-posix-shm* + (ipc-posix-name-prefix "docker") + (ipc-posix-name-prefix "com.docker.") +) + +;; Deny writing to user's Gemini CLI configuration directory and sensitive files +(deny file-write* + (subpath (string-append (param "HOME_DIR") "/.gemini")) + (regex #"/oauth_creds\.json$") + (regex #"/gemini-credentials\.json$") + (regex #"/mcp-oauth-tokens\.json$") + (regex #"/a2a-oauth-tokens\.json$") + (regex #"/google_accounts\.json$") + (regex #"/trusted_hooks\.json$") + (regex #"/trustedFolders\.json$") + (regex #"/policy_integrity\.json$") + (regex #"/\.env($|\..*)") +) + +;; Block reading sensitive credentials and environment files +(deny file-read* + (literal (string-append (param "HOME_DIR") "/.gemini/oauth_creds.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/gemini-credentials.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/mcp-oauth-tokens.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/a2a-oauth-tokens.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/google_accounts.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/trusted_hooks.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/trustedFolders.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/policy_integrity.json")) + (regex #"/oauth_creds\.json$") + (regex #"/gemini-credentials\.json$") + (regex #"/mcp-oauth-tokens\.json$") + (regex #"/a2a-oauth-tokens\.json$") + (regex #"/google_accounts\.json$") + (regex #"/trusted_hooks\.json$") + (regex #"/trustedFolders\.json$") + (regex #"/policy_integrity\.json$") + (regex #"/\.env($|\..*)") +) \ No newline at end of file diff --git a/packages/cli/src/utils/sandbox-macos-strict-open.sb b/packages/cli/src/utils/sandbox-macos-strict-open.sb new file mode 100644 index 0000000000000000000000000000000000000000..c697916c8105e0b780d168461e108b91ec226ad5 --- /dev/null +++ b/packages/cli/src/utils/sandbox-macos-strict-open.sb @@ -0,0 +1,216 @@ +(version 1) + +;; deny everything by default +(deny default) + +;; allow reading ONLY from working directory, system paths, and essential user paths +(allow file-read* + (literal "/") + (subpath (param "TARGET_DIR")) + (subpath (param "TMP_DIR")) + (subpath (param "CACHE_DIR")) + ;; Only allow reading essential dotfiles/directories under HOME, not the entire HOME + (literal (string-append (param "HOME_DIR") "/.gemini/settings.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/keybindings.json")) + (subpath (string-append (param "HOME_DIR") "/.npm")) + (subpath (string-append (param "HOME_DIR") "/.cache")) + (literal (string-append (param "HOME_DIR") "/.gitconfig")) + (subpath (string-append (param "HOME_DIR") "/.nvm")) + (subpath (string-append (param "HOME_DIR") "/.fnm")) + (subpath (string-append (param "HOME_DIR") "/.node")) + (subpath (string-append (param "HOME_DIR") "/.config")) + ;; Allow reads from included directories + (subpath (param "INCLUDE_DIR_0")) + (subpath (param "INCLUDE_DIR_1")) + (subpath (param "INCLUDE_DIR_2")) + (subpath (param "INCLUDE_DIR_3")) + (subpath (param "INCLUDE_DIR_4")) + ;; System paths required for Node.js, shell, and common tools + (subpath "/usr") + (subpath "/bin") + (subpath "/sbin") + (subpath "/Library") + (subpath "/System") + (subpath "/private") + (subpath "/dev") + (subpath "/etc") + (subpath "/opt") + (subpath "/Applications") +) + +;; allow path traversal everywhere (metadata only: stat/lstat, NOT readdir or file content) +;; this is needed for Node.js module resolution to traverse intermediate directories +(allow file-read-metadata) + +;; allow exec/fork (children inherit policy) +(allow process-exec) +(allow process-fork) + +;; allow signals to self, e.g. SIGPIPE on write to closed pipe +(allow signal (target self)) + +;; allow read access to specific information about system +;; from https://source.chromium.org/chromium/chromium/src/+/main:sandbox/policy/mac/common.sb;l=273-319;drc=7b3962fe2e5fc9e2ee58000dc8fbf3429d84d3bd +(allow sysctl-read + (sysctl-name "hw.activecpu") + (sysctl-name "hw.busfrequency_compat") + (sysctl-name "hw.byteorder") + (sysctl-name "hw.cacheconfig") + (sysctl-name "hw.cachelinesize_compat") + (sysctl-name "hw.cpufamily") + (sysctl-name "hw.cpufrequency_compat") + (sysctl-name "hw.cputype") + (sysctl-name "hw.l1dcachesize_compat") + (sysctl-name "hw.l1icachesize_compat") + (sysctl-name "hw.l2cachesize_compat") + (sysctl-name "hw.l3cachesize_compat") + (sysctl-name "hw.logicalcpu_max") + (sysctl-name "hw.machine") + (sysctl-name "hw.ncpu") + (sysctl-name "hw.nperflevels") + (sysctl-name "hw.optional.arm.FEAT_BF16") + (sysctl-name "hw.optional.arm.FEAT_DotProd") + (sysctl-name "hw.optional.arm.FEAT_FCMA") + (sysctl-name "hw.optional.arm.FEAT_FHM") + (sysctl-name "hw.optional.arm.FEAT_FP16") + (sysctl-name "hw.optional.arm.FEAT_I8MM") + (sysctl-name "hw.optional.arm.FEAT_JSCVT") + (sysctl-name "hw.optional.arm.FEAT_LSE") + (sysctl-name "hw.optional.arm.FEAT_RDM") + (sysctl-name "hw.optional.arm.FEAT_SHA512") + (sysctl-name "hw.optional.armv8_2_sha512") + (sysctl-name "hw.packages") + (sysctl-name "hw.pagesize_compat") + (sysctl-name "hw.physicalcpu_max") + (sysctl-name "hw.tbfrequency_compat") + (sysctl-name "hw.vectorunit") + (sysctl-name "kern.hostname") + (sysctl-name "kern.maxfilesperproc") + (sysctl-name "kern.osproductversion") + (sysctl-name "kern.osrelease") + (sysctl-name "kern.ostype") + (sysctl-name "kern.osvariant_status") + (sysctl-name "kern.osversion") + (sysctl-name "kern.secure_kernel") + (sysctl-name "kern.usrstack64") + (sysctl-name "kern.version") + (sysctl-name "sysctl.proc_cputype") + (sysctl-name-prefix "hw.perflevel") +) + +;; allow writes to specific paths +(allow file-write* + (subpath (param "TARGET_DIR")) + (subpath (param "TMP_DIR")) + (subpath (param "CACHE_DIR")) + (subpath (string-append (param "HOME_DIR") "/.npm")) + (subpath (string-append (param "HOME_DIR") "/.cache")) + ;; Allow writes to included directories from --include-directories + (subpath (param "INCLUDE_DIR_0")) + (subpath (param "INCLUDE_DIR_1")) + (subpath (param "INCLUDE_DIR_2")) + (subpath (param "INCLUDE_DIR_3")) + (subpath (param "INCLUDE_DIR_4")) + (literal "/dev/stdout") + (literal "/dev/stderr") + (literal "/dev/null") +) + +;; allow communication with sysmond for process listing (e.g. for pgrep) +(allow mach-lookup (global-name "com.apple.sysmond")) + +;; enable terminal access required by ink +;; fixes setRawMode EPERM failure (at node:tty:81:24) +(allow file-ioctl (regex #"^/dev/tty.*")) + +;; allow inbound network traffic on debugger port +(allow network-inbound (local ip "localhost:9229")) + +;; allow all outbound network traffic +(allow network-outbound) + +;; Block Docker & container daemon UNIX domain sockets (read & write) +(deny file-read* file-write* + (literal "/var/run/docker.sock") + (literal "/var/run/docker.sock.raw") + (literal "/private/var/run/docker.sock") + (literal "/private/var/run/docker.sock.raw") + (subpath "/var/run/docker") + (subpath "/private/var/run/docker") + (subpath "/Users/Shared/.docker") + (subpath (string-append (param "HOME_DIR") "/.docker/run")) + (subpath (string-append (param "HOME_DIR") "/.docker/desktop")) + (subpath (string-append (param "HOME_DIR") "/.colima")) + (subpath (string-append (param "HOME_DIR") "/.orbstack/run")) + (subpath (string-append (param "HOME_DIR") "/.rd")) +) + +;; Block container CLI and daemon binaries from execution +(deny process-exec + (literal "/usr/local/bin/docker") + (literal "/usr/local/bin/dockerd") + (literal "/usr/local/bin/docker-compose") + (literal "/usr/bin/docker") + (literal "/opt/homebrew/bin/docker") + (literal "/opt/homebrew/bin/dockerd") + (literal "/opt/homebrew/bin/docker-compose") + (literal "/opt/homebrew/bin/podman") + (literal "/usr/local/bin/podman") + (literal "/opt/homebrew/bin/colima") + (literal "/usr/local/bin/colima") + (literal "/opt/homebrew/bin/orb") + (literal "/Applications/Docker.app/Contents/Resources/bin/docker") + (subpath "/Applications/Docker.app/Contents/MacOS/") + (subpath "/Applications/OrbStack.app/Contents/MacOS/") + (subpath "/Applications/Rancher Desktop.app/Contents/MacOS/") +) + +;; Block com.docker.* and container manager Mach lookup / XPC services +(deny mach-lookup + (xpc-service-name-prefix "com.docker.") + (global-name-prefix "com.docker.") + (global-name-prefix "dev.kdrag0n.OrbStack") +) + +;; Block Docker POSIX Shared Memory +(deny ipc-posix-shm* + (ipc-posix-name-prefix "docker") + (ipc-posix-name-prefix "com.docker.") +) + +;; Deny writing to user's Gemini CLI configuration directory and sensitive files +(deny file-write* + (subpath (string-append (param "HOME_DIR") "/.gemini")) + (regex #"/oauth_creds\.json$") + (regex #"/gemini-credentials\.json$") + (regex #"/mcp-oauth-tokens\.json$") + (regex #"/a2a-oauth-tokens\.json$") + (regex #"/google_accounts\.json$") + (regex #"/trusted_hooks\.json$") + (regex #"/trustedFolders\.json$") + (regex #"/policy_integrity\.json$") + (regex #"/\.env($|\..*)") +) + +;; Block reading sensitive credentials and environment files +(deny file-read* + (literal (string-append (param "HOME_DIR") "/.gemini/oauth_creds.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/gemini-credentials.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/mcp-oauth-tokens.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/a2a-oauth-tokens.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/google_accounts.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/trusted_hooks.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/trustedFolders.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/policy_integrity.json")) + (regex #"/oauth_creds\.json$") + (regex #"/gemini-credentials\.json$") + (regex #"/mcp-oauth-tokens\.json$") + (regex #"/a2a-oauth-tokens\.json$") + (regex #"/google_accounts\.json$") + (regex #"/trusted_hooks\.json$") + (regex #"/trustedFolders\.json$") + (regex #"/policy_integrity\.json$") + (regex #"/\.env($|\..*)") +) + + diff --git a/packages/cli/src/utils/sandbox.test.ts b/packages/cli/src/utils/sandbox.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..1d0c511f93cff41407d4fa7fcd671654faf63f11 --- /dev/null +++ b/packages/cli/src/utils/sandbox.test.ts @@ -0,0 +1,1802 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { spawn, exec, execFile, execSync } from 'node:child_process'; +import os from 'node:os'; +import fs from 'node:fs'; +import path from 'node:path'; +import { randomBytes } from 'node:crypto'; +import { start_sandbox } from './sandbox.js'; +import { + FatalSandboxError, + homedir, + type Config, + type SandboxConfig, +} from '@google/gemini-cli-core'; +import { createMockSandboxConfig } from '@google/gemini-cli-test-utils'; +import { EventEmitter } from 'node:events'; + +const { mockedHomedir, mockedGetContainerPath, mockedExecCommands } = + vi.hoisted(() => ({ + mockedHomedir: vi.fn().mockReturnValue('/home/user'), + mockedGetContainerPath: vi.fn().mockImplementation((p: string) => p), + mockedExecCommands: [] as string[], + })); + +vi.mock('./sandboxUtils.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getContainerPath: mockedGetContainerPath, + }; +}); + +vi.mock('node:child_process'); +vi.mock('node:os'); +vi.mock('node:fs'); +vi.mock('node:crypto', () => ({ + randomBytes: vi.fn().mockReturnValue(Buffer.from('a1b2c3d4e5f6', 'hex')), +})); +vi.mock('node:util', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + promisify: (fn: (...args: unknown[]) => unknown) => { + if (fn === exec) { + return async (cmd: string) => { + mockedExecCommands.push(cmd); + if (cmd === 'id -u' || cmd === 'id -g') { + return { stdout: '1000', stderr: '' }; + } + if (cmd.includes('curl')) { + return { stdout: '', stderr: '' }; + } + if (cmd.includes('getconf DARWIN_USER_CACHE_DIR')) { + return { stdout: '/tmp/cache', stderr: '' }; + } + return { stdout: '', stderr: '' }; + }; + } + if (fn === execFile) { + return async (file: string, args: string[]) => { + if (file === 'lxc' && args[0] === 'list') { + const output = process.env['TEST_LXC_LIST_OUTPUT']; + if (output === 'throw') { + throw new Error('lxc command not found'); + } + return { stdout: output ?? '[]', stderr: '' }; + } + if ( + file === 'lxc' && + args[0] === 'config' && + args[1] === 'device' && + args[2] === 'add' + ) { + return { stdout: '', stderr: '' }; + } + return { stdout: '', stderr: '' }; + }; + } + return actual.promisify(fn); + }, + }; +}); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + debugLogger: { + log: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + }, + coreEvents: { + emitFeedback: vi.fn(), + }, + FatalSandboxError: class extends Error { + constructor(message: string) { + super(message); + this.name = 'FatalSandboxError'; + } + }, + GEMINI_DIR: '.gemini', + homedir: mockedHomedir, + }; +}); + +describe('sandbox', () => { + const originalEnv = process.env; + const originalArgv = process.argv; + let mockProcessIn: { + pause: ReturnType; + resume: ReturnType; + isTTY: boolean; + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockedExecCommands.length = 0; + process.env = { ...originalEnv }; + process.argv = [...originalArgv]; + mockProcessIn = { + pause: vi.fn(), + resume: vi.fn(), + isTTY: true, + }; + Object.defineProperty(process, 'stdin', { + value: mockProcessIn, + writable: true, + }); + vi.mocked(os.platform).mockReturnValue('linux'); + vi.mocked(os.homedir).mockReturnValue('/home/user'); + vi.mocked(os.tmpdir).mockReturnValue('/tmp'); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.realpathSync).mockImplementation((p) => p as string); + vi.mocked(fs.mkdtempSync).mockImplementation( + (prefix) => `${prefix}test-tmp`, + ); + vi.mocked(fs.chmodSync).mockImplementation(() => {}); + vi.mocked(fs.rmSync).mockImplementation(() => {}); + vi.mocked(execSync).mockReturnValue(Buffer.from('')); + mockedHomedir.mockReturnValue('/home/user'); + }); + + afterEach(() => { + process.env = originalEnv; + process.argv = originalArgv; + vi.unstubAllEnvs(); + }); + + describe('start_sandbox', () => { + it('should handle macOS seatbelt (sandbox-exec)', async () => { + vi.mocked(os.platform).mockReturnValue('darwin'); + const config: SandboxConfig = createMockSandboxConfig({ + command: 'sandbox-exec', + image: 'some-image', + }); + + interface MockProcess extends EventEmitter { + stdout: EventEmitter; + stderr: EventEmitter; + } + const mockSpawnProcess = new EventEmitter() as MockProcess; + mockSpawnProcess.stdout = new EventEmitter(); + mockSpawnProcess.stderr = new EventEmitter(); + vi.mocked(spawn).mockReturnValue( + mockSpawnProcess as unknown as ReturnType, + ); + + const promise = start_sandbox(config, [], undefined, ['arg1']); + + setTimeout(() => { + mockSpawnProcess.emit('close', 0); + }, 10); + + await expect(promise).resolves.toBe(0); + expect(spawn).toHaveBeenCalledWith( + 'sandbox-exec', + expect.arrayContaining([ + '-f', + expect.stringContaining('sandbox-macos-permissive-open.sb'), + ]), + expect.objectContaining({ stdio: 'inherit' }), + ); + }); + + it('should isolate temporary directory for macOS seatbelt (sandbox-exec)', async () => { + vi.mocked(os.platform).mockReturnValue('darwin'); + vi.mocked(os.tmpdir).mockReturnValue('/var/folders/test/T'); + vi.mocked(fs.realpathSync).mockImplementation((p) => p as string); + vi.mocked(fs.mkdtempSync).mockReturnValue( + '/var/folders/test/T/gemini-sandbox-tmp-123456', + ); + + const config: SandboxConfig = createMockSandboxConfig({ + command: 'sandbox-exec', + image: 'some-image', + }); + + interface MockProcess extends EventEmitter { + stdout: EventEmitter; + stderr: EventEmitter; + } + const mockSpawnProcess = new EventEmitter() as MockProcess; + mockSpawnProcess.stdout = new EventEmitter(); + mockSpawnProcess.stderr = new EventEmitter(); + vi.mocked(spawn).mockReturnValue( + mockSpawnProcess as unknown as ReturnType, + ); + + const onSpy = vi.spyOn(process, 'on'); + const offSpy = vi.spyOn(process, 'off'); + + const promise = start_sandbox(config, [], undefined, ['arg1']); + + setTimeout(() => { + mockSpawnProcess.emit('close', 0); + }, 10); + + await expect(promise).resolves.toBe(0); + + // Verify that an isolated temporary directory was created + expect(fs.mkdtempSync).toHaveBeenCalledWith( + expect.stringContaining( + path.join('/var/folders/test/T', 'gemini-sandbox-'), + ), + ); + + const spawnCalls = vi.mocked(spawn).mock.calls; + const spawnArgs = spawnCalls[0]?.[1] as string[]; + + // Verify that TMP_DIR argument passed to seatbelt is the isolated temp directory, NOT host tmpdir + expect(spawnArgs).toContain( + 'TMP_DIR=/var/folders/test/T/gemini-sandbox-tmp-123456', + ); + expect(spawnArgs).not.toContain('TMP_DIR=/var/folders/test/T'); + + // Verify that TMPDIR environment variable is set in the sandboxed shell execution + const shCommand = spawnArgs[spawnArgs.indexOf('sh') + 2]; + expect(shCommand).toContain('TMPDIR='); + expect(shCommand).toContain( + '/var/folders/test/T/gemini-sandbox-tmp-123456', + ); + + // Verify cleanup of the isolated temporary directory + expect(fs.rmSync).toHaveBeenCalledWith( + '/var/folders/test/T/gemini-sandbox-tmp-123456', + expect.objectContaining({ recursive: true, force: true }), + ); + + // Verify that exit and signal cleanup hooks are registered and unregistered + expect(onSpy).toHaveBeenCalledWith('exit', expect.any(Function)); + expect(onSpy).toHaveBeenCalledWith('SIGINT', expect.any(Function)); + expect(onSpy).toHaveBeenCalledWith('SIGTERM', expect.any(Function)); + + expect(offSpy).toHaveBeenCalledWith('exit', expect.any(Function)); + expect(offSpy).toHaveBeenCalledWith('SIGINT', expect.any(Function)); + expect(offSpy).toHaveBeenCalledWith('SIGTERM', expect.any(Function)); + + onSpy.mockRestore(); + offSpy.mockRestore(); + }); + + it('should safely swallow errors if fs.rmSync fails during cleanup', async () => { + vi.mocked(os.platform).mockReturnValue('darwin'); + vi.mocked(os.tmpdir).mockReturnValue('/var/folders/test/T'); + vi.mocked(fs.realpathSync).mockImplementation((p) => p as string); + vi.mocked(fs.mkdtempSync).mockReturnValue( + '/var/folders/test/T/gemini-sandbox-tmp-error', + ); + vi.mocked(fs.rmSync).mockImplementation(() => { + throw new Error('EPERM: operation not permitted'); + }); + + const config: SandboxConfig = createMockSandboxConfig({ + command: 'sandbox-exec', + image: 'some-image', + }); + + interface MockProcess extends EventEmitter { + stdout: EventEmitter; + stderr: EventEmitter; + } + const mockSpawnProcess = new EventEmitter() as MockProcess; + mockSpawnProcess.stdout = new EventEmitter(); + mockSpawnProcess.stderr = new EventEmitter(); + vi.mocked(spawn).mockReturnValue( + mockSpawnProcess as unknown as ReturnType, + ); + + const promise = start_sandbox(config, [], undefined, ['arg1']); + + setTimeout(() => { + mockSpawnProcess.emit('close', 0); + }, 10); + + // Even if fs.rmSync throws, start_sandbox should resolve successfully and not crash + await expect(promise).resolves.toBe(0); + expect(fs.rmSync).toHaveBeenCalled(); + }); + + it('should resolve custom seatbelt profile from user home directory', async () => { + vi.mocked(os.platform).mockReturnValue('darwin'); + vi.stubEnv('SEATBELT_PROFILE', 'custom-test'); + vi.mocked(fs.existsSync).mockImplementation((p) => + String(p).includes( + path.join(homedir(), '.gemini', 'sandbox-macos-custom-test.sb'), + ), + ); + const config: SandboxConfig = createMockSandboxConfig({ + command: 'sandbox-exec', + image: 'some-image', + }); + + interface MockProcess extends EventEmitter { + stdout: EventEmitter; + stderr: EventEmitter; + } + const mockSpawnProcess = new EventEmitter() as MockProcess; + mockSpawnProcess.stdout = new EventEmitter(); + mockSpawnProcess.stderr = new EventEmitter(); + vi.mocked(spawn).mockReturnValue( + mockSpawnProcess as unknown as ReturnType, + ); + + const promise = start_sandbox(config, [], undefined, ['arg1']); + + setTimeout(() => { + mockSpawnProcess.emit('close', 0); + }, 10); + + await expect(promise).resolves.toBe(0); + expect(spawn).toHaveBeenCalledWith( + 'sandbox-exec', + expect.any(Array), + expect.objectContaining({ stdio: 'inherit' }), + ); + const spawnArgs = vi.mocked(spawn).mock.calls[0]?.[1]; + expect(spawnArgs).toEqual( + expect.arrayContaining(['-f', expect.any(String)]), + ); + const profileArg = spawnArgs?.[spawnArgs.indexOf('-f') + 1]; + expect(profileArg).toEqual( + expect.stringContaining( + path.join(homedir(), '.gemini', 'sandbox-macos-custom-test.sb'), + ), + ); + }); + + it('should fall back to project .gemini directory when user profile is missing', async () => { + vi.mocked(os.platform).mockReturnValue('darwin'); + vi.stubEnv('SEATBELT_PROFILE', 'custom-test'); + vi.mocked(fs.existsSync).mockImplementation((p) => { + const s = String(p); + return ( + s.includes(path.join('.gemini', 'sandbox-macos-custom-test.sb')) && + !s.includes(path.join(homedir(), '.gemini')) + ); + }); + const config: SandboxConfig = createMockSandboxConfig({ + command: 'sandbox-exec', + image: 'some-image', + }); + + interface MockProcess extends EventEmitter { + stdout: EventEmitter; + stderr: EventEmitter; + } + const mockSpawnProcess = new EventEmitter() as MockProcess; + mockSpawnProcess.stdout = new EventEmitter(); + mockSpawnProcess.stderr = new EventEmitter(); + vi.mocked(spawn).mockReturnValue( + mockSpawnProcess as unknown as ReturnType, + ); + + const promise = start_sandbox(config, [], undefined, ['arg1']); + + setTimeout(() => { + mockSpawnProcess.emit('close', 0); + }, 10); + + await expect(promise).resolves.toBe(0); + expect(spawn).toHaveBeenCalledWith( + 'sandbox-exec', + expect.any(Array), + expect.objectContaining({ stdio: 'inherit' }), + ); + const spawnArgs = vi.mocked(spawn).mock.calls[0]?.[1]; + expect(spawnArgs).toEqual( + expect.arrayContaining(['-f', expect.any(String)]), + ); + const profileArg = spawnArgs?.[spawnArgs.indexOf('-f') + 1]; + expect(profileArg).toEqual( + expect.stringContaining( + path.join('.gemini', 'sandbox-macos-custom-test.sb'), + ), + ); + expect(profileArg).not.toContain(homedir()); + }); + + it('should throw FatalSandboxError if seatbelt profile is missing', async () => { + vi.mocked(os.platform).mockReturnValue('darwin'); + vi.mocked(fs.existsSync).mockReturnValue(false); + const config: SandboxConfig = createMockSandboxConfig({ + command: 'sandbox-exec', + image: 'some-image', + }); + + await expect(start_sandbox(config)).rejects.toThrow(FatalSandboxError); + }); + + it('should fall back to embedded profile if the .sb file is missing on disk', async () => { + vi.mocked(os.platform).mockReturnValue('darwin'); + vi.mocked(fs.existsSync).mockImplementation((p) => + String(p).includes( + 'gemini-sandbox-macos-permissive-open-a1b2c3d4e5f6.sb', + ), + ); + + const config: SandboxConfig = createMockSandboxConfig({ + command: 'sandbox-exec', + image: 'some-image', + }); + + const onSpy = vi.spyOn(process, 'on'); + const offSpy = vi.spyOn(process, 'off'); + + interface MockProcess extends EventEmitter { + stdout: EventEmitter; + stderr: EventEmitter; + } + const mockSpawnProcess = new EventEmitter() as MockProcess; + mockSpawnProcess.stdout = new EventEmitter(); + mockSpawnProcess.stderr = new EventEmitter(); + vi.mocked(spawn).mockReturnValue( + mockSpawnProcess as unknown as ReturnType, + ); + + const promise = start_sandbox(config, [], undefined, ['arg1']); + + setTimeout(() => { + mockSpawnProcess.emit('close', 0); + }, 10); + + await expect(promise).resolves.toBe(0); + + // Verify fs.writeFileSync was called with the temp profile file, content, and 0o600 permissions + expect(fs.writeFileSync).toHaveBeenCalledWith( + expect.stringContaining( + 'gemini-sandbox-macos-permissive-open-a1b2c3d4e5f6.sb', + ), + expect.stringContaining('deny default'), + expect.objectContaining({ + encoding: 'utf8', + mode: 0o600, + }), + ); + + // Verify spawn was called with the temp profile file + expect(spawn).toHaveBeenCalledWith( + 'sandbox-exec', + expect.arrayContaining([ + '-f', + expect.stringContaining( + 'gemini-sandbox-macos-permissive-open-a1b2c3d4e5f6.sb', + ), + ]), + expect.objectContaining({ stdio: 'inherit' }), + ); + + // Verify process on/off hooks were called for exit, SIGINT, and SIGTERM cleanups + expect(onSpy).toHaveBeenCalledWith('exit', expect.any(Function)); + expect(onSpy).toHaveBeenCalledWith('SIGINT', expect.any(Function)); + expect(onSpy).toHaveBeenCalledWith('SIGTERM', expect.any(Function)); + + expect(offSpy).toHaveBeenCalledWith('exit', expect.any(Function)); + expect(offSpy).toHaveBeenCalledWith('SIGINT', expect.any(Function)); + expect(offSpy).toHaveBeenCalledWith('SIGTERM', expect.any(Function)); + + // Verify fs.unlinkSync was called to clean up the temp file + expect(fs.unlinkSync).toHaveBeenCalledWith( + expect.stringContaining( + 'gemini-sandbox-macos-permissive-open-a1b2c3d4e5f6.sb', + ), + ); + }); + + it.each([ + 'permissive-open', + 'permissive-closed', + 'permissive-proxied', + 'restrictive-open', + 'restrictive-closed', + 'restrictive-proxied', + 'strict-open', + 'strict-proxied', + ])( + 'should fall back to embedded content successfully for profile "%s"', + async (profile) => { + vi.mocked(os.platform).mockReturnValue('darwin'); + // Mock existsSync to return false for the profile file but true for temp directories + vi.mocked(fs.existsSync).mockImplementation((p) => + String(p).includes('gemini-sandbox-macos-'), + ); + + vi.stubEnv('SEATBELT_PROFILE', profile); + + const config: SandboxConfig = createMockSandboxConfig({ + command: 'sandbox-exec', + image: 'some-image', + }); + + interface MockProcess extends EventEmitter { + stdout: EventEmitter; + stderr: EventEmitter; + } + const mockSpawnProcess = new EventEmitter() as MockProcess; + mockSpawnProcess.stdout = new EventEmitter(); + mockSpawnProcess.stderr = new EventEmitter(); + vi.mocked(spawn).mockReturnValue( + mockSpawnProcess as unknown as ReturnType, + ); + + const promise = start_sandbox(config, [], undefined, ['arg1']); + + setTimeout(() => { + mockSpawnProcess.emit('close', 0); + }, 10); + + await expect(promise).resolves.toBe(0); + + // Verify fs.writeFileSync was called with the correct file mode and content for the profile + expect(fs.writeFileSync).toHaveBeenCalledWith( + expect.stringContaining(`gemini-sandbox-macos-${profile}-`), + expect.stringContaining('deny default'), + expect.objectContaining({ + encoding: 'utf8', + mode: 0o600, + }), + ); + + vi.unstubAllEnvs(); + }, + ); + + it('should handle Docker execution', async () => { + const config: SandboxConfig = createMockSandboxConfig({ + command: 'docker', + image: 'gemini-cli-sandbox', + }); + + // Mock image check to return true (image exists) + interface MockProcessWithStdout extends EventEmitter { + stdout: EventEmitter; + } + const mockImageCheckProcess = new EventEmitter() as MockProcessWithStdout; + mockImageCheckProcess.stdout = new EventEmitter(); + vi.mocked(spawn).mockImplementationOnce((_cmd, args) => { + if (args && args[0] === 'images') { + setTimeout(() => { + mockImageCheckProcess.stdout.emit('data', Buffer.from('image-id')); + mockImageCheckProcess.emit('close', 0); + }, 1); + return mockImageCheckProcess as unknown as ReturnType; + } + return new EventEmitter() as unknown as ReturnType; // fallback + }); + + const mockSpawnProcess = new EventEmitter() as unknown as ReturnType< + typeof spawn + >; + mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => { + if (event === 'close') { + setTimeout(() => cb(0), 10); + } + return mockSpawnProcess; + }); + vi.mocked(spawn).mockImplementationOnce((cmd, args) => { + if (cmd === 'docker' && args && args[0] === 'run') { + return mockSpawnProcess; + } + return new EventEmitter() as unknown as ReturnType; + }); + + const promise = start_sandbox(config, [], undefined, ['arg1']); + + await expect(promise).resolves.toBe(0); + expect(spawn).toHaveBeenCalledWith( + 'docker', + expect.arrayContaining([ + 'run', + '-i', + '--rm', + '--init', + '--entrypoint', + '', + ]), + expect.objectContaining({ stdio: 'inherit' }), + ); + + const containerName = 'gemini-cli-sandbox-a1b2c3d4e5f6'; + expect(randomBytes).toHaveBeenCalledWith(6); + expect(mockedExecCommands).not.toEqual( + expect.arrayContaining([expect.stringContaining('ps -a --format')]), + ); + expect(spawn).toHaveBeenNthCalledWith( + 2, + 'docker', + expect.arrayContaining([ + '--name', + containerName, + '--hostname', + containerName, + '--env', + `SANDBOX=${containerName}`, + ]), + expect.objectContaining({ stdio: 'inherit' }), + ); + }); + + it('should not attempt to create a temporary settings directory when homedir is empty', async () => { + mockedHomedir.mockReturnValue(''); + vi.mocked(os.tmpdir).mockReturnValue('/mock/tmp'); + + const config: SandboxConfig = createMockSandboxConfig({ + command: 'docker', + image: 'gemini-cli-sandbox', + }); + + interface MockProcessWithStdout extends EventEmitter { + stdout: EventEmitter; + } + const mockImageCheckProcess = new EventEmitter() as MockProcessWithStdout; + mockImageCheckProcess.stdout = new EventEmitter(); + vi.mocked(spawn).mockImplementationOnce(() => { + setTimeout(() => { + mockImageCheckProcess.stdout.emit('data', Buffer.from('image-id')); + mockImageCheckProcess.emit('close', 0); + }, 1); + return mockImageCheckProcess as unknown as ReturnType; + }); + + const mockSpawnProcess = new EventEmitter() as unknown as ReturnType< + typeof spawn + >; + mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => { + if (event === 'close') { + setTimeout(() => cb(0), 10); + } + return mockSpawnProcess; + }); + vi.mocked(spawn).mockImplementationOnce(() => mockSpawnProcess); + + await expect( + start_sandbox(config, [], undefined, ['arg1']), + ).resolves.toBe(0); + + // Should only create the sandbox temp directory, not the settings fallback directory + expect(fs.mkdtempSync).toHaveBeenCalledTimes(1); + expect(fs.mkdtempSync).toHaveBeenCalledWith( + path.join('/mock/tmp', 'gemini-sandbox-'), + ); + expect(fs.writeFileSync).toHaveBeenCalledWith( + expect.stringMatching(/settings\.json$/), + expect.any(String), + { mode: 0o600 }, + ); + + const dockerCall = vi.mocked(spawn).mock.calls.find((call) => { + const args = call[1] as string[]; + return args && args.includes('run'); + }); + expect(dockerCall).toBeDefined(); + const dockerArgs = dockerCall![1] as string[]; + expect(dockerArgs.some((arg) => arg.includes('.config/gcloud'))).toBe( + false, + ); + }); + + it('should not mount gcloud config directory when homedir is empty even if relative path exists', async () => { + mockedHomedir.mockReturnValue(''); + vi.mocked(os.tmpdir).mockReturnValue('/mock/tmp'); + + vi.mocked(fs.existsSync).mockImplementation((p: fs.PathLike) => { + if (typeof p === 'string' && p.includes('.config')) { + return true; + } + return false; + }); + + const config: SandboxConfig = createMockSandboxConfig({ + command: 'docker', + image: 'gemini-cli-sandbox', + }); + + interface MockProcessWithStdout extends EventEmitter { + stdout: EventEmitter; + } + const mockImageCheckProcess = new EventEmitter() as MockProcessWithStdout; + mockImageCheckProcess.stdout = new EventEmitter(); + vi.mocked(spawn).mockImplementationOnce(() => { + setTimeout(() => { + mockImageCheckProcess.stdout.emit('data', Buffer.from('image-id')); + mockImageCheckProcess.emit('close', 0); + }, 1); + return mockImageCheckProcess as unknown as ReturnType; + }); + + const mockSpawnProcess = new EventEmitter() as unknown as ReturnType< + typeof spawn + >; + mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => { + if (event === 'close') { + setTimeout(() => cb(0), 10); + } + return mockSpawnProcess; + }); + vi.mocked(spawn).mockImplementationOnce(() => mockSpawnProcess); + + await expect( + start_sandbox(config, [], undefined, ['arg1']), + ).resolves.toBe(0); + + const dockerCall = vi.mocked(spawn).mock.calls.find((call) => { + const args = call[1] as string[]; + return args && args.includes('run'); + }); + expect(dockerCall).toBeDefined(); + const dockerArgs = dockerCall![1] as string[]; + expect(dockerArgs.some((arg) => arg.includes('.config/gcloud'))).toBe( + false, + ); + }); + + it('should mount gcloud config directory when homedir is defined and config exists', async () => { + mockedHomedir.mockReturnValue('/home/user'); + vi.mocked(os.tmpdir).mockReturnValue('/mock/tmp'); + + const expectedConfigDir = path.join('/home/user', '.config', 'gcloud'); + vi.mocked(fs.existsSync).mockImplementation((p: fs.PathLike) => { + if (typeof p === 'string' && p === expectedConfigDir) { + return true; + } + return false; + }); + + const config: SandboxConfig = createMockSandboxConfig({ + command: 'docker', + image: 'gemini-cli-sandbox', + }); + + interface MockProcessWithStdout extends EventEmitter { + stdout: EventEmitter; + } + const mockImageCheckProcess = new EventEmitter() as MockProcessWithStdout; + mockImageCheckProcess.stdout = new EventEmitter(); + vi.mocked(spawn).mockImplementationOnce(() => { + setTimeout(() => { + mockImageCheckProcess.stdout.emit('data', Buffer.from('image-id')); + mockImageCheckProcess.emit('close', 0); + }, 1); + return mockImageCheckProcess as unknown as ReturnType; + }); + + const mockSpawnProcess = new EventEmitter() as unknown as ReturnType< + typeof spawn + >; + mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => { + if (event === 'close') { + setTimeout(() => cb(0), 10); + } + return mockSpawnProcess; + }); + vi.mocked(spawn).mockImplementationOnce(() => mockSpawnProcess); + + await expect( + start_sandbox(config, [], undefined, ['arg1']), + ).resolves.toBe(0); + + const dockerCall = vi.mocked(spawn).mock.calls.find((call) => { + const args = call[1] as string[]; + return args && args.includes('run'); + }); + expect(dockerCall).toBeDefined(); + const dockerArgs = dockerCall![1] as string[]; + expect( + dockerArgs.some( + (arg) => arg.includes(expectedConfigDir) && arg.endsWith(':ro'), + ), + ).toBe(true); + }); + + it('should preserve the integration-test prefix for random container names', async () => { + const config: SandboxConfig = createMockSandboxConfig({ + command: 'docker', + image: 'gemini-cli-sandbox', + }); + process.env['GEMINI_CLI_INTEGRATION_TEST'] = 'true'; + + interface MockProcessWithStdout extends EventEmitter { + stdout: EventEmitter; + } + const mockImageCheckProcess = new EventEmitter() as MockProcessWithStdout; + mockImageCheckProcess.stdout = new EventEmitter(); + vi.mocked(spawn).mockImplementationOnce(() => { + setTimeout(() => { + mockImageCheckProcess.stdout.emit('data', Buffer.from('image-id')); + mockImageCheckProcess.emit('close', 0); + }, 1); + return mockImageCheckProcess as unknown as ReturnType; + }); + + const mockSpawnProcess = new EventEmitter() as unknown as ReturnType< + typeof spawn + >; + mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => { + if (event === 'close') { + setTimeout(() => cb(0), 10); + } + return mockSpawnProcess; + }); + vi.mocked(spawn).mockImplementationOnce(() => mockSpawnProcess); + + await expect( + start_sandbox(config, [], undefined, ['arg1']), + ).resolves.toBe(0); + + const containerName = 'gemini-cli-integration-test-a1b2c3d4e5f6'; + expect(randomBytes).toHaveBeenCalledWith(6); + expect(spawn).toHaveBeenNthCalledWith( + 2, + 'docker', + expect.arrayContaining([ + '--name', + containerName, + '--hostname', + containerName, + '--env', + `SANDBOX=${containerName}`, + ]), + expect.objectContaining({ stdio: 'inherit' }), + ); + }); + + it('should pull image if missing', async () => { + const config: SandboxConfig = createMockSandboxConfig({ + command: 'docker', + image: 'missing-image', + }); + + // 1. Image check fails + interface MockProcessWithStdout extends EventEmitter { + stdout: EventEmitter; + } + const mockImageCheckProcess1 = + new EventEmitter() as MockProcessWithStdout; + mockImageCheckProcess1.stdout = new EventEmitter(); + vi.mocked(spawn).mockImplementationOnce(() => { + setTimeout(() => { + mockImageCheckProcess1.emit('close', 0); + }, 1); + return mockImageCheckProcess1 as unknown as ReturnType; + }); + + // 2. Pull image succeeds + interface MockProcessWithStdoutStderr extends EventEmitter { + stdout: EventEmitter; + stderr: EventEmitter; + } + const mockPullProcess = new EventEmitter() as MockProcessWithStdoutStderr; + mockPullProcess.stdout = new EventEmitter(); + mockPullProcess.stderr = new EventEmitter(); + vi.mocked(spawn).mockImplementationOnce(() => { + setTimeout(() => { + mockPullProcess.emit('close', 0); + }, 1); + return mockPullProcess as unknown as ReturnType; + }); + + // 3. Image check succeeds + const mockImageCheckProcess2 = + new EventEmitter() as MockProcessWithStdout; + mockImageCheckProcess2.stdout = new EventEmitter(); + vi.mocked(spawn).mockImplementationOnce(() => { + setTimeout(() => { + mockImageCheckProcess2.stdout.emit('data', Buffer.from('image-id')); + mockImageCheckProcess2.emit('close', 0); + }, 1); + return mockImageCheckProcess2 as unknown as ReturnType; + }); + + // 4. Docker run + const mockSpawnProcess = new EventEmitter() as unknown as ReturnType< + typeof spawn + >; + mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => { + if (event === 'close') { + setTimeout(() => cb(0), 10); + } + return mockSpawnProcess; + }); + vi.mocked(spawn).mockImplementationOnce(() => mockSpawnProcess); + + const promise = start_sandbox(config, [], undefined, ['arg1']); + + await expect(promise).resolves.toBe(0); + expect(spawn).toHaveBeenCalledWith( + 'docker', + expect.arrayContaining(['pull', 'missing-image']), + expect.any(Object), + ); + }); + + it('should throw if image pull fails', async () => { + const config: SandboxConfig = createMockSandboxConfig({ + command: 'docker', + image: 'missing-image', + }); + + // 1. Image check fails + interface MockProcessWithStdout extends EventEmitter { + stdout: EventEmitter; + } + const mockImageCheckProcess1 = + new EventEmitter() as MockProcessWithStdout; + mockImageCheckProcess1.stdout = new EventEmitter(); + vi.mocked(spawn).mockImplementationOnce(() => { + setTimeout(() => { + mockImageCheckProcess1.emit('close', 0); + }, 1); + return mockImageCheckProcess1 as unknown as ReturnType; + }); + + // 2. Pull image fails + interface MockProcessWithStdoutStderr extends EventEmitter { + stdout: EventEmitter; + stderr: EventEmitter; + } + const mockPullProcess = new EventEmitter() as MockProcessWithStdoutStderr; + mockPullProcess.stdout = new EventEmitter(); + mockPullProcess.stderr = new EventEmitter(); + vi.mocked(spawn).mockImplementationOnce(() => { + setTimeout(() => { + mockPullProcess.emit('close', 1); + }, 1); + return mockPullProcess as unknown as ReturnType; + }); + + await expect(start_sandbox(config)).rejects.toThrow(FatalSandboxError); + }); + + it('should mount volumes correctly', async () => { + const config: SandboxConfig = createMockSandboxConfig({ + command: 'docker', + image: 'gemini-cli-sandbox', + }); + process.env['SANDBOX_MOUNTS'] = '/host/path:/container/path:ro'; + vi.mocked(fs.existsSync).mockReturnValue(true); // For mount path check + + // Mock image check to return true + interface MockProcessWithStdout extends EventEmitter { + stdout: EventEmitter; + } + const mockImageCheckProcess = new EventEmitter() as MockProcessWithStdout; + mockImageCheckProcess.stdout = new EventEmitter(); + vi.mocked(spawn).mockImplementationOnce(() => { + setTimeout(() => { + mockImageCheckProcess.stdout.emit('data', Buffer.from('image-id')); + mockImageCheckProcess.emit('close', 0); + }, 1); + return mockImageCheckProcess as unknown as ReturnType; + }); + + const mockSpawnProcess = new EventEmitter() as unknown as ReturnType< + typeof spawn + >; + mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => { + if (event === 'close') { + setTimeout(() => cb(0), 10); + } + return mockSpawnProcess; + }); + vi.mocked(spawn).mockImplementationOnce(() => mockSpawnProcess); + + await start_sandbox(config); + + // The first call is 'docker images -q ...' + expect(spawn).toHaveBeenNthCalledWith( + 1, + 'docker', + expect.arrayContaining(['images', '-q']), + ); + + // The second call is 'docker run ...' + expect(spawn).toHaveBeenNthCalledWith( + 2, + 'docker', + expect.arrayContaining([ + 'run', + '--env', + 'HOME=/home/node', + '--volume', + '/host/path:/container/path:ro', + '--volume', + expect.stringMatching(/[\\/]home[\\/]node[\\/]\.gemini:rw$/), + ]), + expect.any(Object), + ); + }); + + it('should mount an isolated settings directory rather than exposing host credentials', async () => { + const config: SandboxConfig = createMockSandboxConfig({ + command: 'docker', + image: 'gemini-cli-sandbox', + }); + vi.mocked(fs.existsSync).mockReturnValue(true); + + interface MockProcessWithStdout extends EventEmitter { + stdout: EventEmitter; + } + const mockImageCheckProcess = new EventEmitter() as MockProcessWithStdout; + mockImageCheckProcess.stdout = new EventEmitter(); + vi.mocked(spawn).mockImplementationOnce(() => { + setTimeout(() => { + mockImageCheckProcess.stdout.emit('data', Buffer.from('image-id')); + mockImageCheckProcess.emit('close', 0); + }, 1); + return mockImageCheckProcess as unknown as ReturnType; + }); + + const mockSpawnProcess = new EventEmitter() as unknown as ReturnType< + typeof spawn + >; + mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => { + if (event === 'close') { + setTimeout(() => cb(0), 10); + } + return mockSpawnProcess; + }); + vi.mocked(spawn).mockImplementationOnce(() => mockSpawnProcess); + + await start_sandbox(config); + + // Verify that docker run does NOT mount the raw host settings directory directly + expect(spawn).toHaveBeenNthCalledWith( + 2, + 'docker', + expect.not.arrayContaining(['/home/user/.gemini:/home/node/.gemini']), + expect.any(Object), + ); + + // Verify that docker run mounts the isolated sanitized settings directory + expect(spawn).toHaveBeenNthCalledWith( + 2, + 'docker', + expect.arrayContaining([ + '--volume', + expect.stringMatching( + /gemini-sandbox.*:[\\/]home[\\/]node[\\/]\.gemini:rw$/, + ), + ]), + expect.any(Object), + ); + + // Verify that ephemeral sandbox directory permissions are restricted to owner (0o700) + expect(fs.chmodSync).toHaveBeenCalledWith( + expect.stringContaining('gemini-sandbox-'), + 0o700, + ); + }); + + it('should tolerate chmod errors on non-POSIX filesystems without crashing', async () => { + const config: SandboxConfig = createMockSandboxConfig({ + command: 'docker', + image: 'gemini-cli-sandbox', + }); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.chmodSync).mockImplementationOnce(() => { + throw new Error('EPERM: operation not permitted'); + }); + + interface MockProcessWithStdout extends EventEmitter { + stdout: EventEmitter; + } + const mockImageCheckProcess = new EventEmitter() as MockProcessWithStdout; + mockImageCheckProcess.stdout = new EventEmitter(); + vi.mocked(spawn).mockImplementationOnce(() => { + setTimeout(() => { + mockImageCheckProcess.stdout.emit('data', Buffer.from('image-id')); + mockImageCheckProcess.emit('close', 0); + }, 1); + return mockImageCheckProcess as unknown as ReturnType; + }); + + const mockSpawnProcess = new EventEmitter() as unknown as ReturnType< + typeof spawn + >; + mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => { + if (event === 'close') { + setTimeout(() => cb(0), 10); + } + return mockSpawnProcess; + }); + vi.mocked(spawn).mockImplementationOnce(() => mockSpawnProcess); + + await expect(start_sandbox(config)).resolves.toBe(0); + }); + + it('should handle allowedPaths in Docker', async () => { + const config: SandboxConfig = createMockSandboxConfig({ + command: 'docker', + image: 'gemini-cli-sandbox', + allowedPaths: ['/extra/path'], + }); + vi.mocked(fs.existsSync).mockReturnValue(true); + + // Mock image check to return true + interface MockProcessWithStdout extends EventEmitter { + stdout: EventEmitter; + } + const mockImageCheckProcess = new EventEmitter() as MockProcessWithStdout; + mockImageCheckProcess.stdout = new EventEmitter(); + vi.mocked(spawn).mockImplementationOnce(() => { + setTimeout(() => { + mockImageCheckProcess.stdout.emit('data', Buffer.from('image-id')); + mockImageCheckProcess.emit('close', 0); + }, 1); + return mockImageCheckProcess as unknown as ReturnType; + }); + + const mockSpawnProcess = new EventEmitter() as unknown as ReturnType< + typeof spawn + >; + mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => { + if (event === 'close') { + setTimeout(() => cb(0), 10); + } + return mockSpawnProcess; + }); + vi.mocked(spawn).mockImplementationOnce(() => mockSpawnProcess); + + await start_sandbox(config); + + expect(spawn).toHaveBeenCalledWith( + 'docker', + expect.arrayContaining(['--volume', '/extra/path:/extra/path:ro']), + expect.any(Object), + ); + }); + + it('should reject sensitive host mounts in SANDBOX_MOUNTS with FatalSandboxError', async () => { + const config: SandboxConfig = createMockSandboxConfig({ + command: 'docker', + image: 'gemini-cli-sandbox', + }); + vi.stubEnv('SANDBOX_MOUNTS', '/home/user/.gemini:/mnt:rw'); + + interface MockProcessWithStdout extends EventEmitter { + stdout: EventEmitter; + } + const mockImageCheckProcess = new EventEmitter() as MockProcessWithStdout; + mockImageCheckProcess.stdout = new EventEmitter(); + vi.mocked(spawn).mockImplementationOnce(() => { + setTimeout(() => { + mockImageCheckProcess.stdout.emit('data', Buffer.from('image-id')); + mockImageCheckProcess.emit('close', 0); + }, 1); + return mockImageCheckProcess as unknown as ReturnType; + }); + + await expect(start_sandbox(config)).rejects.toThrow(FatalSandboxError); + }); + + it('should reject running container sandbox from a sensitive working directory with FatalSandboxError', async () => { + const config: SandboxConfig = createMockSandboxConfig({ + command: 'docker', + image: 'gemini-cli-sandbox', + }); + const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue('/home/user'); + + try { + interface MockProcessWithStdout extends EventEmitter { + stdout: EventEmitter; + } + const mockImageCheckProcess = + new EventEmitter() as MockProcessWithStdout; + mockImageCheckProcess.stdout = new EventEmitter(); + vi.mocked(spawn).mockImplementationOnce(() => { + setTimeout(() => { + mockImageCheckProcess.stdout.emit('data', Buffer.from('image-id')); + mockImageCheckProcess.emit('close', 0); + }, 1); + return mockImageCheckProcess as unknown as ReturnType; + }); + + const targetWorkdir = path.resolve('/home/user'); + await expect(start_sandbox(config)).rejects.toThrow( + new RegExp( + `Running sandbox from a sensitive host directory '${targetWorkdir.replace(/\\/g, '\\\\')}' is strictly prohibited`, + ), + ); + } finally { + cwdSpy.mockRestore(); + } + }); + + it('should reject running container sandbox when cliConfig targetDir is sensitive with FatalSandboxError', async () => { + const config: SandboxConfig = createMockSandboxConfig({ + command: 'docker', + image: 'gemini-cli-sandbox', + }); + const targetDir = path.resolve('/home/user/.gemini'); + const mockCliConfig = { + getTargetDir: vi.fn().mockReturnValue(targetDir), + getDebugMode: vi.fn().mockReturnValue(false), + } as unknown as Config; + + interface MockProcessWithStdout extends EventEmitter { + stdout: EventEmitter; + } + const mockImageCheckProcess = new EventEmitter() as MockProcessWithStdout; + mockImageCheckProcess.stdout = new EventEmitter(); + vi.mocked(spawn).mockImplementationOnce(() => { + setTimeout(() => { + mockImageCheckProcess.stdout.emit('data', Buffer.from('image-id')); + mockImageCheckProcess.emit('close', 0); + }, 1); + return mockImageCheckProcess as unknown as ReturnType; + }); + + await expect(start_sandbox(config, [], mockCliConfig)).rejects.toThrow( + new RegExp( + `Running sandbox from a sensitive host directory '${targetDir.replace(/\\/g, '\\\\')}' is strictly prohibited`, + ), + ); + }); + + it('should reject running macOS seatbelt sandbox from a sensitive working directory with FatalSandboxError', async () => { + vi.mocked(os.platform).mockReturnValue('darwin'); + const config: SandboxConfig = createMockSandboxConfig({ + command: 'sandbox-exec', + image: 'some-image', + }); + const targetDir = path.resolve('/home/user'); + const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(targetDir); + + try { + await expect(start_sandbox(config)).rejects.toThrow( + new RegExp( + `Running sandbox from a sensitive host directory '${targetDir.replace(/\\/g, '\\\\')}' is strictly prohibited`, + ), + ); + } finally { + cwdSpy.mockRestore(); + } + }); + + it('should reject running LXC sandbox from a sensitive working directory with FatalSandboxError', async () => { + const config: SandboxConfig = createMockSandboxConfig({ + command: 'lxc', + image: 'gemini-sandbox', + }); + const targetDir = path.resolve('/home/user'); + const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(targetDir); + + try { + await expect(start_sandbox(config)).rejects.toThrow( + new RegExp( + `Running sandbox from a sensitive host directory '${targetDir.replace(/\\/g, '\\\\')}' is strictly prohibited`, + ), + ); + } finally { + cwdSpy.mockRestore(); + } + }); + + it('should skip sensitive workspace directory in macOS seatbelt includedDirs', async () => { + vi.mocked(os.platform).mockReturnValue('darwin'); + const config: SandboxConfig = createMockSandboxConfig({ + command: 'sandbox-exec', + image: 'some-image', + }); + const safeWorkspace = path.resolve('/safe/workspace'); + const safeOther = path.resolve('/safe/other'); + const sensitiveUser = path.resolve('/home/user'); + const mockCliConfig = { + getTargetDir: vi.fn().mockReturnValue(safeWorkspace), + getDebugMode: vi.fn().mockReturnValue(false), + getWorkspaceContext: vi.fn().mockReturnValue({ + getDirectories: vi + .fn() + .mockReturnValue([safeWorkspace, sensitiveUser, safeOther]), + }), + } as unknown as Config; + + interface MockProcess extends EventEmitter { + stdout: EventEmitter; + stderr: EventEmitter; + } + const mockSpawnProcess = new EventEmitter() as MockProcess; + mockSpawnProcess.stdout = new EventEmitter(); + mockSpawnProcess.stderr = new EventEmitter(); + vi.mocked(spawn).mockReturnValue( + mockSpawnProcess as unknown as ReturnType, + ); + + const promise = start_sandbox(config, [], mockCliConfig); + setTimeout(() => mockSpawnProcess.emit('close', 0), 10); + await promise; + + const spawnArgs = vi.mocked(spawn).mock.calls[0][1] as string[]; + expect(spawnArgs).toContain(`INCLUDE_DIR_0=${safeOther}`); + const includeDirs = spawnArgs.filter((arg) => + arg.startsWith('INCLUDE_DIR_'), + ); + expect(includeDirs.some((arg) => arg.includes(sensitiveUser))).toBe( + false, + ); + }); + + it('should ignore sensitive paths in allowedPaths', async () => { + const config: SandboxConfig = createMockSandboxConfig({ + command: 'docker', + image: 'gemini-cli-sandbox', + allowedPaths: ['/home/user/.gemini', '/safe/extra/path'], + }); + vi.mocked(fs.existsSync).mockReturnValue(true); + + interface MockProcessWithStdout extends EventEmitter { + stdout: EventEmitter; + } + const mockImageCheckProcess = new EventEmitter() as MockProcessWithStdout; + mockImageCheckProcess.stdout = new EventEmitter(); + vi.mocked(spawn).mockImplementationOnce(() => { + setTimeout(() => { + mockImageCheckProcess.stdout.emit('data', Buffer.from('image-id')); + mockImageCheckProcess.emit('close', 0); + }, 1); + return mockImageCheckProcess as unknown as ReturnType; + }); + + const mockSpawnProcess = new EventEmitter() as unknown as ReturnType< + typeof spawn + >; + mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => { + if (event === 'close') { + setTimeout(() => cb(0), 10); + } + return mockSpawnProcess; + }); + vi.mocked(spawn).mockImplementationOnce(() => mockSpawnProcess); + + await start_sandbox(config); + + const runCalls = vi + .mocked(spawn) + .mock.calls.filter( + (call) => + call[0] === 'docker' && + Array.isArray(call[1]) && + call[1].includes('run'), + ); + expect(runCalls.length).toBe(1); + const runArgs = runCalls[0][1] as string[]; + expect(runArgs).toContain('/safe/extra/path:/safe/extra/path:ro'); + expect( + runArgs.some( + (arg) => + typeof arg === 'string' && arg.includes('/home/user/.gemini:'), + ), + ).toBe(false); + }); + + it('should handle networkAccess: false in Docker', async () => { + const config: SandboxConfig = createMockSandboxConfig({ + command: 'docker', + image: 'gemini-cli-sandbox', + networkAccess: false, + }); + + // Mock image check + interface MockProcessWithStdout extends EventEmitter { + stdout: EventEmitter; + } + const mockImageCheckProcess = new EventEmitter() as MockProcessWithStdout; + mockImageCheckProcess.stdout = new EventEmitter(); + vi.mocked(spawn).mockImplementationOnce(() => { + setTimeout(() => { + mockImageCheckProcess.stdout.emit('data', Buffer.from('image-id')); + mockImageCheckProcess.emit('close', 0); + }, 1); + return mockImageCheckProcess as unknown as ReturnType; + }); + + const mockSpawnProcess = new EventEmitter() as unknown as ReturnType< + typeof spawn + >; + mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => { + if (event === 'close') { + setTimeout(() => cb(0), 10); + } + return mockSpawnProcess; + }); + vi.mocked(spawn).mockImplementationOnce(() => mockSpawnProcess); + + await start_sandbox(config); + + expect(execSync).toHaveBeenCalledWith( + expect.stringContaining('network create --internal gemini-cli-sandbox'), + expect.any(Object), + ); + expect(spawn).toHaveBeenCalledWith( + 'docker', + expect.arrayContaining(['--network', 'gemini-cli-sandbox']), + expect.any(Object), + ); + }); + + it('should handle allowedPaths in macOS seatbelt', async () => { + vi.mocked(os.platform).mockReturnValue('darwin'); + const config: SandboxConfig = createMockSandboxConfig({ + command: 'sandbox-exec', + image: 'some-image', + allowedPaths: ['/Users/user/extra'], + }); + vi.mocked(fs.existsSync).mockReturnValue(true); + + interface MockProcess extends EventEmitter { + stdout: EventEmitter; + stderr: EventEmitter; + } + const mockSpawnProcess = new EventEmitter() as MockProcess; + mockSpawnProcess.stdout = new EventEmitter(); + mockSpawnProcess.stderr = new EventEmitter(); + vi.mocked(spawn).mockReturnValue( + mockSpawnProcess as unknown as ReturnType, + ); + + const promise = start_sandbox(config); + setTimeout(() => mockSpawnProcess.emit('close', 0), 10); + await promise; + + // Check that the extra path is passed as an INCLUDE_DIR_X argument + expect(spawn).toHaveBeenCalledWith( + 'sandbox-exec', + expect.arrayContaining(['INCLUDE_DIR_0=/Users/user/extra']), + expect.any(Object), + ); + }); + + it('should pass through GOOGLE_GEMINI_BASE_URL and GOOGLE_VERTEX_BASE_URL', async () => { + const config: SandboxConfig = createMockSandboxConfig({ + command: 'docker', + image: 'gemini-cli-sandbox', + }); + process.env['GOOGLE_GEMINI_BASE_URL'] = 'http://gemini.proxy'; + process.env['GOOGLE_VERTEX_BASE_URL'] = 'http://vertex.proxy'; + + // Mock image check to return true + interface MockProcessWithStdout extends EventEmitter { + stdout: EventEmitter; + } + const mockImageCheckProcess = new EventEmitter() as MockProcessWithStdout; + mockImageCheckProcess.stdout = new EventEmitter(); + vi.mocked(spawn).mockImplementationOnce(() => { + setTimeout(() => { + mockImageCheckProcess.stdout.emit('data', Buffer.from('image-id')); + mockImageCheckProcess.emit('close', 0); + }, 1); + return mockImageCheckProcess as unknown as ReturnType; + }); + + const mockSpawnProcess = new EventEmitter() as unknown as ReturnType< + typeof spawn + >; + mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => { + if (event === 'close') { + setTimeout(() => cb(0), 10); + } + return mockSpawnProcess; + }); + vi.mocked(spawn).mockImplementationOnce(() => mockSpawnProcess); + + await start_sandbox(config); + + expect(spawn).toHaveBeenCalledWith( + 'docker', + expect.arrayContaining([ + '--env', + 'GOOGLE_GEMINI_BASE_URL=http://gemini.proxy', + '--env', + 'GOOGLE_VERTEX_BASE_URL=http://vertex.proxy', + ]), + expect.any(Object), + ); + }); + + it('should handle user creation on Linux if needed', async () => { + const config: SandboxConfig = createMockSandboxConfig({ + command: 'docker', + image: 'gemini-cli-sandbox', + }); + process.env['SANDBOX_SET_UID_GID'] = 'true'; + vi.mocked(os.platform).mockReturnValue('linux'); + vi.mocked(execSync).mockImplementation((cmd) => { + if (cmd === 'id -u') return Buffer.from('1000'); + if (cmd === 'id -g') return Buffer.from('1000'); + return Buffer.from(''); + }); + + // Mock image check to return true + interface MockProcessWithStdout extends EventEmitter { + stdout: EventEmitter; + } + const mockImageCheckProcess = new EventEmitter() as MockProcessWithStdout; + mockImageCheckProcess.stdout = new EventEmitter(); + vi.mocked(spawn).mockImplementationOnce(() => { + setTimeout(() => { + mockImageCheckProcess.stdout.emit('data', Buffer.from('image-id')); + mockImageCheckProcess.emit('close', 0); + }, 1); + return mockImageCheckProcess as unknown as ReturnType; + }); + + const mockSpawnProcess = new EventEmitter() as unknown as ReturnType< + typeof spawn + >; + mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => { + if (event === 'close') { + setTimeout(() => cb(0), 10); + } + return mockSpawnProcess; + }); + vi.mocked(spawn).mockImplementationOnce(() => mockSpawnProcess); + + await start_sandbox(config); + + expect(spawn).toHaveBeenCalledWith( + 'docker', + expect.arrayContaining(['--user', 'root', '--env', 'HOME=/home/node']), + expect.any(Object), + ); + // Check that the entrypoint command includes the defensive useradd check + const args = vi.mocked(spawn).mock.calls[1][1] as string[]; + const entrypointCmd = args[args.length - 1]; + expect(entrypointCmd).toContain('if command -v useradd'); + expect(entrypointCmd).toContain('groupadd -g 1000 -o gemini'); + expect(entrypointCmd).toContain('id 1000'); + expect(entrypointCmd).toContain('useradd -o -u 1000'); + expect(entrypointCmd).toContain('USER_NAME=$(id -nu 1000 2>/dev/null);'); + expect(entrypointCmd).toContain('if [ -n "$USER_NAME" ]; then'); + expect(entrypointCmd).toContain('su -p "$USER_NAME"'); + expect(entrypointCmd).toContain('else'); + expect(entrypointCmd).toContain('Error: Failed to map host UID 1000'); + expect(entrypointCmd).toContain('exit 1'); + expect(entrypointCmd).toContain("Error: 'useradd' not found"); + }); + + it('should correctly escape home directory with spaces and special characters', async () => { + const config: SandboxConfig = createMockSandboxConfig({ + command: 'docker', + image: 'gemini-cli-sandbox', + }); + process.env['SANDBOX_SET_UID_GID'] = 'true'; + vi.mocked(os.platform).mockReturnValue('linux'); + + const specialHome = '/home/user name `$(id)`'; + mockedHomedir.mockReturnValue(specialHome); + mockedGetContainerPath.mockImplementation((p: string) => p); + + // Mock image check to return true + interface MockProcessWithStdout extends EventEmitter { + stdout: EventEmitter; + } + const mockImageCheckProcess = new EventEmitter() as MockProcessWithStdout; + mockImageCheckProcess.stdout = new EventEmitter(); + vi.mocked(spawn).mockImplementationOnce(() => { + setTimeout(() => { + mockImageCheckProcess.stdout.emit('data', Buffer.from('image-id')); + mockImageCheckProcess.emit('close', 0); + }, 1); + return mockImageCheckProcess as unknown as ReturnType; + }); + + const mockSpawnProcess = new EventEmitter() as unknown as ReturnType< + typeof spawn + >; + mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => { + if (event === 'close') { + setTimeout(() => cb(0), 10); + } + return mockSpawnProcess; + }); + vi.mocked(spawn).mockImplementationOnce(() => mockSpawnProcess); + + await start_sandbox(config); + + const args = vi.mocked(spawn).mock.calls[1][1] as string[]; + const entrypointCmd = args[args.length - 1]; + + // Verify that the special home directory is properly quoted/escaped + // The quote tool should handle spaces and backticks + expect(entrypointCmd).toContain("'/home/user name `$(id)`'"); + }); + + it('should register and unregister proxy exit handlers', async () => { + vi.stubEnv('GEMINI_SANDBOX_PROXY_COMMAND', 'some-proxy-cmd'); + const config: SandboxConfig = createMockSandboxConfig({ + command: 'docker', + image: 'gemini-cli-sandbox', + }); + + const onSpy = vi.spyOn(process, 'on'); + const offSpy = vi.spyOn(process, 'off'); + + interface MockProcessWithStdout extends EventEmitter { + stdout: EventEmitter; + } + + vi.mocked(spawn).mockImplementation((cmd, args) => { + const a = args as string[]; + if (cmd === 'docker' && a && a[0] === 'images') { + const mockImageCheckProcess = + new EventEmitter() as MockProcessWithStdout; + mockImageCheckProcess.stdout = new EventEmitter(); + setTimeout(() => { + mockImageCheckProcess.stdout.emit('data', Buffer.from('image-id')); + mockImageCheckProcess.emit('close', 0); + }, 1); + return mockImageCheckProcess as unknown as ReturnType; + } + if (cmd === 'docker' && a && a[0] === 'run') { + const mockSpawnProcess = new EventEmitter() as unknown as ReturnType< + typeof spawn + >; + mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => { + if (event === 'close') { + if (a.includes('gemini-cli-sandbox-proxy')) { + // Proxy container shouldn't exit during the test + } else { + setTimeout(() => cb(0), 10); + } + } + return mockSpawnProcess; + }); + return mockSpawnProcess; + } + return new EventEmitter() as unknown as ReturnType; + }); + + await start_sandbox(config); + + expect(onSpy).toHaveBeenCalledWith('exit', expect.any(Function)); + expect(onSpy).toHaveBeenCalledWith('SIGINT', expect.any(Function)); + expect(onSpy).toHaveBeenCalledWith('SIGTERM', expect.any(Function)); + + expect(offSpy).toHaveBeenCalledWith('exit', expect.any(Function)); + expect(offSpy).toHaveBeenCalledWith('SIGINT', expect.any(Function)); + expect(offSpy).toHaveBeenCalledWith('SIGTERM', expect.any(Function)); + + onSpy.mockRestore(); + offSpy.mockRestore(); + }); + + describe('LXC sandbox', () => { + const LXC_RUNNING = JSON.stringify([ + { name: 'gemini-sandbox', status: 'Running' }, + ]); + const LXC_STOPPED = JSON.stringify([ + { name: 'gemini-sandbox', status: 'Stopped' }, + ]); + + beforeEach(() => { + delete process.env['TEST_LXC_LIST_OUTPUT']; + }); + + it('should run lxc exec with correct args for a running container', async () => { + process.env['TEST_LXC_LIST_OUTPUT'] = LXC_RUNNING; + const config: SandboxConfig = createMockSandboxConfig({ + command: 'lxc', + image: 'gemini-sandbox', + }); + + const mockSpawnProcess = new EventEmitter() as unknown as ReturnType< + typeof spawn + >; + mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => { + if (event === 'close') { + setTimeout(() => cb(0), 10); + } + return mockSpawnProcess; + }); + + vi.mocked(spawn).mockImplementation((cmd) => { + if (cmd === 'lxc') { + return mockSpawnProcess; + } + return new EventEmitter() as unknown as ReturnType; + }); + + const promise = start_sandbox(config, [], undefined, ['arg1']); + await expect(promise).resolves.toBe(0); + + expect(spawn).toHaveBeenCalledWith( + 'lxc', + expect.arrayContaining(['exec', 'gemini-sandbox', '--cwd']), + expect.objectContaining({ stdio: 'inherit' }), + ); + }); + + it('should throw FatalSandboxError if lxc list fails', async () => { + process.env['TEST_LXC_LIST_OUTPUT'] = 'throw'; + const config: SandboxConfig = createMockSandboxConfig({ + command: 'lxc', + image: 'gemini-sandbox', + }); + + await expect(start_sandbox(config)).rejects.toThrow( + /Failed to query LXC container/, + ); + }); + + it('should throw FatalSandboxError if container is not running', async () => { + process.env['TEST_LXC_LIST_OUTPUT'] = LXC_STOPPED; + const config: SandboxConfig = createMockSandboxConfig({ + command: 'lxc', + image: 'gemini-sandbox', + }); + + await expect(start_sandbox(config)).rejects.toThrow(/is not running/); + }); + + it('should throw FatalSandboxError if container is not found in list', async () => { + process.env['TEST_LXC_LIST_OUTPUT'] = '[]'; + const config: SandboxConfig = createMockSandboxConfig({ + command: 'lxc', + image: 'gemini-sandbox', + }); + + await expect(start_sandbox(config)).rejects.toThrow(/not found/); + }); + }); + }); + + describe('gVisor (runsc)', () => { + it('should use docker with --runtime=runsc on Linux', async () => { + vi.mocked(os.platform).mockReturnValue('linux'); + const config: SandboxConfig = createMockSandboxConfig({ + command: 'runsc', + image: 'gemini-cli-sandbox', + }); + + // Mock image check + interface MockProcessWithStdout extends EventEmitter { + stdout: EventEmitter; + } + const mockImageCheckProcess = new EventEmitter() as MockProcessWithStdout; + mockImageCheckProcess.stdout = new EventEmitter(); + vi.mocked(spawn).mockImplementationOnce(() => { + setTimeout(() => { + mockImageCheckProcess.stdout.emit('data', Buffer.from('image-id')); + mockImageCheckProcess.emit('close', 0); + }, 1); + return mockImageCheckProcess as unknown as ReturnType; + }); + + // Mock docker run + const mockSpawnProcess = new EventEmitter() as unknown as ReturnType< + typeof spawn + >; + mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => { + if (event === 'close') { + setTimeout(() => cb(0), 10); + } + return mockSpawnProcess; + }); + vi.mocked(spawn).mockImplementationOnce(() => mockSpawnProcess); + + await start_sandbox(config, [], undefined, ['arg1']); + + // Verify docker (not runsc) is called for image check + expect(spawn).toHaveBeenNthCalledWith( + 1, + 'docker', + expect.arrayContaining(['images', '-q', 'gemini-cli-sandbox']), + ); + + // Verify docker run includes --runtime=runsc + expect(spawn).toHaveBeenNthCalledWith( + 2, + 'docker', + expect.arrayContaining(['run', '--runtime=runsc']), + expect.objectContaining({ stdio: 'inherit' }), + ); + }); + }); +}); diff --git a/packages/cli/src/utils/sandboxBuiltinProfiles.ts b/packages/cli/src/utils/sandboxBuiltinProfiles.ts new file mode 100644 index 0000000000000000000000000000000000000000..c98b972f17c919e1f414f75abe639d72d2871751 --- /dev/null +++ b/packages/cli/src/utils/sandboxBuiltinProfiles.ts @@ -0,0 +1,991 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export const BUILTIN_SEATBELT_PROFILE_CONTENTS: Record = { + 'permissive-open': `(version 1) +(deny default) +(allow file-read*) +(allow process-exec) +(allow process-fork) +(allow signal (target self)) +(allow sysctl-read + (sysctl-name "hw.activecpu") + (sysctl-name "hw.busfrequency_compat") + (sysctl-name "hw.byteorder") + (sysctl-name "hw.cacheconfig") + (sysctl-name "hw.cachelinesize_compat") + (sysctl-name "hw.cpufamily") + (sysctl-name "hw.cpufrequency_compat") + (sysctl-name "hw.cputype") + (sysctl-name "hw.l1dcachesize_compat") + (sysctl-name "hw.l1icachesize_compat") + (sysctl-name "hw.l2cachesize_compat") + (sysctl-name "hw.l3cachesize_compat") + (sysctl-name "hw.logicalcpu_max") + (sysctl-name "hw.machine") + (sysctl-name "hw.ncpu") + (sysctl-name "hw.nperflevels") + (sysctl-name "hw.optional.arm.FEAT_BF16") + (sysctl-name "hw.optional.arm.FEAT_DotProd") + (sysctl-name "hw.optional.arm.FEAT_FCMA") + (sysctl-name "hw.optional.arm.FEAT_FHM") + (sysctl-name "hw.optional.arm.FEAT_FP16") + (sysctl-name "hw.optional.arm.FEAT_I8MM") + (sysctl-name "hw.optional.arm.FEAT_JSCVT") + (sysctl-name "hw.optional.arm.FEAT_LSE") + (sysctl-name "hw.optional.arm.FEAT_RDM") + (sysctl-name "hw.optional.arm.FEAT_SHA512") + (sysctl-name "hw.optional.armv8_2_sha512") + (sysctl-name "hw.packages") + (sysctl-name "hw.pagesize_compat") + (sysctl-name "hw.physicalcpu_max") + (sysctl-name "hw.tbfrequency_compat") + (sysctl-name "hw.vectorunit") + (sysctl-name "kern.hostname") + (sysctl-name "kern.maxfilesperproc") + (sysctl-name "kern.osproductversion") + (sysctl-name "kern.osrelease") + (sysctl-name "kern.ostype") + (sysctl-name "kern.osvariant_status") + (sysctl-name "kern.osversion") + (sysctl-name "kern.secure_kernel") + (sysctl-name "kern.usrstack64") + (sysctl-name "kern.version") + (sysctl-name "sysctl.proc_cputype") + (sysctl-name-prefix "hw.perflevel") +) +(allow file-write* + (subpath (param "TARGET_DIR")) + (subpath (param "TMP_DIR")) + (subpath (param "CACHE_DIR")) + (subpath (string-append (param "HOME_DIR") "/.npm")) + (subpath (string-append (param "HOME_DIR") "/.cache")) + (subpath (param "INCLUDE_DIR_0")) + (subpath (param "INCLUDE_DIR_1")) + (subpath (param "INCLUDE_DIR_2")) + (subpath (param "INCLUDE_DIR_3")) + (subpath (param "INCLUDE_DIR_4")) + (literal "/dev/stdout") + (literal "/dev/stderr") + (literal "/dev/null") + (literal "/dev/ptmx") + (regex #"^/dev/ttys[0-9]*$") +) +(allow mach-lookup + (global-name "com.apple.sysmond") + (global-name "com.apple.system.opendirectoryd.libinfo") + (global-name "com.apple.system.opendirectoryd.membership") + (global-name "com.apple.bsd.dirhelper") + (global-name "com.apple.SecurityServer") + (global-name "com.apple.networkd") + (global-name "com.apple.ocspd") + (global-name "com.apple.trustd") + (global-name "com.apple.trustd.agent") + (global-name "com.apple.mDNSResponder") + (global-name "com.apple.mDNSResponderHelper") + (global-name "com.apple.SystemConfiguration.DNSConfiguration") + (global-name "com.apple.SystemConfiguration.configd") +) +(allow system-socket + (require-all + (socket-domain AF_SYSTEM) + (socket-protocol 2) + ) +) +(allow file-ioctl (regex #"^/dev/tty.*")) +(allow network-inbound (local ip "*:*")) +(allow network-bind (local ip "*:*")) +(allow network-outbound) +(deny file-read* file-write* + (literal "/var/run/docker.sock") + (literal "/var/run/docker.sock.raw") + (literal "/private/var/run/docker.sock") + (literal "/private/var/run/docker.sock.raw") + (subpath "/var/run/docker") + (subpath "/private/var/run/docker") + (subpath "/Users/Shared/.docker") + (subpath (string-append (param "HOME_DIR") "/.docker/run")) + (subpath (string-append (param "HOME_DIR") "/.docker/desktop")) + (subpath (string-append (param "HOME_DIR") "/.colima")) + (subpath (string-append (param "HOME_DIR") "/.orbstack/run")) + (subpath (string-append (param "HOME_DIR") "/.rd")) +) +(deny process-exec + (literal "/usr/local/bin/docker") + (literal "/usr/local/bin/dockerd") + (literal "/usr/local/bin/docker-compose") + (literal "/usr/bin/docker") + (literal "/opt/homebrew/bin/docker") + (literal "/opt/homebrew/bin/dockerd") + (literal "/opt/homebrew/bin/docker-compose") + (literal "/opt/homebrew/bin/podman") + (literal "/usr/local/bin/podman") + (literal "/opt/homebrew/bin/colima") + (literal "/usr/local/bin/colima") + (literal "/opt/homebrew/bin/orb") + (literal "/Applications/Docker.app/Contents/Resources/bin/docker") + (subpath "/Applications/Docker.app/Contents/MacOS/") + (subpath "/Applications/OrbStack.app/Contents/MacOS/") + (subpath "/Applications/Rancher Desktop.app/Contents/MacOS/") +) +(deny mach-lookup + (xpc-service-name-prefix "com.docker.") + (global-name-prefix "com.docker.") + (global-name-prefix "dev.kdrag0n.OrbStack") +) +(deny ipc-posix-shm* + (ipc-posix-name-prefix "docker") + (ipc-posix-name-prefix "com.docker.") +) +(deny file-write* + (subpath (string-append (param "HOME_DIR") "/.gemini")) + (regex #"/oauth_creds\\.json$") + (regex #"/gemini-credentials\\.json$") + (regex #"/mcp-oauth-tokens\\.json$") + (regex #"/a2a-oauth-tokens\\.json$") + (regex #"/google_accounts\\.json$") + (regex #"/trusted_hooks\\.json$") + (regex #"/trustedFolders\\.json$") + (regex #"/policy_integrity\\.json$") + (regex #"/\\.env($|\\..*)") +) +(deny file-read* + (literal (string-append (param "HOME_DIR") "/.gemini/oauth_creds.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/gemini-credentials.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/mcp-oauth-tokens.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/a2a-oauth-tokens.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/google_accounts.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/trusted_hooks.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/trustedFolders.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/policy_integrity.json")) + (regex #"/oauth_creds\\.json$") + (regex #"/gemini-credentials\\.json$") + (regex #"/mcp-oauth-tokens\\.json$") + (regex #"/a2a-oauth-tokens\\.json$") + (regex #"/google_accounts\\.json$") + (regex #"/trusted_hooks\\.json$") + (regex #"/trustedFolders\\.json$") + (regex #"/policy_integrity\\.json$") + (regex #"/\\.env($|\\..*)") +)`, + + 'permissive-proxied': `(version 1) +(deny default) +(allow file-read*) +(allow process-exec) +(allow process-fork) +(allow signal (target self)) +(allow sysctl-read + (sysctl-name "hw.activecpu") + (sysctl-name "hw.busfrequency_compat") + (sysctl-name "hw.byteorder") + (sysctl-name "hw.cacheconfig") + (sysctl-name "hw.cachelinesize_compat") + (sysctl-name "hw.cpufamily") + (sysctl-name "hw.cpufrequency_compat") + (sysctl-name "hw.cputype") + (sysctl-name "hw.l1dcachesize_compat") + (sysctl-name "hw.l1icachesize_compat") + (sysctl-name "hw.l2cachesize_compat") + (sysctl-name "hw.l3cachesize_compat") + (sysctl-name "hw.logicalcpu_max") + (sysctl-name "hw.machine") + (sysctl-name "hw.ncpu") + (sysctl-name "hw.nperflevels") + (sysctl-name "hw.optional.arm.FEAT_BF16") + (sysctl-name "hw.optional.arm.FEAT_DotProd") + (sysctl-name "hw.optional.arm.FEAT_FCMA") + (sysctl-name "hw.optional.arm.FEAT_FHM") + (sysctl-name "hw.optional.arm.FEAT_FP16") + (sysctl-name "hw.optional.arm.FEAT_I8MM") + (sysctl-name "hw.optional.arm.FEAT_JSCVT") + (sysctl-name "hw.optional.arm.FEAT_LSE") + (sysctl-name "hw.optional.arm.FEAT_RDM") + (sysctl-name "hw.optional.arm.FEAT_SHA512") + (sysctl-name "hw.optional.armv8_2_sha512") + (sysctl-name "hw.packages") + (sysctl-name "hw.pagesize_compat") + (sysctl-name "hw.physicalcpu_max") + (sysctl-name "hw.tbfrequency_compat") + (sysctl-name "hw.vectorunit") + (sysctl-name "kern.hostname") + (sysctl-name "kern.maxfilesperproc") + (sysctl-name "kern.osproductversion") + (sysctl-name "kern.osrelease") + (sysctl-name "kern.ostype") + (sysctl-name "kern.osvariant_status") + (sysctl-name "kern.osversion") + (sysctl-name "kern.secure_kernel") + (sysctl-name "kern.usrstack64") + (sysctl-name "kern.version") + (sysctl-name "sysctl.proc_cputype") + (sysctl-name-prefix "hw.perflevel") +) +(allow file-write* + (subpath (param "TARGET_DIR")) + (subpath (param "TMP_DIR")) + (subpath (param "CACHE_DIR")) + (subpath (string-append (param "HOME_DIR") "/.npm")) + (subpath (string-append (param "HOME_DIR") "/.cache")) + (subpath (param "INCLUDE_DIR_0")) + (subpath (param "INCLUDE_DIR_1")) + (subpath (param "INCLUDE_DIR_2")) + (subpath (param "INCLUDE_DIR_3")) + (subpath (param "INCLUDE_DIR_4")) + (literal "/dev/stdout") + (literal "/dev/stderr") + (literal "/dev/null") + (literal "/dev/ptmx") + (regex #"^/dev/ttys[0-9]*$") +) +(allow mach-lookup + (global-name "com.apple.sysmond") + (global-name "com.apple.system.opendirectoryd.libinfo") + (global-name "com.apple.system.opendirectoryd.membership") + (global-name "com.apple.bsd.dirhelper") + (global-name "com.apple.SecurityServer") + (global-name "com.apple.networkd") + (global-name "com.apple.ocspd") + (global-name "com.apple.trustd") + (global-name "com.apple.trustd.agent") + (global-name "com.apple.mDNSResponder") + (global-name "com.apple.mDNSResponderHelper") + (global-name "com.apple.SystemConfiguration.DNSConfiguration") + (global-name "com.apple.SystemConfiguration.configd") +) +(allow system-socket + (require-all + (socket-domain AF_SYSTEM) + (socket-protocol 2) + ) +) +(allow file-ioctl (regex #"^/dev/tty.*")) +(allow network-inbound (local ip "localhost:9229")) +(allow network-bind (local ip "*:*")) +(allow network-outbound (remote tcp "localhost:8877")) +(deny file-read* file-write* + (literal "/var/run/docker.sock") + (literal "/var/run/docker.sock.raw") + (literal "/private/var/run/docker.sock") + (literal "/private/var/run/docker.sock.raw") + (subpath "/var/run/docker") + (subpath "/private/var/run/docker") + (subpath "/Users/Shared/.docker") + (subpath (string-append (param "HOME_DIR") "/.docker/run")) + (subpath (string-append (param "HOME_DIR") "/.docker/desktop")) + (subpath (string-append (param "HOME_DIR") "/.colima")) + (subpath (string-append (param "HOME_DIR") "/.orbstack/run")) + (subpath (string-append (param "HOME_DIR") "/.rd")) +) +(deny process-exec + (literal "/usr/local/bin/docker") + (literal "/usr/local/bin/dockerd") + (literal "/usr/local/bin/docker-compose") + (literal "/usr/bin/docker") + (literal "/opt/homebrew/bin/docker") + (literal "/opt/homebrew/bin/dockerd") + (literal "/opt/homebrew/bin/docker-compose") + (literal "/opt/homebrew/bin/podman") + (literal "/usr/local/bin/podman") + (literal "/opt/homebrew/bin/colima") + (literal "/usr/local/bin/colima") + (literal "/opt/homebrew/bin/orb") + (literal "/Applications/Docker.app/Contents/Resources/bin/docker") + (subpath "/Applications/Docker.app/Contents/MacOS/") + (subpath "/Applications/OrbStack.app/Contents/MacOS/") + (subpath "/Applications/Rancher Desktop.app/Contents/MacOS/") +) +(deny mach-lookup + (xpc-service-name-prefix "com.docker.") + (global-name-prefix "com.docker.") + (global-name-prefix "dev.kdrag0n.OrbStack") +) +(deny ipc-posix-shm* + (ipc-posix-name-prefix "docker") + (ipc-posix-name-prefix "com.docker.") +) +(deny file-write* + (subpath (string-append (param "HOME_DIR") "/.gemini")) + (regex #"/oauth_creds\\.json$") + (regex #"/gemini-credentials\\.json$") + (regex #"/mcp-oauth-tokens\\.json$") + (regex #"/a2a-oauth-tokens\\.json$") + (regex #"/google_accounts\\.json$") + (regex #"/trusted_hooks\\.json$") + (regex #"/trustedFolders\\.json$") + (regex #"/policy_integrity\\.json$") + (regex #"/\\.env($|\\..*)") +) +(deny file-read* + (literal (string-append (param "HOME_DIR") "/.gemini/oauth_creds.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/gemini-credentials.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/mcp-oauth-tokens.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/a2a-oauth-tokens.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/google_accounts.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/trusted_hooks.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/trustedFolders.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/policy_integrity.json")) + (regex #"/oauth_creds\\.json$") + (regex #"/gemini-credentials\\.json$") + (regex #"/mcp-oauth-tokens\\.json$") + (regex #"/a2a-oauth-tokens\\.json$") + (regex #"/google_accounts\\.json$") + (regex #"/trusted_hooks\\.json$") + (regex #"/trustedFolders\\.json$") + (regex #"/policy_integrity\\.json$") + (regex #"/\\.env($|\\..*)") +)`, + + 'restrictive-open': `(version 1) +(deny default) +(allow file-read*) +(allow file-read* + (literal (string-append (param "HOME_DIR") "/.gemini/settings.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/keybindings.json")) +) +(allow process-exec) +(allow process-fork) +(allow signal (target self)) +(allow sysctl-read + (sysctl-name "hw.activecpu") + (sysctl-name "hw.busfrequency_compat") + (sysctl-name "hw.byteorder") + (sysctl-name "hw.cacheconfig") + (sysctl-name "hw.cachelinesize_compat") + (sysctl-name "hw.cpufamily") + (sysctl-name "hw.cpufrequency_compat") + (sysctl-name "hw.cputype") + (sysctl-name "hw.l1dcachesize_compat") + (sysctl-name "hw.l1icachesize_compat") + (sysctl-name "hw.l2cachesize_compat") + (sysctl-name "hw.l3cachesize_compat") + (sysctl-name "hw.logicalcpu_max") + (sysctl-name "hw.machine") + (sysctl-name "hw.ncpu") + (sysctl-name "hw.nperflevels") + (sysctl-name "hw.optional.arm.FEAT_BF16") + (sysctl-name "hw.optional.arm.FEAT_DotProd") + (sysctl-name "hw.optional.arm.FEAT_FCMA") + (sysctl-name "hw.optional.arm.FEAT_FHM") + (sysctl-name "hw.optional.arm.FEAT_FP16") + (sysctl-name "hw.optional.arm.FEAT_I8MM") + (sysctl-name "hw.optional.arm.FEAT_JSCVT") + (sysctl-name "hw.optional.arm.FEAT_LSE") + (sysctl-name "hw.optional.arm.FEAT_RDM") + (sysctl-name "hw.optional.arm.FEAT_SHA512") + (sysctl-name "hw.optional.armv8_2_sha512") + (sysctl-name "hw.packages") + (sysctl-name "hw.pagesize_compat") + (sysctl-name "hw.physicalcpu_max") + (sysctl-name "hw.tbfrequency_compat") + (sysctl-name "hw.vectorunit") + (sysctl-name "kern.hostname") + (sysctl-name "kern.maxfilesperproc") + (sysctl-name "kern.osproductversion") + (sysctl-name "kern.osrelease") + (sysctl-name "kern.ostype") + (sysctl-name "kern.osvariant_status") + (sysctl-name "kern.osversion") + (sysctl-name "kern.secure_kernel") + (sysctl-name "kern.usrstack64") + (sysctl-name "kern.version") + (sysctl-name "sysctl.proc_cputype") + (sysctl-name-prefix "hw.perflevel") +) +(allow file-write* + (subpath (param "TARGET_DIR")) + (subpath (param "TMP_DIR")) + (subpath (param "CACHE_DIR")) + (subpath (string-append (param "HOME_DIR") "/.npm")) + (subpath (string-append (param "HOME_DIR") "/.cache")) + (subpath (param "INCLUDE_DIR_0")) + (subpath (param "INCLUDE_DIR_1")) + (subpath (param "INCLUDE_DIR_2")) + (subpath (param "INCLUDE_DIR_3")) + (subpath (param "INCLUDE_DIR_4")) + (literal "/dev/stdout") + (literal "/dev/stderr") + (literal "/dev/null") +) +(allow mach-lookup (global-name "com.apple.sysmond")) +(allow file-ioctl (regex #"^/dev/tty.*")) +(allow network-inbound (local ip "localhost:9229")) +(allow network-outbound) +(deny file-read* file-write* + (literal "/var/run/docker.sock") + (literal "/var/run/docker.sock.raw") + (literal "/private/var/run/docker.sock") + (literal "/private/var/run/docker.sock.raw") + (subpath "/var/run/docker") + (subpath "/private/var/run/docker") + (subpath "/Users/Shared/.docker") + (subpath (string-append (param "HOME_DIR") "/.docker/run")) + (subpath (string-append (param "HOME_DIR") "/.docker/desktop")) + (subpath (string-append (param "HOME_DIR") "/.colima")) + (subpath (string-append (param "HOME_DIR") "/.orbstack/run")) + (subpath (string-append (param "HOME_DIR") "/.rd")) +) +(deny process-exec + (literal "/usr/local/bin/docker") + (literal "/usr/local/bin/dockerd") + (literal "/usr/local/bin/docker-compose") + (literal "/usr/bin/docker") + (literal "/opt/homebrew/bin/docker") + (literal "/opt/homebrew/bin/dockerd") + (literal "/opt/homebrew/bin/docker-compose") + (literal "/opt/homebrew/bin/podman") + (literal "/usr/local/bin/podman") + (literal "/opt/homebrew/bin/colima") + (literal "/usr/local/bin/colima") + (literal "/opt/homebrew/bin/orb") + (literal "/Applications/Docker.app/Contents/Resources/bin/docker") + (subpath "/Applications/Docker.app/Contents/MacOS/") + (subpath "/Applications/OrbStack.app/Contents/MacOS/") + (subpath "/Applications/Rancher Desktop.app/Contents/MacOS/") +) +(deny mach-lookup + (xpc-service-name-prefix "com.docker.") + (global-name-prefix "com.docker.") + (global-name-prefix "dev.kdrag0n.OrbStack") +) +(deny ipc-posix-shm* + (ipc-posix-name-prefix "docker") + (ipc-posix-name-prefix "com.docker.") +) +(deny file-write* + (subpath (string-append (param "HOME_DIR") "/.gemini")) + (regex #"/oauth_creds\\.json$") + (regex #"/gemini-credentials\\.json$") + (regex #"/mcp-oauth-tokens\\.json$") + (regex #"/a2a-oauth-tokens\\.json$") + (regex #"/google_accounts\\.json$") + (regex #"/trusted_hooks\\.json$") + (regex #"/trustedFolders\\.json$") + (regex #"/policy_integrity\\.json$") + (regex #"/\\.env($|\\..*)") +) +(deny file-read* + (literal (string-append (param "HOME_DIR") "/.gemini/oauth_creds.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/gemini-credentials.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/mcp-oauth-tokens.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/a2a-oauth-tokens.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/google_accounts.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/trusted_hooks.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/trustedFolders.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/policy_integrity.json")) + (regex #"/oauth_creds\\.json$") + (regex #"/gemini-credentials\\.json$") + (regex #"/mcp-oauth-tokens\\.json$") + (regex #"/a2a-oauth-tokens\\.json$") + (regex #"/google_accounts\\.json$") + (regex #"/trusted_hooks\\.json$") + (regex #"/trustedFolders\\.json$") + (regex #"/policy_integrity\\.json$") + (regex #"/\\.env($|\\..*)") +)`, + + 'restrictive-proxied': `(version 1) +(deny default) +(allow file-read*) +(allow file-read* + (literal (string-append (param "HOME_DIR") "/.gemini/settings.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/keybindings.json")) +) +(allow process-exec) +(allow process-fork) +(allow signal (target self)) +(allow sysctl-read + (sysctl-name "hw.activecpu") + (sysctl-name "hw.busfrequency_compat") + (sysctl-name "hw.byteorder") + (sysctl-name "hw.cacheconfig") + (sysctl-name "hw.cachelinesize_compat") + (sysctl-name "hw.cpufamily") + (sysctl-name "hw.cpufrequency_compat") + (sysctl-name "hw.cputype") + (sysctl-name "hw.l1dcachesize_compat") + (sysctl-name "hw.l1icachesize_compat") + (sysctl-name "hw.l2cachesize_compat") + (sysctl-name "hw.l3cachesize_compat") + (sysctl-name "hw.logicalcpu_max") + (sysctl-name "hw.machine") + (sysctl-name "hw.ncpu") + (sysctl-name "hw.nperflevels") + (sysctl-name "hw.optional.arm.FEAT_BF16") + (sysctl-name "hw.optional.arm.FEAT_DotProd") + (sysctl-name "hw.optional.arm.FEAT_FCMA") + (sysctl-name "hw.optional.arm.FEAT_FHM") + (sysctl-name "hw.optional.arm.FEAT_FP16") + (sysctl-name "hw.optional.arm.FEAT_I8MM") + (sysctl-name "hw.optional.arm.FEAT_JSCVT") + (sysctl-name "hw.optional.arm.FEAT_LSE") + (sysctl-name "hw.optional.arm.FEAT_RDM") + (sysctl-name "hw.optional.arm.FEAT_SHA512") + (sysctl-name "hw.optional.armv8_2_sha512") + (sysctl-name "hw.packages") + (sysctl-name "hw.pagesize_compat") + (sysctl-name "hw.physicalcpu_max") + (sysctl-name "hw.tbfrequency_compat") + (sysctl-name "hw.vectorunit") + (sysctl-name "kern.hostname") + (sysctl-name "kern.maxfilesperproc") + (sysctl-name "kern.osproductversion") + (sysctl-name "kern.osrelease") + (sysctl-name "kern.ostype") + (sysctl-name "kern.osvariant_status") + (sysctl-name "kern.osversion") + (sysctl-name "kern.secure_kernel") + (sysctl-name "kern.usrstack64") + (sysctl-name "kern.version") + (sysctl-name "sysctl.proc_cputype") + (sysctl-name-prefix "hw.perflevel") +) +(allow file-write* + (subpath (param "TARGET_DIR")) + (subpath (param "TMP_DIR")) + (subpath (param "CACHE_DIR")) + (subpath (string-append (param "HOME_DIR") "/.npm")) + (subpath (string-append (param "HOME_DIR") "/.cache")) + (subpath (param "INCLUDE_DIR_0")) + (subpath (param "INCLUDE_DIR_1")) + (subpath (param "INCLUDE_DIR_2")) + (subpath (param "INCLUDE_DIR_3")) + (subpath (param "INCLUDE_DIR_4")) + (literal "/dev/stdout") + (literal "/dev/stderr") + (literal "/dev/null") +) +(allow mach-lookup (global-name "com.apple.sysmond")) +(allow file-ioctl (regex #"^/dev/tty.*")) +(allow network-inbound (local ip "localhost:9229")) +(allow network-outbound (remote tcp "localhost:8877")) +(deny file-read* file-write* + (literal "/var/run/docker.sock") + (literal "/var/run/docker.sock.raw") + (literal "/private/var/run/docker.sock") + (literal "/private/var/run/docker.sock.raw") + (subpath "/var/run/docker") + (subpath "/private/var/run/docker") + (subpath "/Users/Shared/.docker") + (subpath (string-append (param "HOME_DIR") "/.docker/run")) + (subpath (string-append (param "HOME_DIR") "/.docker/desktop")) + (subpath (string-append (param "HOME_DIR") "/.colima")) + (subpath (string-append (param "HOME_DIR") "/.orbstack/run")) + (subpath (string-append (param "HOME_DIR") "/.rd")) +) +(deny process-exec + (literal "/usr/local/bin/docker") + (literal "/usr/local/bin/dockerd") + (literal "/usr/local/bin/docker-compose") + (literal "/usr/bin/docker") + (literal "/opt/homebrew/bin/docker") + (literal "/opt/homebrew/bin/dockerd") + (literal "/opt/homebrew/bin/docker-compose") + (literal "/opt/homebrew/bin/podman") + (literal "/usr/local/bin/podman") + (literal "/opt/homebrew/bin/colima") + (literal "/usr/local/bin/colima") + (literal "/opt/homebrew/bin/orb") + (literal "/Applications/Docker.app/Contents/Resources/bin/docker") + (subpath "/Applications/Docker.app/Contents/MacOS/") + (subpath "/Applications/OrbStack.app/Contents/MacOS/") + (subpath "/Applications/Rancher Desktop.app/Contents/MacOS/") +) +(deny mach-lookup + (xpc-service-name-prefix "com.docker.") + (global-name-prefix "com.docker.") + (global-name-prefix "dev.kdrag0n.OrbStack") +) +(deny ipc-posix-shm* + (ipc-posix-name-prefix "docker") + (ipc-posix-name-prefix "com.docker.") +) +(deny file-write* + (subpath (string-append (param "HOME_DIR") "/.gemini")) + (regex #"/oauth_creds\\.json$") + (regex #"/gemini-credentials\\.json$") + (regex #"/mcp-oauth-tokens\\.json$") + (regex #"/a2a-oauth-tokens\\.json$") + (regex #"/google_accounts\\.json$") + (regex #"/trusted_hooks\\.json$") + (regex #"/trustedFolders\\.json$") + (regex #"/policy_integrity\\.json$") + (regex #"/\\.env($|\\..*)") +) +(deny file-read* + (literal (string-append (param "HOME_DIR") "/.gemini/oauth_creds.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/gemini-credentials.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/mcp-oauth-tokens.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/a2a-oauth-tokens.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/google_accounts.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/trusted_hooks.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/trustedFolders.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/policy_integrity.json")) + (regex #"/oauth_creds\\.json$") + (regex #"/gemini-credentials\\.json$") + (regex #"/mcp-oauth-tokens\\.json$") + (regex #"/a2a-oauth-tokens\\.json$") + (regex #"/google_accounts\\.json$") + (regex #"/trusted_hooks\\.json$") + (regex #"/trustedFolders\\.json$") + (regex #"/policy_integrity\\.json$") + (regex #"/\\.env($|\\..*)") +)`, + + 'strict-open': `(version 1) +(deny default) +(allow file-read* + (literal "/") + (subpath (param "TARGET_DIR")) + (subpath (param "TMP_DIR")) + (subpath (param "CACHE_DIR")) + (literal (string-append (param "HOME_DIR") "/.gemini/settings.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/keybindings.json")) + (subpath (string-append (param "HOME_DIR") "/.npm")) + (subpath (string-append (param "HOME_DIR") "/.cache")) + (literal (string-append (param "HOME_DIR") "/.gitconfig")) + (subpath (string-append (param "HOME_DIR") "/.nvm")) + (subpath (string-append (param "HOME_DIR") "/.fnm")) + (subpath (string-append (param "HOME_DIR") "/.node")) + (subpath (string-append (param "HOME_DIR") "/.config")) + (subpath (param "INCLUDE_DIR_0")) + (subpath (param "INCLUDE_DIR_1")) + (subpath (param "INCLUDE_DIR_2")) + (subpath (param "INCLUDE_DIR_3")) + (subpath (param "INCLUDE_DIR_4")) + (subpath "/usr") + (subpath "/bin") + (subpath "/sbin") + (subpath "/Library") + (subpath "/System") + (subpath "/private") + (subpath "/dev") + (subpath "/etc") + (subpath "/opt") + (subpath "/Applications") +) +(allow file-read-metadata) +(allow process-exec) +(allow process-fork) +(allow signal (target self)) +(allow sysctl-read + (sysctl-name "hw.activecpu") + (sysctl-name "hw.busfrequency_compat") + (sysctl-name "hw.byteorder") + (sysctl-name "hw.cacheconfig") + (sysctl-name "hw.cachelinesize_compat") + (sysctl-name "hw.cpufamily") + (sysctl-name "hw.cpufrequency_compat") + (sysctl-name "hw.cputype") + (sysctl-name "hw.l1dcachesize_compat") + (sysctl-name "hw.l1icachesize_compat") + (sysctl-name "hw.l2cachesize_compat") + (sysctl-name "hw.l3cachesize_compat") + (sysctl-name "hw.logicalcpu_max") + (sysctl-name "hw.machine") + (sysctl-name "hw.ncpu") + (sysctl-name "hw.nperflevels") + (sysctl-name "hw.optional.arm.FEAT_BF16") + (sysctl-name "hw.optional.arm.FEAT_DotProd") + (sysctl-name "hw.optional.arm.FEAT_FCMA") + (sysctl-name "hw.optional.arm.FEAT_FHM") + (sysctl-name "hw.optional.arm.FEAT_FP16") + (sysctl-name "hw.optional.arm.FEAT_I8MM") + (sysctl-name "hw.optional.arm.FEAT_JSCVT") + (sysctl-name "hw.optional.arm.FEAT_LSE") + (sysctl-name "hw.optional.arm.FEAT_RDM") + (sysctl-name "hw.optional.arm.FEAT_SHA512") + (sysctl-name "hw.optional.armv8_2_sha512") + (sysctl-name "hw.packages") + (sysctl-name "hw.pagesize_compat") + (sysctl-name "hw.physicalcpu_max") + (sysctl-name "hw.tbfrequency_compat") + (sysctl-name "hw.vectorunit") + (sysctl-name "kern.hostname") + (sysctl-name "kern.maxfilesperproc") + (sysctl-name "kern.osproductversion") + (sysctl-name "kern.osrelease") + (sysctl-name "kern.ostype") + (sysctl-name "kern.osvariant_status") + (sysctl-name "kern.osversion") + (sysctl-name "kern.secure_kernel") + (sysctl-name "kern.usrstack64") + (sysctl-name "kern.version") + (sysctl-name "sysctl.proc_cputype") + (sysctl-name-prefix "hw.perflevel") +) +(allow file-write* + (subpath (param "TARGET_DIR")) + (subpath (param "TMP_DIR")) + (subpath (param "CACHE_DIR")) + (subpath (string-append (param "HOME_DIR") "/.npm")) + (subpath (string-append (param "HOME_DIR") "/.cache")) + (subpath (param "INCLUDE_DIR_0")) + (subpath (param "INCLUDE_DIR_1")) + (subpath (param "INCLUDE_DIR_2")) + (subpath (param "INCLUDE_DIR_3")) + (subpath (param "INCLUDE_DIR_4")) + (literal "/dev/stdout") + (literal "/dev/stderr") + (literal "/dev/null") +) +(allow mach-lookup (global-name "com.apple.sysmond")) +(allow file-ioctl (regex #"^/dev/tty.*")) +(allow network-inbound (local ip "localhost:9229")) +(allow network-outbound) +(deny file-read* file-write* + (literal "/var/run/docker.sock") + (literal "/var/run/docker.sock.raw") + (literal "/private/var/run/docker.sock") + (literal "/private/var/run/docker.sock.raw") + (subpath "/var/run/docker") + (subpath "/private/var/run/docker") + (subpath "/Users/Shared/.docker") + (subpath (string-append (param "HOME_DIR") "/.docker/run")) + (subpath (string-append (param "HOME_DIR") "/.docker/desktop")) + (subpath (string-append (param "HOME_DIR") "/.colima")) + (subpath (string-append (param "HOME_DIR") "/.orbstack/run")) + (subpath (string-append (param "HOME_DIR") "/.rd")) +) +(deny process-exec + (literal "/usr/local/bin/docker") + (literal "/usr/local/bin/dockerd") + (literal "/usr/local/bin/docker-compose") + (literal "/usr/bin/docker") + (literal "/opt/homebrew/bin/docker") + (literal "/opt/homebrew/bin/dockerd") + (literal "/opt/homebrew/bin/docker-compose") + (literal "/opt/homebrew/bin/podman") + (literal "/usr/local/bin/podman") + (literal "/opt/homebrew/bin/colima") + (literal "/usr/local/bin/colima") + (literal "/opt/homebrew/bin/orb") + (literal "/Applications/Docker.app/Contents/Resources/bin/docker") + (subpath "/Applications/Docker.app/Contents/MacOS/") + (subpath "/Applications/OrbStack.app/Contents/MacOS/") + (subpath "/Applications/Rancher Desktop.app/Contents/MacOS/") +) +(deny mach-lookup + (xpc-service-name-prefix "com.docker.") + (global-name-prefix "com.docker.") + (global-name-prefix "dev.kdrag0n.OrbStack") +) +(deny ipc-posix-shm* + (ipc-posix-name-prefix "docker") + (ipc-posix-name-prefix "com.docker.") +) +(deny file-write* + (subpath (string-append (param "HOME_DIR") "/.gemini")) + (regex #"/oauth_creds\\.json$") + (regex #"/gemini-credentials\\.json$") + (regex #"/mcp-oauth-tokens\\.json$") + (regex #"/a2a-oauth-tokens\\.json$") + (regex #"/google_accounts\\.json$") + (regex #"/trusted_hooks\\.json$") + (regex #"/trustedFolders\\.json$") + (regex #"/policy_integrity\\.json$") + (regex #"/\\.env($|\\..*)") +) +(deny file-read* + (literal (string-append (param "HOME_DIR") "/.gemini/oauth_creds.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/gemini-credentials.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/mcp-oauth-tokens.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/a2a-oauth-tokens.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/google_accounts.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/trusted_hooks.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/trustedFolders.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/policy_integrity.json")) + (regex #"/oauth_creds\\.json$") + (regex #"/gemini-credentials\\.json$") + (regex #"/mcp-oauth-tokens\\.json$") + (regex #"/a2a-oauth-tokens\\.json$") + (regex #"/google_accounts\\.json$") + (regex #"/trusted_hooks\\.json$") + (regex #"/trustedFolders\\.json$") + (regex #"/policy_integrity\\.json$") + (regex #"/\\.env($|\\..*)") +)`, + + 'strict-proxied': `(version 1) +(deny default) +(allow file-read* + (literal "/") + (subpath (param "TARGET_DIR")) + (subpath (param "TMP_DIR")) + (subpath (param "CACHE_DIR")) + (literal (string-append (param "HOME_DIR") "/.gemini/settings.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/keybindings.json")) + (subpath (string-append (param "HOME_DIR") "/.npm")) + (subpath (string-append (param "HOME_DIR") "/.cache")) + (literal (string-append (param "HOME_DIR") "/.gitconfig")) + (subpath (string-append (param "HOME_DIR") "/.nvm")) + (subpath (string-append (param "HOME_DIR") "/.fnm")) + (subpath (string-append (param "HOME_DIR") "/.node")) + (subpath (string-append (param "HOME_DIR") "/.config")) + (subpath (param "INCLUDE_DIR_0")) + (subpath (param "INCLUDE_DIR_1")) + (subpath (param "INCLUDE_DIR_2")) + (subpath (param "INCLUDE_DIR_3")) + (subpath (param "INCLUDE_DIR_4")) + (subpath "/usr") + (subpath "/bin") + (subpath "/sbin") + (subpath "/Library") + (subpath "/System") + (subpath "/private") + (subpath "/dev") + (subpath "/etc") + (subpath "/opt") + (subpath "/Applications") +) +(allow file-read-metadata) +(allow process-exec) +(allow process-fork) +(allow signal (target self)) +(allow sysctl-read + (sysctl-name "hw.activecpu") + (sysctl-name "hw.busfrequency_compat") + (sysctl-name "hw.byteorder") + (sysctl-name "hw.cacheconfig") + (sysctl-name "hw.cachelinesize_compat") + (sysctl-name "hw.cpufamily") + (sysctl-name "hw.cpufrequency_compat") + (sysctl-name "hw.cputype") + (sysctl-name "hw.l1dcachesize_compat") + (sysctl-name "hw.l1icachesize_compat") + (sysctl-name "hw.l2cachesize_compat") + (sysctl-name "hw.l3cachesize_compat") + (sysctl-name "hw.logicalcpu_max") + (sysctl-name "hw.machine") + (sysctl-name "hw.ncpu") + (sysctl-name "hw.nperflevels") + (sysctl-name "hw.optional.arm.FEAT_BF16") + (sysctl-name "hw.optional.arm.FEAT_DotProd") + (sysctl-name "hw.optional.arm.FEAT_FCMA") + (sysctl-name "hw.optional.arm.FEAT_FHM") + (sysctl-name "hw.optional.arm.FEAT_FP16") + (sysctl-name "hw.optional.arm.FEAT_I8MM") + (sysctl-name "hw.optional.arm.FEAT_JSCVT") + (sysctl-name "hw.optional.arm.FEAT_LSE") + (sysctl-name "hw.optional.arm.FEAT_RDM") + (sysctl-name "hw.optional.arm.FEAT_SHA512") + (sysctl-name "hw.optional.armv8_2_sha512") + (sysctl-name "hw.packages") + (sysctl-name "hw.pagesize_compat") + (sysctl-name "hw.physicalcpu_max") + (sysctl-name "hw.tbfrequency_compat") + (sysctl-name "hw.vectorunit") + (sysctl-name "kern.hostname") + (sysctl-name "kern.maxfilesperproc") + (sysctl-name "kern.osproductversion") + (sysctl-name "kern.osrelease") + (sysctl-name "kern.ostype") + (sysctl-name "kern.osvariant_status") + (sysctl-name "kern.osversion") + (sysctl-name "kern.secure_kernel") + (sysctl-name "kern.usrstack64") + (sysctl-name "kern.version") + (sysctl-name "sysctl.proc_cputype") + (sysctl-name-prefix "hw.perflevel") +) +(allow file-write* + (subpath (param "TARGET_DIR")) + (subpath (param "TMP_DIR")) + (subpath (param "CACHE_DIR")) + (subpath (string-append (param "HOME_DIR") "/.npm")) + (subpath (string-append (param "HOME_DIR") "/.cache")) + (subpath (param "INCLUDE_DIR_0")) + (subpath (param "INCLUDE_DIR_1")) + (subpath (param "INCLUDE_DIR_2")) + (subpath (param "INCLUDE_DIR_3")) + (subpath (param "INCLUDE_DIR_4")) + (literal "/dev/stdout") + (literal "/dev/stderr") + (literal "/dev/null") +) +(allow mach-lookup (global-name "com.apple.sysmond")) +(allow file-ioctl (regex #"^/dev/tty.*")) +(allow network-inbound (local ip "localhost:9229")) +(allow network-outbound (remote tcp "localhost:8877")) +(deny file-read* file-write* + (literal "/var/run/docker.sock") + (literal "/var/run/docker.sock.raw") + (literal "/private/var/run/docker.sock") + (literal "/private/var/run/docker.sock.raw") + (subpath "/var/run/docker") + (subpath "/private/var/run/docker") + (subpath "/Users/Shared/.docker") + (subpath (string-append (param "HOME_DIR") "/.docker/run")) + (subpath (string-append (param "HOME_DIR") "/.docker/desktop")) + (subpath (string-append (param "HOME_DIR") "/.colima")) + (subpath (string-append (param "HOME_DIR") "/.orbstack/run")) + (subpath (string-append (param "HOME_DIR") "/.rd")) +) +(deny process-exec + (literal "/usr/local/bin/docker") + (literal "/usr/local/bin/dockerd") + (literal "/usr/local/bin/docker-compose") + (literal "/usr/bin/docker") + (literal "/opt/homebrew/bin/docker") + (literal "/opt/homebrew/bin/dockerd") + (literal "/opt/homebrew/bin/docker-compose") + (literal "/opt/homebrew/bin/podman") + (literal "/usr/local/bin/podman") + (literal "/opt/homebrew/bin/colima") + (literal "/usr/local/bin/colima") + (literal "/opt/homebrew/bin/orb") + (literal "/Applications/Docker.app/Contents/Resources/bin/docker") + (subpath "/Applications/Docker.app/Contents/MacOS/") + (subpath "/Applications/OrbStack.app/Contents/MacOS/") + (subpath "/Applications/Rancher Desktop.app/Contents/MacOS/") +) +(deny mach-lookup + (xpc-service-name-prefix "com.docker.") + (global-name-prefix "com.docker.") + (global-name-prefix "dev.kdrag0n.OrbStack") +) +(deny ipc-posix-shm* + (ipc-posix-name-prefix "docker") + (ipc-posix-name-prefix "com.docker.") +) +(deny file-write* + (subpath (string-append (param "HOME_DIR") "/.gemini")) + (regex #"/oauth_creds\\.json$") + (regex #"/gemini-credentials\\.json$") + (regex #"/mcp-oauth-tokens\\.json$") + (regex #"/a2a-oauth-tokens\\.json$") + (regex #"/google_accounts\\.json$") + (regex #"/trusted_hooks\\.json$") + (regex #"/trustedFolders\\.json$") + (regex #"/policy_integrity\\.json$") + (regex #"/\\.env($|\\..*)") +) +(deny file-read* + (literal (string-append (param "HOME_DIR") "/.gemini/oauth_creds.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/gemini-credentials.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/mcp-oauth-tokens.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/a2a-oauth-tokens.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/google_accounts.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/trusted_hooks.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/trustedFolders.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/policy_integrity.json")) + (regex #"/oauth_creds\\.json$") + (regex #"/gemini-credentials\\.json$") + (regex #"/mcp-oauth-tokens\\.json$") + (regex #"/a2a-oauth-tokens\\.json$") + (regex #"/google_accounts\\.json$") + (regex #"/trusted_hooks\\.json$") + (regex #"/trustedFolders\\.json$") + (regex #"/policy_integrity\\.json$") + (regex #"/\\.env($|\\..*)") +)`, +}; + +// Map standard 'closed' profiles to their strict counterparts for backward compatibility and fallback support +BUILTIN_SEATBELT_PROFILE_CONTENTS['permissive-closed'] = + BUILTIN_SEATBELT_PROFILE_CONTENTS['strict-open']; +BUILTIN_SEATBELT_PROFILE_CONTENTS['restrictive-closed'] = + BUILTIN_SEATBELT_PROFILE_CONTENTS['strict-proxied']; diff --git a/packages/cli/src/utils/sandboxUtils.ts b/packages/cli/src/utils/sandboxUtils.ts new file mode 100644 index 0000000000000000000000000000000000000000..26c5562f3f229e175c49a0ab9543df01dba1107a --- /dev/null +++ b/packages/cli/src/utils/sandboxUtils.ts @@ -0,0 +1,438 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import os from 'node:os'; +import path from 'node:path'; +import fs from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { quote } from 'shell-quote'; +import { + debugLogger, + GEMINI_DIR, + homedir, + resolveToRealPath, +} from '@google/gemini-cli-core'; + +export const LOCAL_DEV_SANDBOX_IMAGE_NAME = 'gemini-cli-sandbox'; +export const SANDBOX_NETWORK_NAME = 'gemini-cli-sandbox'; +export const SANDBOX_PROXY_NAME = 'gemini-cli-sandbox-proxy'; +export const BUILTIN_SEATBELT_PROFILES = [ + 'permissive-open', + 'permissive-closed', + 'permissive-proxied', + 'restrictive-open', + 'restrictive-closed', + 'restrictive-proxied', + 'strict-open', + 'strict-proxied', +]; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Known sensitive or credential file names that must not be mounted into the sandbox container. + */ +export const SENSITIVE_SETTINGS_FILENAMES = new Set([ + 'oauth_creds.json', + 'google_accounts.json', + 'gemini-credentials.json', + 'mcp-oauth-tokens.json', + 'a2a-oauth-tokens.json', + 'trusted_hooks.json', + 'trustedfolders.json', + 'trustedFolders.json', + 'policy_integrity.json', +]); + +/** + * Returns true if the given file or directory path corresponds to credentials or sensitive data + * that must not be mounted into the untrusted sandbox container. + */ +export function isCredentialOrSensitivePath( + targetPath: string, + rootDir?: string, +): boolean { + if (rootDir && path.resolve(targetPath) === path.resolve(rootDir)) { + return false; + } + const base = path.basename(targetPath).toLowerCase(); + if (SENSITIVE_SETTINGS_FILENAMES.has(base)) { + return true; + } + const isRootChild = rootDir + ? path.dirname(path.resolve(targetPath)) === path.resolve(rootDir) + : true; + if (isRootChild && (base === 'history' || base === 'tmp' || base === 'bin')) { + return true; + } + const hasSensitiveSuffix = (s: string) => { + if (base === s) return true; + if (base.endsWith(s)) { + const charBefore = base.charAt(base.length - s.length - 1); + return charBefore === '-' || charBefore === '_' || charBefore === '.'; + } + return false; + }; + + if ( + base.endsWith('.credentials') || + hasSensitiveSuffix('credentials') || + hasSensitiveSuffix('credentials.json') || + hasSensitiveSuffix('tokens.json') || + hasSensitiveSuffix('token.json') || + hasSensitiveSuffix('token') || + hasSensitiveSuffix('creds.json') || + hasSensitiveSuffix('cred.json') || + base.endsWith('.env') || + base.endsWith('.key') || + base.endsWith('.pem') || + base.endsWith('.p12') || + hasSensitiveSuffix('key.json') + ) { + return true; + } + return false; +} + +/** + * Resolves a path to its real path, falling back to path.resolve if it does not exist (ENOENT). + * Rethrows unrecoverable errors so callers can fail closed. + */ +function safeResolveToRealPath(targetPath: string): string { + let current = path.resolve(targetPath); + const parts: string[] = []; + const visited = new Set(); + + while (current && current !== path.dirname(current)) { + const visitKey = + os.platform() === 'win32' ? current.toLowerCase() : current; + if (visited.has(visitKey)) { + throw new Error('Circular symlink detected'); + } + visited.add(visitKey); + + try { + const real = resolveToRealPath(current); + return path.resolve(real, ...parts.slice().reverse()); + } catch (err: unknown) { + if (isRecord(err) && err['code'] === 'ENOENT') { + try { + const stat = fs.lstatSync(current); + if (stat?.isSymbolicLink?.()) { + const target = fs.readlinkSync(current); + current = path.resolve(path.dirname(current), target); + continue; + } + } catch (lstatErr: unknown) { + if (!isRecord(lstatErr) || lstatErr['code'] !== 'ENOENT') { + throw lstatErr; + } + } + parts.push(path.basename(current)); + current = path.dirname(current); + continue; + } + throw err; + } + } + return path.resolve(targetPath); +} + +/** + * Checks if a host path is sensitive and should be prohibited from mounting + * into the sandbox container. This protects ~/.gemini, user home directories, + * and sensitive credential files from being accessed or poisoned. + */ +export function isSensitiveHostPath(hostPath: string): boolean { + try { + const rawHome = homedir(); + if (!rawHome || rawHome.trim() === '') { + // If home directory cannot be determined, do not resolve it to process.cwd(). + // Only block user-tilde notations and sensitive files. + if ( + hostPath === '~' || + hostPath === '~/' || + hostPath === '~\\' || + hostPath === '~/.gemini' || + hostPath.startsWith('~/.gemini/') || + hostPath === '~\\.gemini' || + hostPath.startsWith('~\\.gemini\\') + ) { + return true; + } + + const resolvedPath = safeResolveToRealPath(hostPath); + + const baseName = path.basename(resolvedPath).toLowerCase(); + if ( + baseName === '.env' || + baseName.startsWith('.env.') || + SENSITIVE_SETTINGS_FILENAMES.has(baseName) + ) { + return true; + } + return false; + } + + const home = safeResolveToRealPath(rawHome); + + let expandedPath = hostPath; + if (hostPath === '~' || hostPath === '~/' || hostPath === '~\\') { + expandedPath = home; + } else if (hostPath.startsWith('~/') || hostPath.startsWith('~\\')) { + expandedPath = path.join(home, hostPath.slice(2)); + } + + const normalized = safeResolveToRealPath(expandedPath); + + const geminiDirCandidate = path.join(home, GEMINI_DIR); + const geminiDirOnHost = safeResolveToRealPath(geminiDirCandidate); + + const isWindows = os.platform() === 'win32'; + const arePathsEqual = (p1: string, p2: string) => + isWindows + ? path.resolve(p1).toLowerCase() === path.resolve(p2).toLowerCase() + : path.resolve(p1) === path.resolve(p2); + const isSubpathOf = (child: string, parent: string) => { + const resolvedChild = path.resolve(child); + const resolvedParent = path.resolve(parent); + const parentWithSep = resolvedParent.endsWith(path.sep) + ? resolvedParent + : resolvedParent + path.sep; + return isWindows + ? resolvedChild.toLowerCase().startsWith(parentWithSep.toLowerCase()) + : resolvedChild.startsWith(parentWithSep); + }; + + // Block mounting user home directory root directly or any of its parent directories (e.g. /home, /) + if (arePathsEqual(normalized, home) || isSubpathOf(home, normalized)) { + return true; + } + + // Block mounting ~/.gemini, anything inside ~/.gemini, or any of its parent directories + if ( + arePathsEqual(normalized, geminiDirOnHost) || + isSubpathOf(normalized, geminiDirOnHost) || + isSubpathOf(geminiDirOnHost, normalized) + ) { + return true; + } + + // Block sensitive secrets, credential stores, and environment files anywhere + const baseName = path.basename(normalized).toLowerCase(); + if ( + baseName === '.env' || + baseName.startsWith('.env.') || + SENSITIVE_SETTINGS_FILENAMES.has(baseName) + ) { + return true; + } + } catch { + return true; // Fail closed if path resolution fails + } + return false; +} + +/** + * Sanitizes user settings for the sandbox by stripping unvalidated hooks, + * command hooks, API keys, and sensitive tokens. + */ +export function sanitizeSettingsForSandbox( + settings: Record, +): Record { + const sanitized = structuredClone(settings); + + // Remove hooks to prevent configuration poisoning and unvalidated hook execution + delete sanitized['hooks']; + + // Remove command execution hooks in tools if present + const tools = sanitized['tools']; + if (isRecord(tools)) { + const safeTools = { ...tools }; + delete safeTools['discoveryCommand']; + delete safeTools['callCommand']; + sanitized['tools'] = safeTools; + } + + // Remove sensitive keys and credentials + delete sanitized['apiKey']; + delete sanitized['geminiApiKey']; + delete sanitized['googleApiKey']; + + return sanitized; +} + +/** + * Creates an isolated settings directory in a temporary location, populated with non-sensitive + * configuration files from the user settings directory while excluding credential and auth files. + */ +export function prepareIsolatedSettingsDir( + userSettingsDirOnHost: string, +): string { + const baseTmpDir = os.tmpdir() || '/tmp'; + const isolatedDir = fs.mkdtempSync( + path.join(baseTmpDir, 'gemini-sandbox-settings-'), + ); + fs.chmodSync(isolatedDir, 0o700); + + if (fs.existsSync(userSettingsDirOnHost)) { + try { + fs.cpSync(userSettingsDirOnHost, isolatedDir, { + recursive: true, + filter: (source) => + !isCredentialOrSensitivePath(source, userSettingsDirOnHost), + }); + } catch (err) { + debugLogger.warn( + `Failed to copy user settings to sandbox directory: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + + return isolatedDir; +} + +export function getContainerPath(hostPath: string): string { + if (os.platform() !== 'win32') { + return hostPath; + } + + const withForwardSlashes = hostPath.replace(/\\/g, '/'); + const match = withForwardSlashes.match(/^([A-Z]):\/(.*)/i); + if (match) { + return `/${match[1].toLowerCase()}/${match[2]}`; + } + return withForwardSlashes; +} + +export async function shouldUseCurrentUserInSandbox(): Promise { + const envVar = process.env['SANDBOX_SET_UID_GID']?.toLowerCase().trim(); + + if (envVar === '1' || envVar === 'true') { + return true; + } + if (envVar === '0' || envVar === 'false') { + return false; + } + + // If environment variable is not explicitly set, check for Debian/Ubuntu Linux + if (os.platform() === 'linux') { + try { + const osReleaseContent = await readFile('/etc/os-release', 'utf8'); + const isSupportedDistro = + osReleaseContent.match( + /^ID=["']?(?:debian|ubuntu|nixos|arch|fedora|suse|opensuse)/m, + ) || + osReleaseContent.match( + /^ID_LIKE=["']?.*(?:debian|ubuntu|arch|fedora|suse).*/m, + ); + + if (isSupportedDistro) { + debugLogger.log( + 'Defaulting to use current user UID/GID for supported Linux distribution.', + ); + return true; + } + + // If we're on Linux but the distro is unrecognized, check for a UID mismatch + // that might cause permission issues in the sandbox. + const uid = os.userInfo().uid; + if (uid !== 1000 && uid !== 0) { + debugLogger.warn( + `Warning: Host UID mismatch detected (current UID: ${uid}). ` + + 'If you encounter permission errors in the sandbox, try setting SANDBOX_SET_UID_GID=true.', + ); + } + } catch { + // Silently ignore if /etc/os-release is not found or unreadable. + // The default (false) will be applied in this case. + debugLogger.warn( + 'Warning: Could not read /etc/os-release to auto-detect Linux distribution for UID/GID default.', + ); + } + } + return false; // Default to false if no other condition is met +} + +export function parseImageName(image: string): string { + const [fullName, tag] = image.split(':'); + const name = fullName.split('/').at(-1) ?? 'unknown-image'; + return tag ? `${name}-${tag}` : name; +} + +export function ports(): string[] { + return (process.env['SANDBOX_PORTS'] ?? '') + .split(',') + .filter((p) => p.trim()) + .map((p) => p.trim()); +} + +export function entrypoint(workdir: string, cliArgs: string[]): string[] { + const isWindows = os.platform() === 'win32'; + const containerWorkdir = getContainerPath(workdir); + const shellCmds = []; + const pathSeparator = isWindows ? ';' : ':'; + + let pathSuffix = ''; + if (process.env['PATH']) { + const paths = process.env['PATH'].split(pathSeparator); + for (const p of paths) { + const containerPath = getContainerPath(p); + if ( + containerPath.toLowerCase().startsWith(containerWorkdir.toLowerCase()) + ) { + pathSuffix += `:${containerPath}`; + } + } + } + if (pathSuffix) { + shellCmds.push(`export PATH="$PATH${pathSuffix}";`); + } + + let pythonPathSuffix = ''; + if (process.env['PYTHONPATH']) { + const paths = process.env['PYTHONPATH'].split(pathSeparator); + for (const p of paths) { + const containerPath = getContainerPath(p); + if ( + containerPath.toLowerCase().startsWith(containerWorkdir.toLowerCase()) + ) { + pythonPathSuffix += `:${containerPath}`; + } + } + } + if (pythonPathSuffix) { + shellCmds.push(`export PYTHONPATH="$PYTHONPATH${pythonPathSuffix}";`); + } + + const projectSandboxBashrc = `${GEMINI_DIR}/sandbox.bashrc`; + if (fs.existsSync(projectSandboxBashrc)) { + shellCmds.push(`source ${getContainerPath(projectSandboxBashrc)};`); + } + + ports().forEach((p) => + shellCmds.push( + `socat TCP4-LISTEN:${p},bind=$(hostname -i),fork,reuseaddr TCP4:127.0.0.1:${p} 2> /dev/null &`, + ), + ); + + const quotedCliArgs = cliArgs.slice(2).map((arg) => quote([arg])); + const isDebugMode = + process.env['DEBUG'] === 'true' || process.env['DEBUG'] === '1'; + const cliCmd = + process.env['NODE_ENV'] === 'development' + ? isDebugMode + ? 'npm run debug --' + : 'npm rebuild && npm run start --' + : isDebugMode + ? `node --inspect-brk=0.0.0.0:${process.env['DEBUG_PORT'] || '9229'} $(which gemini)` + : 'gemini'; + + const args = [...shellCmds, cliCmd, ...quotedCliArgs]; + return ['bash', '-c', args.join(' ')]; +} diff --git a/packages/cli/src/utils/sessionUtils.test.ts b/packages/cli/src/utils/sessionUtils.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..5677da57272eb24f64fe52a5b38d382274252b54 --- /dev/null +++ b/packages/cli/src/utils/sessionUtils.test.ts @@ -0,0 +1,1098 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + SessionSelector, + extractFirstUserMessage, + formatRelativeTime, + SessionError, + convertSessionToHistoryFormats, +} from './sessionUtils.js'; +import { + SESSION_FILE_PREFIX, + type Storage, + type MessageRecord, + CoreToolCallStatus, +} from '@google/gemini-cli-core'; +import * as fs from 'node:fs/promises'; +import path from 'node:path'; +import { randomUUID } from 'node:crypto'; + +describe('SessionSelector', () => { + let tmpDir: string; + let storage: Storage; + + beforeEach(async () => { + // Create a temporary directory for testing + tmpDir = path.join(process.cwd(), '.tmp-test-sessions'); + await fs.mkdir(tmpDir, { recursive: true }); + + // Mock storage + storage = { + getProjectTempDir: () => tmpDir, + } as Partial as Storage; + }); + + afterEach(async () => { + // Clean up test files + try { + await fs.rm(tmpDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + describe('sessionExists', () => { + it('should return true if a session file with the exact UUID exists', async () => { + const sessionId = randomUUID(); + const chatsDir = path.join(tmpDir, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + await fs.writeFile( + path.join( + chatsDir, + `session-20240101T000000-${sessionId.slice(0, 8)}.jsonl`, + ), + JSON.stringify({ sessionId }), + ); + + const selector = new SessionSelector(storage); + const exists = await selector.sessionExists(sessionId); + expect(exists).toBe(true); + }); + + it('should return false if no session file matches the UUID', async () => { + const sessionId = randomUUID(); + const chatsDir = path.join(tmpDir, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + await fs.writeFile( + path.join(chatsDir, `session-different-uuid-20240101.jsonl`), + '{}', + ); + + const selector = new SessionSelector(storage); + const exists = await selector.sessionExists(sessionId); + expect(exists).toBe(false); + }); + + it('should return false if the chats directory does not exist', async () => { + const sessionId = randomUUID(); + // Notice we do NOT create chatsDir here. + const selector = new SessionSelector(storage); + const exists = await selector.sessionExists(sessionId); + expect(exists).toBe(false); + }); + }); + + it('should resolve session by UUID', async () => { + const sessionId1 = randomUUID(); + const sessionId2 = randomUUID(); + + // Create test session files + const chatsDir = path.join(tmpDir, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + const session1 = { + sessionId: sessionId1, + projectHash: 'test-hash', + startTime: '2024-01-01T10:00:00.000Z', + lastUpdated: '2024-01-01T10:30:00.000Z', + messages: [ + { + type: 'user', + content: 'Test message 1', + id: 'msg1', + timestamp: '2024-01-01T10:00:00.000Z', + }, + ], + }; + + const session2 = { + sessionId: sessionId2, + projectHash: 'test-hash', + startTime: '2024-01-01T11:00:00.000Z', + lastUpdated: '2024-01-01T11:30:00.000Z', + messages: [ + { + type: 'user', + content: 'Test message 2', + id: 'msg2', + timestamp: '2024-01-01T11:00:00.000Z', + }, + ], + }; + + await fs.writeFile( + path.join( + chatsDir, + `${SESSION_FILE_PREFIX}2024-01-01T10-00-${sessionId1.slice(0, 8)}.json`, + ), + JSON.stringify(session1, null, 2), + ); + + await fs.writeFile( + path.join( + chatsDir, + `${SESSION_FILE_PREFIX}2024-01-01T11-00-${sessionId2.slice(0, 8)}.json`, + ), + JSON.stringify(session2, null, 2), + ); + + const sessionSelector = new SessionSelector(storage); + + // Test resolving by UUID + const result1 = await sessionSelector.resolveSession(sessionId1); + expect(result1.sessionData.sessionId).toBe(sessionId1); + expect(result1.sessionData.messages[0].content).toBe('Test message 1'); + + const result2 = await sessionSelector.resolveSession(sessionId2); + expect(result2.sessionData.sessionId).toBe(sessionId2); + expect(result2.sessionData.messages[0].content).toBe('Test message 2'); + }); + + it('should resolve session by index', async () => { + const sessionId1 = randomUUID(); + const sessionId2 = randomUUID(); + + // Create test session files + const chatsDir = path.join(tmpDir, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + const session1 = { + sessionId: sessionId1, + projectHash: 'test-hash', + startTime: '2024-01-01T10:00:00.000Z', + lastUpdated: '2024-01-01T10:30:00.000Z', + messages: [ + { + type: 'user', + content: 'First session', + id: 'msg1', + timestamp: '2024-01-01T10:00:00.000Z', + }, + ], + }; + + const session2 = { + sessionId: sessionId2, + projectHash: 'test-hash', + startTime: '2024-01-01T11:00:00.000Z', + lastUpdated: '2024-01-01T11:30:00.000Z', + messages: [ + { + type: 'user', + content: 'Second session', + id: 'msg2', + timestamp: '2024-01-01T11:00:00.000Z', + }, + ], + }; + + await fs.writeFile( + path.join( + chatsDir, + `${SESSION_FILE_PREFIX}2024-01-01T10-00-${sessionId1.slice(0, 8)}.json`, + ), + JSON.stringify(session1, null, 2), + ); + + await fs.writeFile( + path.join( + chatsDir, + `${SESSION_FILE_PREFIX}2024-01-01T11-00-${sessionId2.slice(0, 8)}.json`, + ), + JSON.stringify(session2, null, 2), + ); + + const sessionSelector = new SessionSelector(storage); + + // Test resolving by index (1-based) + const result1 = await sessionSelector.resolveSession('1'); + expect(result1.sessionData.messages[0].content).toBe('First session'); + + const result2 = await sessionSelector.resolveSession('2'); + expect(result2.sessionData.messages[0].content).toBe('Second session'); + }); + + it('should resolve latest session', async () => { + const sessionId1 = randomUUID(); + const sessionId2 = randomUUID(); + + // Create test session files + const chatsDir = path.join(tmpDir, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + const session1 = { + sessionId: sessionId1, + projectHash: 'test-hash', + startTime: '2024-01-01T10:00:00.000Z', + lastUpdated: '2024-01-01T10:30:00.000Z', + messages: [ + { + type: 'user', + content: 'First session', + id: 'msg1', + timestamp: '2024-01-01T10:00:00.000Z', + }, + ], + }; + + const session2 = { + sessionId: sessionId2, + projectHash: 'test-hash', + startTime: '2024-01-01T11:00:00.000Z', + lastUpdated: '2024-01-01T11:30:00.000Z', + messages: [ + { + type: 'user', + content: 'Latest session', + id: 'msg2', + timestamp: '2024-01-01T11:00:00.000Z', + }, + ], + }; + + await fs.writeFile( + path.join( + chatsDir, + `${SESSION_FILE_PREFIX}2024-01-01T10-00-${sessionId1.slice(0, 8)}.json`, + ), + JSON.stringify(session1, null, 2), + ); + + await fs.writeFile( + path.join( + chatsDir, + `${SESSION_FILE_PREFIX}2024-01-01T11-00-${sessionId2.slice(0, 8)}.json`, + ), + JSON.stringify(session2, null, 2), + ); + + const sessionSelector = new SessionSelector(storage); + + // Test resolving latest + const result = await sessionSelector.resolveSession('latest'); + expect(result.sessionData.messages[0].content).toBe('Latest session'); + }); + + it('should resolve session by UUID with whitespace (trimming)', async () => { + const sessionId = randomUUID(); + + // Create test session files + const chatsDir = path.join(tmpDir, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + const session = { + sessionId, + projectHash: 'test-hash', + startTime: '2024-01-01T10:00:00.000Z', + lastUpdated: '2024-01-01T10:30:00.000Z', + messages: [ + { + type: 'user', + content: 'Test message', + id: 'msg1', + timestamp: '2024-01-01T10:00:00.000Z', + }, + ], + }; + + await fs.writeFile( + path.join( + chatsDir, + `${SESSION_FILE_PREFIX}2024-01-01T10-00-${sessionId.slice(0, 8)}.json`, + ), + JSON.stringify(session, null, 2), + ); + + const sessionSelector = new SessionSelector(storage); + + // Test resolving by UUID with leading/trailing spaces + const result = await sessionSelector.resolveSession(` ${sessionId} `); + expect(result.sessionData.sessionId).toBe(sessionId); + expect(result.sessionData.messages[0].content).toBe('Test message'); + }); + + it('should deduplicate sessions by ID', async () => { + const sessionId = randomUUID(); + + // Create test session files + const chatsDir = path.join(tmpDir, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + const sessionOriginal = { + sessionId, + projectHash: 'test-hash', + startTime: '2024-01-01T10:00:00.000Z', + lastUpdated: '2024-01-01T10:30:00.000Z', + messages: [ + { + type: 'user', + content: 'Original', + id: 'msg1', + timestamp: '2024-01-01T10:00:00.000Z', + }, + ], + }; + + const sessionDuplicate = { + sessionId, + projectHash: 'test-hash', + startTime: '2024-01-01T10:00:00.000Z', + lastUpdated: '2024-01-01T11:00:00.000Z', // Newer + messages: [ + { + type: 'user', + content: 'Newer Duplicate', + id: 'msg1', + timestamp: '2024-01-01T10:00:00.000Z', + }, + ], + }; + + // File 1 + await fs.writeFile( + path.join( + chatsDir, + `${SESSION_FILE_PREFIX}2024-01-01T10-00-${sessionId.slice(0, 8)}.json`, + ), + JSON.stringify(sessionOriginal, null, 2), + ); + + // File 2 (Simulate a copy or newer version with same ID) + await fs.writeFile( + path.join( + chatsDir, + `${SESSION_FILE_PREFIX}2024-01-01T11-00-${sessionId.slice(0, 8)}.json`, + ), + JSON.stringify(sessionDuplicate, null, 2), + ); + + const sessionSelector = new SessionSelector(storage); + const sessions = await sessionSelector.listSessions(); + + expect(sessions.length).toBe(1); + expect(sessions[0].id).toBe(sessionId); + // Should keep the one with later lastUpdated + expect(sessions[0].lastUpdated).toBe('2024-01-01T11:00:00.000Z'); + }); + + it('should throw error for invalid session identifier', async () => { + const sessionId1 = randomUUID(); + + // Create test session files + const chatsDir = path.join(tmpDir, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + const session1 = { + sessionId: sessionId1, + projectHash: 'test-hash', + startTime: '2024-01-01T10:00:00.000Z', + lastUpdated: '2024-01-01T10:30:00.000Z', + messages: [ + { + type: 'user', + content: 'Test message 1', + id: 'msg1', + timestamp: '2024-01-01T10:00:00.000Z', + }, + ], + }; + + await fs.writeFile( + path.join( + chatsDir, + `${SESSION_FILE_PREFIX}2024-01-01T10-00-${sessionId1.slice(0, 8)}.json`, + ), + JSON.stringify(session1, null, 2), + ); + + const sessionSelector = new SessionSelector(storage); + + await expect( + sessionSelector.resolveSession('invalid-uuid'), + ).rejects.toThrow(SessionError); + + await expect(sessionSelector.resolveSession('999')).rejects.toThrow( + SessionError, + ); + }); + + it('should throw SessionError with NO_SESSIONS_FOUND when resolving latest with no sessions', async () => { + // Empty chats directory — no session files + const chatsDir = path.join(tmpDir, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + const emptyStorage = { + getProjectTempDir: () => tmpDir, + } as Partial as Storage; + + const sessionSelector = new SessionSelector(emptyStorage); + + await expect(sessionSelector.resolveSession('latest')).rejects.toSatisfy( + (error) => { + expect(error).toBeInstanceOf(SessionError); + expect((error as SessionError).code).toBe('NO_SESSIONS_FOUND'); + return true; + }, + ); + }); + + it('should not list sessions with only system messages', async () => { + const sessionIdWithUser = randomUUID(); + const sessionIdSystemOnly = randomUUID(); + + // Create test session files + const chatsDir = path.join(tmpDir, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + // Session with user message - should be listed + const sessionWithUser = { + sessionId: sessionIdWithUser, + projectHash: 'test-hash', + startTime: '2024-01-01T10:00:00.000Z', + lastUpdated: '2024-01-01T10:30:00.000Z', + messages: [ + { + type: 'user', + content: 'Hello world', + id: 'msg1', + timestamp: '2024-01-01T10:00:00.000Z', + }, + ], + }; + + // Session with only system messages - should NOT be listed + const sessionSystemOnly = { + sessionId: sessionIdSystemOnly, + projectHash: 'test-hash', + startTime: '2024-01-01T11:00:00.000Z', + lastUpdated: '2024-01-01T11:30:00.000Z', + messages: [ + { + type: 'info', + content: 'Session started', + id: 'msg1', + timestamp: '2024-01-01T11:00:00.000Z', + }, + { + type: 'error', + content: 'An error occurred', + id: 'msg2', + timestamp: '2024-01-01T11:01:00.000Z', + }, + ], + }; + + await fs.writeFile( + path.join( + chatsDir, + `${SESSION_FILE_PREFIX}2024-01-01T10-00-${sessionIdWithUser.slice(0, 8)}.json`, + ), + JSON.stringify(sessionWithUser, null, 2), + ); + + await fs.writeFile( + path.join( + chatsDir, + `${SESSION_FILE_PREFIX}2024-01-01T11-00-${sessionIdSystemOnly.slice(0, 8)}.json`, + ), + JSON.stringify(sessionSystemOnly, null, 2), + ); + + const sessionSelector = new SessionSelector(storage); + const sessions = await sessionSelector.listSessions(); + + // Should only list the session with user message + expect(sessions.length).toBe(1); + expect(sessions[0].id).toBe(sessionIdWithUser); + }); + + it('should not list command-only sessions', async () => { + const commandOnlySessionId = randomUUID(); + + const chatsDir = path.join(tmpDir, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + const metadata = { + sessionId: commandOnlySessionId, + projectHash: 'test-hash', + startTime: '2024-01-01T10:00:00.000Z', + lastUpdated: '2024-01-01T10:01:00.000Z', + }; + const commandMessage = { + type: 'user', + content: '/resume', + id: 'msg1', + timestamp: '2024-01-01T10:00:30.000Z', + }; + + await fs.writeFile( + path.join( + chatsDir, + `${SESSION_FILE_PREFIX}2024-01-01T10-00-${commandOnlySessionId.slice(0, 8)}.jsonl`, + ), + `${JSON.stringify(metadata)}\n${JSON.stringify(commandMessage)}\n`, + ); + + const sessionSelector = new SessionSelector(storage); + const sessions = await sessionSelector.listSessions(); + + expect(sessions).toEqual([]); + }); + + it('should use the first non-command user message for display', async () => { + const sessionId = randomUUID(); + + const chatsDir = path.join(tmpDir, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + const metadata = { + sessionId, + projectHash: 'test-hash', + startTime: '2024-01-01T10:00:00.000Z', + lastUpdated: '2024-01-01T10:02:00.000Z', + }; + const commandMessage = { + type: 'user', + content: '/resume', + id: 'msg1', + timestamp: '2024-01-01T10:00:30.000Z', + }; + const realMessage = { + type: 'user', + content: 'Help me fix resume history', + id: 'msg2', + timestamp: '2024-01-01T10:01:00.000Z', + }; + + await fs.writeFile( + path.join( + chatsDir, + `${SESSION_FILE_PREFIX}2024-01-01T10-00-${sessionId.slice(0, 8)}.jsonl`, + ), + `${JSON.stringify(metadata)}\n${JSON.stringify(commandMessage)}\n${JSON.stringify(realMessage)}\n`, + ); + + const sessionSelector = new SessionSelector(storage); + const sessions = await sessionSelector.listSessions(); + + expect(sessions).toHaveLength(1); + expect(sessions[0].firstUserMessage).toBe('Help me fix resume history'); + expect(sessions[0].displayName).toBe('Help me fix resume history'); + }); + + it('should list session with gemini message even without user message', async () => { + const sessionIdGeminiOnly = randomUUID(); + + // Create test session files + const chatsDir = path.join(tmpDir, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + // Session with only gemini message - should be listed + const sessionGeminiOnly = { + sessionId: sessionIdGeminiOnly, + projectHash: 'test-hash', + startTime: '2024-01-01T10:00:00.000Z', + lastUpdated: '2024-01-01T10:30:00.000Z', + messages: [ + { + type: 'gemini', + content: 'Hello, how can I help?', + id: 'msg1', + timestamp: '2024-01-01T10:00:00.000Z', + }, + ], + }; + + await fs.writeFile( + path.join( + chatsDir, + `${SESSION_FILE_PREFIX}2024-01-01T10-00-${sessionIdGeminiOnly.slice(0, 8)}.json`, + ), + JSON.stringify(sessionGeminiOnly, null, 2), + ); + + const sessionSelector = new SessionSelector(storage); + const sessions = await sessionSelector.listSessions(); + + // Should list the session with gemini message + expect(sessions.length).toBe(1); + expect(sessions[0].id).toBe(sessionIdGeminiOnly); + }); + + it('should not list sessions marked as subagent', async () => { + const mainSessionId = randomUUID(); + const subagentSessionId = randomUUID(); + + // Create test session files + const chatsDir = path.join(tmpDir, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + // Main session - should be listed + const mainSession = { + sessionId: mainSessionId, + projectHash: 'test-hash', + startTime: '2024-01-01T10:00:00.000Z', + lastUpdated: '2024-01-01T10:30:00.000Z', + messages: [ + { + type: 'user', + content: 'Hello world', + id: 'msg1', + timestamp: '2024-01-01T10:00:00.000Z', + }, + ], + kind: 'main', + }; + + // Subagent session - should NOT be listed + const subagentSession = { + sessionId: subagentSessionId, + projectHash: 'test-hash', + startTime: '2024-01-01T11:00:00.000Z', + lastUpdated: '2024-01-01T11:30:00.000Z', + messages: [ + { + type: 'user', + content: 'Internal subagent task', + id: 'msg1', + timestamp: '2024-01-01T11:00:00.000Z', + }, + ], + kind: 'subagent', + }; + + await fs.writeFile( + path.join( + chatsDir, + `${SESSION_FILE_PREFIX}2024-01-01T10-00-${mainSessionId.slice(0, 8)}.json`, + ), + JSON.stringify(mainSession, null, 2), + ); + + await fs.writeFile( + path.join( + chatsDir, + `${SESSION_FILE_PREFIX}2024-01-01T11-00-${subagentSessionId.slice(0, 8)}.json`, + ), + JSON.stringify(subagentSession, null, 2), + ); + + const sessionSelector = new SessionSelector(storage); + const sessions = await sessionSelector.listSessions(); + + // Should only list the main session + expect(sessions.length).toBe(1); + expect(sessions[0].id).toBe(mainSessionId); + }); + + it('should list legacy session JSON without timestamps (regression #18593)', async () => { + const sessionId = randomUUID(); + + const chatsDir = path.join(tmpDir, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + const session = { + sessionId, + projectHash: 'test-hash', + messages: [ + { + type: 'user', + content: 'Legacy session message', + id: 'msg1', + timestamp: '2024-01-01T10:00:00.000Z', + }, + ], + }; + + const filePath = path.join( + chatsDir, + `${SESSION_FILE_PREFIX}2024-01-01T10-00-${sessionId.slice(0, 8)}.json`, + ); + await fs.writeFile(filePath, JSON.stringify(session, null, 2)); + const fallbackTimestamp = new Date('2024-01-01T10:30:00.000Z'); + await fs.utimes(filePath, fallbackTimestamp, fallbackTimestamp); + + const sessionSelector = new SessionSelector(storage); + const sessions = await sessionSelector.listSessions(); + + expect(sessions.length).toBe(1); + expect(sessions[0].id).toBe(sessionId); + expect(sessions[0].startTime).toBe(fallbackTimestamp.toISOString()); + expect(sessions[0].lastUpdated).toBe(fallbackTimestamp.toISOString()); + }); + + it('should resolve legacy session JSON without timestamps by UUID (regression #18593)', async () => { + const sessionId = randomUUID(); + + const chatsDir = path.join(tmpDir, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + const session = { + sessionId, + projectHash: 'test-hash', + messages: [ + { + type: 'user', + content: 'Legacy session message', + id: 'msg1', + timestamp: '2024-01-01T10:00:00.000Z', + }, + ], + }; + + const filePath = path.join( + chatsDir, + `${SESSION_FILE_PREFIX}2024-01-01T10-00-${sessionId.slice(0, 8)}.json`, + ); + await fs.writeFile(filePath, JSON.stringify(session, null, 2)); + const fallbackTimestamp = new Date('2024-01-01T10:30:00.000Z'); + await fs.utimes(filePath, fallbackTimestamp, fallbackTimestamp); + + const sessionSelector = new SessionSelector(storage); + const result = await sessionSelector.resolveSession(sessionId); + + expect(result.sessionData.sessionId).toBe(sessionId); + expect(result.sessionData.startTime).toBe(fallbackTimestamp.toISOString()); + expect(result.sessionData.lastUpdated).toBe( + fallbackTimestamp.toISOString(), + ); + }); + + it('should throw INVALID_SESSION_IDENTIFIER for a UUID that does not exist on disk at all', async () => { + const existingSessionId = randomUUID(); + const nonExistentId = randomUUID(); + + const chatsDir = path.join(tmpDir, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + const session = { + sessionId: existingSessionId, + projectHash: 'test-hash', + startTime: '2024-01-01T10:00:00.000Z', + lastUpdated: '2024-01-01T10:30:00.000Z', + messages: [ + { + type: 'user', + content: 'Hello', + id: 'msg1', + timestamp: '2024-01-01T10:00:00.000Z', + }, + ], + }; + + await fs.writeFile( + path.join( + chatsDir, + `${SESSION_FILE_PREFIX}2024-01-01T10-00-${existingSessionId.slice(0, 8)}.json`, + ), + JSON.stringify(session, null, 2), + ); + + const sessionSelector = new SessionSelector(storage); + + await expect(sessionSelector.findSession(nonExistentId)).rejects.toSatisfy( + (error) => { + expect(error).toBeInstanceOf(SessionError); + expect((error as SessionError).code).toBe('INVALID_SESSION_IDENTIFIER'); + return true; + }, + ); + }); +}); + +describe('extractFirstUserMessage', () => { + it('should extract first non-resume user message', () => { + const messages = [ + { + type: 'user', + content: '/resume', + id: 'msg1', + timestamp: '2024-01-01T10:00:00.000Z', + }, + { + type: 'user', + content: 'Hello world', + id: 'msg2', + timestamp: '2024-01-01T10:01:00.000Z', + }, + ] as MessageRecord[]; + + expect(extractFirstUserMessage(messages)).toBe('Hello world'); + }); + + it('should not truncate long messages', () => { + const longMessage = 'a'.repeat(150); + const messages = [ + { + type: 'user', + content: longMessage, + id: 'msg1', + timestamp: '2024-01-01T10:00:00.000Z', + }, + ] as MessageRecord[]; + + const result = extractFirstUserMessage(messages); + expect(result).toBe(longMessage); + }); + + it('should return "Empty conversation" for no user messages', () => { + const messages = [ + { + type: 'gemini', + content: 'Hello', + id: 'msg1', + timestamp: '2024-01-01T10:00:00.000Z', + }, + ] as MessageRecord[]; + + expect(extractFirstUserMessage(messages)).toBe('Empty conversation'); + }); +}); + +describe('formatRelativeTime', () => { + it('should format time correctly', () => { + const now = new Date(); + + // 5 minutes ago + const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000); + expect(formatRelativeTime(fiveMinutesAgo.toISOString())).toBe( + '5 minutes ago', + ); + + // 1 minute ago + const oneMinuteAgo = new Date(now.getTime() - 1 * 60 * 1000); + expect(formatRelativeTime(oneMinuteAgo.toISOString())).toBe('1 minute ago'); + + // 2 hours ago + const twoHoursAgo = new Date(now.getTime() - 2 * 60 * 60 * 1000); + expect(formatRelativeTime(twoHoursAgo.toISOString())).toBe('2 hours ago'); + + // 1 hour ago + const oneHourAgo = new Date(now.getTime() - 1 * 60 * 60 * 1000); + expect(formatRelativeTime(oneHourAgo.toISOString())).toBe('1 hour ago'); + + // 3 days ago + const threeDaysAgo = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000); + expect(formatRelativeTime(threeDaysAgo.toISOString())).toBe('3 days ago'); + + // 1 day ago + const oneDayAgo = new Date(now.getTime() - 1 * 24 * 60 * 60 * 1000); + expect(formatRelativeTime(oneDayAgo.toISOString())).toBe('1 day ago'); + + // Just now (within 60 seconds) + const thirtySecondsAgo = new Date(now.getTime() - 30 * 1000); + expect(formatRelativeTime(thirtySecondsAgo.toISOString())).toBe('Just now'); + }); +}); + +describe('convertSessionToHistoryFormats', () => { + it('should preserve tool call arguments', () => { + const messages: MessageRecord[] = [ + { + id: '1', + timestamp: new Date().toISOString(), + type: 'gemini', + content: '', + toolCalls: [ + { + id: 'call_1', + name: 'update_topic', + args: { + title: 'Researching bug', + summary: 'I am looking into the issue.', + }, + status: CoreToolCallStatus.Success, + timestamp: new Date().toISOString(), + displayName: 'Update Topic Context', + description: 'Updating the topic', + renderOutputAsMarkdown: true, + resultDisplay: 'Topic updated', + }, + ], + }, + ]; + + const result = convertSessionToHistoryFormats(messages); + + expect(result.uiHistory).toHaveLength(1); + const toolGroup = result.uiHistory[0]; + if (toolGroup.type === 'tool_group') { + expect(toolGroup.tools).toHaveLength(1); + const tool = toolGroup.tools[0]; + expect(tool.callId).toBe('call_1'); + expect(tool.name).toBe('Update Topic Context'); + expect(tool.description).toBe('Updating the topic'); + expect(tool.renderOutputAsMarkdown).toBe(true); + expect(tool.status).toBe(CoreToolCallStatus.Success); + expect(tool.resultDisplay).toBe('Topic updated'); + expect(tool.args).toEqual({ + title: 'Researching bug', + summary: 'I am looking into the issue.', + }); + } else { + throw new Error('Expected tool_group history item'); + } + }); + + it('should map tool call status correctly when not success', () => { + const messages: MessageRecord[] = [ + { + id: '1', + timestamp: new Date().toISOString(), + type: 'gemini', + content: '', + toolCalls: [ + { + id: 'call_1', + name: 'test_tool', + status: CoreToolCallStatus.Error, + timestamp: new Date().toISOString(), + args: {}, + }, + { + id: 'call_2', + name: 'test_tool_2', + status: CoreToolCallStatus.Cancelled, + timestamp: new Date().toISOString(), + args: {}, + }, + ], + }, + ]; + + const result = convertSessionToHistoryFormats(messages); + expect(result.uiHistory).toHaveLength(1); + + const toolGroup = result.uiHistory[0]; + if (toolGroup.type === 'tool_group') { + expect(toolGroup.tools).toHaveLength(2); + expect(toolGroup.tools[0].status).toBe(CoreToolCallStatus.Error); + expect(toolGroup.tools[1].status).toBe(CoreToolCallStatus.Error); // Cancelled maps to error in this older format projection + } else { + throw new Error('Expected tool_group history item'); + } + }); + + it('should convert various message types', () => { + const messages: MessageRecord[] = [ + { + id: '1', + timestamp: new Date().toISOString(), + type: 'user', + content: 'Hello user', + }, + { + id: '2', + timestamp: new Date().toISOString(), + type: 'info', + content: 'System info', + }, + { + id: '3', + timestamp: new Date().toISOString(), + type: 'error', + content: 'System error', + }, + { + id: '4', + timestamp: new Date().toISOString(), + type: 'warning', + content: 'System warning', + }, + { + id: '5', + timestamp: new Date().toISOString(), + type: 'gemini', + content: 'Hello gemini', + thoughts: [ + { + subject: 'Thinking', + description: 'about things', + timestamp: new Date().toISOString(), + }, + ], + }, + ]; + + const result = convertSessionToHistoryFormats(messages); + + // thoughts become a separate item + expect(result.uiHistory).toHaveLength(6); + expect(result.uiHistory[0]).toEqual({ type: 'user', text: 'Hello user' }); + expect(result.uiHistory[1]).toEqual({ type: 'info', text: 'System info' }); + expect(result.uiHistory[2]).toEqual({ + type: 'error', + text: 'System error', + }); + expect(result.uiHistory[3]).toEqual({ + type: 'warning', + text: 'System warning', + }); + expect(result.uiHistory[4]).toEqual({ + type: 'thinking', + thought: { subject: 'Thinking', description: 'about things' }, + }); + expect(result.uiHistory[5]).toEqual({ + type: 'gemini', + text: 'Hello gemini', + }); + }); + + it('should filter out from UI history', () => { + const messages: MessageRecord[] = [ + { + id: '1', + timestamp: new Date().toISOString(), + type: 'user', + content: + '\nThis is the Gemini CLI\n', + }, + { + id: '2', + timestamp: new Date().toISOString(), + type: 'user', + content: 'Real message', + }, + ]; + + const result = convertSessionToHistoryFormats(messages); + expect(result.uiHistory).toHaveLength(1); + expect(result.uiHistory[0].text).toBe('Real message'); + }); + + it('should handle missing tool descriptions and displayNames', () => { + const messages: MessageRecord[] = [ + { + id: '1', + timestamp: new Date().toISOString(), + type: 'gemini', + content: '', + toolCalls: [ + { + id: 'call_1', + name: 'test_tool', + status: CoreToolCallStatus.Success, + timestamp: new Date().toISOString(), + args: {}, + }, + ], + }, + ]; + + const result = convertSessionToHistoryFormats(messages); + expect(result.uiHistory).toHaveLength(1); + + const toolGroup = result.uiHistory[0]; + if (toolGroup.type === 'tool_group') { + expect(toolGroup.tools[0].name).toBe('test_tool'); // Fallback to name + expect(toolGroup.tools[0].description).toBe(''); // Fallback to empty string + } else { + throw new Error('Expected tool_group history item'); + } + }); +}); diff --git a/packages/cli/src/utils/sessionUtils.ts b/packages/cli/src/utils/sessionUtils.ts new file mode 100644 index 0000000000000000000000000000000000000000..2830451aa004cb8d96c16c04724b9d465b5d73ec --- /dev/null +++ b/packages/cli/src/utils/sessionUtils.ts @@ -0,0 +1,670 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + checkExhaustive, + partListUnionToString, + SESSION_FILE_PREFIX, + CoreToolCallStatus, + type Storage, + type ConversationRecord, + type MessageRecord, + loadConversationRecord, +} from '@google/gemini-cli-core'; +import * as fs from 'node:fs/promises'; +import path from 'node:path'; +import { stripUnsafeCharacters } from '../ui/utils/textUtils.js'; +import { MessageType, type HistoryItemWithoutId } from '../ui/types.js'; + +/** + * Constant for the resume "latest" identifier. + * Used when --resume is passed without a value to select the most recent session. + */ +export const RESUME_LATEST = 'latest'; + +/** + * Error codes for session-related errors. + */ +export type SessionErrorCode = + | 'NO_SESSIONS_FOUND' + | 'INVALID_SESSION_IDENTIFIER'; + +/** + * Error thrown for session-related failures. + * Uses a code field to differentiate between error types. + */ +export class SessionError extends Error { + constructor( + readonly code: SessionErrorCode, + message: string, + ) { + super(message); + this.name = 'SessionError'; + } + + /** + * Creates an error for when no sessions exist for the current project. + */ + static noSessionsFound(): SessionError { + return new SessionError( + 'NO_SESSIONS_FOUND', + 'No previous sessions found for this project.', + ); + } + + /** + * Creates an error for when a session identifier is invalid. + */ + static invalidSessionIdentifier( + identifier: string, + chatsDir?: string, + ): SessionError { + const dirInfo = chatsDir ? ` in ${chatsDir}` : ''; + return new SessionError( + 'INVALID_SESSION_IDENTIFIER', + `Invalid session identifier "${identifier}".\n Searched for sessions${dirInfo}.\n Use --list-sessions to see available sessions, then use --resume {number}, --resume {uuid}, or --resume latest.`, + ); + } +} + +/** + * Represents a text match found during search with surrounding context. + */ +export interface TextMatch { + /** Text content before the match (with ellipsis if truncated) */ + before: string; + /** The exact matched text */ + match: string; + /** Text content after the match (with ellipsis if truncated) */ + after: string; + /** Role of the message author where the match was found */ + role: 'user' | 'assistant'; +} + +/** + * Session information for display and selection purposes. + */ +export interface SessionInfo { + /** Unique session identifier (filename without .json) */ + id: string; + /** Filename without extension */ + file: string; + /** Full filename including .json extension */ + fileName: string; + /** ISO timestamp when session started */ + startTime: string; + /** Total number of messages in the session */ + messageCount: number; + /** ISO timestamp when session was last updated */ + lastUpdated: string; + /** Display name for the session (typically first user message) */ + displayName: string; + /** Cleaned first user message content */ + firstUserMessage: string; + /** Whether this is the currently active session */ + isCurrentSession: boolean; + /** Display index in the list */ + index: number; + /** AI-generated summary of the session (if available) */ + summary?: string; + /** Full concatenated content (only loaded when needed for search) */ + fullContent?: string; + /** Processed messages with normalized roles (only loaded when needed) */ + messages?: Array<{ role: 'user' | 'assistant'; content: string }>; + /** Search result snippets when filtering */ + matchSnippets?: TextMatch[]; + /** Total number of matches found in this session */ + matchCount?: number; +} + +/** + * Represents a session file, which may be valid or corrupted. + */ +export interface SessionFileEntry { + /** Full filename including .json extension */ + fileName: string; + /** Parsed session info if valid, null if corrupted */ + sessionInfo: SessionInfo | null; +} + +/** + * Result of resolving a session selection argument. + */ +export interface SessionSelectionResult { + sessionPath: string; + sessionData: ConversationRecord; + displayInfo: string; +} + +/** + * Cleans and sanitizes message content for display by: + * - Converting newlines to spaces + * - Collapsing multiple whitespace to single spaces + * - Removing non-printable characters (keeping only ASCII 32-126) + * - Trimming leading/trailing whitespace + * @param message - The raw message content to clean + * @returns Sanitized message suitable for display + */ +export const cleanMessage = (message: string): string => + message + .replace(/\n+/g, ' ') + .replace(/\s+/g, ' ') + .replace(/[^\x20-\x7E]+/g, '') // Non-printable. + .trim(); + +/** + * Extracts the first meaningful user message from conversation messages. + */ +export const extractFirstUserMessage = (messages: MessageRecord[]): string => { + const userMessage = messages + // First try filtering out slash commands. + .filter((msg) => { + const content = partListUnionToString(msg.content); + return ( + !content.startsWith('/') && + !content.startsWith('?') && + content.trim().length > 0 + ); + }) + .find((msg) => msg.type === 'user'); + + let content: string; + + if (!userMessage) { + // Fallback to first user message even if it's a slash command + const firstMsg = messages.find((msg) => msg.type === 'user'); + if (!firstMsg) return 'Empty conversation'; + content = cleanMessage(partListUnionToString(firstMsg.content)); + } else { + content = cleanMessage(partListUnionToString(userMessage.content)); + } + + return content; +}; + +/** + * Formats a timestamp as relative time. + * @param timestamp - The timestamp to format + * @param style - 'long' (e.g. "2 hours ago") or 'short' (e.g. "2h") + */ +export const formatRelativeTime = ( + timestamp: string, + style: 'long' | 'short' = 'long', +): string => { + const now = new Date(); + const time = new Date(timestamp); + const diffMs = now.getTime() - time.getTime(); + const diffSeconds = Math.floor(diffMs / 1000); + const diffMinutes = Math.floor(diffSeconds / 60); + const diffHours = Math.floor(diffMinutes / 60); + const diffDays = Math.floor(diffHours / 24); + + if (style === 'short') { + if (diffSeconds < 1) return 'now'; + if (diffSeconds < 60) return `${diffSeconds}s`; + if (diffMinutes < 60) return `${diffMinutes}m`; + if (diffHours < 24) return `${diffHours}h`; + if (diffDays < 30) return `${diffDays}d`; + const diffMonths = Math.floor(diffDays / 30); + return diffMonths < 12 + ? `${diffMonths}mo` + : `${Math.floor(diffMonths / 12)}y`; + } else { + if (diffDays > 0) { + return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`; + } else if (diffHours > 0) { + return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`; + } else if (diffMinutes > 0) { + return `${diffMinutes} minute${diffMinutes === 1 ? '' : 's'} ago`; + } else { + return 'Just now'; + } + } +}; + +export interface GetSessionOptions { + /** Whether to load full message content (needed for search) */ + includeFullContent?: boolean; +} + +/** + * Loads all session files (including corrupted ones) from the chats directory. + * @returns Array of session file entries, with sessionInfo null for corrupted files + */ +export const getAllSessionFiles = async ( + chatsDir: string, + currentSessionId?: string, + options: GetSessionOptions = {}, +): Promise => { + try { + const files = await fs.readdir(chatsDir); + const sessionFiles = files + .filter( + (f) => + f.startsWith(SESSION_FILE_PREFIX) && + (f.endsWith('.json') || f.endsWith('.jsonl')), + ) + .sort(); // Sort by filename, which includes timestamp + + const sessionPromises = sessionFiles.map( + async (file): Promise => { + const filePath = path.join(chatsDir, file); + try { + const content = await loadConversationRecord(filePath, { + metadataOnly: !options.includeFullContent, + }); + if (!content) { + return { fileName: file, sessionInfo: null }; + } + + // Validate required fields + if (!content.sessionId) { + // Missing required fields - treat as corrupted + return { fileName: file, sessionInfo: null }; + } + + const fileTimestamp = + !content.startTime || !content.lastUpdated + ? ( + await fs.stat(filePath).catch(() => undefined) + )?.mtime.toISOString() + : undefined; + const fallbackTimestamp = fileTimestamp ?? new Date().toISOString(); + const startTime = + content.startTime || content.lastUpdated || fallbackTimestamp; + const lastUpdated = + content.lastUpdated || content.startTime || fallbackTimestamp; + + // Skip sessions with no resumable conversation content, including + // startup-only, system-only, command-only, and internal-context-only + // sessions. + if (!content.hasResumableContent) { + return { fileName: file, sessionInfo: null }; + } + + // Skip subagent sessions - these are implementation details of a tool call + // and shouldn't be surfaced for resumption in the main agent history. + if (content.kind === 'subagent') { + return { fileName: file, sessionInfo: null }; + } + + const firstUserMessage = content.firstUserMessage + ? cleanMessage(content.firstUserMessage) + : extractFirstUserMessage(content.messages); + const isCurrentSession = currentSessionId + ? file.includes(currentSessionId.slice(0, 8)) + : false; + + let fullContent: string | undefined; + let messages: + | Array<{ role: 'user' | 'assistant'; content: string }> + | undefined; + + if (options.includeFullContent) { + fullContent = content.messages + .map((msg) => partListUnionToString(msg.content)) + .join(' '); + messages = content.messages.map((msg) => ({ + role: + msg.type === 'user' + ? ('user' as const) + : ('assistant' as const), + content: partListUnionToString(msg.content), + })); + } + + const sessionInfo: SessionInfo = { + id: content.sessionId, + file: file.replace(/\.jsonl?$/, ''), + fileName: file, + startTime, + lastUpdated, + messageCount: content.messageCount ?? content.messages.length, + displayName: content.summary + ? stripUnsafeCharacters(content.summary) + : firstUserMessage, + firstUserMessage, + isCurrentSession, + index: 0, // Will be set after sorting valid sessions + summary: content.summary, + fullContent, + messages, + }; + + return { fileName: file, sessionInfo }; + } catch { + // File is corrupted (can't read or parse JSON) + return { fileName: file, sessionInfo: null }; + } + }, + ); + + return await Promise.all(sessionPromises); + } catch (error) { + // It's expected that the directory might not exist, which is not an error. + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + return []; + } + // For other errors (e.g., permissions), re-throw to be handled by the caller. + throw error; + } +}; + +/** + * Loads all valid session files from the chats directory and converts them to SessionInfo. + * Corrupted files are automatically filtered out. + */ +export const getSessionFiles = async ( + chatsDir: string, + currentSessionId?: string, + options: GetSessionOptions = {}, +): Promise => { + const allFiles = await getAllSessionFiles( + chatsDir, + currentSessionId, + options, + ); + + // Filter out corrupted files and extract SessionInfo + const validSessions = allFiles + .filter( + (entry): entry is { fileName: string; sessionInfo: SessionInfo } => + entry.sessionInfo !== null, + ) + .map((entry) => entry.sessionInfo); + + // Deduplicate sessions by ID + const uniqueSessionsMap = new Map(); + for (const session of validSessions) { + // If duplicate exists, keep the one with the later lastUpdated timestamp + if ( + !uniqueSessionsMap.has(session.id) || + new Date(session.lastUpdated).getTime() > + new Date(uniqueSessionsMap.get(session.id)!.lastUpdated).getTime() + ) { + uniqueSessionsMap.set(session.id, session); + } + } + const uniqueSessions = Array.from(uniqueSessionsMap.values()); + + // Sort by startTime (oldest first) for stable session numbering + uniqueSessions.sort( + (a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime(), + ); + + // Set the correct 1-based indexes after sorting + uniqueSessions.forEach((session, index) => { + session.index = index + 1; + }); + + return uniqueSessions; +}; + +/** + * Utility class for session discovery and selection. + */ +export class SessionSelector { + constructor(private storage: Storage) {} + + /** + * Checks if a session with the given ID already exists on disk. + */ + async sessionExists(id: string): Promise { + const chatsDir = path.join(this.storage.getProjectTempDir(), 'chats'); + const files = await fs.readdir(chatsDir).catch(() => []); + + // The filename format is `session--.jsonl` + const shortId = id.slice(0, 8); + const candidateFiles = files.filter( + (f) => + f.startsWith(SESSION_FILE_PREFIX) && + (f.endsWith(`-${shortId}.json`) || f.endsWith(`-${shortId}.jsonl`)), + ); + + for (const fileName of candidateFiles) { + try { + const sessionPath = path.join(chatsDir, fileName); + const sessionData = await loadConversationRecord(sessionPath); + if (sessionData && sessionData.sessionId === id) { + return true; + } + } catch { + // Ignore unparseable files + } + } + + return false; + } + + /** + * Lists all available sessions for the current project. + */ + async listSessions(): Promise { + const chatsDir = path.join(this.storage.getProjectTempDir(), 'chats'); + return getSessionFiles(chatsDir); + } + + /** + * Finds a session by identifier (UUID or numeric index). + * + * @param identifier - Can be a full UUID or an index number (1-based) + * @returns Promise resolving to the found SessionInfo + * @throws Error if the session is not found or identifier is invalid + */ + async findSession(identifier: string): Promise { + const trimmedIdentifier = identifier.trim(); + const sessions = await this.listSessions(); + + if (sessions.length === 0) { + throw SessionError.noSessionsFound(); + } + + // Sort by startTime (oldest first, so newest sessions get highest numbers) + const sortedSessions = sessions.sort( + (a, b) => + new Date(a.startTime).getTime() - new Date(b.startTime).getTime(), + ); + + // Try to find by UUID first + const sessionByUuid = sortedSessions.find( + (session) => session.id === trimmedIdentifier, + ); + if (sessionByUuid) { + return sessionByUuid; + } + + // Parse as index number (1-based) - only allow numeric indexes + const index = parseInt(trimmedIdentifier, 10); + if ( + !isNaN(index) && + index.toString() === trimmedIdentifier && + index > 0 && + index <= sortedSessions.length + ) { + return sortedSessions[index - 1]; + } + + const chatsDir = path.join(this.storage.getProjectTempDir(), 'chats'); + throw SessionError.invalidSessionIdentifier(trimmedIdentifier, chatsDir); + } + + /** + * Resolves a resume argument to a specific session. + * + * @param resumeArg - Can be "latest", a full UUID, or an index number (1-based) + * @returns Promise resolving to session selection result + */ + async resolveSession(resumeArg: string): Promise { + let selectedSession: SessionInfo; + const trimmedResumeArg = resumeArg.trim(); + + if (trimmedResumeArg === RESUME_LATEST) { + const sessions = await this.listSessions(); + + if (sessions.length === 0) { + throw SessionError.noSessionsFound(); + } + + // Sort by startTime (oldest first, so newest sessions get highest numbers) + sessions.sort( + (a, b) => + new Date(a.startTime).getTime() - new Date(b.startTime).getTime(), + ); + + selectedSession = sessions[sessions.length - 1]; + } else { + try { + selectedSession = await this.findSession(trimmedResumeArg); + } catch (error) { + // SessionError already has detailed messages - just rethrow + if (error instanceof SessionError) { + throw error; + } + // Wrap unexpected errors with context + throw new Error( + `Failed to find session "${trimmedResumeArg}": ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + return this.selectSession(selectedSession); + } + + /** + * Loads session data for a selected session. + */ + private async selectSession( + sessionInfo: SessionInfo, + ): Promise { + const chatsDir = path.join(this.storage.getProjectTempDir(), 'chats'); + const sessionPath = path.join(chatsDir, sessionInfo.fileName); + + try { + const sessionData = await loadConversationRecord(sessionPath); + if (!sessionData) { + throw new Error('Failed to load session data'); + } + const normalizedSessionData = { + ...sessionData, + startTime: sessionData.startTime || sessionInfo.startTime, + lastUpdated: sessionData.lastUpdated || sessionInfo.lastUpdated, + }; + + const displayInfo = `Session ${sessionInfo.index}: ${sessionInfo.firstUserMessage} (${sessionInfo.messageCount} messages, ${formatRelativeTime(sessionInfo.lastUpdated)})`; + + return { + sessionPath, + sessionData: normalizedSessionData, + displayInfo, + }; + } catch (error) { + throw new Error( + `Failed to load session ${sessionInfo.id}: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } + } +} + +/** + * Converts session/conversation data into UI history format. + */ +export function convertSessionToHistoryFormats( + messages: ConversationRecord['messages'], +): { + uiHistory: HistoryItemWithoutId[]; +} { + const uiHistory: HistoryItemWithoutId[] = []; + + for (const msg of messages) { + // Add thoughts if present + if (msg.type === 'gemini' && msg.thoughts && msg.thoughts.length > 0) { + for (const thought of msg.thoughts) { + uiHistory.push({ + type: 'thinking', + thought: { + subject: thought.subject, + description: thought.description, + }, + }); + } + } + + // Add the message only if it has content + const displayContentString = msg.displayContent + ? partListUnionToString(msg.displayContent) + : undefined; + const contentString = partListUnionToString(msg.content); + const uiText = displayContentString || contentString; + + // Skip internal context messages in the UI history + const trimmedText = uiText.trim(); + if ( + trimmedText.startsWith('') || + trimmedText.startsWith('') + ) { + continue; + } + + if (trimmedText) { + let messageType: MessageType; + switch (msg.type) { + case 'user': + messageType = MessageType.USER; + break; + case 'info': + messageType = MessageType.INFO; + break; + case 'error': + messageType = MessageType.ERROR; + break; + case 'warning': + messageType = MessageType.WARNING; + break; + case 'gemini': + messageType = MessageType.GEMINI; + break; + default: + checkExhaustive(msg); + messageType = MessageType.GEMINI; + break; + } + + uiHistory.push({ + type: messageType, + text: uiText, + }); + } + + // Add tool calls if present + if ( + msg.type !== 'user' && + 'toolCalls' in msg && + msg.toolCalls && + msg.toolCalls.length > 0 + ) { + uiHistory.push({ + type: 'tool_group', + tools: msg.toolCalls.map((tool) => ({ + callId: tool.id, + name: tool.displayName || tool.name, + args: tool.args, + description: tool.description || '', + renderOutputAsMarkdown: tool.renderOutputAsMarkdown ?? true, + status: + tool.status === 'success' + ? CoreToolCallStatus.Success + : CoreToolCallStatus.Error, + resultDisplay: tool.resultDisplay, + confirmationDetails: undefined, + })), + }); + } + } + + return { + uiHistory, + }; +} diff --git a/packages/cli/src/utils/sessions.ts b/packages/cli/src/utils/sessions.ts new file mode 100644 index 0000000000000000000000000000000000000000..96674466d81e884108140c7d19c9763bdf0b0684 --- /dev/null +++ b/packages/cli/src/utils/sessions.ts @@ -0,0 +1,109 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + deleteStoredSession, + generateSummary, + writeToStderr, + writeToStdout, + type Config, +} from '@google/gemini-cli-core'; +import { + formatRelativeTime, + SessionSelector, + type SessionInfo, +} from './sessionUtils.js'; + +export async function listSessions(config: Config): Promise { + // Generate summary for most recent session if needed + await generateSummary(config); + + const sessionSelector = new SessionSelector(config.storage); + const sessions = await sessionSelector.listSessions(); + + if (sessions.length === 0) { + writeToStdout('No previous sessions found for this project.'); + return; + } + + writeToStdout( + `\nAvailable sessions for this project (${sessions.length}):\n`, + ); + + sessions + .sort( + (a, b) => + new Date(a.startTime).getTime() - new Date(b.startTime).getTime(), + ) + .forEach((session, index) => { + const current = session.isCurrentSession ? ', current' : ''; + const time = formatRelativeTime(session.lastUpdated); + const title = + session.displayName.length > 100 + ? session.displayName.slice(0, 97) + '...' + : session.displayName; + writeToStdout( + ` ${index + 1}. ${title} (${time}${current}) [${session.id}]\n`, + ); + }); +} + +export async function deleteSession( + config: Config, + sessionIndex: string, +): Promise { + const sessionSelector = new SessionSelector(config.storage); + const sessions = await sessionSelector.listSessions(); + + if (sessions.length === 0) { + writeToStderr('No sessions found for this project.'); + return; + } + + // Sort sessions by start time to match list-sessions ordering + const sortedSessions = sessions.sort( + (a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime(), + ); + + let sessionToDelete: SessionInfo; + + // Try to find by UUID first + const sessionByUuid = sortedSessions.find( + (session) => session.id === sessionIndex, + ); + if (sessionByUuid) { + sessionToDelete = sessionByUuid; + } else { + // Parse session index + const index = parseInt(sessionIndex, 10); + if (isNaN(index) || index < 1 || index > sessions.length) { + writeToStderr( + `Invalid session identifier "${sessionIndex}". Use --list-sessions to see available sessions.`, + ); + return; + } + sessionToDelete = sortedSessions[index - 1]; + } + + // Prevent deleting the current session + if (sessionToDelete.isCurrentSession) { + writeToStderr('Cannot delete the current active session.'); + return; + } + + try { + await deleteStoredSession(config, sessionToDelete.file); + + const time = formatRelativeTime(sessionToDelete.lastUpdated); + writeToStdout( + `Deleted session ${sessionToDelete.index}: ${sessionToDelete.firstUserMessage} (${time})`, + ); + } catch (error) { + writeToStderr( + `Failed to delete session: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } +} diff --git a/packages/cli/src/utils/settingsUtils.test.ts b/packages/cli/src/utils/settingsUtils.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..9274c1b6f8641ebce798cff3e90e82c465f75e2f --- /dev/null +++ b/packages/cli/src/utils/settingsUtils.test.ts @@ -0,0 +1,842 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + // Schema utilities + getSettingsByCategory, + getSettingDefinition, + requiresRestart, + getDefaultValue, + getRestartRequiredSettings, + getEffectiveValue, + getAllSettingKeys, + getSettingsByType, + getSettingsRequiringRestart, + isValidSettingKey, + getSettingCategory, + shouldShowInDialog, + getDialogSettingsByCategory, + getDialogSettingsByType, + getDialogSettingKeys, + // Business logic utilities, + TEST_ONLY, + isInSettingsScope, + getDisplayValue, +} from './settingsUtils.js'; +import { + getSettingsSchema, + type SettingDefinition, + type Settings, + type SettingsSchema, + type SettingsSchemaType, +} from '../config/settingsSchema.js'; + +vi.mock('../config/settingsSchema.js', async (importOriginal) => { + const original = + await importOriginal(); + return { + ...original, + getSettingsSchema: vi.fn(), + }; +}); + +function makeMockSettings(settings: unknown): Settings { + return settings as Settings; +} + +describe('SettingsUtils', () => { + beforeEach(() => { + const SETTINGS_SCHEMA = { + mcpServers: { + type: 'object', + label: 'MCP Servers', + category: 'Advanced', + requiresRestart: true, + default: {} as Record, + description: 'Configuration for MCP servers.', + showInDialog: false, + }, + test: { + type: 'string', + label: 'Test', + category: 'Basic', + requiresRestart: false, + default: 'hello', + description: 'A test field', + showInDialog: true, + }, + advanced: { + type: 'object', + label: 'Advanced', + category: 'Advanced', + requiresRestart: true, + default: {}, + description: 'Advanced settings for power users.', + showInDialog: false, + properties: { + autoConfigureMemory: { + type: 'boolean', + label: 'Auto Configure Max Old Space Size', + category: 'Advanced', + requiresRestart: true, + default: false, + description: 'Automatically configure Node.js memory limits', + showInDialog: true, + }, + }, + }, + ui: { + type: 'object', + label: 'UI', + category: 'UI', + requiresRestart: false, + default: {}, + description: 'User interface settings.', + showInDialog: false, + properties: { + theme: { + type: 'string', + label: 'Theme', + category: 'UI', + requiresRestart: false, + default: undefined as string | undefined, + description: 'The color theme for the UI.', + showInDialog: false, + }, + requiresRestart: { + type: 'boolean', + label: 'Requires Restart', + category: 'UI', + default: false, + requiresRestart: true, + }, + accessibility: { + type: 'object', + label: 'Accessibility', + category: 'UI', + requiresRestart: true, + default: {}, + description: 'Accessibility settings.', + showInDialog: false, + properties: { + enableLoadingPhrases: { + type: 'boolean', + label: 'Enable Loading Phrases', + category: 'UI', + requiresRestart: true, + default: true, + description: 'Enable loading phrases during operations.', + showInDialog: true, + }, + }, + }, + }, + }, + tools: { + type: 'object', + label: 'Tools', + category: 'Tools', + requiresRestart: false, + default: {}, + description: 'Tool settings.', + showInDialog: false, + properties: { + shell: { + type: 'object', + label: 'Shell', + category: 'Tools', + requiresRestart: false, + default: {}, + description: 'Shell tool settings.', + showInDialog: false, + properties: { + pager: { + type: 'string', + label: 'Pager', + category: 'Tools', + requiresRestart: false, + default: 'less', + description: 'The pager to use for long output.', + showInDialog: true, + }, + }, + }, + }, + }, + } as const satisfies SettingsSchema; + + vi.mocked(getSettingsSchema).mockReturnValue( + SETTINGS_SCHEMA as unknown as SettingsSchemaType, + ); + }); + afterEach(() => { + TEST_ONLY.clearFlattenedSchema(); + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + describe('Schema Utilities', () => { + describe('getSettingsByCategory', () => { + it('should group settings by category', () => { + const categories = getSettingsByCategory(); + expect(categories).toHaveProperty('Advanced'); + expect(categories).toHaveProperty('Basic'); + }); + + it('should include key property in grouped settings', () => { + const categories = getSettingsByCategory(); + + Object.entries(categories).forEach(([_category, settings]) => { + settings.forEach((setting) => { + expect(setting.key).toBeDefined(); + }); + }); + }); + }); + + describe('getSettingDefinition', () => { + it('should return definition for valid setting', () => { + const definition = getSettingDefinition('ui.theme'); + expect(definition).toBeDefined(); + expect(definition?.label).toBe('Theme'); + }); + + it('should return undefined for invalid setting', () => { + const definition = getSettingDefinition('invalidSetting'); + expect(definition).toBeUndefined(); + }); + }); + + describe('requiresRestart', () => { + it('should return true for settings that require restart', () => { + expect(requiresRestart('ui.requiresRestart')).toBe(true); + }); + + it('should return false for settings that do not require restart', () => { + expect(requiresRestart('ui.theme')).toBe(false); + }); + + it('should return false for invalid settings', () => { + expect(requiresRestart('invalidSetting')).toBe(false); + }); + }); + + describe('getDefaultValue', () => { + it('should return correct default values', () => { + expect(getDefaultValue('test')).toBe('hello'); + expect(getDefaultValue('ui.requiresRestart')).toBe(false); + }); + + it('should return undefined for invalid settings', () => { + expect(getDefaultValue('invalidSetting')).toBeUndefined(); + }); + }); + + describe('getRestartRequiredSettings', () => { + it('should return all settings that require restart', () => { + const restartSettings = getRestartRequiredSettings(); + expect(restartSettings).toContain('mcpServers'); + expect(restartSettings).toContain('ui.requiresRestart'); + }); + }); + + describe('getEffectiveValue', () => { + it('should return value from settings when set', () => { + const settings = makeMockSettings({ ui: { requiresRestart: true } }); + + const value = getEffectiveValue('ui.requiresRestart', settings); + expect(value).toBe(true); + }); + + it('should return default value when not set anywhere', () => { + const settings = makeMockSettings({}); + + const value = getEffectiveValue('ui.requiresRestart', settings); + expect(value).toBe(false); // default value + }); + + it('should handle nested settings correctly', () => { + const settings = makeMockSettings({ + ui: { accessibility: { enableLoadingPhrases: false } }, + }); + + const value = getEffectiveValue( + 'ui.accessibility.enableLoadingPhrases', + settings, + ); + expect(value).toBe(false); + }); + + it('should return undefined for invalid settings', () => { + const settings = makeMockSettings({}); + + const value = getEffectiveValue('invalidSetting', settings); + expect(value).toBeUndefined(); + }); + }); + + describe('getAllSettingKeys', () => { + it('should return all setting keys', () => { + const keys = getAllSettingKeys(); + expect(keys).toContain('test'); + expect(keys).toContain('ui.accessibility.enableLoadingPhrases'); + }); + }); + + describe('getSettingsByType', () => { + it('should return only boolean settings', () => { + const booleanSettings = getSettingsByType('boolean'); + expect(booleanSettings.length).toBeGreaterThan(0); + booleanSettings.forEach((setting) => { + expect(setting.type).toBe('boolean'); + }); + }); + }); + + describe('getSettingsRequiringRestart', () => { + it('should return only settings that require restart', () => { + const restartSettings = getSettingsRequiringRestart(); + expect(restartSettings.length).toBeGreaterThan(0); + restartSettings.forEach((setting) => { + expect(setting.requiresRestart).toBe(true); + }); + }); + }); + + describe('isValidSettingKey', () => { + it('should return true for valid setting keys', () => { + expect(isValidSettingKey('ui.requiresRestart')).toBe(true); + expect(isValidSettingKey('ui.accessibility.enableLoadingPhrases')).toBe( + true, + ); + }); + + it('should return false for invalid setting keys', () => { + expect(isValidSettingKey('invalidSetting')).toBe(false); + expect(isValidSettingKey('')).toBe(false); + }); + }); + + describe('getSettingCategory', () => { + it('should return correct category for valid settings', () => { + expect(getSettingCategory('ui.requiresRestart')).toBe('UI'); + expect( + getSettingCategory('ui.accessibility.enableLoadingPhrases'), + ).toBe('UI'); + }); + + it('should return undefined for invalid settings', () => { + expect(getSettingCategory('invalidSetting')).toBeUndefined(); + }); + }); + + describe('shouldShowInDialog', () => { + it('should return true for settings marked to show in dialog', () => { + expect(shouldShowInDialog('ui.requiresRestart')).toBe(true); + expect(shouldShowInDialog('general.vimMode')).toBe(true); + expect(shouldShowInDialog('ui.hideWindowTitle')).toBe(true); + }); + + it('should return false for settings marked to hide from dialog', () => { + expect(shouldShowInDialog('ui.theme')).toBe(false); + }); + + it('should return true for invalid settings (default behavior)', () => { + expect(shouldShowInDialog('invalidSetting')).toBe(true); + }); + }); + + describe('getDialogSettingsByCategory', () => { + it('should only return settings marked for dialog display', async () => { + const categories = getDialogSettingsByCategory(); + + // Should include UI settings that are marked for dialog + expect(categories['UI']).toBeDefined(); + const uiSettings = categories['UI']; + const uiKeys = uiSettings.map((s) => s.key); + expect(uiKeys).toContain('ui.requiresRestart'); + expect(uiKeys).toContain('ui.accessibility.enableLoadingPhrases'); + expect(uiKeys).not.toContain('ui.theme'); // This is now marked false + }); + + it('should include Advanced category settings', () => { + const categories = getDialogSettingsByCategory(); + + // Advanced settings should now be included because of autoConfigureMemory + expect(categories['Advanced']).toBeDefined(); + const advancedSettings = categories['Advanced']; + expect(advancedSettings.map((s) => s.key)).toContain( + 'advanced.autoConfigureMemory', + ); + }); + + it('should include settings with showInDialog=true', () => { + const categories = getDialogSettingsByCategory(); + + const allSettings = Object.values(categories).flat(); + const allKeys = allSettings.map((s) => s.key); + + expect(allKeys).toContain('test'); + expect(allKeys).toContain('ui.requiresRestart'); + expect(allKeys).not.toContain('ui.theme'); // Now hidden + expect(allKeys).not.toContain('general.preferredEditor'); // Now hidden + }); + }); + + describe('getDialogSettingsByType', () => { + it('should return only boolean dialog settings', () => { + const booleanSettings = getDialogSettingsByType('boolean'); + + const keys = booleanSettings.map((s) => s.key); + expect(keys).toContain('ui.requiresRestart'); + expect(keys).toContain('ui.accessibility.enableLoadingPhrases'); + expect(keys).not.toContain('privacy.usageStatisticsEnabled'); + expect(keys).not.toContain('security.auth.selectedType'); // Advanced setting + expect(keys).not.toContain('security.auth.useExternal'); // Advanced setting + }); + + it('should return only string dialog settings', () => { + const stringSettings = getDialogSettingsByType('string'); + + const keys = stringSettings.map((s) => s.key); + // Note: theme and preferredEditor are now hidden from dialog + expect(keys).not.toContain('ui.theme'); // Now marked false + expect(keys).not.toContain('general.preferredEditor'); // Now marked false + expect(keys).not.toContain('security.auth.selectedType'); // Advanced setting + + // Check that user-facing tool settings are included + expect(keys).toContain('tools.shell.pager'); + + // Check that advanced/hidden tool settings are excluded + expect(keys).not.toContain('tools.discoveryCommand'); + expect(keys).not.toContain('tools.callCommand'); + expect(keys.every((key) => !key.startsWith('advanced.'))).toBe(true); + }); + }); + + describe('getDialogSettingKeys', () => { + it('should return only settings marked for dialog display', () => { + const dialogKeys = getDialogSettingKeys(); + + // Should include settings marked for dialog + expect(dialogKeys).toContain('ui.requiresRestart'); + + // Should include nested settings marked for dialog + expect(dialogKeys).toContain('ui.accessibility.enableLoadingPhrases'); + + // Should NOT include settings marked as hidden + expect(dialogKeys).not.toContain('ui.theme'); // Hidden + }); + + it('should return fewer keys than getAllSettingKeys', () => { + const allKeys = getAllSettingKeys(); + const dialogKeys = getDialogSettingKeys(); + + expect(dialogKeys.length).toBeLessThan(allKeys.length); + expect(dialogKeys.length).toBeGreaterThan(0); + }); + + const nestedDialogKey = 'context.fileFiltering.respectGitIgnore'; + + function mockNestedDialogSchema() { + vi.mocked(getSettingsSchema).mockReturnValue({ + context: { + type: 'object', + label: 'Context', + category: 'Context', + requiresRestart: false, + default: {}, + description: 'Settings for managing context provided to the model.', + showInDialog: false, + properties: { + fileFiltering: { + type: 'object', + label: 'File Filtering', + category: 'Context', + requiresRestart: true, + default: {}, + description: 'Settings for git-aware file filtering.', + showInDialog: false, + properties: { + respectGitIgnore: { + type: 'boolean', + label: 'Respect .gitignore', + category: 'Context', + requiresRestart: true, + default: true, + description: 'Respect .gitignore files when searching', + showInDialog: true, + }, + }, + }, + }, + }, + } as unknown as SettingsSchemaType); + } + + it('should include nested file filtering setting in dialog keys', () => { + mockNestedDialogSchema(); + + const dialogKeys = getDialogSettingKeys(); + expect(dialogKeys).toContain(nestedDialogKey); + }); + }); + }); + + describe('Business Logic Utilities', () => { + describe('isInSettingsScope', () => { + it('should return true for top-level settings that exist', () => { + const settings = makeMockSettings({ ui: { requiresRestart: true } }); + expect(isInSettingsScope('ui.requiresRestart', settings)).toBe(true); + }); + + it('should return false for top-level settings that do not exist', () => { + const settings = makeMockSettings({}); + expect(isInSettingsScope('ui.requiresRestart', settings)).toBe(false); + }); + + it('should return true for nested settings that exist', () => { + const settings = makeMockSettings({ + ui: { accessibility: { enableLoadingPhrases: true } }, + }); + expect( + isInSettingsScope('ui.accessibility.enableLoadingPhrases', settings), + ).toBe(true); + }); + + it('should return false for nested settings that do not exist', () => { + const settings = makeMockSettings({}); + expect( + isInSettingsScope('ui.accessibility.enableLoadingPhrases', settings), + ).toBe(false); + }); + + it('should return false when parent exists but child does not', () => { + const settings = makeMockSettings({ ui: { accessibility: {} } }); + expect( + isInSettingsScope('ui.accessibility.enableLoadingPhrases', settings), + ).toBe(false); + }); + }); + + describe('getDisplayValue', () => { + describe('enum behavior', () => { + enum StringEnum { + FOO = 'foo', + BAR = 'bar', + BAZ = 'baz', + } + + enum NumberEnum { + ONE = 1, + TWO = 2, + THREE = 3, + } + + const SETTING: SettingDefinition = { + type: 'enum', + label: 'Theme', + options: [ + { + value: StringEnum.FOO, + label: 'Foo', + }, + { + value: StringEnum.BAR, + label: 'Bar', + }, + { + value: StringEnum.BAZ, + label: 'Baz', + }, + ], + category: 'UI', + requiresRestart: false, + default: StringEnum.BAR, + description: 'The color theme for the UI.', + showInDialog: false, + }; + + it('handles display of number-based enums', () => { + vi.mocked(getSettingsSchema).mockReturnValue({ + ui: { + properties: { + theme: { + ...SETTING, + options: [ + { + value: NumberEnum.ONE, + label: 'One', + }, + { + value: NumberEnum.TWO, + label: 'Two', + }, + { + value: NumberEnum.THREE, + label: 'Three', + }, + ], + }, + }, + }, + } as unknown as SettingsSchemaType); + + const settings = makeMockSettings({ + ui: { theme: NumberEnum.THREE }, + }); + const mergedSettings = makeMockSettings({ + ui: { theme: NumberEnum.THREE }, + }); + + const result = getDisplayValue('ui.theme', settings, mergedSettings); + + expect(result).toBe('Three*'); + }); + + it('handles default values for number-based enums', () => { + vi.mocked(getSettingsSchema).mockReturnValue({ + ui: { + properties: { + theme: { + ...SETTING, + default: NumberEnum.THREE, + options: [ + { + value: NumberEnum.ONE, + label: 'One', + }, + { + value: NumberEnum.TWO, + label: 'Two', + }, + { + value: NumberEnum.THREE, + label: 'Three', + }, + ], + }, + }, + }, + } as unknown as SettingsSchemaType); + + const result = getDisplayValue( + 'ui.theme', + makeMockSettings({}), + makeMockSettings({}), + ); + expect(result).toBe('Three'); + }); + + it('shows the enum display value', () => { + vi.mocked(getSettingsSchema).mockReturnValue({ + ui: { properties: { theme: { ...SETTING } } }, + } as unknown as SettingsSchemaType); + const settings = makeMockSettings({ ui: { theme: StringEnum.BAR } }); + const mergedSettings = makeMockSettings({ + ui: { theme: StringEnum.BAR }, + }); + + const result = getDisplayValue('ui.theme', settings, mergedSettings); + expect(result).toBe('Bar*'); + }); + + it('passes through unknown values verbatim', () => { + vi.mocked(getSettingsSchema).mockReturnValue({ + ui: { + properties: { + theme: { ...SETTING }, + }, + }, + } as unknown as SettingsSchemaType); + const settings = makeMockSettings({ ui: { theme: 'xyz' } }); + const mergedSettings = makeMockSettings({ ui: { theme: 'xyz' } }); + + const result = getDisplayValue('ui.theme', settings, mergedSettings); + expect(result).toBe('xyz*'); + }); + + it('shows the default value for string enums', () => { + vi.mocked(getSettingsSchema).mockReturnValue({ + ui: { + properties: { + theme: { ...SETTING, default: StringEnum.BAR }, + }, + }, + } as unknown as SettingsSchemaType); + + const result = getDisplayValue( + 'ui.theme', + makeMockSettings({}), + makeMockSettings({}), + ); + expect(result).toBe('Bar'); + }); + }); + + it('should show value with * when setting exists in scope', () => { + const settings = makeMockSettings({ ui: { requiresRestart: true } }); + const mergedSettings = makeMockSettings({ + ui: { requiresRestart: true }, + }); + + const result = getDisplayValue( + 'ui.requiresRestart', + settings, + mergedSettings, + ); + expect(result).toBe('true*'); + }); + it('should not show * when key is not in scope', () => { + const settings = makeMockSettings({}); // no setting in scope + const mergedSettings = makeMockSettings({ + ui: { requiresRestart: false }, + }); + + const result = getDisplayValue( + 'ui.requiresRestart', + settings, + mergedSettings, + ); + expect(result).toBe('false'); // shows default value + }); + + it('should show value with * when setting exists in scope, even when it matches default', () => { + const settings = makeMockSettings({ + ui: { requiresRestart: false }, + }); // false matches default, but key is explicitly set in scope + const mergedSettings = makeMockSettings({ + ui: { requiresRestart: false }, + }); + + const result = getDisplayValue( + 'ui.requiresRestart', + settings, + mergedSettings, + ); + expect(result).toBe('false*'); + }); + + it('should show schema default (not inherited merged value) when key is not in scope', () => { + const settings = makeMockSettings({}); // no setting in current scope + const mergedSettings = makeMockSettings({ + ui: { requiresRestart: true }, + }); // inherited merged value differs from schema default (false) + + const result = getDisplayValue( + 'ui.requiresRestart', + settings, + mergedSettings, + ); + expect(result).toBe('false'); + }); + + it('should display objects as JSON strings, not "[object Object]"', () => { + vi.mocked(getSettingsSchema).mockReturnValue({ + experimental: { + type: 'object', + label: 'Experimental', + category: 'Experimental', + requiresRestart: true, + default: {}, + description: 'Experimental settings', + showInDialog: false, + properties: { + gemmaModelRouter: { + type: 'object', + label: 'Gemma Model Router', + category: 'Experimental', + requiresRestart: true, + default: {}, + description: 'Gemma model router settings', + showInDialog: true, + }, + }, + }, + } as unknown as SettingsSchemaType); + + // Test with empty object (default) + const emptySettings = makeMockSettings({}); + const emptyResult = getDisplayValue( + 'experimental.gemmaModelRouter', + emptySettings, + emptySettings, + ); + expect(emptyResult).toBe('{}'); + expect(emptyResult).not.toBe('[object Object]'); + + // Test with object containing values + const settings = makeMockSettings({ + experimental: { + gemmaModelRouter: { enabled: true, host: 'localhost' }, + }, + }); + const result = getDisplayValue( + 'experimental.gemmaModelRouter', + settings, + settings, + ); + expect(result).toBe('{"enabled":true,"host":"localhost"}*'); + expect(result).not.toContain('[object Object]'); + }); + }); + + describe('getDisplayValue with units', () => { + it('should format percentage correctly when unit is %', () => { + vi.mocked(getSettingsSchema).mockReturnValue({ + model: { + properties: { + compressionThreshold: { + type: 'number', + label: 'Context Compression Threshold', + category: 'Model', + requiresRestart: true, + default: 0.5, + unit: '%', + }, + }, + }, + } as unknown as SettingsSchemaType); + + const settings = makeMockSettings({ + model: { compressionThreshold: 0.8 }, + }); + const result = getDisplayValue( + 'model.compressionThreshold', + settings, + makeMockSettings({}), + ); + expect(result).toBe('0.8 (80%)*'); + }); + + it('should append unit for non-% units', () => { + vi.mocked(getSettingsSchema).mockReturnValue({ + ui: { + properties: { + pollingInterval: { + type: 'number', + label: 'Polling Interval', + category: 'UI', + requiresRestart: false, + default: 60, + unit: 's', + }, + }, + }, + } as unknown as SettingsSchemaType); + + const settings = makeMockSettings({ ui: { pollingInterval: 30 } }); + const result = getDisplayValue( + 'ui.pollingInterval', + settings, + makeMockSettings({}), + ); + expect(result).toBe('30s*'); + }); + }); + }); +}); diff --git a/packages/cli/src/utils/settingsUtils.ts b/packages/cli/src/utils/settingsUtils.ts new file mode 100644 index 0000000000000000000000000000000000000000..371c28649ac2bc1058bb31a4e4480a5b4043818c --- /dev/null +++ b/packages/cli/src/utils/settingsUtils.ts @@ -0,0 +1,407 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Settings } from '../config/settings.js'; +import { + getSettingsSchema, + type SettingDefinition, + type SettingsSchema, + type SettingsType, + type SettingsValue, +} from '../config/settingsSchema.js'; +import { ExperimentFlags, type Config } from '@google/gemini-cli-core'; + +// The schema is now nested, but many parts of the UI and logic work better +// with a flattened structure and dot-notation keys. This section flattens the +// schema into a map for easier lookups. + +type FlattenedSchema = Record; + +function flattenSchema(schema: SettingsSchema, prefix = ''): FlattenedSchema { + let result: FlattenedSchema = {}; + for (const key in schema) { + const newKey = prefix ? `${prefix}.${key}` : key; + const definition = schema[key]; + result[newKey] = { ...definition, key: newKey }; + if (definition.properties) { + result = { ...result, ...flattenSchema(definition.properties, newKey) }; + } + } + return result; +} + +let _FLATTENED_SCHEMA: FlattenedSchema | undefined; + +/** Returns a flattened schema, the first call is memoized for future requests. */ +export function getFlattenedSchema() { + return ( + _FLATTENED_SCHEMA ?? + (_FLATTENED_SCHEMA = flattenSchema(getSettingsSchema())) + ); +} + +function clearFlattenedSchema() { + _FLATTENED_SCHEMA = undefined; +} + +export function getSettingsByCategory(): Record< + string, + Array +> { + const categories: Record< + string, + Array + > = {}; + + Object.values(getFlattenedSchema()).forEach((definition) => { + const category = definition.category; + if (!categories[category]) { + categories[category] = []; + } + categories[category].push(definition); + }); + + return categories; +} + +export function getSettingDefinition( + key: string, +): (SettingDefinition & { key: string }) | undefined { + return getFlattenedSchema()[key]; +} + +export function requiresRestart(key: string): boolean { + return getFlattenedSchema()[key]?.requiresRestart ?? false; +} + +export function getDefaultValue(key: string): SettingsValue { + return getFlattenedSchema()[key]?.default; +} + +/** + * Get the effective default value for a setting, checking experiment values when available. + * For settings like Context Compression Threshold, this will return the experiment value if set, + * otherwise falls back to the schema default. + */ +export function getEffectiveDefaultValue( + key: string, + config?: Config, +): SettingsValue { + if (key === 'model.compressionThreshold' && config) { + const experiments = config.getExperiments(); + const experimentValue = + experiments?.flags[ExperimentFlags.CONTEXT_COMPRESSION_THRESHOLD] + ?.floatValue; + if (experimentValue !== undefined && experimentValue !== 0) { + return experimentValue; + } + } + + return getDefaultValue(key); +} + +export function getRestartRequiredSettings(): string[] { + return Object.values(getFlattenedSchema()) + .filter((definition) => definition.requiresRestart) + .map((definition) => definition.key); +} + +/** + * Get restart-required setting keys that are also visible in the dialog. + * Non-dialog restart keys (e.g. parent container objects like mcpServers, tools) + * are excluded because users cannot change them through the dialog. + */ +export function getDialogRestartRequiredSettings(): string[] { + return Object.values(getFlattenedSchema()) + .filter( + (definition) => + definition.requiresRestart && definition.showInDialog !== false, + ) + .map((definition) => definition.key); +} + +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function isSettingsValue(value: unknown): value is SettingsValue { + if (value === undefined) return true; + if (value === null) return false; + const type = typeof value; + return ( + type === 'string' || + type === 'number' || + type === 'boolean' || + type === 'object' + ); +} + +/** + * Gets a value from a nested object using a key path array iteratively. + */ +export function getNestedValue(obj: unknown, path: string[]): unknown { + let current = obj; + for (const key of path) { + if (!isRecord(current) || !(key in current)) { + return undefined; + } + current = current[key]; + } + return current; +} + +/** + * Get the effective value for a setting falling back to the default value + */ +export function getEffectiveValue( + key: string, + settings: Settings, +): SettingsValue { + const definition = getSettingDefinition(key); + if (!definition) { + return undefined; + } + + const path = key.split('.'); + + // Check the current scope's settings first + const value = getNestedValue(settings, path); + if (value !== undefined && isSettingsValue(value)) { + return value; + } + + // Return default value if no value is set anywhere + return definition.default; +} + +export function getAllSettingKeys(): string[] { + return Object.keys(getFlattenedSchema()); +} + +export function getSettingsByType( + type: SettingsType, +): Array { + return Object.values(getFlattenedSchema()).filter( + (definition) => definition.type === type, + ); +} + +export function getSettingsRequiringRestart(): Array< + SettingDefinition & { + key: string; + } +> { + return Object.values(getFlattenedSchema()).filter( + (definition) => definition.requiresRestart, + ); +} + +/** + * Validate if a setting key exists in the schema + */ +export function isValidSettingKey(key: string): boolean { + return key in getFlattenedSchema(); +} + +export function getSettingCategory(key: string): string | undefined { + return getFlattenedSchema()[key]?.category; +} + +export function shouldShowInDialog(key: string): boolean { + return getFlattenedSchema()[key]?.showInDialog ?? true; // Default to true for backward compatibility +} + +export function getDialogSettingKeys(): string[] { + return Object.values(getFlattenedSchema()) + .filter((definition) => definition.showInDialog !== false) + .map((definition) => definition.key); +} + +/** + * Get all settings that should be shown in the dialog, grouped by category like "Advanced", "General", etc. + */ +export function getDialogSettingsByCategory(): Record< + string, + Array +> { + const categories: Record< + string, + Array + > = {}; + + Object.values(getFlattenedSchema()) + .filter((definition) => definition.showInDialog !== false) + .forEach((definition) => { + const category = definition.category; + if (!categories[category]) { + categories[category] = []; + } + categories[category].push(definition); + }); + + return categories; +} + +export function getDialogSettingsByType( + type: SettingsType, +): Array { + return Object.values(getFlattenedSchema()).filter( + (definition) => + definition.type === type && definition.showInDialog !== false, + ); +} + +export function isInSettingsScope( + key: string, + scopeSettings: Settings, +): boolean { + const path = key.split('.'); + const value = getNestedValue(scopeSettings, path); + return value !== undefined; +} + +/** + * Appends a star (*) to settings that exist in the scope + */ +export function getDisplayValue( + key: string, + scopeSettings: Settings, + _mergedSettings: Settings, +): string { + const definition = getSettingDefinition(key); + const existsInScope = isInSettingsScope(key, scopeSettings); + + let value: SettingsValue; + if (existsInScope) { + value = getEffectiveValue(key, scopeSettings); + } else { + value = getDefaultValue(key); + } + + let valueString = String(value); + + // Handle object types by stringifying them + if ( + definition?.type === 'object' && + value !== null && + typeof value === 'object' + ) { + valueString = JSON.stringify(value); + } else if (definition?.type === 'enum' && definition.options) { + const option = definition.options?.find((option) => option.value === value); + valueString = option?.label ?? `${value}`; + } + + if (definition?.unit === '%' && typeof value === 'number') { + valueString = `${value} (${Math.round(value * 100)}%)`; + } else if (definition?.unit) { + valueString = `${valueString}${definition.unit}`; + } + if (existsInScope) { + return `${valueString}*`; + } + + return valueString; +} + +/**Utilities for parsing Settings that can be inline edited by the user typing out values */ +function tryParseJsonStringArray(input: string): string[] | null { + try { + const parsed: unknown = JSON.parse(input); + if ( + Array.isArray(parsed) && + parsed.every((item): item is string => typeof item === 'string') + ) { + return parsed; + } + return null; + } catch { + return null; + } +} + +function tryParseJsonObject(input: string): Record | null { + try { + const parsed: unknown = JSON.parse(input); + if (isRecord(parsed) && !Array.isArray(parsed)) { + return parsed; + } + return null; + } catch { + return null; + } +} + +function parseStringArrayValue(input: string): string[] { + const trimmed = input.trim(); + if (trimmed === '') return []; + + return ( + tryParseJsonStringArray(trimmed) ?? + input + .split(',') + .map((p) => p.trim()) + .filter((p) => p.length > 0) + ); +} + +function parseObjectValue(input: string): Record | null { + const trimmed = input.trim(); + if (trimmed === '') { + return null; + } + + return tryParseJsonObject(trimmed); +} + +export function parseEditedValue( + type: SettingsType, + newValue: string, +): SettingsValue | null { + if (type === 'number') { + if (newValue.trim() === '') { + return null; + } + + const numParsed = Number(newValue.trim()); + if (Number.isNaN(numParsed)) { + return null; + } + + return numParsed; + } + + if (type === 'array') { + return parseStringArrayValue(newValue); + } + + if (type === 'object') { + return parseObjectValue(newValue); + } + + return newValue; +} + +export function getEditValue( + type: SettingsType, + rawValue: SettingsValue, +): string | undefined { + if (rawValue === undefined) { + return undefined; + } + + if (type === 'array' && Array.isArray(rawValue)) { + return rawValue.join(', '); + } + + if (type === 'object' && rawValue !== null && typeof rawValue === 'object') { + return JSON.stringify(rawValue); + } + + return undefined; +} + +export const TEST_ONLY = { clearFlattenedSchema }; diff --git a/packages/cli/src/utils/terminalNotifications.test.ts b/packages/cli/src/utils/terminalNotifications.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..4abd3dddc48b973d494fe77a0d4fab8839090599 --- /dev/null +++ b/packages/cli/src/utils/terminalNotifications.test.ts @@ -0,0 +1,299 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + buildRunEventNotificationContent, + MAX_NOTIFICATION_BODY_CHARS, + MAX_NOTIFICATION_SUBTITLE_CHARS, + MAX_NOTIFICATION_TITLE_CHARS, + notifyViaTerminal, + TerminalNotificationMethod, +} from './terminalNotifications.js'; + +const writeToStdout = vi.hoisted(() => vi.fn()); +const debugLogger = vi.hoisted(() => ({ + debug: vi.fn(), +})); + +vi.mock('@google/gemini-cli-core', () => ({ + writeToStdout, + debugLogger, +})); + +describe('terminal notifications', () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.unstubAllEnvs(); + vi.stubEnv('TMUX', ''); + vi.stubEnv('STY', ''); + vi.stubEnv('WT_SESSION', ''); + vi.stubEnv('TERM_PROGRAM', ''); + vi.stubEnv('TERM', ''); + vi.stubEnv('ALACRITTY_WINDOW_ID', ''); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('returns false without writing when disabled', async () => { + const shown = await notifyViaTerminal(false, { + title: 't', + body: 'b', + }); + + expect(shown).toBe(false); + expect(writeToStdout).not.toHaveBeenCalled(); + }); + + it('emits OSC 9 notification when iTerm2 is detected', async () => { + vi.stubEnv('TERM_PROGRAM', 'iTerm.app'); + + const shown = await notifyViaTerminal(true, { + title: 'Title "quoted"', + subtitle: 'Sub\\title', + body: 'Body', + }); + + expect(shown).toBe(true); + expect(writeToStdout).toHaveBeenCalledTimes(1); + const emitted = String(writeToStdout.mock.calls[0][0]); + expect(emitted.startsWith('\x1b]9;')).toBe(true); + expect(emitted.endsWith('\x07')).toBe(true); + }); + + it('emits OSC 777 for unknown terminals', async () => { + const shown = await notifyViaTerminal(true, { + title: 'Title', + subtitle: 'Subtitle', + body: 'Body', + }); + + expect(shown).toBe(true); + expect(writeToStdout).toHaveBeenCalledTimes(1); + const emitted = String(writeToStdout.mock.calls[0][0]); + expect(emitted.startsWith('\x1b]777;notify;')).toBe(true); + }); + + it('uses BEL when Windows Terminal is detected', async () => { + vi.stubEnv('WT_SESSION', '1'); + + const shown = await notifyViaTerminal(true, { + title: 'Title', + body: 'Body', + }); + + expect(shown).toBe(true); + expect(writeToStdout).toHaveBeenCalledWith('\x07'); + }); + + it('uses BEL when Alacritty is detected', async () => { + vi.stubEnv('ALACRITTY_WINDOW_ID', '1'); + + const shown = await notifyViaTerminal(true, { + title: 'Title', + body: 'Body', + }); + + expect(shown).toBe(true); + expect(writeToStdout).toHaveBeenCalledWith('\x07'); + }); + + it('uses BEL when Apple Terminal is detected', async () => { + vi.stubEnv('TERM_PROGRAM', 'Apple_Terminal'); + + const shown = await notifyViaTerminal(true, { + title: 'Title', + body: 'Body', + }); + + expect(shown).toBe(true); + expect(writeToStdout).toHaveBeenCalledWith('\x07'); + }); + + it('uses BEL when VSCode Terminal is detected', async () => { + vi.stubEnv('TERM_PROGRAM', 'vscode'); + + const shown = await notifyViaTerminal(true, { + title: 'Title', + body: 'Body', + }); + + expect(shown).toBe(true); + expect(writeToStdout).toHaveBeenCalledWith('\x07'); + }); + + it('returns false and does not throw when terminal write fails', async () => { + writeToStdout.mockImplementation(() => { + throw new Error('no permissions'); + }); + + await expect( + notifyViaTerminal(true, { + title: 'Title', + body: 'Body', + }), + ).resolves.toBe(false); + expect(debugLogger.debug).toHaveBeenCalledTimes(1); + }); + + it('strips terminal control sequences and newlines from payload text', async () => { + vi.stubEnv('TERM_PROGRAM', 'iTerm.app'); + + const shown = await notifyViaTerminal(true, { + title: 'Title', + body: '\x1b[32mGreen\x1b[0m\nLine', + }); + + expect(shown).toBe(true); + const emitted = String(writeToStdout.mock.calls[0][0]); + const payload = emitted.slice('\x1b]9;'.length, -1); + expect(payload).toContain('Green'); + expect(payload).toContain('Line'); + expect(payload).not.toContain('[32m'); + expect(payload).not.toContain('\n'); + expect(payload).not.toContain('\r'); + }); + + it('builds bounded attention notification content', () => { + const content = buildRunEventNotificationContent({ + type: 'attention', + heading: 'h'.repeat(400), + detail: 'd'.repeat(400), + }); + + expect(content.title.length).toBeLessThanOrEqual( + MAX_NOTIFICATION_TITLE_CHARS, + ); + expect((content.subtitle ?? '').length).toBeLessThanOrEqual( + MAX_NOTIFICATION_SUBTITLE_CHARS, + ); + expect(content.body.length).toBeLessThanOrEqual( + MAX_NOTIFICATION_BODY_CHARS, + ); + }); + + it('emits OSC 9 notification when method is explicitly set to osc9', async () => { + // Explicitly set terminal to something that would normally use BEL + vi.stubEnv('WT_SESSION', '1'); + + const shown = await notifyViaTerminal( + true, + { + title: 'Explicit OSC 9', + body: 'Body', + }, + TerminalNotificationMethod.Osc9, + ); + + expect(shown).toBe(true); + expect(writeToStdout).toHaveBeenCalledTimes(1); + const emitted = String(writeToStdout.mock.calls[0][0]); + expect(emitted.startsWith('\x1b]9;')).toBe(true); + expect(emitted.endsWith('\x07')).toBe(true); + expect(emitted).toContain('Explicit OSC 9'); + }); + + it('emits OSC 777 notification when method is explicitly set to osc777', async () => { + // Explicitly set terminal to something that would normally use BEL + vi.stubEnv('WT_SESSION', '1'); + const shown = await notifyViaTerminal( + true, + { + title: 'Explicit OSC 777', + body: 'Body', + }, + TerminalNotificationMethod.Osc777, + ); + + expect(shown).toBe(true); + expect(writeToStdout).toHaveBeenCalledTimes(1); + const emitted = String(writeToStdout.mock.calls[0][0]); + expect(emitted.startsWith('\x1b]777;notify;')).toBe(true); + expect(emitted.endsWith('\x07')).toBe(true); + expect(emitted).toContain('Explicit OSC 777'); + }); + + it('emits BEL notification when method is explicitly set to bell', async () => { + // Explicitly set terminal to something that supports OSC 9 + vi.stubEnv('TERM_PROGRAM', 'iTerm.app'); + + const shown = await notifyViaTerminal( + true, + { + title: 'Explicit BEL', + body: 'Body', + }, + TerminalNotificationMethod.Bell, + ); + + expect(shown).toBe(true); + expect(writeToStdout).toHaveBeenCalledTimes(1); + expect(writeToStdout).toHaveBeenCalledWith('\x07'); + }); + + it('replaces semicolons with colons in OSC 777 to avoid breaking the sequence', async () => { + const shown = await notifyViaTerminal( + true, + { + title: 'Title; with; semicolons', + subtitle: 'Sub;title', + body: 'Body; with; semicolons', + }, + TerminalNotificationMethod.Osc777, + ); + + expect(shown).toBe(true); + const emitted = String(writeToStdout.mock.calls[0][0]); + + // Format: \x1b]777;notify;title;body\x07 + expect(emitted).toContain('Title: with: semicolons'); + expect(emitted).toContain('Sub:title'); + expect(emitted).toContain('Body: with: semicolons'); + expect(emitted).not.toContain('Title; with; semicolons'); + expect(emitted).not.toContain('Body; with; semicolons'); + + // Extract everything after '\x1b]777;notify;' and before '\x07' + const payload = emitted.slice('\x1b]777;notify;'.length, -1); + + // There should be exactly one semicolon separating title and body + const semicolonsCount = (payload.match(/;/g) || []).length; + expect(semicolonsCount).toBe(1); + }); + + it('wraps OSC sequence in tmux passthrough when TMUX env var is set', async () => { + vi.stubEnv('TMUX', '1'); + vi.stubEnv('TERM_PROGRAM', 'iTerm.app'); + + const shown = await notifyViaTerminal(true, { + title: 'Title', + body: 'Body', + }); + + expect(shown).toBe(true); + expect(writeToStdout).toHaveBeenCalledTimes(1); + const emitted = String(writeToStdout.mock.calls[0][0]); + expect(emitted.startsWith('\x1bPtmux;\x1b\x1b]9;')).toBe(true); + expect(emitted.endsWith('\x1b\\')).toBe(true); + }); + + it('wraps OSC sequence in GNU screen passthrough when STY env var is set', async () => { + vi.stubEnv('STY', '1'); + vi.stubEnv('TERM_PROGRAM', 'iTerm.app'); + + const shown = await notifyViaTerminal(true, { + title: 'Title', + body: 'Body', + }); + + expect(shown).toBe(true); + expect(writeToStdout).toHaveBeenCalledTimes(1); + const emitted = String(writeToStdout.mock.calls[0][0]); + expect(emitted.startsWith('\x1bP\x1b]9;')).toBe(true); + expect(emitted.endsWith('\x1b\\')).toBe(true); + }); +}); diff --git a/packages/cli/src/utils/terminalNotifications.ts b/packages/cli/src/utils/terminalNotifications.ts new file mode 100644 index 0000000000000000000000000000000000000000..ea2539ff0fca4c239f399439b2fbb88c3e3c5a39 --- /dev/null +++ b/packages/cli/src/utils/terminalNotifications.ts @@ -0,0 +1,179 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { debugLogger, writeToStdout } from '@google/gemini-cli-core'; +import type { LoadedSettings } from '../config/settings.js'; +import { sanitizeForDisplay } from '../ui/utils/textUtils.js'; +import { TerminalCapabilityManager } from '../ui/utils/terminalCapabilityManager.js'; + +export const MAX_NOTIFICATION_TITLE_CHARS = 48; +export const MAX_NOTIFICATION_SUBTITLE_CHARS = 64; +export const MAX_NOTIFICATION_BODY_CHARS = 180; + +const BEL = '\x07'; +const OSC9_PREFIX = '\x1b]9;'; +const OSC777_PREFIX = '\x1b]777;notify;'; +const OSC_TEXT_SEPARATOR = ' | '; + +export interface RunEventNotificationContent { + title: string; + subtitle?: string; + body: string; +} + +export type RunEventNotificationEvent = + | { + type: 'attention'; + heading?: string; + detail?: string; + } + | { + type: 'session_complete'; + detail?: string; + }; + +function sanitizeNotificationContent( + content: RunEventNotificationContent, +): RunEventNotificationContent { + const title = sanitizeForDisplay(content.title, MAX_NOTIFICATION_TITLE_CHARS); + const subtitle = content.subtitle + ? sanitizeForDisplay(content.subtitle, MAX_NOTIFICATION_SUBTITLE_CHARS) + : undefined; + const body = sanitizeForDisplay(content.body, MAX_NOTIFICATION_BODY_CHARS); + + return { + title: title || 'Gemini CLI', + subtitle: subtitle || undefined, + body: body || 'Open Gemini CLI for details.', + }; +} + +export function buildRunEventNotificationContent( + event: RunEventNotificationEvent, +): RunEventNotificationContent { + if (event.type === 'attention') { + return sanitizeNotificationContent({ + title: 'Gemini CLI needs your attention', + subtitle: event.heading ?? 'Action required', + body: event.detail ?? 'Open Gemini CLI to continue.', + }); + } + + return sanitizeNotificationContent({ + title: 'Gemini CLI session complete', + subtitle: 'Run finished', + body: event.detail ?? 'The session finished successfully.', + }); +} + +export function isNotificationsEnabled(settings: LoadedSettings): boolean { + const general = settings.merged.general as + | { enableNotifications?: boolean } + | undefined; + + return general?.enableNotifications === true; +} + +export enum TerminalNotificationMethod { + Auto = 'auto', + Osc9 = 'osc9', + Osc777 = 'osc777', + Bell = 'bell', +} + +export function getNotificationMethod( + settings: LoadedSettings, +): TerminalNotificationMethod { + switch (settings.merged.general?.notificationMethod) { + case TerminalNotificationMethod.Osc9: + return TerminalNotificationMethod.Osc9; + case TerminalNotificationMethod.Osc777: + return TerminalNotificationMethod.Osc777; + case TerminalNotificationMethod.Bell: + return TerminalNotificationMethod.Bell; + default: + return TerminalNotificationMethod.Auto; + } +} + +function wrapWithPassthrough(sequence: string): string { + const capabilityManager = TerminalCapabilityManager.getInstance(); + if (capabilityManager.isTmux()) { + // eslint-disable-next-line no-control-regex + return `\x1bPtmux;${sequence.replace(/\x1b/g, '\x1b\x1b')}\x1b\\`; + } else if (capabilityManager.isScreen()) { + return `\x1bP${sequence}\x1b\\`; + } + return sequence; +} + +function emitOsc9Notification(content: RunEventNotificationContent): void { + const sanitized = sanitizeNotificationContent(content); + const pieces = [sanitized.title, sanitized.subtitle, sanitized.body].filter( + Boolean, + ); + const combined = pieces.join(OSC_TEXT_SEPARATOR); + + writeToStdout(wrapWithPassthrough(`${OSC9_PREFIX}${combined}${BEL}`)); +} + +function emitOsc777Notification(content: RunEventNotificationContent): void { + const sanitized = sanitizeNotificationContent(content); + const bodyParts = [sanitized.subtitle, sanitized.body].filter(Boolean); + const body = bodyParts.join(OSC_TEXT_SEPARATOR); + + // Replace ';' with ':' to avoid breaking the OSC 777 sequence + const safeTitle = sanitized.title.replace(/;/g, ':'); + const safeBody = body.replace(/;/g, ':'); + + writeToStdout( + wrapWithPassthrough(`${OSC777_PREFIX}${safeTitle};${safeBody}${BEL}`), + ); +} + +function emitBellNotification(): void { + writeToStdout(BEL); +} + +export async function notifyViaTerminal( + notificationsEnabled: boolean, + content: RunEventNotificationContent, + method: TerminalNotificationMethod = TerminalNotificationMethod.Auto, +): Promise { + if (!notificationsEnabled) { + return false; + } + + try { + if (method === TerminalNotificationMethod.Osc9) { + emitOsc9Notification(content); + } else if (method === TerminalNotificationMethod.Osc777) { + emitOsc777Notification(content); + } else if (method === TerminalNotificationMethod.Bell) { + emitBellNotification(); + } else { + // auto + const capabilityManager = TerminalCapabilityManager.getInstance(); + if (capabilityManager.isITerm2()) { + emitOsc9Notification(content); + } else if ( + capabilityManager.isAlacritty() || + capabilityManager.isAppleTerminal() || + capabilityManager.isVSCodeTerminal() || + capabilityManager.isWindowsTerminal() + ) { + emitBellNotification(); + } else { + emitOsc777Notification(content); + } + } + + return true; + } catch (error) { + debugLogger.debug('Failed to emit terminal notification:', error); + return false; + } +} diff --git a/packages/cli/src/utils/tierUtils.ts b/packages/cli/src/utils/tierUtils.ts new file mode 100644 index 0000000000000000000000000000000000000000..7722a9a41196a7fd78f55dd60147d549ab945185 --- /dev/null +++ b/packages/cli/src/utils/tierUtils.ts @@ -0,0 +1,15 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Checks if the given tier name corresponds to an "Ultra" tier. + * + * @param tierName The name of the user's tier. + * @returns True if the tier is an "Ultra" tier, false otherwise. + */ +export function isUltraTier(tierName?: string): boolean { + return !!tierName?.toLowerCase().includes('ultra'); +} diff --git a/packages/cli/src/utils/toolOutputCleanup.test.ts b/packages/cli/src/utils/toolOutputCleanup.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..18e43ab6d0545ec403204892eab25c0ff99b0dfc --- /dev/null +++ b/packages/cli/src/utils/toolOutputCleanup.test.ts @@ -0,0 +1,354 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { debugLogger, TOOL_OUTPUTS_DIR } from '@google/gemini-cli-core'; +import type { Settings } from '../config/settings.js'; +import { cleanupToolOutputFiles } from './sessionCleanup.js'; + +describe('Tool Output Cleanup', () => { + let testTempDir: string; + + beforeEach(async () => { + // Create a unique temp directory for each test + testTempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'tool-output-test-')); + vi.spyOn(debugLogger, 'error').mockImplementation(() => {}); + vi.spyOn(debugLogger, 'warn').mockImplementation(() => {}); + vi.spyOn(debugLogger, 'debug').mockImplementation(() => {}); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + // Clean up the temp directory + try { + await fs.rm(testTempDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + describe('cleanupToolOutputFiles', () => { + it('should return early when cleanup is disabled', async () => { + const settings: Settings = { + general: { sessionRetention: { enabled: false } }, + }; + + const result = await cleanupToolOutputFiles(settings, false, testTempDir); + + expect(result.disabled).toBe(true); + expect(result.scanned).toBe(0); + expect(result.deleted).toBe(0); + expect(result.failed).toBe(0); + }); + + it('should return early when sessionRetention is not configured', async () => { + const settings: Settings = {}; + + const result = await cleanupToolOutputFiles(settings, false, testTempDir); + + expect(result.disabled).toBe(true); + expect(result.scanned).toBe(0); + expect(result.deleted).toBe(0); + }); + + it('should return early when tool-outputs directory does not exist', async () => { + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxAge: '7d', + }, + }, + }; + + // Don't create the tool-outputs directory + const result = await cleanupToolOutputFiles(settings, false, testTempDir); + + expect(result.disabled).toBe(false); + expect(result.scanned).toBe(0); + expect(result.deleted).toBe(0); + expect(result.failed).toBe(0); + }); + + it('should delete files older than maxAge', async () => { + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxAge: '7d', + }, + }, + }; + + // Create tool-outputs directory and files + const toolOutputDir = path.join(testTempDir, TOOL_OUTPUTS_DIR); + await fs.mkdir(toolOutputDir, { recursive: true }); + + const now = Date.now(); + const fiveDaysAgo = now - 5 * 24 * 60 * 60 * 1000; + const tenDaysAgo = now - 10 * 24 * 60 * 60 * 1000; + + // Create files with different ages + const recentFile = path.join(toolOutputDir, 'shell_recent.txt'); + const oldFile = path.join(toolOutputDir, 'shell_old.txt'); + + await fs.writeFile(recentFile, 'recent content'); + await fs.writeFile(oldFile, 'old content'); + + // Set file modification times + await fs.utimes(recentFile, fiveDaysAgo / 1000, fiveDaysAgo / 1000); + await fs.utimes(oldFile, tenDaysAgo / 1000, tenDaysAgo / 1000); + + const result = await cleanupToolOutputFiles(settings, false, testTempDir); + + expect(result.disabled).toBe(false); + expect(result.scanned).toBe(2); + expect(result.deleted).toBe(1); // Only the 10-day-old file should be deleted + expect(result.failed).toBe(0); + + // Verify the old file was deleted and recent file remains + const remainingFiles = await fs.readdir(toolOutputDir); + expect(remainingFiles).toContain('shell_recent.txt'); + expect(remainingFiles).not.toContain('shell_old.txt'); + }); + + it('should delete oldest files when exceeding maxCount', async () => { + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxCount: 2, + }, + }, + }; + + // Create tool-outputs directory and files + const toolOutputDir = path.join(testTempDir, TOOL_OUTPUTS_DIR); + await fs.mkdir(toolOutputDir, { recursive: true }); + + const now = Date.now(); + const oneDayAgo = now - 1 * 24 * 60 * 60 * 1000; + const twoDaysAgo = now - 2 * 24 * 60 * 60 * 1000; + const threeDaysAgo = now - 3 * 24 * 60 * 60 * 1000; + + // Create 3 files with different ages + const file1 = path.join(toolOutputDir, 'shell_1.txt'); + const file2 = path.join(toolOutputDir, 'shell_2.txt'); + const file3 = path.join(toolOutputDir, 'shell_3.txt'); + + await fs.writeFile(file1, 'content 1'); + await fs.writeFile(file2, 'content 2'); + await fs.writeFile(file3, 'content 3'); + + // Set file modification times (file3 is oldest) + await fs.utimes(file1, oneDayAgo / 1000, oneDayAgo / 1000); + await fs.utimes(file2, twoDaysAgo / 1000, twoDaysAgo / 1000); + await fs.utimes(file3, threeDaysAgo / 1000, threeDaysAgo / 1000); + + const result = await cleanupToolOutputFiles(settings, false, testTempDir); + + expect(result.disabled).toBe(false); + expect(result.scanned).toBe(3); + expect(result.deleted).toBe(1); // Should delete 1 file to get down to maxCount of 2 + expect(result.failed).toBe(0); + + // Verify the oldest file was deleted + const remainingFiles = await fs.readdir(toolOutputDir); + expect(remainingFiles).toHaveLength(2); + expect(remainingFiles).not.toContain('shell_3.txt'); + }); + + it('should handle empty directory', async () => { + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxAge: '7d', + }, + }, + }; + + // Create empty tool-outputs directory + const toolOutputDir = path.join(testTempDir, TOOL_OUTPUTS_DIR); + await fs.mkdir(toolOutputDir, { recursive: true }); + + const result = await cleanupToolOutputFiles(settings, false, testTempDir); + + expect(result.disabled).toBe(false); + expect(result.scanned).toBe(0); + expect(result.deleted).toBe(0); + expect(result.failed).toBe(0); + }); + + it('should apply both maxAge and maxCount together', async () => { + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxAge: '3d', + maxCount: 2, + }, + }, + }; + + // Create tool-outputs directory and files + const toolOutputDir = path.join(testTempDir, TOOL_OUTPUTS_DIR); + await fs.mkdir(toolOutputDir, { recursive: true }); + + const now = Date.now(); + const oneDayAgo = now - 1 * 24 * 60 * 60 * 1000; + const twoDaysAgo = now - 2 * 24 * 60 * 60 * 1000; + const twoAndHalfDaysAgo = now - 2.5 * 24 * 60 * 60 * 1000; + const fiveDaysAgo = now - 5 * 24 * 60 * 60 * 1000; + const tenDaysAgo = now - 10 * 24 * 60 * 60 * 1000; + + // Create 5 files with different ages + const file1 = path.join(toolOutputDir, 'shell_1.txt'); // 1 day old - keep + const file2 = path.join(toolOutputDir, 'shell_2.txt'); // 2 days old - keep + const file3 = path.join(toolOutputDir, 'shell_3.txt'); // 2.5 days old - delete by count + const file4 = path.join(toolOutputDir, 'shell_4.txt'); // 5 days old - delete by age + const file5 = path.join(toolOutputDir, 'shell_5.txt'); // 10 days old - delete by age + + await fs.writeFile(file1, 'content 1'); + await fs.writeFile(file2, 'content 2'); + await fs.writeFile(file3, 'content 3'); + await fs.writeFile(file4, 'content 4'); + await fs.writeFile(file5, 'content 5'); + + // Set file modification times + await fs.utimes(file1, oneDayAgo / 1000, oneDayAgo / 1000); + await fs.utimes(file2, twoDaysAgo / 1000, twoDaysAgo / 1000); + await fs.utimes( + file3, + twoAndHalfDaysAgo / 1000, + twoAndHalfDaysAgo / 1000, + ); + await fs.utimes(file4, fiveDaysAgo / 1000, fiveDaysAgo / 1000); + await fs.utimes(file5, tenDaysAgo / 1000, tenDaysAgo / 1000); + + const result = await cleanupToolOutputFiles(settings, false, testTempDir); + + expect(result.disabled).toBe(false); + expect(result.scanned).toBe(5); + // file4 and file5 deleted by maxAge, file3 deleted by maxCount + expect(result.deleted).toBe(3); + expect(result.failed).toBe(0); + + // Verify only the 2 newest files remain + const remainingFiles = await fs.readdir(toolOutputDir); + expect(remainingFiles).toHaveLength(2); + expect(remainingFiles).toContain('shell_1.txt'); + expect(remainingFiles).toContain('shell_2.txt'); + expect(remainingFiles).not.toContain('shell_3.txt'); + expect(remainingFiles).not.toContain('shell_4.txt'); + expect(remainingFiles).not.toContain('shell_5.txt'); + }); + + it('should log debug information when enabled', async () => { + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxAge: '1d', + }, + }, + }; + + // Create tool-outputs directory and an old file + const toolOutputDir = path.join(testTempDir, TOOL_OUTPUTS_DIR); + await fs.mkdir(toolOutputDir, { recursive: true }); + + const tenDaysAgo = Date.now() - 10 * 24 * 60 * 60 * 1000; + const oldFile = path.join(toolOutputDir, 'shell_old.txt'); + await fs.writeFile(oldFile, 'old content'); + await fs.utimes(oldFile, tenDaysAgo / 1000, tenDaysAgo / 1000); + + const debugSpy = vi + .spyOn(debugLogger, 'debug') + .mockImplementation(() => {}); + + await cleanupToolOutputFiles(settings, true, testTempDir); + + expect(debugSpy).toHaveBeenCalledWith( + expect.stringContaining('Tool output cleanup: deleted'), + ); + + debugSpy.mockRestore(); + }); + + it('should delete expired session subdirectories', async () => { + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxAge: '1d', + }, + }, + }; + + const toolOutputDir = path.join(testTempDir, TOOL_OUTPUTS_DIR); + await fs.mkdir(toolOutputDir, { recursive: true }); + + const now = Date.now(); + const tenDaysAgo = now - 10 * 24 * 60 * 60 * 1000; + const oneHourAgo = now - 1 * 60 * 60 * 1000; + + const oldSessionDir = path.join(toolOutputDir, 'session-old'); + const recentSessionDir = path.join(toolOutputDir, 'session-recent'); + + await fs.mkdir(oldSessionDir); + await fs.mkdir(recentSessionDir); + + // Set modification times + await fs.utimes(oldSessionDir, tenDaysAgo / 1000, tenDaysAgo / 1000); + await fs.utimes(recentSessionDir, oneHourAgo / 1000, oneHourAgo / 1000); + + const result = await cleanupToolOutputFiles(settings, false, testTempDir); + + expect(result.deleted).toBe(1); + const remainingDirs = await fs.readdir(toolOutputDir); + expect(remainingDirs).toContain('session-recent'); + expect(remainingDirs).not.toContain('session-old'); + }); + + it('should skip subdirectories with path traversal characters', async () => { + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxAge: '1d', + }, + }, + }; + + const toolOutputDir = path.join(testTempDir, TOOL_OUTPUTS_DIR); + await fs.mkdir(toolOutputDir, { recursive: true }); + + // Create an unsafe directory name + const unsafeDir = path.join(toolOutputDir, 'session-.._.._danger'); + await fs.mkdir(unsafeDir, { recursive: true }); + + const debugSpy = vi + .spyOn(debugLogger, 'debug') + .mockImplementation(() => {}); + + await cleanupToolOutputFiles(settings, false, testTempDir); + + expect(debugSpy).toHaveBeenCalledWith( + expect.stringContaining('Skipping unsafe tool-output subdirectory'), + ); + + // Directory should still exist (it was skipped, not deleted) + const entries = await fs.readdir(toolOutputDir); + expect(entries).toContain('session-.._.._danger'); + + debugSpy.mockRestore(); + }); + }); +}); diff --git a/packages/cli/src/utils/updateEventEmitter.test.ts b/packages/cli/src/utils/updateEventEmitter.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a91622a18489ef8a84f75dbe1e381c9eb09cccb5 --- /dev/null +++ b/packages/cli/src/utils/updateEventEmitter.test.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi } from 'vitest'; +import { updateEventEmitter } from './updateEventEmitter.js'; + +describe('updateEventEmitter', () => { + it('should allow registering and emitting events', () => { + const callback = vi.fn(); + const eventName = 'test-event'; + + updateEventEmitter.on(eventName, callback); + updateEventEmitter.emit(eventName, 'test-data'); + + expect(callback).toHaveBeenCalledWith('test-data'); + + updateEventEmitter.off(eventName, callback); + }); +}); diff --git a/packages/cli/src/utils/userStartupWarnings.test.ts b/packages/cli/src/utils/userStartupWarnings.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..53e837371d89567dda3dc23853896d944f201058 --- /dev/null +++ b/packages/cli/src/utils/userStartupWarnings.test.ts @@ -0,0 +1,289 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { getUserStartupWarnings } from './userStartupWarnings.js'; +import * as os from 'node:os'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { + isFolderTrustEnabled, + isWorkspaceTrusted, +} from '../config/trustedFolders.js'; +import { + getCompatibilityWarnings, + WarningPriority, +} from '@google/gemini-cli-core'; + +// Mock os.homedir to control the home directory in tests +vi.mock('node:os', async (importOriginal) => { + const actualOs = await importOriginal(); + return { + ...actualOs, + homedir: vi.fn(() => actualOs.homedir()), + }; +}); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + getCompatibilityWarnings: vi.fn().mockReturnValue([]), + isHeadlessMode: vi.fn().mockReturnValue(false), + WarningPriority: { + Low: 'low', + High: 'high', + }, + }; +}); + +vi.mock('../config/trustedFolders.js', () => ({ + isFolderTrustEnabled: vi.fn(), + isWorkspaceTrusted: vi.fn(), +})); + +describe('getUserStartupWarnings', () => { + let testRootDir: string; + let homeDir: string; + + beforeEach(async () => { + testRootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'warnings-test-')); + homeDir = path.join(testRootDir, 'home'); + await fs.mkdir(homeDir, { recursive: true }); + vi.mocked(os.homedir).mockReturnValue(homeDir); + vi.mocked(isFolderTrustEnabled).mockReturnValue(false); + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: false, + source: undefined, + }); + vi.mocked(getCompatibilityWarnings).mockReturnValue([]); + }); + + afterEach(async () => { + await fs.rm(testRootDir, { recursive: true, force: true }); + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + describe('home directory check', () => { + it('should return a warning when running in home directory', async () => { + const warnings = await getUserStartupWarnings({}, homeDir); + expect(warnings).toContainEqual( + expect.objectContaining({ + id: 'home-directory', + message: expect.stringContaining( + 'Warning you are running Gemini CLI in your home directory', + ), + priority: WarningPriority.Low, + }), + ); + }); + + it('should not return a warning when running in a project directory', async () => { + const projectDir = path.join(testRootDir, 'project'); + await fs.mkdir(projectDir); + const warnings = await getUserStartupWarnings({}, projectDir); + expect(warnings.find((w) => w.id === 'home-directory')).toBeUndefined(); + }); + + it('should not return a warning when showHomeDirectoryWarning is false', async () => { + const warnings = await getUserStartupWarnings( + { ui: { showHomeDirectoryWarning: false } }, + homeDir, + ); + expect(warnings.find((w) => w.id === 'home-directory')).toBeUndefined(); + }); + + it('should not return a warning when running in a subdirectory of home', async () => { + const subDir = path.join(homeDir, 'projects', 'my-app'); + await fs.mkdir(subDir, { recursive: true }); + const warnings = await getUserStartupWarnings({}, subDir); + expect(warnings.find((w) => w.id === 'home-directory')).toBeUndefined(); + }); + + it('should not return a warning when home directory is a symlink and running in a subdirectory', async () => { + const realHome = path.join(testRootDir, 'real-home'); + await fs.mkdir(realHome, { recursive: true }); + const symlinkedHome = path.join(testRootDir, 'symlinked-home'); + await fs.symlink(realHome, symlinkedHome); + vi.mocked(os.homedir).mockReturnValue(symlinkedHome); + + const subDir = path.join(symlinkedHome, 'projects'); + await fs.mkdir(subDir, { recursive: true }); + const warnings = await getUserStartupWarnings({}, subDir); + expect(warnings.find((w) => w.id === 'home-directory')).toBeUndefined(); + }); + + it('should return a warning when home directory is a symlink and running in it', async () => { + const realHome = path.join(testRootDir, 'real-home2'); + await fs.mkdir(realHome, { recursive: true }); + const symlinkedHome = path.join(testRootDir, 'symlinked-home2'); + await fs.symlink(realHome, symlinkedHome); + vi.mocked(os.homedir).mockReturnValue(symlinkedHome); + + const warnings = await getUserStartupWarnings({}, symlinkedHome); + expect(warnings).toContainEqual( + expect.objectContaining({ + id: 'home-directory', + message: expect.stringContaining( + 'Warning you are running Gemini CLI in your home directory', + ), + priority: WarningPriority.Low, + }), + ); + }); + + it('should not return a warning when GEMINI_CLI_HOME differs from os.homedir', async () => { + const projectDir = path.join(testRootDir, 'project'); + await fs.mkdir(projectDir, { recursive: true }); + vi.stubEnv('GEMINI_CLI_HOME', projectDir); + + const warnings = await getUserStartupWarnings({}, projectDir); + expect(warnings.find((w) => w.id === 'home-directory')).toBeUndefined(); + }); + + it('should not return a warning when folder trust is enabled and workspace is trusted', async () => { + vi.mocked(isFolderTrustEnabled).mockReturnValue(true); + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: true, + source: 'file', + }); + + const warnings = await getUserStartupWarnings({}, homeDir); + expect(warnings.find((w) => w.id === 'home-directory')).toBeUndefined(); + }); + }); + + describe('root directory check', () => { + it('should return a warning when running in a root directory', async () => { + const rootDir = path.parse(testRootDir).root; + const warnings = await getUserStartupWarnings({}, rootDir); + expect(warnings).toContainEqual( + expect.objectContaining({ + id: 'root-directory', + message: expect.stringContaining('root directory'), + priority: WarningPriority.High, + }), + ); + }); + + it('should not return a warning when running in a non-root directory', async () => { + const projectDir = path.join(testRootDir, 'project'); + await fs.mkdir(projectDir); + const warnings = await getUserStartupWarnings({}, projectDir); + expect(warnings.find((w) => w.id === 'root-directory')).toBeUndefined(); + }); + }); + + describe('error handling', () => { + it('should handle errors when checking directory', async () => { + const nonExistentPath = path.join(testRootDir, 'non-existent'); + const warnings = await getUserStartupWarnings({}, nonExistentPath); + const expectedMessage = + 'Could not verify the current directory due to a file system error.'; + expect(warnings).toEqual([ + expect.objectContaining({ message: expectedMessage }), + expect.objectContaining({ message: expectedMessage }), + ]); + }); + }); + + describe('folder trust check', () => { + it('should throw FatalUntrustedWorkspaceError when untrusted in headless mode', async () => { + const { isHeadlessMode, FatalUntrustedWorkspaceError } = await import( + '@google/gemini-cli-core' + ); + vi.mocked(isFolderTrustEnabled).mockReturnValue(true); + vi.mocked(isWorkspaceTrusted).mockImplementation(() => { + throw new FatalUntrustedWorkspaceError( + 'Gemini CLI is not running in a trusted directory', + ); + }); + vi.mocked(isHeadlessMode).mockReturnValue(true); + + await expect( + getUserStartupWarnings({}, testRootDir), + ).rejects.toThrowError(FatalUntrustedWorkspaceError); + }); + + it('should not return a warning when trusted in headless mode', async () => { + const { isHeadlessMode } = await import('@google/gemini-cli-core'); + vi.mocked(isFolderTrustEnabled).mockReturnValue(true); + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: true, + source: 'file', + }); + vi.mocked(isHeadlessMode).mockReturnValue(true); + + const warnings = await getUserStartupWarnings({}, testRootDir); + expect(warnings.find((w) => w.id === 'folder-trust')).toBeUndefined(); + }); + + it('should not return a warning when untrusted in interactive mode', async () => { + const { isHeadlessMode } = await import('@google/gemini-cli-core'); + vi.mocked(isFolderTrustEnabled).mockReturnValue(true); + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: false, + source: undefined, + }); + vi.mocked(isHeadlessMode).mockReturnValue(false); + + const warnings = await getUserStartupWarnings({}, testRootDir); + expect(warnings.find((w) => w.id === 'folder-trust')).toBeUndefined(); + }); + }); + + describe('compatibility warnings', () => { + it('should include compatibility warnings by default', async () => { + const compWarning = { + id: 'comp-1', + message: 'Comp warning 1', + priority: WarningPriority.High, + }; + vi.mocked(getCompatibilityWarnings).mockReturnValue([compWarning]); + const projectDir = path.join(testRootDir, 'project'); + await fs.mkdir(projectDir); + + const warnings = await getUserStartupWarnings({}, projectDir); + expect(warnings).toContainEqual(compWarning); + }); + + it('should not include compatibility warnings when showCompatibilityWarnings is false', async () => { + const compWarning = { + id: 'comp-1', + message: 'Comp warning 1', + priority: WarningPriority.High, + }; + vi.mocked(getCompatibilityWarnings).mockReturnValue([compWarning]); + const projectDir = path.join(testRootDir, 'project'); + await fs.mkdir(projectDir); + + const warnings = await getUserStartupWarnings( + { ui: { showCompatibilityWarnings: false } }, + projectDir, + ); + expect(warnings).not.toContainEqual(compWarning); + }); + + it('should correctly pass isAlternateBuffer option to getCompatibilityWarnings', async () => { + const projectDir = path.join(testRootDir, 'project-alt'); + await fs.mkdir(projectDir); + + await getUserStartupWarnings({}, projectDir, { isAlternateBuffer: true }); + expect(getCompatibilityWarnings).toHaveBeenCalledWith({ + isAlternateBuffer: true, + }); + + await getUserStartupWarnings({}, projectDir, { + isAlternateBuffer: false, + }); + expect(getCompatibilityWarnings).toHaveBeenCalledWith({ + isAlternateBuffer: false, + }); + }); + }); +}); diff --git a/packages/cli/src/utils/windowTitle.test.ts b/packages/cli/src/utils/windowTitle.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..853ede8db088559cd84a24e08dfb1f1137951d6d --- /dev/null +++ b/packages/cli/src/utils/windowTitle.test.ts @@ -0,0 +1,240 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { + computeTerminalTitle, + type TerminalTitleOptions, +} from './windowTitle.js'; +import { StreamingState } from '../ui/types.js'; + +describe('computeTerminalTitle', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it.each([ + { + description: 'idle state title with folder name', + args: { + streamingState: StreamingState.Idle, + isConfirming: false, + isSilentWorking: false, + folderName: 'my-project', + showThoughts: false, + useDynamicTitle: true, + } as TerminalTitleOptions, + expected: '◇ Ready (my-project)', + }, + { + description: 'legacy title when useDynamicTitle is false', + args: { + streamingState: StreamingState.Responding, + isConfirming: false, + isSilentWorking: false, + folderName: 'my-project', + showThoughts: true, + useDynamicTitle: false, + } as TerminalTitleOptions, + expected: 'Gemini CLI (my-project)'.padEnd(80, ' '), + exact: true, + }, + { + description: + 'active state title with "Working…" when thoughts are disabled', + args: { + streamingState: StreamingState.Responding, + thoughtSubject: 'Reading files', + isConfirming: false, + isSilentWorking: false, + folderName: 'my-project', + showThoughts: false, + useDynamicTitle: true, + } as TerminalTitleOptions, + expected: '✦ Working… (my-project)', + }, + { + description: + 'active state title with thought subject and suffix when thoughts are short enough', + args: { + streamingState: StreamingState.Responding, + thoughtSubject: 'Short thought', + isConfirming: false, + isSilentWorking: false, + folderName: 'my-project', + showThoughts: true, + useDynamicTitle: true, + } as TerminalTitleOptions, + expected: '✦ Short thought (my-project)', + }, + { + description: + 'fallback active title with suffix if no thought subject is provided even when thoughts are enabled', + args: { + streamingState: StreamingState.Responding, + thoughtSubject: undefined, + isConfirming: false, + isSilentWorking: false, + folderName: 'my-project', + showThoughts: true, + useDynamicTitle: true, + } as TerminalTitleOptions, + expected: '✦ Working… (my-project)'.padEnd(80, ' '), + exact: true, + }, + { + description: 'action required state when confirming', + args: { + streamingState: StreamingState.Idle, + isConfirming: true, + isSilentWorking: false, + folderName: 'my-project', + showThoughts: false, + useDynamicTitle: true, + } as TerminalTitleOptions, + expected: '✋ Action Required (my-project)', + }, + { + description: 'silent working state', + args: { + streamingState: StreamingState.Responding, + isConfirming: false, + isSilentWorking: true, + folderName: 'my-project', + showThoughts: false, + useDynamicTitle: true, + } as TerminalTitleOptions, + expected: '⏲ Working… (my-project)', + }, + ])('should return $description', ({ args, expected, exact }) => { + const title = computeTerminalTitle(args); + if (exact) { + expect(title).toBe(expected); + } else { + expect(title).toContain(expected); + } + expect(title.length).toBe(80); + }); + + it('should return active state title with thought subject and NO suffix when thoughts are very long', () => { + const longThought = 'A'.repeat(70); + const title = computeTerminalTitle({ + streamingState: StreamingState.Responding, + thoughtSubject: longThought, + isConfirming: false, + isSilentWorking: false, + folderName: 'my-project', + showThoughts: true, + useDynamicTitle: true, + }); + + expect(title).not.toContain('(my-project)'); + expect(title).toContain('✦ AAAAAAAAAAAAAAAA'); + expect(title.length).toBe(80); + }); + + it('should truncate long thought subjects when thoughts are enabled', () => { + const longThought = 'A'.repeat(100); + const title = computeTerminalTitle({ + streamingState: StreamingState.Responding, + thoughtSubject: longThought, + isConfirming: false, + isSilentWorking: false, + folderName: 'my-project', + showThoughts: true, + useDynamicTitle: true, + }); + + expect(title.length).toBe(80); + expect(title).toContain('…'); + expect(title.trimEnd().length).toBe(80); + }); + + it('should strip control characters from the title', () => { + const title = computeTerminalTitle({ + streamingState: StreamingState.Responding, + thoughtSubject: 'BadTitle\x00 With\x07Control\x1BChars', + isConfirming: false, + isSilentWorking: false, + folderName: 'my-project', + showThoughts: true, + useDynamicTitle: true, + }); + + expect(title).toContain('BadTitle WithControlChars'); + expect(title).not.toContain('\x00'); + expect(title).not.toContain('\x07'); + expect(title).not.toContain('\x1B'); + expect(title.length).toBe(80); + }); + + it('should prioritize CLI_TITLE environment variable over folder name when thoughts are disabled', () => { + vi.stubEnv('CLI_TITLE', 'EnvOverride'); + + const title = computeTerminalTitle({ + streamingState: StreamingState.Idle, + isConfirming: false, + isSilentWorking: false, + folderName: 'my-project', + showThoughts: false, + useDynamicTitle: true, + }); + + expect(title).toContain('◇ Ready (EnvOverride)'); + expect(title).not.toContain('my-project'); + expect(title.length).toBe(80); + }); + + it.each([ + { + name: 'folder name', + folderName: 'A'.repeat(100), + expected: '◇ Ready (AAAAA', + }, + { + name: 'CLI_TITLE', + folderName: 'my-project', + envTitle: 'B'.repeat(100), + expected: '◇ Ready (BBBBB', + }, + ])( + 'should truncate very long $name to fit within 80 characters', + ({ folderName, envTitle, expected }) => { + if (envTitle) { + vi.stubEnv('CLI_TITLE', envTitle); + } + + const title = computeTerminalTitle({ + streamingState: StreamingState.Idle, + isConfirming: false, + isSilentWorking: false, + folderName, + showThoughts: false, + useDynamicTitle: true, + }); + + expect(title.length).toBe(80); + expect(title).toContain(expected); + expect(title).toContain('…)'); + }, + ); + + it('should truncate long folder name when useDynamicTitle is false', () => { + const longFolderName = 'C'.repeat(100); + const title = computeTerminalTitle({ + streamingState: StreamingState.Responding, + isConfirming: false, + isSilentWorking: false, + folderName: longFolderName, + showThoughts: true, + useDynamicTitle: false, + }); + + expect(title.length).toBe(80); + expect(title).toContain('Gemini CLI (CCCCC'); + expect(title).toContain('…)'); + }); +}); diff --git a/packages/cli/src/utils/worktreeSetup.ts b/packages/cli/src/utils/worktreeSetup.ts new file mode 100644 index 0000000000000000000000000000000000000000..596c367d3e4c73426f8d650fd20f35550b673350 --- /dev/null +++ b/packages/cli/src/utils/worktreeSetup.ts @@ -0,0 +1,43 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + getProjectRootForWorktree, + createWorktreeService, + writeToStderr, + type WorktreeInfo, +} from '@google/gemini-cli-core'; + +/** + * Sets up a git worktree for parallel sessions. + * + * This function uses a guard (GEMINI_CLI_WORKTREE_HANDLED) to ensure that + * when the CLI relaunches itself (e.g. for memory allocation), it doesn't + * attempt to create a nested worktree. + */ +export async function setupWorktree( + worktreeName: string | undefined, +): Promise { + if (process.env['GEMINI_CLI_WORKTREE_HANDLED'] === '1') { + return undefined; + } + + try { + const projectRoot = await getProjectRootForWorktree(process.cwd()); + const service = await createWorktreeService(projectRoot); + + const worktreeInfo = await service.setup(worktreeName || undefined); + + process.chdir(worktreeInfo.path); + process.env['GEMINI_CLI_WORKTREE_HANDLED'] = '1'; + + return worktreeInfo; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + writeToStderr(`Failed to create or switch to worktree: ${errorMessage}\n`); + process.exit(1); + } +}