From 7956eb239e865bbb746ca2a862db63fb3e4c87f4 Mon Sep 17 00:00:00 2001 From: "N. Taylor Mullen" Date: Tue, 6 Jan 2026 20:09:39 -0800 Subject: [PATCH] Introduce GEMINI_CLI_HOME for strict test isolation (#15907) --- eslint.config.js | 32 ++++++++ integration-tests/test-helper.ts | 33 ++++++-- .../src/commands/command-registry.test.ts | 66 ++++++++++------ .../src/commands/command-registry.ts | 7 +- packages/a2a-server/src/config/config.ts | 2 +- packages/a2a-server/src/config/extension.ts | 4 +- .../a2a-server/src/config/settings.test.ts | 22 ++++-- packages/a2a-server/src/config/settings.ts | 2 +- packages/a2a-server/src/persistence/gcs.ts | 2 +- .../config/extension-manager-skills.test.ts | 9 +++ packages/cli/src/config/extension-manager.ts | 8 +- packages/cli/src/config/extension.test.ts | 1 + packages/cli/src/config/extensions/storage.ts | 4 +- packages/cli/src/config/settings.ts | 3 +- .../settings_validation_warning.test.ts | 15 ++-- .../cli/src/config/trustedFolders.test.ts | 9 +++ packages/cli/src/config/trustedFolders.ts | 2 +- .../src/ui/components/Notifications.test.tsx | 1 + .../cli/src/ui/components/Notifications.tsx | 10 ++- .../ui/hooks/slashCommandProcessor.test.tsx | 1 + .../src/ui/hooks/useExtensionUpdates.test.tsx | 9 +++ .../hooks/usePermissionsModifyTrust.test.ts | 13 +++- .../cli/src/ui/themes/theme-manager.test.ts | 9 +++ packages/cli/src/ui/themes/theme-manager.ts | 5 +- .../cli/src/ui/utils/directoryUtils.test.ts | 1 + packages/cli/src/ui/utils/directoryUtils.ts | 8 +- .../cli/src/ui/utils/terminalSetup.test.ts | 9 +++ packages/cli/src/ui/utils/terminalSetup.ts | 6 +- packages/cli/src/utils/resolvePath.test.ts | 4 + packages/cli/src/utils/resolvePath.ts | 6 +- packages/cli/src/utils/sandbox.test.ts | 31 ++++++-- packages/cli/src/utils/sandbox.ts | 33 +++++--- .../cli/src/utils/userStartupWarnings.test.ts | 9 +++ packages/cli/src/utils/userStartupWarnings.ts | 5 +- .../code_assist/oauth-credential-storage.ts | 7 +- packages/core/src/code_assist/oauth2.test.ts | 75 +++++++++++++------ packages/core/src/code_assist/oauth2.ts | 2 +- packages/core/src/config/storage.ts | 4 +- packages/core/src/core/prompts.ts | 5 +- packages/core/src/ide/ide-installer.test.ts | 13 +++- packages/core/src/ide/ide-installer.ts | 4 +- packages/core/src/index.ts | 1 + .../mcp/token-storage/file-token-storage.ts | 4 +- packages/core/src/services/gitService.test.ts | 17 ++++- .../src/utils/installationManager.test.ts | 19 +++-- .../core/src/utils/memoryDiscovery.test.ts | 11 +++ packages/core/src/utils/memoryDiscovery.ts | 3 +- packages/core/src/utils/paths.ts | 23 +++++- .../core/src/utils/userAccountManager.test.ts | 10 +-- packages/vscode-ide-companion/esbuild.js | 2 +- .../src/ide-server.test.ts | 9 +++ .../vscode-ide-companion/src/ide-server.ts | 4 +- scripts/sandbox_command.js | 4 +- scripts/telemetry_utils.js | 5 +- 54 files changed, 455 insertions(+), 148 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 8f86cb6d8e..c2d0d3b69b 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -169,6 +169,38 @@ export default tseslint.config( '@typescript-eslint/await-thenable': ['error'], '@typescript-eslint/no-floating-promises': ['error'], '@typescript-eslint/no-unnecessary-type-assertion': ['error'], + 'no-restricted-imports': [ + 'error', + { + paths: [ + { + name: 'node:os', + importNames: ['homedir', 'tmpdir'], + message: + 'Please use the helpers from @google/gemini-cli-core instead of node:os homedir()/tmpdir() to ensure strict environment isolation.', + }, + { + name: 'os', + importNames: ['homedir', 'tmpdir'], + message: + 'Please use the helpers from @google/gemini-cli-core instead of os homedir()/tmpdir() to ensure strict environment isolation.', + }, + ], + }, + ], + }, + }, + { + // Allow os.homedir() in tests and paths.ts where it is used to implement the helper + files: [ + '**/*.test.ts', + '**/*.test.tsx', + 'packages/core/src/utils/paths.ts', + 'packages/test-utils/src/**/*.ts', + 'scripts/**/*.js', + ], + rules: { + 'no-restricted-imports': 'off', }, }, { diff --git a/integration-tests/test-helper.ts b/integration-tests/test-helper.ts index 8fc4208ebf..818951afd9 100644 --- a/integration-tests/test-helper.ts +++ b/integration-tests/test-helper.ts @@ -274,6 +274,7 @@ export class InteractiveRun { export class TestRig { testDir: string | null = null; + homeDir: string | null = null; testName?: string; _lastRunStdout?: string; // Path to the copied fake responses file for this test. @@ -294,7 +295,9 @@ export class TestRig { const testFileDir = env['INTEGRATION_TEST_FILE_DIR'] || join(os.tmpdir(), 'gemini-cli-tests'); this.testDir = join(testFileDir, sanitizedName); + this.homeDir = join(testFileDir, sanitizedName + '-home'); mkdirSync(this.testDir, { recursive: true }); + mkdirSync(this.homeDir, { recursive: true }); if (options.fakeResponsesPath) { this.fakeResponsesPath = join(this.testDir, 'fake-responses.json'); this.originalFakeResponsesPath = options.fakeResponsesPath; @@ -304,11 +307,15 @@ export class TestRig { } // Create a settings file to point the CLI to the local collector - const geminiDir = join(this.testDir, GEMINI_DIR); - mkdirSync(geminiDir, { recursive: true }); + const projectGeminiDir = join(this.testDir, GEMINI_DIR); + mkdirSync(projectGeminiDir, { recursive: true }); + // In sandbox mode, use an absolute path for telemetry inside the container // The container mounts the test directory at the same path as the host - const telemetryPath = join(this.testDir, 'telemetry.log'); // Always use test directory for telemetry + const telemetryPath = join(this.homeDir, 'telemetry.log'); // Always use home directory for telemetry + + // Ensure the CLI uses our separate home directory for global state + process.env['GEMINI_CLI_HOME'] = this.homeDir; const settings = { general: { @@ -339,7 +346,7 @@ export class TestRig { ...options.settings, // Allow tests to override/add settings }; writeFileSync( - join(geminiDir, 'settings.json'), + join(projectGeminiDir, 'settings.json'), JSON.stringify(settings, null, 2), ); } @@ -595,7 +602,7 @@ export class TestRig { ) { fs.copyFileSync(this.fakeResponsesPath, this.originalFakeResponsesPath!); } - // Clean up test directory + // Clean up test directory and home directory if (this.testDir && !env['KEEP_OUTPUT']) { try { fs.rmSync(this.testDir, { recursive: true, force: true }); @@ -606,11 +613,21 @@ export class TestRig { } } } + if (this.homeDir && !env['KEEP_OUTPUT']) { + try { + fs.rmSync(this.homeDir, { recursive: true, force: true }); + } catch (error) { + // Ignore cleanup errors + if (env['VERBOSE'] === 'true') { + console.warn('Cleanup warning:', (error as Error).message); + } + } + } } async waitForTelemetryReady() { // Telemetry is always written to the test directory - const logFilePath = join(this.testDir!, 'telemetry.log'); + const logFilePath = join(this.homeDir!, 'telemetry.log'); if (!logFilePath) return; @@ -861,7 +878,7 @@ export class TestRig { private _readAndParseTelemetryLog(): ParsedLog[] { // Telemetry is always written to the test directory - const logFilePath = join(this.testDir!, 'telemetry.log'); + const logFilePath = join(this.homeDir!, 'telemetry.log'); if (!logFilePath || !fs.existsSync(logFilePath)) { return []; @@ -903,7 +920,7 @@ export class TestRig { // If not, fall back to parsing from stdout if (env['GEMINI_SANDBOX'] === 'podman') { // Try reading from file first - const logFilePath = join(this.testDir!, 'telemetry.log'); + const logFilePath = join(this.homeDir!, 'telemetry.log'); if (fs.existsSync(logFilePath)) { try { diff --git a/packages/a2a-server/src/commands/command-registry.test.ts b/packages/a2a-server/src/commands/command-registry.test.ts index 70e32cc4fc..15958afb20 100644 --- a/packages/a2a-server/src/commands/command-registry.test.ts +++ b/packages/a2a-server/src/commands/command-registry.test.ts @@ -7,53 +7,79 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { Command } from './types.js'; -describe('CommandRegistry', () => { - const mockListExtensionsCommandInstance: Command = { +const { + mockExtensionsCommand, + mockListExtensionsCommand, + mockExtensionsCommandInstance, + mockListExtensionsCommandInstance, +} = vi.hoisted(() => { + const listInstance: Command = { name: 'extensions list', description: 'Lists all installed extensions.', execute: vi.fn(), }; - const mockListExtensionsCommand = vi.fn( - () => mockListExtensionsCommandInstance, - ); - const mockExtensionsCommandInstance: Command = { + const extInstance: Command = { name: 'extensions', description: 'Manage extensions.', execute: vi.fn(), - subCommands: [mockListExtensionsCommandInstance], + subCommands: [listInstance], }; - const mockExtensionsCommand = vi.fn(() => mockExtensionsCommandInstance); + return { + mockListExtensionsCommandInstance: listInstance, + mockExtensionsCommandInstance: extInstance, + mockExtensionsCommand: vi.fn(() => extInstance), + mockListExtensionsCommand: vi.fn(() => listInstance), + }; +}); + +vi.mock('./extensions.js', () => ({ + ExtensionsCommand: mockExtensionsCommand, + ListExtensionsCommand: mockListExtensionsCommand, +})); + +vi.mock('./init.js', () => ({ + InitCommand: vi.fn(() => ({ + name: 'init', + description: 'Initializes the server.', + execute: vi.fn(), + })), +})); + +vi.mock('./restore.js', () => ({ + RestoreCommand: vi.fn(() => ({ + name: 'restore', + description: 'Restores the server.', + execute: vi.fn(), + })), +})); + +import { commandRegistry } from './command-registry.js'; + +describe('CommandRegistry', () => { beforeEach(async () => { - vi.resetModules(); - vi.doMock('./extensions.js', () => ({ - ExtensionsCommand: mockExtensionsCommand, - ListExtensionsCommand: mockListExtensionsCommand, - })); + vi.clearAllMocks(); + commandRegistry.initialize(); }); it('should register ExtensionsCommand on initialization', async () => { - const { commandRegistry } = await import('./command-registry.js'); expect(mockExtensionsCommand).toHaveBeenCalled(); const command = commandRegistry.get('extensions'); expect(command).toBe(mockExtensionsCommandInstance); }); it('should register sub commands on initialization', async () => { - const { commandRegistry } = await import('./command-registry.js'); const command = commandRegistry.get('extensions list'); expect(command).toBe(mockListExtensionsCommandInstance); }); it('get() should return undefined for a non-existent command', async () => { - const { commandRegistry } = await import('./command-registry.js'); const command = commandRegistry.get('non-existent'); expect(command).toBeUndefined(); }); it('register() should register a new command', async () => { - const { commandRegistry } = await import('./command-registry.js'); const mockCommand: Command = { name: 'test-command', description: '', @@ -65,7 +91,6 @@ describe('CommandRegistry', () => { }); it('register() should register a nested command', async () => { - const { commandRegistry } = await import('./command-registry.js'); const mockSubSubCommand: Command = { name: 'test-command-sub-sub', description: '', @@ -95,8 +120,8 @@ describe('CommandRegistry', () => { }); it('register() should not enter an infinite loop with a cyclic command', async () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const { commandRegistry } = await import('./command-registry.js'); + const { debugLogger } = await import('@google/gemini-cli-core'); + const warnSpy = vi.spyOn(debugLogger, 'warn').mockImplementation(() => {}); const mockCommand: Command = { name: 'cyclic-command', description: '', @@ -112,7 +137,6 @@ describe('CommandRegistry', () => { expect(warnSpy).toHaveBeenCalledWith( 'Command cyclic-command already registered. Skipping.', ); - // If the test finishes, it means we didn't get into an infinite loop. warnSpy.mockRestore(); }); }); diff --git a/packages/a2a-server/src/commands/command-registry.ts b/packages/a2a-server/src/commands/command-registry.ts index 47e2800d9d..7b19d5d1f5 100644 --- a/packages/a2a-server/src/commands/command-registry.ts +++ b/packages/a2a-server/src/commands/command-registry.ts @@ -10,10 +10,15 @@ import { InitCommand } from './init.js'; import { RestoreCommand } from './restore.js'; import type { Command } from './types.js'; -class CommandRegistry { +export class CommandRegistry { private readonly commands = new Map(); constructor() { + this.initialize(); + } + + initialize() { + this.commands.clear(); this.register(new ExtensionsCommand()); this.register(new RestoreCommand()); this.register(new InitCommand()); diff --git a/packages/a2a-server/src/config/config.ts b/packages/a2a-server/src/config/config.ts index d5158cba61..9c26173a69 100644 --- a/packages/a2a-server/src/config/config.ts +++ b/packages/a2a-server/src/config/config.ts @@ -6,7 +6,6 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { homedir } from 'node:os'; import * as dotenv from 'dotenv'; import type { TelemetryTarget } from '@google/gemini-cli-core'; @@ -23,6 +22,7 @@ import { type ExtensionLoader, startupProfiler, PREVIEW_GEMINI_MODEL, + homedir, } from '@google/gemini-cli-core'; import { logger } from '../utils/logger.js'; diff --git a/packages/a2a-server/src/config/extension.ts b/packages/a2a-server/src/config/extension.ts index f56eadfb0c..7da0f0572e 100644 --- a/packages/a2a-server/src/config/extension.ts +++ b/packages/a2a-server/src/config/extension.ts @@ -11,10 +11,10 @@ import { type MCPServerConfig, type ExtensionInstallMetadata, type GeminiCLIExtension, + homedir, } from '@google/gemini-cli-core'; import * as fs from 'node:fs'; import * as path from 'node:path'; -import * as os from 'node:os'; import { logger } from '../utils/logger.js'; export const EXTENSIONS_DIRECTORY_NAME = path.join(GEMINI_DIR, 'extensions'); @@ -39,7 +39,7 @@ interface ExtensionConfig { export function loadExtensions(workspaceDir: string): GeminiCLIExtension[] { const allExtensions = [ ...loadExtensionsFromDir(workspaceDir), - ...loadExtensionsFromDir(os.homedir()), + ...loadExtensionsFromDir(homedir()), ]; const uniqueExtensions: GeminiCLIExtension[] = []; diff --git a/packages/a2a-server/src/config/settings.test.ts b/packages/a2a-server/src/config/settings.test.ts index 0aebbb2a94..b5788b0fb6 100644 --- a/packages/a2a-server/src/config/settings.test.ts +++ b/packages/a2a-server/src/config/settings.test.ts @@ -27,13 +27,21 @@ vi.mock('node:os', async (importOriginal) => { }; }); -vi.mock('@google/gemini-cli-core', () => ({ - GEMINI_DIR: '.gemini', - debugLogger: { - error: vi.fn(), - }, - getErrorMessage: (error: unknown) => String(error), -})); +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + const path = await import('node:path'); + const os = await import('node:os'); + return { + ...actual, + GEMINI_DIR: '.gemini', + debugLogger: { + error: vi.fn(), + }, + getErrorMessage: (error: unknown) => String(error), + homedir: () => path.join(os.tmpdir(), `gemini-home-${mocks.suffix}`), + }; +}); describe('loadSettings', () => { const mockHomeDir = path.join(os.tmpdir(), `gemini-home-${mocks.suffix}`); diff --git a/packages/a2a-server/src/config/settings.ts b/packages/a2a-server/src/config/settings.ts index f46db47b6f..7040a80d4e 100644 --- a/packages/a2a-server/src/config/settings.ts +++ b/packages/a2a-server/src/config/settings.ts @@ -6,7 +6,6 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { homedir } from 'node:os'; import type { MCPServerConfig } from '@google/gemini-cli-core'; import { @@ -14,6 +13,7 @@ import { GEMINI_DIR, getErrorMessage, type TelemetrySettings, + homedir, } from '@google/gemini-cli-core'; import stripJsonComments from 'strip-json-comments'; diff --git a/packages/a2a-server/src/persistence/gcs.ts b/packages/a2a-server/src/persistence/gcs.ts index d42ae02270..6ee9ddee23 100644 --- a/packages/a2a-server/src/persistence/gcs.ts +++ b/packages/a2a-server/src/persistence/gcs.ts @@ -9,7 +9,7 @@ import { gzipSync, gunzipSync } from 'node:zlib'; import * as tar from 'tar'; import * as fse from 'fs-extra'; import { promises as fsPromises, createReadStream } from 'node:fs'; -import { tmpdir } from 'node:os'; +import { tmpdir } from '@google/gemini-cli-core'; import { join } from 'node:path'; import type { Task as SDKTask } from '@a2a-js/sdk'; import type { TaskStore } from '@a2a-js/sdk/server'; diff --git a/packages/cli/src/config/extension-manager-skills.test.ts b/packages/cli/src/config/extension-manager-skills.test.ts index 495336e4a8..b0db1a0258 100644 --- a/packages/cli/src/config/extension-manager-skills.test.ts +++ b/packages/cli/src/config/extension-manager-skills.test.ts @@ -24,6 +24,15 @@ vi.mock('os', async (importOriginal) => { }; }); +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + homedir: mockHomedir, + }; +}); + describe('ExtensionManager skills validation', () => { let tempHomeDir: string; let tempWorkspaceDir: string; diff --git a/packages/cli/src/config/extension-manager.ts b/packages/cli/src/config/extension-manager.ts index 4fc9aa6258..d5fe2ad2b1 100644 --- a/packages/cli/src/config/extension-manager.ts +++ b/packages/cli/src/config/extension-manager.ts @@ -6,7 +6,6 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import * as os from 'node:os'; import { stat } from 'node:fs/promises'; import chalk from 'chalk'; import { ExtensionEnablementManager } from './extensions/extensionEnablement.js'; @@ -39,6 +38,7 @@ import { logExtensionUninstall, logExtensionUpdateEvent, loadSkillsFromDir, + homedir, type ExtensionEvents, type MCPServerConfig, type ExtensionInstallMetadata, @@ -692,7 +692,7 @@ Would you like to attempt to install via "git clone" instead?`, toOutputString(extension: GeminiCLIExtension): string { const userEnabled = this.extensionEnablementManager.isEnabled( extension.name, - os.homedir(), + homedir(), ); const workspaceEnabled = this.extensionEnablementManager.isEnabled( extension.name, @@ -766,7 +766,7 @@ Would you like to attempt to install via "git clone" instead?`, if (scope !== SettingScope.Session) { const scopePath = - scope === SettingScope.Workspace ? this.workspaceDir : os.homedir(); + scope === SettingScope.Workspace ? this.workspaceDir : homedir(); this.extensionEnablementManager.disable(name, true, scopePath); } await logExtensionDisable( @@ -801,7 +801,7 @@ Would you like to attempt to install via "git clone" instead?`, if (scope !== SettingScope.Session) { const scopePath = - scope === SettingScope.Workspace ? this.workspaceDir : os.homedir(); + scope === SettingScope.Workspace ? this.workspaceDir : homedir(); this.extensionEnablementManager.enable(name, true, scopePath); } await logExtensionEnable( diff --git a/packages/cli/src/config/extension.test.ts b/packages/cli/src/config/extension.test.ts index 9a60b96e40..0bfa7a0358 100644 --- a/packages/cli/src/config/extension.test.ts +++ b/packages/cli/src/config/extension.test.ts @@ -105,6 +105,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { logExtensionUninstall: mockLogExtensionUninstall, logExtensionUpdateEvent: mockLogExtensionUpdateEvent, logExtensionDisable: mockLogExtensionDisable, + homedir: mockHomedir, ExtensionEnableEvent: vi.fn(), ExtensionInstallEvent: vi.fn(), ExtensionUninstallEvent: vi.fn(), diff --git a/packages/cli/src/config/extensions/storage.ts b/packages/cli/src/config/extensions/storage.ts index 8682e578f6..c1cb147e24 100644 --- a/packages/cli/src/config/extensions/storage.ts +++ b/packages/cli/src/config/extensions/storage.ts @@ -11,7 +11,7 @@ import { EXTENSION_SETTINGS_FILENAME, EXTENSIONS_CONFIG_FILENAME, } from './variables.js'; -import { Storage } from '@google/gemini-cli-core'; +import { Storage, homedir } from '@google/gemini-cli-core'; export class ExtensionStorage { private readonly extensionName: string; @@ -36,7 +36,7 @@ export class ExtensionStorage { } static getUserExtensionsDir(): string { - return new Storage(os.homedir()).getExtensionsDir(); + return new Storage(homedir()).getExtensionsDir(); } static async createTmpDir(): Promise { diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 5cba3dd637..1389430f29 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -6,7 +6,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { homedir, platform } from 'node:os'; +import { platform } from 'node:os'; import * as dotenv from 'dotenv'; import process from 'node:process'; import { @@ -16,6 +16,7 @@ import { getErrorMessage, Storage, coreEvents, + homedir, } from '@google/gemini-cli-core'; import stripJsonComments from 'strip-json-comments'; import { DefaultLight } from '../ui/themes/default-light.js'; diff --git a/packages/cli/src/config/settings_validation_warning.test.ts b/packages/cli/src/config/settings_validation_warning.test.ts index 67212bf0bc..498f803dd9 100644 --- a/packages/cli/src/config/settings_validation_warning.test.ts +++ b/packages/cli/src/config/settings_validation_warning.test.ts @@ -27,6 +27,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { return { ...actual, coreEvents: mockCoreEvents, + homedir: () => '/mock/home/user', Storage: class extends actual.Storage { static override getGlobalSettingsPath = () => '/mock/home/user/.gemini/settings.json'; @@ -52,11 +53,15 @@ vi.mock('./trustedFolders.js', () => ({ }, })); -vi.mock('os', () => ({ - homedir: () => '/mock/home/user', - platform: () => 'linux', - totalmem: () => 16 * 1024 * 1024 * 1024, -})); +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(); diff --git a/packages/cli/src/config/trustedFolders.test.ts b/packages/cli/src/config/trustedFolders.test.ts index 436e300957..9bd4cef9f6 100644 --- a/packages/cli/src/config/trustedFolders.test.ts +++ b/packages/cli/src/config/trustedFolders.test.ts @@ -36,6 +36,15 @@ vi.mock('os', async (importOriginal) => { platform: vi.fn(() => 'linux'), }; }); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + homedir: () => '/mock/home/user', + }; +}); vi.mock('fs', async (importOriginal) => { const actualFs = await importOriginal(); return { diff --git a/packages/cli/src/config/trustedFolders.ts b/packages/cli/src/config/trustedFolders.ts index 9a894c76cb..3057a7d3ec 100644 --- a/packages/cli/src/config/trustedFolders.ts +++ b/packages/cli/src/config/trustedFolders.ts @@ -6,13 +6,13 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { homedir } from 'node:os'; import { FatalConfigError, getErrorMessage, isWithinRoot, ideContextStore, GEMINI_DIR, + homedir, } from '@google/gemini-cli-core'; import type { Settings } from './settings.js'; import stripJsonComments from 'strip-json-comments'; diff --git a/packages/cli/src/ui/components/Notifications.test.tsx b/packages/cli/src/ui/components/Notifications.test.tsx index 6e0c178e86..0e04799cba 100644 --- a/packages/cli/src/ui/components/Notifications.test.tsx +++ b/packages/cli/src/ui/components/Notifications.test.tsx @@ -48,6 +48,7 @@ vi.mock('node:path', async () => { vi.mock('@google/gemini-cli-core', () => ({ GEMINI_DIR: '.gemini', + homedir: () => '/mock/home', Storage: { getGlobalTempDir: () => '/mock/temp', }, diff --git a/packages/cli/src/ui/components/Notifications.tsx b/packages/cli/src/ui/components/Notifications.tsx index f36e9708fc..460d03f88b 100644 --- a/packages/cli/src/ui/components/Notifications.tsx +++ b/packages/cli/src/ui/components/Notifications.tsx @@ -12,13 +12,17 @@ import { theme } from '../semantic-colors.js'; import { StreamingState } from '../types.js'; import { UpdateNotification } from './UpdateNotification.js'; -import { GEMINI_DIR, Storage, debugLogger } from '@google/gemini-cli-core'; +import { + GEMINI_DIR, + Storage, + debugLogger, + homedir, +} from '@google/gemini-cli-core'; import * as fs from 'node:fs/promises'; -import os from 'node:os'; import path from 'node:path'; -const settingsPath = path.join(os.homedir(), GEMINI_DIR, 'settings.json'); +const settingsPath = path.join(homedir(), GEMINI_DIR, 'settings.json'); const screenReaderNudgeFilePath = path.join( Storage.getGlobalTempDir(), diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx b/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx index 476a414717..d9831952b4 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx @@ -74,6 +74,7 @@ vi.mock('node:process', () => { exit: mockProcessExit, platform: 'sunos', cwd: () => '/fake/dir', + env: {}, } as unknown as NodeJS.Process; return { ...mockProcess, diff --git a/packages/cli/src/ui/hooks/useExtensionUpdates.test.tsx b/packages/cli/src/ui/hooks/useExtensionUpdates.test.tsx index ae19a1fc55..5e78f4c4d6 100644 --- a/packages/cli/src/ui/hooks/useExtensionUpdates.test.tsx +++ b/packages/cli/src/ui/hooks/useExtensionUpdates.test.tsx @@ -30,6 +30,15 @@ vi.mock('os', async (importOriginal) => { }; }); +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + homedir: () => os.homedir(), + }; +}); + vi.mock('../../config/extensions/update.js', () => ({ checkForAllExtensionUpdates: vi.fn(), updateExtension: vi.fn(), diff --git a/packages/cli/src/ui/hooks/usePermissionsModifyTrust.test.ts b/packages/cli/src/ui/hooks/usePermissionsModifyTrust.test.ts index cc69d14eca..84e00cae15 100644 --- a/packages/cli/src/ui/hooks/usePermissionsModifyTrust.test.ts +++ b/packages/cli/src/ui/hooks/usePermissionsModifyTrust.test.ts @@ -28,9 +28,16 @@ const mockedIsWorkspaceTrusted = vi.hoisted(() => vi.fn()); const mockedUseSettings = vi.hoisted(() => vi.fn()); // Mock modules -vi.mock('node:process', () => ({ - cwd: mockedCwd, -})); +vi.mock('node:process', () => { + const mockProcess = { + cwd: mockedCwd, + env: {}, + }; + return { + ...mockProcess, + default: mockProcess, + }; +}); vi.mock('node:path', async (importOriginal) => { const actual = await importOriginal(); diff --git a/packages/cli/src/ui/themes/theme-manager.test.ts b/packages/cli/src/ui/themes/theme-manager.test.ts index 60d69ef7f9..02ef4ff633 100644 --- a/packages/cli/src/ui/themes/theme-manager.test.ts +++ b/packages/cli/src/ui/themes/theme-manager.test.ts @@ -27,6 +27,15 @@ vi.mock('node:os', async (importOriginal) => { }; }); +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + homedir: () => os.homedir(), + }; +}); + const validCustomTheme: CustomTheme = { type: 'custom', name: 'MyCustomTheme', diff --git a/packages/cli/src/ui/themes/theme-manager.ts b/packages/cli/src/ui/themes/theme-manager.ts index 9a1e6af7d4..ef67f7fc25 100644 --- a/packages/cli/src/ui/themes/theme-manager.ts +++ b/packages/cli/src/ui/themes/theme-manager.ts @@ -18,7 +18,6 @@ import { ShadesOfPurple } from './shades-of-purple.js'; import { XCode } from './xcode.js'; import * as fs from 'node:fs'; import * as path from 'node:path'; -import * as os from 'node:os'; import type { Theme, ThemeType, CustomTheme } from './theme.js'; import { createCustomTheme, validateCustomTheme } from './theme.js'; import type { SemanticColors } from './semantic-tokens.js'; @@ -26,7 +25,7 @@ import { ANSI } from './ansi.js'; import { ANSILight } from './ansi-light.js'; import { NoColorTheme } from './no-color.js'; import process from 'node:process'; -import { debugLogger } from '@google/gemini-cli-core'; +import { debugLogger, homedir } from '@google/gemini-cli-core'; export interface ThemeDisplay { name: string; @@ -255,7 +254,7 @@ class ThemeManager { } // 2. Perform security check. - const homeDir = path.resolve(os.homedir()); + const homeDir = path.resolve(homedir()); if (!canonicalPath.startsWith(homeDir)) { debugLogger.warn( `Theme file at "${themePath}" is outside your home directory. ` + diff --git a/packages/cli/src/ui/utils/directoryUtils.test.ts b/packages/cli/src/ui/utils/directoryUtils.test.ts index b001ece22c..eaf50005d0 100644 --- a/packages/cli/src/ui/utils/directoryUtils.test.ts +++ b/packages/cli/src/ui/utils/directoryUtils.test.ts @@ -16,6 +16,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { await importOriginal(); return { ...original, + homedir: () => mockHomeDir, loadServerHierarchicalMemory: vi.fn().mockResolvedValue({ memoryContent: 'mock memory', fileCount: 10, diff --git a/packages/cli/src/ui/utils/directoryUtils.ts b/packages/cli/src/ui/utils/directoryUtils.ts index 084052525b..e293243989 100644 --- a/packages/cli/src/ui/utils/directoryUtils.ts +++ b/packages/cli/src/ui/utils/directoryUtils.ts @@ -4,10 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import * as os from 'node:os'; import * as path from 'node:path'; import * as fs from 'node:fs'; import { opendir } from 'node:fs/promises'; +import { homedir } from '@google/gemini-cli-core'; const MAX_SUGGESTIONS = 50; const MATCH_BUFFER_MULTIPLIER = 3; @@ -18,9 +18,9 @@ export function expandHomeDir(p: string): string { } let expandedPath = p; if (p.toLowerCase().startsWith('%userprofile%')) { - expandedPath = os.homedir() + p.substring('%userprofile%'.length); + expandedPath = homedir() + p.substring('%userprofile%'.length); } else if (p === '~' || p.startsWith('~/')) { - expandedPath = os.homedir() + p.substring(1); + expandedPath = homedir() + p.substring(1); } return path.normalize(expandedPath); } @@ -56,7 +56,7 @@ function parsePartialPath(partialPath: string): ParsedPath { !partialPath.includes('/') && !partialPath.includes(path.sep) ) { - searchDir = os.homedir(); + searchDir = homedir(); filter = partialPath.substring(1); } } diff --git a/packages/cli/src/ui/utils/terminalSetup.test.ts b/packages/cli/src/ui/utils/terminalSetup.test.ts index 6a4c3d85ec..a6f7b290a7 100644 --- a/packages/cli/src/ui/utils/terminalSetup.test.ts +++ b/packages/cli/src/ui/utils/terminalSetup.test.ts @@ -42,6 +42,15 @@ vi.mock('node:os', () => ({ platform: mocks.platform, })); +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + homedir: mocks.homedir, + }; +}); + vi.mock('./terminalCapabilityManager.js', () => ({ terminalCapabilityManager: { isKittyProtocolEnabled: vi.fn().mockReturnValue(false), diff --git a/packages/cli/src/ui/utils/terminalSetup.ts b/packages/cli/src/ui/utils/terminalSetup.ts index a66b6a19f4..9fb81099bb 100644 --- a/packages/cli/src/ui/utils/terminalSetup.ts +++ b/packages/cli/src/ui/utils/terminalSetup.ts @@ -30,7 +30,7 @@ import { exec } from 'node:child_process'; import { promisify } from 'node:util'; import { terminalCapabilityManager } from './terminalCapabilityManager.js'; -import { debugLogger } from '@google/gemini-cli-core'; +import { debugLogger, homedir } from '@google/gemini-cli-core'; export const VSCODE_SHIFT_ENTER_SEQUENCE = '\\\r\n'; @@ -124,7 +124,7 @@ function getVSCodeStyleConfigDir(appName: string): string | null { if (platform === 'darwin') { return path.join( - os.homedir(), + homedir(), 'Library', 'Application Support', appName, @@ -136,7 +136,7 @@ function getVSCodeStyleConfigDir(appName: string): string | null { } return path.join(process.env['APPDATA'], appName, 'User'); } else { - return path.join(os.homedir(), '.config', appName, 'User'); + return path.join(homedir(), '.config', appName, 'User'); } } diff --git a/packages/cli/src/utils/resolvePath.test.ts b/packages/cli/src/utils/resolvePath.test.ts index 9f4b8d0b24..949ccea59a 100644 --- a/packages/cli/src/utils/resolvePath.test.ts +++ b/packages/cli/src/utils/resolvePath.test.ts @@ -13,6 +13,10 @@ vi.mock('node:os', () => ({ homedir: vi.fn(), })); +vi.mock('@google/gemini-cli-core', () => ({ + homedir: () => os.homedir(), +})); + describe('resolvePath', () => { beforeEach(() => { vi.mocked(os.homedir).mockReturnValue('/home/user'); diff --git a/packages/cli/src/utils/resolvePath.ts b/packages/cli/src/utils/resolvePath.ts index dea8be55ae..14d5f77cbb 100644 --- a/packages/cli/src/utils/resolvePath.ts +++ b/packages/cli/src/utils/resolvePath.ts @@ -4,8 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import * as os from 'node:os'; import * as path from 'node:path'; +import { homedir } from '@google/gemini-cli-core'; export function resolvePath(p: string): string { if (!p) { @@ -13,9 +13,9 @@ export function resolvePath(p: string): string { } let expandedPath = p; if (p.toLowerCase().startsWith('%userprofile%')) { - expandedPath = os.homedir() + p.substring('%userprofile%'.length); + expandedPath = homedir() + p.substring('%userprofile%'.length); } else if (p === '~' || p.startsWith('~/')) { - expandedPath = os.homedir() + p.substring(1); + expandedPath = homedir() + p.substring(1); } return path.normalize(expandedPath); } diff --git a/packages/cli/src/utils/sandbox.test.ts b/packages/cli/src/utils/sandbox.test.ts index ed04ee80e5..9f59ca008c 100644 --- a/packages/cli/src/utils/sandbox.test.ts +++ b/packages/cli/src/utils/sandbox.test.ts @@ -12,9 +12,19 @@ import { start_sandbox } from './sandbox.js'; import { FatalSandboxError, type SandboxConfig } from '@google/gemini-cli-core'; import { EventEmitter } from 'node:events'; -vi.mock('../config/settings.js', () => ({ - USER_SETTINGS_DIR: '/home/user/.gemini', +const { mockedHomedir, mockedGetContainerPath } = vi.hoisted(() => ({ + mockedHomedir: vi.fn().mockReturnValue('/home/user'), + mockedGetContainerPath: vi.fn().mockImplementation((p: string) => p), })); + +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'); @@ -44,6 +54,7 @@ vi.mock('node:util', async (importOriginal) => { }, }; }); + vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); @@ -64,7 +75,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { } }, GEMINI_DIR: '.gemini', - USER_SETTINGS_DIR: '/home/user/.gemini', + homedir: mockedHomedir, }; }); @@ -341,13 +352,23 @@ describe('sandbox', () => { await start_sandbox(config); - expect(spawn).toHaveBeenCalledWith( + // 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', '--volume', '/host/path:/container/path:ro', '--volume', - expect.stringContaining('/home/user/.gemini'), + expect.stringMatching(/[\\/]home[\\/]user[\\/]\.gemini/), ]), expect.any(Object), ); diff --git a/packages/cli/src/utils/sandbox.ts b/packages/cli/src/utils/sandbox.ts index c05f2cf3a4..2edadae2ad 100644 --- a/packages/cli/src/utils/sandbox.ts +++ b/packages/cli/src/utils/sandbox.ts @@ -5,12 +5,11 @@ */ import { exec, execSync, spawn, type ChildProcess } from 'node:child_process'; -import os from 'node:os'; import path from 'node:path'; import fs from 'node:fs'; +import os from 'node:os'; import { fileURLToPath } from 'node:url'; import { quote, parse } from 'shell-quote'; -import { USER_SETTINGS_DIR } from '../config/settings.js'; import { promisify } from 'node:util'; import type { Config, SandboxConfig } from '@google/gemini-cli-core'; import { @@ -18,6 +17,7 @@ import { debugLogger, FatalSandboxError, GEMINI_DIR, + homedir, } from '@google/gemini-cli-core'; import { ConsolePatcher } from '../ui/utils/ConsolePatcher.js'; import { randomBytes } from 'node:crypto'; @@ -82,7 +82,7 @@ export async function start_sandbox( '-D', `TMP_DIR=${fs.realpathSync(os.tmpdir())}`, '-D', - `HOME_DIR=${fs.realpathSync(os.homedir())}`, + `HOME_DIR=${fs.realpathSync(homedir())}`, '-D', `CACHE_DIR=${fs.realpathSync((await execAsync('getconf DARWIN_USER_CACHE_DIR')).stdout.trim())}`, ]; @@ -288,18 +288,23 @@ export async function start_sandbox( // mount user settings directory inside container, after creating if missing // note user/home changes inside sandbox and we mount at BOTH paths for consistency - const userSettingsDirOnHost = USER_SETTINGS_DIR; + const userHomeDirOnHost = homedir(); const userSettingsDirInSandbox = getContainerPath( `/home/node/${GEMINI_DIR}`, ); - if (!fs.existsSync(userSettingsDirOnHost)) { - fs.mkdirSync(userSettingsDirOnHost); + if (!fs.existsSync(userHomeDirOnHost)) { + fs.mkdirSync(userHomeDirOnHost, { recursive: true }); } + const userSettingsDirOnHost = path.join(userHomeDirOnHost, GEMINI_DIR); + if (!fs.existsSync(userSettingsDirOnHost)) { + fs.mkdirSync(userSettingsDirOnHost, { recursive: true }); + } + args.push( '--volume', `${userSettingsDirOnHost}:${userSettingsDirInSandbox}`, ); - if (userSettingsDirInSandbox !== userSettingsDirOnHost) { + if (userSettingsDirInSandbox !== getContainerPath(userSettingsDirOnHost)) { args.push( '--volume', `${userSettingsDirOnHost}:${getContainerPath(userSettingsDirOnHost)}`, @@ -309,8 +314,16 @@ export async function start_sandbox( // mount os.tmpdir() as os.tmpdir() inside container args.push('--volume', `${os.tmpdir()}:${getContainerPath(os.tmpdir())}`); + // mount homedir() as homedir() inside container + if (userHomeDirOnHost !== os.homedir()) { + args.push( + '--volume', + `${userHomeDirOnHost}:${getContainerPath(userHomeDirOnHost)}`, + ); + } + // mount gcloud config directory if it exists - const gcloudConfigDir = path.join(os.homedir(), '.config', 'gcloud'); + const gcloudConfigDir = path.join(homedir(), '.config', 'gcloud'); if (fs.existsSync(gcloudConfigDir)) { args.push( '--volume', @@ -585,7 +598,7 @@ export async function start_sandbox( // necessary on Linux to ensure the user exists within the // container's /etc/passwd file, which is required by os.userInfo(). const username = 'gemini'; - const homeDir = getContainerPath(os.homedir()); + const homeDir = getContainerPath(homedir()); const setupUserCommands = [ // Use -f with groupadd to avoid errors if the group already exists. @@ -606,7 +619,7 @@ export async function start_sandbox( // We still need userFlag for the simpler proxy container, which does not have this issue. userFlag = `--user ${uid}:${gid}`; // When forcing a UID in the sandbox, $HOME can be reset to '/', so we copy $HOME as well. - args.push('--env', `HOME=${os.homedir()}`); + args.push('--env', `HOME=${homedir()}`); } // push container image name diff --git a/packages/cli/src/utils/userStartupWarnings.test.ts b/packages/cli/src/utils/userStartupWarnings.test.ts index 5f87f286ce..0a9f957617 100644 --- a/packages/cli/src/utils/userStartupWarnings.test.ts +++ b/packages/cli/src/utils/userStartupWarnings.test.ts @@ -19,6 +19,15 @@ vi.mock('os', async (importOriginal) => { }; }); +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + homedir: () => os.homedir(), + }; +}); + describe('getUserStartupWarnings', () => { let testRootDir: string; let homeDir: string; diff --git a/packages/cli/src/utils/userStartupWarnings.ts b/packages/cli/src/utils/userStartupWarnings.ts index d355290789..37a5dd49cd 100644 --- a/packages/cli/src/utils/userStartupWarnings.ts +++ b/packages/cli/src/utils/userStartupWarnings.ts @@ -5,8 +5,9 @@ */ import fs from 'node:fs/promises'; -import * as os from 'node:os'; import path from 'node:path'; +import process from 'node:process'; +import { homedir } from '@google/gemini-cli-core'; type WarningCheck = { id: string; @@ -20,7 +21,7 @@ const homeDirectoryCheck: WarningCheck = { try { const [workspaceRealPath, homeRealPath] = await Promise.all([ fs.realpath(workspaceRoot), - fs.realpath(os.homedir()), + fs.realpath(homedir()), ]); if (workspaceRealPath === homeRealPath) { diff --git a/packages/core/src/code_assist/oauth-credential-storage.ts b/packages/core/src/code_assist/oauth-credential-storage.ts index 2f1c78d1df..149f53b97f 100644 --- a/packages/core/src/code_assist/oauth-credential-storage.ts +++ b/packages/core/src/code_assist/oauth-credential-storage.ts @@ -9,9 +9,8 @@ import { HybridTokenStorage } from '../mcp/token-storage/hybrid-token-storage.js import { OAUTH_FILE } from '../config/storage.js'; import type { OAuthCredentials } from '../mcp/token-storage/types.js'; import * as path from 'node:path'; -import * as os from 'node:os'; import { promises as fs } from 'node:fs'; -import { GEMINI_DIR } from '../utils/paths.js'; +import { GEMINI_DIR, homedir } from '../utils/paths.js'; import { coreEvents } from '../utils/events.js'; const KEYCHAIN_SERVICE_NAME = 'gemini-cli-oauth'; @@ -91,7 +90,7 @@ export class OAuthCredentialStorage { await this.storage.deleteCredentials(MAIN_ACCOUNT_KEY); // Also try to remove the old file if it exists - const oldFilePath = path.join(os.homedir(), GEMINI_DIR, OAUTH_FILE); + const oldFilePath = path.join(homedir(), GEMINI_DIR, OAUTH_FILE); await fs.rm(oldFilePath, { force: true }).catch(() => {}); } catch (error: unknown) { coreEvents.emitFeedback( @@ -107,7 +106,7 @@ export class OAuthCredentialStorage { * Migrate credentials from old file-based storage to keychain */ private static async migrateFromFileStorage(): Promise { - const oldFilePath = path.join(os.homedir(), GEMINI_DIR, OAUTH_FILE); + const oldFilePath = path.join(homedir(), GEMINI_DIR, OAUTH_FILE); let credsJson: string; try { diff --git a/packages/core/src/code_assist/oauth2.test.ts b/packages/core/src/code_assist/oauth2.test.ts index 50a7f07a67..0da2106db5 100644 --- a/packages/core/src/code_assist/oauth2.test.ts +++ b/packages/core/src/code_assist/oauth2.test.ts @@ -26,16 +26,24 @@ import { AuthType } from '../core/contentGenerator.js'; import type { Config } from '../config/config.js'; import readline from 'node:readline'; import { FORCE_ENCRYPTED_FILE_ENV_VAR } from '../mcp/token-storage/index.js'; -import { GEMINI_DIR } from '../utils/paths.js'; +import { GEMINI_DIR, homedir as pathsHomedir } from '../utils/paths.js'; import { debugLogger } from '../utils/debugLogger.js'; import { writeToStdout } from '../utils/stdio.js'; import { FatalCancellationError } from '../utils/errors.js'; import process from 'node:process'; -vi.mock('os', async (importOriginal) => { - const os = await importOriginal(); +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal(); return { - ...os, + ...actual, + homedir: vi.fn(), + }; +}); + +vi.mock('../utils/paths.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, homedir: vi.fn(), }; }); @@ -89,6 +97,7 @@ describe('oauth2', () => { path.join(os.tmpdir(), 'gemini-cli-test-home-'), ); vi.mocked(os.homedir).mockReturnValue(tempHomeDir); + vi.mocked(pathsHomedir).mockReturnValue(tempHomeDir); }); afterEach(() => { fs.rmSync(tempHomeDir, { recursive: true, force: true }); @@ -1129,15 +1138,10 @@ describe('oauth2', () => { () => mockHttpServer as unknown as http.Server, ); - // Mock process.on to immediately trigger SIGINT + // Mock process.on to capture SIGINT handler const processOnSpy = vi .spyOn(process, 'on') - .mockImplementation((event, listener: () => void) => { - if (event === 'SIGINT') { - listener(); - } - return process; - }); + .mockImplementation(() => process); const processRemoveListenerSpy = vi.spyOn(process, 'removeListener'); @@ -1146,6 +1150,24 @@ describe('oauth2', () => { mockConfig, ); + // Wait for the SIGINT handler to be registered + let sigIntHandler: (() => void) | undefined; + await vi.waitFor(() => { + const sigintCall = processOnSpy.mock.calls.find( + (call) => call[0] === 'SIGINT', + ); + sigIntHandler = sigintCall?.[1] as (() => void) | undefined; + if (!sigIntHandler) + throw new Error('SIGINT handler not registered yet'); + }); + + expect(sigIntHandler).toBeDefined(); + + // Trigger SIGINT + if (sigIntHandler) { + sigIntHandler(); + } + await expect(clientPromise).rejects.toThrow(FatalCancellationError); expect(processRemoveListenerSpy).toHaveBeenCalledWith( 'SIGINT', @@ -1180,17 +1202,10 @@ describe('oauth2', () => { () => mockHttpServer as unknown as http.Server, ); - // Spy on process.stdin.on and immediately trigger Ctrl+C + // Spy on process.stdin.on to capture data handler const stdinOnSpy = vi .spyOn(process.stdin, 'on') - .mockImplementation( - (event: string, listener: (data: Buffer) => void) => { - if (event === 'data') { - listener(Buffer.from([0x03])); - } - return process.stdin; - }, - ); + .mockImplementation(() => process.stdin); const stdinRemoveListenerSpy = vi.spyOn( process.stdin, @@ -1202,6 +1217,23 @@ describe('oauth2', () => { mockConfig, ); + // Wait for the stdin handler to be registered + let dataHandler: ((data: Buffer) => void) | undefined; + await vi.waitFor(() => { + const dataCall = stdinOnSpy.mock.calls.find( + (call: [string, ...unknown[]]) => call[0] === 'data', + ); + dataHandler = dataCall?.[1] as ((data: Buffer) => void) | undefined; + if (!dataHandler) throw new Error('stdin handler not registered yet'); + }); + + expect(dataHandler).toBeDefined(); + + // Trigger Ctrl+C + if (dataHandler) { + dataHandler(Buffer.from([0x03])); + } + await expect(clientPromise).rejects.toThrow(FatalCancellationError); expect(stdinRemoveListenerSpy).toHaveBeenCalledWith( 'data', @@ -1302,7 +1334,8 @@ describe('oauth2', () => { tempHomeDir = fs.mkdtempSync( path.join(os.tmpdir(), 'gemini-cli-test-home-'), ); - (os.homedir as Mock).mockReturnValue(tempHomeDir); + vi.mocked(os.homedir).mockReturnValue(tempHomeDir); + vi.mocked(pathsHomedir).mockReturnValue(tempHomeDir); }); afterEach(() => { diff --git a/packages/core/src/code_assist/oauth2.ts b/packages/core/src/code_assist/oauth2.ts index 406e054f1e..9b4d2cf079 100644 --- a/packages/core/src/code_assist/oauth2.ts +++ b/packages/core/src/code_assist/oauth2.ts @@ -336,7 +336,7 @@ async function initOauthClient( // Note that SIGINT might not get raised on Ctrl+C in raw mode // so we also need to look for Ctrl+C directly in stdin. - stdinHandler = (data) => { + stdinHandler = (data: Buffer) => { if (data.includes(0x03)) { reject( new FatalCancellationError('Authentication cancelled by user.'), diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index 7da4aa2a56..bfadc2f7b7 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -8,7 +8,7 @@ import * as path from 'node:path'; import * as os from 'node:os'; import * as crypto from 'node:crypto'; import * as fs from 'node:fs'; -import { GEMINI_DIR } from '../utils/paths.js'; +import { GEMINI_DIR, homedir } from '../utils/paths.js'; export const GOOGLE_ACCOUNTS_FILENAME = 'google_accounts.json'; export const OAUTH_FILE = 'oauth_creds.json'; @@ -23,7 +23,7 @@ export class Storage { } static getGlobalGeminiDir(): string { - const homeDir = os.homedir(); + const homeDir = homedir(); if (!homeDir) { return path.join(os.tmpdir(), GEMINI_DIR); } diff --git a/packages/core/src/core/prompts.ts b/packages/core/src/core/prompts.ts index 6327cd4753..243582494f 100644 --- a/packages/core/src/core/prompts.ts +++ b/packages/core/src/core/prompts.ts @@ -6,7 +6,6 @@ import path from 'node:path'; import fs from 'node:fs'; -import os from 'node:os'; import { EDIT_TOOL_NAME, GLOB_TOOL_NAME, @@ -23,7 +22,7 @@ import process from 'node:process'; import { isGitRepository } from '../utils/gitUtils.js'; import { CodebaseInvestigatorAgent } from '../agents/codebase-investigator.js'; import type { Config } from '../config/config.js'; -import { GEMINI_DIR } from '../utils/paths.js'; +import { GEMINI_DIR, homedir } from '../utils/paths.js'; import { debugLogger } from '../utils/debugLogger.js'; import { WriteTodosTool } from '../tools/write-todos.js'; import { resolveModel, isPreviewModel } from '../config/models.js'; @@ -53,7 +52,7 @@ export function resolvePathFromEnv(envVar?: string): { // Safely expand the tilde (~) character to the user's home directory. if (customPath.startsWith('~/') || customPath === '~') { try { - const home = os.homedir(); // This is the call that can throw an error. + const home = homedir(); // This is the call that can throw an error. if (customPath === '~') { customPath = home; } else { diff --git a/packages/core/src/ide/ide-installer.test.ts b/packages/core/src/ide/ide-installer.test.ts index 4ca07dd419..5f0ab9abb4 100644 --- a/packages/core/src/ide/ide-installer.test.ts +++ b/packages/core/src/ide/ide-installer.test.ts @@ -14,8 +14,15 @@ vi.mock('node:child_process', async (importOriginal) => { spawnSync: vi.fn(() => ({ status: 0 })), }; }); -vi.mock('fs'); -vi.mock('os'); +vi.mock('node:fs'); +vi.mock('node:os'); +vi.mock('../utils/paths.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + homedir: vi.fn(), + }; +}); import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { getIdeInstaller } from './ide-installer.js'; @@ -24,12 +31,14 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { IDE_DEFINITIONS, type IdeInfo } from './detect-ide.js'; +import { homedir as pathsHomedir } from '../utils/paths.js'; describe('ide-installer', () => { const HOME_DIR = '/home/user'; beforeEach(() => { vi.spyOn(os, 'homedir').mockReturnValue(HOME_DIR); + vi.mocked(pathsHomedir).mockReturnValue(HOME_DIR); }); afterEach(() => { diff --git a/packages/core/src/ide/ide-installer.ts b/packages/core/src/ide/ide-installer.ts index f1fd50fa4d..903e831268 100644 --- a/packages/core/src/ide/ide-installer.ts +++ b/packages/core/src/ide/ide-installer.ts @@ -8,9 +8,9 @@ import * as child_process from 'node:child_process'; import * as process from 'node:process'; import * as path from 'node:path'; import * as fs from 'node:fs'; -import * as os from 'node:os'; import { IDE_DEFINITIONS, type IdeInfo } from './detect-ide.js'; import { GEMINI_CLI_COMPANION_EXTENSION_NAME } from './constants.js'; +import { homedir } from '../utils/paths.js'; export interface IdeInstaller { install(): Promise; @@ -49,7 +49,7 @@ async function findCommand( // 2. Check common installation locations. const locations: string[] = []; - const homeDir = os.homedir(); + const homeDir = homedir(); if (command === 'code' || command === 'code.cmd') { if (platform === 'darwin') { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e20ed7f0a5..2f11c4ae71 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -50,6 +50,7 @@ export * from './code_assist/telemetry.js'; export * from './core/apiKeyCredentialStorage.js'; // Export utilities +export { homedir, tmpdir } from './utils/paths.js'; export * from './utils/paths.js'; export * from './utils/schemaValidator.js'; export * from './utils/errors.js'; diff --git a/packages/core/src/mcp/token-storage/file-token-storage.ts b/packages/core/src/mcp/token-storage/file-token-storage.ts index a9e17b60da..7a806de4a1 100644 --- a/packages/core/src/mcp/token-storage/file-token-storage.ts +++ b/packages/core/src/mcp/token-storage/file-token-storage.ts @@ -10,7 +10,7 @@ import * as os from 'node:os'; import * as crypto from 'node:crypto'; import { BaseTokenStorage } from './base-token-storage.js'; import type { OAuthCredentials } from './types.js'; -import { GEMINI_DIR } from '../../utils/paths.js'; +import { GEMINI_DIR, homedir } from '../../utils/paths.js'; export class FileTokenStorage extends BaseTokenStorage { private readonly tokenFilePath: string; @@ -18,7 +18,7 @@ export class FileTokenStorage extends BaseTokenStorage { constructor(serviceName: string) { super(serviceName); - const configDir = path.join(os.homedir(), GEMINI_DIR); + const configDir = path.join(homedir(), GEMINI_DIR); this.tokenFilePath = path.join(configDir, 'mcp-oauth-tokens-v2.json'); this.encryptionKey = this.deriveEncryptionKey(); } diff --git a/packages/core/src/services/gitService.test.ts b/packages/core/src/services/gitService.test.ts index d45dbed407..b3e3975265 100644 --- a/packages/core/src/services/gitService.test.ts +++ b/packages/core/src/services/gitService.test.ts @@ -18,7 +18,11 @@ import { Storage } from '../config/storage.js'; import * as path from 'node:path'; import * as fs from 'node:fs/promises'; import * as os from 'node:os'; -import { getProjectHash, GEMINI_DIR } from '../utils/paths.js'; +import { + getProjectHash, + GEMINI_DIR, + homedir as pathsHomedir, +} from '../utils/paths.js'; import { spawnAsync } from '../utils/shell-utils.js'; vi.mock('../utils/shell-utils.js', () => ({ @@ -52,7 +56,7 @@ vi.mock('../utils/gitUtils.js', () => ({ })); const hoistedMockHomedir = vi.hoisted(() => vi.fn()); -vi.mock('os', async (importOriginal) => { +vi.mock('node:os', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, @@ -60,6 +64,14 @@ vi.mock('os', async (importOriginal) => { }; }); +vi.mock('../utils/paths.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + homedir: vi.fn(), + }; +}); + const hoistedMockDebugLogger = vi.hoisted(() => ({ debug: vi.fn(), warn: vi.fn(), @@ -93,6 +105,7 @@ describe('GitService', () => { }); hoistedMockHomedir.mockReturnValue(homedir); + (pathsHomedir as Mock).mockReturnValue(homedir); hoistedMockEnv.mockImplementation(() => ({ checkIsRepo: hoistedMockCheckIsRepo, diff --git a/packages/core/src/utils/installationManager.test.ts b/packages/core/src/utils/installationManager.test.ts index 2b4c586cb4..1cc7f69926 100644 --- a/packages/core/src/utils/installationManager.test.ts +++ b/packages/core/src/utils/installationManager.test.ts @@ -11,7 +11,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import path from 'node:path'; import { randomUUID } from 'node:crypto'; -import { GEMINI_DIR } from './paths.js'; +import { GEMINI_DIR, homedir as pathsHomedir } from './paths.js'; import { debugLogger } from './debugLogger.js'; vi.mock('node:fs', async (importOriginal) => { @@ -23,22 +23,30 @@ vi.mock('node:fs', async (importOriginal) => { } as typeof actual; }); -vi.mock('os', async (importOriginal) => { - const os = await importOriginal(); +vi.mock('node:os', async (importOriginal) => { + const os = await importOriginal(); return { ...os, homedir: vi.fn(), }; }); -vi.mock('crypto', async (importOriginal) => { - const crypto = await importOriginal(); +vi.mock('node:crypto', async (importOriginal) => { + const crypto = await importOriginal(); return { ...crypto, randomUUID: vi.fn(), }; }); +vi.mock('./paths.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + homedir: vi.fn(), + }; +}); + describe('InstallationManager', () => { let tempHomeDir: string; let installationManager: InstallationManager; @@ -49,6 +57,7 @@ describe('InstallationManager', () => { tempHomeDir = fs.mkdtempSync( path.join(os.tmpdir(), 'gemini-cli-test-home-'), ); + (pathsHomedir as Mock).mockReturnValue(tempHomeDir); (os.homedir as Mock).mockReturnValue(tempHomeDir); installationManager = new InstallationManager(); }); diff --git a/packages/core/src/utils/memoryDiscovery.test.ts b/packages/core/src/utils/memoryDiscovery.test.ts index 83f19569db..101cf5ad85 100644 --- a/packages/core/src/utils/memoryDiscovery.test.ts +++ b/packages/core/src/utils/memoryDiscovery.test.ts @@ -35,6 +35,16 @@ vi.mock('os', async (importOriginal) => { }; }); +vi.mock('../utils/paths.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + homedir: vi.fn(), + }; +}); + +import { homedir as pathsHomedir } from './paths.js'; + describe('memoryDiscovery', () => { const DEFAULT_FOLDER_TRUST = true; let testRootDir: string; @@ -67,6 +77,7 @@ describe('memoryDiscovery', () => { cwd = await createEmptyDir(path.join(projectRoot, 'src')); homedir = await createEmptyDir(path.join(testRootDir, 'userhome')); vi.mocked(os.homedir).mockReturnValue(homedir); + vi.mocked(pathsHomedir).mockReturnValue(homedir); }); afterEach(async () => { diff --git a/packages/core/src/utils/memoryDiscovery.ts b/packages/core/src/utils/memoryDiscovery.ts index 88a6760fe0..4997f543a0 100644 --- a/packages/core/src/utils/memoryDiscovery.ts +++ b/packages/core/src/utils/memoryDiscovery.ts @@ -7,14 +7,13 @@ import * as fs from 'node:fs/promises'; import * as fsSync from 'node:fs'; import * as path from 'node:path'; -import { homedir } from 'node:os'; import { bfsFileSearch } from './bfsFileSearch.js'; import { getAllGeminiMdFilenames } from '../tools/memoryTool.js'; import type { FileDiscoveryService } from '../services/fileDiscoveryService.js'; import { processImports } from './memoryImportProcessor.js'; import type { FileFilteringOptions } from '../config/constants.js'; import { DEFAULT_MEMORY_FILE_FILTERING_OPTIONS } from '../config/constants.js'; -import { GEMINI_DIR } from './paths.js'; +import { GEMINI_DIR, homedir } from './paths.js'; import type { ExtensionLoader } from './extensionLoader.js'; import { debugLogger } from './debugLogger.js'; import type { Config } from '../config/config.js'; diff --git a/packages/core/src/utils/paths.ts b/packages/core/src/utils/paths.ts index c32a3ce9f3..4d14a6d230 100644 --- a/packages/core/src/utils/paths.ts +++ b/packages/core/src/utils/paths.ts @@ -6,6 +6,7 @@ import path from 'node:path'; import os from 'node:os'; +import process from 'node:process'; import * as crypto from 'node:crypto'; export const GEMINI_DIR = '.gemini'; @@ -18,13 +19,33 @@ export const GOOGLE_ACCOUNTS_FILENAME = 'google_accounts.json'; */ export const SHELL_SPECIAL_CHARS = /[ \t()[\]{};|*?$`'"#&<>!~]/; +/** + * Returns the home directory. + * If GEMINI_CLI_HOME environment variable is set, it returns its value. + * Otherwise, it returns the user's home directory. + */ +export function homedir(): string { + const envHome = process.env['GEMINI_CLI_HOME']; + if (envHome) { + return envHome; + } + return os.homedir(); +} + +/** + * Returns the operating system's default directory for temporary files. + */ +export function tmpdir(): string { + return os.tmpdir(); +} + /** * Replaces the home directory with a tilde. * @param path - The path to tildeify. * @returns The tildeified path. */ export function tildeifyPath(path: string): string { - const homeDir = os.homedir(); + const homeDir = homedir(); if (path.startsWith(homeDir)) { return path.replace(homeDir, '~'); } diff --git a/packages/core/src/utils/userAccountManager.test.ts b/packages/core/src/utils/userAccountManager.test.ts index 8b4cb61056..4e970c334f 100644 --- a/packages/core/src/utils/userAccountManager.test.ts +++ b/packages/core/src/utils/userAccountManager.test.ts @@ -10,13 +10,13 @@ import { UserAccountManager } from './userAccountManager.js'; import * as fs from 'node:fs'; import * as os from 'node:os'; import path from 'node:path'; -import { GEMINI_DIR } from './paths.js'; +import { GEMINI_DIR, homedir as pathsHomedir } from './paths.js'; import { debugLogger } from './debugLogger.js'; -vi.mock('os', async (importOriginal) => { - const os = await importOriginal(); +vi.mock('./paths.js', async (importOriginal) => { + const actual = await importOriginal(); return { - ...os, + ...actual, homedir: vi.fn(), }; }); @@ -30,7 +30,7 @@ describe('UserAccountManager', () => { tempHomeDir = fs.mkdtempSync( path.join(os.tmpdir(), 'gemini-cli-test-home-'), ); - (os.homedir as Mock).mockReturnValue(tempHomeDir); + (pathsHomedir as Mock).mockReturnValue(tempHomeDir); accountsFile = () => path.join(tempHomeDir, GEMINI_DIR, 'google_accounts.json'); userAccountManager = new UserAccountManager(); diff --git a/packages/vscode-ide-companion/esbuild.js b/packages/vscode-ide-companion/esbuild.js index 7de7c7ada0..468ba34825 100644 --- a/packages/vscode-ide-companion/esbuild.js +++ b/packages/vscode-ide-companion/esbuild.js @@ -53,7 +53,7 @@ async function main() { /* add to the end of plugins array */ esbuildProblemMatcherPlugin, ], - loader: { '.node': 'file' }, + loader: { '.node': 'file', '.wasm': 'binary' }, }); if (watch) { await ctx.watch(); diff --git a/packages/vscode-ide-companion/src/ide-server.test.ts b/packages/vscode-ide-companion/src/ide-server.test.ts index 95f1c27a63..eb28638a78 100644 --- a/packages/vscode-ide-companion/src/ide-server.test.ts +++ b/packages/vscode-ide-companion/src/ide-server.test.ts @@ -38,6 +38,15 @@ vi.mock('node:os', async (importOriginal) => { }; }); +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + tmpdir: vi.fn(() => '/tmp'), + }; +}); + const vscodeMock = vi.hoisted(() => ({ workspace: { workspaceFolders: [ diff --git a/packages/vscode-ide-companion/src/ide-server.ts b/packages/vscode-ide-companion/src/ide-server.ts index f29bae29fd..4e4ef443f6 100644 --- a/packages/vscode-ide-companion/src/ide-server.ts +++ b/packages/vscode-ide-companion/src/ide-server.ts @@ -23,7 +23,7 @@ import { randomUUID } from 'node:crypto'; import { type Server as HTTPServer } from 'node:http'; import * as path from 'node:path'; import * as fs from 'node:fs/promises'; -import * as os from 'node:os'; +import { tmpdir } from '@google/gemini-cli-core'; import type { z } from 'zod'; import type { DiffManager } from './diff-manager.js'; import { OpenFilesManager } from './open-files-manager.js'; @@ -343,7 +343,7 @@ export class IDEServer { this.log(`IDE server listening on http://127.0.0.1:${this.port}`); let portFile: string | undefined; try { - const portDir = path.join(os.tmpdir(), 'gemini', 'ide'); + const portDir = path.join(tmpdir(), 'gemini', 'ide'); await fs.mkdir(portDir, { recursive: true }); portFile = path.join( portDir, diff --git a/scripts/sandbox_command.js b/scripts/sandbox_command.js index 29fb8a3b17..00becb667f 100644 --- a/scripts/sandbox_command.js +++ b/scripts/sandbox_command.js @@ -33,10 +33,12 @@ const argv = yargs(hideBin(process.argv)).option('q', { default: false, }).argv; +const homedir = () => process.env['GEMINI_CLI_HOME'] || os.homedir(); + let geminiSandbox = process.env.GEMINI_SANDBOX; if (!geminiSandbox) { - const userSettingsFile = join(os.homedir(), GEMINI_DIR, 'settings.json'); + const userSettingsFile = join(homedir(), GEMINI_DIR, 'settings.json'); if (existsSync(userSettingsFile)) { const settings = JSON.parse( stripJsonComments(readFileSync(userSettingsFile, 'utf-8')), diff --git a/scripts/telemetry_utils.js b/scripts/telemetry_utils.js index 1c81b1eb1b..4ab776e964 100644 --- a/scripts/telemetry_utils.js +++ b/scripts/telemetry_utils.js @@ -24,8 +24,11 @@ const projectHash = crypto .update(projectRoot) .digest('hex'); +// Returns the home directory, respecting GEMINI_CLI_HOME +const homedir = () => process.env['GEMINI_CLI_HOME'] || os.homedir(); + // User-level .gemini directory in home -const USER_GEMINI_DIR = path.join(os.homedir(), GEMINI_DIR); +const USER_GEMINI_DIR = path.join(homedir(), GEMINI_DIR); // Project-level .gemini directory in the workspace const WORKSPACE_GEMINI_DIR = path.join(projectRoot, GEMINI_DIR);