Download packages/cli/src/config/settings.test.ts from SaylorTwift/gemini-cli: direct link, hf CLI and curl.
- Browser
- Download file 128 kB
-
https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/cli/src/config/settings.test.ts
- Command line
-
hf download hf://SaylorTwift/gemini-cli/packages/cli/src/config/settings.test.ts
-
curl -L -o settings.test.ts https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/cli/src/config/settings.test.ts
128 kB
| /** | |
| * @license | |
| * Copyright 2025 Google LLC | |
| * SPDX-License-Identifier: Apache-2.0 | |
| */ | |
| /// <reference types="vitest/globals" /> | |
| // 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<typeof osActual>(); | |
| 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<typeof import('./settings.js')>(); | |
| 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<typeof import('./settingsSchema.js')>(); | |
| 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<typeof fs>(); | |
| 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<typeof import('@google/gemini-cli-core')>(); | |
| 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<typeof fs.existsSync>; | |
| let mockStripJsonComments: Mocked<typeof stripJsonComments>; | |
| let mockFsMkdirSync: Mocked<typeof fs.mkdirSync>; | |
| 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<string, unknown>; | |
| 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<string, unknown>; | |
| 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<string, unknown>; | |
| 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<string, unknown>; | |
| 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<string, unknown>)[ | |
| '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<string, unknown>)[ | |
| '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<string, MCPServerConfig> = { | |
| '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<string, unknown>)[ | |
| 'test' | |
| ] as TestData; | |
| const originalValue = ( | |
| userSettings.originalSettings as Record<string, unknown> | |
| )['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<string, unknown>)['test-server']; | |
| // The settings in LoadedSettings should still have the server | |
| const userSettings = loadedSettings.forScope(SettingScope.User); | |
| expect( | |
| (userSettings.settings.mcpServers as Record<string, unknown>)[ | |
| 'test-server' | |
| ], | |
| ).toBeDefined(); | |
| expect( | |
| (userSettings.originalSettings.mcpServers as Record<string, unknown>)[ | |
| '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<string, unknown>)[ | |
| 'test' | |
| ] as { myMap: Map<string, string> }; | |
| // 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<string, unknown> = { 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/); | |
| }); | |
| }); | |
| }); | |